Add design spec for Flights Map C.3 (Route + Spider Drawing)
Covers the polyline layer: routesToPolylines pure function, city-code- based IMapPolyline shape, MapCanvas polyline sync via markerIndexRef with visibility filtering, intermediate-city tooltip force-open pass.
This commit is contained in:
@@ -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<IFlightsMapFilterState, "departure" | "arrival">,
|
||||
): 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<string>();
|
||||
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<string>();
|
||||
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<string>` is updated from the `intermediateIds` prop via a small effect that also re-runs `syncTooltips`:
|
||||
|
||||
```tsx
|
||||
const intermediateIdsRef = useRef<Set<string>>(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<IMapPolyline[]>(
|
||||
() => routesToPolylines(routes, filterState),
|
||||
[routes, filterState.departure, filterState.arrival],
|
||||
);
|
||||
|
||||
const intermediateIds = useMemo<string[]>(
|
||||
() => intermediateCityIds(routes),
|
||||
[routes],
|
||||
);
|
||||
|
||||
// ...
|
||||
|
||||
<MapCanvas
|
||||
markers={markers}
|
||||
polylines={polylines}
|
||||
tileUrl={tileUrl}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
className="flights-map-start__map"
|
||||
domestic={filterState.domestic}
|
||||
international={filterState.international}
|
||||
intermediateIds={intermediateIds}
|
||||
/>
|
||||
```
|
||||
|
||||
The existing `polylines = useMemo<IMapPolyline[]>(() => [], [])` line is replaced. The existing `<MapCanvas />` 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.
|
||||
Reference in New Issue
Block a user