diff --git a/src/features/flights-map/components/MapCanvas.test.tsx b/src/features/flights-map/components/MapCanvas.test.tsx index bb4c01ee..acf3cbe4 100644 --- a/src/features/flights-map/components/MapCanvas.test.tsx +++ b/src/features/flights-map/components/MapCanvas.test.tsx @@ -48,15 +48,23 @@ interface MockMap { fireZoomend: () => void; } +interface MockPolyline { + latlngs: unknown; + opts: unknown; + addTo: ReturnType; +} + const createdMarkers: MockMarker[] = []; const createdLayerGroups: MockLayerGroup[] = []; const createdMaps: MockMap[] = []; +const createdPolylines: MockPolyline[] = []; let layerGroupCounter = 0; function resetLeafletMockState() { createdMarkers.length = 0; createdLayerGroups.length = 0; createdMaps.length = 0; + createdPolylines.length = 0; layerGroupCounter = 0; } @@ -130,7 +138,15 @@ vi.mock("leaflet", () => { m._addedLayers.delete(l); return m; }), - hasLayer: vi.fn((l: MockLayerGroup) => m._addedLayers.has(l)), + hasLayer: vi.fn((target: unknown) => { + if (target && typeof target === "object" && "_markers" in (target as Record)) { + return m._addedLayers.has(target as MockLayerGroup); + } + const owning = createdLayerGroups.find((lg) => + lg._markers.includes(target as MockMarker), + ); + return owning ? m._addedLayers.has(owning) : false; + }), getZoom: vi.fn(() => m._zoom), setZoom: (z: number) => { m._zoom = z; @@ -155,8 +171,17 @@ vi.mock("leaflet", () => { return { lat, lng }; } - function polyline() { - return { addTo: vi.fn() }; + function polyline(latlngs: unknown, opts: unknown) { + const p: MockPolyline = { + latlngs, + opts, + addTo: vi.fn((target: { addLayer?: (l: unknown) => unknown }) => { + target.addLayer?.(p); + return p; + }), + }; + createdPolylines.push(p); + return p; } function popup() { @@ -365,3 +390,180 @@ describe("MapCanvas — tooltip visibility rules", () => { expect(b.openTooltip).toHaveBeenCalled(); }); }); + +// --------------------------------------------------------------------------- +// C.3 polyline + intermediate tests +// --------------------------------------------------------------------------- + +import type { IMapPolyline } from "../types.js"; + +function cm( + id: string, + countryType: "ru" | "other" = "ru", + overrides: Partial = {}, +): IMapMarker { + return { + id, + lat: 55, + lng: 37, + style: "blue-small", + label: id, + tooltipPermanent: true, + zoomLevel: 2, + countryType, + ...overrides, + }; +} + +function pl(id: string, cityIds: string[], style: "direct" | "connecting" = "direct"): IMapPolyline { + return { id, cityIds, style }; +} + +describe("MapCanvas — polylines (C.3)", () => { + beforeEach(() => resetLeafletMockState()); + + it("resolves coords from markers and draws a polyline A→B", () => { + render( + , + ); + const map = createdMaps[0]!; + map.setZoom(6); + map.fireZoomend(); + + expect(createdPolylines.length).toBeGreaterThanOrEqual(1); + }); + + it("does not draw a polyline when fewer than 2 cities are visible", () => { + render( + , + ); + const map = createdMaps[0]!; + map.setZoom(6); + map.fireZoomend(); + + expect(createdPolylines.length).toBe(0); + }); + + it("skips intermediate cities whose marker is not on the map", () => { + render( + , + ); + const map = createdMaps[0]!; + // Reset so we count only polylines drawn by the next sync cycle. + createdPolylines.length = 0; + map.setZoom(6); + map.fireZoomend(); + + expect(createdPolylines.length).toBe(1); + }); + + it("applies connecting style for non-direct routes", () => { + render( + , + ); + const map = createdMaps[0]!; + map.setZoom(6); + map.fireZoomend(); + + const styled = createdPolylines[0]!; + const opts = styled.opts as { dashArray?: string } | undefined; + expect(opts?.dashArray).toBe("4 14"); + }); + + it("rebuilds polylines on zoomend", () => { + render( + , + ); + const map = createdMaps[0]!; + map.setZoom(6); + map.fireZoomend(); + const before = createdPolylines.length; + + map.setZoom(4); + map.fireZoomend(); + + expect(createdPolylines.length).toBeGreaterThan(before); + }); + + it("silently skips polylines with unknown city codes", () => { + render( + , + ); + const map = createdMaps[0]!; + map.setZoom(6); + map.fireZoomend(); + + expect(createdPolylines.length).toBe(0); + }); +}); + +describe("MapCanvas — intermediateIds (C.3)", () => { + beforeEach(() => resetLeafletMockState()); + + it("force-opens intermediate marker tooltips even at zoom <= 3", () => { + render( + , + ); + const map = createdMaps[0]!; + map.setZoom(3); + map.fireZoomend(); + + const X = createdMarkers.find((m) => m.options.title === "X")!; + expect(X.openTooltip).toHaveBeenCalled(); + }); + + it("force-opens intermediate tooltips with >=2 highlighted markers", () => { + render( + , + ); + const map = createdMaps[0]!; + map.setZoom(5); + map.fireZoomend(); + + const X = createdMarkers.find((m) => m.options.title === "X")!; + expect(X.openTooltip).toHaveBeenCalled(); + }); +}); diff --git a/src/features/flights-map/components/MapCanvas.tsx b/src/features/flights-map/components/MapCanvas.tsx index 00ab0f4b..41cf77c4 100644 --- a/src/features/flights-map/components/MapCanvas.tsx +++ b/src/features/flights-map/components/MapCanvas.tsx @@ -141,6 +141,8 @@ export interface MapCanvasProps { domestic?: boolean; /** When true, hide countryType="ru" markers. */ international?: boolean; + /** Marker IDs whose tooltips must be force-opened (intermediate cities on multi-hop routes). */ + intermediateIds?: string[]; } // --------------------------------------------------------------------------- @@ -164,6 +166,7 @@ export const MapCanvas: FC = ({ className, domestic = false, international = false, + intermediateIds, }) => { const containerRef = useRef(null); const mapRef = useRef(null); @@ -179,6 +182,7 @@ export const MapCanvas: FC = ({ const highlightLayerRef = useRef(null); const markerIndexRef = useRef>(new Map()); const highlightedIdsRef = useRef>(new Set()); + const intermediateIdsRef = useRef>(new Set()); // Track latest toggles so the zoomend handler sees current values const domesticRef = useRef(domestic); @@ -231,6 +235,7 @@ export const MapCanvas: FC = ({ const highlighted = highlightedIdsRef.current; const closeNonHighlighted = currentZoom <= 3 || highlighted.size >= 2; + // Pass 1: zoom/highlight rules. markerIndexRef.current.forEach((marker, id) => { if (closeNonHighlighted && !highlighted.has(id)) { marker.closeTooltip(); @@ -238,6 +243,41 @@ export const MapCanvas: FC = ({ marker.openTooltip(); } }); + + // Pass 2: force-open intermediate route tooltips (Angular parity). + for (const id of intermediateIdsRef.current) { + const m = markerIndexRef.current.get(id); + if (m) m.openTooltip(); + } + } + + function syncPolylines(): void { + const map = mapRef.current; + const layer = polylinesLayerRef.current; + if (!map || !layer) return; + + layer.clearLayers(); + + for (const pl of polylines) { + const styleOpts = POLYLINE_STYLES[pl.style]; + + const visible: L.Marker[] = []; + for (const cityId of pl.cityIds) { + const m = markerIndexRef.current.get(cityId); + if (m && map.hasLayer(m)) visible.push(m); + } + if (visible.length < 2) continue; + + const segments: L.LatLng[] = []; + for (let i = 0; i < visible.length - 1; i++) { + const from = visible[i]!.getLatLng(); + const to = visible[i + 1]!.getLatLng(); + const arc = buildGreatCircleArc(from, to); + segments.push(...(i === 0 ? arc : arc.slice(1))); + } + + L.polyline(segments, styleOpts).addTo(layer); + } } // --- Initialize map --- @@ -281,6 +321,7 @@ export const MapCanvas: FC = ({ // Rerun visibility + tooltip rules on every zoom change. map.on("zoomend", () => { syncVisibility(); + syncPolylines(); syncTooltips(); }); @@ -296,6 +337,7 @@ export const MapCanvas: FC = ({ zoomLayersRef.current = null; markerIndexRef.current = new Map(); highlightedIdsRef.current = new Set(); + intermediateIdsRef.current = new Set(); }; }, []); @@ -365,30 +407,7 @@ export const MapCanvas: FC = ({ // --- Sync polylines --- useEffect(() => { - const layer = polylinesLayerRef.current; - if (!layer) return; - - layer.clearLayers(); - - for (const pl of polylines) { - const style = POLYLINE_STYLES[pl.style]; - - // Build great-circle arcs between consecutive points - const arcPoints: L.LatLng[] = []; - for (let i = 0; i < pl.points.length - 1; i++) { - const ptFrom = pl.points[i]; - const ptTo = pl.points[i + 1]; - if (!ptFrom || !ptTo) continue; - const from = L.latLng(ptFrom.lat, ptFrom.lng); - const to = L.latLng(ptTo.lat, ptTo.lng); - const arc = buildGreatCircleArc(from, to); - arcPoints.push(...(i === 0 ? arc : arc.slice(1))); - } - - if (arcPoints.length >= 2) { - L.polyline(arcPoints, style).addTo(layer); - } - } + syncPolylines(); }, [polylines]); // --- Sync popups --- @@ -416,9 +435,16 @@ export const MapCanvas: FC = ({ // --- Re-sync visibility + tooltips when toggles change --- useEffect(() => { syncVisibility(); + syncPolylines(); syncTooltips(); }, [domestic, international]); + // --- Re-sync tooltips when intermediateIds change --- + useEffect(() => { + intermediateIdsRef.current = new Set(intermediateIds ?? []); + syncTooltips(); + }, [intermediateIds]); + return (
= {}, +): IFlightsMapFilterState { + return { + connections: false, + domestic: false, + international: false, + ...overrides, + }; +} + +describe("routesToPolylines — empty input", () => { + it("returns [] for no routes", () => { + expect(routesToPolylines([], filter({ departure: "A" }))).toEqual([]); + }); +}); + +describe("routesToPolylines — spider mode (departure only)", () => { + it("produces one polyline per unique last-hop destination", () => { + const routes: IFlightRoute[] = [ + { route: ["A", "B"], isDirect: true }, + { route: ["A", "C"], isDirect: true }, + { route: ["A", "B"], isDirect: true }, + ]; + + const pls = routesToPolylines(routes, filter({ departure: "A" })); + + expect(pls).toHaveLength(2); + expect(pls.every((p) => p.cityIds[0] === "A")).toBe(true); + expect(pls.every((p) => p.cityIds.length === 2)).toBe(true); + expect(pls.every((p) => p.style === "direct")).toBe(true); + const dests = pls.map((p) => p.cityIds[1]).sort(); + expect(dests).toEqual(["B", "C"]); + }); + + it("drops routes whose last hop equals departure", () => { + const routes: IFlightRoute[] = [ + { route: ["A", "A"], isDirect: true }, + { route: ["A", "B"], isDirect: true }, + ]; + const pls = routesToPolylines(routes, filter({ departure: "A" })); + 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" })); + expect(pls).toEqual([]); + }); +}); + +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" })); + expect(pls).toHaveLength(1); + expect(pls[0]!.style).toBe("direct"); + expect(pls[0]!.cityIds).toEqual(["A", "B"]); + }); + + it("non-direct route gets style=\"connecting\" (hop count irrelevant)", () => { + const routes: IFlightRoute[] = [ + { route: ["A", "X", "B"], isDirect: false }, + { route: ["A", "B"], isDirect: false }, + ]; + const pls = routesToPolylines(routes, filter({ departure: "A", arrival: "B" })); + expect(pls.every((p) => p.style === "connecting")).toBe(true); + }); + + it("assigns unique IDs across multiple routes", () => { + const routes: IFlightRoute[] = [ + { route: ["A", "B"], isDirect: true }, + { route: ["A", "X", "B"], isDirect: false }, + { route: ["A", "B"], isDirect: false }, + ]; + const pls = routesToPolylines(routes, filter({ departure: "A", arrival: "B" })); + const ids = new Set(pls.map((p) => p.id)); + expect(ids.size).toBe(pls.length); + }); +}); + +describe("intermediateCityIds", () => { + it("returns [] for no routes", () => { + expect(intermediateCityIds([])).toEqual([]); + }); + + it("ignores 2-city routes", () => { + expect(intermediateCityIds([{ route: ["A", "B"], isDirect: true }])).toEqual([]); + }); + + it("extracts the single inner city from a 3-city route", () => { + expect( + intermediateCityIds([{ route: ["A", "X", "B"], isDirect: false }]), + ).toEqual(["X"]); + }); + + it("extracts both inner cities from a 4-city route", () => { + expect( + intermediateCityIds([{ route: ["A", "X", "Y", "B"], isDirect: false }]).sort(), + ).toEqual(["X", "Y"]); + }); + + it("dedups intermediates across multiple routes", () => { + const ids = intermediateCityIds([ + { route: ["A", "X", "B"], isDirect: false }, + { route: ["C", "X", "D"], isDirect: false }, + ]); + expect(ids).toEqual(["X"]); + }); +}); diff --git a/src/features/flights-map/routesToPolylines.ts b/src/features/flights-map/routesToPolylines.ts new file mode 100644 index 00000000..a53efff2 --- /dev/null +++ b/src/features/flights-map/routesToPolylines.ts @@ -0,0 +1,60 @@ +/** + * Transform API-returned routes into polylines the map can render. + * + * Two modes, mirroring Angular `fetchAndDraw`: + * - Spider (departure only): one straight polyline from departure to each + * unique destination across all routes. + * - Route (departure + arrival): one polyline per route following its city + * sequence; direct routes solid, connecting routes dashed. + * + * `intermediateCityIds` returns marker IDs whose tooltips must be force-opened + * along multi-hop routes, matching Angular `updateIntermediateTooltip`. + */ + +import type { + IFlightRoute, + IFlightsMapFilterState, + IMapPolyline, +} from "./types.js"; + +export function routesToPolylines( + routes: IFlightRoute[], + filterState: Pick, +): IMapPolyline[] { + if (routes.length === 0) return []; + + const hasDeparture = Boolean(filterState.departure); + const hasArrival = Boolean(filterState.arrival); + const isSpiderMode = hasDeparture && !hasArrival; + + if (isSpiderMode) { + const fromCode = filterState.departure!; + const destCodes = new Set(); + for (const r of routes) { + if (r.route.length > 1) { + const dest = 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) => ({ + id: `route-${i}-${r.route.join("-")}`, + cityIds: r.route, + style: r.isDirect ? "direct" : "connecting", + })); +} + +export function intermediateCityIds(routes: IFlightRoute[]): string[] { + 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]!); + } + return [...ids]; +} diff --git a/src/features/flights-map/types.ts b/src/features/flights-map/types.ts index ac02917c..a9f14a9f 100644 --- a/src/features/flights-map/types.ts +++ b/src/features/flights-map/types.ts @@ -101,10 +101,14 @@ export type PolylineStyle = "direct" | "connecting"; /** * A polyline to render on the map. + * + * Cities are referenced by code; MapCanvas resolves coordinates via its + * internal markerIndex and filters out cities whose marker is not currently + * on the map (matches Angular `drawPolyline` behavior). */ export interface IMapPolyline { id: string; - points: Array<{ lat: number; lng: number }>; + cityIds: string[]; style: PolylineStyle; }