# 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> = {},
): 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<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",
  }));
}

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];
}
```

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<MapCanvasProps> = ({
  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<Set<string>>(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<typeof vi.fn>;
}

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<string, unknown>)) {
    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> = {},
): 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(
      <MapCanvas
        markers={[cm("A"), cm("B")]}
        polylines={[pl("line", ["A", "B"])]}
        tileUrl="t"
      />,
    );
    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(
      <MapCanvas
        markers={[cm("A")]}
        polylines={[pl("line", ["A", "B"])]}
        tileUrl="t"
      />,
    );
    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(
      <MapCanvas
        markers={[cm("A"), cm("B", "other"), cm("C")]}
        polylines={[pl("tri", ["A", "B", "C"])]}
        domestic={true}
        tileUrl="t"
      />,
    );
    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(
      <MapCanvas
        markers={[cm("A"), cm("B")]}
        polylines={[pl("dashed", ["A", "B"], "connecting")]}
        tileUrl="t"
      />,
    );
    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(
      <MapCanvas
        markers={[cm("A"), cm("B")]}
        polylines={[pl("line", ["A", "B"])]}
        tileUrl="t"
      />,
    );
    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(
      <MapCanvas
        markers={[cm("A")]}
        polylines={[pl("line", ["A", "NOPE"])]}
        tileUrl="t"
      />,
    );
    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(
      <MapCanvas
        markers={[
          cm("A", "ru", { highlighted: true }),
          cm("B", "ru", { highlighted: true }),
          cm("X", "ru"),
        ]}
        polylines={[]}
        intermediateIds={["X"]}
        tileUrl="t"
      />,
    );
    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(
      <MapCanvas
        markers={[
          cm("A", "ru", { highlighted: true }),
          cm("B", "ru", { highlighted: true }),
          cm("X", "ru"),
        ]}
        polylines={[]}
        intermediateIds={["X"]}
        tileUrl="t"
      />,
    );
    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<IMapPolyline[]>(() => [], []);
```

Replace with:

```tsx
const polylines = useMemo<IMapPolyline[]>(
  () => routesToPolylines(routes, filterState),
  [routes, filterState.departure, filterState.arrival],
);

const intermediateIds = useMemo<string[]>(
  () => intermediateCityIds(routes),
  [routes],
);
```

- [ ] **Step 4.3: Pass `intermediateIds` to MapCanvas**

Find the existing `<MapCanvas ... />` element. Add the `intermediateIds` prop:

```tsx
<MapCanvas
  markers={markers}
  polylines={polylines}
  tileUrl={tileUrl}
  onMarkerClick={handleMarkerClick}
  className="flights-map-start__map"
  domestic={filterState.domestic}
  international={filterState.international}
  intermediateIds={intermediateIds}
/>
```

- [ ] **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(<FlightsMapStartPage />);
    const polylines = lastMapCanvasProps!["polylines"] as unknown[];
    expect(polylines).toEqual([]);
  });

  it("passes an empty intermediateIds when no routes", () => {
    render(<FlightsMapStartPage />);
    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(<FlightsMapStartPage />);

    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(<FlightsMapStartPage />);

    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<string>` 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.
