Add categorized rendering to MapCanvas: zoom-tier layers, highlight layer, tooltip rules

This commit is contained in:
2026-04-17 08:41:42 +03:00
parent 855a7c31e6
commit 725a048315
2 changed files with 498 additions and 5 deletions
@@ -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<typeof vi.fn>;
on: ReturnType<typeof vi.fn>;
bindTooltip: ReturnType<typeof vi.fn>;
openTooltip: ReturnType<typeof vi.fn>;
closeTooltip: ReturnType<typeof vi.fn>;
setIcon: ReturnType<typeof vi.fn>;
setLatLng: ReturnType<typeof vi.fn>;
getLatLng: ReturnType<typeof vi.fn>;
options: { title?: string };
}
interface MockLayerGroup {
_id: number;
_markers: MockMarker[];
addTo: ReturnType<typeof vi.fn>;
addLayer: ReturnType<typeof vi.fn>;
removeLayer: ReturnType<typeof vi.fn>;
clearLayers: ReturnType<typeof vi.fn>;
hasLayer: ReturnType<typeof vi.fn>;
}
interface MockMap {
_zoom: number;
_eventHandlers: Record<string, Array<() => void>>;
_addedLayers: Set<MockLayerGroup>;
on: ReturnType<typeof vi.fn>;
remove: ReturnType<typeof vi.fn>;
addLayer: ReturnType<typeof vi.fn>;
removeLayer: ReturnType<typeof vi.fn>;
hasLayer: ReturnType<typeof vi.fn>;
getZoom: ReturnType<typeof vi.fn>;
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<MockLayerGroup>(),
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> = {},
): IMapMarker {
return {
id,
lat: 55,
lng: 37,
style: "blue-small",
label: id,
tooltipPermanent: true,
zoomLevel,
countryType,
...overrides,
};
}
function renderCanvas(
markers: IMapMarker[],
props: Partial<React.ComponentProps<typeof MapCanvas>> = {},
) {
return render(
<MapCanvas
markers={markers}
polylines={[]}
tileUrl="https://tiles.example/{z}/{x}/{y}"
{...props}
/>,
);
}
// ---------------------------------------------------------------------------
// 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();
});
});
@@ -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<MapCanvasProps> = ({
maxZoom = 6,
onMarkerClick,
className,
domestic = false,
international = false,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
@@ -165,6 +171,21 @@ export const MapCanvas: FC<MapCanvasProps> = ({
const polylinesLayerRef = useRef<L.LayerGroup | null>(null);
const popupsLayerRef = useRef<L.LayerGroup | null>(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<L.LayerGroup[][] | null>(null);
const highlightLayerRef = useRef<L.LayerGroup | null>(null);
const markerIndexRef = useRef<Map<string, L.Marker>>(new Map());
const highlightedIdsRef = useRef<Set<string>>(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<MapCanvasProps> = ({
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<MapCanvasProps> = ({
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<MapCanvasProps> = ({
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<MapCanvasProps> = ({
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<MapCanvasProps> = ({
}
}, [popups]);
// --- Re-sync visibility + tooltips when toggles change ---
useEffect(() => {
syncVisibility();
syncTooltips();
}, [domestic, international]);
return (
<div
ref={containerRef}