# Flights Map C.2 (Markers + Zoom Tiers) 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:** Render every dictionary city as a Leaflet marker with population-tier-based zoom visibility, RU/non-RU partitioning for domestic/international toggles, orange-icon highlighting for the selected departure/arrival, and Angular-parity tooltip rules.

**Architecture:** Add a pure feature-local `cityCategory.ts` module (port of Angular `CityCategoryService`). Extend `IMapMarker` with optional `zoomLevel`/`countryType`/`highlighted` fields. `MapCanvas` gains 10 internal LayerGroups (`ru|other × zoom 2..6`) plus a separate always-on highlight layer; new `domestic`/`international` props drive a visibility predicate run on every marker-change and `zoomend`. `FlightsMapStartPage` replaces its empty-markers placeholder with a real mapping from `useDictionaries()`.

**Tech Stack:** TypeScript, React 18, Leaflet (already wired in MapCanvas only), vitest, jsdom, @testing-library/react.

**Related spec:** `docs/superpowers/specs/2026-04-17-flights-map-c2-markers-zoom-design.md`

---

## File Structure

**New:**
- `src/features/flights-map/cityCategory.ts` — four ReadonlySet constants + `getCityZoomLevel`.
- `src/features/flights-map/cityCategory.test.ts` — spot checks, zoom-level mapping, set sizes.
- `src/features/flights-map/components/MapCanvas.test.tsx` — Leaflet-mocked tests for the new layer logic.

**Modified:**
- `src/features/flights-map/types.ts` — extend `IMapMarker` with 3 optional fields; extend `MapCanvasProps` with `domestic`/`international`.
- `src/features/flights-map/components/MapCanvas.tsx` — layer-group bookkeeping, highlight layer, tooltip rules, new props.
- `src/features/flights-map/components/FlightsMapStartPage.tsx` — real `markers` built from dictionaries; pass new props.
- `src/features/flights-map/components/FlightsMapStartPage.test.tsx` — append integration tests.

**Untouched:**
- `MapCanvas` tile-layer init, polyline sync, popup sync, great-circle arc math.
- Dictionaries module (`src/shared/dictionaries/`).

---

## Task 1: `cityCategory.ts` — sets and lookup

**Files:**
- Create: `src/features/flights-map/cityCategory.ts`
- Create: `src/features/flights-map/cityCategory.test.ts`

- [ ] **Step 1.1: Write failing tests**

Create `src/features/flights-map/cityCategory.test.ts`:

```ts
import { describe, it, expect } from "vitest";
import {
  POPULATION_1KK,
  POPULATION_500K,
  POPULATION_100K,
  POPULAR_RESORTS,
  getCityZoomLevel,
} from "./cityCategory.js";

describe("cityCategory — set sizes", () => {
  it("POPULATION_1KK has 29 entries (Angular parity)", () => {
    expect(POPULATION_1KK.size).toBe(29);
  });

  it("POPULATION_500K has 23 entries", () => {
    expect(POPULATION_500K.size).toBe(23);
  });

  it("POPULATION_100K has 39 entries", () => {
    expect(POPULATION_100K.size).toBe(39);
  });

  it("POPULAR_RESORTS has 15 entries", () => {
    expect(POPULAR_RESORTS.size).toBe(15);
  });
});

describe("cityCategory — membership spot checks", () => {
  it("MOW is in POPULATION_1KK", () => {
    expect(POPULATION_1KK.has("MOW")).toBe(true);
  });

  it("DXB is in POPULAR_RESORTS", () => {
    expect(POPULAR_RESORTS.has("DXB")).toBe(true);
  });

  it("TJM is in POPULATION_500K", () => {
    expect(POPULATION_500K.has("TJM")).toBe(true);
  });

  it("MMK is in POPULATION_100K", () => {
    expect(POPULATION_100K.has("MMK")).toBe(true);
  });
});

describe("getCityZoomLevel", () => {
  it("returns 2 for a 1kk-population city", () => {
    expect(getCityZoomLevel("MOW")).toBe(2);
  });

  it("returns 2 for a popular resort", () => {
    expect(getCityZoomLevel("DXB")).toBe(2);
  });

  it("returns 5 for a 500k-population city", () => {
    expect(getCityZoomLevel("TJM")).toBe(5);
  });

  it("returns 6 for a 100k-population city", () => {
    expect(getCityZoomLevel("MMK")).toBe(6);
  });

  it("returns 6 for an unknown city code", () => {
    expect(getCityZoomLevel("XXX")).toBe(6);
  });
});
```

- [ ] **Step 1.2: Run tests to verify they fail**

Run: `pnpm vitest run src/features/flights-map/cityCategory.test.ts`
Expected: FAIL with "Cannot find module './cityCategory.js'".

- [ ] **Step 1.3: Implement cityCategory.ts**

Create `src/features/flights-map/cityCategory.ts`:

