From 514bae6051c58451e89b2d778d1fe8378e060e78 Mon Sep 17 00:00:00 2001 From: gnezim Date: Fri, 17 Apr 2026 10:00:48 +0300 Subject: [PATCH] Add Flights Map C.3 implementation plan Six TDD tasks covering the routes-to-polylines pure helper, IMapPolyline reshape to cityIds, MapCanvas polyline sync with visibility filtering, intermediate-tooltip force-open pass, page wiring, and integration tests. Tasks 1-3 share a commit due to coupling between type and consumer. --- ...-17-flights-map-c3-route-spider-drawing.md | 1008 +++++++++++++++++ 1 file changed, 1008 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-17-flights-map-c3-route-spider-drawing.md diff --git a/docs/superpowers/plans/2026-04-17-flights-map-c3-route-spider-drawing.md b/docs/superpowers/plans/2026-04-17-flights-map-c3-route-spider-drawing.md new file mode 100644 index 00000000..d15dc017 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-flights-map-c3-route-spider-drawing.md @@ -0,0 +1,1008 @@ +# Flights Map C.3 (Route + Spider Drawing) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Draw API-returned routes as polylines on the map (direct vs. dashed), with a spider-mode fallback when only departure is selected, and force-open tooltips on intermediate cities of multi-hop routes — all in Angular parity. + +**Architecture:** A new pure `routesToPolylines` module transforms `IFlightRoute[]` into a reshaped `IMapPolyline` (city-code-based). `MapCanvas` gains a polyline sync that resolves coordinates via its existing `markerIndexRef`, filters invisible cities at draw time, and rebuilds polylines on zoom/toggle changes. A second tooltip-sync pass force-opens intermediate-city tooltips regardless of zoom/highlight rules. `FlightsMapStartPage` wires `useFlightsMapSearch` routes into polylines + intermediate IDs. + +**Tech Stack:** TypeScript, React 18, Leaflet (isolated to MapCanvas), vitest, jsdom, @testing-library/react. + +**Related spec:** `docs/superpowers/specs/2026-04-17-flights-map-c3-route-spider-drawing-design.md` + +--- + +## File Structure + +**New:** +- `src/features/flights-map/routesToPolylines.ts` — pure functions `routesToPolylines` + `intermediateCityIds`. +- `src/features/flights-map/routesToPolylines.test.ts` — unit tests. + +**Modified:** +- `src/features/flights-map/types.ts` — replace `IMapPolyline.points` with `cityIds: string[]`; add `MapCanvasProps.intermediateIds?: string[]`. +- `src/features/flights-map/components/MapCanvas.tsx` — new `syncPolylines` (city-code-based + visibility filter); `intermediateIdsRef` + pass-2 tooltip force-open; zoomend handler triggers polyline re-sync; toggle-change effect re-syncs polylines. +- `src/features/flights-map/components/MapCanvas.test.tsx` — extend the Leaflet mock (marker-aware `hasLayer`, polyline constructor capture, polyline `addTo` delegation); append polyline + intermediate tests. +- `src/features/flights-map/components/FlightsMapStartPage.tsx` — wire `routesToPolylines` + `intermediateCityIds`; pass `intermediateIds` prop to MapCanvas. +- `src/features/flights-map/components/FlightsMapStartPage.test.tsx` — extend the `useFlightsMapSearch` mock to be controllable; append polyline/spider/intermediate integration tests. + +**Untouched:** +- Dictionaries module (`src/shared/dictionaries/`). +- `useFlightsMapSearch` hook. +- Search params construction in FlightsMapStartPage (departure/arrival → `FlightsMapSearchParams`). + +--- + +## Task 1: `routesToPolylines` module + +**Files:** +- Create: `src/features/flights-map/routesToPolylines.ts` +- Create: `src/features/flights-map/routesToPolylines.test.ts` + +- [ ] **Step 1.1: Write failing tests** + +Create `src/features/flights-map/routesToPolylines.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { + routesToPolylines, + intermediateCityIds, +} from "./routesToPolylines.js"; +import type { IFlightRoute, IFlightsMapFilterState } from "./types.js"; + +function filter( + overrides: Partial = {}, +): IFlightsMapFilterState { + return { + connections: false, + domestic: false, + international: false, + ...overrides, + }; +} + +describe("routesToPolylines — empty input", () => { + it("returns [] for no routes", () => { + expect(routesToPolylines([], filter({ departure: "A" }))).toEqual([]); + }); +}); + +describe("routesToPolylines — spider mode (departure only)", () => { + it("produces one polyline per unique last-hop destination", () => { + const routes: IFlightRoute[] = [ + { route: ["A", "B"], isDirect: true }, + { route: ["A", "C"], isDirect: true }, + { route: ["A", "B"], isDirect: true }, // duplicate + ]; + + const pls = routesToPolylines(routes, filter({ departure: "A" })); + + expect(pls).toHaveLength(2); + expect(pls.every((p) => p.cityIds[0] === "A")).toBe(true); + expect(pls.every((p) => p.cityIds.length === 2)).toBe(true); + expect(pls.every((p) => p.style === "direct")).toBe(true); + const dests = pls.map((p) => p.cityIds[1]).sort(); + expect(dests).toEqual(["B", "C"]); + }); + + it("drops routes whose last hop equals departure", () => { + const routes: IFlightRoute[] = [ + { route: ["A", "A"], isDirect: true }, + { route: ["A", "B"], isDirect: true }, + ]; + const pls = routesToPolylines(routes, filter({ departure: "A" })); + expect(pls).toHaveLength(1); + expect(pls[0]!.cityIds).toEqual(["A", "B"]); + }); + + it("skips single-city routes (len < 2)", () => { + const routes: IFlightRoute[] = [{ route: ["A"], isDirect: true }]; + const pls = routesToPolylines(routes, filter({ departure: "A" })); + expect(pls).toEqual([]); + }); +}); + +describe("routesToPolylines — route mode (departure + arrival)", () => { + it("direct route gets style=\"direct\"", () => { + const routes: IFlightRoute[] = [{ route: ["A", "B"], isDirect: true }]; + const pls = routesToPolylines(routes, filter({ departure: "A", arrival: "B" })); + expect(pls).toHaveLength(1); + expect(pls[0]!.style).toBe("direct"); + expect(pls[0]!.cityIds).toEqual(["A", "B"]); + }); + + it("non-direct route gets style=\"connecting\" (hop count irrelevant)", () => { + const routes: IFlightRoute[] = [ + { route: ["A", "X", "B"], isDirect: false }, + // two-city route with isDirect=false is still "connecting" + { route: ["A", "B"], isDirect: false }, + ]; + const pls = routesToPolylines(routes, filter({ departure: "A", arrival: "B" })); + expect(pls.every((p) => p.style === "connecting")).toBe(true); + }); + + it("assigns unique IDs across multiple routes", () => { + const routes: IFlightRoute[] = [ + { route: ["A", "B"], isDirect: true }, + { route: ["A", "X", "B"], isDirect: false }, + { route: ["A", "B"], isDirect: false }, + ]; + const pls = routesToPolylines(routes, filter({ departure: "A", arrival: "B" })); + const ids = new Set(pls.map((p) => p.id)); + expect(ids.size).toBe(pls.length); + }); +}); + +describe("intermediateCityIds", () => { + it("returns [] for no routes", () => { + expect(intermediateCityIds([])).toEqual([]); + }); + + it("ignores 2-city routes", () => { + expect(intermediateCityIds([{ route: ["A", "B"], isDirect: true }])).toEqual([]); + }); + + it("extracts the single inner city from a 3-city route", () => { + expect( + intermediateCityIds([{ route: ["A", "X", "B"], isDirect: false }]), + ).toEqual(["X"]); + }); + + it("extracts both inner cities from a 4-city route", () => { + expect( + intermediateCityIds([{ route: ["A", "X", "Y", "B"], isDirect: false }]).sort(), + ).toEqual(["X", "Y"]); + }); + + it("dedups intermediates across multiple routes", () => { + const ids = intermediateCityIds([ + { route: ["A", "X", "B"], isDirect: false }, + { route: ["C", "X", "D"], isDirect: false }, + ]); + expect(ids).toEqual(["X"]); + }); +}); +``` + +- [ ] **Step 1.2: Run failing tests** + +Run: `pnpm vitest run src/features/flights-map/routesToPolylines.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 1.3: Implement the module** + +Create `src/features/flights-map/routesToPolylines.ts`: + +```ts +/** + * Transform API-returned routes into polylines the map can render. + * + * Two modes, mirroring Angular `fetchAndDraw`: + * - Spider (departure only): one straight polyline from departure to each + * unique destination across all routes. + * - Route (departure + arrival): one polyline per route following its city + * sequence; direct routes solid, connecting routes dashed. + * + * `intermediateCityIds` returns marker IDs whose tooltips must be force-opened + * along multi-hop routes, matching Angular `updateIntermediateTooltip`. + */ + +import type { + IFlightRoute, + IFlightsMapFilterState, + IMapPolyline, +} from "./types.js"; + +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", + })); +} + +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]; +} +``` + +Note: the new module references `IMapPolyline.cityIds`, which is defined by Task 2's type change. If you execute tasks out of order, typecheck after Task 2 lands — otherwise the module will typecheck clean once Task 2 commits. + +- [ ] **Step 1.4: Run tests — they will fail typecheck until Task 2** + +This is expected — the new types for `IMapPolyline` land in Task 2. If you're running Task 1 and Task 2 in a single commit cycle, proceed to Task 2 now and run tests at the end of Task 2. + +If you prefer to verify Task 1 in isolation, defer the commit below until after Task 2's type change is in place. + +- [ ] **Step 1.5: Commit (after Task 2 or together with it)** + +```bash +git add src/features/flights-map/routesToPolylines.ts src/features/flights-map/routesToPolylines.test.ts +git commit -m "Add routesToPolylines + intermediateCityIds pure helpers" +``` + +--- + +## Task 2: Type changes (`IMapPolyline` reshape + `MapCanvasProps.intermediateIds`) + +**Files:** +- Modify: `src/features/flights-map/types.ts` + +- [ ] **Step 2.1: Replace `IMapPolyline`** + +Open `src/features/flights-map/types.ts`. Find the current interface: + +```ts +export interface IMapPolyline { + id: string; + points: Array<{ lat: number; lng: number }>; + style: PolylineStyle; +} +``` + +Replace with: + +```ts +/** + * A polyline to render on the map. + * + * Cities are referenced by code; MapCanvas resolves coordinates via its + * internal markerIndex and filters out cities whose marker is not currently + * on the map (matches Angular `drawPolyline` behavior). + */ +export interface IMapPolyline { + id: string; + cityIds: string[]; + style: PolylineStyle; +} +``` + +There are no other consumers of the old `points` shape in this codebase — grep to confirm. If any surface, they need migration; none are expected. + +- [ ] **Step 2.2: Add `intermediateIds` to `MapCanvasProps` — NOTE** + +`MapCanvasProps` lives in `MapCanvas.tsx`, not `types.ts`. You'll add the field there in Task 3. No change to `types.ts` for this field. + +- [ ] **Step 2.3: Typecheck** + +Run: `pnpm tsc --noEmit` +Expected: Errors in `MapCanvas.tsx` where `pl.points` is referenced; errors in tests that built polylines with `points`; possibly errors in the existing MapCanvas test Leaflet mock (if it references polyline internals). These will be fixed by Task 3. **Do not commit types.ts yet** — bundle it into Task 3's commit. + +--- + +## Task 3: MapCanvas polyline sync + intermediate tooltip pass + +**Files:** +- Modify: `src/features/flights-map/components/MapCanvas.tsx` +- Modify: `src/features/flights-map/components/MapCanvas.test.tsx` + +This is the biggest task. Break it into steps. + +- [ ] **Step 3.1: Add `intermediateIds` prop + ref** + +Open `src/features/flights-map/components/MapCanvas.tsx`. + +In the `MapCanvasProps` interface (add near `domestic` / `international`): + +```ts +/** Marker IDs whose tooltips must be force-opened (intermediate cities on multi-hop routes). */ +intermediateIds?: string[]; +``` + +Destructure it in the component signature (default `undefined`): + +```tsx +export const MapCanvas: FC = ({ + markers, + polylines, + popups, + tileUrl, + center = [53, 45], + zoom = 5, + minZoom = 3, + maxZoom = 6, + onMarkerClick, + className, + domestic = false, + international = false, + intermediateIds, +}) => { +``` + +Near the other refs (after `highlightedIdsRef`), add: + +```tsx +const intermediateIdsRef = useRef>(new Set()); +``` + +Update the init-effect cleanup to reset the new ref (add to the existing cleanup block): + +```tsx + intermediateIdsRef.current = new Set(); +``` + +- [ ] **Step 3.2: Extend `syncTooltips` with pass 2** + +Find the existing `syncTooltips` function. Replace it entirely with: + +```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. + 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(); + } +} +``` + +- [ ] **Step 3.3: Add the `intermediateIds` sync effect** + +After the existing `useEffect(() => { syncVisibility(); syncTooltips(); }, [domestic, international]);` block, add: + +```tsx +useEffect(() => { + intermediateIdsRef.current = new Set(intermediateIds ?? []); + syncTooltips(); +}, [intermediateIds]); +``` + +- [ ] **Step 3.4: Replace the old polyline sync with the city-code version** + +Find the existing polyline-sync effect. It currently looks roughly like: + +```tsx +useEffect(() => { + const layer = polylinesLayerRef.current; + if (!layer) return; + layer.clearLayers(); + for (const pl of polylines) { + const style = POLYLINE_STYLES[pl.style]; + const arcPoints: L.LatLng[] = []; + for (let i = 0; i < pl.points.length - 1; i++) { + const ptFrom = pl.points[i]; + const ptTo = pl.points[i + 1]; + if (!ptFrom || !ptTo) continue; + const from = L.latLng(ptFrom.lat, ptFrom.lng); + const to = L.latLng(ptTo.lat, ptTo.lng); + const arc = buildGreatCircleArc(from, to); + arcPoints.push(...(i === 0 ? arc : arc.slice(1))); + } + if (arcPoints.length >= 2) { + L.polyline(arcPoints, style).addTo(layer); + } + } +}, [polylines]); +``` + +Replace the whole effect with a `syncPolylines` helper + its `useEffect`. First, define the helper near the other sync helpers (`syncVisibility`, `syncTooltips`): + +```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]; + + // Resolve to markers currently on the map (Angular parity). + 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]); +``` + +Then, update the two other sync effects to also call `syncPolylines`: + +1. **The zoomend handler** in the init effect (find `map.on("zoomend", ...)`): + +```tsx + map.on("zoomend", () => { + syncVisibility(); + syncPolylines(); + syncTooltips(); + }); +``` + +2. **The domestic/international effect:** + +```tsx +useEffect(() => { + syncVisibility(); + syncPolylines(); + syncTooltips(); +}, [domestic, international]); +``` + +- [ ] **Step 3.5: Make the existing Leaflet mock marker-aware and polyline-capturing** + +Open `src/features/flights-map/components/MapCanvas.test.tsx`. + +**Add a `createdPolylines` array** near the other `created*` arrays (line ~51): + +```ts +interface MockPolyline { + latlngs: unknown; + opts: unknown; + addTo: ReturnType; +} + +const createdPolylines: MockPolyline[] = []; +``` + +**Update `resetLeafletMockState`:** + +```ts +function resetLeafletMockState() { + createdMarkers.length = 0; + createdLayerGroups.length = 0; + createdMaps.length = 0; + createdPolylines.length = 0; + layerGroupCounter = 0; +} +``` + +**Replace the `polyline` factory** inside the `vi.mock("leaflet", ...)` call (currently `function polyline() { return { addTo: vi.fn() }; }`): + +```ts +function polyline(latlngs: unknown, opts: unknown) { + const p: MockPolyline = { + latlngs, + opts, + addTo: vi.fn((target: { addLayer?: (l: unknown) => unknown }) => { + target.addLayer?.(p); + return p; + }), + }; + createdPolylines.push(p); + return p; +} +``` + +**Replace `map.hasLayer`** to make it marker-aware. Find: + +```ts +hasLayer: vi.fn((l: MockLayerGroup) => m._addedLayers.has(l)), +``` + +Replace with: + +```ts +hasLayer: vi.fn((target: unknown) => { + // LayerGroup path: target has _markers. + if (target && typeof target === "object" && "_markers" in (target as Record)) { + return m._addedLayers.has(target as MockLayerGroup); + } + // Marker path: find the group that contains it. + const owning = createdLayerGroups.find((lg) => + lg._markers.includes(target as MockMarker), + ); + return owning ? m._addedLayers.has(owning) : false; +}), +``` + +(`map.hasLayer` in real Leaflet accepts any layer, including a marker inside a LayerGroup — it returns true when the marker's ancestor is on the map.) + +- [ ] **Step 3.6: Append new polyline + intermediate tests** + +At the end of `MapCanvas.test.tsx`, add: + +```tsx +// --------------------------------------------------------------------------- +// C.3 polyline + intermediate tests +// --------------------------------------------------------------------------- + +import type { IMapPolyline } from "../types.js"; + +function cm( + id: string, + countryType: "ru" | "other" = "ru", + overrides: Partial = {}, +): IMapMarker { + return { + id, + lat: 55, + lng: 37, + style: "blue-small", + label: id, + tooltipPermanent: true, + zoomLevel: 2, + countryType, + ...overrides, + }; +} + +function pl(id: string, cityIds: string[], style: "direct" | "connecting" = "direct"): IMapPolyline { + return { id, cityIds, style }; +} + +describe("MapCanvas — polylines (C.3)", () => { + beforeEach(() => resetLeafletMockState()); + + it("resolves coords from markers and draws a polyline A→B", () => { + render( + , + ); + const map = createdMaps[0]!; + map.setZoom(6); + map.fireZoomend(); + + expect(createdPolylines.length).toBeGreaterThanOrEqual(1); + }); + + it("does not draw a polyline when fewer than 2 cities are visible", () => { + render( + , + ); + const map = createdMaps[0]!; + map.setZoom(6); + map.fireZoomend(); + + // "B" does not exist → visible.length < 2. + expect(createdPolylines.length).toBe(0); + }); + + it("skips intermediate cities whose marker is not on the map", () => { + // All three markers created, but B's zoom layer will not be added to the map + // because we force domestic=true and set B to countryType="other". + render( + , + ); + const map = createdMaps[0]!; + map.setZoom(6); + map.fireZoomend(); + + expect(createdPolylines.length).toBe(1); + // The polyline had 3 cities in input; with B invisible, visible.length = 2 + // (A and C). We don't assert exact arc point counts — just that the polyline + // was drawn. + }); + + it("applies connecting style for non-direct routes", () => { + render( + , + ); + const map = createdMaps[0]!; + map.setZoom(6); + map.fireZoomend(); + + const styled = createdPolylines[0]!; + const opts = styled.opts as { dashArray?: string } | undefined; + expect(opts?.dashArray).toBe("4 14"); + }); + + it("rebuilds polylines on zoomend", () => { + render( + , + ); + const map = createdMaps[0]!; + map.setZoom(6); + map.fireZoomend(); + const before = createdPolylines.length; + + map.setZoom(4); + map.fireZoomend(); + + // Each zoomend triggers clearLayers + re-add. createdPolylines accumulates + // across renders; assert at least one more was created on the second fire. + expect(createdPolylines.length).toBeGreaterThan(before); + }); + + it("silently skips polylines with unknown city codes", () => { + render( + , + ); + const map = createdMaps[0]!; + map.setZoom(6); + map.fireZoomend(); + + expect(createdPolylines.length).toBe(0); + }); +}); + +describe("MapCanvas — intermediateIds (C.3)", () => { + beforeEach(() => resetLeafletMockState()); + + it("force-opens intermediate marker tooltips even at zoom <= 3", () => { + render( + , + ); + const map = createdMaps[0]!; + map.setZoom(3); + map.fireZoomend(); + + const X = createdMarkers.find((m) => m.options.title === "X")!; + expect(X.openTooltip).toHaveBeenCalled(); + }); + + it("force-opens intermediate tooltips with >=2 highlighted markers", () => { + render( + , + ); + const map = createdMaps[0]!; + map.setZoom(5); + map.fireZoomend(); + + const X = createdMarkers.find((m) => m.options.title === "X")!; + expect(X.openTooltip).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 3.7: Run all MapCanvas tests** + +Run: `pnpm vitest run src/features/flights-map/components/MapCanvas.test.tsx` +Expected: PASS — prior 8 + 8 new = 16 tests. + +- [ ] **Step 3.8: Run routesToPolylines tests (Task 1's deferred verification)** + +Run: `pnpm vitest run src/features/flights-map/routesToPolylines.test.ts` +Expected: PASS — 13 tests. + +- [ ] **Step 3.9: Typecheck** + +Run: `pnpm tsc --noEmit` +Expected: no output. + +- [ ] **Step 3.10: Full suite** + +Run: `pnpm vitest run` +Expected: all pass. + +- [ ] **Step 3.11: Commit** + +```bash +git add \ + src/features/flights-map/routesToPolylines.ts \ + src/features/flights-map/routesToPolylines.test.ts \ + src/features/flights-map/types.ts \ + src/features/flights-map/components/MapCanvas.tsx \ + src/features/flights-map/components/MapCanvas.test.tsx +git commit -m "Draw routes as city-code polylines and force-open intermediate tooltips + +- routesToPolylines + intermediateCityIds pure helpers with unit coverage. +- IMapPolyline reshaped from points to cityIds for Angular-parity drawing. +- MapCanvas resolves coords via markerIndex, filters invisible cities on + every zoom/toggle change, and runs a second tooltip pass that keeps + intermediate-city tooltips open regardless of zoom/highlight rules." +``` + +Bundling Tasks 1 + 2 + 3 in one commit because the type change and the new helper's consumer (MapCanvas) can't compile in isolation. + +--- + +## Task 4: Wire `FlightsMapStartPage` to drive polylines from search results + +**Files:** +- Modify: `src/features/flights-map/components/FlightsMapStartPage.tsx` + +- [ ] **Step 4.1: Add imports** + +Open `src/features/flights-map/components/FlightsMapStartPage.tsx`. + +Add the import near the existing `@/` imports: + +```tsx +import { routesToPolylines, intermediateCityIds } from "../routesToPolylines.js"; +``` + +- [ ] **Step 4.2: Replace the empty polylines memo** + +Find the existing empty polylines memo: + +```tsx +const polylines = useMemo(() => [], []); +``` + +Replace with: + +```tsx +const polylines = useMemo( + () => routesToPolylines(routes, filterState), + [routes, filterState.departure, filterState.arrival], +); + +const intermediateIds = useMemo( + () => intermediateCityIds(routes), + [routes], +); +``` + +- [ ] **Step 4.3: Pass `intermediateIds` to MapCanvas** + +Find the existing `` element. Add the `intermediateIds` prop: + +```tsx + +``` + +- [ ] **Step 4.4: Typecheck + full suite** + +Run: +- `pnpm tsc --noEmit` — expect no output. +- `pnpm vitest run` — expect all pass (page test currently mocks MapCanvas so the new prop just flows through the capturing mock once Task 5 adds assertions). + +- [ ] **Step 4.5: Commit** + +```bash +git add src/features/flights-map/components/FlightsMapStartPage.tsx +git commit -m "Drive polylines and intermediateIds from useFlightsMapSearch routes" +``` + +--- + +## Task 5: Integration tests for polyline/spider wiring + +**Files:** +- Modify: `src/features/flights-map/components/FlightsMapStartPage.test.tsx` + +- [ ] **Step 5.1: Extend the `useFlightsMapSearch` mock to be controllable** + +Open `src/features/flights-map/components/FlightsMapStartPage.test.tsx`. + +Find the existing `vi.mock("../hooks/useFlightsMapSearch.js", ...)` block (it currently returns static `{ routes: [], loading: false, error: null }`). Replace with a mutable state object: + +```tsx +const searchState: { + routes: Array<{ route: string[]; isDirect: boolean }>; + loading: boolean; + error: Error | null; +} = { + routes: [], + loading: false, + error: null, +}; +vi.mock("../hooks/useFlightsMapSearch.js", () => ({ + useFlightsMapSearch: () => ({ + ...searchState, + refresh: vi.fn(), + }), +})); +``` + +Also ensure the existing `filterState` or `useState` chain inside the page allows us to observe both departure-only (spider) and departure+arrival (route) conditions. The page's internal state starts empty; to put the page into spider mode for a test, we'd need to trigger the `handleMarkerClick` path. Instead, we test by asserting at the `polylines` prop level (captured via the `lastMapCanvasProps` mock from C.2), recognizing that an empty initial filterState (no departure/arrival) produces zero polylines regardless of the search mock. That's an acceptable test shape — we assert behavior post-click. + +Alternatively, simpler: test `routesToPolylines` behavior inline by providing routes and observing that the captured `polylines` array contains route-mode entries when filterState has both departure and arrival. The page's filterState initial is `{ connections: false, domestic: false, international: false }` (no dep/arr), so to trigger route mode we need to simulate clicks. **Simpler approach:** verify only route-mode output for now; deep spider testing lives in `routesToPolylines.test.ts`. + +- [ ] **Step 5.2: Append integration tests** + +Append a new describe block at the END of the file: + +```tsx +describe("FlightsMapStartPage — polylines from search results (C.3)", () => { + beforeEach(() => { + lastMapCanvasProps = null; + dictState.dictionaries = { + cities: [ + { code: "A", name: "A", country_code: "RU", location: { lat: 55, lon: 37 } }, + { code: "B", name: "B", country_code: "RU", location: { lat: 60, lon: 40 } }, + { code: "X", name: "X", country_code: "RU", location: { lat: 58, lon: 38 } }, + ], + }; + dictState.loading = false; + dictState.error = null; + searchState.routes = []; + searchState.loading = false; + searchState.error = null; + }); + + it("passes an empty polylines array when no routes", () => { + render(); + const polylines = lastMapCanvasProps!["polylines"] as unknown[]; + expect(polylines).toEqual([]); + }); + + it("passes an empty intermediateIds when no routes", () => { + render(); + const ids = lastMapCanvasProps!["intermediateIds"] as string[]; + expect(ids).toEqual([]); + }); + + it("flows intermediateIds from a multi-hop route", () => { + searchState.routes = [{ route: ["A", "X", "B"], isDirect: false }]; + + render(); + + const ids = lastMapCanvasProps!["intermediateIds"] as string[]; + expect(ids).toEqual(["X"]); + }); + + it("flows route-mode polylines with correct style flags when search returned routes", () => { + // Initial page filterState has no departure/arrival, so spider mode would be + // off and route mode requires both. routesToPolylines returns route-mode + // output when hasDeparture=false && hasArrival=false (falls through to the + // route-mode branch). Note: Angular wouldn't fetch routes without a + // departure, but the pure transform is valid for any input. + searchState.routes = [ + { route: ["A", "B"], isDirect: true }, + { route: ["A", "X", "B"], isDirect: false }, + ]; + + render(); + + const polylines = lastMapCanvasProps!["polylines"] as Array<{ + style: string; + cityIds: string[]; + }>; + expect(polylines).toHaveLength(2); + expect(polylines[0]!.style).toBe("direct"); + expect(polylines[1]!.style).toBe("connecting"); + }); +}); +``` + +- [ ] **Step 5.3: Run the appended tests** + +Run: `pnpm vitest run src/features/flights-map/components/FlightsMapStartPage.test.tsx` +Expected: PASS — prior tests + 4 new = 10 tests in this file. + +- [ ] **Step 5.4: Full suite** + +Run: `pnpm vitest run` +Expected: all pass. + +- [ ] **Step 5.5: Commit** + +```bash +git add src/features/flights-map/components/FlightsMapStartPage.test.tsx +git commit -m "Test FlightsMapStartPage polyline + intermediateIds wiring" +``` + +--- + +## Task 6: Final verification + +- [ ] **Step 6.1: Typecheck** + +Run: `pnpm tsc --noEmit` +Expected: no output. + +- [ ] **Step 6.2: Full vitest suite** + +Run: `pnpm vitest run` +Expected: all pass. + +- [ ] **Step 6.3: Scoped test run** + +Run: `pnpm vitest run src/features/flights-map/` +Expected: all feature tests pass. New delta: +13 routesToPolylines + +8 MapCanvas + +4 page integration = +25 tests. + +--- + +## Self-Review Log + +Ran against the spec: + +- **Spec coverage.** + - `routesToPolylines` + `intermediateCityIds` pure module → Task 1. + - `IMapPolyline.cityIds` reshape → Task 2 (bundled into Task 3 commit). + - `MapCanvasProps.intermediateIds` → Task 3.1. + - MapCanvas polyline sync with visibility filter → Task 3.4 (`syncPolylines`). + - Zoomend re-runs polylines → Task 3.4 (zoomend handler update). + - Domestic/international toggle effect re-runs polylines → Task 3.4. + - Pass-2 tooltip force-open → Task 3.2 (`syncTooltips`) + Task 3.3 (effect sets `intermediateIdsRef`). + - Page wiring → Task 4. + - Integration tests → Task 5. + - Final verification → Task 6. +- **Placeholders.** None. +- **Type consistency.** `IMapPolyline` uses `cityIds: string[]` everywhere (types.ts, routesToPolylines.ts, MapCanvas `syncPolylines`, tests). `intermediateIds?: string[]` prop + `intermediateIdsRef: Set` are consistent. `PolylineStyle` values `"direct"` and `"connecting"` line up with the existing `POLYLINE_STYLES` map. +- **Commit bundling.** Tasks 1/2/3 share one commit because the type change forces MapCanvas to update in lockstep. Tasks 4/5 have their own commits. Noted explicitly in Step 3.11. +- **Mock update completeness.** The Leaflet mock gains polyline capture, marker-aware `hasLayer`, and reset-state tracking. This is the minimum needed for the new assertions.