# Flights Map C.4: Popups + Filtering + Auto-Fallback — Design

**Date:** 2026-04-17
**Author:** brainstorming session
**Scope:** Sub-feature C.4 of Gap C (Flights Map rebuild)
**Status:** Approved
**Depends on:** C.1 (dictionaries), C.2 (markers + zoom), C.3 (polylines) — all landed.

## Goal

Close the remaining gaps between the Angular flights-map body component and the React port by adding: client-side route filtering (`filterRoutes`), auto-fallback when direct routes are empty (retry with `connections=1` + mirror into UI toggle), departure/arrival buy-ticket popups (route mode only), and airport→city-code normalization in the polyline pipeline so that API responses containing airport codes render correctly.

## Non-Goals

- Date picker UI wiring, exchange button, geolocation-based default departure → **C.5**.
- Any UX/UI polish to the existing popup HTML beyond Angular parity.
- Localization of the buy-ticket link (Russian-locale URL is hardcoded in Angular; matches).

## Architecture

Four pieces:

```
src/features/flights-map/
├── filterRoutes.ts                       NEW (pure)
├── filterRoutes.test.ts                  NEW
├── buyTicketUrl.ts                       NEW (pure)
├── buyTicketUrl.test.ts                  NEW
├── routesToPolylines.ts                  MODIFY (normalize airport→city codes)
├── routesToPolylines.test.ts             MODIFY (pass dictionaries arg; add normalization tests)
└── components/
    ├── FlightsMapStartPage.tsx           MODIFY (filterRoutes memo, auto-fallback, popups memo)
    └── FlightsMapStartPage.test.tsx      MODIFY (append 6 integration tests)
```

`MapCanvas.tsx` is **unchanged** — its popup sync effect already handles the `popups` prop correctly; nothing about polylines/markers needs new work.

The four scope items are orthogonal enough to implement independently; they land together because the auto-fallback and popup construction both depend on `filteredRoutes`.

## `filterRoutes.ts`

```ts
import type { IDictionaries } from "@/shared/dictionaries/index.js";
import { getCityCodeByAirportCode } from "@/shared/dictionaries/index.js";
import type { IFlightRoute, IFlightsMapFilterState } from "./types.js";

/**
 * Client-side filter matching Angular `filterRoutes`.
 *
 * - domestic && !international → keep routes where EVERY city is RU.
 * - international && !domestic → keep routes where SOME city is non-RU.
 * - connections → keep only non-direct routes.
 * - both domestic && international → no domestic/intl filter (show all).
 *
 * Route entries may contain either city codes or airport codes; this function
 * normalizes each to its city code via the dictionaries before set-membership
 * checks.
 */
export function filterRoutes(
  routes: IFlightRoute[],
  filter: Pick<IFlightsMapFilterState, "domestic" | "international" | "connections">,
  dictionaries: IDictionaries,
): IFlightRoute[] {
  const { domestic, international, connections } = filter;

  const toCityCode = (code: string): string =>
    getCityCodeByAirportCode(dictionaries, code) ?? code;

  const isDomestic = (r: IFlightRoute): boolean =>
    r.route.every((code) => dictionaries.ruCityCodes.has(toCityCode(code)));

  const isInternational = (r: IFlightRoute): boolean =>
    r.route.some((code) => dictionaries.otherCityCodes.has(toCityCode(code)));

  const hasConnections = (r: IFlightRoute): boolean => !r.isDirect;

  const predicates: Array<(r: IFlightRoute) => boolean> = [];

  if (domestic && !international) predicates.push(isDomestic);
  else if (international && !domestic) predicates.push(isInternational);

  if (connections) predicates.push(hasConnections);

  if (predicates.length === 0) return routes;
  return routes.filter((r) => predicates.every((p) => p(r)));
}
```

## `buyTicketUrl.ts`