```ts
/**
 * City category data + zoom-level lookup.
 *
 * Direct port of Angular `CityCategoryService`. Cities are partitioned into
 * four population/resort tiers; `getCityZoomLevel` maps a city code to the
 * minimum map zoom at which its marker should render.
 *
 * Keep the Sets exported for regression tests — editing them should be an
 * intentional product decision.
 */

export const POPULATION_1KK: ReadonlySet<string> = new Set([
  "QIN", "THR", "FRU", "KZN", "LED", "IST", "HKG", "PEE", "UFA",
  "OVB", "CEK", "CAI", "SVX", "EVN", "DEL", "MSQ",
  "KJA", "KUF", "NQZ", "OMS", "ALA", "SHA", "VOG",
  "BAK", "BKK", "BJS", "MOW", "GOJ", "VVO", "KHV",
]);

export const POPULATION_500K: ReadonlySet<string> = new Set([
  "TJM", "TOF", "SKD", "ULY", "PEZ", "MCX", "KEJ",
  "IJK", "REN", "ASF", "NOZ", "BAX", "RTW", "DOH",
  "AUH", "SSH", "TAS", "CIT", "SGN", "CAN", "HRB",
  "VRA", "KGF",
]);

export const POPULATION_100K: ReadonlySet<string> = new Set([
  "MMK", "GDX", "SGC", "DYR", "ESL", "OSS", "KVD", "KVK", "GUW", "SCW",
  "SCO", "NJC", "NBC", "UGC", "UUD", "YKS", "MJZ", "SKX", "STW", "KSN",
  "KGD", "HMA", "HTA", "RGK", "GRV", "ABA", "PKC", "NAL", "MQF", "MRV",
  "OSW", "ARH", "UUS", "NUX", "BHK", "PYJ", "CSY", "BQS", "DLM",
]);

export const POPULAR_RESORTS: ReadonlySet<string> = new Set([
  "AER", "AYT", "BJV", "CMB", "DPS", "DXB", "GOI",
  "HAV", "HKT", "HRG", "MLE", "NHA", "SEZ", "SYX", "IKT",
]);

export function getCityZoomLevel(code: string): number {
  if (POPULATION_1KK.has(code) || POPULAR_RESORTS.has(code)) return 2;
  if (POPULATION_500K.has(code)) return 5;
  if (POPULATION_100K.has(code)) return 6;
  return 6;
}
```

Note: `POPULATION_1KK` is hand-counted as 29 entries; if the test fails the size assertion, count the lines in the Angular source at `ClientApp/src/app/features/flights-map/services/category-city.service.ts` and reconcile (do not adjust the test silently).

- [ ] **Step 1.4: Run tests to verify they pass**

Run: `pnpm vitest run src/features/flights-map/cityCategory.test.ts`
Expected: PASS — 14 tests.

- [ ] **Step 1.5: Commit**

```bash
git add src/features/flights-map/cityCategory.ts src/features/flights-map/cityCategory.test.ts
git commit -m "Port Angular CityCategoryService to feature-local cityCategory module"
```

---

## Task 2: Extend `IMapMarker` and `MapCanvasProps`

**Files:**
- Modify: `src/features/flights-map/types.ts`

- [ ] **Step 2.1: Extend IMapMarker**

Open `src/features/flights-map/types.ts`. Find the existing `IMapMarker` interface and replace it with:

```ts
/**
 * A marker to render on the map.
 *
 * When `zoomLevel` and `countryType` are both set, MapCanvas places the
 * marker into the categorized-rendering flow (10 LayerGroups keyed by
 * countryType × zoomLevel). Otherwise the marker renders via the legacy
 * flat-layer flow.
 *
 * When `highlighted` is true, the marker is forced into the always-on
 * highlight layer with an orange icon regardless of `style` and
 * `zoomLevel`.
 */
export interface IMapMarker {
  id: string;
  lat: number;
  lng: number;
  style: MarkerStyle;
  label?: string | undefined;
  tooltipPermanent?: boolean | undefined;
  zoomLevel?: number | undefined;
  countryType?: "ru" | "other" | undefined;
  highlighted?: boolean | undefined;
}
```

- [ ] **Step 2.2: Verify typecheck**

Run: `pnpm tsc --noEmit`
Expected: no output.

- [ ] **Step 2.3: Commit**

```bash
git add src/features/flights-map/types.ts
git commit -m "Extend IMapMarker with zoomLevel, countryType, highlighted fields"
```

---

## Task 3: MapCanvas categorized-rendering — layer construction + visibility

**Files:**
- Modify: `src/features/flights-map/components/MapCanvas.tsx`
- Create: `src/features/flights-map/components/MapCanvas.test.tsx`

- [ ] **Step 3.1: Write the Leaflet mock + failing tests**

Create `src/features/flights-map/components/MapCanvas.test.tsx`:

