diff --git a/src/features/flights-map/components/MapCanvas.test.tsx b/src/features/flights-map/components/MapCanvas.test.tsx index 8fc03fb4..c5d24f01 100644 --- a/src/features/flights-map/components/MapCanvas.test.tsx +++ b/src/features/flights-map/components/MapCanvas.test.tsx @@ -40,12 +40,14 @@ interface MockMap { _zoom: number; _eventHandlers: Record void>>; _addedLayers: Set; + _container: HTMLElement; on: ReturnType; remove: ReturnType; addLayer: ReturnType; removeLayer: ReturnType; hasLayer: ReturnType; fitBounds: ReturnType; + getContainer: ReturnType; getZoom: ReturnType; setZoom: (z: number) => void; fireZoomend: () => void; @@ -139,10 +141,12 @@ vi.mock("leaflet", () => { } function mapFn(_container: unknown, _opts: unknown) { + const container = document.createElement("div"); const m: MockMap = { _zoom: 5, _eventHandlers: {}, _addedLayers: new Set(), + _container: container, on: vi.fn((evt: string, fn: () => void) => { m._eventHandlers[evt] ??= []; m._eventHandlers[evt]!.push(fn); @@ -167,6 +171,7 @@ vi.mock("leaflet", () => { return owning ? m._addedLayers.has(owning) : false; }), fitBounds: vi.fn(() => m), + getContainer: vi.fn(() => container), getZoom: vi.fn(() => m._zoom), setZoom: (z: number) => { m._zoom = z; @@ -359,6 +364,27 @@ describe("MapCanvas — legacy (flat) path", () => { expect(L.DomEvent.stop).toHaveBeenCalled(); expect(onMarkerClick).toHaveBeenCalledWith("MOW"); }); + + it("selects marker icon clicks at the map container capture layer", () => { + const onMarkerClick = vi.fn(); + renderCanvas( + [{ id: "MOW", lat: 1, lng: 2, style: "blue-small", label: "Москва", tooltipPermanent: true }], + { onMarkerClick }, + ); + + const map = createdMaps[0]; + if (!map) { + throw new Error("expected map to be created"); + } + const markerIcon = document.createElement("img"); + markerIcon.className = "leaflet-marker-icon"; + markerIcon.setAttribute("title", "MOW"); + map._container.append(markerIcon); + + markerIcon.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + + expect(onMarkerClick).toHaveBeenCalledWith("MOW"); + }); }); describe("MapCanvas — categorized: visibility predicate", () => { diff --git a/src/features/flights-map/components/MapCanvas.tsx b/src/features/flights-map/components/MapCanvas.tsx index a5fb0579..65361ae8 100644 --- a/src/features/flights-map/components/MapCanvas.tsx +++ b/src/features/flights-map/components/MapCanvas.tsx @@ -45,6 +45,16 @@ function getIcon(style: MarkerStyle): L.Icon { return L.icon(opts); } +function findMarkerIdFromEventTarget(target: EventTarget | null): string | undefined { + if (!(target instanceof Element)) return undefined; + + const tooltip = target.closest(".city-label[data-marker-id]"); + if (tooltip?.dataset.markerId) return tooltip.dataset.markerId; + + const marker = target.closest(".leaflet-marker-icon[title]"); + return marker?.getAttribute("title") ?? undefined; +} + // --------------------------------------------------------------------------- // Polyline styles // --------------------------------------------------------------------------- @@ -325,6 +335,24 @@ export const MapCanvas: FC = ({ minZoom: minZoomRef.current, }).addTo(map); + const container = map.getContainer(); + const stopCityPointer = (event: Event) => { + const markerId = findMarkerIdFromEventTarget(event.target); + if (!markerId || !markerIndexRef.current.has(markerId)) return; + event.stopPropagation(); + }; + const selectCityFromDom = (event: Event) => { + const markerId = findMarkerIdFromEventTarget(event.target); + if (!markerId || !markerIndexRef.current.has(markerId)) return; + event.preventDefault(); + event.stopPropagation(); + onMarkerClickRef.current?.(markerId); + }; + container.addEventListener("pointerdown", stopCityPointer, true); + container.addEventListener("mousedown", stopCityPointer, true); + container.addEventListener("touchstart", stopCityPointer, true); + container.addEventListener("click", selectCityFromDom, true); + markersLayerRef.current = L.layerGroup().addTo(map); polylinesLayerRef.current = L.layerGroup().addTo(map); popupsLayerRef.current = L.layerGroup().addTo(map); @@ -356,6 +384,10 @@ export const MapCanvas: FC = ({ mapRef.current = map; return () => { + container.removeEventListener("pointerdown", stopCityPointer, true); + container.removeEventListener("mousedown", stopCityPointer, true); + container.removeEventListener("touchstart", stopCityPointer, true); + container.removeEventListener("click", selectCityFromDom, true); map.remove(); mapRef.current = null; markersLayerRef.current = null; @@ -407,9 +439,35 @@ export const MapCanvas: FC = ({ }); const tooltip = marker.getTooltip(); + const attachTooltipDomHandlers = () => { + const element = tooltip?.getElement(); + if (!element || element.dataset.markerClickBound === m.id) return; + + element.dataset.markerClickBound = m.id; + element.dataset.markerId = m.id; + + const stopTooltipPointerEvent = (event: Event) => { + event.stopPropagation(); + }; + const selectFromTooltip = (event: Event) => { + event.preventDefault(); + event.stopPropagation(); + onMarkerClickRef.current?.(m.id); + }; + + element.addEventListener("pointerdown", stopTooltipPointerEvent, true); + element.addEventListener("pointerup", stopTooltipPointerEvent, true); + element.addEventListener("mousedown", stopTooltipPointerEvent, true); + element.addEventListener("mouseup", stopTooltipPointerEvent, true); + element.addEventListener("touchstart", stopTooltipPointerEvent, true); + element.addEventListener("touchend", stopTooltipPointerEvent, true); + element.addEventListener("dblclick", stopTooltipPointerEvent, true); + element.addEventListener("click", selectFromTooltip, true); + }; const stopTooltipEvent = (event: L.LeafletMouseEvent) => { L.DomEvent.stop(event); }; + marker.on("tooltipopen", attachTooltipDomHandlers); tooltip?.on("mousedown", stopTooltipEvent); tooltip?.on("mouseup", stopTooltipEvent); tooltip?.on("dblclick", stopTooltipEvent); diff --git a/tests/e2e/flights-map.spec.ts b/tests/e2e/flights-map.spec.ts index cbeb7c9b..4bcd454c 100644 --- a/tests/e2e/flights-map.spec.ts +++ b/tests/e2e/flights-map.spec.ts @@ -102,6 +102,56 @@ async function routeFlightsMapDirectRouteFixtures( return destinationRequests; } +async function routeFlightsMapMoscowSpiderFixtures( + page: import("@playwright/test").Page, +): Promise { + await routeAppSettingsFixture(page); + await routeDictionaryFixtures(page); + + const destinationRequests: string[] = []; + + await page.route("**/api/flights/1/*/destinations?**", async (route) => { + const url = new URL(route.request().url()); + destinationRequests.push(url.toString()); + const departure = url.searchParams.get("departure"); + const arrival = url.searchParams.get("arrival"); + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + routes: + departure === "MOW" && !arrival + ? [ + { route: ["SVO", "LED"], isDirect: true }, + { route: ["SVO", "MLE"], isDirect: true }, + ] + : [], + }, + }), + }); + }); + + await page.route("**/api/flights/v1/*/days/**/flights-map/", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ days: "1".repeat(200) }), + }), + ); + + await page.route("**/map/api/tile/**", (route) => + route.fulfill({ + status: 200, + contentType: "image/gif", + body: Buffer.from("R0lGODlhAQABAAAAACw=", "base64"), + }), + ); + + return destinationRequests; +} + async function selectCity( page: import("@playwright/test").Page, inputTestId: string, @@ -157,6 +207,70 @@ test.describe("Flights Map", () => { expect(consoleMessages).toEqual([]); }); + test("first click on a city label selects the city and draws spider routes", async ({ + page, + consoleMessages, + }) => { + const destinationRequests = await routeFlightsMapMoscowSpiderFixtures(page); + + await page.goto("/ru-ru/flights-map"); + await expect(page.getByTestId("flights-map-start")).toBeVisible(); + + const moscowLabel = page.locator(".leaflet-tooltip.city-label", { + hasText: "Москва", + }); + await expect(moscowLabel).toHaveCount(1, { timeout: 10000 }); + + await moscowLabel.click(); + + await expect(page.getByTestId("fm-departure-input").locator("input")).toHaveValue( + "Москва", + ); + await expect(page.getByTestId("fm-arrival-input").locator("input")).toHaveValue(""); + await expect + .poll(() => + destinationRequests.some( + (url) => + url.includes("departure=MOW") && + !url.includes("arrival="), + ), + ) + .toBe(true); + await expect(page.locator("path.leaflet-interactive")).not.toHaveCount(0); + + expect(consoleMessages).toEqual([]); + }); + + test("first click on a city marker selects the city", async ({ + page, + consoleMessages, + }) => { + const destinationRequests = await routeFlightsMapMoscowSpiderFixtures(page); + + await page.goto("/ru-ru/flights-map"); + await expect(page.getByTestId("flights-map-start")).toBeVisible(); + + const moscowMarker = page.locator('img.leaflet-marker-icon[title="MOW"]'); + await expect(moscowMarker).toHaveCount(1, { timeout: 10000 }); + + await moscowMarker.click(); + + await expect(page.getByTestId("fm-departure-input").locator("input")).toHaveValue( + "Москва", + ); + await expect + .poll(() => + destinationRequests.some( + (url) => + url.includes("departure=MOW") && + !url.includes("arrival="), + ), + ) + .toBe(true); + + expect(consoleMessages).toEqual([]); + }); + test("transfer-only checkbox stays off while a direct route search is still loading", async ({ page, consoleMessages,