```ts
const BASE = "https://www.aeroflot.ru/sb/app/ru-ru#/search";
const FIXED_PARAMS =
  "adults=1&cabin=economy&children=0&infants=0&autosearch=Y" +
  "&utm_source=aflwebbot&utm_medium=referral" +
  "&utm_campaign=ref_3015_general_rf_button.index__all_flight.map";

/**
 * Buy-ticket URL for the arrival popup. Mirrors Angular `getLink`.
 *
 * @param departure 3-letter city code (e.g. "MOW").
 * @param arrival 3-letter city code (e.g. "LED").
 * @param date YYYYMMDD string — typically filter.date or today.
 */
export function buildBuyTicketUrl(
  departure: string,
  arrival: string,
  date: string,
): string {
  return `${BASE}?${FIXED_PARAMS}&routes=${departure}.${date}.${arrival}`;
}
```

An `escapeHtml(str)` utility is co-located in the same file (prevents HTML injection via city names):

```ts
export function escapeHtml(s: string): string {
  return s
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}
```

## `routesToPolylines.ts` changes

Both exported functions gain a `dictionaries: IDictionaries` parameter and normalize airport codes to city codes before using them:

```ts
import type { IDictionaries } from "@/shared/dictionaries/index.js";
import { getCityCodeByAirportCode } from "@/shared/dictionaries/index.js";
// ...existing imports

export function routesToPolylines(
  routes: IFlightRoute[],
  filterState: Pick<IFlightsMapFilterState, "departure" | "arrival">,
  dictionaries: IDictionaries,
): IMapPolyline[] {
  if (routes.length === 0) return [];

  const toCity = (code: string): string =>
    getCityCodeByAirportCode(dictionaries, code) ?? code;

  const hasDeparture = Boolean(filterState.departure);
  const hasArrival = Boolean(filterState.arrival);
  const isSpiderMode = hasDeparture && !hasArrival;

  if (isSpiderMode) {
    const fromCode = toCity(filterState.departure!);
    const destCodes = new Set<string>();
    for (const r of routes) {
      if (r.route.length > 1) {
        const dest = toCity(r.route[r.route.length - 1]!);
        if (dest !== fromCode) destCodes.add(dest);
      }
    }
    return [...destCodes].map((dest) => ({
      id: `spider-${fromCode}-${dest}`,
      cityIds: [fromCode, dest],
      style: "direct",
    }));
  }

  return routes.map((r, i) => {
    const normalized = r.route.map(toCity);
    return {
      id: `route-${i}-${normalized.join("-")}`,
      cityIds: normalized,
      style: r.isDirect ? "direct" : "connecting",
    };
  });
}

export function intermediateCityIds(
  routes: IFlightRoute[],
  dictionaries: IDictionaries,
): string[] {
  const toCity = (code: string): string =>
    getCityCodeByAirportCode(dictionaries, code) ?? code;

  const ids = new Set<string>();
  for (const r of routes) {
    if (r.route.length <= 2) continue;
    for (let i = 1; i < r.route.length - 1; i++) ids.add(toCity(r.route[i]!));
  }
  return [...ids];
}
```

All existing callers and tests pass a dictionaries fixture; tests are updated accordingly.

## `FlightsMapStartPage.tsx` wiring

### Imports

```tsx
import { filterRoutes } from "../filterRoutes.js";
import { buildBuyTicketUrl, escapeHtml } from "../buyTicketUrl.js";
import { routesToPolylines, intermediateCityIds } from "../routesToPolylines.js";
import { getCityCodeByAirportCode } from "@/shared/dictionaries/index.js";
```

### `effectiveConnections` state + auto-fallback

```tsx
const [effectiveConnections, setEffectiveConnections] = useState<0 | 1>(
  filterState.connections ? 1 : 0,
);

// Sync user toggle → effective.
useEffect(() => {
  setEffectiveConnections(filterState.connections ? 1 : 0);
}, [filterState.connections]);

const searchParams = useMemo<FlightsMapSearchParams | null>(() => {
  if (!filterState.departure) return null;
  const today = todayYyyymmdd();
  return {
    departure: filterState.departure,
    arrival: filterState.arrival,
    dateFrom: today,
    dateTo: addMonthsYyyymmdd(today, 6),
    connections: effectiveConnections,
  };
}, [filterState.departure, filterState.arrival, effectiveConnections]);

const { routes, loading, error } = useFlightsMapSearch(searchParams);

// Auto-fallback: empty result, route mode, connections=0 → retry with 1.
useEffect(() => {
  if (loading || error) return;
  if (effectiveConnections !== 0) return;
  if (!filterState.departure || !filterState.arrival) return;
  if (routes.length > 0) return;
  setEffectiveConnections(1);
}, [
  loading,
  error,
  effectiveConnections,
  filterState.departure,
  filterState.arrival,
  routes,
]);

// Mirror fallback back into UI toggle once.
useEffect(() => {
  if (effectiveConnections === 1 && !filterState.connections) {
    setFilterState((prev) => ({ ...prev, connections: true }));
  }
}, [effectiveConnections, filterState.connections]);
```