```tsx
/**
 * @vitest-environment jsdom
 */

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render } from "@testing-library/react";
import type { IMapMarker } from "../types.js";

// ---------------------------------------------------------------------------
// Leaflet mock
// ---------------------------------------------------------------------------

interface MockMarker {
  icon: unknown;
  tooltipArgs: unknown[] | null;
  addTo: ReturnType<typeof vi.fn>;
  on: ReturnType<typeof vi.fn>;
  bindTooltip: ReturnType<typeof vi.fn>;
  openTooltip: ReturnType<typeof vi.fn>;
  closeTooltip: ReturnType<typeof vi.fn>;
  setIcon: ReturnType<typeof vi.fn>;
  setLatLng: ReturnType<typeof vi.fn>;
  getLatLng: ReturnType<typeof vi.fn>;
  options: { title?: string };
}

interface MockLayerGroup {
  _id: number;
  _markers: MockMarker[];
  addTo: ReturnType<typeof vi.fn>;
  addLayer: ReturnType<typeof vi.fn>;
  removeLayer: ReturnType<typeof vi.fn>;
  clearLayers: ReturnType<typeof vi.fn>;
  hasLayer: ReturnType<typeof vi.fn>;
}

interface MockMap {
  _zoom: number;
  _eventHandlers: Record<string, Array<() => void>>;
  _addedLayers: Set<MockLayerGroup>;
  on: ReturnType<typeof vi.fn>;
  remove: ReturnType<typeof vi.fn>;
  addLayer: ReturnType<typeof vi.fn>;
  removeLayer: ReturnType<typeof vi.fn>;
  hasLayer: ReturnType<typeof vi.fn>;
  getZoom: ReturnType<typeof vi.fn>;
  setZoom: (z: number) => void;
  fireZoomend: () => void;
}

const createdMarkers: MockMarker[] = [];
const createdLayerGroups: MockLayerGroup[] = [];
const createdMaps: MockMap[] = [];
let layerGroupCounter = 0;

function resetLeafletMockState() {
  createdMarkers.length = 0;
  createdLayerGroups.length = 0;
  createdMaps.length = 0;
  layerGroupCounter = 0;
}

vi.mock("leaflet/dist/leaflet.css", () => ({}));

vi.mock("leaflet", () => {
  function marker(_latlng: unknown, opts: { icon?: unknown; title?: string } = {}) {
    const m: MockMarker = {
      icon: opts.icon,
      tooltipArgs: null,
      addTo: vi.fn(() => m),
      on: vi.fn(() => m),
      bindTooltip: vi.fn((...args: unknown[]) => {
        m.tooltipArgs = args;
        return m;
      }),
      openTooltip: vi.fn(() => m),
      closeTooltip: vi.fn(() => m),
      setIcon: vi.fn((ic: unknown) => {
        m.icon = ic;
        return m;
      }),
      setLatLng: vi.fn(() => m),
      getLatLng: vi.fn(() => ({ lat: 0, lng: 0 })),
      options: opts.title !== undefined ? { title: opts.title } : {},
    };
    createdMarkers.push(m);
    return m;
  }

  function layerGroup() {
    const id = layerGroupCounter++;
    const lg: MockLayerGroup = {
      _id: id,
      _markers: [],
      addTo: vi.fn(() => lg),
      addLayer: vi.fn((l: MockMarker) => {
        lg._markers.push(l);
        return lg;
      }),
      removeLayer: vi.fn(() => lg),
      clearLayers: vi.fn(() => {
        lg._markers.length = 0;
        return lg;
      }),
      hasLayer: vi.fn(() => false),
    };
    createdLayerGroups.push(lg);
    return lg;
  }

  function mapFn(_container: unknown, _opts: unknown) {
    const m: MockMap = {
      _zoom: 5,
      _eventHandlers: {},
      _addedLayers: new Set<MockLayerGroup>(),
      on: vi.fn((evt: string, fn: () => void) => {
        m._eventHandlers[evt] ??= [];
        m._eventHandlers[evt].push(fn);
        return m;
      }),
      remove: vi.fn(),
      addLayer: vi.fn((l: MockLayerGroup) => {
        m._addedLayers.add(l);
        return m;
      }),
      removeLayer: vi.fn((l: MockLayerGroup) => {
        m._addedLayers.delete(l);
        return m;
      }),
      hasLayer: vi.fn((l: MockLayerGroup) => m._addedLayers.has(l)),
      getZoom: vi.fn(() => m._zoom),
      setZoom: (z: number) => {
        m._zoom = z;
      },
      fireZoomend: () => {
        (m._eventHandlers["zoomend"] ?? []).forEach((h) => h());
      },
    };
    createdMaps.push(m);
    return m;
  }

  function tileLayer() {
    return { addTo: vi.fn(() => ({ addTo: vi.fn() })) };
  }

  function icon(opts: unknown) {
    return { __icon: opts };
  }

  function latLng(lat: number, lng: number) {
    return { lat, lng };
  }

  function polyline() {
    return { addTo: vi.fn() };
  }

  function popup() {
    return {
      setLatLng: vi.fn(function (this: unknown) { return this; }),
      setContent: vi.fn(function (this: unknown) { return this; }),
      addTo: vi.fn(function (this: unknown) { return this; }),
    };
  }

  const L = { marker, layerGroup, map: mapFn, tileLayer, icon, latLng, polyline, popup };
  return { default: L, ...L };
});

// ---------------------------------------------------------------------------
// Imports AFTER the mock
// ---------------------------------------------------------------------------

import { MapCanvas } from "./MapCanvas.js";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function cat(
  id: string,
  zoomLevel: number,
  countryType: "ru" | "other",
  overrides: Partial<IMapMarker> = {},
): IMapMarker {
  return {
    id,
    lat: 55,
    lng: 37,
    style: "blue-small",
    label: id,
    tooltipPermanent: true,
    zoomLevel,
    countryType,
    ...overrides,
  };
}

function renderCanvas(
  markers: IMapMarker[],
  props: Partial<React.ComponentProps<typeof MapCanvas>> = {},
) {
  return render(
    <MapCanvas
      markers={markers}
      polylines={[]}
      tileUrl="https://tiles.example/{z}/{x}/{y}"
      {...props}
    />,
  );
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe("MapCanvas — legacy (flat) path", () => {
  beforeEach(() => resetLeafletMockState());

  it("renders flat markers without creating zoom-category layers", () => {
    renderCanvas([
      { id: "A", lat: 1, lng: 2, style: "blue-small", label: "A" },
    ]);
    expect(createdMarkers).toHaveLength(1);
    // Init creates: markersLayer, polylinesLayer, popupsLayer (3) + our 10 zoom layers + 1 highlight layer = 14
    // But only the flat path is exercised — zoom layers stay empty.
    // Existence of 14 LayerGroups is acceptable; flat marker must be somewhere.
    const allMarkers = createdLayerGroups.flatMap((l) => l._markers);
    expect(allMarkers).toContain(createdMarkers[0]);
  });
});

describe("MapCanvas — categorized: visibility predicate", () => {
  beforeEach(() => resetLeafletMockState());

  it("at zoom 3, shows only layers with zoomTier <= 3", () => {
    const markers = [
      cat("m2", 2, "ru"),
      cat("m3", 3, "ru"),
      cat("m5", 5, "ru"),
      cat("m6", 6, "ru"),
    ];
    renderCanvas(markers);

    const map = createdMaps[0]!;
    map.setZoom(3);
    map.fireZoomend();

    // After fireZoomend, layers with zoomTier > 3 should have been removed
    // (at least once) and layers with tier <= 3 should have been added.
    const addCalls = map.addLayer.mock.calls.length;
    const removeCalls = map.removeLayer.mock.calls.length;
    expect(addCalls).toBeGreaterThan(0);
    expect(removeCalls).toBeGreaterThan(0);
  });

  it("domestic=true hides countryType=\"other\" layers", () => {
    const markers = [cat("ru2", 2, "ru"), cat("other2", 2, "other")];
    renderCanvas(markers, { domestic: true });

    const map = createdMaps[0]!;
    map.setZoom(6);
    map.fireZoomend();

    const added: MockLayerGroup[] = map.addLayer.mock.calls.map((c) => c[0] as MockLayerGroup);
    const removed: MockLayerGroup[] = map.removeLayer.mock.calls.map((c) => c[0] as MockLayerGroup);

    // Every layer containing an "other" marker must have been removed at least once,
    // and never appear in the final "addLayer" sequence after being removed.
    const otherLayers = createdLayerGroups.filter((l) =>
      l._markers.some((m) => m.options.title === "other2"),
    );
    for (const l of otherLayers) {
      expect(removed).toContain(l);
    }
    // ru layer should have been added
    const ruLayer = createdLayerGroups.find((l) =>
      l._markers.some((m) => m.options.title === "ru2"),
    );
    expect(ruLayer).toBeDefined();
    expect(added).toContain(ruLayer!);
  });

  it("international=true hides countryType=\"ru\" layers", () => {
    const markers = [cat("ru2", 2, "ru"), cat("other2", 2, "other")];
    renderCanvas(markers, { international: true });

    const map = createdMaps[0]!;
    map.setZoom(6);
    map.fireZoomend();

    const added: MockLayerGroup[] = map.addLayer.mock.calls.map((c) => c[0] as MockLayerGroup);
    const removed: MockLayerGroup[] = map.removeLayer.mock.calls.map((c) => c[0] as MockLayerGroup);

    const ruLayers = createdLayerGroups.filter((l) =>
      l._markers.some((m) => m.options.title === "ru2"),
    );
    for (const l of ruLayers) {
      expect(removed).toContain(l);
    }
    const otherLayer = createdLayerGroups.find((l) =>
      l._markers.some((m) => m.options.title === "other2"),
    );
    expect(added).toContain(otherLayer!);
  });
});

describe("MapCanvas — categorized: highlight layer", () => {
  beforeEach(() => resetLeafletMockState());

  it("highlighted markers go into a distinct always-on layer with orange icon", () => {
    renderCanvas([cat("hi", 6, "ru", { highlighted: true })]);

    const highlightedMarker = createdMarkers[0]!;
    // Orange icon applied (icon object is non-null and was set via either construction or setIcon)
    expect(highlightedMarker.icon).toBeDefined();

    // Highlighted marker must be addLayer'd onto some layer group
    const owningLayer = createdLayerGroups.find((l) => l._markers.includes(highlightedMarker));
    expect(owningLayer).toBeDefined();

    // That layer was addTo'd to the map at init (always-on)
    const map = createdMaps[0]!;
    expect(map.addLayer.mock.calls.map((c) => c[0]).includes(owningLayer!) || owningLayer!.addTo.mock.calls.length > 0).toBe(true);
  });
});

describe("MapCanvas — tooltip visibility rules", () => {
  beforeEach(() => resetLeafletMockState());

  it("at zoom <= 3, closes non-highlighted tooltips", () => {
    const markers = [cat("a", 2, "ru"), cat("b", 2, "ru", { highlighted: true })];
    renderCanvas(markers);

    const map = createdMaps[0]!;
    map.setZoom(3);
    map.fireZoomend();

    const a = createdMarkers.find((m) => m.options.title === "a")!;
    const b = createdMarkers.find((m) => m.options.title === "b")!;
    expect(a.closeTooltip).toHaveBeenCalled();
    expect(b.closeTooltip).not.toHaveBeenCalled();
  });

  it("with >=2 highlighted markers, closes non-highlighted tooltips at any zoom", () => {
    const markers = [
      cat("a", 2, "ru"),
      cat("b", 2, "ru", { highlighted: true }),
      cat("c", 2, "ru", { highlighted: true }),
    ];
    renderCanvas(markers);

    const map = createdMaps[0]!;
    map.setZoom(5);
    map.fireZoomend();

    const a = createdMarkers.find((m) => m.options.title === "a")!;
    expect(a.closeTooltip).toHaveBeenCalled();
  });

  it("at zoom 4 with 0-1 highlighted, opens all tooltips", () => {
    const markers = [cat("a", 2, "ru"), cat("b", 2, "ru", { highlighted: true })];
    renderCanvas(markers);

    const map = createdMaps[0]!;
    map.setZoom(4);
    map.fireZoomend();

    const a = createdMarkers.find((m) => m.options.title === "a")!;
    const b = createdMarkers.find((m) => m.options.title === "b")!;
    expect(a.openTooltip).toHaveBeenCalled();
    expect(b.openTooltip).toHaveBeenCalled();
  });
});
```

