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); + }); +});