diff --git a/docs/superpowers/specs/2026-04-17-flights-map-c3-route-spider-drawing-design.md b/docs/superpowers/specs/2026-04-17-flights-map-c3-route-spider-drawing-design.md new file mode 100644 index 00000000..8875e531 --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-flights-map-c3-route-spider-drawing-design.md @@ -0,0 +1,344 @@ +# Flights Map C.3: Route + Spider Drawing — Design + +**Date:** 2026-04-17 +**Author:** brainstorming session +**Scope:** Sub-feature C.3 of Gap C (Flights Map rebuild) +**Status:** Approved +**Depends on:** C.1 (dictionaries), C.2 (markers + zoom tiers) — both landed. + +## Goal + +Turn API-returned routes into Leaflet polylines on the map, matching Angular's two drawing modes: + +- **Route mode** (departure + arrival selected) — one polyline per route through its city sequence; direct routes solid, connecting (multi-hop) routes dashed. +- **Spider mode** (departure only) — one straight polyline from departure to each unique destination found in the routes response. + +Additionally, force-open tooltips on intermediate cities of multi-hop routes (Angular parity). + +## Non-Goals + +- Client-side filtering of routes by domestic/international/connections → **C.4**. +- Auto-enable-connections fallback when direct routes return nothing → **C.4**. +- Buy-ticket popups on departure/arrival → **C.4**. +- Date picker / calendar wiring → **C.5**. +- Exchange button, geolocation-based default departure → **C.5**. + +## Architecture + +New pure module `routesToPolylines.ts` transforms route data to polyline data. `IMapPolyline` is redefined to reference cities by code (matching Angular), so `MapCanvas` can filter invisible cities at draw time. `MapCanvas` owns polyline geometry + the new tooltip "force-open" pass. + +``` +src/features/flights-map/ +├── routesToPolylines.ts NEW (pure) +├── routesToPolylines.test.ts NEW +├── types.ts MODIFY (IMapPolyline.cityIds, MapCanvasProps.intermediateIds) +└── components/ + ├── MapCanvas.tsx MODIFY (polyline sync resolves cityIds + filters invisible; tooltip pass 2) + ├── MapCanvas.test.tsx MODIFY (append polyline + intermediate tests) + ├── FlightsMapStartPage.tsx MODIFY (wire routes→polylines, derive intermediateIds) + └── FlightsMapStartPage.test.tsx MODIFY (append polyline/spider/intermediate integration tests) +``` + +## `routesToPolylines.ts` + +```ts +import type { + IFlightRoute, + IFlightsMapFilterState, + IMapPolyline, +} from "./types.js"; + +/** + * Convert API routes into the polyline shape MapCanvas consumes. + * + * Route mode (departure + arrival set): one polyline per route following its + * city sequence. Direct routes solid, connecting routes dashed. + * + * Spider mode (departure only): one straight polyline from departure to each + * unique last-hop destination across all returned routes. + */ +export function routesToPolylines( + routes: IFlightRoute[], + filterState: Pick, +): IMapPolyline[] { + if (routes.length === 0) return []; + + const hasDeparture = Boolean(filterState.departure); + const hasArrival = Boolean(filterState.arrival); + const isSpiderMode = hasDeparture && !hasArrival; + + if (isSpiderMode) { + const fromCode = filterState.departure!; + const destCodes = new Set(); + for (const r of routes) { + if (r.route.length > 1) { + const dest = r.route[r.route.length - 1]!; + if (dest !== fromCode) destCodes.add(dest); + } + } + return [...destCodes].map((dest) => ({ + id: `spider-${fromCode}-${dest}`, + cityIds: [fromCode, dest], + style: "direct", + })); + } + + return routes.map((r, i) => ({ + id: `route-${i}-${r.route.join("-")}`, + cityIds: r.route, + style: r.isDirect ? "direct" : "connecting", + })); +} + +/** + * Inner cities of all multi-hop routes, deduplicated. These are the marker + * IDs whose tooltips must be force-opened regardless of zoom/highlight rules + * (Angular parity — see `updateIntermediateTooltip`). + */ +export function intermediateCityIds(routes: IFlightRoute[]): string[] { + const ids = new Set(); + for (const r of routes) { + if (r.route.length <= 2) continue; + for (let i = 1; i < r.route.length - 1; i++) ids.add(r.route[i]!); + } + return [...ids]; +} +``` + +## Type changes (`types.ts`) + +```ts +// BEFORE +export interface IMapPolyline { + id: string; + points: Array<{ lat: number; lng: number }>; + style: PolylineStyle; +} + +// AFTER +export interface IMapPolyline { + id: string; + cityIds: string[]; + style: PolylineStyle; +} +``` + +`MapCanvasProps` gains one optional field: + +```ts +export interface MapCanvasProps { + // ...existing (markers, polylines, popups, tileUrl, center, zoom, minZoom, maxZoom, + // onMarkerClick, className, domestic, international) + intermediateIds?: string[]; +} +``` + +## `MapCanvas` polyline sync + +The existing lat/lng-based polyline effect is replaced with a city-code-based version that resolves coordinates via the already-present `markerIndexRef` and filters out cities whose marker is not currently on the map. This is a direct port of Angular `drawPolyline`. + +```tsx +function syncPolylines(): void { + const map = mapRef.current; + const layer = polylinesLayerRef.current; + if (!map || !layer) return; + + layer.clearLayers(); + + for (const pl of polylines) { + const styleOpts = POLYLINE_STYLES[pl.style]; + + const visible: L.Marker[] = []; + for (const cityId of pl.cityIds) { + const m = markerIndexRef.current.get(cityId); + if (m && map.hasLayer(m)) visible.push(m); + } + if (visible.length < 2) continue; + + const segments: L.LatLng[] = []; + for (let i = 0; i < visible.length - 1; i++) { + const from = visible[i]!.getLatLng(); + const to = visible[i + 1]!.getLatLng(); + const arc = buildGreatCircleArc(from, to); + segments.push(...(i === 0 ? arc : arc.slice(1))); + } + + L.polyline(segments, styleOpts).addTo(layer); + } +} + +useEffect(() => { + syncPolylines(); +}, [polylines]); +``` + +The `zoomend` handler (wired at map init) calls all three syncs in Angular's order — visibility first, polylines second (because they depend on visibility), tooltips last: + +```tsx +map.on("zoomend", () => { + syncVisibility(); + syncPolylines(); + syncTooltips(); +}); +``` + +The toggle-change effect gains the polyline re-run: + +```tsx +useEffect(() => { + syncVisibility(); + syncPolylines(); + syncTooltips(); +}, [domestic, international]); +``` + +**Important subtlety about marker visibility.** `map.hasLayer(marker)` in real Leaflet returns `true` when any ancestor layer of the marker is on the map. The categorized markers live in layer groups, and a marker is visible iff its layer group is on the map. We must ensure the test mock and the real code both interpret "visible" the same way — practically, `map.hasLayer(marker)` in real Leaflet already returns true when the enclosing LayerGroup is on the map. The existing mock (from C.2) already tracks `_addedLayers`; we'll extend it so `map.hasLayer(marker)` walks the layer groups that contain the marker. + +## `MapCanvas` tooltip sync — pass 2 + +```tsx +function syncTooltips(): void { + const map = mapRef.current; + if (!map) return; + + const currentZoom = map.getZoom(); + const highlighted = highlightedIdsRef.current; + const closeNonHighlighted = currentZoom <= 3 || highlighted.size >= 2; + + // Pass 1: zoom/highlight rules (unchanged from C.2). + markerIndexRef.current.forEach((marker, id) => { + if (closeNonHighlighted && !highlighted.has(id)) marker.closeTooltip(); + else if (!closeNonHighlighted) marker.openTooltip(); + }); + + // Pass 2: force-open intermediate route tooltips (Angular parity). + for (const id of intermediateIdsRef.current) { + const m = markerIndexRef.current.get(id); + if (m) m.openTooltip(); + } +} +``` + +`intermediateIdsRef: Set` is updated from the `intermediateIds` prop via a small effect that also re-runs `syncTooltips`: + +```tsx +const intermediateIdsRef = useRef>(new Set()); + +useEffect(() => { + intermediateIdsRef.current = new Set(intermediateIds ?? []); + syncTooltips(); +}, [intermediateIds]); +``` + +## `FlightsMapStartPage` wiring + +```tsx +import { routesToPolylines, intermediateCityIds } from "../routesToPolylines.js"; + +// ...existing: dictionaries, filterState, useFlightsMapSearch, useFlightsMapCalendar... + +const polylines = useMemo( + () => routesToPolylines(routes, filterState), + [routes, filterState.departure, filterState.arrival], +); + +const intermediateIds = useMemo( + () => intermediateCityIds(routes), + [routes], +); + +// ... + + +``` + +The existing `polylines = useMemo(() => [], [])` line is replaced. The existing `` call gains one prop (`intermediateIds`). + +## Error handling / edge cases + +- **Empty routes.** `routesToPolylines` returns `[]`; polyline layer cleared; no crash. +- **Spider mode with no departure set in filter.** Defensive: `hasDeparture` guard prevents the branch. Returns route-mode output (which would also be `[]` in practice since the API call requires departure). +- **Single-city route (`route.length === 1`).** `visible.length < 2` guard skips that polyline. Matches Angular. +- **Unknown city code in a route.** `markerIndexRef.get(id)` returns undefined → filtered out of `visible`. If all cities are unknown, polyline skipped. +- **Intermediate city hidden at current zoom tier** or by domestic/international toggle. `map.hasLayer(marker)` returns false → polyline shortcuts around it. Matches Angular. +- **Last-hop destination equal to departure in spider mode.** Filtered out by `dest !== fromCode` guard (degenerate API data). +- **Route fetch error.** Existing `map-error` banner handles. `polylines` and `intermediateIds` default to empty. +- **Intermediate ID tooltip rule conflicts with highlight rule.** Pass 1 closes non-highlighted tooltips, pass 2 re-opens intermediates. Intermediates always win (Angular ordering). Acceptable. + +## Testing + +### `routesToPolylines.test.ts` (pure) + +- Empty `routes` → `[]` (both functions). +- Spider mode (dep only): unique destinations; skips `dest === fromCode`; `style: "direct"` for all; `cityIds: [dep, dest]`. +- Spider mode with routes missing a valid last hop (`route.length === 1`) skipped. +- Route mode (dep + arr): `r.isDirect=true` → `"direct"`; `r.isDirect=false` → `"connecting"` (hop count not considered). +- Route mode IDs unique across multiple entries (use index + joined route path). +- `intermediateCityIds`: route length 2 → none; length 3 → inner; length 4 → both inner; duplicates across routes dedup'd. + +### `MapCanvas.test.tsx` (append) + +Extend the existing Leaflet mock to track `L.polyline` constructor args and to make `map.hasLayer(marker)` return true iff the marker's owning LayerGroup is in `map._addedLayers`. Implementation sketch: + +```ts +// Existing marker factory tracks which layer added it; extend map.hasLayer: +hasLayer: vi.fn((target: MockLayerGroup | MockMarker) => { + if ("_markers" in target) return m._addedLayers.has(target); // LayerGroup + const owning = createdLayerGroups.find((lg) => lg._markers.includes(target)); + return owning ? m._addedLayers.has(owning) : false; // Marker +}), + +// polyline factory: +function polyline(latlngs: unknown[], opts: unknown) { + const p = { latlngs, opts, addTo: vi.fn((target: MockLayerGroup) => { + target.addLayer(p as unknown as MockMarker); + return p; + }) }; + createdPolylines.push(p); + return p; +} +``` + +Tests: + +- **Polyline resolves coords from markers.** Mount with markers A,B; polyline `cityIds: ["A", "B"]`. Assert `L.polyline` called with args whose first `latlngs` element has lat/lng matching A and last element matching B. +- **Multi-hop polyline.** Markers A,B,C; polyline `cityIds: ["A", "B", "C"]`. Assert `L.polyline` called; `latlngs` array spans both arcs. +- **Invisible intermediate skipped.** Simulate B not in any added layer (mock `map._addedLayers` excludes B's layer). Polyline A→B→C renders; assert the produced `latlngs` matches an A→C arc only. +- **Fewer than 2 visible cities.** Only A rendered; polyline `cityIds: ["A","B"]` → `L.polyline` not called. +- **Style mapping.** `style: "direct"` → passed opts match `POLYLINE_STYLES.direct` (identity or deep equal). `style: "connecting"` → `POLYLINE_STYLES.connecting`. +- **`zoomend` rebuilds polylines.** After fire, `polylinesLayer.clearLayers` called; `L.polyline` called again. +- **`intermediateIds` force-open.** Mount with intermediate marker I (non-highlighted) and `intermediateIds=["I"]` at zoom 3. Assert `I.openTooltip` was called (after pass-1 would otherwise close it). +- **`intermediateIds` applies with ≥2 highlighted markers.** Same assertion at zoom 5 with two other highlighted markers. +- **Unknown city code in polyline.** cityIds `["A","UNKNOWN"]` → `L.polyline` not called. + +### `FlightsMapStartPage.test.tsx` (append) + +Extend the existing `useFlightsMapSearch` mock to return controllable routes (current mock returns `{ routes: [], loading: false, error: null }`). Tests: + +- **Direct route → solid polyline.** Mock returns `[{ route: ["A","B"], isDirect: true }]`; captured `polylines[0].style === "direct"`; `cityIds === ["A","B"]`. +- **Connecting route → dashed polyline.** Mock returns `[{ route: ["A","X","B"], isDirect: false }]`; captured `polylines[0].style === "connecting"`; `cityIds === ["A","X","B"]`. +- **Spider mode → one polyline per unique destination.** Filter has `departure="A"`, no arrival; routes `[{route:["A","B"]},{route:["A","C"]},{route:["A","B"]}]`. Captured `polylines.length === 2`; each entry's `cityIds.length === 2`. +- **Empty routes → empty polylines + empty intermediateIds.** +- **Intermediate IDs.** Route with 3 cities → captured `intermediateIds` contains the middle one. + +## Success criteria + +- `pnpm tsc --noEmit` clean. +- All new tests pass. Expected delta: ≈20 new tests (7 routesToPolylines + 9 MapCanvas + 5 page integration). +- Full suite green. +- The page renders identically while search params are incomplete (no polylines, no intermediate tooltips forced). +- Once a real search resolves, polylines render through visible cities with the correct style, and intermediate-city tooltips stay open. + +## Deferred to later sub-features + +- **C.4:** `filterRoutes` client-side domestic/international/connections filter; `fetchAndDrawRoute` auto-fallback to `connections=1`; buy-ticket departure + arrival popups. +- **C.5:** Calendar/date-picker disabled-days wiring; exchange button; geolocation default departure.