- [ ] **Step 3.2: Run the failing tests**

Run: `pnpm vitest run src/features/flights-map/components/MapCanvas.test.tsx`
Expected: FAIL — the categorized code path and new props don't exist yet.

- [ ] **Step 3.3: Extend `MapCanvasProps` and add init-time highlight/zoom layers**

Open `src/features/flights-map/components/MapCanvas.tsx`. Replace the existing `MapCanvasProps` interface (around line 129) with:

```tsx
export interface MapCanvasProps {
  markers: IMapMarker[];
  polylines: IMapPolyline[];
  popups?: IMapPopup[];
  tileUrl: string;
  center?: [number, number];
  zoom?: number;
  minZoom?: number;
  maxZoom?: number;
  onMarkerClick?: (markerId: string) => void;
  className?: string;
  /** When true, hide countryType="other" markers. */
  domestic?: boolean;
  /** When true, hide countryType="ru" markers. */
  international?: boolean;
}
```

Update the `MapCanvas` component signature to destructure `domestic` and `international` (both default to `false`):

```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,
}) => {
```

Right after the existing refs (`markersLayerRef`, `polylinesLayerRef`, `popupsLayerRef`), add:

```tsx
// Categorized rendering: 10 zoom layers + 1 highlight layer + an id→marker index.
// Indexed as zoomLayersRef.current[countryIdx][zoomTier] where:
//   countryIdx: 0 = "ru", 1 = "other"
//   zoomTier: 2..6 (tiers outside this range clamp to 6)
const zoomLayersRef = useRef<L.LayerGroup[][] | null>(null);
const highlightLayerRef = useRef<L.LayerGroup | null>(null);
const markerIndexRef = useRef<Map<string, L.Marker>>(new Map());
const highlightedIdsRef = useRef<Set<string>>(new Set());

// Track latest toggles so the zoomend handler sees current values
const domesticRef = useRef(domestic);
domesticRef.current = domestic;
const internationalRef = useRef(international);
internationalRef.current = international;
```