### Filtered routes + polylines + intermediates

```tsx
const filteredRoutes = useMemo<IFlightRoute[]>(
  () => (dictionaries ? filterRoutes(routes, filterState, dictionaries) : []),
  [routes, filterState.domestic, filterState.international, filterState.connections, dictionaries],
);

const polylines = useMemo<IMapPolyline[]>(
  () => (dictionaries ? routesToPolylines(filteredRoutes, filterState, dictionaries) : []),
  [filteredRoutes, filterState.departure, filterState.arrival, dictionaries],
);

const intermediateIds = useMemo<string[]>(
  () => (dictionaries ? intermediateCityIds(filteredRoutes, dictionaries) : []),
  [filteredRoutes, dictionaries],
);
```

### Popups

```tsx
const popups = useMemo<IMapPopup[]>(() => {
  if (!dictionaries) return [];
  if (!filterState.departure || !filterState.arrival) return [];
  if (filteredRoutes.length === 0) return [];

  const first = filteredRoutes[0]!.route;
  const depCode = first[0]!;
  const arrCode = first[first.length - 1]!;

  const depCityCode = getCityCodeByAirportCode(dictionaries, depCode) ?? depCode;
  const arrCityCode = getCityCodeByAirportCode(dictionaries, arrCode) ?? arrCode;

  const depCity = dictionaries.cityByCode.get(depCityCode);
  const arrCity = dictionaries.cityByCode.get(arrCityCode);
  if (!depCity || !arrCity) return [];

  const date = filterState.date ?? todayYyyymmdd();
  const buyUrl = buildBuyTicketUrl(depCityCode, arrCityCode, date);

  const depHtml = `
    <div class="popup-header-test"><span>${escapeHtml(depCity.name)}</span></div>
  `;

  const arrHtml = `
    <div class="popup-header-test"><span>${escapeHtml(arrCity.name)}</span></div>
    <div style="text-align:center;">
      <a href="${buyUrl}" target="_blank" class="popup-buy-ticket">
        ${t("FLIGHTS-MAP.BUY-TICKET")}
      </a>
    </div>
  `;

  return [
    { lat: depCity.location.lat, lng: depCity.location.lon, content: depHtml },
    { lat: arrCity.location.lat, lng: arrCity.location.lon, content: arrHtml },
  ];
}, [dictionaries, filterState.departure, filterState.arrival, filterState.date, filteredRoutes, t]);
```

### MapCanvas props

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

## Error handling / edge cases

- **Dictionaries null.** All memos fall through to `[]`; no crash.
- **Retry errors.** Auto-fallback effect gates on `!error`; won't loop on network failure.
- **Filter reduces to zero.** Polylines empty; popups empty (gated on `filteredRoutes.length > 0`); existing "No directions found" overlay still shows.
- **Both `domestic` AND `international` true.** No domestic/intl filter applied. Matches Angular.
- **Spider mode + fallback.** Effect gates on `filterState.arrival` → no fallback in spider. Matches Angular.
- **Spider mode + filter.** `filterRoutes` runs unconditionally; predicates evaluate per route and produce correct output for both modes. Confirmed safe.
- **HTML injection via city name.** `escapeHtml` covers `& < > "`.
- **City code not in dictionary.** `toCityCode` returns the original input; `ruCityCodes.has(x)` / `otherCityCodes.has(x)` both false; `isDomestic`/`isInternational` fail that route. Matches Angular silent-failure.
- **`filterState.date` undefined.** Buy-ticket URL uses `todayYyyymmdd()`. Matches Angular.
- **Popups in spider mode.** Gated off via `!filterState.arrival`. Matches Angular.
- **Auto-fallback + user-triggered manual toggle.** User flips connections to true → sync effect sets effective=1 → mirror effect no-ops (already true) → no loop.

