# 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 === 30`, `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.