- [ ] **Step 3.4: Wire layer construction and `zoomend` into the init effect**

In the init `useEffect` (the one that starts with `if (!containerRef.current || mapRef.current) return;` and creates the map / tile layer / `markersLayerRef` / `polylinesLayerRef` / `popupsLayerRef`), add the new layer groups and the zoom listener. Replace the block after the existing `popupsLayerRef.current = L.layerGroup().addTo(map);` and before the `mapRef.current = map;` line with:

```tsx
    markersLayerRef.current = L.layerGroup().addTo(map);
    polylinesLayerRef.current = L.layerGroup().addTo(map);
    popupsLayerRef.current = L.layerGroup().addTo(map);

    // Highlight layer — always on.
    highlightLayerRef.current = L.layerGroup().addTo(map);

    // Zoom layers — 2 country types × 5 tiers (2..6). NOT added to the map
    // yet; the visibility sync effect decides which to add based on current
    // zoom + toggles.
    const zoomLayers: L.LayerGroup[][] = [[], []];
    for (let c = 0; c < 2; c++) {
      for (let t = 0; t < 5; t++) {
        zoomLayers[c]![t] = L.layerGroup();
      }
    }
    zoomLayersRef.current = zoomLayers;

    // Rerun visibility + tooltip rules on every zoom change.
    map.on("zoomend", () => {
      syncVisibility();
      syncTooltips();
    });

    mapRef.current = map;
```

