diff --git a/docs/superpowers/plans/2026-04-17-flights-map-c2-markers-zoom.md b/docs/superpowers/plans/2026-04-17-flights-map-c2-markers-zoom.md new file mode 100644 index 00000000..42881c4c --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-flights-map-c2-markers-zoom.md @@ -0,0 +1,1190 @@ +# 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 = 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 = 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 = 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 = 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; + on: ReturnType; + bindTooltip: ReturnType; + openTooltip: ReturnType; + closeTooltip: ReturnType; + setIcon: ReturnType; + setLatLng: ReturnType; + getLatLng: ReturnType; + options: { title?: string }; +} + +interface MockLayerGroup { + _id: number; + _markers: MockMarker[]; + addTo: ReturnType; + addLayer: ReturnType; + removeLayer: ReturnType; + clearLayers: ReturnType; + hasLayer: ReturnType; +} + +interface MockMap { + _zoom: number; + _eventHandlers: Record void>>; + _addedLayers: Set; + on: ReturnType; + remove: ReturnType; + addLayer: ReturnType; + removeLayer: ReturnType; + hasLayer: ReturnType; + getZoom: ReturnType; + 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(), + 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 { + return { + id, + lat: 55, + lng: 37, + style: "blue-small", + label: id, + tooltipPermanent: true, + zoomLevel, + countryType, + ...overrides, + }; +} + +function renderCanvas( + markers: IMapMarker[], + props: Partial> = {}, +) { + return render( + , + ); +} + +// --------------------------------------------------------------------------- +// 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 = ({ + 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(null); +const highlightLayerRef = useRef(null); +const markerIndexRef = useRef>(new Map()); +const highlightedIdsRef = useRef>(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(() => [], []); +const polylines = useMemo(() => [], []); +``` + +Replace with: + +```tsx +const markers = useMemo(() => { + 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(() => [], []); +``` + +- [ ] **Step 4.3: Pass `domestic` and `international` props to MapCanvas** + +Find the existing `` element and add the two new props: + +```tsx + +``` + +- [ ] **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 `
`). Replace it with a capturing mock: + +```tsx +let lastMapCanvasProps: Record | null = null; +vi.mock("./MapCanvas.js", () => ({ + MapCanvas: (props: Record) => { + lastMapCanvasProps = props; + return
; + }, +})); +``` + +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(); + + const markers = lastMapCanvasProps!["markers"] as Array>; + 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(); + + const markers = lastMapCanvasProps!["markers"] as Array>; + expect(markers.map((m) => m["id"])).toEqual(["MOW"]); + }); + + it("passes domestic/international toggles through to MapCanvas", () => { + dictState.dictionaries = { cities: [] }; + + render(); + + // 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.