@djangocfg/ui-tools 2.1.109 → 2.1.111

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.
Files changed (159) hide show
  1. package/README.md +242 -49
  2. package/dist/JsonSchemaForm-65NLLK56.mjs +4 -0
  3. package/dist/JsonSchemaForm-65NLLK56.mjs.map +1 -0
  4. package/dist/JsonSchemaForm-PY6DH3HE.cjs +13 -0
  5. package/dist/JsonSchemaForm-PY6DH3HE.cjs.map +1 -0
  6. package/dist/JsonTree-6RYAOPSS.mjs +4 -0
  7. package/dist/JsonTree-6RYAOPSS.mjs.map +1 -0
  8. package/dist/JsonTree-7OH6CIHT.cjs +10 -0
  9. package/dist/JsonTree-7OH6CIHT.cjs.map +1 -0
  10. package/dist/MapContainer-GXQLP5WY.mjs +214 -0
  11. package/dist/MapContainer-GXQLP5WY.mjs.map +1 -0
  12. package/dist/MapContainer-RYG4HPH4.cjs +221 -0
  13. package/dist/MapContainer-RYG4HPH4.cjs.map +1 -0
  14. package/dist/{Mermaid.client-4OCKJ6QD.mjs → Mermaid.client-OKACITCW.mjs} +16 -7
  15. package/dist/Mermaid.client-OKACITCW.mjs.map +1 -0
  16. package/dist/{Mermaid.client-ZP6OE46Z.cjs → Mermaid.client-PNXEC6YL.cjs} +16 -7
  17. package/dist/Mermaid.client-PNXEC6YL.cjs.map +1 -0
  18. package/dist/{PlaygroundLayout-XXVBU4WZ.cjs → PlaygroundLayout-SYMEAG3J.cjs} +25 -24
  19. package/dist/PlaygroundLayout-SYMEAG3J.cjs.map +1 -0
  20. package/dist/{PlaygroundLayout-LMQTVXSP.mjs → PlaygroundLayout-UQRBU5RH.mjs} +4 -3
  21. package/dist/PlaygroundLayout-UQRBU5RH.mjs.map +1 -0
  22. package/dist/{PrettyCode.client-2CLSV2VD.cjs → PrettyCode.client-DANYYQYO.cjs} +11 -4
  23. package/dist/PrettyCode.client-DANYYQYO.cjs.map +1 -0
  24. package/dist/{PrettyCode.client-Y2BVON7R.mjs → PrettyCode.client-RS5ZTNBT.mjs} +11 -4
  25. package/dist/PrettyCode.client-RS5ZTNBT.mjs.map +1 -0
  26. package/dist/chunk-2DSR7V2L.mjs +561 -0
  27. package/dist/chunk-2DSR7V2L.mjs.map +1 -0
  28. package/dist/chunk-47T5ECYV.cjs +1357 -0
  29. package/dist/chunk-47T5ECYV.cjs.map +1 -0
  30. package/dist/chunk-5QT3QYFZ.cjs +189 -0
  31. package/dist/chunk-5QT3QYFZ.cjs.map +1 -0
  32. package/dist/chunk-7IIRYG4S.mjs +1057 -0
  33. package/dist/chunk-7IIRYG4S.mjs.map +1 -0
  34. package/dist/{chunk-FB5QBSI3.cjs → chunk-DI3HUXHK.cjs} +15 -195
  35. package/dist/chunk-DI3HUXHK.cjs.map +1 -0
  36. package/dist/chunk-EVGWYASL.cjs +1528 -0
  37. package/dist/chunk-EVGWYASL.cjs.map +1 -0
  38. package/dist/chunk-F2N7P5XU.cjs +30 -0
  39. package/dist/chunk-F2N7P5XU.cjs.map +1 -0
  40. package/dist/{chunk-L6UHASYQ.mjs → chunk-G6PRZP5I.mjs} +7 -186
  41. package/dist/chunk-G6PRZP5I.mjs.map +1 -0
  42. package/dist/chunk-JWB2EWQO.mjs +5 -0
  43. package/dist/chunk-JWB2EWQO.mjs.map +1 -0
  44. package/dist/chunk-LTJX2JXE.mjs +338 -0
  45. package/dist/chunk-LTJX2JXE.mjs.map +1 -0
  46. package/dist/chunk-OVNC4KW6.mjs +1494 -0
  47. package/dist/chunk-OVNC4KW6.mjs.map +1 -0
  48. package/dist/chunk-PNZSJN6T.cjs +1086 -0
  49. package/dist/chunk-PNZSJN6T.cjs.map +1 -0
  50. package/dist/chunk-TEFRA7GW.cjs +565 -0
  51. package/dist/chunk-TEFRA7GW.cjs.map +1 -0
  52. package/dist/chunk-UOMPPIED.mjs +1343 -0
  53. package/dist/chunk-UOMPPIED.mjs.map +1 -0
  54. package/dist/chunk-W6YHQI4F.mjs +187 -0
  55. package/dist/chunk-W6YHQI4F.mjs.map +1 -0
  56. package/dist/chunk-XTBRWVIV.cjs +346 -0
  57. package/dist/chunk-XTBRWVIV.cjs.map +1 -0
  58. package/dist/components-C7ZL7OMY.mjs +5 -0
  59. package/dist/components-C7ZL7OMY.mjs.map +1 -0
  60. package/dist/components-CJ2IB65O.cjs +27 -0
  61. package/dist/components-CJ2IB65O.cjs.map +1 -0
  62. package/dist/components-EASJYK45.mjs +6 -0
  63. package/dist/components-EASJYK45.mjs.map +1 -0
  64. package/dist/components-LDRFDV4A.cjs +22 -0
  65. package/dist/components-LDRFDV4A.cjs.map +1 -0
  66. package/dist/components-VZKUTDJK.mjs +5 -0
  67. package/dist/components-VZKUTDJK.mjs.map +1 -0
  68. package/dist/components-Y64GTIMQ.cjs +42 -0
  69. package/dist/components-Y64GTIMQ.cjs.map +1 -0
  70. package/dist/index.cjs +701 -4813
  71. package/dist/index.cjs.map +1 -1
  72. package/dist/index.d.cts +1274 -1026
  73. package/dist/index.d.ts +1274 -1026
  74. package/dist/index.mjs +358 -4730
  75. package/dist/index.mjs.map +1 -1
  76. package/package.json +27 -4
  77. package/src/components/index.ts +17 -0
  78. package/src/components/lazy-wrapper.tsx +281 -0
  79. package/src/index.ts +92 -7
  80. package/src/tools/AudioPlayer/components/HybridAudioPlayer.tsx +14 -5
  81. package/src/tools/AudioPlayer/lazy.tsx +85 -0
  82. package/src/tools/Gallery/components/Gallery.tsx +182 -0
  83. package/src/tools/Gallery/components/GalleryCarousel.tsx +251 -0
  84. package/src/tools/Gallery/components/GalleryCompact.tsx +173 -0
  85. package/src/tools/Gallery/components/GalleryGrid.tsx +493 -0
  86. package/src/tools/Gallery/components/GalleryImage.tsx +66 -0
  87. package/src/tools/Gallery/components/GalleryLightbox.tsx +331 -0
  88. package/src/tools/Gallery/components/GalleryMedia.tsx +66 -0
  89. package/src/tools/Gallery/components/GalleryThumbnails.tsx +173 -0
  90. package/src/tools/Gallery/components/GalleryThumbnailsVirtual.tsx +138 -0
  91. package/src/tools/Gallery/components/GalleryVideo.tsx +222 -0
  92. package/src/tools/Gallery/components/index.ts +13 -0
  93. package/src/tools/Gallery/hooks/index.ts +23 -0
  94. package/src/tools/Gallery/hooks/useGallery.ts +137 -0
  95. package/src/tools/Gallery/hooks/useImageDimensions.ts +223 -0
  96. package/src/tools/Gallery/hooks/usePinchZoom.ts +234 -0
  97. package/src/tools/Gallery/hooks/usePreloadImages.ts +71 -0
  98. package/src/tools/Gallery/hooks/useSwipe.ts +86 -0
  99. package/src/tools/Gallery/hooks/useVirtualList.ts +129 -0
  100. package/src/tools/Gallery/hooks/useZoom.ts +316 -0
  101. package/src/tools/Gallery/index.ts +66 -0
  102. package/src/tools/Gallery/types.ts +183 -0
  103. package/src/tools/Gallery/utils/imageAnalysis.ts +52 -0
  104. package/src/tools/Gallery/utils/index.ts +11 -0
  105. package/src/tools/ImageViewer/components/ImageToolbar.tsx +20 -8
  106. package/src/tools/ImageViewer/components/ImageViewer.tsx +12 -4
  107. package/src/tools/ImageViewer/lazy.tsx +37 -0
  108. package/src/tools/JsonForm/lazy.tsx +43 -0
  109. package/src/tools/JsonForm/widgets/ColorWidget.tsx +4 -1
  110. package/src/tools/JsonTree/lazy.tsx +45 -0
  111. package/src/tools/LottiePlayer/lazy.tsx +57 -0
  112. package/src/tools/Map/components/CustomOverlay.tsx +54 -0
  113. package/src/tools/Map/components/DrawControl.tsx +36 -0
  114. package/src/tools/Map/components/GeocoderControl.tsx +70 -0
  115. package/src/tools/Map/components/LayerSwitcher.tsx +225 -0
  116. package/src/tools/Map/components/MapCluster.tsx +273 -0
  117. package/src/tools/Map/components/MapContainer.tsx +191 -0
  118. package/src/tools/Map/components/MapControls.tsx +44 -0
  119. package/src/tools/Map/components/MapLegend.tsx +161 -0
  120. package/src/tools/Map/components/MapMarker.tsx +102 -0
  121. package/src/tools/Map/components/MapPopup.tsx +46 -0
  122. package/src/tools/Map/components/MapSource.tsx +30 -0
  123. package/src/tools/Map/components/index.ts +20 -0
  124. package/src/tools/Map/context/MapContext.tsx +89 -0
  125. package/src/tools/Map/context/index.ts +2 -0
  126. package/src/tools/Map/hooks/index.ts +9 -0
  127. package/src/tools/Map/hooks/useMap.ts +11 -0
  128. package/src/tools/Map/hooks/useMapControl.ts +99 -0
  129. package/src/tools/Map/hooks/useMapEvents.ts +147 -0
  130. package/src/tools/Map/hooks/useMapLayers.ts +83 -0
  131. package/src/tools/Map/hooks/useMapViewport.ts +62 -0
  132. package/src/tools/Map/hooks/useMarkers.ts +85 -0
  133. package/src/tools/Map/index.ts +116 -0
  134. package/src/tools/Map/layers/cluster.ts +94 -0
  135. package/src/tools/Map/layers/index.ts +15 -0
  136. package/src/tools/Map/layers/line.ts +93 -0
  137. package/src/tools/Map/layers/point.ts +61 -0
  138. package/src/tools/Map/layers/polygon.ts +73 -0
  139. package/src/tools/Map/lazy.tsx +56 -0
  140. package/src/tools/Map/styles/index.ts +15 -0
  141. package/src/tools/Map/types.ts +259 -0
  142. package/src/tools/Map/utils/geo.ts +88 -0
  143. package/src/tools/Map/utils/index.ts +16 -0
  144. package/src/tools/Map/utils/transform.ts +107 -0
  145. package/src/tools/Mermaid/Mermaid.client.tsx +12 -4
  146. package/src/tools/Mermaid/components/MermaidFullscreenModal.tsx +6 -2
  147. package/src/tools/Mermaid/lazy.tsx +46 -0
  148. package/src/tools/OpenapiViewer/lazy.tsx +72 -0
  149. package/src/tools/PrettyCode/PrettyCode.client.tsx +10 -3
  150. package/src/tools/PrettyCode/lazy.tsx +64 -0
  151. package/src/tools/VideoPlayer/lazy.tsx +63 -0
  152. package/dist/Mermaid.client-4OCKJ6QD.mjs.map +0 -1
  153. package/dist/Mermaid.client-ZP6OE46Z.cjs.map +0 -1
  154. package/dist/PlaygroundLayout-LMQTVXSP.mjs.map +0 -1
  155. package/dist/PlaygroundLayout-XXVBU4WZ.cjs.map +0 -1
  156. package/dist/PrettyCode.client-2CLSV2VD.cjs.map +0 -1
  157. package/dist/PrettyCode.client-Y2BVON7R.mjs.map +0 -1
  158. package/dist/chunk-FB5QBSI3.cjs.map +0 -1
  159. package/dist/chunk-L6UHASYQ.mjs.map +0 -1