Update the cleanup block to clear the new refs:

```tsx
    return () => {
      map.remove();
      mapRef.current = null;
      markersLayerRef.current = null;
      polylinesLayerRef.current = null;
      popupsLayerRef.current = null;
      highlightLayerRef.current = null;
      zoomLayersRef.current = null;
      markerIndexRef.current = new Map();
      highlightedIdsRef.current = new Set();
    };
```

Because the init effect references `syncVisibility` and `syncTooltips`, define them as stable helpers **inside** the component (above the init effect). Place these immediately after the `onMarkerClickRef` / ref captures block and before the init effect:

```tsx
// ------- Helpers that read current refs (stable closures) -------

function syncVisibility(): void {
  const map = mapRef.current;
  const zoomLayers = zoomLayersRef.current;
  if (!map || !zoomLayers) return;

  const currentZoom = map.getZoom();
  const dom = domesticRef.current;
  const intl = internationalRef.current;

  for (let c = 0; c < 2; c++) {
    const countryType = c === 0 ? "ru" : "other";
    for (let t = 0; t < 5; t++) {
      const tier = t + 2;
      const layer = zoomLayers[c]![t]!;
      const inRange = tier <= currentZoom;
      const hiddenByIntl = intl && countryType === "ru";
      const hiddenByDom = dom && countryType === "other";
      const shouldShow = inRange && !hiddenByIntl && !hiddenByDom;
      if (shouldShow) {
        map.addLayer(layer);
      } else {
        map.removeLayer(layer);
      }
    }
  }
}

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;

  markerIndexRef.current.forEach((marker, id) => {
    if (closeNonHighlighted && !highlighted.has(id)) {
      marker.closeTooltip();
    } else if (!closeNonHighlighted) {
      marker.openTooltip();
    }
  });
}
```

(These are declared with `function` so they hoist above the init effect and can be referenced by `map.on("zoomend", ...)`. They close over refs only, so no dependency issues.)

- [ ] **Step 3.5: Rewrite the marker-sync effect to route categorized vs. flat markers**

Replace the existing `syncMarkers` callback and its `useEffect` with:

```tsx
const syncMarkers = useCallback(() => {
  const flatLayer = markersLayerRef.current;
  const zoomLayers = zoomLayersRef.current;
  const highlightLayer = highlightLayerRef.current;
  if (!flatLayer || !zoomLayers || !highlightLayer) return;

  // Clear everything before re-adding.
  flatLayer.clearLayers();
  highlightLayer.clearLayers();
  for (let c = 0; c < 2; c++) {
    for (let t = 0; t < 5; t++) {
      zoomLayers[c]![t]!.clearLayers();
    }
  }
  markerIndexRef.current = new Map();
  highlightedIdsRef.current = new Set();

  for (const m of markers) {
    const isCategorized =
      m.zoomLevel !== undefined && m.countryType !== undefined;

    const iconStyle: MarkerStyle = m.highlighted ? "orange" : m.style;
    const marker = L.marker([m.lat, m.lng], {
      icon: getIcon(iconStyle),
      title: m.id,
    });

    if (m.label) {
      marker.bindTooltip(m.label, {
        permanent: m.tooltipPermanent ?? false,
        direction: "top",
        className: "city-label",
      });
    }

    marker.on("click", () => {
      onMarkerClickRef.current?.(m.id);
    });

    markerIndexRef.current.set(m.id, marker);

    if (m.highlighted) {
      highlightedIdsRef.current.add(m.id);
      marker.addTo(highlightLayer);
    } else if (isCategorized) {
      const countryIdx = m.countryType === "ru" ? 0 : 1;
      const rawTier = m.zoomLevel!;
      const tier = rawTier >= 2 && rawTier <= 6 ? rawTier : 6;
      const tierIdx = tier - 2;
      marker.addTo(zoomLayers[countryIdx]![tierIdx]!);
    } else {
      marker.addTo(flatLayer);
    }
  }

  syncVisibility();
  syncTooltips();
}, [markers]);

useEffect(() => {
  syncMarkers();
}, [syncMarkers]);
```

- [ ] **Step 3.6: Add a visibility-sync effect that reacts to toggle changes**

Add this effect AFTER the existing marker/polyline/popup sync effects:

```tsx
useEffect(() => {
  syncVisibility();
  syncTooltips();
}, [domestic, international]);
```

- [ ] **Step 3.7: Run MapCanvas tests**

