# 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.
