diff --git a/apps/www/components/tool-ui/geo-map/_adapter.tsx b/apps/www/components/tool-ui/geo-map/_adapter.tsx index 95259a1b..75bd85a0 100644 --- a/apps/www/components/tool-ui/geo-map/_adapter.tsx +++ b/apps/www/components/tool-ui/geo-map/_adapter.tsx @@ -1,23 +1,14 @@ /** * Adapter: UI and utility re-exports for copy-standalone portability. * + * This file must stay free of leaflet: the facade imports it, and leaflet + * reads `window` at module scope. Map primitives live in `_leaflet-adapter`, + * which only client-only modules may import. + * * When copying this component to another project, update these imports * to match your project's paths: * - * cn → Your Tailwind merge utility (e.g., "@/lib/utils", "~/lib/cn") - * Leaflet → map primitives from react-leaflet + * cn → Your Tailwind merge utility (e.g., "@/lib/utils", "~/lib/cn") */ export { cn } from "@/lib/utils"; -export { - CircleMarker, - MapContainer, - Marker, - Polyline, - Popup, - TileLayer, - Tooltip, - ZoomControl, - useMap, - useMapEvents, -} from "react-leaflet"; diff --git a/apps/www/components/tool-ui/geo-map/_leaflet-adapter.tsx b/apps/www/components/tool-ui/geo-map/_leaflet-adapter.tsx new file mode 100644 index 00000000..0c361c7f --- /dev/null +++ b/apps/www/components/tool-ui/geo-map/_leaflet-adapter.tsx @@ -0,0 +1,25 @@ +/** + * Adapter: react-leaflet re-exports for copy-standalone portability. + * + * Leaflet reads `window` at module scope, so only modules that already live + * behind the client-only boundary (the engine and its overlays) may import + * from this file. The facade stays on `_adapter` and must never depend on it. + * + * When copying this component to another project, update these imports + * to match your project's paths: + * + * Leaflet → map primitives from react-leaflet + */ + +export { + CircleMarker, + MapContainer, + Marker, + Polyline, + Popup, + TileLayer, + Tooltip, + ZoomControl, + useMap, + useMapEvents, +} from "react-leaflet"; diff --git a/apps/www/components/tool-ui/geo-map/geo-map-engine.tsx b/apps/www/components/tool-ui/geo-map/geo-map-engine.tsx index 798beee2..e4b23fc2 100644 --- a/apps/www/components/tool-ui/geo-map/geo-map-engine.tsx +++ b/apps/www/components/tool-ui/geo-map/geo-map-engine.tsx @@ -12,7 +12,7 @@ import { ZoomControl, useMap, useMapEvents, -} from "./_adapter"; +} from "./_leaflet-adapter"; import { createClusterIcon, resolveMarkerIcon } from "./geo-map-icons"; import { GeoMapOverlays } from "./geo-map-overlays"; import type { diff --git a/apps/www/components/tool-ui/geo-map/geo-map-overlays.tsx b/apps/www/components/tool-ui/geo-map/geo-map-overlays.tsx index a9cbe105..f714bfd8 100644 --- a/apps/www/components/tool-ui/geo-map/geo-map-overlays.tsx +++ b/apps/www/components/tool-ui/geo-map/geo-map-overlays.tsx @@ -2,7 +2,8 @@ import { useMemo, useState } from "react"; -import { Popup, Tooltip, cn } from "./_adapter"; +import { cn } from "./_adapter"; +import { Popup, Tooltip } from "./_leaflet-adapter"; function GeoMapPopupContent({ label, diff --git a/apps/www/components/tool-ui/geo-map/geo-map.tsx b/apps/www/components/tool-ui/geo-map/geo-map.tsx index f56084ed..65f9b703 100644 --- a/apps/www/components/tool-ui/geo-map/geo-map.tsx +++ b/apps/www/components/tool-ui/geo-map/geo-map.tsx @@ -1,11 +1,18 @@ "use client"; +import dynamic from "next/dynamic"; import { memo, useEffect, useState } from "react"; import { cn } from "./_adapter"; -import { GeoMapEngine } from "./geo-map-engine"; import styles from "./geo-map-theme.module.css"; import type { GeoMapProps, GeoMapStyle } from "./schema"; +// Leaflet touches `window` at module scope, so the engine must never be +// evaluated during SSR; every GeoMap consumer inherits this guard. +const GeoMapEngine = dynamic( + () => import("./geo-map-engine").then((m) => m.GeoMapEngine), + { ssr: false }, +); + const LIGHT_TILE_URL = "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"; const DARK_TILE_URL = diff --git a/apps/www/lib/tests/geo-map-ssr.test.ts b/apps/www/lib/tests/geo-map-ssr.test.ts new file mode 100644 index 00000000..6117566c --- /dev/null +++ b/apps/www/lib/tests/geo-map-ssr.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; + +describe("geo-map SSR safety", () => { + it("imports the facade without evaluating leaflet", async () => { + expect(globalThis.window).toBeUndefined(); + await expect( + import("@/components/tool-ui/geo-map"), + ).resolves.toHaveProperty("GeoMap"); + }); +}); diff --git a/apps/www/lib/tests/tool-ui/geo-map/spatial.test.ts b/apps/www/lib/tests/tool-ui/geo-map/spatial.test.ts index 2153cc64..0c46e5f8 100644 --- a/apps/www/lib/tests/tool-ui/geo-map/spatial.test.ts +++ b/apps/www/lib/tests/tool-ui/geo-map/spatial.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, vi } from "vitest"; -vi.mock("@/components/tool-ui/geo-map/_adapter", () => ({ +vi.mock("@/components/tool-ui/geo-map/_leaflet-adapter", () => ({ CircleMarker: () => null, MapContainer: () => null, Marker: () => null, diff --git a/apps/www/public/r/geo-map.json b/apps/www/public/r/geo-map.json index aaec36ae..d9660b27 100644 --- a/apps/www/public/r/geo-map.json +++ b/apps/www/public/r/geo-map.json @@ -6,6 +6,7 @@ "description": "Display geolocated entities and fleet positions.", "dependencies": [ "leaflet", + "next", "react-leaflet", "supercluster", "zod" @@ -15,13 +16,19 @@ "path": "components/tool-ui/geo-map/_adapter.tsx", "type": "registry:component", "target": "components/tool-ui/geo-map/_adapter.tsx", - "content": "/**\n * Adapter: UI and utility re-exports for copy-standalone portability.\n *\n * When copying this component to another project, update these imports\n * to match your project's paths:\n *\n * cn → Your Tailwind merge utility (e.g., \"@/lib/utils\", \"~/lib/cn\")\n * Leaflet → map primitives from react-leaflet\n */\n\nexport { cn } from \"@/lib/utils\";\nexport {\n CircleMarker,\n MapContainer,\n Marker,\n Polyline,\n Popup,\n TileLayer,\n Tooltip,\n ZoomControl,\n useMap,\n useMapEvents,\n} from \"react-leaflet\";\n" + "content": "/**\n * Adapter: UI and utility re-exports for copy-standalone portability.\n *\n * This file must stay free of leaflet: the facade imports it, and leaflet\n * reads `window` at module scope. Map primitives live in `_leaflet-adapter`,\n * which only client-only modules may import.\n *\n * When copying this component to another project, update these imports\n * to match your project's paths:\n *\n * cn → Your Tailwind merge utility (e.g., \"@/lib/utils\", \"~/lib/cn\")\n */\n\nexport { cn } from \"@/lib/utils\";\n" + }, + { + "path": "components/tool-ui/geo-map/_leaflet-adapter.tsx", + "type": "registry:component", + "target": "components/tool-ui/geo-map/_leaflet-adapter.tsx", + "content": "/**\n * Adapter: react-leaflet re-exports for copy-standalone portability.\n *\n * Leaflet reads `window` at module scope, so only modules that already live\n * behind the client-only boundary (the engine and its overlays) may import\n * from this file. The facade stays on `_adapter` and must never depend on it.\n *\n * When copying this component to another project, update these imports\n * to match your project's paths:\n *\n * Leaflet → map primitives from react-leaflet\n */\n\nexport {\n CircleMarker,\n MapContainer,\n Marker,\n Polyline,\n Popup,\n TileLayer,\n Tooltip,\n ZoomControl,\n useMap,\n useMapEvents,\n} from \"react-leaflet\";\n" }, { "path": "components/tool-ui/geo-map/geo-map-engine.tsx", "type": "registry:component", "target": "components/tool-ui/geo-map/geo-map-engine.tsx", - "content": "\"use client\";\n\nimport type { Map as LeafletMap } from \"leaflet\";\nimport { memo, useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport Supercluster from \"supercluster\";\nimport {\n CircleMarker,\n MapContainer,\n Marker,\n Polyline,\n TileLayer,\n ZoomControl,\n useMap,\n useMapEvents,\n} from \"./_adapter\";\nimport { createClusterIcon, resolveMarkerIcon } from \"./geo-map-icons\";\nimport { GeoMapOverlays } from \"./geo-map-overlays\";\nimport type {\n GeoMapClustering,\n GeoMapFitTarget,\n GeoMapMarker,\n GeoMapRoute,\n GeoMapViewport,\n} from \"./schema\";\n\nconst TILE_ATTRIBUTION =\n '© OpenStreetMap contributors © CARTO';\nconst ROUTE_DEFAULT_COLOR = \"var(--primary)\";\nconst ROUTE_DEFAULT_WEIGHT = 3;\nconst ROUTE_DEFAULT_OPACITY = 0.85;\nconst EMPTY_ROUTES: GeoMapRoute[] = [];\n\nconst CLUSTER_RADIUS_DEFAULT = 60;\nconst CLUSTER_MAX_ZOOM_DEFAULT = 16;\nconst CLUSTER_MIN_POINTS_DEFAULT = 2;\n\nconst DEFAULT_CENTER: [number, number] = [20, 0];\nexport const DEFAULT_VIEW_ZOOM = 2;\nconst SINGLE_LOCATION_ZOOM = 13;\nconst DEFAULT_VIEWPORT_PADDING = 32;\n\ntype LeafletRuntime = Pick<\n typeof import(\"leaflet\"),\n \"divIcon\" | \"latLngBounds\"\n>;\n\nexport type GeoMapBbox = [\n west: number,\n south: number,\n east: number,\n north: number,\n];\nexport type GeoMapLatLng = [lat: number, lng: number];\n\nexport type GeoMapClusterProperties = {\n cluster?: boolean;\n cluster_id?: number;\n point_count?: number;\n markerId?: string;\n};\n\nexport type GeoMapClusterFeature = GeoJSON.Feature<\n GeoJSON.Point,\n GeoMapClusterProperties\n>;\n\ntype MarkerClusterPointProperties = GeoMapClusterProperties & {\n markerId?: string;\n marker?: GeoMapMarker;\n};\n\ntype MapViewportState = {\n bbox: GeoMapBbox;\n zoom: number;\n};\n\nfunction roundCoordinate(value: number): number {\n return Math.round(value * 1_000_000) / 1_000_000;\n}\n\nfunction normalizeViewportState(state: MapViewportState): MapViewportState {\n return {\n bbox: [\n roundCoordinate(state.bbox[0]),\n roundCoordinate(state.bbox[1]),\n roundCoordinate(state.bbox[2]),\n roundCoordinate(state.bbox[3]),\n ],\n zoom: state.zoom,\n };\n}\n\nfunction areViewportStatesEqual(\n a: MapViewportState | null,\n b: MapViewportState,\n): boolean {\n if (!a) {\n return false;\n }\n\n return (\n a.zoom === b.zoom &&\n a.bbox[0] === b.bbox[0] &&\n a.bbox[1] === b.bbox[1] &&\n a.bbox[2] === b.bbox[2] &&\n a.bbox[3] === b.bbox[3]\n );\n}\n\nfunction serializeFitPoints(points: [number, number][]): string {\n return points\n .map(([lat, lng]) => `${roundCoordinate(lat)},${roundCoordinate(lng)}`)\n .join(\"|\");\n}\n\nfunction readViewportState(map: LeafletMap): MapViewportState {\n const bounds = map.getBounds();\n return normalizeViewportState({\n bbox: [\n bounds.getWest(),\n bounds.getSouth(),\n bounds.getEast(),\n bounds.getNorth(),\n ],\n zoom: Math.round(map.getZoom()),\n });\n}\n\nexport function collectFitPoints(\n markers: GeoMapMarker[],\n routes: GeoMapRoute[],\n target: GeoMapFitTarget,\n): GeoMapLatLng[] {\n const markerPoints =\n target === \"markers\" || target === \"all\"\n ? markers.map((marker) => [marker.lat, marker.lng] as GeoMapLatLng)\n : [];\n\n const routePoints =\n target === \"routes\" || target === \"all\"\n ? routes.flatMap((route) =>\n route.points.map((point) => [point.lat, point.lng] as GeoMapLatLng),\n )\n : [];\n\n return [...markerPoints, ...routePoints];\n}\n\nexport function resolveFitPointsWithFallback(\n markers: GeoMapMarker[],\n routes: GeoMapRoute[],\n target: GeoMapFitTarget,\n): GeoMapLatLng[] {\n const selected = collectFitPoints(markers, routes, target);\n if (selected.length > 0) {\n return selected;\n }\n\n if (target !== \"markers\") {\n return collectFitPoints(markers, routes, \"markers\");\n }\n\n return [];\n}\n\nexport function splitDatelineBbox(bbox: GeoMapBbox): GeoMapBbox[] {\n const [west, south, east, north] = bbox;\n\n if (west <= east) {\n return [bbox];\n }\n\n return [\n [west, south, 180, north],\n [-180, south, east, north],\n ];\n}\n\nfunction getClusterFeatureKey(feature: GeoMapClusterFeature): string {\n const properties = feature.properties ?? {};\n\n if (properties.cluster && typeof properties.cluster_id === \"number\") {\n return `cluster:${properties.cluster_id}`;\n }\n\n if (\n typeof properties.markerId === \"string\" &&\n properties.markerId.length > 0\n ) {\n return `marker:${properties.markerId}`;\n }\n\n if (feature.id !== undefined && feature.id !== null) {\n return `id:${String(feature.id)}`;\n }\n\n const [lng, lat] = feature.geometry.coordinates;\n return `point:${lat}:${lng}`;\n}\n\nfunction dedupeClusterFeatures(\n features: GeoMapClusterFeature[],\n): GeoMapClusterFeature[] {\n const seen = new Set();\n const deduped: GeoMapClusterFeature[] = [];\n\n features.forEach((feature) => {\n const key = getClusterFeatureKey(feature);\n if (seen.has(key)) {\n return;\n }\n\n seen.add(key);\n deduped.push(feature);\n });\n\n return deduped;\n}\n\nexport function getClustersForDatelineAwareBbox(\n bbox: GeoMapBbox,\n zoom: number,\n getClustersForBbox: (\n candidateBbox: GeoMapBbox,\n zoom: number,\n ) => GeoMapClusterFeature[],\n): GeoMapClusterFeature[] {\n const queried = splitDatelineBbox(bbox).flatMap((candidateBbox) =>\n getClustersForBbox(candidateBbox, zoom),\n );\n\n return dedupeClusterFeatures(queried);\n}\n\nexport function toSafeExpansionZoom(\n zoom: number,\n options?: { minZoom?: number; maxZoom?: number; fallback?: number },\n): number {\n const minZoom = options?.minZoom ?? 1;\n const maxZoom = options?.maxZoom ?? 22;\n const fallback = options?.fallback ?? 2;\n\n if (!Number.isFinite(zoom)) {\n return fallback;\n }\n\n return Math.min(maxZoom, Math.max(minZoom, Math.round(zoom)));\n}\n\nfunction resolveInitialView(\n markers: GeoMapMarker[],\n routes: GeoMapRoute[],\n viewport: GeoMapViewport | undefined,\n): { center: [number, number]; zoom: number } {\n if (viewport?.mode === \"center\") {\n return {\n center: [viewport.center.lat, viewport.center.lng],\n zoom: viewport.zoom,\n };\n }\n\n const fitTarget = viewport?.target ?? \"all\";\n const fitPoints = resolveFitPointsWithFallback(markers, routes, fitTarget);\n\n if (fitPoints.length === 1) {\n return {\n center: [fitPoints[0][0], fitPoints[0][1]],\n zoom: viewport?.maxZoom\n ? Math.min(SINGLE_LOCATION_ZOOM, viewport.maxZoom)\n : SINGLE_LOCATION_ZOOM,\n };\n }\n\n return { center: DEFAULT_CENTER, zoom: DEFAULT_VIEW_ZOOM };\n}\n\nfunction ViewportController({\n markers,\n routes,\n viewport,\n leafletRuntime,\n}: {\n markers: GeoMapMarker[];\n routes: GeoMapRoute[];\n viewport: GeoMapViewport | undefined;\n leafletRuntime: LeafletRuntime;\n}) {\n const map = useMap();\n const lastAppliedViewportRef = useRef(null);\n\n useEffect(() => {\n lastAppliedViewportRef.current = null;\n }, [map]);\n\n useEffect(() => {\n if (viewport?.mode === \"center\") {\n const viewportKey = `center:${roundCoordinate(viewport.center.lat)}:${roundCoordinate(viewport.center.lng)}:${viewport.zoom}`;\n if (lastAppliedViewportRef.current === viewportKey) {\n return;\n }\n\n lastAppliedViewportRef.current = viewportKey;\n map.setView([viewport.center.lat, viewport.center.lng], viewport.zoom);\n return;\n }\n\n const fitTarget = viewport?.target ?? \"all\";\n const fitPoints = resolveFitPointsWithFallback(markers, routes, fitTarget);\n if (fitPoints.length === 0) {\n return;\n }\n\n const maxZoom = viewport?.maxZoom;\n if (fitPoints.length === 1) {\n const [lat, lng] = fitPoints[0];\n const zoom = maxZoom\n ? Math.min(SINGLE_LOCATION_ZOOM, maxZoom)\n : SINGLE_LOCATION_ZOOM;\n const viewportKey = `fit-single:${roundCoordinate(lat)}:${roundCoordinate(lng)}:${zoom}`;\n if (lastAppliedViewportRef.current === viewportKey) {\n return;\n }\n\n lastAppliedViewportRef.current = viewportKey;\n map.setView([lat, lng], zoom);\n return;\n }\n\n const padding = viewport?.padding ?? DEFAULT_VIEWPORT_PADDING;\n const viewportKey = `fit:${fitTarget}:${padding}:${maxZoom ?? \"none\"}:${serializeFitPoints(fitPoints)}`;\n if (lastAppliedViewportRef.current === viewportKey) {\n return;\n }\n\n lastAppliedViewportRef.current = viewportKey;\n const bounds = leafletRuntime.latLngBounds(fitPoints);\n map.fitBounds(bounds, {\n maxZoom,\n padding: [padding, padding],\n });\n }, [leafletRuntime, map, markers, routes, viewport]);\n\n return null;\n}\n\nfunction MapObserver({\n onViewportChange,\n onMapReady,\n}: {\n onViewportChange: (state: MapViewportState) => void;\n onMapReady: (map: LeafletMap) => void;\n}) {\n const map = useMapEvents({\n moveend: () => {\n onViewportChange(readViewportState(map));\n },\n zoomend: () => {\n onViewportChange(readViewportState(map));\n },\n });\n\n useEffect(() => {\n onMapReady(map);\n onViewportChange(readViewportState(map));\n }, [map, onMapReady, onViewportChange]);\n\n return null;\n}\n\nfunction resolveMarkerAriaLabel(marker: GeoMapMarker): string {\n if (marker.label && marker.description) {\n return `${marker.label}. ${marker.description}`;\n }\n\n return (\n marker.label ??\n marker.description ??\n `Marker at ${marker.lat.toFixed(4)}, ${marker.lng.toFixed(4)}`\n );\n}\n\nexport const GeoMapEngine = memo(function GeoMapEngine({\n id,\n markers,\n routes,\n clustering,\n viewport,\n showZoomControl,\n tileUrl,\n mapAriaLabel,\n tooltipClassName,\n popupClassName,\n onMarkerClick,\n onRouteClick,\n onReadyChange,\n}: {\n id: string;\n markers: GeoMapMarker[];\n routes?: GeoMapRoute[];\n clustering?: GeoMapClustering;\n viewport?: GeoMapViewport;\n showZoomControl: boolean;\n tileUrl: string;\n mapAriaLabel: string;\n tooltipClassName?: string;\n popupClassName?: string;\n onMarkerClick?: (marker: GeoMapMarker) => void;\n onRouteClick?: (route: GeoMapRoute) => void;\n onReadyChange?: (isReady: boolean) => void;\n}) {\n const resolvedRoutes = routes ?? EMPTY_ROUTES;\n const [leafletRuntime, setLeafletRuntime] = useState(\n null,\n );\n const [mapInstance, setMapInstance] = useState(null);\n const [viewportState, setViewportState] = useState(\n null,\n );\n\n const handleViewportChange = useCallback((nextState: MapViewportState) => {\n const normalized = normalizeViewportState(nextState);\n setViewportState((previousState) =>\n areViewportStatesEqual(previousState, normalized)\n ? previousState\n : normalized,\n );\n }, []);\n\n useEffect(() => {\n let isActive = true;\n\n void import(\"leaflet\").then((module) => {\n if (!isActive) {\n return;\n }\n\n setLeafletRuntime({\n divIcon: module.divIcon,\n latLngBounds: module.latLngBounds,\n });\n });\n\n return () => {\n isActive = false;\n };\n }, []);\n\n const isReady = leafletRuntime !== null;\n\n useEffect(() => {\n onReadyChange?.(isReady);\n }, [isReady, onReadyChange]);\n\n useEffect(() => {\n if (!mapInstance) {\n return;\n }\n\n const container = mapInstance.getContainer();\n container.setAttribute(\"role\", \"region\");\n container.setAttribute(\"aria-label\", mapAriaLabel);\n }, [mapAriaLabel, mapInstance]);\n\n useEffect(() => {\n if (!mapInstance) {\n return;\n }\n\n const handleEscape = (event: KeyboardEvent) => {\n if (event.key === \"Escape\") {\n mapInstance.closePopup();\n }\n };\n\n document.addEventListener(\"keydown\", handleEscape);\n return () => {\n document.removeEventListener(\"keydown\", handleEscape);\n };\n }, [mapInstance]);\n\n const initialView = useMemo(\n () => resolveInitialView(markers, resolvedRoutes, viewport),\n [markers, resolvedRoutes, viewport],\n );\n\n const markerById = useMemo(() => {\n const map = new Map();\n markers.forEach((marker, index) => {\n map.set(marker.id ?? `marker-${index}`, marker);\n });\n return map;\n }, [markers]);\n\n const clusterConfig = useMemo(\n () => ({\n enabled: clustering?.enabled === true,\n radius: clustering?.radius ?? CLUSTER_RADIUS_DEFAULT,\n maxZoom: clustering?.maxZoom ?? CLUSTER_MAX_ZOOM_DEFAULT,\n minPoints: clustering?.minPoints ?? CLUSTER_MIN_POINTS_DEFAULT,\n }),\n [clustering],\n );\n\n const clusterIndex = useMemo(() => {\n if (!clusterConfig.enabled) {\n return null;\n }\n\n const index = new Supercluster({\n radius: clusterConfig.radius,\n maxZoom: clusterConfig.maxZoom,\n minPoints: clusterConfig.minPoints,\n });\n\n const points = markers.map((marker, index) => {\n const markerId = marker.id ?? `marker-${index}`;\n return {\n type: \"Feature\" as const,\n id: markerId,\n geometry: {\n type: \"Point\" as const,\n coordinates: [marker.lng, marker.lat] as [number, number],\n },\n properties: {\n markerId,\n marker,\n },\n };\n });\n\n index.load(points);\n return index;\n }, [\n clusterConfig.enabled,\n clusterConfig.maxZoom,\n clusterConfig.minPoints,\n clusterConfig.radius,\n markers,\n ]);\n\n const clusteredFeatures = useMemo(() => {\n if (!clusterConfig.enabled || !clusterIndex || !viewportState) {\n return [] as GeoMapClusterFeature[];\n }\n\n return getClustersForDatelineAwareBbox(\n viewportState.bbox,\n viewportState.zoom,\n (bbox, zoom) =>\n clusterIndex.getClusters(bbox, zoom) as GeoMapClusterFeature[],\n );\n }, [clusterConfig.enabled, clusterIndex, viewportState]);\n\n const renderMarker = useCallback(\n (\n marker: GeoMapMarker,\n markerKey: string,\n markerPositionOverride?: [number, number],\n ) => {\n const markerPosition: [number, number] = markerPositionOverride ?? [\n marker.lat,\n marker.lng,\n ];\n const tooltipMode = marker.tooltip ?? \"hover\";\n const tooltipContent = marker.label ?? marker.description;\n const icon = marker.icon;\n const markerAriaLabel = resolveMarkerAriaLabel(marker);\n\n if (!leafletRuntime) {\n return null;\n }\n\n const leafletIcon = resolveMarkerIcon(icon, leafletRuntime);\n if (leafletIcon) {\n return (\n onMarkerClick?.(marker),\n }}\n >\n \n \n );\n }\n\n const markerStroke =\n icon?.type === \"dot\"\n ? (icon.borderColor ?? \"var(--border)\")\n : \"var(--border)\";\n const markerFill =\n icon?.type === \"dot\"\n ? (icon.color ?? \"var(--primary)\")\n : \"var(--primary)\";\n const markerRadius = icon?.type === \"dot\" ? (icon.radius ?? 7) : 7;\n\n return (\n onMarkerClick?.(marker),\n }}\n >\n \n \n );\n },\n [leafletRuntime, onMarkerClick, popupClassName, tooltipClassName],\n );\n\n if (!leafletRuntime) {\n return null;\n }\n\n return (\n \n \n {showZoomControl && }\n \n \n\n {resolvedRoutes.map((route, routeIndex) => {\n const routeKey = route.id ?? `${id}-route-${routeIndex}`;\n const positions = route.points.map((point) => [\n point.lat,\n point.lng,\n ]) as [number, number][];\n const tooltipMode = route.tooltip ?? \"hover\";\n const tooltipContent = route.label ?? route.description;\n\n return (\n onRouteClick?.(route),\n }}\n >\n \n \n );\n })}\n\n {clusterConfig.enabled && clusterIndex && viewportState\n ? clusteredFeatures.map((feature, index) => {\n const [lng, lat] = feature.geometry.coordinates;\n const properties = (feature.properties ??\n {}) as MarkerClusterPointProperties;\n\n if (\n properties.cluster &&\n typeof properties.cluster_id === \"number\"\n ) {\n const pointCount = properties.point_count ?? 0;\n const clusterId = properties.cluster_id;\n const clusterIcon = createClusterIcon(pointCount, leafletRuntime);\n const clusterAriaLabel = `Cluster containing ${pointCount} locations`;\n\n return (\n {\n if (!mapInstance) {\n return;\n }\n\n const expansionZoom = toSafeExpansionZoom(\n clusterIndex.getClusterExpansionZoom(clusterId),\n {\n maxZoom: 22,\n fallback:\n (viewportState.zoom ?? DEFAULT_VIEW_ZOOM) + 2,\n },\n );\n mapInstance.flyTo([lat, lng], expansionZoom);\n },\n }}\n />\n );\n }\n\n const marker =\n properties.marker ??\n markerById.get(properties.markerId ?? `marker-${index}`);\n if (!marker) {\n return null;\n }\n\n const markerKey =\n marker.id ?? properties.markerId ?? `${id}-cluster-leaf-${index}`;\n return renderMarker(marker, markerKey, [lat, lng]);\n })\n : markers.map((marker, index) =>\n renderMarker(marker, marker.id ?? `${id}-marker-${index}`),\n )}\n \n );\n});\n" + "content": "\"use client\";\n\nimport type { Map as LeafletMap } from \"leaflet\";\nimport { memo, useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport Supercluster from \"supercluster\";\nimport {\n CircleMarker,\n MapContainer,\n Marker,\n Polyline,\n TileLayer,\n ZoomControl,\n useMap,\n useMapEvents,\n} from \"./_leaflet-adapter\";\nimport { createClusterIcon, resolveMarkerIcon } from \"./geo-map-icons\";\nimport { GeoMapOverlays } from \"./geo-map-overlays\";\nimport type {\n GeoMapClustering,\n GeoMapFitTarget,\n GeoMapMarker,\n GeoMapRoute,\n GeoMapViewport,\n} from \"./schema\";\n\nconst TILE_ATTRIBUTION =\n '© OpenStreetMap contributors © CARTO';\nconst ROUTE_DEFAULT_COLOR = \"var(--primary)\";\nconst ROUTE_DEFAULT_WEIGHT = 3;\nconst ROUTE_DEFAULT_OPACITY = 0.85;\nconst EMPTY_ROUTES: GeoMapRoute[] = [];\n\nconst CLUSTER_RADIUS_DEFAULT = 60;\nconst CLUSTER_MAX_ZOOM_DEFAULT = 16;\nconst CLUSTER_MIN_POINTS_DEFAULT = 2;\n\nconst DEFAULT_CENTER: [number, number] = [20, 0];\nexport const DEFAULT_VIEW_ZOOM = 2;\nconst SINGLE_LOCATION_ZOOM = 13;\nconst DEFAULT_VIEWPORT_PADDING = 32;\n\ntype LeafletRuntime = Pick<\n typeof import(\"leaflet\"),\n \"divIcon\" | \"latLngBounds\"\n>;\n\nexport type GeoMapBbox = [\n west: number,\n south: number,\n east: number,\n north: number,\n];\nexport type GeoMapLatLng = [lat: number, lng: number];\n\nexport type GeoMapClusterProperties = {\n cluster?: boolean;\n cluster_id?: number;\n point_count?: number;\n markerId?: string;\n};\n\nexport type GeoMapClusterFeature = GeoJSON.Feature<\n GeoJSON.Point,\n GeoMapClusterProperties\n>;\n\ntype MarkerClusterPointProperties = GeoMapClusterProperties & {\n markerId?: string;\n marker?: GeoMapMarker;\n};\n\ntype MapViewportState = {\n bbox: GeoMapBbox;\n zoom: number;\n};\n\nfunction roundCoordinate(value: number): number {\n return Math.round(value * 1_000_000) / 1_000_000;\n}\n\nfunction normalizeViewportState(state: MapViewportState): MapViewportState {\n return {\n bbox: [\n roundCoordinate(state.bbox[0]),\n roundCoordinate(state.bbox[1]),\n roundCoordinate(state.bbox[2]),\n roundCoordinate(state.bbox[3]),\n ],\n zoom: state.zoom,\n };\n}\n\nfunction areViewportStatesEqual(\n a: MapViewportState | null,\n b: MapViewportState,\n): boolean {\n if (!a) {\n return false;\n }\n\n return (\n a.zoom === b.zoom &&\n a.bbox[0] === b.bbox[0] &&\n a.bbox[1] === b.bbox[1] &&\n a.bbox[2] === b.bbox[2] &&\n a.bbox[3] === b.bbox[3]\n );\n}\n\nfunction serializeFitPoints(points: [number, number][]): string {\n return points\n .map(([lat, lng]) => `${roundCoordinate(lat)},${roundCoordinate(lng)}`)\n .join(\"|\");\n}\n\nfunction readViewportState(map: LeafletMap): MapViewportState {\n const bounds = map.getBounds();\n return normalizeViewportState({\n bbox: [\n bounds.getWest(),\n bounds.getSouth(),\n bounds.getEast(),\n bounds.getNorth(),\n ],\n zoom: Math.round(map.getZoom()),\n });\n}\n\nexport function collectFitPoints(\n markers: GeoMapMarker[],\n routes: GeoMapRoute[],\n target: GeoMapFitTarget,\n): GeoMapLatLng[] {\n const markerPoints =\n target === \"markers\" || target === \"all\"\n ? markers.map((marker) => [marker.lat, marker.lng] as GeoMapLatLng)\n : [];\n\n const routePoints =\n target === \"routes\" || target === \"all\"\n ? routes.flatMap((route) =>\n route.points.map((point) => [point.lat, point.lng] as GeoMapLatLng),\n )\n : [];\n\n return [...markerPoints, ...routePoints];\n}\n\nexport function resolveFitPointsWithFallback(\n markers: GeoMapMarker[],\n routes: GeoMapRoute[],\n target: GeoMapFitTarget,\n): GeoMapLatLng[] {\n const selected = collectFitPoints(markers, routes, target);\n if (selected.length > 0) {\n return selected;\n }\n\n if (target !== \"markers\") {\n return collectFitPoints(markers, routes, \"markers\");\n }\n\n return [];\n}\n\nexport function splitDatelineBbox(bbox: GeoMapBbox): GeoMapBbox[] {\n const [west, south, east, north] = bbox;\n\n if (west <= east) {\n return [bbox];\n }\n\n return [\n [west, south, 180, north],\n [-180, south, east, north],\n ];\n}\n\nfunction getClusterFeatureKey(feature: GeoMapClusterFeature): string {\n const properties = feature.properties ?? {};\n\n if (properties.cluster && typeof properties.cluster_id === \"number\") {\n return `cluster:${properties.cluster_id}`;\n }\n\n if (\n typeof properties.markerId === \"string\" &&\n properties.markerId.length > 0\n ) {\n return `marker:${properties.markerId}`;\n }\n\n if (feature.id !== undefined && feature.id !== null) {\n return `id:${String(feature.id)}`;\n }\n\n const [lng, lat] = feature.geometry.coordinates;\n return `point:${lat}:${lng}`;\n}\n\nfunction dedupeClusterFeatures(\n features: GeoMapClusterFeature[],\n): GeoMapClusterFeature[] {\n const seen = new Set();\n const deduped: GeoMapClusterFeature[] = [];\n\n features.forEach((feature) => {\n const key = getClusterFeatureKey(feature);\n if (seen.has(key)) {\n return;\n }\n\n seen.add(key);\n deduped.push(feature);\n });\n\n return deduped;\n}\n\nexport function getClustersForDatelineAwareBbox(\n bbox: GeoMapBbox,\n zoom: number,\n getClustersForBbox: (\n candidateBbox: GeoMapBbox,\n zoom: number,\n ) => GeoMapClusterFeature[],\n): GeoMapClusterFeature[] {\n const queried = splitDatelineBbox(bbox).flatMap((candidateBbox) =>\n getClustersForBbox(candidateBbox, zoom),\n );\n\n return dedupeClusterFeatures(queried);\n}\n\nexport function toSafeExpansionZoom(\n zoom: number,\n options?: { minZoom?: number; maxZoom?: number; fallback?: number },\n): number {\n const minZoom = options?.minZoom ?? 1;\n const maxZoom = options?.maxZoom ?? 22;\n const fallback = options?.fallback ?? 2;\n\n if (!Number.isFinite(zoom)) {\n return fallback;\n }\n\n return Math.min(maxZoom, Math.max(minZoom, Math.round(zoom)));\n}\n\nfunction resolveInitialView(\n markers: GeoMapMarker[],\n routes: GeoMapRoute[],\n viewport: GeoMapViewport | undefined,\n): { center: [number, number]; zoom: number } {\n if (viewport?.mode === \"center\") {\n return {\n center: [viewport.center.lat, viewport.center.lng],\n zoom: viewport.zoom,\n };\n }\n\n const fitTarget = viewport?.target ?? \"all\";\n const fitPoints = resolveFitPointsWithFallback(markers, routes, fitTarget);\n\n if (fitPoints.length === 1) {\n return {\n center: [fitPoints[0][0], fitPoints[0][1]],\n zoom: viewport?.maxZoom\n ? Math.min(SINGLE_LOCATION_ZOOM, viewport.maxZoom)\n : SINGLE_LOCATION_ZOOM,\n };\n }\n\n return { center: DEFAULT_CENTER, zoom: DEFAULT_VIEW_ZOOM };\n}\n\nfunction ViewportController({\n markers,\n routes,\n viewport,\n leafletRuntime,\n}: {\n markers: GeoMapMarker[];\n routes: GeoMapRoute[];\n viewport: GeoMapViewport | undefined;\n leafletRuntime: LeafletRuntime;\n}) {\n const map = useMap();\n const lastAppliedViewportRef = useRef(null);\n\n useEffect(() => {\n lastAppliedViewportRef.current = null;\n }, [map]);\n\n useEffect(() => {\n if (viewport?.mode === \"center\") {\n const viewportKey = `center:${roundCoordinate(viewport.center.lat)}:${roundCoordinate(viewport.center.lng)}:${viewport.zoom}`;\n if (lastAppliedViewportRef.current === viewportKey) {\n return;\n }\n\n lastAppliedViewportRef.current = viewportKey;\n map.setView([viewport.center.lat, viewport.center.lng], viewport.zoom);\n return;\n }\n\n const fitTarget = viewport?.target ?? \"all\";\n const fitPoints = resolveFitPointsWithFallback(markers, routes, fitTarget);\n if (fitPoints.length === 0) {\n return;\n }\n\n const maxZoom = viewport?.maxZoom;\n if (fitPoints.length === 1) {\n const [lat, lng] = fitPoints[0];\n const zoom = maxZoom\n ? Math.min(SINGLE_LOCATION_ZOOM, maxZoom)\n : SINGLE_LOCATION_ZOOM;\n const viewportKey = `fit-single:${roundCoordinate(lat)}:${roundCoordinate(lng)}:${zoom}`;\n if (lastAppliedViewportRef.current === viewportKey) {\n return;\n }\n\n lastAppliedViewportRef.current = viewportKey;\n map.setView([lat, lng], zoom);\n return;\n }\n\n const padding = viewport?.padding ?? DEFAULT_VIEWPORT_PADDING;\n const viewportKey = `fit:${fitTarget}:${padding}:${maxZoom ?? \"none\"}:${serializeFitPoints(fitPoints)}`;\n if (lastAppliedViewportRef.current === viewportKey) {\n return;\n }\n\n lastAppliedViewportRef.current = viewportKey;\n const bounds = leafletRuntime.latLngBounds(fitPoints);\n map.fitBounds(bounds, {\n maxZoom,\n padding: [padding, padding],\n });\n }, [leafletRuntime, map, markers, routes, viewport]);\n\n return null;\n}\n\nfunction MapObserver({\n onViewportChange,\n onMapReady,\n}: {\n onViewportChange: (state: MapViewportState) => void;\n onMapReady: (map: LeafletMap) => void;\n}) {\n const map = useMapEvents({\n moveend: () => {\n onViewportChange(readViewportState(map));\n },\n zoomend: () => {\n onViewportChange(readViewportState(map));\n },\n });\n\n useEffect(() => {\n onMapReady(map);\n onViewportChange(readViewportState(map));\n }, [map, onMapReady, onViewportChange]);\n\n return null;\n}\n\nfunction resolveMarkerAriaLabel(marker: GeoMapMarker): string {\n if (marker.label && marker.description) {\n return `${marker.label}. ${marker.description}`;\n }\n\n return (\n marker.label ??\n marker.description ??\n `Marker at ${marker.lat.toFixed(4)}, ${marker.lng.toFixed(4)}`\n );\n}\n\nexport const GeoMapEngine = memo(function GeoMapEngine({\n id,\n markers,\n routes,\n clustering,\n viewport,\n showZoomControl,\n tileUrl,\n mapAriaLabel,\n tooltipClassName,\n popupClassName,\n onMarkerClick,\n onRouteClick,\n onReadyChange,\n}: {\n id: string;\n markers: GeoMapMarker[];\n routes?: GeoMapRoute[];\n clustering?: GeoMapClustering;\n viewport?: GeoMapViewport;\n showZoomControl: boolean;\n tileUrl: string;\n mapAriaLabel: string;\n tooltipClassName?: string;\n popupClassName?: string;\n onMarkerClick?: (marker: GeoMapMarker) => void;\n onRouteClick?: (route: GeoMapRoute) => void;\n onReadyChange?: (isReady: boolean) => void;\n}) {\n const resolvedRoutes = routes ?? EMPTY_ROUTES;\n const [leafletRuntime, setLeafletRuntime] = useState(\n null,\n );\n const [mapInstance, setMapInstance] = useState(null);\n const [viewportState, setViewportState] = useState(\n null,\n );\n\n const handleViewportChange = useCallback((nextState: MapViewportState) => {\n const normalized = normalizeViewportState(nextState);\n setViewportState((previousState) =>\n areViewportStatesEqual(previousState, normalized)\n ? previousState\n : normalized,\n );\n }, []);\n\n useEffect(() => {\n let isActive = true;\n\n void import(\"leaflet\").then((module) => {\n if (!isActive) {\n return;\n }\n\n setLeafletRuntime({\n divIcon: module.divIcon,\n latLngBounds: module.latLngBounds,\n });\n });\n\n return () => {\n isActive = false;\n };\n }, []);\n\n const isReady = leafletRuntime !== null;\n\n useEffect(() => {\n onReadyChange?.(isReady);\n }, [isReady, onReadyChange]);\n\n useEffect(() => {\n if (!mapInstance) {\n return;\n }\n\n const container = mapInstance.getContainer();\n container.setAttribute(\"role\", \"region\");\n container.setAttribute(\"aria-label\", mapAriaLabel);\n }, [mapAriaLabel, mapInstance]);\n\n useEffect(() => {\n if (!mapInstance) {\n return;\n }\n\n const handleEscape = (event: KeyboardEvent) => {\n if (event.key === \"Escape\") {\n mapInstance.closePopup();\n }\n };\n\n document.addEventListener(\"keydown\", handleEscape);\n return () => {\n document.removeEventListener(\"keydown\", handleEscape);\n };\n }, [mapInstance]);\n\n const initialView = useMemo(\n () => resolveInitialView(markers, resolvedRoutes, viewport),\n [markers, resolvedRoutes, viewport],\n );\n\n const markerById = useMemo(() => {\n const map = new Map();\n markers.forEach((marker, index) => {\n map.set(marker.id ?? `marker-${index}`, marker);\n });\n return map;\n }, [markers]);\n\n const clusterConfig = useMemo(\n () => ({\n enabled: clustering?.enabled === true,\n radius: clustering?.radius ?? CLUSTER_RADIUS_DEFAULT,\n maxZoom: clustering?.maxZoom ?? CLUSTER_MAX_ZOOM_DEFAULT,\n minPoints: clustering?.minPoints ?? CLUSTER_MIN_POINTS_DEFAULT,\n }),\n [clustering],\n );\n\n const clusterIndex = useMemo(() => {\n if (!clusterConfig.enabled) {\n return null;\n }\n\n const index = new Supercluster({\n radius: clusterConfig.radius,\n maxZoom: clusterConfig.maxZoom,\n minPoints: clusterConfig.minPoints,\n });\n\n const points = markers.map((marker, index) => {\n const markerId = marker.id ?? `marker-${index}`;\n return {\n type: \"Feature\" as const,\n id: markerId,\n geometry: {\n type: \"Point\" as const,\n coordinates: [marker.lng, marker.lat] as [number, number],\n },\n properties: {\n markerId,\n marker,\n },\n };\n });\n\n index.load(points);\n return index;\n }, [\n clusterConfig.enabled,\n clusterConfig.maxZoom,\n clusterConfig.minPoints,\n clusterConfig.radius,\n markers,\n ]);\n\n const clusteredFeatures = useMemo(() => {\n if (!clusterConfig.enabled || !clusterIndex || !viewportState) {\n return [] as GeoMapClusterFeature[];\n }\n\n return getClustersForDatelineAwareBbox(\n viewportState.bbox,\n viewportState.zoom,\n (bbox, zoom) =>\n clusterIndex.getClusters(bbox, zoom) as GeoMapClusterFeature[],\n );\n }, [clusterConfig.enabled, clusterIndex, viewportState]);\n\n const renderMarker = useCallback(\n (\n marker: GeoMapMarker,\n markerKey: string,\n markerPositionOverride?: [number, number],\n ) => {\n const markerPosition: [number, number] = markerPositionOverride ?? [\n marker.lat,\n marker.lng,\n ];\n const tooltipMode = marker.tooltip ?? \"hover\";\n const tooltipContent = marker.label ?? marker.description;\n const icon = marker.icon;\n const markerAriaLabel = resolveMarkerAriaLabel(marker);\n\n if (!leafletRuntime) {\n return null;\n }\n\n const leafletIcon = resolveMarkerIcon(icon, leafletRuntime);\n if (leafletIcon) {\n return (\n onMarkerClick?.(marker),\n }}\n >\n \n \n );\n }\n\n const markerStroke =\n icon?.type === \"dot\"\n ? (icon.borderColor ?? \"var(--border)\")\n : \"var(--border)\";\n const markerFill =\n icon?.type === \"dot\"\n ? (icon.color ?? \"var(--primary)\")\n : \"var(--primary)\";\n const markerRadius = icon?.type === \"dot\" ? (icon.radius ?? 7) : 7;\n\n return (\n onMarkerClick?.(marker),\n }}\n >\n \n \n );\n },\n [leafletRuntime, onMarkerClick, popupClassName, tooltipClassName],\n );\n\n if (!leafletRuntime) {\n return null;\n }\n\n return (\n \n \n {showZoomControl && }\n \n \n\n {resolvedRoutes.map((route, routeIndex) => {\n const routeKey = route.id ?? `${id}-route-${routeIndex}`;\n const positions = route.points.map((point) => [\n point.lat,\n point.lng,\n ]) as [number, number][];\n const tooltipMode = route.tooltip ?? \"hover\";\n const tooltipContent = route.label ?? route.description;\n\n return (\n onRouteClick?.(route),\n }}\n >\n \n \n );\n })}\n\n {clusterConfig.enabled && clusterIndex && viewportState\n ? clusteredFeatures.map((feature, index) => {\n const [lng, lat] = feature.geometry.coordinates;\n const properties = (feature.properties ??\n {}) as MarkerClusterPointProperties;\n\n if (\n properties.cluster &&\n typeof properties.cluster_id === \"number\"\n ) {\n const pointCount = properties.point_count ?? 0;\n const clusterId = properties.cluster_id;\n const clusterIcon = createClusterIcon(pointCount, leafletRuntime);\n const clusterAriaLabel = `Cluster containing ${pointCount} locations`;\n\n return (\n {\n if (!mapInstance) {\n return;\n }\n\n const expansionZoom = toSafeExpansionZoom(\n clusterIndex.getClusterExpansionZoom(clusterId),\n {\n maxZoom: 22,\n fallback:\n (viewportState.zoom ?? DEFAULT_VIEW_ZOOM) + 2,\n },\n );\n mapInstance.flyTo([lat, lng], expansionZoom);\n },\n }}\n />\n );\n }\n\n const marker =\n properties.marker ??\n markerById.get(properties.markerId ?? `marker-${index}`);\n if (!marker) {\n return null;\n }\n\n const markerKey =\n marker.id ?? properties.markerId ?? `${id}-cluster-leaf-${index}`;\n return renderMarker(marker, markerKey, [lat, lng]);\n })\n : markers.map((marker, index) =>\n renderMarker(marker, marker.id ?? `${id}-marker-${index}`),\n )}\n \n );\n});\n" }, { "path": "components/tool-ui/geo-map/geo-map-icons.ts", @@ -33,7 +40,7 @@ "path": "components/tool-ui/geo-map/geo-map-overlays.tsx", "type": "registry:component", "target": "components/tool-ui/geo-map/geo-map-overlays.tsx", - "content": "\"use client\";\n\nimport { useMemo, useState } from \"react\";\n\nimport { Popup, Tooltip, cn } from \"./_adapter\";\n\nfunction GeoMapPopupContent({\n label,\n description,\n}: {\n label?: string;\n description?: string;\n}) {\n return (\n
\n {label && (\n

\n {label}\n

\n )}\n {description && (\n

\n {description}\n

\n )}\n
\n );\n}\n\nfunction GeoMapTooltipContent({ text }: { text: string }) {\n return {text};\n}\n\nexport function GeoMapOverlays({\n tooltipMode,\n tooltipContent,\n label,\n description,\n tooltipClassName,\n popupClassName,\n}: {\n tooltipMode: \"none\" | \"hover\" | \"always\";\n tooltipContent?: string;\n label?: string;\n description?: string;\n tooltipClassName?: string;\n popupClassName?: string;\n}) {\n const hasPopup = Boolean(label || description);\n const [isPopupOpen, setIsPopupOpen] = useState(false);\n const shouldRenderTooltip =\n tooltipMode !== \"none\" && tooltipContent && (!hasPopup || !isPopupOpen);\n const popupEventHandlers = useMemo(\n () => ({\n add: () => setIsPopupOpen(true),\n remove: () => setIsPopupOpen(false),\n }),\n [],\n );\n\n return (\n <>\n {shouldRenderTooltip && (\n \n \n \n )}\n {hasPopup && (\n \n \n \n )}\n \n );\n}\n" + "content": "\"use client\";\n\nimport { useMemo, useState } from \"react\";\n\nimport { cn } from \"./_adapter\";\nimport { Popup, Tooltip } from \"./_leaflet-adapter\";\n\nfunction GeoMapPopupContent({\n label,\n description,\n}: {\n label?: string;\n description?: string;\n}) {\n return (\n
\n {label && (\n

\n {label}\n

\n )}\n {description && (\n

\n {description}\n

\n )}\n
\n );\n}\n\nfunction GeoMapTooltipContent({ text }: { text: string }) {\n return {text};\n}\n\nexport function GeoMapOverlays({\n tooltipMode,\n tooltipContent,\n label,\n description,\n tooltipClassName,\n popupClassName,\n}: {\n tooltipMode: \"none\" | \"hover\" | \"always\";\n tooltipContent?: string;\n label?: string;\n description?: string;\n tooltipClassName?: string;\n popupClassName?: string;\n}) {\n const hasPopup = Boolean(label || description);\n const [isPopupOpen, setIsPopupOpen] = useState(false);\n const shouldRenderTooltip =\n tooltipMode !== \"none\" && tooltipContent && (!hasPopup || !isPopupOpen);\n const popupEventHandlers = useMemo(\n () => ({\n add: () => setIsPopupOpen(true),\n remove: () => setIsPopupOpen(false),\n }),\n [],\n );\n\n return (\n <>\n {shouldRenderTooltip && (\n \n \n \n )}\n {hasPopup && (\n \n \n \n )}\n \n );\n}\n" }, { "path": "components/tool-ui/geo-map/geo-map-theme.module.css", @@ -45,7 +52,7 @@ "path": "components/tool-ui/geo-map/geo-map.tsx", "type": "registry:component", "target": "components/tool-ui/geo-map/geo-map.tsx", - "content": "\"use client\";\n\nimport { memo, useEffect, useState } from \"react\";\nimport { cn } from \"./_adapter\";\nimport { GeoMapEngine } from \"./geo-map-engine\";\nimport styles from \"./geo-map-theme.module.css\";\nimport type { GeoMapProps, GeoMapStyle } from \"./schema\";\n\nconst LIGHT_TILE_URL =\n \"https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png\";\nconst DARK_TILE_URL =\n \"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png\";\n\nfunction getSystemTheme(): \"light\" | \"dark\" {\n if (typeof window === \"undefined\") return \"light\";\n return window.matchMedia?.(\"(prefers-color-scheme: dark)\").matches\n ? \"dark\"\n : \"light\";\n}\n\nfunction getDocumentTheme(): \"light\" | \"dark\" | null {\n if (typeof document === \"undefined\") return null;\n\n const root = document.documentElement;\n const dataTheme = root.getAttribute(\"data-theme\")?.toLowerCase();\n if (dataTheme === \"dark\") return \"dark\";\n if (dataTheme === \"light\") return \"light\";\n if (root.classList.contains(\"dark\")) return \"dark\";\n if (root.classList.contains(\"light\")) return \"light\";\n\n return null;\n}\n\nfunction useInheritedTheme(): \"light\" | \"dark\" {\n const [theme, setTheme] = useState<\"light\" | \"dark\">(() => {\n return getDocumentTheme() ?? getSystemTheme();\n });\n\n useEffect(() => {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") {\n return;\n }\n\n const update = () => setTheme(getDocumentTheme() ?? getSystemTheme());\n\n const mql = window.matchMedia?.(\"(prefers-color-scheme: dark)\");\n mql?.addEventListener(\"change\", update);\n\n const observer = new MutationObserver(update);\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\"],\n });\n\n return () => {\n mql?.removeEventListener(\"change\", update);\n observer.disconnect();\n };\n }, []);\n\n return theme;\n}\n\nfunction resolveMapAriaLabel(title?: string, description?: string): string {\n if (title && description) {\n return `${title}. ${description}`;\n }\n\n return title ?? description ?? \"Geographic map\";\n}\n\nexport const GeoMap = memo(function GeoMap({\n id,\n role: _role,\n receipt: _receipt,\n title,\n description,\n markers,\n routes,\n clustering,\n viewport,\n showZoomControl = true,\n theme,\n className,\n style,\n tooltipClassName,\n popupClassName,\n onMarkerClick,\n onRouteClick,\n}: GeoMapProps) {\n const inheritedTheme = useInheritedTheme();\n const resolvedTheme = theme ?? inheritedTheme;\n const [isMapReady, setIsMapReady] = useState(false);\n const tileUrl = resolvedTheme === \"dark\" ? DARK_TILE_URL : LIGHT_TILE_URL;\n const mapAriaLabel = resolveMapAriaLabel(title, description);\n const resolvedRootStyle: GeoMapStyle = {\n \"--geo-map-canvas-bg\":\n resolvedTheme === \"dark\" ? \"var(--background)\" : \"var(--muted)\",\n ...style,\n };\n\n return (\n \n \n \n\n {(title || description) && (\n \n {title && (\n

\n {title}\n

\n )}\n {description && (\n

\n {description}\n

\n )}\n \n )}\n\n {!isMapReady && (\n \n Loading map...\n \n )}\n \n \n );\n});\n" + "content": "\"use client\";\n\nimport dynamic from \"next/dynamic\";\nimport { memo, useEffect, useState } from \"react\";\nimport { cn } from \"./_adapter\";\nimport styles from \"./geo-map-theme.module.css\";\nimport type { GeoMapProps, GeoMapStyle } from \"./schema\";\n\n// Leaflet touches `window` at module scope, so the engine must never be\n// evaluated during SSR; every GeoMap consumer inherits this guard.\nconst GeoMapEngine = dynamic(\n () => import(\"./geo-map-engine\").then((m) => m.GeoMapEngine),\n { ssr: false },\n);\n\nconst LIGHT_TILE_URL =\n \"https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png\";\nconst DARK_TILE_URL =\n \"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png\";\n\nfunction getSystemTheme(): \"light\" | \"dark\" {\n if (typeof window === \"undefined\") return \"light\";\n return window.matchMedia?.(\"(prefers-color-scheme: dark)\").matches\n ? \"dark\"\n : \"light\";\n}\n\nfunction getDocumentTheme(): \"light\" | \"dark\" | null {\n if (typeof document === \"undefined\") return null;\n\n const root = document.documentElement;\n const dataTheme = root.getAttribute(\"data-theme\")?.toLowerCase();\n if (dataTheme === \"dark\") return \"dark\";\n if (dataTheme === \"light\") return \"light\";\n if (root.classList.contains(\"dark\")) return \"dark\";\n if (root.classList.contains(\"light\")) return \"light\";\n\n return null;\n}\n\nfunction useInheritedTheme(): \"light\" | \"dark\" {\n const [theme, setTheme] = useState<\"light\" | \"dark\">(() => {\n return getDocumentTheme() ?? getSystemTheme();\n });\n\n useEffect(() => {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") {\n return;\n }\n\n const update = () => setTheme(getDocumentTheme() ?? getSystemTheme());\n\n const mql = window.matchMedia?.(\"(prefers-color-scheme: dark)\");\n mql?.addEventListener(\"change\", update);\n\n const observer = new MutationObserver(update);\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\"],\n });\n\n return () => {\n mql?.removeEventListener(\"change\", update);\n observer.disconnect();\n };\n }, []);\n\n return theme;\n}\n\nfunction resolveMapAriaLabel(title?: string, description?: string): string {\n if (title && description) {\n return `${title}. ${description}`;\n }\n\n return title ?? description ?? \"Geographic map\";\n}\n\nexport const GeoMap = memo(function GeoMap({\n id,\n role: _role,\n receipt: _receipt,\n title,\n description,\n markers,\n routes,\n clustering,\n viewport,\n showZoomControl = true,\n theme,\n className,\n style,\n tooltipClassName,\n popupClassName,\n onMarkerClick,\n onRouteClick,\n}: GeoMapProps) {\n const inheritedTheme = useInheritedTheme();\n const resolvedTheme = theme ?? inheritedTheme;\n const [isMapReady, setIsMapReady] = useState(false);\n const tileUrl = resolvedTheme === \"dark\" ? DARK_TILE_URL : LIGHT_TILE_URL;\n const mapAriaLabel = resolveMapAriaLabel(title, description);\n const resolvedRootStyle: GeoMapStyle = {\n \"--geo-map-canvas-bg\":\n resolvedTheme === \"dark\" ? \"var(--background)\" : \"var(--muted)\",\n ...style,\n };\n\n return (\n \n \n \n\n {(title || description) && (\n \n {title && (\n

\n {title}\n

\n )}\n {description && (\n

\n {description}\n

\n )}\n \n )}\n\n {!isMapReady && (\n \n Loading map...\n \n )}\n \n \n );\n});\n" }, { "path": "components/tool-ui/geo-map/index.tsx", diff --git a/apps/www/public/r/registry.json b/apps/www/public/r/registry.json index b6943ad5..b23df0f4 100644 --- a/apps/www/public/r/registry.json +++ b/apps/www/public/r/registry.json @@ -531,6 +531,7 @@ "description": "Display geolocated entities and fleet positions.", "dependencies": [ "leaflet", + "next", "react-leaflet", "supercluster", "zod" @@ -541,6 +542,11 @@ "type": "registry:component", "target": "components/tool-ui/geo-map/_adapter.tsx" }, + { + "path": "components/tool-ui/geo-map/_leaflet-adapter.tsx", + "type": "registry:component", + "target": "components/tool-ui/geo-map/_leaflet-adapter.tsx" + }, { "path": "components/tool-ui/geo-map/geo-map-engine.tsx", "type": "registry:component",