Run: `pnpm vitest run src/features/flights-map/components/MapCanvas.test.tsx`
Expected: PASS — 8 tests.

- [ ] **Step 3.8: Run full typecheck**

Run: `pnpm tsc --noEmit`
Expected: no output.

- [ ] **Step 3.9: Run full suite (no regressions)**

Run: `pnpm vitest run`
Expected: PASS.

- [ ] **Step 3.10: Commit**

```bash
git add src/features/flights-map/components/MapCanvas.tsx src/features/flights-map/components/MapCanvas.test.tsx
git commit -m "Add categorized rendering to MapCanvas: zoom-tier layers, highlight layer, tooltip rules"
```

---

## Task 4: Wire `FlightsMapStartPage` to build real markers

**Files:**
- Modify: `src/features/flights-map/components/FlightsMapStartPage.tsx`

- [ ] **Step 4.1: Import cityCategory and surface the dictionaries object**

Open `src/features/flights-map/components/FlightsMapStartPage.tsx`.

Add the imports near the existing `@/` imports (before `import type { IFlightsMapFilterState, ... }`):

```tsx
import { getCityZoomLevel } from "../cityCategory.js";
```

Find the existing destructure of `useDictionaries`:

```tsx
const {
  dictionaries: _dictionaries,
  loading: dictionariesLoading,
  error: dictionariesError,
} = useDictionaries(lang);
```

Replace with (drop the underscore — we now consume it):

```tsx
const {
  dictionaries,
  loading: dictionariesLoading,
  error: dictionariesError,
} = useDictionaries(lang);
```

- [ ] **Step 4.2: Replace the empty markers/polylines placeholders**

Find the existing block:

```tsx
// Build markers and polylines from routes (placeholder -- real city
// coordinates come from a dictionaries service in a future iteration)
const markers = useMemo<IMapMarker[]>(() => [], []);
const polylines = useMemo<IMapPolyline[]>(() => [], []);
```

Replace with:

```tsx
const markers = useMemo<IMapMarker[]>(() => {
  if (!dictionaries) return [];

  return dictionaries.cities
    .filter(
      (c) =>
        typeof c.location?.lat === "number" &&
        typeof c.location?.lon === "number",
    )
    .map((city) => {
      const isHighlighted =
        city.code === filterState.departure ||
        city.code === filterState.arrival;
      return {
        id: city.code,
        lat: city.location.lat,
        lng: city.location.lon,
        label: city.name,
        tooltipPermanent: true,
        style: isHighlighted ? "orange" : "blue-small",
        zoomLevel: getCityZoomLevel(city.code),
        countryType: city.country_code === "RU" ? "ru" : "other",
        highlighted: isHighlighted,
      };
    });
}, [dictionaries, filterState.departure, filterState.arrival]);

const polylines = useMemo<IMapPolyline[]>(() => [], []);
```

- [ ] **Step 4.3: Pass `domestic` and `international` props to MapCanvas**

Find the existing `<MapCanvas ... />` element and add the two new props:

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

- [ ] **Step 4.4: Typecheck**

Run: `pnpm tsc --noEmit`
Expected: no output.

- [ ] **Step 4.5: Full suite (expect existing page tests still pass because MapCanvas is mocked)**

Run: `pnpm vitest run`
Expected: PASS.

- [ ] **Step 4.6: Commit**

```bash
git add src/features/flights-map/components/FlightsMapStartPage.tsx
git commit -m "Populate FlightsMapStartPage markers from dictionaries with zoom tiers"
```

---

## Task 5: Integration tests for `FlightsMapStartPage` marker wiring

**Files:**
- Modify: `src/features/flights-map/components/FlightsMapStartPage.test.tsx`

- [ ] **Step 5.1: Append the new mock + tests**

Open `src/features/flights-map/components/FlightsMapStartPage.test.tsx`.

Locate the existing `vi.mock("./MapCanvas.js", ...)` block (it returns a `<div data-testid="map-canvas" />`). Replace it with a capturing mock:

```tsx
let lastMapCanvasProps: Record<string, unknown> | null = null;
vi.mock("./MapCanvas.js", () => ({
  MapCanvas: (props: Record<string, unknown>) => {
    lastMapCanvasProps = props;
    return <div data-testid="map-canvas" />;
  },
}));
```

Locate the existing `vi.mock("@/shared/dictionaries/index.js", ...)` block. Update the fixture-friendly shape:

```tsx
const dictState: {
  dictionaries:
    | null
    | {
        cities: Array<{
          code: string;
          name: string;
          country_code: string;
          location: { lat: number; lon: number };
        }>;
      };
  loading: boolean;
  error: Error | null;
} = {
  dictionaries: null,
  loading: true,
  error: null,
};
vi.mock("@/shared/dictionaries/index.js", () => ({
  useDictionaries: () => dictState,
}));
```

Append a new describe block at the end of the file:

