From 13bb96fdec0d84de61a9c0adcfb5c20bfb861921 Mon Sep 17 00:00:00 2001 From: gnezim Date: Fri, 17 Apr 2026 08:27:38 +0300 Subject: [PATCH] Add design spec for Flights Map C.2 (Markers + Zoom Tiers) Captures the markers-from-dictionaries + zoom-visibility scope: cityCategory.ts port, IMapMarker extension, MapCanvas 10-LayerGroup bookkeeping, highlighted-layer, domestic/international toggles, and the Angular-parity tooltip visibility rules. --- ...4-17-flights-map-c2-markers-zoom-design.md | 267 ++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-17-flights-map-c2-markers-zoom-design.md diff --git a/docs/superpowers/specs/2026-04-17-flights-map-c2-markers-zoom-design.md b/docs/superpowers/specs/2026-04-17-flights-map-c2-markers-zoom-design.md new file mode 100644 index 00000000..f9404548 --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-flights-map-c2-markers-zoom-design.md @@ -0,0 +1,267 @@ +# Flights Map C.2: Markers + Zoom Tiers — Design + +**Date:** 2026-04-17 +**Author:** brainstorming session +**Scope:** Sub-feature C.2 of Gap C (Flights Map rebuild) +**Status:** Approved +**Depends on:** C.1 (dictionaries) — landed. + +## Goal + +Render every city in the dictionaries as a marker on the map with population-tier-based zoom visibility, partitioned into RU vs. non-RU layer groups for the domestic/international toggles. Highlight selected departure/arrival in orange. Apply Angular-parity tooltip visibility rules. Do not draw any routes yet (C.3). + +## Non-Goals + +- Drawing polylines / routes / spider mode (C.3). +- Popups and buy-ticket links (C.4). +- Client-side route filtering, auto-fallback, calendar, exchange, geolocation (C.4, C.5). +- Sharing `CityCategoryService` data with other features. +- Typeahead / search-box city lookup using the dictionaries. + +## Architecture + +Three modules change; one is new. + +``` +src/features/flights-map/ +├── cityCategory.ts NEW (pure, no deps) +├── cityCategory.test.ts NEW +├── types.ts MODIFY (extend IMapMarker) +└── components/ + ├── MapCanvas.tsx MODIFY (10-layer bookkeeping, tooltip rules) + ├── MapCanvas.test.tsx NEW (fresh Leaflet mock) + ├── FlightsMapStartPage.tsx MODIFY (build markers[], pass toggles) + └── FlightsMapStartPage.test.tsx MODIFY (append integration tests) +``` + +`cityCategory.ts` is feature-local: the city → zoom-tier data belongs to the map feature and nothing else consumes it. + +`MapCanvas` keeps being the only file that imports `leaflet` — per project contract. + +## `cityCategory.ts` + +Pure module with four Sets and one lookup function, direct port of Angular `CityCategoryService`: + +```ts +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", +]); + +/** + * Returns the minimum map zoom at which this city should be rendered. + * - 1M+ population and popular resorts: always visible (zoom 2+). + * - 500k population: visible at zoom 5+. + * - 100k population and everything else: visible only at max zoom (6). + */ +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; +} +``` + +## `types.ts` extensions + +`IMapMarker` gets three optional fields. Existing consumers that don't set them keep working unchanged: + +```ts +export interface IMapMarker { + id: string; + lat: number; + lng: number; + style: MarkerStyle; + label?: string | undefined; + tooltipPermanent?: boolean | undefined; + + // NEW: when all three are set, MapCanvas enters categorized-rendering mode. + zoomLevel?: number | undefined; + countryType?: "ru" | "other" | undefined; + highlighted?: boolean | undefined; +} +``` + +`MapCanvasProps` gains two optional booleans: + +```ts +export interface MapCanvasProps { + // ...existing props + domestic?: boolean; // when true, hide countryType="other" markers + international?: boolean; // when true, hide countryType="ru" markers +} +``` + +## `MapCanvas` changes + +Internal state (via `useRef`): + +```ts +zoomLayersRef: L.LayerGroup[][]; // zoomLayersRef[countryIdx][zoomTier] + // countryIdx: 0 = ru, 1 = other + // zoomTier: 2,3,4,5,6 +highlightLayerRef: L.LayerGroup; // always-on +markerIndexRef: Map; // id → marker +highlightedIdsRef: Set; // currently-highlighted ids +``` + +Lifecycle: + +1. **Init effect** (unchanged for the legacy path). Additionally constructs: + - `highlightLayerRef = L.layerGroup().addTo(map)`. + - For each of `{"ru", "other"}` × `{2, 3, 4, 5, 6}`: an empty `L.layerGroup()` (NOT added to map yet — `updateVisibility` adds them based on current zoom). Stored in `zoomLayersRef`. + - Wires `map.on("zoomend", updateVisibility)`. + +2. **Marker sync effect** (re-runs when `markers` changes): + - Clear all 10 zoom layers and the highlight layer. + - Clear `markerIndexRef` and `highlightedIdsRef`. + - For each marker: + - Construct `L.marker` with icon per `style`, click handler per `onMarkerClick`, bound tooltip per `label`/`tooltipPermanent`. + - Store in `markerIndexRef.set(m.id, marker)`. + - If `m.highlighted === true`: force icon to `"orange"`, add to `highlightLayerRef`, add id to `highlightedIdsRef`. + - Else if `m.zoomLevel !== undefined && m.countryType !== undefined`: add to `zoomLayersRef[countryIdx][zoomLevel]` (clamp `zoomLevel` to `[2, 6]`; values outside that range go into tier 6). + - Else (legacy): add to the existing flat markers layer (unchanged behavior). + - After all markers placed: call `updateVisibility()` then `updateTooltipVisibility()`. + +3. **Visibility sync effect** (re-runs when `markers`, `domestic`, or `international` change): + - Walk `zoomLayersRef` — for each layer at `(countryType, zoomTier)`: + - `shouldShow = zoomTier <= map.getZoom() && !(international && countryType==="ru") && !(domestic && countryType==="other")`. + - `shouldShow ? map.addLayer(layer) : map.removeLayer(layer)`. + - `map.addLayer(highlightLayerRef)` is unconditional (already added at init, but idempotent). + +4. **Tooltip visibility helper** `updateTooltipVisibility()`: + - `currentZoom = map.getZoom()`. + - If `currentZoom <= 3 || highlightedIdsRef.size >= 2`: for every marker in `markerIndexRef`, if it's not in `highlightedIdsRef`, call `marker.closeTooltip()`. + - Otherwise: for every marker in `markerIndexRef`, call `marker.openTooltip()`. + +5. **`zoomend` handler** calls both `updateVisibility` and `updateTooltipVisibility` in sequence. + +The legacy single-layer flat path stays intact — if no markers have `zoomLevel`/`countryType`, the 10 new layers just stay empty and no `zoomend` bookkeeping triggers anything visible. Backward-compatible. + +## `FlightsMapStartPage` changes + +Replace the placeholder `markers = useMemo(() => [], [])` with the real computation. Drop cities without a valid location, matching Angular. + +```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]); +``` + +Unwrap `dictionaries` from the `useDictionaries` destructure (previously `_dictionaries` placeholder). Pass the two new props to `MapCanvas`: + +```tsx + +``` + +## Error handling + +- **`dictionaries === null`** (loading or fetch error) → `markers = []`. MapCanvas renders an empty map. Loader / error banner already rendered by page. +- **Unknown city code** in `getCityZoomLevel` → defaults to tier 6 (least-visible). Matches Angular. +- **City with missing `location`** → skipped at the `.filter(...)` step above. Angular does the same (`if (city.location?.lat == null || city.location?.lon == null) return;`). +- **`zoomLevel` outside [2, 6]** on a marker → clamp into tier 6 rather than throw. (Defense against future callers.) +- **`highlighted=true` marker with out-of-range `zoomLevel`** → still goes into the always-on highlight layer. Behavior preserved. +- **Both `domestic` and `international` true** → empty map (same degenerate behavior as Angular). + +## Testing + +Three test files. + +### `cityCategory.test.ts` — pure, no DOM + +- Spot-check members of each set (e.g., `POPULATION_1KK.has("MOW")`, `POPULAR_RESORTS.has("DXB")`) as regression guards against accidental edits. +- `getCityZoomLevel("MOW")` → `2` (1kk). +- `getCityZoomLevel("DXB")` → `2` (resort). +- `getCityZoomLevel("TJM")` → `5` (500k). +- `getCityZoomLevel("MMK")` → `6` (100k). +- `getCityZoomLevel("UNKNOWN")` → `6` (default). +- `POPULATION_1KK.size === 29`, `POPULATION_500K.size === 23`, `POPULATION_100K.size === 39`, `POPULAR_RESORTS.size === 15` — exact sizes (tight couplings to the Angular source; any delta is intentional and must update this expectation). + +### `MapCanvas.test.tsx` — jsdom + +Builds a minimal Leaflet mock via `vi.mock("leaflet", () => ...)`. The mock exports factory functions that return vi-backed stubs tracking `.addTo`, `.on`, `.remove`, `.openTooltip`, `.closeTooltip`, `.clearLayers`, `.setIcon`, `.bindTooltip`, `.addLayer`, `.removeLayer`, `.getZoom`, `.setLatLng`, `.setContent`. The mock is local to this test file. + +Tests: + +- **Legacy flat path.** Render with markers missing `zoomLevel`/`countryType` → single markers layer receives all markers; no zoom-layer bookkeeping triggers add/remove. +- **Layer construction.** Render with at least one categorized marker → `L.layerGroup()` called 10+ times (10 zoom layers + highlight + markers + polylines + popups). +- **Visibility at zoom 3.** `getZoom()` returns 3 → `addLayer` called for each zoom-layer with `zoomTier <= 3`; `removeLayer` called for `zoomTier > 3`. +- **Visibility at zoom 6.** All 10 layers added (assuming neither filter is on). +- **`domestic=true`.** `addLayer` NOT called for `other/*` layers; IS called for `ru/*` layers whose tier ≤ current zoom. +- **`international=true`.** Mirror — no `ru/*`, yes `other/*`. +- **Highlight.** Marker with `highlighted=true` is added to the highlight layer; highlight layer is added to the map. +- **Tooltip rule at zoom 3.** Non-highlighted markers' `.closeTooltip` called. +- **Tooltip rule with 2 highlighted markers.** Same — non-highlighted markers' `.closeTooltip` called. +- **Tooltip rule at zoom 4 with 0 highlighted.** All markers' `.openTooltip` called; no `.closeTooltip`. +- **`zoomend` rewires.** Simulate calling the captured `zoomend` handler after `getZoom()` returns a new value → `addLayer`/`removeLayer` called per updated predicate. + +### `FlightsMapStartPage.test.tsx` — append + +Tests the page-level plumbing using a mocked MapCanvas: + +- **Markers from dictionaries.** Mock `useDictionaries` to return 2 cities (1 RU, 1 non-RU). Capture MapCanvas props via `vi.mock` factory; assert `markers.length === 2`, assert `countryType` values, `zoomLevel` set, `label` set. +- **Missing-location city skipped.** Mock `useDictionaries` to return 1 city with `{ lat: NaN, lon: 0 }` (or missing fields) → that city is not in the passed markers. +- **`domestic` / `international` flow through.** Pass these in the filter state → captured props reflect them. +- **Departure click highlights.** Click a marker via the captured `onMarkerClick`, then re-render → the corresponding marker now has `highlighted: true` and `style: "orange"`. + +## Success Criteria + +- `pnpm tsc --noEmit` clean. +- All new tests pass (~25 new). +- Full vitest suite green (no regressions). +- `FlightsMapStartPage` renders zero visual changes in a dictionaries-loading state (empty map still shows the loader). +- Once dictionaries resolve, the map renders N markers where N is the dictionary city count (minus skipped ones). Visible set depends on zoom + toggles per the predicate. This is not visually asserted in unit tests — it's asserted via the per-predicate tests above plus a manual browser check at implementation time. + +## Deferred to later sub-features + +- C.3: `polylines` prop is already wired; route-drawing hooks into it by mapping API route responses to polylines using `dictionaries.cityByCode` + `airportByCode`. The `markerIndexRef` structure prepared here is useful then. +- C.3: Intermediate city tooltip forcing (`updateIntermediateTooltip`) — needs route data, not markers. +- C.4: Popups (departure/arrival buy-ticket HTML). +- C.5: Geolocation default departure; exchange button; date picker disabled-days integration.