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:
2026-04-17 09:53:21 +03:00
parent a9b47036b5
commit a23513045b
@@ -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.