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:
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user