Add categorized rendering to MapCanvas: zoom-tier layers, highlight layer, tooltip rules
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user