```tsx
describe("FlightsMapStartPage — markers from dictionaries", () => {
  beforeEach(() => {
    lastMapCanvasProps = null;
    dictState.dictionaries = null;
    dictState.loading = false;
    dictState.error = null;
  });

  it("maps cities to IMapMarker[] with zoomLevel and countryType", () => {
    dictState.dictionaries = {
      cities: [
        {
          code: "MOW",
          name: "Москва",
          country_code: "RU",
          location: { lat: 55, lon: 37 },
        },
        {
          code: "PAR",
          name: "Париж",
          country_code: "FR",
          location: { lat: 48, lon: 2 },
        },
      ],
    };

    render(<FlightsMapStartPage />);

    const markers = lastMapCanvasProps!["markers"] as Array<Record<string, unknown>>;
    expect(markers).toHaveLength(2);

    const mow = markers.find((m) => m["id"] === "MOW")!;
    expect(mow["countryType"]).toBe("ru");
    expect(mow["zoomLevel"]).toBe(2); // POPULATION_1KK
    expect(mow["label"]).toBe("Москва");

    const par = markers.find((m) => m["id"] === "PAR")!;
    expect(par["countryType"]).toBe("other");
    expect(par["zoomLevel"]).toBe(6); // default tier (unknown code)
  });

  it("drops cities whose location is missing or invalid", () => {
    dictState.dictionaries = {
      cities: [
        { code: "MOW", name: "Москва", country_code: "RU", location: { lat: 55, lon: 37 } },
        { code: "BAD", name: "Bad", country_code: "RU", location: { lat: Number.NaN, lon: 0 } },
      ],
    };

    render(<FlightsMapStartPage />);

    const markers = lastMapCanvasProps!["markers"] as Array<Record<string, unknown>>;
    expect(markers.map((m) => m["id"])).toEqual(["MOW"]);
  });

  it("passes domestic/international toggles through to MapCanvas", () => {
    dictState.dictionaries = { cities: [] };

    render(<FlightsMapStartPage />);

    // Both toggles default to false in initial filter state
    expect(lastMapCanvasProps!["domestic"]).toBe(false);
    expect(lastMapCanvasProps!["international"]).toBe(false);
  });
});
```

Note: the "drops cities whose location is missing or invalid" test relies on `Number.isNaN` semantics in the `typeof ... === "number"` guard. `typeof Number.NaN === "number"` is `true` — so the filter in `FlightsMapStartPage.tsx` would not drop a NaN marker by that check alone. If the test fails for this reason, tighten the filter:

```tsx
.filter(
  (c) =>
    typeof c.location?.lat === "number" &&
    typeof c.location?.lon === "number" &&
    Number.isFinite(c.location.lat) &&
    Number.isFinite(c.location.lon),
)
```

Adjust in `FlightsMapStartPage.tsx` if that test fails — do not weaken the test.

- [ ] **Step 5.2: Run the tests**

Run: `pnpm vitest run src/features/flights-map/components/FlightsMapStartPage.test.tsx`
Expected: PASS — all prior tests + the 3 new ones.

- [ ] **Step 5.3: Run full suite**

Run: `pnpm vitest run`
Expected: PASS.

- [ ] **Step 5.4: Commit**

```bash
git add src/features/flights-map/components/FlightsMapStartPage.test.tsx src/features/flights-map/components/FlightsMapStartPage.tsx
git commit -m "Test FlightsMapStartPage marker construction from dictionaries"
```

(The commit may include `FlightsMapStartPage.tsx` if step 5.1's `Number.isFinite` tightening was needed — that's fine; both belong in the same logical change.)

---

## 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: PASS. Count should be +25 over the post-C.1 baseline (14 cityCategory + 8 MapCanvas + 3 FlightsMapStartPage).

- [ ] **Step 6.3: Scoped test run**

Run: `pnpm vitest run src/features/flights-map/`
Expected: all feature tests pass.

---

## Self-Review Log

Ran against the spec:

- **Spec coverage.**
  - `cityCategory.ts` port → Task 1.
  - `IMapMarker` extension → Task 2.
  - MapCanvas 10-layer construction, highlight layer, `zoomend` wiring, `domestic`/`international` props → Task 3.
  - Tooltip visibility rules → Task 3 (tests) + `syncTooltips` helper (impl).
  - `FlightsMapStartPage` markers from dictionaries + missing-location skip → Task 4.
  - Page-level integration tests (mapping, filter skip, toggle flow-through) → Task 5.
  - Final verification → Task 6.
- **Placeholders.** None.
- **Type consistency.** `IMapMarker` shape matches the spec (three optional fields). `MapCanvasProps` additions match (`domestic`, `international`). Helper signatures in MapCanvas (`syncVisibility`, `syncTooltips`, `syncMarkers`) are self-consistent. `countryType` uses `"ru" | "other"` exactly as in the spec. Layer index math (`countryIdx` 0/1, `tier = t + 2`) is used identically in construction and visibility predicate.
- **Known trade-off.** The marker-sync step clears and rebuilds all 10 zoom layers + highlight layer + markerIndex on every `markers` change. For N≈2000 cities, that's cheap (sub-ms) but not incremental. Angular does the same pattern. If perf becomes an issue in C.3+, we can switch to diffing; out of scope here.