@@ -0,0 +1,225 @@
1
+ 'use client'
2
+
3
+ import { useState, useCallback, useMemo, memo } from 'react'
4
+ import { useMapLayers } from '../hooks/useMapLayers'
5
+
6
+ import type { LayerSwitcherProps, LayerConfig } from '../types'
7
+
8
+ const positionStyles: Record<string, React.CSSProperties> = {
9
+ 'top-left': { top: 10, left: 10 },
10
+ 'top-right': { top: 10, right: 10 },
11
+ 'bottom-left': { bottom: 10, left: 10 },
12
+ 'bottom-right': { bottom: 10, right: 10 },
13
+ }
14
+
15
+ function LayerSwitcherComponent({
16
+ layers,
17
+ position = 'top-right',
18
+ title = 'Layers',
19
+ collapsible = true,
20
+ defaultCollapsed = false,
21
+ showToggleAll = false,
22
+ className = '',
23
+ style,
24
+ onChange,
25
+ }: LayerSwitcherProps) {
26
+ const { setLayerVisibility } = useMapLayers()
27
+ const [collapsed, setCollapsed] = useState(defaultCollapsed)
28
+
29
+ const initialVisibility = useMemo(() => {
30
+ const initial: Record<string, boolean> = {}
31
+ layers.forEach((layer) => {
32
+ initial[layer.id] = layer.defaultVisible !== false
33
+ })
34
+ return initial
35
+ }, [layers])
36
+
37
+ const [visibility, setVisibility] = useState<Record<string, boolean>>(initialVisibility)
38
+
39
+ const handleToggle = useCallback(
40
+ (layerId: string) => {
41
+ const newVisible = !visibility[layerId]
42
+ setVisibility((prev) => ({ ...prev, [layerId]: newVisible }))
43
+ setLayerVisibility(layerId, newVisible)
44
+ onChange?.(layerId, newVisible)
45
+ },
46
+ [visibility, setLayerVisibility, onChange]
47
+ )
48
+
49
+ const handleToggleAll = useCallback(
50
+ (visible: boolean) => {
51
+ const newVisibility: Record<string, boolean> = {}
52
+ layers.forEach((layer) => {
53
+ newVisibility[layer.id] = visible
54
+ setLayerVisibility(layer.id, visible)
55
+ onChange?.(layer.id, visible)
56
+ })
57
+ setVisibility(newVisibility)
58
+ },
59
+ [layers, setLayerVisibility, onChange]
60
+ )
61
+
62
+ const allVisible = useMemo(() => Object.values(visibility).every(Boolean), [visibility])
63
+ const noneVisible = useMemo(() => Object.values(visibility).every((v) => !v), [visibility])
64
+
65
+ const containerStyle = useMemo<React.CSSProperties>(
66
+ () => ({
67
+ position: 'absolute',
68
+ ...positionStyles[position],
69
+ backgroundColor: 'white',
70
+ borderRadius: 8,
71
+ boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
72
+ padding: collapsed ? 8 : 12,
73
+ minWidth: collapsed ? 'auto' : 150,
74
+ zIndex: 1,
75
+ ...style,
76
+ }),
77
+ [position, collapsed, style]
78
+ )
79
+
80
+ const headerStyle = useMemo<React.CSSProperties>(
81
+ () => ({
82
+ display: 'flex',
83
+ alignItems: 'center',
84
+ justifyContent: 'space-between',
85
+ gap: 8,
86
+ cursor: collapsible ? 'pointer' : 'default',
87
+ fontWeight: 600,
88
+ fontSize: 12,
89
+ color: '#333',
90
+ marginBottom: collapsed ? 0 : 8,
91
+ }),
92
+ [collapsible, collapsed]
93
+ )
94
+
95
+ const itemStyle = useMemo<React.CSSProperties>(
96
+ () => ({
97
+ display: 'flex',
98
+ alignItems: 'center',
99
+ gap: 8,
100
+ padding: '4px 0',
101
+ fontSize: 12,
102
+ color: '#666',
103
+ cursor: 'pointer',
104
+ }),
105
+ []
106
+ )
107
+
108
+ const checkboxStyle = useMemo<React.CSSProperties>(
109
+ () => ({
110
+ width: 14,
111
+ height: 14,
112
+ cursor: 'pointer',
113
+ }),
114
+ []
115
+ )
116
+
117
+ const groups = useMemo(() => {
118
+ return layers.reduce(
119
+ (acc, layer) => {
120
+ const group = layer.group || ''
121
+ if (!acc[group]) acc[group] = []
122
+ acc[group].push(layer)
123
+ return acc
124
+ },
125
+ {} as Record<string, LayerConfig[]>
126
+ )
127
+ }, [layers])
128
+
129
+ const handleHeaderClick = collapsible ? () => setCollapsed(!collapsed) : undefined
130
+
131
+ return (
132
+ <div className={className} style={containerStyle}>
133
+ <div style={headerStyle} onClick={handleHeaderClick}>
134
+ <span>{title}</span>
135
+ {collapsible && (
136
+ <svg
137
+ width="12"
138
+ height="12"
139
+ viewBox="0 0 12 12"
140
+ style={{
141
+ transform: collapsed ? 'rotate(-90deg)' : 'rotate(0deg)',
142
+ transition: 'transform 0.2s',
143
+ }}
144
+ >
145
+ <path d="M3 4.5L6 7.5L9 4.5" stroke="#666" fill="none" />
146
+ </svg>
147
+ )}
148
+ </div>
149
+ {!collapsed && (
150
+ <div>
151
+ {showToggleAll && (
152
+ <div
153
+ style={{
154
+ ...itemStyle,
155
+ borderBottom: '1px solid #eee',
156
+ marginBottom: 4,
157
+ paddingBottom: 8,
158
+ }}
159
+ >
160
+ <button
161
+ onClick={() => handleToggleAll(true)}
162
+ disabled={allVisible}
163
+ style={{
164
+ fontSize: 10,
165
+ padding: '2px 6px',
166
+ cursor: allVisible ? 'default' : 'pointer',
167
+ opacity: allVisible ? 0.5 : 1,
168
+ }}
169
+ >
170
+ All
171
+ </button>
172
+ <button
173
+ onClick={() => handleToggleAll(false)}
174
+ disabled={noneVisible}
175
+ style={{
176
+ fontSize: 10,
177
+ padding: '2px 6px',
178
+ cursor: noneVisible ? 'default' : 'pointer',
179
+ opacity: noneVisible ? 0.5 : 1,
180
+ }}
181
+ >
182
+ None
183
+ </button>
184
+ </div>
185
+ )}
186
+ {Object.entries(groups).map(([group, groupLayers]) => (
187
+ <div key={group || 'default'}>
188
+ {group && (
189
+ <div
190
+ style={{
191
+ fontSize: 10,
192
+ fontWeight: 600,
193
+ color: '#999',
194
+ marginTop: 8,
195
+ marginBottom: 4,
196
+ textTransform: 'uppercase',
197
+ }}
198
+ >
199
+ {group}
200
+ </div>
201
+ )}
202
+ {groupLayers.map((layer) => (
203
+ <div
204
+ key={layer.id}
205
+ style={itemStyle}
206
+ onClick={() => handleToggle(layer.id)}
207
+ >
208
+ <input
209
+ type="checkbox"
210
+ checked={visibility[layer.id]}
211
+ onChange={() => handleToggle(layer.id)}
212
+ style={checkboxStyle}
213
+ />
214
+ <span>{layer.label}</span>
215
+ </div>
216
+ ))}
217
+ </div>
218
+ ))}
219
+ </div>
220
+ )}
221
+ </div>
222
+ )
223
+ }
224
+
225
+ export const LayerSwitcher = memo(LayerSwitcherComponent)
@@ -0,0 +1,273 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useState, useEffect, memo, type ReactNode } from 'react'
4
+ import { Source, Layer, useMap, Popup } from 'react-map-gl/maplibre'
5
+ import type { GeoJSONSource, MapMouseEvent } from 'maplibre-gl'
6
+ import { createClusterLayers } from '../layers'
7
+ import type { ClusterLayerOptions } from '../types'
8
+
9
+ // Inject popup styles once
10
+ const POPUP_STYLE_ID = 'map-cluster-popup-styles'
11
+ function injectPopupStyles() {
12
+ if (typeof document === 'undefined') return
13
+ if (document.getElementById(POPUP_STYLE_ID)) return
14
+
15
+ const style = document.createElement('style')
16
+ style.id = POPUP_STYLE_ID
17
+ style.textContent = `
18
+ .maplibregl-popup.map-popup-clean .maplibregl-popup-content {
19
+ padding: 0 !important;
20
+ background: transparent !important;
21
+ box-shadow: none !important;
22
+ border-radius: 0 !important;
23
+ }
24
+ .maplibregl-popup.map-popup-clean .maplibregl-popup-tip {
25
+ display: none !important;
26
+ }
27
+ `
28
+ document.head.appendChild(style)
29
+ }
30
+
31
+ export interface MapClusterProps {
32
+ sourceId: string
33
+ data: GeoJSON.FeatureCollection
34
+ clusterRadius?: number
35
+ clusterMaxZoom?: number
36
+ onClusterClick?: (clusterId: number, coordinates: [number, number]) => void
37
+ onPointClick?: (feature: GeoJSON.Feature) => void
38
+ /** Render prop for popup content - handles popup state automatically */
39
+ renderPopup?: (feature: GeoJSON.Feature, onClose: () => void) => ReactNode
40
+ /** Popup anchor position */
41
+ popupAnchor?: 'top' | 'bottom' | 'left' | 'right'
42
+ /** Popup offset */
43
+ popupOffset?: number | [number, number]
44
+ /** Pan offset X when clicking point (pixels). Positive = point shifts right. */
45
+ panOffsetX?: number
46
+ /** Pan offset Y when clicking point (pixels). Positive = point shifts down. */
47
+ panOffsetY?: number
48
+ colors?: [string, string, string]
49
+ radii?: [number, number, number]
50
+ thresholds?: [number, number]
51
+ /** Color when marker/cluster is hovered */
52
+ hoverColor?: string
53
+ }
54
+
55
+ export const MapCluster = memo(function MapCluster({
56
+ sourceId,
57
+ data,
58
+ clusterRadius = 50,
59
+ clusterMaxZoom = 14,
60
+ onClusterClick,
61
+ onPointClick,
62
+ renderPopup,
63
+ popupAnchor = 'bottom',
64
+ popupOffset = 15,
65
+ panOffsetX = 0,
66
+ panOffsetY = 150,
67
+ colors,
68
+ radii,
69
+ thresholds,
70
+ hoverColor,
71
+ }: MapClusterProps) {
72
+ const { current: map } = useMap()
73
+ const [selectedFeature, setSelectedFeature] = useState<GeoJSON.Feature | null>(null)
74
+ const [popupCoords, setPopupCoords] = useState<[number, number] | null>(null)
75
+ const [hoveredFeatureId, setHoveredFeatureId] = useState<number | string | null>(null)
76
+
77
+ // Inject popup styles on mount
78
+ useEffect(() => {
79
+ injectPopupStyles()
80
+ }, [])
81
+
82
+ const layerOptions: ClusterLayerOptions = {
83
+ sourceId,
84
+ colors,
85
+ radii,
86
+ thresholds,
87
+ hoverColor,
88
+ }
89
+
90
+ const { cluster, clusterCount, unclusteredPoint } = createClusterLayers(layerOptions)
91
+
92
+ const handleClosePopup = useCallback(() => {
93
+ setSelectedFeature(null)
94
+ setPopupCoords(null)
95
+ }, [])
96
+
97
+ const handleClick = useCallback(
98
+ async (event: MapMouseEvent) => {
99
+ if (!map) return
100
+
101
+ const features = map.queryRenderedFeatures(event.point, {
102
+ layers: [cluster.id!, unclusteredPoint.id!],
103
+ })
104
+
105
+ if (!features || features.length === 0) return
106
+
107
+ const feature = features[0]
108
+ const geometry = feature.geometry
109
+
110
+ if (geometry.type !== 'Point') return
111
+
112
+ const coordinates = geometry.coordinates as [number, number]
113
+ const clusterId = feature.properties?.cluster_id
114
+
115
+ if (clusterId) {
116
+ const source = map.getSource(sourceId) as GeoJSONSource
117
+ if (!source) return
118
+
119
+ try {
120
+ const zoom = await source.getClusterExpansionZoom(clusterId)
121
+ map.easeTo({
122
+ center: coordinates,
123
+ zoom,
124
+ duration: 500,
125
+ })
126
+ onClusterClick?.(clusterId, coordinates)
127
+ } catch (error) {
128
+ console.error('Error expanding cluster:', error)
129
+ }
130
+ } else {
131
+ // Handle point click
132
+ onPointClick?.(feature)
133
+
134
+ // If renderPopup is provided, show popup and pan to point
135
+ if (renderPopup) {
136
+ // Pan map to center on point with offset for popup visibility
137
+ map.easeTo({
138
+ center: coordinates,
139
+ duration: 300,
140
+ offset: [panOffsetX, panOffsetY],
141
+ })
142
+ setSelectedFeature(feature)
143
+ setPopupCoords(coordinates)
144
+ }
145
+ }
146
+ },
147
+ [map, sourceId, cluster.id, unclusteredPoint.id, onClusterClick, onPointClick, renderPopup, panOffsetX, panOffsetY]
148
+ )
149
+
150
+ // Close popup when clicking empty area
151
+ const handleMapClick = useCallback(
152
+ (event: MapMouseEvent) => {
153
+ if (!map) return
154
+
155
+ const features = map.queryRenderedFeatures(event.point, {
156
+ layers: [cluster.id!, unclusteredPoint.id!],
157
+ })
158
+
159
+ // Close popup if clicked on empty area (no features)
160
+ if (!features || features.length === 0) {
161
+ handleClosePopup()
162
+ }
163
+ },
164
+ [map, cluster.id, unclusteredPoint.id, handleClosePopup]
165
+ )
166
+
167
+ // Register click and hover handlers with proper cleanup
168
+ useEffect(() => {
169
+ if (!map) return
170
+
171
+ const clusterLayerId = cluster.id!
172
+ const pointLayerId = unclusteredPoint.id!
173
+ let currentHoveredId: number | string | null = null
174
+
175
+ // Helper to clear hover state
176
+ const clearHoverState = () => {
177
+ if (currentHoveredId !== null) {
178
+ map.setFeatureState(
179
+ { source: sourceId, id: currentHoveredId },
180
+ { hover: false }
181
+ )
182
+ currentHoveredId = null
183
+ setHoveredFeatureId(null)
184
+ }
185
+ }
186
+
187
+ // Hover handler for both clusters and points
188
+ const handleMouseMove = (e: MapMouseEvent) => {
189
+ const features = map.queryRenderedFeatures(e.point, {
190
+ layers: [clusterLayerId, pointLayerId],
191
+ })
192
+
193
+ if (features.length > 0) {
194
+ map.getCanvas().style.cursor = 'pointer'
195
+ const feature = features[0]
196
+ const featureId = feature.id
197
+
198
+ if (featureId !== undefined && featureId !== currentHoveredId) {
199
+ clearHoverState()
200
+ currentHoveredId = featureId
201
+ setHoveredFeatureId(featureId)
202
+ map.setFeatureState(
203
+ { source: sourceId, id: featureId },
204
+ { hover: true }
205
+ )
206
+ }
207
+ } else {
208
+ map.getCanvas().style.cursor = ''
209
+ clearHoverState()
210
+ }
211
+ }
212
+
213
+ const handleMouseLeave = () => {
214
+ map.getCanvas().style.cursor = ''
215
+ clearHoverState()
216
+ }
217
+
218
+ map.on('click', clusterLayerId, handleClick)
219
+ map.on('click', pointLayerId, handleClick)
220
+ map.on('click', handleMapClick)
221
+ map.on('mousemove', clusterLayerId, handleMouseMove)
222
+ map.on('mousemove', pointLayerId, handleMouseMove)
223
+ map.on('mouseleave', clusterLayerId, handleMouseLeave)
224
+ map.on('mouseleave', pointLayerId, handleMouseLeave)
225
+
226
+ // Cleanup on unmount
227
+ return () => {
228
+ clearHoverState()
229
+ map.off('click', clusterLayerId, handleClick)
230
+ map.off('click', pointLayerId, handleClick)
231
+ map.off('click', handleMapClick)
232
+ map.off('mousemove', clusterLayerId, handleMouseMove)
233
+ map.off('mousemove', pointLayerId, handleMouseMove)
234
+ map.off('mouseleave', clusterLayerId, handleMouseLeave)
235
+ map.off('mouseleave', pointLayerId, handleMouseLeave)
236
+ }
237
+ }, [map, sourceId, cluster.id, unclusteredPoint.id, handleClick, handleMapClick])
238
+
239
+ return (
240
+ <>
241
+ <Source
242
+ id={sourceId}
243
+ type="geojson"
244
+ data={data}
245
+ cluster={true}
246
+ clusterMaxZoom={clusterMaxZoom}
247
+ clusterRadius={clusterRadius}
248
+ generateId={true}
249
+ >
250
+ <Layer {...cluster} />
251
+ <Layer {...clusterCount} />
252
+ <Layer {...unclusteredPoint} />
253
+ </Source>
254
+
255
+ {/* Popup rendered via render prop */}
256
+ {renderPopup && selectedFeature && popupCoords && (
257
+ <Popup
258
+ longitude={popupCoords[0]}
259
+ latitude={popupCoords[1]}
260
+ anchor={popupAnchor}
261
+ onClose={handleClosePopup}
262
+ closeOnClick={false}
263
+ closeButton={false}
264
+ offset={popupOffset}
265
+ maxWidth="none"
266
+ className="map-popup-clean"
267
+ >
268
+ {renderPopup(selectedFeature, handleClosePopup)}
269
+ </Popup>
270
+ )}
271
+ </>
272
+ )
273
+ })
@@ -0,0 +1,191 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, type ReactNode } from 'react'
4
+ import Map, { type ViewStateChangeEvent } from 'react-map-gl/maplibre'
5
+ import { ExternalLink, RotateCcw } from 'lucide-react'
6
+ import { cn } from '@djangocfg/ui-core/lib'
7
+ import 'maplibre-gl/dist/maplibre-gl.css'
8
+
9
+ import { MapProvider, useMapContext } from '../context'
10
+ import { MAP_STYLES } from '../styles'
11
+ import type { MapViewport, MapStyleKey } from '../types'
12
+
13
+ export interface MapContainerProps {
14
+ children?: ReactNode
15
+ initialViewport?: Partial<MapViewport>
16
+ mapStyle?: MapStyleKey | string
17
+ interactiveLayerIds?: string[]
18
+ className?: string
19
+ style?: React.CSSProperties
20
+ cursor?: string
21
+ attributionControl?: boolean
22
+ reuseMaps?: boolean
23
+ /** URL to open in external maps app (shows "Open in Maps" button if provided) */
24
+ openInMapsUrl?: string
25
+ /** Label for the open in maps button */
26
+ openInMapsLabel?: string
27
+ /** Auto-reset to initial viewport after N ms of inactivity (0 = disabled) */
28
+ autoResetDelay?: number
29
+ /** Show reset button */
30
+ showResetButton?: boolean
31
+ }
32
+
33
+ interface MapInnerProps extends Omit<MapContainerProps, 'initialViewport'> {}
34
+
35
+ function MapInner({
36
+ children,
37
+ mapStyle = 'light',
38
+ interactiveLayerIds,
39
+ style,
40
+ cursor,
41
+ attributionControl = true,
42
+ reuseMaps = true,
43
+ openInMapsUrl,
44
+ openInMapsLabel = 'Open in Maps',
45
+ autoResetDelay = 0,
46
+ showResetButton = false,
47
+ }: MapInnerProps) {
48
+ const { mapRef, viewport, setViewport, setIsLoaded, resetToInitial, initialViewport } = useMapContext()
49
+ const resetTimerRef = useRef<NodeJS.Timeout | null>(null)
50
+ const isInteractingRef = useRef(false)
51
+
52
+ // Check if viewport has changed from initial
53
+ const hasViewportChanged =
54
+ Math.abs(viewport.longitude - initialViewport.longitude) > 0.0001 ||
55
+ Math.abs(viewport.latitude - initialViewport.latitude) > 0.0001 ||
56
+ Math.abs(viewport.zoom - initialViewport.zoom) > 0.1
57
+
58
+ const handleMove = useCallback(
59
+ (evt: ViewStateChangeEvent) => {
60
+ setViewport({
61
+ longitude: evt.viewState.longitude,
62
+ latitude: evt.viewState.latitude,
63
+ zoom: evt.viewState.zoom,
64
+ bearing: evt.viewState.bearing,
65
+ pitch: evt.viewState.pitch,
66
+ })
67
+ },
68
+ [setViewport]
69
+ )
70
+
71
+ const handleMoveStart = useCallback(() => {
72
+ isInteractingRef.current = true
73
+ // Clear any pending reset timer
74
+ if (resetTimerRef.current) {
75
+ clearTimeout(resetTimerRef.current)
76
+ resetTimerRef.current = null
77
+ }
78
+ }, [])
79
+
80
+ const handleMoveEnd = useCallback(() => {
81
+ isInteractingRef.current = false
82
+ // Start auto-reset timer if enabled
83
+ if (autoResetDelay > 0) {
84
+ resetTimerRef.current = setTimeout(() => {
85
+ if (!isInteractingRef.current) {
86
+ resetToInitial()
87
+ }
88
+ }, autoResetDelay)
89
+ }
90
+ }, [autoResetDelay, resetToInitial])
91
+
92
+ const handleLoad = useCallback(() => {
93
+ setIsLoaded(true)
94
+ }, [setIsLoaded])
95
+
96
+ // Cleanup timer on unmount
97
+ useEffect(() => {
98
+ return () => {
99
+ if (resetTimerRef.current) {
100
+ clearTimeout(resetTimerRef.current)
101
+ }
102
+ }
103
+ }, [])
104
+
105
+ const resolvedStyle = mapStyle in MAP_STYLES
106
+ ? MAP_STYLES[mapStyle as MapStyleKey]
107
+ : mapStyle
108
+
109
+ return (
110
+ <>
111
+ <Map
112
+ ref={mapRef}
113
+ {...viewport}
114
+ onMove={handleMove}
115
+ onMoveStart={handleMoveStart}
116
+ onMoveEnd={handleMoveEnd}
117
+ onLoad={handleLoad}
118
+ mapStyle={resolvedStyle}
119
+ interactiveLayerIds={interactiveLayerIds}
120
+ attributionControl={attributionControl ? {} : false}
121
+ reuseMaps={reuseMaps}
122
+ style={{
123
+ width: '100%',
124
+ height: '100%',
125
+ ...style,
126
+ }}
127
+ cursor={cursor}
128
+ >
129
+ {children}
130
+ </Map>
131
+
132
+ {/* Map overlay buttons */}
133
+ <div className="absolute bottom-3 right-3 flex items-center gap-2">
134
+ {/* Reset button */}
135
+ {showResetButton && hasViewportChanged && (
136
+ <button
137
+ type="button"
138
+ onClick={resetToInitial}
139
+ className={cn(
140
+ 'inline-flex items-center gap-2 px-3 py-2 rounded-lg',
141
+ 'bg-background/95 backdrop-blur-sm text-foreground text-sm font-medium',
142
+ 'hover:bg-background transition-colors shadow-sm border border-border'
143
+ )}
144
+ >
145
+ <RotateCcw className="w-4 h-4" />
146
+ Reset
147
+ </button>
148
+ )}
149
+
150
+ {/* Open in Maps button */}
151
+ {openInMapsUrl && (
152
+ <a
153
+ href={openInMapsUrl}
154
+ target="_blank"
155
+ rel="noopener noreferrer"
156
+ className={cn(
157
+ 'inline-flex items-center gap-2 px-4 py-2 rounded-lg',
158
+ 'bg-background/95 backdrop-blur-sm text-foreground text-sm font-medium',
159
+ 'hover:bg-background transition-colors shadow-sm border border-border'
160
+ )}
161
+ >
162
+ <ExternalLink className="w-4 h-4" />
163
+ {openInMapsLabel}
164
+ </a>
165
+ )}
166
+ </div>
167
+ </>
168
+ )
169
+ }
170
+
171
+ export function MapContainer({
172
+ children,
173
+ initialViewport,
174
+ className,
175
+ ...props
176
+ }: MapContainerProps) {
177
+ return (
178
+ <div className={className} style={{ width: '100%', height: '100%', position: 'relative' }}>
179
+ <MapProvider initialViewport={initialViewport}>
180
+ <MapInner {...props}>{children}</MapInner>
181
+ </MapProvider>
182
+ </div>
183
+ )
184
+ }
185
+
186
+ /**
187
+ * Use this when you need the map inside an existing MapProvider
188
+ */
189
+ export function MapView(props: Omit<MapContainerProps, 'initialViewport'>) {
190
+ return <MapInner {...props} />
191
+ }