From 725a0483151a1172ee85a62dc76aacab98b47772 Mon Sep 17 00:00:00 2001 From: gnezim Date: Fri, 17 Apr 2026 08:41:42 +0300 Subject: [PATCH] Add categorized rendering to MapCanvas: zoom-tier layers, highlight layer, tooltip rules --- .../flights-map/components/MapCanvas.test.tsx | 367 ++++++++++++++++++ .../flights-map/components/MapCanvas.tsx | 136 ++++++- 2 files changed, 498 insertions(+), 5 deletions(-) create mode 100644 src/features/flights-map/components/MapCanvas.test.tsx diff --git a/src/features/flights-map/components/MapCanvas.test.tsx b/src/features/flights-map/components/MapCanvas.test.tsx new file mode 100644 index 00000000..bb4c01ee --- /dev/null +++ b/src/features/flights-map/components/MapCanvas.test.tsx @@ -0,0 +1,367 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render } from "@testing-library/react"; +import type { IMapMarker } from "../types.js"; + +// --------------------------------------------------------------------------- +// Leaflet mock +// --------------------------------------------------------------------------- + +interface MockMarker { + icon: unknown; + tooltipArgs: unknown[] | null; + addTo: ReturnType; + on: ReturnType; + bindTooltip: ReturnType; + openTooltip: ReturnType; + closeTooltip: ReturnType; + setIcon: ReturnType; + setLatLng: ReturnType; + getLatLng: ReturnType; + options: { title?: string }; +} + +interface MockLayerGroup { + _id: number; + _markers: MockMarker[]; + addTo: ReturnType; + addLayer: ReturnType; + removeLayer: ReturnType; + clearLayers: ReturnType; + hasLayer: ReturnType; +} + +interface MockMap { + _zoom: number; + _eventHandlers: Record void>>; + _addedLayers: Set; + on: ReturnType; + remove: ReturnType; + addLayer: ReturnType; + removeLayer: ReturnType; + hasLayer: ReturnType; + getZoom: ReturnType; + setZoom: (z: number) => void; + fireZoomend: () => void; +} + +const createdMarkers: MockMarker[] = []; +const createdLayerGroups: MockLayerGroup[] = []; +const createdMaps: MockMap[] = []; +let layerGroupCounter = 0; + +function resetLeafletMockState() { + createdMarkers.length = 0; + createdLayerGroups.length = 0; + createdMaps.length = 0; + layerGroupCounter = 0; +} + +vi.mock("leaflet/dist/leaflet.css", () => ({})); + +vi.mock("leaflet", () => { + function marker(_latlng: unknown, opts: { icon?: unknown; title?: string } = {}) { + const m: MockMarker = { + icon: opts.icon, + tooltipArgs: null, + addTo: vi.fn((target: { addLayer?: (l: MockMarker) => unknown }) => { + target.addLayer?.(m); + return m; + }), + on: vi.fn(() => m), + bindTooltip: vi.fn((...args: unknown[]) => { + m.tooltipArgs = args; + return m; + }), + openTooltip: vi.fn(() => m), + closeTooltip: vi.fn(() => m), + setIcon: vi.fn((ic: unknown) => { + m.icon = ic; + return m; + }), + setLatLng: vi.fn(() => m), + getLatLng: vi.fn(() => ({ lat: 0, lng: 0 })), + options: opts.title !== undefined ? { title: opts.title } : {}, + }; + createdMarkers.push(m); + return m; + } + + function layerGroup() { + const id = layerGroupCounter++; + const lg: MockLayerGroup = { + _id: id, + _markers: [], + addTo: vi.fn(() => lg), + addLayer: vi.fn((l: MockMarker) => { + lg._markers.push(l); + return lg; + }), + removeLayer: vi.fn(() => lg), + clearLayers: vi.fn(() => { + lg._markers.length = 0; + return lg; + }), + hasLayer: vi.fn(() => false), + }; + createdLayerGroups.push(lg); + return lg; + } + + function mapFn(_container: unknown, _opts: unknown) { + const m: MockMap = { + _zoom: 5, + _eventHandlers: {}, + _addedLayers: new Set(), + on: vi.fn((evt: string, fn: () => void) => { + m._eventHandlers[evt] ??= []; + m._eventHandlers[evt]!.push(fn); + return m; + }), + remove: vi.fn(), + addLayer: vi.fn((l: MockLayerGroup) => { + m._addedLayers.add(l); + return m; + }), + removeLayer: vi.fn((l: MockLayerGroup) => { + m._addedLayers.delete(l); + return m; + }), + hasLayer: vi.fn((l: MockLayerGroup) => m._addedLayers.has(l)), + getZoom: vi.fn(() => m._zoom), + setZoom: (z: number) => { + m._zoom = z; + }, + fireZoomend: () => { + (m._eventHandlers["zoomend"] ?? []).forEach((h) => h()); + }, + }; + createdMaps.push(m); + return m; + } + + function tileLayer() { + return { addTo: vi.fn(() => ({ addTo: vi.fn() })) }; + } + + function icon(opts: unknown) { + return { __icon: opts }; + } + + function latLng(lat: number, lng: number) { + return { lat, lng }; + } + + function polyline() { + return { addTo: vi.fn() }; + } + + function popup() { + return { + setLatLng: vi.fn(function (this: unknown) { return this; }), + setContent: vi.fn(function (this: unknown) { return this; }), + addTo: vi.fn(function (this: unknown) { return this; }), + }; + } + + const L = { marker, layerGroup, map: mapFn, tileLayer, icon, latLng, polyline, popup }; + return { default: L, ...L }; +}); + +// --------------------------------------------------------------------------- +// Imports AFTER the mock +// --------------------------------------------------------------------------- + +import { MapCanvas } from "./MapCanvas.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function cat( + id: string, + zoomLevel: number, + countryType: "ru" | "other", + overrides: Partial = {}, +): IMapMarker { + return { + id, + lat: 55, + lng: 37, + style: "blue-small", + label: id, + tooltipPermanent: true, + zoomLevel, + countryType, + ...overrides, + }; +} + +function renderCanvas( + markers: IMapMarker[], + props: Partial> = {}, +) { + return render( + , + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("MapCanvas — legacy (flat) path", () => { + beforeEach(() => resetLeafletMockState()); + + it("renders flat markers without creating zoom-category layers", () => { + renderCanvas([ + { id: "A", lat: 1, lng: 2, style: "blue-small", label: "A" }, + ]); + expect(createdMarkers).toHaveLength(1); + const allMarkers = createdLayerGroups.flatMap((l) => l._markers); + expect(allMarkers).toContain(createdMarkers[0]); + }); +}); + +describe("MapCanvas — categorized: visibility predicate", () => { + beforeEach(() => resetLeafletMockState()); + + it("at zoom 3, shows only layers with zoomTier <= 3", () => { + const markers = [ + cat("m2", 2, "ru"), + cat("m3", 3, "ru"), + cat("m5", 5, "ru"), + cat("m6", 6, "ru"), + ]; + renderCanvas(markers); + + const map = createdMaps[0]!; + map.setZoom(3); + map.fireZoomend(); + + const addCalls = map.addLayer.mock.calls.length; + const removeCalls = map.removeLayer.mock.calls.length; + expect(addCalls).toBeGreaterThan(0); + expect(removeCalls).toBeGreaterThan(0); + }); + + it("domestic=true hides countryType=\"other\" layers", () => { + const markers = [cat("ru2", 2, "ru"), cat("other2", 2, "other")]; + renderCanvas(markers, { domestic: true }); + + const map = createdMaps[0]!; + map.setZoom(6); + map.fireZoomend(); + + const added: MockLayerGroup[] = map.addLayer.mock.calls.map((c) => c[0] as MockLayerGroup); + const removed: MockLayerGroup[] = map.removeLayer.mock.calls.map((c) => c[0] as MockLayerGroup); + + const otherLayers = createdLayerGroups.filter((l) => + l._markers.some((m) => m.options.title === "other2"), + ); + for (const l of otherLayers) { + expect(removed).toContain(l); + } + const ruLayer = createdLayerGroups.find((l) => + l._markers.some((m) => m.options.title === "ru2"), + ); + expect(ruLayer).toBeDefined(); + expect(added).toContain(ruLayer!); + }); + + it("international=true hides countryType=\"ru\" layers", () => { + const markers = [cat("ru2", 2, "ru"), cat("other2", 2, "other")]; + renderCanvas(markers, { international: true }); + + const map = createdMaps[0]!; + map.setZoom(6); + map.fireZoomend(); + + const added: MockLayerGroup[] = map.addLayer.mock.calls.map((c) => c[0] as MockLayerGroup); + const removed: MockLayerGroup[] = map.removeLayer.mock.calls.map((c) => c[0] as MockLayerGroup); + + const ruLayers = createdLayerGroups.filter((l) => + l._markers.some((m) => m.options.title === "ru2"), + ); + for (const l of ruLayers) { + expect(removed).toContain(l); + } + const otherLayer = createdLayerGroups.find((l) => + l._markers.some((m) => m.options.title === "other2"), + ); + expect(added).toContain(otherLayer!); + }); +}); + +describe("MapCanvas — categorized: highlight layer", () => { + beforeEach(() => resetLeafletMockState()); + + it("highlighted markers go into a distinct always-on layer with orange icon", () => { + renderCanvas([cat("hi", 6, "ru", { highlighted: true })]); + + const highlightedMarker = createdMarkers[0]!; + expect(highlightedMarker.icon).toBeDefined(); + + const owningLayer = createdLayerGroups.find((l) => l._markers.includes(highlightedMarker)); + expect(owningLayer).toBeDefined(); + + const map = createdMaps[0]!; + expect(map.addLayer.mock.calls.map((c) => c[0]).includes(owningLayer!) || owningLayer!.addTo.mock.calls.length > 0).toBe(true); + }); +}); + +describe("MapCanvas — tooltip visibility rules", () => { + beforeEach(() => resetLeafletMockState()); + + it("at zoom <= 3, closes non-highlighted tooltips", () => { + const markers = [cat("a", 2, "ru"), cat("b", 2, "ru", { highlighted: true })]; + renderCanvas(markers); + + const map = createdMaps[0]!; + map.setZoom(3); + map.fireZoomend(); + + const a = createdMarkers.find((m) => m.options.title === "a")!; + const b = createdMarkers.find((m) => m.options.title === "b")!; + expect(a.closeTooltip).toHaveBeenCalled(); + expect(b.closeTooltip).not.toHaveBeenCalled(); + }); + + it("with >=2 highlighted markers, closes non-highlighted tooltips at any zoom", () => { + const markers = [ + cat("a", 2, "ru"), + cat("b", 2, "ru", { highlighted: true }), + cat("c", 2, "ru", { highlighted: true }), + ]; + renderCanvas(markers); + + const map = createdMaps[0]!; + map.setZoom(5); + map.fireZoomend(); + + const a = createdMarkers.find((m) => m.options.title === "a")!; + expect(a.closeTooltip).toHaveBeenCalled(); + }); + + it("at zoom 4 with 0-1 highlighted, opens all tooltips", () => { + const markers = [cat("a", 2, "ru"), cat("b", 2, "ru", { highlighted: true })]; + renderCanvas(markers); + + const map = createdMaps[0]!; + map.setZoom(4); + map.fireZoomend(); + + const a = createdMarkers.find((m) => m.options.title === "a")!; + const b = createdMarkers.find((m) => m.options.title === "b")!; + expect(a.openTooltip).toHaveBeenCalled(); + expect(b.openTooltip).toHaveBeenCalled(); + }); +}); diff --git a/src/features/flights-map/components/MapCanvas.tsx b/src/features/flights-map/components/MapCanvas.tsx index d7733033..00ab0f4b 100644 --- a/src/features/flights-map/components/MapCanvas.tsx +++ b/src/features/flights-map/components/MapCanvas.tsx @@ -137,6 +137,10 @@ export interface MapCanvasProps { maxZoom?: number; onMarkerClick?: (markerId: string) => void; className?: string; + /** When true, hide countryType="other" markers. */ + domestic?: boolean; + /** When true, hide countryType="ru" markers. */ + international?: boolean; } // --------------------------------------------------------------------------- @@ -158,6 +162,8 @@ export const MapCanvas: FC = ({ maxZoom = 6, onMarkerClick, className, + domestic = false, + international = false, }) => { const containerRef = useRef(null); const mapRef = useRef(null); @@ -165,6 +171,21 @@ export const MapCanvas: FC = ({ const polylinesLayerRef = useRef(null); const popupsLayerRef = useRef(null); + // Categorized rendering: 10 zoom layers + 1 highlight layer + an id→marker index. + // Indexed as zoomLayersRef.current[countryIdx][zoomTier] where: + // countryIdx: 0 = "ru", 1 = "other" + // zoomTier: 2..6 (tiers outside this range clamp to 6) + const zoomLayersRef = useRef(null); + const highlightLayerRef = useRef(null); + const markerIndexRef = useRef>(new Map()); + const highlightedIdsRef = useRef>(new Set()); + + // Track latest toggles so the zoomend handler sees current values + const domesticRef = useRef(domestic); + domesticRef.current = domestic; + const internationalRef = useRef(international); + internationalRef.current = international; + const onMarkerClickRef = useRef(onMarkerClick); onMarkerClickRef.current = onMarkerClick; @@ -175,6 +196,50 @@ export const MapCanvas: FC = ({ const minZoomRef = useRef(minZoom); const maxZoomRef = useRef(maxZoom); + function syncVisibility(): void { + const map = mapRef.current; + const zoomLayers = zoomLayersRef.current; + if (!map || !zoomLayers) return; + + const currentZoom = map.getZoom(); + const dom = domesticRef.current; + const intl = internationalRef.current; + + for (let c = 0; c < 2; c++) { + const countryType = c === 0 ? "ru" : "other"; + for (let t = 0; t < 5; t++) { + const tier = t + 2; + const layer = zoomLayers[c]![t]!; + const inRange = tier <= currentZoom; + const hiddenByIntl = intl && countryType === "ru"; + const hiddenByDom = dom && countryType === "other"; + const shouldShow = inRange && !hiddenByIntl && !hiddenByDom; + if (shouldShow) { + map.addLayer(layer); + } else { + map.removeLayer(layer); + } + } + } + } + + function syncTooltips(): void { + const map = mapRef.current; + if (!map) return; + + const currentZoom = map.getZoom(); + const highlighted = highlightedIdsRef.current; + const closeNonHighlighted = currentZoom <= 3 || highlighted.size >= 2; + + markerIndexRef.current.forEach((marker, id) => { + if (closeNonHighlighted && !highlighted.has(id)) { + marker.closeTooltip(); + } else if (!closeNonHighlighted) { + marker.openTooltip(); + } + }); + } + // --- Initialize map --- useEffect(() => { if (!containerRef.current || mapRef.current) return; @@ -199,6 +264,26 @@ export const MapCanvas: FC = ({ polylinesLayerRef.current = L.layerGroup().addTo(map); popupsLayerRef.current = L.layerGroup().addTo(map); + // Highlight layer — always on. + highlightLayerRef.current = L.layerGroup().addTo(map); + + // Zoom layers — 2 country types × 5 tiers (2..6). NOT added to the map + // yet; the visibility sync effect decides which to add based on current + // zoom + toggles. + const zoomLayers: L.LayerGroup[][] = [[], []]; + for (let c = 0; c < 2; c++) { + for (let t = 0; t < 5; t++) { + zoomLayers[c]![t] = L.layerGroup(); + } + } + zoomLayersRef.current = zoomLayers; + + // Rerun visibility + tooltip rules on every zoom change. + map.on("zoomend", () => { + syncVisibility(); + syncTooltips(); + }); + mapRef.current = map; return () => { @@ -207,19 +292,38 @@ export const MapCanvas: FC = ({ markersLayerRef.current = null; polylinesLayerRef.current = null; popupsLayerRef.current = null; + highlightLayerRef.current = null; + zoomLayersRef.current = null; + markerIndexRef.current = new Map(); + highlightedIdsRef.current = new Set(); }; }, []); // --- Sync markers --- const syncMarkers = useCallback(() => { - const layer = markersLayerRef.current; - if (!layer) return; + const flatLayer = markersLayerRef.current; + const zoomLayers = zoomLayersRef.current; + const highlightLayer = highlightLayerRef.current; + if (!flatLayer || !zoomLayers || !highlightLayer) return; - layer.clearLayers(); + // Clear everything before re-adding. + flatLayer.clearLayers(); + highlightLayer.clearLayers(); + for (let c = 0; c < 2; c++) { + for (let t = 0; t < 5; t++) { + zoomLayers[c]![t]!.clearLayers(); + } + } + markerIndexRef.current = new Map(); + highlightedIdsRef.current = new Set(); for (const m of markers) { + const isCategorized = + m.zoomLevel !== undefined && m.countryType !== undefined; + + const iconStyle: MarkerStyle = m.highlighted ? "orange" : m.style; const marker = L.marker([m.lat, m.lng], { - icon: getIcon(m.style), + icon: getIcon(iconStyle), title: m.id, }); @@ -235,8 +339,24 @@ export const MapCanvas: FC = ({ onMarkerClickRef.current?.(m.id); }); - marker.addTo(layer); + markerIndexRef.current.set(m.id, marker); + + if (m.highlighted) { + highlightedIdsRef.current.add(m.id); + marker.addTo(highlightLayer); + } else if (isCategorized) { + const countryIdx = m.countryType === "ru" ? 0 : 1; + const rawTier = m.zoomLevel!; + const tier = rawTier >= 2 && rawTier <= 6 ? rawTier : 6; + const tierIdx = tier - 2; + marker.addTo(zoomLayers[countryIdx]![tierIdx]!); + } else { + marker.addTo(flatLayer); + } } + + syncVisibility(); + syncTooltips(); }, [markers]); useEffect(() => { @@ -293,6 +413,12 @@ export const MapCanvas: FC = ({ } }, [popups]); + // --- Re-sync visibility + tooltips when toggles change --- + useEffect(() => { + syncVisibility(); + syncTooltips(); + }, [domestic, international]); + return (