@spree/dashboard-ui 1.0.0-beta.1 → 1.0.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui/map.tsx DELETED
@@ -1,2110 +0,0 @@
1
- 'use client'
2
-
3
- import type { MarkerOptions, PopupOptions } from 'maplibre-gl'
4
- import * as MapLibreGL from 'maplibre-gl'
5
- import 'maplibre-gl/dist/maplibre-gl.css'
6
-
7
- import type * as GeoJSON from 'geojson'
8
- // `?worker&url` hands the file to the bundler, which emits it as its own chunk
9
- // with the shared code it needs and gives back a same-origin URL.
10
- import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
11
- import {
12
- createContext,
13
- forwardRef,
14
- type ReactNode,
15
- useCallback,
16
- useContext,
17
- useEffect,
18
- useId,
19
- useImperativeHandle,
20
- useMemo,
21
- useRef,
22
- useState,
23
- } from 'react'
24
- import { createPortal } from 'react-dom'
25
- import { cn } from '../lib/utils'
26
- import { Loader2, Locate, Maximize, Minus, Plus, X } from '../spree/icons'
27
-
28
- // The worker ships in the package, so it is bundled and served from this
29
- // origin. The registry's default fetches it from unpkg at runtime, which means
30
- // executing third-party code the deploy never reviewed, a map that breaks when
31
- // that CDN is unreachable, and a CSP that has to allow it.
32
- if (typeof window !== 'undefined' && !MapLibreGL.getWorkerUrl()) {
33
- MapLibreGL.setWorkerUrl(workerUrl)
34
- }
35
-
36
- const defaultStyles = {
37
- dark: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
38
- light: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
39
- }
40
-
41
- // A tile-less, dependency-free style with a transparent background. Use it for
42
- // data visualizations (choropleths, world arcs, dot maps) where you draw your
43
- // own layers and don't need a street basemap. The easiest way to opt in is the
44
- // `blank` prop:
45
- // <Map blank>...</Map>
46
- // The transparent background lets the themed container show through.
47
- const blankMapStyle: MapLibreGL.StyleSpecification = {
48
- version: 8,
49
- sources: {},
50
- layers: [
51
- {
52
- id: 'background',
53
- type: 'background',
54
- paint: { 'background-color': 'rgba(0, 0, 0, 0)' },
55
- },
56
- ],
57
- }
58
-
59
- // Prevent equivalent inline style objects from triggering a full map style reload.
60
- function useStableValue<T>(value: T): T {
61
- const key = useMemo(() => JSON.stringify(value) ?? '', [value])
62
- // eslint-disable-next-line react-hooks/exhaustive-deps
63
- return useMemo(() => value, [key])
64
- }
65
-
66
- function mergeHoverPaint<T extends Record<string, unknown>>(
67
- paint: T,
68
- hoverPaint: T | undefined,
69
- ): T {
70
- if (!hoverPaint) return paint
71
- const merged: Record<string, unknown> = { ...paint }
72
- for (const [key, hoverValue] of Object.entries(hoverPaint)) {
73
- if (hoverValue === undefined) continue
74
- const baseValue = merged[key]
75
- merged[key] =
76
- baseValue === undefined
77
- ? hoverValue
78
- : ['case', ['boolean', ['feature-state', 'hover'], false], hoverValue, baseValue]
79
- }
80
- return merged as T
81
- }
82
-
83
- type Theme = 'light' | 'dark'
84
-
85
- // Check the document for an explicit theme (works with next-themes, etc.).
86
- // Covers both `attribute="class"` (the default) and `attribute="data-theme"`.
87
- function getDocumentTheme(): Theme | null {
88
- if (typeof document === 'undefined') return null
89
- const root = document.documentElement
90
- if (root.classList.contains('dark')) return 'dark'
91
- if (root.classList.contains('light')) return 'light'
92
- const dataTheme = root.dataset.theme
93
- if (dataTheme === 'dark' || dataTheme === 'light') return dataTheme
94
- return null
95
- }
96
-
97
- // Get system preference
98
- function getSystemTheme(): Theme {
99
- if (typeof window === 'undefined') return 'light'
100
- return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
101
- }
102
-
103
- function useResolvedTheme(themeProp?: 'light' | 'dark'): Theme {
104
- const [detectedTheme, setDetectedTheme] = useState<Theme>(
105
- () => getDocumentTheme() ?? getSystemTheme(),
106
- )
107
-
108
- useEffect(() => {
109
- if (themeProp) return // Skip detection if theme is provided via prop
110
-
111
- // Watch for document theme changes (e.g., next-themes toggling the class
112
- // or the data-theme attribute).
113
- const observer = new MutationObserver(() => {
114
- const docTheme = getDocumentTheme()
115
- if (docTheme) {
116
- setDetectedTheme(docTheme)
117
- }
118
- })
119
- observer.observe(document.documentElement, {
120
- attributes: true,
121
- attributeFilter: ['class', 'data-theme'],
122
- })
123
-
124
- // Also watch for system preference changes
125
- const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
126
- const handleSystemChange = (e: MediaQueryListEvent) => {
127
- // Only use system preference if no document class is set
128
- if (!getDocumentTheme()) {
129
- setDetectedTheme(e.matches ? 'dark' : 'light')
130
- }
131
- }
132
- mediaQuery.addEventListener('change', handleSystemChange)
133
-
134
- return () => {
135
- observer.disconnect()
136
- mediaQuery.removeEventListener('change', handleSystemChange)
137
- }
138
- }, [themeProp])
139
-
140
- return themeProp ?? detectedTheme
141
- }
142
-
143
- type MapContextValue = {
144
- map: MapLibreGL.Map | null
145
- isLoaded: boolean
146
- resolvedTheme: Theme
147
- }
148
-
149
- const MapContext = createContext<MapContextValue | null>(null)
150
-
151
- function useMap() {
152
- const context = useContext(MapContext)
153
- if (!context) {
154
- throw new Error('useMap must be used within a Map component')
155
- }
156
- return context
157
- }
158
-
159
- /** Map viewport state */
160
- type MapViewport = {
161
- /** Center coordinates [longitude, latitude] */
162
- center: [number, number]
163
- /** Zoom level */
164
- zoom: number
165
- /** Bearing (rotation) in degrees */
166
- bearing: number
167
- /** Pitch (tilt) in degrees */
168
- pitch: number
169
- }
170
-
171
- type MapStyleOption = string | MapLibreGL.StyleSpecification
172
-
173
- type MapRef = MapLibreGL.Map
174
-
175
- type MapProps = {
176
- children?: ReactNode
177
- /** Additional CSS classes for the map container */
178
- className?: string
179
- /**
180
- * Theme for the map. If not provided, automatically detects system preference.
181
- * Pass your theme value here.
182
- */
183
- theme?: Theme
184
- /** Custom map styles for light and dark themes. Overrides the default Carto styles. */
185
- styles?: {
186
- light?: MapStyleOption
187
- dark?: MapStyleOption
188
- }
189
- /**
190
- * Use a transparent, tile-less basemap instead of the default Carto street
191
- * basemap — a blank canvas. Used alone it renders nothing; add your own
192
- * layers on top (`<MapGeoJSON>`, `<MapArc>`, markers, etc.). Ideal for data
193
- * visualizations (choropleths, arcs, dot maps).
194
- * Ignored when an explicit `styles` prop is provided.
195
- */
196
- blank?: boolean
197
- /** Map projection type. Use `{ type: "globe" }` for 3D globe view. */
198
- projection?: MapLibreGL.ProjectionSpecification
199
- /**
200
- * Controlled viewport. When provided with onViewportChange,
201
- * the map becomes controlled and viewport is driven by this prop.
202
- */
203
- viewport?: Partial<MapViewport>
204
- /**
205
- * Callback fired continuously as the viewport changes (pan, zoom, rotate, pitch).
206
- * Can be used standalone to observe changes, or with `viewport` prop
207
- * to enable controlled mode where the map viewport is driven by your state.
208
- */
209
- onViewportChange?: (viewport: MapViewport) => void
210
- /** Show a loading indicator on the map */
211
- loading?: boolean
212
- } & Omit<MapLibreGL.MapOptions, 'container' | 'style'>
213
-
214
- function DefaultLoader() {
215
- return (
216
- <div className="bg-background/50 absolute inset-0 z-10 flex items-center justify-center backdrop-blur-xs">
217
- <div className="flex gap-1">
218
- <span className="bg-muted-foreground/60 size-1.5 animate-pulse rounded-full" />
219
- <span className="bg-muted-foreground/60 size-1.5 animate-pulse rounded-full [animation-delay:150ms]" />
220
- <span className="bg-muted-foreground/60 size-1.5 animate-pulse rounded-full [animation-delay:300ms]" />
221
- </div>
222
- </div>
223
- )
224
- }
225
-
226
- function getViewport(map: MapLibreGL.Map): MapViewport {
227
- const center = map.getCenter()
228
- return {
229
- center: [center.lng, center.lat],
230
- zoom: map.getZoom(),
231
- bearing: map.getBearing(),
232
- pitch: map.getPitch(),
233
- }
234
- }
235
-
236
- const Map = forwardRef<MapRef, MapProps>(function Map(
237
- {
238
- children,
239
- className,
240
- theme: themeProp,
241
- styles,
242
- blank = false,
243
- projection,
244
- viewport,
245
- onViewportChange,
246
- loading = false,
247
- ...props
248
- },
249
- ref,
250
- ) {
251
- const containerRef = useRef<HTMLDivElement>(null)
252
- const [mapInstance, setMapInstance] = useState<MapLibreGL.Map | null>(null)
253
- const [isLoaded, setIsLoaded] = useState(false)
254
- const [isStyleLoaded, setIsStyleLoaded] = useState(false)
255
- const [pendingStyle, setPendingStyle] = useState<MapStyleOption | null>(null)
256
- const currentStyleRef = useRef<MapStyleOption | null>(null)
257
- const styleSwapInFlightRef = useRef(false)
258
- const internalUpdateRef = useRef(false)
259
- const resolvedTheme = useResolvedTheme(themeProp)
260
-
261
- const isControlled = viewport !== undefined && onViewportChange !== undefined
262
-
263
- const onViewportChangeRef = useRef(onViewportChange)
264
- onViewportChangeRef.current = onViewportChange
265
-
266
- const stableStyles = useStableValue(styles)
267
-
268
- const mapStyles = useMemo(() => {
269
- // Explicit styles win. Otherwise `blank` opts into the transparent
270
- // tile-less basemap; with neither, fall back to the Carto defaults.
271
- if (stableStyles) {
272
- return {
273
- dark: stableStyles.dark ?? defaultStyles.dark,
274
- light: stableStyles.light ?? defaultStyles.light,
275
- }
276
- }
277
- if (blank) {
278
- return { dark: blankMapStyle, light: blankMapStyle }
279
- }
280
- return defaultStyles
281
- }, [stableStyles, blank])
282
-
283
- // Expose the map instance to the parent component
284
- useImperativeHandle(ref, () => mapInstance as MapLibreGL.Map, [mapInstance])
285
-
286
- // Initialize the map
287
- useEffect(() => {
288
- if (!containerRef.current) return
289
-
290
- const initialStyle = resolvedTheme === 'dark' ? mapStyles.dark : mapStyles.light
291
- currentStyleRef.current = initialStyle
292
-
293
- const map = new MapLibreGL.Map({
294
- container: containerRef.current,
295
- style: initialStyle,
296
- renderWorldCopies: false,
297
- attributionControl: {
298
- compact: true,
299
- },
300
- ...props,
301
- ...viewport,
302
- })
303
-
304
- const styleLoadHandler = () => {
305
- styleSwapInFlightRef.current = false
306
- setIsStyleLoaded(true)
307
- }
308
- const loadHandler = () => setIsLoaded(true)
309
-
310
- // Viewport change handler - skip if triggered by internal update
311
- const handleMove = () => {
312
- if (internalUpdateRef.current) return
313
- onViewportChangeRef.current?.(getViewport(map))
314
- }
315
-
316
- map.on('load', loadHandler)
317
- map.on('style.load', styleLoadHandler)
318
- map.on('move', handleMove)
319
- setMapInstance(map)
320
-
321
- return () => {
322
- map.off('load', loadHandler)
323
- map.off('style.load', styleLoadHandler)
324
- map.off('move', handleMove)
325
- map.remove()
326
- setIsLoaded(false)
327
- setIsStyleLoaded(false)
328
- setMapInstance(null)
329
- }
330
- // eslint-disable-next-line react-hooks/exhaustive-deps
331
- }, [])
332
-
333
- // Sync controlled viewport to map
334
- useEffect(() => {
335
- if (!mapInstance || !isControlled || !viewport) return
336
- if (mapInstance.isMoving()) return
337
-
338
- const current = getViewport(mapInstance)
339
- const next = {
340
- center: viewport.center ?? current.center,
341
- zoom: viewport.zoom ?? current.zoom,
342
- bearing: viewport.bearing ?? current.bearing,
343
- pitch: viewport.pitch ?? current.pitch,
344
- }
345
-
346
- if (
347
- next.center[0] === current.center[0] &&
348
- next.center[1] === current.center[1] &&
349
- next.zoom === current.zoom &&
350
- next.bearing === current.bearing &&
351
- next.pitch === current.pitch
352
- ) {
353
- return
354
- }
355
-
356
- internalUpdateRef.current = true
357
- mapInstance.jumpTo(next)
358
- internalUpdateRef.current = false
359
- }, [mapInstance, isControlled, viewport])
360
-
361
- // Handle style change: close the gate (so layer children tear down and
362
- // re-add on the incoming style) - the swap itself is staged to the effect below.
363
- useEffect(() => {
364
- if (!mapInstance || !resolvedTheme) return
365
-
366
- const newStyle = resolvedTheme === 'dark' ? mapStyles.dark : mapStyles.light
367
-
368
- if (currentStyleRef.current === newStyle) return
369
-
370
- currentStyleRef.current = newStyle
371
- setIsStyleLoaded(false)
372
- setPendingStyle(newStyle)
373
- }, [mapInstance, resolvedTheme, mapStyles])
374
-
375
- useEffect(() => {
376
- if (!mapInstance || !pendingStyle) return
377
-
378
- setPendingStyle(null)
379
- styleSwapInFlightRef.current = true
380
- // Full reload (no diff) so `style.load` fires deterministically. A
381
- // successful diff would never fire it, leaving isStyleLoaded stuck false.
382
- mapInstance.setStyle(pendingStyle, { diff: false })
383
- }, [mapInstance, pendingStyle])
384
-
385
- // Sync projection when the prop changes after mount.
386
- useEffect(() => {
387
- if (!mapInstance || !isStyleLoaded || !projection) return
388
- if (styleSwapInFlightRef.current) return
389
- mapInstance.setProjection(projection)
390
- }, [mapInstance, isStyleLoaded, projection])
391
-
392
- const contextValue = useMemo(
393
- () => ({
394
- map: mapInstance,
395
- isLoaded: isLoaded && isStyleLoaded,
396
- resolvedTheme,
397
- }),
398
- [mapInstance, isLoaded, isStyleLoaded, resolvedTheme],
399
- )
400
-
401
- return (
402
- <MapContext.Provider value={contextValue}>
403
- <div ref={containerRef} className={cn('relative h-full w-full', className)}>
404
- {(!isLoaded || loading) && <DefaultLoader />}
405
- {/* SSR-safe: children render only when map is loaded on client */}
406
- {mapInstance && children}
407
- </div>
408
- </MapContext.Provider>
409
- )
410
- })
411
-
412
- type MarkerContextValue = {
413
- marker: MapLibreGL.Marker
414
- map: MapLibreGL.Map | null
415
- }
416
-
417
- const MarkerContext = createContext<MarkerContextValue | null>(null)
418
-
419
- function useMarkerContext() {
420
- const context = useContext(MarkerContext)
421
- if (!context) {
422
- throw new Error('Marker components must be used within MapMarker')
423
- }
424
- return context
425
- }
426
-
427
- type MapMarkerProps = {
428
- /** Longitude coordinate for marker position */
429
- longitude: number
430
- /** Latitude coordinate for marker position */
431
- latitude: number
432
- /** Marker subcomponents (MarkerContent, MarkerPopup, MarkerTooltip, MarkerLabel) */
433
- children: ReactNode
434
- /** Callback when marker is clicked */
435
- onClick?: (e: MouseEvent) => void
436
- /** Callback when mouse enters marker */
437
- onMouseEnter?: (e: MouseEvent) => void
438
- /** Callback when mouse leaves marker */
439
- onMouseLeave?: (e: MouseEvent) => void
440
- /** Callback when marker drag starts (requires draggable: true) */
441
- onDragStart?: (lngLat: { lng: number; lat: number }) => void
442
- /** Callback during marker drag (requires draggable: true) */
443
- onDrag?: (lngLat: { lng: number; lat: number }) => void
444
- /** Callback when marker drag ends (requires draggable: true) */
445
- onDragEnd?: (lngLat: { lng: number; lat: number }) => void
446
- } & Omit<MarkerOptions, 'element'>
447
-
448
- function MapMarker({
449
- longitude,
450
- latitude,
451
- children,
452
- onClick,
453
- onMouseEnter,
454
- onMouseLeave,
455
- onDragStart,
456
- onDrag,
457
- onDragEnd,
458
- draggable = false,
459
- ...markerOptions
460
- }: MapMarkerProps) {
461
- const { map } = useMap()
462
-
463
- const callbacksRef = useRef({
464
- onClick,
465
- onMouseEnter,
466
- onMouseLeave,
467
- onDragStart,
468
- onDrag,
469
- onDragEnd,
470
- })
471
- callbacksRef.current = {
472
- onClick,
473
- onMouseEnter,
474
- onMouseLeave,
475
- onDragStart,
476
- onDrag,
477
- onDragEnd,
478
- }
479
-
480
- const marker = useMemo(() => {
481
- const markerInstance = new MapLibreGL.Marker({
482
- ...markerOptions,
483
- element: document.createElement('div'),
484
- draggable,
485
- }).setLngLat([longitude, latitude])
486
-
487
- const handleClick = (e: MouseEvent) => callbacksRef.current.onClick?.(e)
488
- const handleMouseEnter = (e: MouseEvent) => callbacksRef.current.onMouseEnter?.(e)
489
- const handleMouseLeave = (e: MouseEvent) => callbacksRef.current.onMouseLeave?.(e)
490
-
491
- markerInstance.getElement()?.addEventListener('click', handleClick)
492
- markerInstance.getElement()?.addEventListener('mouseenter', handleMouseEnter)
493
- markerInstance.getElement()?.addEventListener('mouseleave', handleMouseLeave)
494
-
495
- const handleDragStart = () => {
496
- const lngLat = markerInstance.getLngLat()
497
- callbacksRef.current.onDragStart?.({ lng: lngLat.lng, lat: lngLat.lat })
498
- }
499
- const handleDrag = () => {
500
- const lngLat = markerInstance.getLngLat()
501
- callbacksRef.current.onDrag?.({ lng: lngLat.lng, lat: lngLat.lat })
502
- }
503
- const handleDragEnd = () => {
504
- const lngLat = markerInstance.getLngLat()
505
- callbacksRef.current.onDragEnd?.({ lng: lngLat.lng, lat: lngLat.lat })
506
- }
507
-
508
- markerInstance.on('dragstart', handleDragStart)
509
- markerInstance.on('drag', handleDrag)
510
- markerInstance.on('dragend', handleDragEnd)
511
-
512
- return markerInstance
513
-
514
- // eslint-disable-next-line react-hooks/exhaustive-deps
515
- }, [])
516
-
517
- useEffect(() => {
518
- if (!map) return
519
-
520
- marker.addTo(map)
521
-
522
- return () => {
523
- marker.remove()
524
- }
525
-
526
- // eslint-disable-next-line react-hooks/exhaustive-deps
527
- }, [map])
528
-
529
- const { offset, rotation, rotationAlignment, pitchAlignment } = markerOptions
530
-
531
- useEffect(() => {
532
- const current = marker.getLngLat()
533
- if (current.lng !== longitude || current.lat !== latitude) {
534
- marker.setLngLat([longitude, latitude])
535
- }
536
-
537
- if (marker.isDraggable() !== draggable) {
538
- marker.setDraggable(draggable)
539
- }
540
-
541
- const currentOffset = marker.getOffset()
542
- const newOffset = offset ?? [0, 0]
543
- const [newOffsetX, newOffsetY] = Array.isArray(newOffset)
544
- ? newOffset
545
- : [newOffset.x, newOffset.y]
546
- if (currentOffset.x !== newOffsetX || currentOffset.y !== newOffsetY) {
547
- marker.setOffset(newOffset)
548
- }
549
-
550
- if (marker.getRotation() !== (rotation ?? 0)) {
551
- marker.setRotation(rotation ?? 0)
552
- }
553
- if (marker.getRotationAlignment() !== (rotationAlignment ?? 'auto')) {
554
- marker.setRotationAlignment(rotationAlignment ?? 'auto')
555
- }
556
- if (marker.getPitchAlignment() !== (pitchAlignment ?? 'auto')) {
557
- marker.setPitchAlignment(pitchAlignment ?? 'auto')
558
- }
559
- }, [marker, longitude, latitude, draggable, offset, rotation, rotationAlignment, pitchAlignment])
560
-
561
- return <MarkerContext.Provider value={{ marker, map }}>{children}</MarkerContext.Provider>
562
- }
563
-
564
- type MarkerContentProps = {
565
- /** Custom marker content. Defaults to a blue dot if not provided */
566
- children?: ReactNode
567
- /** Additional CSS classes for the marker container */
568
- className?: string
569
- }
570
-
571
- function MarkerContent({ children, className }: MarkerContentProps) {
572
- const { marker } = useMarkerContext()
573
-
574
- return createPortal(
575
- <div className={cn('relative cursor-pointer', className)}>
576
- {children || <DefaultMarkerIcon />}
577
- </div>,
578
- marker.getElement(),
579
- )
580
- }
581
-
582
- function DefaultMarkerIcon() {
583
- return (
584
- <div className="relative h-4 w-4 rounded-full border-2 border-white bg-blue-500 shadow-lg" />
585
- )
586
- }
587
-
588
- function PopupCloseButton({ onClick }: { onClick: () => void }) {
589
- return (
590
- <button
591
- type="button"
592
- onClick={onClick}
593
- aria-label="Close popup"
594
- className="focus-visible:ring-ring hover:bg-accent text-foreground absolute top-1 right-1 z-10 inline-flex size-5 cursor-pointer items-center justify-center rounded-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-inset"
595
- >
596
- <X className="size-3.5" />
597
- </button>
598
- )
599
- }
600
-
601
- type MarkerPopupProps = {
602
- /** Popup content */
603
- children: ReactNode
604
- /** Additional CSS classes for the popup container */
605
- className?: string
606
- /** Show a close button in the popup (default: false) */
607
- closeButton?: boolean
608
- } & Omit<PopupOptions, 'className' | 'closeButton'>
609
-
610
- function MarkerPopup({
611
- children,
612
- className,
613
- closeButton = false,
614
- ...popupOptions
615
- }: MarkerPopupProps) {
616
- const { marker, map } = useMarkerContext()
617
- const container = useMemo(() => document.createElement('div'), [])
618
- const { offset, maxWidth } = popupOptions
619
-
620
- const popup = useMemo(() => {
621
- const popupInstance = new MapLibreGL.Popup({
622
- offset: 16,
623
- ...popupOptions,
624
- closeButton: false,
625
- })
626
- .setMaxWidth('none')
627
- .setDOMContent(container)
628
-
629
- return popupInstance
630
- // eslint-disable-next-line react-hooks/exhaustive-deps
631
- }, [])
632
-
633
- useEffect(() => {
634
- if (!map) return
635
-
636
- popup.setDOMContent(container)
637
- marker.setPopup(popup)
638
-
639
- return () => {
640
- marker.setPopup(null)
641
- }
642
- // eslint-disable-next-line react-hooks/exhaustive-deps
643
- }, [map])
644
-
645
- // Sync popup options when they change.
646
- useEffect(() => {
647
- popup.setOffset(offset ?? 16)
648
- if (maxWidth) {
649
- popup.setMaxWidth(maxWidth)
650
- }
651
- }, [popup, offset, maxWidth])
652
-
653
- const handleClose = () => popup.remove()
654
-
655
- return createPortal(
656
- <div
657
- className={cn(
658
- 'bg-popover text-popover-foreground relative max-w-62 rounded-md border p-3 shadow-md',
659
- 'animate-in fade-in-0 zoom-in-95 duration-200 ease-out',
660
- className,
661
- )}
662
- >
663
- {closeButton && <PopupCloseButton onClick={handleClose} />}
664
- {children}
665
- </div>,
666
- container,
667
- )
668
- }
669
-
670
- type MarkerTooltipProps = {
671
- /** Tooltip content */
672
- children: ReactNode
673
- /** Additional CSS classes for the tooltip container */
674
- className?: string
675
- } & Omit<PopupOptions, 'className' | 'closeButton' | 'closeOnClick'>
676
-
677
- function MarkerTooltip({ children, className, ...popupOptions }: MarkerTooltipProps) {
678
- const { marker, map } = useMarkerContext()
679
- const container = useMemo(() => document.createElement('div'), [])
680
- const { offset, maxWidth } = popupOptions
681
-
682
- const tooltip = useMemo(() => {
683
- const tooltipInstance = new MapLibreGL.Popup({
684
- offset: 16,
685
- ...popupOptions,
686
- closeOnClick: true,
687
- closeButton: false,
688
- }).setMaxWidth('none')
689
-
690
- return tooltipInstance
691
- // eslint-disable-next-line react-hooks/exhaustive-deps
692
- }, [])
693
-
694
- useEffect(() => {
695
- if (!map) return
696
-
697
- tooltip.setDOMContent(container)
698
-
699
- const handleMouseEnter = () => {
700
- tooltip.setLngLat(marker.getLngLat()).addTo(map)
701
- }
702
- const handleMouseLeave = () => tooltip.remove()
703
-
704
- marker.getElement()?.addEventListener('mouseenter', handleMouseEnter)
705
- marker.getElement()?.addEventListener('mouseleave', handleMouseLeave)
706
-
707
- return () => {
708
- marker.getElement()?.removeEventListener('mouseenter', handleMouseEnter)
709
- marker.getElement()?.removeEventListener('mouseleave', handleMouseLeave)
710
- tooltip.remove()
711
- }
712
- // eslint-disable-next-line react-hooks/exhaustive-deps
713
- }, [map])
714
-
715
- // Sync tooltip options when they change.
716
- useEffect(() => {
717
- tooltip.setOffset(offset ?? 16)
718
- if (maxWidth) {
719
- tooltip.setMaxWidth(maxWidth)
720
- }
721
- }, [tooltip, offset, maxWidth])
722
-
723
- return createPortal(
724
- <div
725
- className={cn(
726
- 'bg-foreground text-background pointer-events-none rounded-md px-2 py-1 text-xs text-balance shadow-md',
727
- 'animate-in fade-in-0 zoom-in-95 duration-200 ease-out',
728
- className,
729
- )}
730
- >
731
- {children}
732
- </div>,
733
- container,
734
- )
735
- }
736
-
737
- type MarkerLabelProps = {
738
- /** Label text content */
739
- children: ReactNode
740
- /** Additional CSS classes for the label */
741
- className?: string
742
- /** Position of the label relative to the marker (default: "top") */
743
- position?: 'top' | 'bottom'
744
- }
745
-
746
- function MarkerLabel({ children, className, position = 'top' }: MarkerLabelProps) {
747
- const positionClasses = {
748
- top: 'bottom-full mb-1',
749
- bottom: 'top-full mt-1',
750
- }
751
-
752
- return (
753
- <div
754
- className={cn(
755
- 'absolute left-1/2 -translate-x-1/2 whitespace-nowrap',
756
- 'text-foreground text-[10px] font-medium',
757
- positionClasses[position],
758
- className,
759
- )}
760
- >
761
- {children}
762
- </div>
763
- )
764
- }
765
-
766
- type MapControlsProps = {
767
- /** Position of the controls on the map (default: "bottom-right") */
768
- position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
769
- /** Show zoom in/out buttons (default: true) */
770
- showZoom?: boolean
771
- /** Show compass button to reset bearing (default: false) */
772
- showCompass?: boolean
773
- /** Show locate button to find user's location (default: false) */
774
- showLocate?: boolean
775
- /** Show fullscreen toggle button (default: false) */
776
- showFullscreen?: boolean
777
- /** Additional CSS classes for the controls container */
778
- className?: string
779
- /** Callback with user coordinates when located */
780
- onLocate?: (coords: { longitude: number; latitude: number }) => void
781
- }
782
-
783
- const positionClasses = {
784
- 'top-left': 'top-2 left-2',
785
- 'top-right': 'top-2 right-2',
786
- 'bottom-left': 'bottom-2 left-2',
787
- 'bottom-right': 'bottom-10 right-2',
788
- }
789
-
790
- function ControlGroup({ children }: { children: React.ReactNode }) {
791
- return (
792
- <div className="border-border bg-background [&>button:not(:last-child)]:border-border flex flex-col overflow-hidden rounded-md border shadow-sm [&>button:not(:last-child)]:border-b">
793
- {children}
794
- </div>
795
- )
796
- }
797
-
798
- function ControlButton({
799
- onClick,
800
- label,
801
- children,
802
- disabled = false,
803
- }: {
804
- onClick: () => void
805
- label: string
806
- children: React.ReactNode
807
- disabled?: boolean
808
- }) {
809
- return (
810
- <button
811
- onClick={onClick}
812
- aria-label={label}
813
- type="button"
814
- className={cn(
815
- 'flex size-8 items-center justify-center transition-colors',
816
- 'first:rounded-t-md last:rounded-b-md',
817
- 'hover:bg-accent dark:hover:bg-accent/40',
818
- 'focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset',
819
- 'disabled:pointer-events-none disabled:opacity-50',
820
- )}
821
- disabled={disabled}
822
- >
823
- {children}
824
- </button>
825
- )
826
- }
827
-
828
- function MapControls({
829
- position = 'bottom-right',
830
- showZoom = true,
831
- showCompass = false,
832
- showLocate = false,
833
- showFullscreen = false,
834
- className,
835
- onLocate,
836
- }: MapControlsProps) {
837
- const { map } = useMap()
838
- const [waitingForLocation, setWaitingForLocation] = useState(false)
839
-
840
- const handleZoomIn = useCallback(() => {
841
- map?.zoomTo(map.getZoom() + 1, { duration: 300 })
842
- }, [map])
843
-
844
- const handleZoomOut = useCallback(() => {
845
- map?.zoomTo(map.getZoom() - 1, { duration: 300 })
846
- }, [map])
847
-
848
- const handleResetBearing = useCallback(() => {
849
- map?.resetNorthPitch({ duration: 300 })
850
- }, [map])
851
-
852
- const handleLocate = useCallback(() => {
853
- if (!('geolocation' in navigator)) return
854
- setWaitingForLocation(true)
855
- navigator.geolocation.getCurrentPosition(
856
- (pos) => {
857
- const coords = {
858
- longitude: pos.coords.longitude,
859
- latitude: pos.coords.latitude,
860
- }
861
- map?.flyTo({
862
- center: [coords.longitude, coords.latitude],
863
- zoom: 14,
864
- duration: 1500,
865
- })
866
- onLocate?.(coords)
867
- setWaitingForLocation(false)
868
- },
869
- (error) => {
870
- console.error('Error getting location:', error)
871
- setWaitingForLocation(false)
872
- },
873
- // Without a timeout the spec default is Infinity: a dismissed permission
874
- // prompt would leave the button disabled forever.
875
- { timeout: 10000 },
876
- )
877
- }, [map, onLocate])
878
-
879
- const handleFullscreen = useCallback(() => {
880
- const container = map?.getContainer()
881
- if (!container) return
882
- if (document.fullscreenElement) {
883
- document.exitFullscreen()
884
- } else {
885
- container.requestFullscreen()
886
- }
887
- }, [map])
888
-
889
- return (
890
- <div
891
- className={cn('absolute z-10 flex flex-col gap-1.5', positionClasses[position], className)}
892
- >
893
- {showZoom && (
894
- <ControlGroup>
895
- <ControlButton onClick={handleZoomIn} label="Zoom in">
896
- <Plus className="size-4" />
897
- </ControlButton>
898
- <ControlButton onClick={handleZoomOut} label="Zoom out">
899
- <Minus className="size-4" />
900
- </ControlButton>
901
- </ControlGroup>
902
- )}
903
- {showCompass && (
904
- <ControlGroup>
905
- <CompassButton onClick={handleResetBearing} />
906
- </ControlGroup>
907
- )}
908
- {showLocate && (
909
- <ControlGroup>
910
- <ControlButton
911
- onClick={handleLocate}
912
- label="Find my location"
913
- disabled={waitingForLocation}
914
- >
915
- {waitingForLocation ? (
916
- <Loader2 className="size-4 animate-spin" />
917
- ) : (
918
- <Locate className="size-4" />
919
- )}
920
- </ControlButton>
921
- </ControlGroup>
922
- )}
923
- {showFullscreen && (
924
- <ControlGroup>
925
- <ControlButton onClick={handleFullscreen} label="Toggle fullscreen">
926
- <Maximize className="size-4" />
927
- </ControlButton>
928
- </ControlGroup>
929
- )}
930
- </div>
931
- )
932
- }
933
-
934
- function CompassButton({ onClick }: { onClick: () => void }) {
935
- const { map } = useMap()
936
- const compassRef = useRef<SVGSVGElement>(null)
937
-
938
- useEffect(() => {
939
- if (!map || !compassRef.current) return
940
-
941
- const compass = compassRef.current
942
-
943
- const updateRotation = () => {
944
- const bearing = map.getBearing()
945
- const pitch = map.getPitch()
946
- compass.style.transform = `rotateX(${pitch}deg) rotateZ(${-bearing}deg)`
947
- }
948
-
949
- map.on('rotate', updateRotation)
950
- map.on('pitch', updateRotation)
951
- updateRotation()
952
-
953
- return () => {
954
- map.off('rotate', updateRotation)
955
- map.off('pitch', updateRotation)
956
- }
957
- }, [map])
958
-
959
- return (
960
- <ControlButton onClick={onClick} label="Reset bearing to north">
961
- <svg
962
- ref={compassRef}
963
- viewBox="0 0 24 24"
964
- className="size-5"
965
- style={{ transformStyle: 'preserve-3d' }}
966
- >
967
- <path d="M12 2L16 12H12V2Z" className="fill-red-500" />
968
- <path d="M12 2L8 12H12V2Z" className="fill-red-300" />
969
- <path d="M12 22L16 12H12V22Z" className="fill-muted-foreground/60" />
970
- <path d="M12 22L8 12H12V22Z" className="fill-muted-foreground/30" />
971
- </svg>
972
- </ControlButton>
973
- )
974
- }
975
-
976
- type MapPopupProps = {
977
- /** Longitude coordinate for popup position */
978
- longitude: number
979
- /** Latitude coordinate for popup position */
980
- latitude: number
981
- /** Callback when popup is closed */
982
- onClose?: () => void
983
- /** Popup content */
984
- children: ReactNode
985
- /** Additional CSS classes for the popup container */
986
- className?: string
987
- /** Show a close button in the popup (default: false) */
988
- closeButton?: boolean
989
- } & Omit<PopupOptions, 'className' | 'closeButton'>
990
-
991
- function MapPopup({
992
- longitude,
993
- latitude,
994
- onClose,
995
- children,
996
- className,
997
- closeButton = false,
998
- ...popupOptions
999
- }: MapPopupProps) {
1000
- const { map } = useMap()
1001
- const onCloseRef = useRef(onClose)
1002
- onCloseRef.current = onClose
1003
- const container = useMemo(() => document.createElement('div'), [])
1004
- const { offset, maxWidth } = popupOptions
1005
-
1006
- const popup = useMemo(() => {
1007
- const popupInstance = new MapLibreGL.Popup({
1008
- offset: 16,
1009
- ...popupOptions,
1010
- closeButton: false,
1011
- })
1012
- .setMaxWidth('none')
1013
- .setLngLat([longitude, latitude])
1014
-
1015
- return popupInstance
1016
- // eslint-disable-next-line react-hooks/exhaustive-deps
1017
- }, [])
1018
-
1019
- useEffect(() => {
1020
- if (!map) return
1021
-
1022
- const onCloseProp = () => onCloseRef.current?.()
1023
-
1024
- popup.on('close', onCloseProp)
1025
-
1026
- popup.setDOMContent(container)
1027
- popup.addTo(map)
1028
-
1029
- return () => {
1030
- popup.off('close', onCloseProp)
1031
- if (popup.isOpen()) {
1032
- popup.remove()
1033
- }
1034
- }
1035
- // eslint-disable-next-line react-hooks/exhaustive-deps
1036
- }, [map])
1037
-
1038
- // Sync popup position and options when they change.
1039
- useEffect(() => {
1040
- const current = popup.getLngLat()
1041
- if (!current || current.lng !== longitude || current.lat !== latitude) {
1042
- popup.setLngLat([longitude, latitude])
1043
- }
1044
- popup.setOffset(offset ?? 16)
1045
- if (maxWidth) {
1046
- popup.setMaxWidth(maxWidth)
1047
- }
1048
- }, [popup, longitude, latitude, offset, maxWidth])
1049
-
1050
- const handleClose = () => {
1051
- popup.remove()
1052
- }
1053
-
1054
- return createPortal(
1055
- <div
1056
- className={cn(
1057
- 'bg-popover text-popover-foreground relative max-w-62 rounded-md border p-3 shadow-md',
1058
- 'animate-in fade-in-0 zoom-in-95 duration-200 ease-out',
1059
- className,
1060
- )}
1061
- >
1062
- {closeButton && <PopupCloseButton onClick={handleClose} />}
1063
- {children}
1064
- </div>,
1065
- container,
1066
- )
1067
- }
1068
-
1069
- type MapRouteProps = {
1070
- /** Optional unique identifier for the route layer */
1071
- id?: string
1072
- /** Array of [longitude, latitude] coordinate pairs defining the route */
1073
- coordinates: [number, number][]
1074
- /** Line color as CSS color value (default: "#4285F4") */
1075
- color?: string
1076
- /** Line width in pixels (default: 3) */
1077
- width?: number
1078
- /** Line opacity from 0 to 1 (default: 0.8) */
1079
- opacity?: number
1080
- /** Dash pattern [dash length, gap length] for dashed lines */
1081
- dashArray?: [number, number]
1082
- /** Callback when the route line is clicked */
1083
- onClick?: () => void
1084
- /** Callback when mouse enters the route line */
1085
- onMouseEnter?: () => void
1086
- /** Callback when mouse leaves the route line */
1087
- onMouseLeave?: () => void
1088
- /** Whether the route is interactive - shows pointer cursor on hover (default: true) */
1089
- interactive?: boolean
1090
- }
1091
-
1092
- function MapRoute({
1093
- id: propId,
1094
- coordinates,
1095
- color = '#4285F4',
1096
- width = 3,
1097
- opacity = 0.8,
1098
- dashArray,
1099
- onClick,
1100
- onMouseEnter,
1101
- onMouseLeave,
1102
- interactive = true,
1103
- }: MapRouteProps) {
1104
- const { map, isLoaded } = useMap()
1105
- const autoId = useId()
1106
- const id = propId ?? autoId
1107
- const sourceId = `route-source-${id}`
1108
- const layerId = `route-layer-${id}`
1109
-
1110
- // Add source and layer on mount
1111
- useEffect(() => {
1112
- if (!isLoaded || !map) return
1113
-
1114
- map.addSource(sourceId, {
1115
- type: 'geojson',
1116
- data: {
1117
- type: 'Feature',
1118
- properties: {},
1119
- geometry: { type: 'LineString', coordinates: [] },
1120
- },
1121
- })
1122
-
1123
- map.addLayer({
1124
- id: layerId,
1125
- type: 'line',
1126
- source: sourceId,
1127
- layout: { 'line-join': 'round', 'line-cap': 'round' },
1128
- paint: {
1129
- 'line-color': color,
1130
- 'line-width': width,
1131
- 'line-opacity': opacity,
1132
- ...(dashArray && { 'line-dasharray': dashArray }),
1133
- },
1134
- })
1135
-
1136
- return () => {
1137
- try {
1138
- if (map.getLayer(layerId)) map.removeLayer(layerId)
1139
- if (map.getSource(sourceId)) map.removeSource(sourceId)
1140
- } catch {
1141
- // ignore
1142
- }
1143
- }
1144
- // eslint-disable-next-line react-hooks/exhaustive-deps
1145
- }, [isLoaded, map])
1146
-
1147
- // When coordinates change, update the source data
1148
- useEffect(() => {
1149
- if (!isLoaded || !map || coordinates.length < 2) return
1150
-
1151
- const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource
1152
- if (source) {
1153
- source.setData({
1154
- type: 'Feature',
1155
- properties: {},
1156
- geometry: { type: 'LineString', coordinates },
1157
- })
1158
- }
1159
- }, [isLoaded, map, coordinates, sourceId])
1160
-
1161
- useEffect(() => {
1162
- if (!isLoaded || !map || !map.getLayer(layerId)) return
1163
-
1164
- map.setPaintProperty(layerId, 'line-color', color)
1165
- map.setPaintProperty(layerId, 'line-width', width)
1166
- map.setPaintProperty(layerId, 'line-opacity', opacity)
1167
- map.setPaintProperty(layerId, 'line-dasharray', dashArray)
1168
- }, [isLoaded, map, layerId, color, width, opacity, dashArray])
1169
-
1170
- // Handle click and hover events
1171
- useEffect(() => {
1172
- if (!isLoaded || !map || !interactive) return
1173
-
1174
- const handleClick = () => {
1175
- onClick?.()
1176
- }
1177
- const handleMouseEnter = () => {
1178
- map.getCanvas().style.cursor = 'pointer'
1179
- onMouseEnter?.()
1180
- }
1181
- const handleMouseLeave = () => {
1182
- map.getCanvas().style.cursor = ''
1183
- onMouseLeave?.()
1184
- }
1185
-
1186
- map.on('click', layerId, handleClick)
1187
- map.on('mouseenter', layerId, handleMouseEnter)
1188
- map.on('mouseleave', layerId, handleMouseLeave)
1189
-
1190
- return () => {
1191
- map.off('click', layerId, handleClick)
1192
- map.off('mouseenter', layerId, handleMouseEnter)
1193
- map.off('mouseleave', layerId, handleMouseLeave)
1194
- }
1195
- }, [isLoaded, map, layerId, onClick, onMouseEnter, onMouseLeave, interactive])
1196
-
1197
- return null
1198
- }
1199
-
1200
- type MapGeoJSONData<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> =
1201
- | GeoJSON.FeatureCollection<GeoJSON.Geometry, P>
1202
- | GeoJSON.Feature<GeoJSON.Geometry, P>
1203
- | GeoJSON.Geometry
1204
- | string
1205
-
1206
- type MapFillPaint = NonNullable<MapLibreGL.FillLayerSpecification['paint']>
1207
- type MapLinePaint = NonNullable<MapLibreGL.LineLayerSpecification['paint']>
1208
-
1209
- /** A rendered feature with strongly-typed `properties`. */
1210
- type MapGeoJSONFeature<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> = Omit<
1211
- MapLibreGL.MapGeoJSONFeature,
1212
- 'properties'
1213
- > & { properties: P }
1214
-
1215
- /** Event payload passed to MapGeoJSON interaction callbacks. */
1216
- type MapGeoJSONEvent<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> = {
1217
- /** The feature under the cursor, with its typed GeoJSON properties. */
1218
- feature: MapGeoJSONFeature<P>
1219
- /** Longitude of the cursor at the time of the event. */
1220
- longitude: number
1221
- /** Latitude of the cursor at the time of the event. */
1222
- latitude: number
1223
- /** The underlying MapLibre mouse event for advanced use cases. */
1224
- originalEvent: MapLibreGL.MapLayerMouseEvent
1225
- }
1226
-
1227
- type MapGeoJSONProps<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> = {
1228
- /** GeoJSON data (FeatureCollection, Feature, Geometry) or a URL to fetch it from. */
1229
- data: MapGeoJSONData<P>
1230
- /** Optional unique identifier prefix for the source/layers. Auto-generated if not provided. */
1231
- id?: string
1232
- /**
1233
- * Feature property to promote to the feature `id`. Required for hover
1234
- * feature-state (`fillHoverPaint`) and stable `onHover`/`onClick` payloads.
1235
- */
1236
- promoteId?: string
1237
- /**
1238
- * Paint for the polygon fill layer. Merged on top of a theme-aware monochrome
1239
- * surface tone (`fill-color`). Pass `false` to omit the fill layer entirely
1240
- * (e.g. outlines only).
1241
- */
1242
- fillPaint?: MapFillPaint | false
1243
- /**
1244
- * Paint for the outline layer. Merged on top of a hairline default
1245
- * (`line-color` = a near-surface neutral, `line-width` = 0.5) for thin
1246
- * separators. Override `line-color` if your container differs, or pass
1247
- * `false` to omit the layer.
1248
- */
1249
- linePaint?: MapLinePaint | false
1250
- /**
1251
- * Paint merged onto the fill layer for the feature under the cursor, applied
1252
- * as a `case` expression keyed on hover feature-state. Requires `promoteId`.
1253
- */
1254
- fillHoverPaint?: MapFillPaint
1255
- /** Callback when a feature is clicked. */
1256
- onClick?: (e: MapGeoJSONEvent<P>) => void
1257
- /** Callback fired when the hovered feature changes; `null` when the cursor leaves. */
1258
- onHover?: (e: MapGeoJSONEvent<P> | null) => void
1259
- /** Whether features respond to mouse events (default: false). */
1260
- interactive?: boolean
1261
- /** Optional MapLibre layer id to insert the layers before (z-order control). */
1262
- beforeId?: string
1263
- }
1264
-
1265
- // Monochrome defaults: a neutral-gray fill (hex of the grayscale chart tokens)
1266
- // with a fixed near-surface line for thin separators. Colors are hardcoded (not
1267
- // theme tokens), tuned for a typical light/dark surface. Override via
1268
- // `fillPaint` / `linePaint`.
1269
- const GEOJSON_DEFAULT_COLORS = {
1270
- light: { fill: '#d4d4d4', line: '#ffffff' },
1271
- dark: { fill: '#404040', line: '#171717' },
1272
- } satisfies Record<Theme, { fill: string; line: string }>
1273
-
1274
- /**
1275
- * Renders arbitrary GeoJSON as fill + outline layers on the map. Composes like
1276
- * `MapRoute` / `MapArc` — drop it inside `<Map>` (typically with `blank`) for
1277
- * choropleths and region/data maps. For full control over expressions and
1278
- * multiple layers, manage layers directly via `useMap()` instead.
1279
- */
1280
- function MapGeoJSON<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties>({
1281
- data,
1282
- id: propId,
1283
- promoteId,
1284
- fillPaint,
1285
- linePaint,
1286
- fillHoverPaint,
1287
- onClick,
1288
- onHover,
1289
- interactive = false,
1290
- beforeId,
1291
- }: MapGeoJSONProps<P>) {
1292
- const { map, isLoaded, resolvedTheme } = useMap()
1293
- const autoId = useId()
1294
- const id = propId ?? autoId
1295
- const sourceId = `geojson-source-${id}`
1296
- const fillLayerId = `geojson-fill-${id}`
1297
- const lineLayerId = `geojson-line-${id}`
1298
-
1299
- const defaults = GEOJSON_DEFAULT_COLORS[resolvedTheme]
1300
-
1301
- const showFill = fillPaint !== false
1302
- const showLine = linePaint !== false
1303
-
1304
- const mergedFillPaint = useMemo(
1305
- () => mergeHoverPaint({ 'fill-color': defaults.fill, ...(fillPaint || {}) }, fillHoverPaint),
1306
- [defaults.fill, fillPaint, fillHoverPaint],
1307
- )
1308
- const mergedLinePaint = useMemo(
1309
- () => ({
1310
- 'line-color': defaults.line,
1311
- 'line-width': 0.5,
1312
- ...(linePaint || {}),
1313
- }),
1314
- [defaults.line, linePaint],
1315
- )
1316
- const latestRef = useRef({ onClick, onHover })
1317
- latestRef.current = { onClick, onHover }
1318
-
1319
- // Add source on mount.
1320
- useEffect(() => {
1321
- if (!isLoaded || !map) return
1322
-
1323
- map.addSource(sourceId, {
1324
- type: 'geojson',
1325
- data,
1326
- ...(promoteId ? { promoteId } : {}),
1327
- })
1328
-
1329
- return () => {
1330
- try {
1331
- if (map.getLayer(lineLayerId)) map.removeLayer(lineLayerId)
1332
- if (map.getLayer(fillLayerId)) map.removeLayer(fillLayerId)
1333
- if (map.getSource(sourceId)) map.removeSource(sourceId)
1334
- } catch {
1335
- // style may be mid-reload
1336
- }
1337
- }
1338
- // eslint-disable-next-line react-hooks/exhaustive-deps
1339
- }, [isLoaded, map])
1340
-
1341
- // Sync data when it changes.
1342
- useEffect(() => {
1343
- if (!isLoaded || !map) return
1344
- const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource | undefined
1345
- source?.setData(data as never)
1346
- }, [isLoaded, map, data, sourceId])
1347
-
1348
- // Sync layers and paint when visibility or styling changes.
1349
- useEffect(() => {
1350
- if (!isLoaded || !map) return
1351
-
1352
- const source = map.getSource(sourceId)
1353
- if (!source) return
1354
-
1355
- if (showFill && !map.getLayer(fillLayerId)) {
1356
- map.addLayer(
1357
- {
1358
- id: fillLayerId,
1359
- type: 'fill',
1360
- source: sourceId,
1361
- paint: mergedFillPaint,
1362
- },
1363
- beforeId,
1364
- )
1365
- } else if (!showFill && map.getLayer(fillLayerId)) {
1366
- map.removeLayer(fillLayerId)
1367
- }
1368
-
1369
- if (showLine && !map.getLayer(lineLayerId)) {
1370
- map.addLayer(
1371
- {
1372
- id: lineLayerId,
1373
- type: 'line',
1374
- source: sourceId,
1375
- paint: mergedLinePaint,
1376
- },
1377
- beforeId,
1378
- )
1379
- } else if (!showLine && map.getLayer(lineLayerId)) {
1380
- map.removeLayer(lineLayerId)
1381
- }
1382
-
1383
- if (showFill && map.getLayer(fillLayerId)) {
1384
- for (const [key, value] of Object.entries(mergedFillPaint)) {
1385
- map.setPaintProperty(fillLayerId, key as keyof MapFillPaint, value as never)
1386
- }
1387
- }
1388
- if (showLine && map.getLayer(lineLayerId)) {
1389
- for (const [key, value] of Object.entries(mergedLinePaint)) {
1390
- map.setPaintProperty(lineLayerId, key as keyof MapLinePaint, value as never)
1391
- }
1392
- }
1393
- }, [
1394
- isLoaded,
1395
- map,
1396
- sourceId,
1397
- fillLayerId,
1398
- lineLayerId,
1399
- showFill,
1400
- showLine,
1401
- mergedFillPaint,
1402
- mergedLinePaint,
1403
- beforeId,
1404
- ])
1405
-
1406
- // Interaction handlers (bound to the fill layer).
1407
- useEffect(() => {
1408
- if (!isLoaded || !map || !interactive || !showFill) return
1409
-
1410
- let hoveredId: string | number | null = null
1411
-
1412
- const setHover = (next: string | number | null) => {
1413
- if (next === hoveredId) return
1414
- const sourceExists = !!map.getSource(sourceId)
1415
- if (hoveredId != null && sourceExists) {
1416
- map.setFeatureState({ source: sourceId, id: hoveredId }, { hover: false })
1417
- }
1418
- hoveredId = next
1419
- if (next != null && sourceExists) {
1420
- map.setFeatureState({ source: sourceId, id: next }, { hover: true })
1421
- }
1422
- }
1423
-
1424
- const handleMouseMove = (e: MapLibreGL.MapLayerMouseEvent) => {
1425
- const feature = e.features?.[0]
1426
- if (!feature) return
1427
- map.getCanvas().style.cursor = 'pointer'
1428
-
1429
- const featureId = feature.id
1430
- if (featureId === hoveredId) return
1431
- setHover(featureId ?? null)
1432
- latestRef.current.onHover?.({
1433
- feature: feature as unknown as MapGeoJSONFeature<P>,
1434
- longitude: e.lngLat.lng,
1435
- latitude: e.lngLat.lat,
1436
- originalEvent: e,
1437
- })
1438
- }
1439
-
1440
- const handleMouseLeave = () => {
1441
- setHover(null)
1442
- map.getCanvas().style.cursor = ''
1443
- latestRef.current.onHover?.(null)
1444
- }
1445
-
1446
- const handleClick = (e: MapLibreGL.MapLayerMouseEvent) => {
1447
- const feature = e.features?.[0]
1448
- if (!feature) return
1449
- latestRef.current.onClick?.({
1450
- feature: feature as unknown as MapGeoJSONFeature<P>,
1451
- longitude: e.lngLat.lng,
1452
- latitude: e.lngLat.lat,
1453
- originalEvent: e,
1454
- })
1455
- }
1456
-
1457
- map.on('mousemove', fillLayerId, handleMouseMove)
1458
- map.on('mouseleave', fillLayerId, handleMouseLeave)
1459
- map.on('click', fillLayerId, handleClick)
1460
-
1461
- return () => {
1462
- map.off('mousemove', fillLayerId, handleMouseMove)
1463
- map.off('mouseleave', fillLayerId, handleMouseLeave)
1464
- map.off('click', fillLayerId, handleClick)
1465
- setHover(null)
1466
- map.getCanvas().style.cursor = ''
1467
- }
1468
- }, [isLoaded, map, fillLayerId, sourceId, interactive, showFill])
1469
-
1470
- return null
1471
- }
1472
-
1473
- /** A single arc to render inside <MapArc data={...}>. */
1474
- type MapArcDatum = {
1475
- /** Unique identifier for this arc. Required for hover state tracking and event payloads. */
1476
- id: string | number
1477
- /** Start coordinate as [longitude, latitude]. */
1478
- from: [number, number]
1479
- /** End coordinate as [longitude, latitude]. */
1480
- to: [number, number]
1481
- }
1482
-
1483
- /** Event payload passed to MapArc interaction callbacks. */
1484
- type MapArcEvent<T extends MapArcDatum = MapArcDatum> = {
1485
- /** The arc datum that was hovered or clicked. */
1486
- arc: T
1487
- /** Longitude of the cursor at the time of the event. */
1488
- longitude: number
1489
- /** Latitude of the cursor at the time of the event. */
1490
- latitude: number
1491
- /** The underlying MapLibre mouse event for advanced use cases. */
1492
- originalEvent: MapLibreGL.MapMouseEvent
1493
- }
1494
-
1495
- type MapArcLinePaint = NonNullable<MapLibreGL.LineLayerSpecification['paint']>
1496
- type MapArcLineLayout = NonNullable<MapLibreGL.LineLayerSpecification['layout']>
1497
-
1498
- type MapArcProps<T extends MapArcDatum = MapArcDatum> = {
1499
- /** Array of arcs to render. Each arc must have a unique `id`. */
1500
- data: T[]
1501
- /** Optional unique identifier prefix for the arc source/layers. Auto-generated if not provided. */
1502
- id?: string
1503
- /**
1504
- * How far each arc bows away from a straight line. `0` renders straight
1505
- * lines; higher values bend further. Negative values bend to the opposite
1506
- * side. Arcs are computed as a quadratic Bézier in lng/lat space; the
1507
- * destination longitude is unwrapped relative to the origin so that arcs
1508
- * cross the antimeridian via the shorter great-circle direction. (default: 0.2)
1509
- */
1510
- curvature?: number
1511
- /** Number of samples used to render each curve. Higher = smoother. (default: 64) */
1512
- samples?: number
1513
- /**
1514
- * MapLibre paint properties for the arc layer. Merged on top of sensible
1515
- * defaults (`line-color: #4285F4`, `line-width: 2`, `line-opacity: 0.85`).
1516
- * Any value can be a MapLibre expression for per-feature styling, every
1517
- * field on each arc datum (besides `from`/`to`) is exposed via `["get", ...]`.
1518
- */
1519
- paint?: MapArcLinePaint
1520
- /** MapLibre layout properties for the arc layer. Defaults to rounded joins/caps. */
1521
- layout?: MapArcLineLayout
1522
- /**
1523
- * Paint properties applied to the arc currently under the cursor. Each key
1524
- * is merged into `paint` as a `case` expression keyed on per-feature hover
1525
- * state, so only the hovered arc changes appearance.
1526
- */
1527
- hoverPaint?: MapArcLinePaint
1528
- /** Callback when an arc is clicked. */
1529
- onClick?: (e: MapArcEvent<T>) => void
1530
- /**
1531
- * Callback fired when the hovered arc changes. Receives the cursor's
1532
- * lng/lat at the moment of entry, and `null` when the cursor leaves the
1533
- * last hovered arc.
1534
- */
1535
- onHover?: (e: MapArcEvent<T> | null) => void
1536
- /** Whether arcs respond to mouse events (default: true). */
1537
- interactive?: boolean
1538
- /** Optional MapLibre layer id to insert the arc layers before (z-order control). */
1539
- beforeId?: string
1540
- }
1541
-
1542
- const DEFAULT_ARC_CURVATURE = 0.2
1543
- const DEFAULT_ARC_SAMPLES = 64
1544
- const ARC_HIT_MIN_WIDTH = 12
1545
- const ARC_HIT_PADDING = 6
1546
-
1547
- const DEFAULT_ARC_PAINT: MapArcLinePaint = {
1548
- 'line-color': '#4285F4',
1549
- 'line-width': 2,
1550
- 'line-opacity': 0.85,
1551
- }
1552
-
1553
- const DEFAULT_ARC_LAYOUT: MapArcLineLayout = {
1554
- 'line-join': 'round',
1555
- 'line-cap': 'round',
1556
- }
1557
-
1558
- function buildArcCoordinates(
1559
- from: [number, number],
1560
- to: [number, number],
1561
- curvature: number,
1562
- samples: number,
1563
- ): [number, number][] {
1564
- const [x0, y0] = from
1565
- const [xTo, y2] = to
1566
- // Unwrap the destination longitude so |dx| <= 180. This makes arcs that
1567
- // straddle the antimeridian (e.g. Tokyo -> San Francisco) bow the short way
1568
- // across the Pacific instead of the long way around the globe. Resulting
1569
- // longitudes may fall outside [-180, 180]; MapLibre renders them correctly
1570
- // on the globe projection, and on mercator when world copies are enabled.
1571
- const rawDx = xTo - x0
1572
- const x2 = rawDx > 180 ? xTo - 360 : rawDx < -180 ? xTo + 360 : xTo
1573
- const dx = x2 - x0
1574
- const dy = y2 - y0
1575
- const distance = Math.hypot(dx, dy)
1576
-
1577
- if (distance === 0 || curvature === 0) return [from, [x2, y2]]
1578
-
1579
- const mx = (x0 + x2) / 2
1580
- const my = (y0 + y2) / 2
1581
- const nx = -dy / distance
1582
- const ny = dx / distance
1583
- const offset = distance * curvature
1584
- const cx = mx + nx * offset
1585
- const cy = my + ny * offset
1586
-
1587
- const points: [number, number][] = []
1588
- const segments = Math.max(2, Math.floor(samples))
1589
- for (let i = 0; i <= segments; i += 1) {
1590
- const t = i / segments
1591
- const inv = 1 - t
1592
- const x = inv * inv * x0 + 2 * inv * t * cx + t * t * x2
1593
- const y = inv * inv * y0 + 2 * inv * t * cy + t * t * y2
1594
- points.push([x, y])
1595
- }
1596
- return points
1597
- }
1598
-
1599
- function MapArc<T extends MapArcDatum = MapArcDatum>({
1600
- data,
1601
- id: propId,
1602
- curvature = DEFAULT_ARC_CURVATURE,
1603
- samples = DEFAULT_ARC_SAMPLES,
1604
- paint,
1605
- layout,
1606
- hoverPaint,
1607
- onClick,
1608
- onHover,
1609
- interactive = true,
1610
- beforeId,
1611
- }: MapArcProps<T>) {
1612
- const { map, isLoaded } = useMap()
1613
- const autoId = useId()
1614
- const id = propId ?? autoId
1615
- const sourceId = `arc-source-${id}`
1616
- const layerId = `arc-layer-${id}`
1617
- const hitLayerId = `arc-hit-layer-${id}`
1618
-
1619
- const mergedPaint = useMemo(
1620
- () => mergeHoverPaint({ ...DEFAULT_ARC_PAINT, ...paint }, hoverPaint),
1621
- [paint, hoverPaint],
1622
- )
1623
- const mergedLayout = useMemo(() => ({ ...DEFAULT_ARC_LAYOUT, ...layout }), [layout])
1624
-
1625
- const hitWidth = useMemo(() => {
1626
- const w = paint?.['line-width'] ?? DEFAULT_ARC_PAINT['line-width']
1627
- const base = typeof w === 'number' ? w : ARC_HIT_MIN_WIDTH
1628
- return Math.max(base + ARC_HIT_PADDING, ARC_HIT_MIN_WIDTH)
1629
- }, [paint])
1630
-
1631
- const geoJSON = useMemo<GeoJSON.FeatureCollection<GeoJSON.LineString>>(
1632
- () => ({
1633
- type: 'FeatureCollection',
1634
- features: data.map((arc) => {
1635
- const { from, to, ...properties } = arc
1636
- return {
1637
- type: 'Feature',
1638
- properties,
1639
- geometry: {
1640
- type: 'LineString',
1641
- coordinates: buildArcCoordinates(from, to, curvature, samples),
1642
- },
1643
- }
1644
- }),
1645
- }),
1646
- [data, curvature, samples],
1647
- )
1648
-
1649
- const latestRef = useRef({ data, onClick, onHover })
1650
- latestRef.current = { data, onClick, onHover }
1651
-
1652
- // Add source and layers on mount.
1653
- useEffect(() => {
1654
- if (!isLoaded || !map) return
1655
-
1656
- map.addSource(sourceId, {
1657
- type: 'geojson',
1658
- data: geoJSON,
1659
- promoteId: 'id',
1660
- })
1661
-
1662
- map.addLayer(
1663
- {
1664
- id: hitLayerId,
1665
- type: 'line',
1666
- source: sourceId,
1667
- layout: DEFAULT_ARC_LAYOUT,
1668
- paint: {
1669
- 'line-color': 'rgba(0, 0, 0, 0)',
1670
- 'line-width': hitWidth,
1671
- 'line-opacity': 1,
1672
- },
1673
- },
1674
- beforeId,
1675
- )
1676
-
1677
- map.addLayer(
1678
- {
1679
- id: layerId,
1680
- type: 'line',
1681
- source: sourceId,
1682
- layout: mergedLayout,
1683
- paint: mergedPaint,
1684
- },
1685
- beforeId,
1686
- )
1687
-
1688
- return () => {
1689
- try {
1690
- if (map.getLayer(layerId)) map.removeLayer(layerId)
1691
- if (map.getLayer(hitLayerId)) map.removeLayer(hitLayerId)
1692
- if (map.getSource(sourceId)) map.removeSource(sourceId)
1693
- } catch {
1694
- // ignore
1695
- }
1696
- }
1697
- // eslint-disable-next-line react-hooks/exhaustive-deps
1698
- }, [isLoaded, map])
1699
-
1700
- // Sync features when data / curvature / samples change.
1701
- useEffect(() => {
1702
- if (!isLoaded || !map) return
1703
- const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource | undefined
1704
- source?.setData(geoJSON)
1705
- }, [isLoaded, map, geoJSON, sourceId])
1706
-
1707
- // Sync paint/layout when they change.
1708
- useEffect(() => {
1709
- if (!isLoaded || !map || !map.getLayer(layerId)) return
1710
- for (const [key, value] of Object.entries(mergedPaint)) {
1711
- map.setPaintProperty(layerId, key as keyof MapArcLinePaint, value as never)
1712
- }
1713
- for (const [key, value] of Object.entries(mergedLayout)) {
1714
- map.setLayoutProperty(layerId, key as keyof MapArcLineLayout, value as never)
1715
- }
1716
- if (map.getLayer(hitLayerId)) {
1717
- map.setPaintProperty(hitLayerId, 'line-width', hitWidth)
1718
- }
1719
- }, [isLoaded, map, layerId, hitLayerId, mergedPaint, mergedLayout, hitWidth])
1720
-
1721
- // Interaction handlers
1722
- useEffect(() => {
1723
- if (!isLoaded || !map || !interactive) return
1724
-
1725
- let hoveredId: string | number | null = null
1726
-
1727
- const setHover = (next: string | number | null) => {
1728
- if (next === hoveredId) return
1729
- const sourceExists = !!map.getSource(sourceId)
1730
- if (hoveredId != null && sourceExists) {
1731
- map.setFeatureState({ source: sourceId, id: hoveredId }, { hover: false })
1732
- }
1733
- hoveredId = next
1734
- if (next != null && sourceExists) {
1735
- map.setFeatureState({ source: sourceId, id: next }, { hover: true })
1736
- }
1737
- }
1738
-
1739
- const findArc = (featureId: string | number | undefined) =>
1740
- featureId == null
1741
- ? undefined
1742
- : latestRef.current.data.find((arc) => String(arc.id) === String(featureId))
1743
-
1744
- const handleMouseMove = (e: MapLibreGL.MapLayerMouseEvent) => {
1745
- const featureId = e.features?.[0]?.id as string | number | undefined
1746
- if (featureId == null || featureId === hoveredId) return
1747
-
1748
- setHover(featureId)
1749
- map.getCanvas().style.cursor = 'pointer'
1750
-
1751
- const arc = findArc(featureId)
1752
- if (arc) {
1753
- latestRef.current.onHover?.({
1754
- arc: arc as T,
1755
- longitude: e.lngLat.lng,
1756
- latitude: e.lngLat.lat,
1757
- originalEvent: e,
1758
- })
1759
- }
1760
- }
1761
-
1762
- const handleMouseLeave = () => {
1763
- setHover(null)
1764
- map.getCanvas().style.cursor = ''
1765
- latestRef.current.onHover?.(null)
1766
- }
1767
-
1768
- const handleClick = (e: MapLibreGL.MapLayerMouseEvent) => {
1769
- const arc = findArc(e.features?.[0]?.id as string | number | undefined)
1770
- if (!arc) return
1771
- latestRef.current.onClick?.({
1772
- arc: arc as T,
1773
- longitude: e.lngLat.lng,
1774
- latitude: e.lngLat.lat,
1775
- originalEvent: e,
1776
- })
1777
- }
1778
-
1779
- map.on('mousemove', hitLayerId, handleMouseMove)
1780
- map.on('mouseleave', hitLayerId, handleMouseLeave)
1781
- map.on('click', hitLayerId, handleClick)
1782
-
1783
- return () => {
1784
- map.off('mousemove', hitLayerId, handleMouseMove)
1785
- map.off('mouseleave', hitLayerId, handleMouseLeave)
1786
- map.off('click', hitLayerId, handleClick)
1787
- setHover(null)
1788
- map.getCanvas().style.cursor = ''
1789
- }
1790
- }, [isLoaded, map, hitLayerId, sourceId, interactive])
1791
-
1792
- return null
1793
- }
1794
-
1795
- type MapClusterLayerProps<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> = {
1796
- /** GeoJSON FeatureCollection data or URL to fetch GeoJSON from */
1797
- data: string | GeoJSON.FeatureCollection<GeoJSON.Point, P>
1798
- /** Maximum zoom level to cluster points on (default: 14) */
1799
- clusterMaxZoom?: number
1800
- /** Radius of each cluster when clustering points in pixels (default: 50) */
1801
- clusterRadius?: number
1802
- /** Colors for cluster circles: [small, medium, large] based on point count (default: ["#3b82f6", "#1d4ed8", "#1e3a8a"]) */
1803
- clusterColors?: [string, string, string]
1804
- /** Point count thresholds for color/size steps: [medium, large] (default: [100, 750]) */
1805
- clusterThresholds?: [number, number]
1806
- /** Color for unclustered individual points (default: "#3b82f6") */
1807
- pointColor?: string
1808
- /** Callback when an unclustered point is clicked */
1809
- onPointClick?: (feature: GeoJSON.Feature<GeoJSON.Point, P>, coordinates: [number, number]) => void
1810
- /** Callback when a cluster is clicked. If not provided, zooms into the cluster */
1811
- onClusterClick?: (clusterId: number, coordinates: [number, number], pointCount: number) => void
1812
- }
1813
-
1814
- const DEFAULT_CLUSTER_COLORS: [string, string, string] = ['#3b82f6', '#1d4ed8', '#1e3a8a']
1815
- const DEFAULT_CLUSTER_THRESHOLDS: [number, number] = [100, 750]
1816
-
1817
- function MapClusterLayer<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties>({
1818
- data,
1819
- clusterMaxZoom = 14,
1820
- clusterRadius = 50,
1821
- clusterColors = DEFAULT_CLUSTER_COLORS,
1822
- clusterThresholds = DEFAULT_CLUSTER_THRESHOLDS,
1823
- pointColor = '#3b82f6',
1824
- onPointClick,
1825
- onClusterClick,
1826
- }: MapClusterLayerProps<P>) {
1827
- const { map, isLoaded } = useMap()
1828
- const id = useId()
1829
- const sourceId = `cluster-source-${id}`
1830
- const clusterLayerId = `clusters-${id}`
1831
- const clusterCountLayerId = `cluster-count-${id}`
1832
- const unclusteredLayerId = `unclustered-point-${id}`
1833
-
1834
- const stylePropsRef = useRef({
1835
- clusterColors,
1836
- clusterThresholds,
1837
- pointColor,
1838
- })
1839
-
1840
- // Add source and layers on mount
1841
- useEffect(() => {
1842
- if (!isLoaded || !map) return
1843
-
1844
- // Add clustered GeoJSON source
1845
- map.addSource(sourceId, {
1846
- type: 'geojson',
1847
- data,
1848
- cluster: true,
1849
- clusterMaxZoom,
1850
- clusterRadius,
1851
- })
1852
-
1853
- // Add cluster circles layer
1854
- map.addLayer({
1855
- id: clusterLayerId,
1856
- type: 'circle',
1857
- source: sourceId,
1858
- filter: ['has', 'point_count'],
1859
- paint: {
1860
- 'circle-color': [
1861
- 'step',
1862
- ['get', 'point_count'],
1863
- clusterColors[0],
1864
- clusterThresholds[0],
1865
- clusterColors[1],
1866
- clusterThresholds[1],
1867
- clusterColors[2],
1868
- ],
1869
- 'circle-radius': [
1870
- 'step',
1871
- ['get', 'point_count'],
1872
- 20,
1873
- clusterThresholds[0],
1874
- 30,
1875
- clusterThresholds[1],
1876
- 40,
1877
- ],
1878
- 'circle-stroke-width': 0.75,
1879
- 'circle-stroke-color': '#fff',
1880
- 'circle-opacity': 0.85,
1881
- },
1882
- })
1883
-
1884
- // Add cluster count text layer
1885
- map.addLayer({
1886
- id: clusterCountLayerId,
1887
- type: 'symbol',
1888
- source: sourceId,
1889
- filter: ['has', 'point_count'],
1890
- layout: {
1891
- 'text-field': '{point_count_abbreviated}',
1892
- 'text-font': ['Open Sans Semibold'],
1893
- 'text-size': 12,
1894
- },
1895
- paint: {
1896
- 'text-color': '#fff',
1897
- },
1898
- })
1899
-
1900
- // Add unclustered point layer
1901
- map.addLayer({
1902
- id: unclusteredLayerId,
1903
- type: 'circle',
1904
- source: sourceId,
1905
- filter: ['!', ['has', 'point_count']],
1906
- paint: {
1907
- 'circle-color': pointColor,
1908
- 'circle-radius': 5,
1909
- 'circle-stroke-width': 2,
1910
- 'circle-stroke-color': '#fff',
1911
- },
1912
- })
1913
-
1914
- return () => {
1915
- try {
1916
- if (map.getLayer(clusterCountLayerId)) map.removeLayer(clusterCountLayerId)
1917
- if (map.getLayer(unclusteredLayerId)) map.removeLayer(unclusteredLayerId)
1918
- if (map.getLayer(clusterLayerId)) map.removeLayer(clusterLayerId)
1919
- if (map.getSource(sourceId)) map.removeSource(sourceId)
1920
- } catch {
1921
- // ignore
1922
- }
1923
- }
1924
- // eslint-disable-next-line react-hooks/exhaustive-deps
1925
- }, [isLoaded, map, sourceId])
1926
-
1927
- // Update source data when data prop changes (only for non-URL data)
1928
- useEffect(() => {
1929
- if (!isLoaded || !map || typeof data === 'string') return
1930
-
1931
- const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource
1932
- if (source) {
1933
- source.setData(data)
1934
- }
1935
- }, [isLoaded, map, data, sourceId])
1936
-
1937
- // Update layer styles when props change
1938
- useEffect(() => {
1939
- if (!isLoaded || !map) return
1940
-
1941
- const prev = stylePropsRef.current
1942
- const colorsChanged =
1943
- prev.clusterColors !== clusterColors || prev.clusterThresholds !== clusterThresholds
1944
-
1945
- // Update cluster layer colors and sizes
1946
- if (map.getLayer(clusterLayerId) && colorsChanged) {
1947
- map.setPaintProperty(clusterLayerId, 'circle-color', [
1948
- 'step',
1949
- ['get', 'point_count'],
1950
- clusterColors[0],
1951
- clusterThresholds[0],
1952
- clusterColors[1],
1953
- clusterThresholds[1],
1954
- clusterColors[2],
1955
- ])
1956
- map.setPaintProperty(clusterLayerId, 'circle-radius', [
1957
- 'step',
1958
- ['get', 'point_count'],
1959
- 20,
1960
- clusterThresholds[0],
1961
- 30,
1962
- clusterThresholds[1],
1963
- 40,
1964
- ])
1965
- }
1966
-
1967
- // Update unclustered point layer color
1968
- if (map.getLayer(unclusteredLayerId) && prev.pointColor !== pointColor) {
1969
- map.setPaintProperty(unclusteredLayerId, 'circle-color', pointColor)
1970
- }
1971
-
1972
- stylePropsRef.current = { clusterColors, clusterThresholds, pointColor }
1973
- }, [
1974
- isLoaded,
1975
- map,
1976
- clusterLayerId,
1977
- unclusteredLayerId,
1978
- clusterColors,
1979
- clusterThresholds,
1980
- pointColor,
1981
- ])
1982
-
1983
- // Handle click events
1984
- useEffect(() => {
1985
- if (!isLoaded || !map) return
1986
-
1987
- // Cluster click handler - zoom into cluster
1988
- const handleClusterClick = async (
1989
- e: MapLibreGL.MapMouseEvent & {
1990
- features?: MapLibreGL.MapGeoJSONFeature[]
1991
- },
1992
- ) => {
1993
- const features = map.queryRenderedFeatures(e.point, {
1994
- layers: [clusterLayerId],
1995
- })
1996
- if (!features.length) return
1997
-
1998
- const feature = features[0]
1999
- const clusterId = feature.properties?.cluster_id as number
2000
- const pointCount = feature.properties?.point_count as number
2001
- const coordinates = (feature.geometry as GeoJSON.Point).coordinates as [number, number]
2002
-
2003
- if (onClusterClick) {
2004
- onClusterClick(clusterId, coordinates, pointCount)
2005
- } else {
2006
- // Default behavior: zoom to cluster expansion zoom
2007
- const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource
2008
- const zoom = await source.getClusterExpansionZoom(clusterId)
2009
- map.easeTo({
2010
- center: coordinates,
2011
- zoom,
2012
- })
2013
- }
2014
- }
2015
-
2016
- // Unclustered point click handler
2017
- const handlePointClick = (
2018
- e: MapLibreGL.MapMouseEvent & {
2019
- features?: MapLibreGL.MapGeoJSONFeature[]
2020
- },
2021
- ) => {
2022
- if (!onPointClick || !e.features?.length) return
2023
-
2024
- const feature = e.features[0]
2025
- const coordinates = (feature.geometry as GeoJSON.Point).coordinates.slice() as [
2026
- number,
2027
- number,
2028
- ]
2029
-
2030
- // Handle world copies
2031
- while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
2032
- coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360
2033
- }
2034
-
2035
- onPointClick(feature as unknown as GeoJSON.Feature<GeoJSON.Point, P>, coordinates)
2036
- }
2037
-
2038
- // Cursor style handlers
2039
- const handleMouseEnterCluster = () => {
2040
- map.getCanvas().style.cursor = 'pointer'
2041
- }
2042
- const handleMouseLeaveCluster = () => {
2043
- map.getCanvas().style.cursor = ''
2044
- }
2045
- const handleMouseEnterPoint = () => {
2046
- if (onPointClick) {
2047
- map.getCanvas().style.cursor = 'pointer'
2048
- }
2049
- }
2050
- const handleMouseLeavePoint = () => {
2051
- map.getCanvas().style.cursor = ''
2052
- }
2053
-
2054
- map.on('click', clusterLayerId, handleClusterClick)
2055
- map.on('click', unclusteredLayerId, handlePointClick)
2056
- map.on('mouseenter', clusterLayerId, handleMouseEnterCluster)
2057
- map.on('mouseleave', clusterLayerId, handleMouseLeaveCluster)
2058
- map.on('mouseenter', unclusteredLayerId, handleMouseEnterPoint)
2059
- map.on('mouseleave', unclusteredLayerId, handleMouseLeavePoint)
2060
-
2061
- return () => {
2062
- map.off('click', clusterLayerId, handleClusterClick)
2063
- map.off('click', unclusteredLayerId, handlePointClick)
2064
- map.off('mouseenter', clusterLayerId, handleMouseEnterCluster)
2065
- map.off('mouseleave', clusterLayerId, handleMouseLeaveCluster)
2066
- map.off('mouseenter', unclusteredLayerId, handleMouseEnterPoint)
2067
- map.off('mouseleave', unclusteredLayerId, handleMouseLeavePoint)
2068
- }
2069
- }, [isLoaded, map, clusterLayerId, unclusteredLayerId, sourceId, onClusterClick, onPointClick])
2070
-
2071
- return null
2072
- }
2073
-
2074
- export type {
2075
- MapArcDatum,
2076
- MapArcEvent,
2077
- MapArcProps,
2078
- MapClusterLayerProps,
2079
- MapControlsProps,
2080
- MapGeoJSONData,
2081
- MapGeoJSONEvent,
2082
- MapGeoJSONFeature,
2083
- MapGeoJSONProps,
2084
- MapMarkerProps,
2085
- MapPopupProps,
2086
- MapProps,
2087
- MapRef,
2088
- MapRouteProps,
2089
- MapStyleOption,
2090
- MapViewport,
2091
- MarkerContentProps,
2092
- MarkerLabelProps,
2093
- MarkerPopupProps,
2094
- MarkerTooltipProps,
2095
- }
2096
- export {
2097
- Map,
2098
- MapArc,
2099
- MapClusterLayer,
2100
- MapControls,
2101
- MapGeoJSON,
2102
- MapMarker,
2103
- MapPopup,
2104
- MapRoute,
2105
- MarkerContent,
2106
- MarkerLabel,
2107
- MarkerPopup,
2108
- MarkerTooltip,
2109
- useMap,
2110
- }