Wire filterRoutes, auto-fallback, and buy-ticket popups into Flights Map

routesToPolylines + intermediateCityIds now normalize airport codes to city
codes via the dictionaries so API responses resolve correctly. The page adds
effectiveConnections state + two effects for Angular-parity fallback
(retry connections=1 on empty direct-route result, then flip the UI toggle),
a filterRoutes memo feeding polylines and intermediateIds, and a popups memo
rendering departure + arrival buy-ticket popups in route mode only.
This commit is contained in:
2026-04-17 11:00:40 +03:00
parent 77272423c1
commit 4e92e79a99
4 changed files with 207 additions and 30 deletions
@@ -79,6 +79,7 @@ const dictState: {
};
vi.mock("@/shared/dictionaries/index.js", () => ({
useDictionaries: () => dictState,
getCityCodeByAirportCode: () => undefined,
}));
describe("FlightsMapStartPage — dictionaries integration", () => {
@@ -9,7 +9,7 @@
* @module
*/
import { type FC, lazy, Suspense, useState, useCallback, useMemo } from "react";
import { type FC, lazy, Suspense, useState, useEffect, useCallback, useMemo } from "react";
import { useParams } from "@modern-js/runtime/router";
import { useTranslation } from "@/i18n/provider.js";
import { PageLayout } from "@/ui/layout/PageLayout.js";
@@ -19,7 +19,9 @@ import { FlightsMapFilter } from "./FlightsMapFilter.js";
import { useFlightsMapSearch } from "../hooks/useFlightsMapSearch.js";
import { useFlightsMapCalendar } from "../hooks/useFlightsMapCalendar.js";
import { routesToPolylines, intermediateCityIds } from "../routesToPolylines.js";
import { useDictionaries } from "@/shared/dictionaries/index.js";
import { filterRoutes } from "../filterRoutes.js";
import { buildBuyTicketUrl, escapeHtml } from "../buyTicketUrl.js";
import { useDictionaries, getCityCodeByAirportCode } from "@/shared/dictionaries/index.js";
import { getEnv } from "@/env/index.js";
import { getCityZoomLevel } from "../cityCategory.js";
import type {
@@ -28,6 +30,8 @@ import type {
FlightsMapCalendarParams,
IMapMarker,
IMapPolyline,
IMapPopup,
IFlightRoute,
} from "../types.js";
import "./FlightsMapStartPage.scss";
@@ -80,6 +84,15 @@ export const FlightsMapStartPage: FC = () => {
international: false,
});
const [effectiveConnections, setEffectiveConnections] = useState<0 | 1>(
filterState.connections ? 1 : 0,
);
// Sync user toggle → effective.
useEffect(() => {
setEffectiveConnections(filterState.connections ? 1 : 0);
}, [filterState.connections]);
// Build search params from filter state
const searchParams = useMemo<FlightsMapSearchParams | null>(() => {
if (!filterState.departure) return null;
@@ -90,9 +103,9 @@ export const FlightsMapStartPage: FC = () => {
arrival: filterState.arrival,
dateFrom: today,
dateTo: addMonthsYyyymmdd(today, 6),
connections: filterState.connections ? 1 : 0,
connections: effectiveConnections,
};
}, [filterState.departure, filterState.arrival, filterState.connections]);
}, [filterState.departure, filterState.arrival, effectiveConnections]);
// Build calendar params
const calendarParams = useMemo<FlightsMapCalendarParams | null>(() => {
@@ -110,6 +123,29 @@ export const FlightsMapStartPage: FC = () => {
const { routes, loading, error } = useFlightsMapSearch(searchParams);
const { availableDays } = useFlightsMapCalendar(calendarParams);
// 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,
]);
// Reflect fallback in the UI toggle once.
useEffect(() => {
if (effectiveConnections === 1 && !filterState.connections) {
setFilterState((prev) => ({ ...prev, connections: true }));
}
}, [effectiveConnections, filterState.connections]);
const handleFilterChange = useCallback((newState: IFlightsMapFilterState) => {
setFilterState(newState);
}, []);
@@ -160,16 +196,75 @@ export const FlightsMapStartPage: FC = () => {
});
}, [dictionaries, filterState.departure, filterState.arrival]);
const filteredRoutes = useMemo<IFlightRoute[]>(
() => (dictionaries ? filterRoutes(routes, filterState, dictionaries) : []),
[
routes,
filterState.domestic,
filterState.international,
filterState.connections,
dictionaries,
],
);
const polylines = useMemo<IMapPolyline[]>(
() => routesToPolylines(routes, filterState),
[routes, filterState.departure, filterState.arrival],
() =>
dictionaries
? routesToPolylines(filteredRoutes, filterState, dictionaries)
: [],
[filteredRoutes, filterState.departure, filterState.arrival, dictionaries],
);
const intermediateIds = useMemo<string[]>(
() => intermediateCityIds(routes),
[routes],
() => (dictionaries ? intermediateCityIds(filteredRoutes, dictionaries) : []),
[filteredRoutes, dictionaries],
);
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,
]);
// Tile URL from env or default
const tileUrl = `${env.API_BASE_URL}/tiles/{z}/{x}/{y}.png`;
@@ -224,6 +319,7 @@ export const FlightsMapStartPage: FC = () => {
<MapCanvas
markers={markers}
polylines={polylines}
popups={popups}
tileUrl={tileUrl}
onMarkerClick={handleMarkerClick}
className="flights-map-start__map"
@@ -3,7 +3,18 @@ import {
routesToPolylines,
intermediateCityIds,
} from "./routesToPolylines.js";
import type { IFlightRoute, IFlightsMapFilterState } from "./types.js";
import { transformDictionaries } from "@/shared/dictionaries/index.js";
import type {
IFlightRoute,
IFlightsMapFilterState,
} from "./types.js";
import type { IDictionaries, IRawDictionaries } from "@/shared/dictionaries/index.js";
// Empty-dictionary fixture for tests that don't care about normalization.
const EMPTY_DICT: IDictionaries = transformDictionaries(
{ regions: [], countries: [], cities: [], airports: [] },
"ru",
);
function filter(
overrides: Partial<IFlightsMapFilterState> = {},
@@ -18,7 +29,7 @@ function filter(
describe("routesToPolylines — empty input", () => {
it("returns [] for no routes", () => {
expect(routesToPolylines([], filter({ departure: "A" }))).toEqual([]);
expect(routesToPolylines([], filter({ departure: "A" }), EMPTY_DICT)).toEqual([]);
});
});
@@ -30,7 +41,7 @@ describe("routesToPolylines — spider mode (departure only)", () => {
{ route: ["A", "B"], isDirect: true },
];
const pls = routesToPolylines(routes, filter({ departure: "A" }));
const pls = routesToPolylines(routes, filter({ departure: "A" }), EMPTY_DICT);
expect(pls).toHaveLength(2);
expect(pls.every((p) => p.cityIds[0] === "A")).toBe(true);
@@ -45,14 +56,14 @@ describe("routesToPolylines — spider mode (departure only)", () => {
{ route: ["A", "A"], isDirect: true },
{ route: ["A", "B"], isDirect: true },
];
const pls = routesToPolylines(routes, filter({ departure: "A" }));
const pls = routesToPolylines(routes, filter({ departure: "A" }), EMPTY_DICT);
expect(pls).toHaveLength(1);
expect(pls[0]!.cityIds).toEqual(["A", "B"]);
});
it("skips single-city routes (len < 2)", () => {
const routes: IFlightRoute[] = [{ route: ["A"], isDirect: true }];
const pls = routesToPolylines(routes, filter({ departure: "A" }));
const pls = routesToPolylines(routes, filter({ departure: "A" }), EMPTY_DICT);
expect(pls).toEqual([]);
});
});
@@ -60,7 +71,7 @@ describe("routesToPolylines — spider mode (departure only)", () => {
describe("routesToPolylines — route mode (departure + arrival)", () => {
it("direct route gets style=\"direct\"", () => {
const routes: IFlightRoute[] = [{ route: ["A", "B"], isDirect: true }];
const pls = routesToPolylines(routes, filter({ departure: "A", arrival: "B" }));
const pls = routesToPolylines(routes, filter({ departure: "A", arrival: "B" }), EMPTY_DICT);
expect(pls).toHaveLength(1);
expect(pls[0]!.style).toBe("direct");
expect(pls[0]!.cityIds).toEqual(["A", "B"]);
@@ -71,7 +82,7 @@ describe("routesToPolylines — route mode (departure + arrival)", () => {
{ route: ["A", "X", "B"], isDirect: false },
{ route: ["A", "B"], isDirect: false },
];
const pls = routesToPolylines(routes, filter({ departure: "A", arrival: "B" }));
const pls = routesToPolylines(routes, filter({ departure: "A", arrival: "B" }), EMPTY_DICT);
expect(pls.every((p) => p.style === "connecting")).toBe(true);
});
@@ -81,7 +92,7 @@ describe("routesToPolylines — route mode (departure + arrival)", () => {
{ route: ["A", "X", "B"], isDirect: false },
{ route: ["A", "B"], isDirect: false },
];
const pls = routesToPolylines(routes, filter({ departure: "A", arrival: "B" }));
const pls = routesToPolylines(routes, filter({ departure: "A", arrival: "B" }), EMPTY_DICT);
const ids = new Set(pls.map((p) => p.id));
expect(ids.size).toBe(pls.length);
});
@@ -89,22 +100,22 @@ describe("routesToPolylines — route mode (departure + arrival)", () => {
describe("intermediateCityIds", () => {
it("returns [] for no routes", () => {
expect(intermediateCityIds([])).toEqual([]);
expect(intermediateCityIds([], EMPTY_DICT)).toEqual([]);
});
it("ignores 2-city routes", () => {
expect(intermediateCityIds([{ route: ["A", "B"], isDirect: true }])).toEqual([]);
expect(intermediateCityIds([{ route: ["A", "B"], isDirect: true }], EMPTY_DICT)).toEqual([]);
});
it("extracts the single inner city from a 3-city route", () => {
expect(
intermediateCityIds([{ route: ["A", "X", "B"], isDirect: false }]),
intermediateCityIds([{ route: ["A", "X", "B"], isDirect: false }], EMPTY_DICT),
).toEqual(["X"]);
});
it("extracts both inner cities from a 4-city route", () => {
expect(
intermediateCityIds([{ route: ["A", "X", "Y", "B"], isDirect: false }]).sort(),
intermediateCityIds([{ route: ["A", "X", "Y", "B"], isDirect: false }], EMPTY_DICT).sort(),
).toEqual(["X", "Y"]);
});
@@ -112,7 +123,56 @@ describe("intermediateCityIds", () => {
const ids = intermediateCityIds([
{ route: ["A", "X", "B"], isDirect: false },
{ route: ["C", "X", "D"], isDirect: false },
]);
], EMPTY_DICT);
expect(ids).toEqual(["X"]);
});
});
// ---------------------------------------------------------------------------
// Airport-code normalization (C.4)
// ---------------------------------------------------------------------------
const rawWithAirports: IRawDictionaries = {
regions: [],
countries: [{ code: "RU", title: { ru: "Россия" }, world_region_id: 500374 }],
cities: [
{ code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } },
{ code: "LED", title: { ru: "Санкт-Петербург" }, country_code: "RU", has_afl_flights: true, location: { lat: 60, lon: 30 } },
],
airports: [
{ code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } },
{ code: "LED", city_code: "LED", title: { ru: "Пулково" }, has_afl_flights: true, location: { lat: 60, lon: 30 } },
],
};
const DICT_WITH_AIRPORTS: IDictionaries = transformDictionaries(rawWithAirports, "ru");
describe("routesToPolylines — airport-code normalization", () => {
it("normalizes route-mode cityIds from airport codes to city codes", () => {
const routes: IFlightRoute[] = [{ route: ["SVO", "LED"], isDirect: true }];
const pls = routesToPolylines(
routes,
filter({ departure: "MOW", arrival: "LED" }),
DICT_WITH_AIRPORTS,
);
expect(pls).toHaveLength(1);
expect(pls[0]!.cityIds).toEqual(["MOW", "LED"]);
});
it("normalizes spider-mode cityIds", () => {
const routes: IFlightRoute[] = [{ route: ["SVO", "LED"], isDirect: true }];
const pls = routesToPolylines(
routes,
filter({ departure: "MOW" }),
DICT_WITH_AIRPORTS,
);
expect(pls).toHaveLength(1);
expect(pls[0]!.cityIds).toEqual(["MOW", "LED"]);
});
it("intermediateCityIds returns city codes when airport codes appear inside a multi-hop route", () => {
const routes: IFlightRoute[] = [
{ route: ["LED", "SVO", "MOW"], isDirect: false },
];
expect(intermediateCityIds(routes, DICT_WITH_AIRPORTS)).toEqual(["MOW"]);
});
});
+29 -9
View File
@@ -7,10 +7,17 @@
* - Route (departure + arrival): one polyline per route following its city
* sequence; direct routes solid, connecting routes dashed.
*
* Route entries may contain airport codes in addition to city codes; both
* functions normalize each code to its city code via the dictionaries before
* building polylines. This ensures polylines resolve against MapCanvas'
* city-code-keyed marker index regardless of what the API returns.
*
* `intermediateCityIds` returns marker IDs whose tooltips must be force-opened
* along multi-hop routes, matching Angular `updateIntermediateTooltip`.
*/
import type { IDictionaries } from "@/shared/dictionaries/index.js";
import { getCityCodeByAirportCode } from "@/shared/dictionaries/index.js";
import type {
IFlightRoute,
IFlightsMapFilterState,
@@ -20,19 +27,23 @@ import type {
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 = filterState.departure!;
const fromCode = toCity(filterState.departure!);
const destCodes = new Set<string>();
for (const r of routes) {
if (r.route.length > 1) {
const dest = r.route[r.route.length - 1]!;
const dest = toCity(r.route[r.route.length - 1]!);
if (dest !== fromCode) destCodes.add(dest);
}
}
@@ -43,18 +54,27 @@ export function routesToPolylines(
}));
}
return routes.map((r, i) => ({
id: `route-${i}-${r.route.join("-")}`,
cityIds: r.route,
style: r.isDirect ? "direct" : "connecting",
}));
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[]): string[] {
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(r.route[i]!);
for (let i = 1; i < r.route.length - 1; i++) ids.add(toCity(r.route[i]!));
}
return [...ids];
}