Draw routes as city-code polylines and force-open intermediate tooltips

- routesToPolylines + intermediateCityIds pure helpers with unit coverage.
- IMapPolyline reshaped from points to cityIds for Angular-parity drawing.
- MapCanvas resolves coords via markerIndex, filters invisible cities on
  every zoom/toggle change, and runs a second tooltip pass that keeps
  intermediate-city tooltips open regardless of zoom/highlight rules.
This commit is contained in:
2026-04-17 10:08:44 +03:00
parent 514bae6051
commit a9ed92466f
5 changed files with 438 additions and 28 deletions
@@ -48,15 +48,23 @@ interface MockMap {
fireZoomend: () => void;
}
interface MockPolyline {
latlngs: unknown;
opts: unknown;
addTo: ReturnType<typeof vi.fn>;
}
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<string, unknown>)) {
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> = {},
): 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(
<MapCanvas
markers={[cm("A"), cm("B")]}
polylines={[pl("line", ["A", "B"])]}
tileUrl="t"
/>,
);
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(
<MapCanvas
markers={[cm("A")]}
polylines={[pl("line", ["A", "B"])]}
tileUrl="t"
/>,
);
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(
<MapCanvas
markers={[cm("A"), cm("B", "other"), cm("C")]}
polylines={[pl("tri", ["A", "B", "C"])]}
domestic={true}
tileUrl="t"
/>,
);
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(
<MapCanvas
markers={[cm("A"), cm("B")]}
polylines={[pl("dashed", ["A", "B"], "connecting")]}
tileUrl="t"
/>,
);
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(
<MapCanvas
markers={[cm("A"), cm("B")]}
polylines={[pl("line", ["A", "B"])]}
tileUrl="t"
/>,
);
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(
<MapCanvas
markers={[cm("A")]}
polylines={[pl("line", ["A", "NOPE"])]}
tileUrl="t"
/>,
);
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(
<MapCanvas
markers={[
cm("A", "ru", { highlighted: true }),
cm("B", "ru", { highlighted: true }),
cm("X", "ru"),
]}
polylines={[]}
intermediateIds={["X"]}
tileUrl="t"
/>,
);
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(
<MapCanvas
markers={[
cm("A", "ru", { highlighted: true }),
cm("B", "ru", { highlighted: true }),
cm("X", "ru"),
]}
polylines={[]}
intermediateIds={["X"]}
tileUrl="t"
/>,
);
const map = createdMaps[0]!;
map.setZoom(5);
map.fireZoomend();
const X = createdMarkers.find((m) => m.options.title === "X")!;
expect(X.openTooltip).toHaveBeenCalled();
});
});
@@ -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<MapCanvasProps> = ({
className,
domestic = false,
international = false,
intermediateIds,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
@@ -179,6 +182,7 @@ export const MapCanvas: FC<MapCanvasProps> = ({
const highlightLayerRef = useRef<L.LayerGroup | null>(null);
const markerIndexRef = useRef<Map<string, L.Marker>>(new Map());
const highlightedIdsRef = useRef<Set<string>>(new Set());
const intermediateIdsRef = useRef<Set<string>>(new Set());
// Track latest toggles so the zoomend handler sees current values
const domesticRef = useRef(domestic);
@@ -231,6 +235,7 @@ export const MapCanvas: FC<MapCanvasProps> = ({
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<MapCanvasProps> = ({
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<MapCanvasProps> = ({
// Rerun visibility + tooltip rules on every zoom change.
map.on("zoomend", () => {
syncVisibility();
syncPolylines();
syncTooltips();
});
@@ -296,6 +337,7 @@ export const MapCanvas: FC<MapCanvasProps> = ({
zoomLayersRef.current = null;
markerIndexRef.current = new Map();
highlightedIdsRef.current = new Set();
intermediateIdsRef.current = new Set();
};
}, []);
@@ -365,30 +407,7 @@ export const MapCanvas: FC<MapCanvasProps> = ({
// --- 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<MapCanvasProps> = ({
// --- 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 (
<div
ref={containerRef}
@@ -0,0 +1,118 @@
import { describe, it, expect } from "vitest";
import {
routesToPolylines,
intermediateCityIds,
} from "./routesToPolylines.js";
import type { IFlightRoute, IFlightsMapFilterState } from "./types.js";
function filter(
overrides: Partial<IFlightsMapFilterState> = {},
): 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"]);
});
});
@@ -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<IFlightsMapFilterState, "departure" | "arrival">,
): 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<string>();
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<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]!);
}
return [...ids];
}
+5 -1
View File
@@ -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;
}