## Testing

### `filterRoutes.test.ts`

- Empty routes → `[]`.
- No toggles → routes unchanged.
- `domestic=true, international=false`: drops routes with any non-RU city.
- `international=true, domestic=false`: drops routes where all cities are RU.
- `domestic && international` (both true) → no domestic/intl filter.
- `connections=true` → drops `isDirect=true` routes.
- Airport-code normalization: route `["SVO", "JFK"]` evaluated via resolved `["MOW", "NYC"]`.
- Unknown code in a route: falls through `toCityCode`; membership checks both false; route dropped when domestic or international toggle is active.
- Combo `domestic + connections`: both predicates applied (all-RU AND non-direct).

### `buyTicketUrl.test.ts`

- URL prefix `https://www.aeroflot.ru/sb/app/ru-ru#/search?`.
- Contains `routes=MOW.20260501.LED` for `("MOW","LED","20260501")`.
- Contains `adults=1`, `cabin=economy`, `utm_source=aflwebbot` (stability of hardcoded params).
- `escapeHtml` replaces `&`, `<`, `>`, `"`.

### `routesToPolylines.test.ts` amendments

- Every existing test call gains a minimal `dictionaries` fixture (empty but valid `IDictionaries`).
- New: spider-mode with airport codes → polylines normalized to city codes.
- New: route-mode with mixed airport/city codes → `cityIds` are all city codes.
- New: `intermediateCityIds` with airport codes → inner entries normalized.

### `FlightsMapStartPage.test.tsx` (append)

Extend the existing `useFlightsMapSearch` mock to record call args:

```ts
const searchCalls: Array<FlightsMapSearchParams | null> = [];
vi.mock("../hooks/useFlightsMapSearch.js", () => ({
  useFlightsMapSearch: (params: FlightsMapSearchParams | null) => {
    searchCalls.push(params);
    return { ...searchState, refresh: vi.fn() };
  },
}));
```

Tests:

- **filterRoutes integration.** Dictionaries with RU+non-RU cities; mock routes mix of domestic+international; set `filterState.domestic = true` via simulated click or via a test-controllable initial-state override → captured polylines only include domestic routes.
- **Auto-fallback fires.** Initial mount with departure+arrival set but routes empty, `connections=false` → assert `searchCalls` contains an entry with `connections: 1` (the retry).
- **Mirror effect.** After auto-fallback, the `filterState.connections` value visible to MapCanvas props is `true`.
- **Popups in route mode.** Dictionaries entries for MOW+LED with locations; route `[{route:["MOW","LED"],isDirect:true}]`; filter `{departure:"MOW",arrival:"LED"}` → captured `popups.length === 2`, first.content includes "Москва", second.content includes `href="https://www.aeroflot.ru/sb/app/ru-ru#/search?...routes=MOW.<date>.LED"`.
- **No popups in spider mode.** Filter `{departure:"MOW"}`, no arrival → `popups` prop is `[]`.
- **No popups when filteredRoutes is empty.** Filter with toggle that filters everything out → `popups` is `[]`.

## Success criteria

- `pnpm tsc --noEmit` clean.
- All new tests pass. Expected delta: ~22 new (9 filterRoutes + 5 buyTicketUrl + 3 routesToPolylines normalization + 5 page integration).
- Full suite green.
- Page renders identically to today when dictionaries are loading or error.
- With dictionaries present + routes returned, the map draws polylines, shows departure+arrival popups with the buy-ticket link, and the filter toggles apply client-side.

## Deferred to C.5

- Date picker UI: once wired, the popup URL memo already depends on `filterState.date`, so popups regenerate automatically.
- Exchange button (swap dep↔arr) and geolocation default departure.
- Calendar `disabled-days` integration (uses `useFlightsMapCalendar` which already exists).
