From e339b7af2c90bd5d7ad64872b2cf3095bdaf598f Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:51:42 +0000 Subject: [PATCH] =?UTF-8?q?Travel:=20reconstruct=20on=20a=20REAL=20map=20?= =?UTF-8?q?=E2=80=94=20swap=20the=20SVG=20picker=20for=20MapLibre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer's map was a static hand-drawn SVG. Replace it with an actual MapLibre map, so reconstructing a journey feels like a real map: pan/zoom, bundled offline land, tap a pin to add a stop in order, and watch the great-circle route draw live. - New RouteMap: a dedicated, self-contained MapLibre instance through the bundled offline MapSource seam (Natural Earth land, no tiles, no network, no geolocation prompt). It renders ONLY your pool of visited places as pins (added stops get a green ring); tapping picks the nearest and appends it. It's fully decoupled from the app's main MapView, so it can't perturb the production map. A companion list beneath the canvas (real buttons + flags) is the keyboard/AT path, and an aria-live region announces every add. - New pure helpers, each unit-tested: pickPoints (pool → pin FeatureCollection with route index), stopsArcs (great-circle arc per resolvable leg, factored out of visitedLayers.tripArcs which now delegates to it), mapFit.fitBounds (antimeridian-aware framing, extracted from MapScreen — no behaviour change). - Kept the List segment AND its gazetteer search, so any airport is still reachable (airports matter for flights). Gate: tsc clean, 459 unit tests, e2e green — trip-reconstruction, trips, tripedit, tripfilter, a new trip-routemap spec, and a11y (list + the real map both pass the axe WCAG 2.1 AA gate). --- apps/postcards/src/features/map/MapScreen.tsx | 27 +- apps/postcards/src/features/map/mapFit.ts | 35 +++ .../src/features/map/visitedLayers.ts | 39 ++- .../src/features/travel/MyPlacesPicker.tsx | 126 +--------- .../src/features/travel/RouteMap.tsx | 231 ++++++++++++++++++ .../src/features/travel/TripComposer.tsx | 2 + .../src/features/travel/landGeometry.ts | 32 +++ .../src/features/travel/pickPoints.ts | 34 +++ apps/postcards/src/lib/i18n/en.ts | 3 +- apps/postcards/src/lib/i18n/fr.ts | 3 +- apps/postcards/src/lib/i18n/ko.ts | 3 +- apps/postcards/src/styles.css | 22 ++ apps/postcards/tests/e2e/a11y.spec.ts | 4 +- .../postcards/tests/e2e/trip-routemap.spec.ts | 45 ++++ apps/postcards/tests/unit/routeMap.spec.ts | 84 +++++++ 15 files changed, 535 insertions(+), 155 deletions(-) create mode 100644 apps/postcards/src/features/map/mapFit.ts create mode 100644 apps/postcards/src/features/travel/RouteMap.tsx create mode 100644 apps/postcards/src/features/travel/landGeometry.ts create mode 100644 apps/postcards/src/features/travel/pickPoints.ts create mode 100644 apps/postcards/tests/e2e/trip-routemap.spec.ts create mode 100644 apps/postcards/tests/unit/routeMap.spec.ts diff --git a/apps/postcards/src/features/map/MapScreen.tsx b/apps/postcards/src/features/map/MapScreen.tsx index 6f2404f..a11b57b 100644 --- a/apps/postcards/src/features/map/MapScreen.tsx +++ b/apps/postcards/src/features/map/MapScreen.tsx @@ -16,6 +16,7 @@ import { GuideButton } from "../guides/GuideButton"; import { StatStrip } from "../stats/StatStrip"; import { MapView, hasSavedCamera, type Basemap, type MapFocus, type MapFit } from "./MapView"; import { tripArcs } from "./visitedLayers"; +import { fitBounds } from "./mapFit"; import { dateBuckets, mapDateMatches, rangeExactYear, type MapDate } from "../travel/period"; import { citiesInView, type Bounds } from "./viewport"; import { bundledMapSource } from "../../lib/map-source/bundledMapSource"; @@ -641,29 +642,9 @@ export function MapScreen({ active = true }: { active?: boolean } = {}) { }, [myPlaceCoords]); function fitToMyPlaces(instant = false) { - if (!myPlaceCoords.length) return; - let south = Infinity, north = -Infinity; - for (const c of myPlaceCoords) { - south = Math.min(south, c.lat); - north = Math.max(north, c.lat); - } - // Longitude needs antimeridian care (Fiji + Samoa must not frame the whole - // globe): the tightest frame is the complement of the LARGEST gap between - // consecutive sorted longitudes (wrapping counts as a gap too). - const lons = myPlaceCoords.map((c) => c.lon).sort((a, b) => a - b); - let gapAfter = lons.length - 1; - let gapSize = lons[0]! + 360 - lons[lons.length - 1]!; - for (let i = 1; i < lons.length; i++) { - const g = lons[i]! - lons[i - 1]!; - if (g > gapSize) { - gapSize = g; - gapAfter = i - 1; - } - } - const west = lons[(gapAfter + 1) % lons.length]!; - let east = lons[gapAfter]!; - if (east < west) east += 360; // the frame crosses the antimeridian - setFit((f) => ({ bounds: [[west, south], [east, north]], key: (f?.key ?? 0) + 1, instant })); + const bounds = fitBounds(myPlaceCoords); + if (!bounds) return; + setFit((f) => ({ bounds, key: (f?.key ?? 0) + 1, instant })); } return ( diff --git a/apps/postcards/src/features/map/mapFit.ts b/apps/postcards/src/features/map/mapFit.ts new file mode 100644 index 0000000..63c6a91 --- /dev/null +++ b/apps/postcards/src/features/map/mapFit.ts @@ -0,0 +1,35 @@ +// Antimeridian-aware framing shared by the main map and the trip composer's +// route map. The tightest longitude frame is the COMPLEMENT of the largest gap +// between consecutive sorted longitudes (wrapping counts as a gap), so Fiji + +// Samoa frame tight instead of spanning the whole globe. Pure & testable. + +export type LngLatBounds = [[number, number], [number, number]]; + +/** Bounds `[[west, south], [east, north]]` enclosing all coords, or null when + * there are none. `east` may exceed 180 when the frame crosses the antimeridian. */ +export function fitBounds(coords: { lon: number; lat: number }[]): LngLatBounds | null { + if (!coords.length) return null; + let south = Infinity; + let north = -Infinity; + for (const c of coords) { + south = Math.min(south, c.lat); + north = Math.max(north, c.lat); + } + const lons = coords.map((c) => c.lon).sort((a, b) => a - b); + let gapAfter = lons.length - 1; + let gapSize = lons[0]! + 360 - lons[lons.length - 1]!; // the wrap-around gap + for (let i = 1; i < lons.length; i++) { + const g = lons[i]! - lons[i - 1]!; + if (g > gapSize) { + gapSize = g; + gapAfter = i - 1; + } + } + const west = lons[(gapAfter + 1) % lons.length]!; + let east = lons[gapAfter]!; + if (east < west) east += 360; // the frame crosses the antimeridian + return [ + [west, south], + [east, north], + ]; +} diff --git a/apps/postcards/src/features/map/visitedLayers.ts b/apps/postcards/src/features/map/visitedLayers.ts index 5d52428..e7c395c 100644 --- a/apps/postcards/src/features/map/visitedLayers.ts +++ b/apps/postcards/src/features/map/visitedLayers.ts @@ -1,5 +1,5 @@ import type { Feature, FeatureCollection, LineString, Point } from "geojson"; -import type { Trip, Visit } from "../../lib/schema/models"; +import type { PlaceRef, TravelMode, Trip, Visit } from "../../lib/schema/models"; import type { ReferenceData } from "../../lib/reference/types"; import { coordsOf } from "../travel/distance"; @@ -214,16 +214,33 @@ export function tripArcs(trips: Trip[], ref: ReferenceData): FeatureCollection[] = []; for (const t of trips) { const chain = t.stops && t.stops.length >= 2 ? t.stops : [t.from, t.to]; - for (let i = 0; i < chain.length - 1; i++) { - const from = coordsOf(chain[i]!, ref); - const to = coordsOf(chain[i + 1]!, ref); - if (!from || !to) continue; - features.push({ - type: "Feature", - geometry: { type: "LineString", coordinates: greatCircle(from, to) }, - properties: { mode: t.mode }, - }); - } + features.push(...stopsArcs(chain, ref, t.mode).features); + } + return { type: "FeatureCollection", features }; +} + +/** + * Great-circle arcs for an ORDERED chain of stops (spec 019) — one arc per + * consecutive resolvable leg, tagged with the travel `mode`. Powers the live + * route drawn while reconstructing a journey (the composer's real map). A leg + * touching a coordinate-less stop is skipped — nothing invented (FR-013); fewer + * than two stops → an empty collection. Takes raw stops, NOT a Trip. + */ +export function stopsArcs( + stops: PlaceRef[], + ref: ReferenceData, + mode: TravelMode, +): FeatureCollection { + const features: Feature[] = []; + for (let i = 0; i < stops.length - 1; i++) { + const from = coordsOf(stops[i]!, ref); + const to = coordsOf(stops[i + 1]!, ref); + if (!from || !to) continue; + features.push({ + type: "Feature", + geometry: { type: "LineString", coordinates: greatCircle(from, to) }, + properties: { mode }, + }); } return { type: "FeatureCollection", features }; } diff --git a/apps/postcards/src/features/travel/MyPlacesPicker.tsx b/apps/postcards/src/features/travel/MyPlacesPicker.tsx index b9500c8..f5b3853 100644 --- a/apps/postcards/src/features/travel/MyPlacesPicker.tsx +++ b/apps/postcards/src/features/travel/MyPlacesPicker.tsx @@ -2,34 +2,34 @@ import { useDeferredValue, useMemo, useState } from "react"; import { getReferenceData } from "../../lib/reference/referenceData"; import { searchPlaces } from "../visits/search"; import { countryFlag } from "../../lib/format/format"; -import { LAND_OUTLINE } from "../../lib/publish/landOutline"; +import { RouteMap } from "./RouteMap"; import { useT } from "../../lib/i18n"; -import type { PlaceRef } from "../../lib/schema/models"; +import type { PlaceRef, TravelMode } from "../../lib/schema/models"; import type { MyPlace } from "./myPlaces"; // Pick trip stops fast. Two ways: // • List — the places you've BEEN (visited + past trips) as instant taps, AND a // search that reaches ANY airport or city in the gazetteer (airports are central // to reconstructing flights, and most people never log them as visits). -// • Map — a lightweight offline SVG map of your places (no MapLibre/tiles); tap a pin. +// • Map — the app's REAL MapLibre map (offline, bundled land) of your places; tap a +// pin to add it in sequence and watch the route draw (see RouteMap). // Flags everywhere for instant recognition (spec 019). -const W = 720; -const H = 380; -const PAD = 34; -const MIN_SPAN = 0.35; -const mercY = (lat: number) => Math.log(Math.tan(Math.PI / 4 + (Math.max(-85, Math.min(85, lat)) * Math.PI) / 360)); - const flagFor = (p: PlaceRef) => (p.kind === "airport" ? "✈️" : countryFlag(p.countryId)); export function MyPlacesPicker({ places, addedKeys, onPick, + stops, + travelMode, }: { places: MyPlace[]; addedKeys: Set; onPick: (place: PlaceRef) => void; + /** The route so far — drives the live arc + the "added" pin rings on the map. */ + stops: PlaceRef[]; + travelMode: TravelMode; }) { const t = useT(); const ref = useMemo(() => getReferenceData(), []); @@ -131,114 +131,8 @@ export function MyPlacesPicker({ ) : places.length === 0 ? (

{t("trip.compose.noPlaces")}

) : ( - + )} ); } - -/** The offline SVG map: your places as pins, tap to add. Pins are decorative for - * assistive tech; the legend list below is the keyboard/AT path (WCAG). */ -function PickMap({ - places, - addedKeys, - onPick, -}: { - places: MyPlace[]; - addedKeys: Set; - onPick: (place: PlaceRef) => void; -}) { - const t = useT(); - const layout = useMemo(() => { - const X = places.map((p) => (p.lon * Math.PI) / 180); - const Y = places.map((p) => mercY(p.lat)); - let minX = Math.min(...X); - let maxX = Math.max(...X); - let minY = Math.min(...Y); - let maxY = Math.max(...Y); - if (maxX - minX < MIN_SPAN) { - const c = (minX + maxX) / 2; - minX = c - MIN_SPAN / 2; - maxX = c + MIN_SPAN / 2; - } - if (maxY - minY < MIN_SPAN) { - const c = (minY + maxY) / 2; - minY = c - MIN_SPAN / 2; - maxY = c + MIN_SPAN / 2; - } - let spanX = maxX - minX; - let spanY = maxY - minY; - minX -= spanX * 0.16; - maxX += spanX * 0.16; - minY -= spanY * 0.2; - maxY += spanY * 0.2; - spanX = maxX - minX; - spanY = maxY - minY; - const scale = Math.min((W - 2 * PAD) / spanX, (H - 2 * PAD) / spanY); - const midX = (minX + maxX) / 2; - const midY = (minY + maxY) / 2; - const sx = (x: number) => W / 2 + (x - midX) * scale; - const sy = (y: number) => H / 2 - (y - midY) * scale; - let land = ""; - for (const ring of LAND_OUTLINE) { - for (const off of [-360, 0, 360]) { - let seg = ""; - let any = false; - let unwrapped = 0; - let prevRaw: number | null = null; - for (let i = 0; i < ring.length; i++) { - const llon = ring[i]![0]; - const llat = ring[i]![1]; - if (prevRaw === null) unwrapped = llon + off; - else { - let d = llon - prevRaw; - if (d > 180) d -= 360; - else if (d < -180) d += 360; - unwrapped += d; - } - prevRaw = llon; - const Lx = sx((unwrapped * Math.PI) / 180); - const Ly = sy(mercY(llat)); - seg += (i === 0 ? "M" : "L") + Lx.toFixed(1) + " " + Ly.toFixed(1); - if (Lx > -60 && Lx < W + 60 && Ly > -60 && Ly < H + 60) any = true; - } - if (any) land += seg + "Z"; - } - } - const dots = places.map((p) => ({ ...p, x: sx((p.lon * Math.PI) / 180), y: sy(mercY(p.lat)) })); - return { land, dots }; - }, [places]); - - return ( -
- - {layout.land && } - {layout.dots.map((p) => ( - onPick(p.place)}> - - - ))} - -
    - {layout.dots.map((p) => ( -
  • - -
  • - ))} -
-
- ); -} diff --git a/apps/postcards/src/features/travel/RouteMap.tsx b/apps/postcards/src/features/travel/RouteMap.tsx new file mode 100644 index 0000000..a7e5e80 --- /dev/null +++ b/apps/postcards/src/features/travel/RouteMap.tsx @@ -0,0 +1,231 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import maplibregl, { type StyleSpecification } from "maplibre-gl"; +import { getReferenceData } from "../../lib/reference/referenceData"; +import { useSettings } from "../../lib/store/useSettings"; +import { usePrefersReducedMotion } from "../../lib/hooks/usePrefersReducedMotion"; +import { countryFlag } from "../../lib/format/format"; +import { stopsArcs } from "../map/visitedLayers"; +import { fitBounds } from "../map/mapFit"; +import { useT } from "../../lib/i18n"; +import type { PlaceRef, TravelMode } from "../../lib/schema/models"; +import type { MyPlace } from "./myPlaces"; +import { getLand } from "./landGeometry"; +import { pickPointsFC } from "./pickPoints"; + +// The composer's REAL picker map: a dedicated, offline MapLibre instance (bundled +// Natural Earth land via the MapSource seam — no tiles, no network, no geolocation +// prompt) showing ONLY your pool of visited places as tappable pins. Tapping lays +// down stops in order and draws the live great-circle route. It is fully decoupled +// from the app's main MapView, so it can never perturb the production map. +// +// The map is a POINTER enhancement, not the only path: a companion list of the same +// places (real + + ))} + + + ); +} diff --git a/apps/postcards/src/features/travel/TripComposer.tsx b/apps/postcards/src/features/travel/TripComposer.tsx index 08ab09d..d6345e6 100644 --- a/apps/postcards/src/features/travel/TripComposer.tsx +++ b/apps/postcards/src/features/travel/TripComposer.tsx @@ -158,6 +158,8 @@ export function TripComposer({ tripId, onClose }: { tripId: string | null; onClo places={pool} addedKeys={addedKeys} onPick={(place) => setStops((st) => addStop(st, place))} + stops={stops} + travelMode={mode} /> {/* Optional details — name now, date whenever. */} diff --git a/apps/postcards/src/features/travel/landGeometry.ts b/apps/postcards/src/features/travel/landGeometry.ts new file mode 100644 index 0000000..c2d59b4 --- /dev/null +++ b/apps/postcards/src/features/travel/landGeometry.ts @@ -0,0 +1,32 @@ +import type { FeatureCollection } from "geojson"; +import { feature } from "topojson-client"; + +// Bundled, offline land geometry for the composer's route map — the SAME public +// Natural Earth asset the main map uses (public domain, SW-precached), fetched +// as a plain URL so it comes from cache with zero extra network. Parsed at most +// once per session; a failed load clears its slot so a later attempt can retry. +// Kept independent of MapView so the route map can't perturb the production map. + +const LAND_URL = `${import.meta.env.BASE_URL}basemap/countries-50m.json`; + +let landPromise: Promise | null = null; + +export function getLand(): Promise { + if (!landPromise) { + landPromise = (async () => { + try { + const res = await fetch(LAND_URL); + if (!res.ok) return null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const topo: any = await res.json(); + return feature(topo, topo.objects.countries) as unknown as FeatureCollection; + } catch { + return null; + } + })().then((fc) => { + if (!fc) landPromise = null; // allow a retry after an offline first-run miss + return fc; + }); + } + return landPromise; +} diff --git a/apps/postcards/src/features/travel/pickPoints.ts b/apps/postcards/src/features/travel/pickPoints.ts new file mode 100644 index 0000000..908b491 --- /dev/null +++ b/apps/postcards/src/features/travel/pickPoints.ts @@ -0,0 +1,34 @@ +import type { FeatureCollection, Point } from "geojson"; +import type { PlaceRef } from "../../lib/schema/models"; +import { placeKey } from "../../lib/schema/helpers"; +import type { MyPlace } from "./myPlaces"; + +// The tap-to-add pins for the composer's real map: ONE point per place you've +// been (the pool), each carrying its stable key, its kind (city vs airport vs … +// for colouring) and its 1-based position in the current route (`seq`, 0 when +// it isn't a stop yet — drives the "already added" ring). Pure & testable; the +// map layer reads only these props, so it never needs a PlaceRef. + +export function pickPointsFC(pool: MyPlace[], stops: PlaceRef[]): FeatureCollection { + const seqByKey = new Map(); + stops.forEach((s, i) => { + const k = placeKey(s); + // First occurrence wins the label (a round trip revisits a stop; show its + // first number rather than overwriting with the later one). + if (!seqByKey.has(k)) seqByKey.set(k, i + 1); + }); + return { + type: "FeatureCollection", + features: pool.map((p) => ({ + type: "Feature", + geometry: { type: "Point", coordinates: [p.lon, p.lat] }, + properties: { + key: p.key, + kind: p.place.kind, + seq: seqByKey.get(p.key) ?? 0, + added: seqByKey.has(p.key), + name: p.name, + }, + })), + }; +} diff --git a/apps/postcards/src/lib/i18n/en.ts b/apps/postcards/src/lib/i18n/en.ts index 221ced3..9bba984 100644 --- a/apps/postcards/src/lib/i18n/en.ts +++ b/apps/postcards/src/lib/i18n/en.ts @@ -450,7 +450,8 @@ export const en = { "trip.compose.searchPrompt": "Search for an airport (e.g. CDG) or a city to add it.", "trip.compose.noMatch": "No matches — try an airport code (e.g. CDG) or a city name.", "trip.compose.pickAria": "Add {name} to the trip", - "trip.compose.mapAria": "Map of your places — tap one to add it", + "trip.compose.mapCanvasAria": "Map of your places — tap a pin to add it to the trip", + "trip.compose.addedLive": "Added {name} — stop {index}", "trip.compose.noPlaces": "No places to map yet — switch to List and search for an airport or city.", "trip.compose.removeStop": "Remove {name}", diff --git a/apps/postcards/src/lib/i18n/fr.ts b/apps/postcards/src/lib/i18n/fr.ts index f36ecbd..0d1cf54 100644 --- a/apps/postcards/src/lib/i18n/fr.ts +++ b/apps/postcards/src/lib/i18n/fr.ts @@ -445,7 +445,8 @@ export const fr: Messages = { "trip.compose.searchPrompt": "Recherchez un aéroport (ex. CDG) ou une ville à ajouter.", "trip.compose.noMatch": "Aucun résultat — essayez un code d’aéroport (ex. CDG) ou une ville.", "trip.compose.pickAria": "Ajouter {name} au voyage", - "trip.compose.mapAria": "Carte de vos lieux — touchez-en un pour l’ajouter", + "trip.compose.mapCanvasAria": "Carte de vos lieux — touchez un point pour l’ajouter au voyage", + "trip.compose.addedLive": "{name} ajouté — étape {index}", "trip.compose.noPlaces": "Aucun lieu à afficher — passez en Liste et recherchez un aéroport ou une ville.", "trip.compose.removeStop": "Retirer {name}", diff --git a/apps/postcards/src/lib/i18n/ko.ts b/apps/postcards/src/lib/i18n/ko.ts index 3bd0de4..2916cd7 100644 --- a/apps/postcards/src/lib/i18n/ko.ts +++ b/apps/postcards/src/lib/i18n/ko.ts @@ -444,7 +444,8 @@ export const ko: Messages = { "trip.compose.searchPrompt": "추가할 공항(예: CDG) 또는 도시를 검색하세요.", "trip.compose.noMatch": "결과 없음 — 공항 코드(예: CDG)나 도시 이름을 입력하세요.", "trip.compose.pickAria": "{name}을(를) 여행에 추가", - "trip.compose.mapAria": "내 장소 지도 — 탭하여 추가", + "trip.compose.mapCanvasAria": "내 장소 지도 — 핀을 탭하여 여행에 추가", + "trip.compose.addedLive": "{name} 추가됨 — {index}번째 경유지", "trip.compose.noPlaces": "표시할 장소가 없습니다 — 목록으로 전환해 공항이나 도시를 검색하세요.", "trip.compose.removeStop": "{name} 제거", diff --git a/apps/postcards/src/styles.css b/apps/postcards/src/styles.css index 7c71303..155ee1f 100644 --- a/apps/postcards/src/styles.css +++ b/apps/postcards/src/styles.css @@ -2788,6 +2788,28 @@ h1.brand { flex-direction: column; gap: 8px; } +/* The composer's real map picker (RouteMap): a sized, relative canvas the absolute + MapLibre .map-canvas fills, with the keyboard/AT companion list beneath it. */ +.route-map { + display: flex; + flex-direction: column; + gap: 8px; +} +.route-map-canvas { + position: relative; + width: 100%; + height: clamp(220px, 42vh, 460px); + border-radius: var(--radius); + overflow: hidden; + border: 1px solid var(--border); + background: #d9e6f1; +} +.route-map-canvas .maplibregl-canvas { + border-radius: var(--radius); +} +.route-map-list { + max-height: 180px; +} .myplaces-list { list-style: none; margin: 0; diff --git a/apps/postcards/tests/e2e/a11y.spec.ts b/apps/postcards/tests/e2e/a11y.spec.ts index 18b45dc..956f992 100644 --- a/apps/postcards/tests/e2e/a11y.spec.ts +++ b/apps/postcards/tests/e2e/a11y.spec.ts @@ -72,11 +72,11 @@ test("the trip composer passes the axe WCAG 2.1 AA gate", async ({ page }) => { await assertNoSeriousViolations(page, "trip composer (list)"); - // The map pick mode must also pass the gate. + // The map pick mode (the real MapLibre RouteMap) must also pass the gate. await page .getByRole("group", { name: "How to pick places" }) .getByRole("button", { name: "Map" }) .click(); - await expect(page.locator(".myplaces-map")).toBeVisible(); + await expect(page.locator(".route-map-canvas canvas.maplibregl-canvas")).toBeVisible(); await assertNoSeriousViolations(page, "trip composer (map)"); }); diff --git a/apps/postcards/tests/e2e/trip-routemap.spec.ts b/apps/postcards/tests/e2e/trip-routemap.spec.ts new file mode 100644 index 0000000..9e0f6bf --- /dev/null +++ b/apps/postcards/tests/e2e/trip-routemap.spec.ts @@ -0,0 +1,45 @@ +import { test, expect, type Page } from "@playwright/test"; +import { gotoTab } from "./nav-helper"; + +// The composer's real map picker (RouteMap): switching to the Map segment mounts an +// actual MapLibre canvas (not the old SVG), and the companion list beneath it — the +// keyboard/AT path — still builds the route, with the live arc reflecting each add. + +async function markVisited(page: Page, city: string): Promise { + await page.getByLabel("Search a city or country").fill(city); + await page.getByRole("button", { name: `Mark ${city} visited` }).first().click(); + await page.keyboard.press("Escape"); +} + +test("Map segment shows a real MapLibre map; the companion list builds the route", async ({ + page, +}: { + page: Page; +}) => { + await page.goto("/"); + for (const city of ["Paris", "Tokyo", "Osaka"]) await markVisited(page, city); + + await gotoTab(page, "Trips"); + await page.getByRole("button", { name: "Reconstruct a journey" }).click(); + await expect(page.getByRole("heading", { name: "New trip" })).toBeVisible(); + + // Switch the picker to the Map segment (scoped to the picker — the bottom-nav + // also has a "Map" tab). + await page.locator(".myplaces-picker").getByRole("button", { name: /Map/ }).click(); + + // A REAL MapLibre map mounts — its WebGL canvas is present inside our sized box. + const canvas = page.locator(".route-map-canvas canvas.maplibregl-canvas"); + await expect(canvas).toBeVisible(); + + // Build the route via the companion list (the keyboard/AT mirror of the pins). + await page.getByRole("button", { name: "Add Paris to the trip" }).click(); + await page.getByRole("button", { name: "Add Tokyo to the trip" }).click(); + const stops = page.locator(".trip-stops li"); + await expect(stops).toHaveCount(2); + + // Save and confirm the trip landed (a 2-stop trip reads as "Paris → Tokyo"). + await page.getByRole("button", { name: "Save trip" }).click(); + await expect(page.getByRole("heading", { name: "Travel log" })).toBeVisible(); + await expect(page.getByRole("button", { name: /Edit trip/ }).first()).toBeVisible(); + await expect(page.getByText("Tokyo", { exact: false }).first()).toBeVisible(); +}); diff --git a/apps/postcards/tests/unit/routeMap.spec.ts b/apps/postcards/tests/unit/routeMap.spec.ts new file mode 100644 index 0000000..e7e35a1 --- /dev/null +++ b/apps/postcards/tests/unit/routeMap.spec.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import { fitBounds } from "../../src/features/map/mapFit"; +import { pickPointsFC } from "../../src/features/travel/pickPoints"; +import { stopsArcs } from "../../src/features/map/visitedLayers"; +import { placeKey } from "../../src/lib/schema/helpers"; +import type { MyPlace } from "../../src/features/travel/myPlaces"; +import type { PlaceRef } from "../../src/lib/schema/models"; +import type { City, ReferenceData } from "../../src/lib/reference/types"; + +const city = (id: string, name: string, lon: number, lat: number): City => ({ + id, name, countryIso2: "FR", subdivisionId: null, lon, lat, population: 1000, +}); +const cities: Record = { + paris: city("paris", "Paris", 2.35, 48.85), + tokyo: city("tokyo", "Tokyo", 139.69, 35.68), + osaka: city("osaka", "Osaka", 135.5, 34.69), +}; +const ref = { + cityById: (id: string) => cities[id], + airportById: () => undefined, + heritageById: () => undefined, +} as unknown as ReferenceData; + +const cityRef = (id: string): PlaceRef => ({ kind: "city", id, name: cities[id]!.name, countryId: "FR" }); +const myPlace = (id: string): MyPlace => { + const place = cityRef(id); + const c = cities[id]!; + return { key: placeKey(place), place, name: c.name, countryId: "FR", lon: c.lon, lat: c.lat }; +}; + +describe("mapFit.fitBounds", () => { + it("returns null for no coordinates", () => { + expect(fitBounds([])).toBeNull(); + }); + it("frames a simple spread west→east, south→north", () => { + const b = fitBounds([{ lon: 2, lat: 48 }, { lon: 140, lat: 35 }]); + expect(b).toEqual([[2, 35], [140, 48]]); + }); + it("frames antimeridian pairs tight (Fiji + Samoa), not the whole globe", () => { + // Fiji ~178, Samoa ~-172: the tight frame wraps 178 → 188 (east > 180), width 10°. + const b = fitBounds([{ lon: 178, lat: -18 }, { lon: -172, lat: -13 }])!; + const width = b[1][0] - b[0][0]; + expect(width).toBeCloseTo(10, 5); + expect(b[0][0]).toBe(178); + expect(b[1][0]).toBe(188); + }); +}); + +describe("pickPoints.pickPointsFC", () => { + it("emits one feature per pool place with a stable key and kind", () => { + const pool = [myPlace("paris"), myPlace("tokyo")]; + const fc = pickPointsFC(pool, []); + expect(fc.features).toHaveLength(2); + expect(fc.features.every((f) => f.properties!.seq === 0 && f.properties!.added === false)).toBe(true); + expect(fc.features[0]!.properties!.kind).toBe("city"); + }); + it("labels added stops with their 1-based route index and an added flag", () => { + const pool = [myPlace("paris"), myPlace("tokyo"), myPlace("osaka")]; + const fc = pickPointsFC(pool, [cityRef("tokyo"), cityRef("paris")]); + const byKey = new Map(fc.features.map((f) => [f.properties!.key, f.properties!])); + expect(byKey.get(placeKey(cityRef("tokyo")))!.seq).toBe(1); + expect(byKey.get(placeKey(cityRef("paris")))!.seq).toBe(2); + expect(byKey.get(placeKey(cityRef("osaka")))!.seq).toBe(0); // not on the route + }); +}); + +describe("visitedLayers.stopsArcs", () => { + it("draws one great-circle leg per consecutive resolvable pair", () => { + const fc = stopsArcs([cityRef("paris"), cityRef("tokyo"), cityRef("osaka")], ref, "flight"); + expect(fc.features).toHaveLength(2); + expect(fc.features[0]!.geometry.type).toBe("LineString"); + expect(fc.features[0]!.properties!.mode).toBe("flight"); + }); + it("skips a leg touching a coordinate-less stop without dropping the rest", () => { + const country: PlaceRef = { kind: "country", id: "JP", name: "Japan", countryId: "JP" }; + // paris → JP(no coord) → tokyo: only the JP legs are skipped; nothing else. + const fc = stopsArcs([cityRef("paris"), country, cityRef("tokyo")], ref, "flight"); + expect(fc.features).toHaveLength(0); + }); + it("returns empty for fewer than two stops", () => { + expect(stopsArcs([cityRef("paris")], ref, "flight").features).toHaveLength(0); + expect(stopsArcs([], ref, "flight").features).toHaveLength(0); + }); +});