diff --git a/src/features/flights-map/components/FlightsMapStartPage.test.tsx b/src/features/flights-map/components/FlightsMapStartPage.test.tsx index 15ffcb3e..dedb007f 100644 --- a/src/features/flights-map/components/FlightsMapStartPage.test.tsx +++ b/src/features/flights-map/components/FlightsMapStartPage.test.tsx @@ -79,6 +79,7 @@ const dictState: { }; vi.mock("@/shared/dictionaries/index.js", () => ({ useDictionaries: () => dictState, + getCityCodeByAirportCode: () => undefined, })); describe("FlightsMapStartPage — dictionaries integration", () => { diff --git a/src/features/flights-map/components/FlightsMapStartPage.tsx b/src/features/flights-map/components/FlightsMapStartPage.tsx index 907d05eb..e5933d4f 100644 --- a/src/features/flights-map/components/FlightsMapStartPage.tsx +++ b/src/features/flights-map/components/FlightsMapStartPage.tsx @@ -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(() => { 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(() => { @@ -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( + () => (dictionaries ? filterRoutes(routes, filterState, dictionaries) : []), + [ + routes, + filterState.domestic, + filterState.international, + filterState.connections, + dictionaries, + ], + ); + const polylines = useMemo( - () => routesToPolylines(routes, filterState), - [routes, filterState.departure, filterState.arrival], + () => + dictionaries + ? routesToPolylines(filteredRoutes, filterState, dictionaries) + : [], + [filteredRoutes, filterState.departure, filterState.arrival, dictionaries], ); const intermediateIds = useMemo( - () => intermediateCityIds(routes), - [routes], + () => (dictionaries ? intermediateCityIds(filteredRoutes, dictionaries) : []), + [filteredRoutes, dictionaries], ); + const popups = useMemo(() => { + 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 = ` + + `; + + const arrHtml = ` + + + `; + + 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 = () => { = {}, @@ -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"]); + }); +}); diff --git a/src/features/flights-map/routesToPolylines.ts b/src/features/flights-map/routesToPolylines.ts index a53efff2..430b837a 100644 --- a/src/features/flights-map/routesToPolylines.ts +++ b/src/features/flights-map/routesToPolylines.ts @@ -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, + 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(); 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(); 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]; }