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.
This commit is contained in:
@@ -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<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",
|
||||
]);
|
||||
|
||||
/**
|
||||
* 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<string, L.Marker>; // id → marker
|
||||
highlightedIdsRef: Set<string>; // 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<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]);
|
||||
```
|
||||
|
||||
Unwrap `dictionaries` from the `useDictionaries` destructure (previously `_dictionaries` placeholder). Pass the two new props to `MapCanvas`:
|
||||
|
||||
```tsx
|
||||
<MapCanvas
|
||||
markers={markers}
|
||||
polylines={polylines}
|
||||
tileUrl={tileUrl}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
className="flights-map-start__map"
|
||||
domestic={filterState.domestic}
|
||||
international={filterState.international}
|
||||
/>
|
||||
```
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user