shadcn-map 0.1.0
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/dist/components/ClusterLayer.svelte +511 -0
- package/dist/components/ClusterLayer.svelte.d.ts +29 -0
- package/dist/components/DetailsPanel.svelte +180 -0
- package/dist/components/DetailsPanel.svelte.d.ts +18 -0
- package/dist/components/Map.svelte +321 -0
- package/dist/components/Map.svelte.d.ts +63 -0
- package/dist/components/Marker.svelte +594 -0
- package/dist/components/Marker.svelte.d.ts +34 -0
- package/dist/components/Popup.svelte +228 -0
- package/dist/components/Popup.svelte.d.ts +18 -0
- package/dist/components/controls/GeolocateControl.svelte +141 -0
- package/dist/components/controls/GeolocateControl.svelte.d.ts +22 -0
- package/dist/components/controls/NavigationControl.svelte +50 -0
- package/dist/components/controls/NavigationControl.svelte.d.ts +12 -0
- package/dist/components/controls/ScaleControl.svelte +49 -0
- package/dist/components/controls/ScaleControl.svelte.d.ts +12 -0
- package/dist/components/controls/index.d.ts +6 -0
- package/dist/components/controls/index.js +3 -0
- package/dist/components/index.d.ts +13 -0
- package/dist/components/index.js +7 -0
- package/dist/context.svelte.d.ts +37 -0
- package/dist/context.svelte.js +96 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/styles/colors.d.ts +54 -0
- package/dist/styles/colors.js +53 -0
- package/dist/styles/dark.d.ts +7 -0
- package/dist/styles/dark.js +168 -0
- package/dist/styles/index.d.ts +3 -0
- package/dist/styles/index.js +3 -0
- package/dist/styles/light.d.ts +7 -0
- package/dist/styles/light.js +168 -0
- package/dist/theme.d.ts +2 -0
- package/dist/theme.js +34 -0
- package/dist/types.d.ts +56 -0
- package/dist/types.js +1 -0
- package/package.json +72 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
<script lang='ts' module>
|
|
2
|
+
export interface ClusterPoint {
|
|
3
|
+
/** Unique identifier */
|
|
4
|
+
id: string | number
|
|
5
|
+
/** Position [lng, lat] */
|
|
6
|
+
lngLat: [number, number]
|
|
7
|
+
/** Additional properties */
|
|
8
|
+
properties?: Record<string, unknown>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ClusterLayerProps {
|
|
12
|
+
/** Array of points to cluster */
|
|
13
|
+
points: ClusterPoint[]
|
|
14
|
+
/** Click callback for individual points */
|
|
15
|
+
onclick?: (point: ClusterPoint) => void
|
|
16
|
+
/** Click callback for clusters */
|
|
17
|
+
onclusterclick?: (clusterId: number, zoom: number) => void
|
|
18
|
+
/** Cluster radius in pixels */
|
|
19
|
+
clusterRadius?: number
|
|
20
|
+
/** Max zoom to cluster at */
|
|
21
|
+
clusterMaxZoom?: number
|
|
22
|
+
/** Whether to render unclustered points */
|
|
23
|
+
showUnclustered?: boolean
|
|
24
|
+
/** Callback with unclustered point ids */
|
|
25
|
+
onunclusteredchange?: (ids: Set<string>) => void
|
|
26
|
+
/** Callback when cluster source is loading */
|
|
27
|
+
onclusterloadingchange?: (loading: boolean) => void
|
|
28
|
+
}
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<script lang='ts'>
|
|
32
|
+
import type { GeoJSONSource, MapLayerMouseEvent, MapLibreMap } from '../types'
|
|
33
|
+
import Supercluster from 'supercluster'
|
|
34
|
+
import { onMount } from 'svelte'
|
|
35
|
+
import { getMapContext } from '../context.svelte'
|
|
36
|
+
|
|
37
|
+
const {
|
|
38
|
+
points,
|
|
39
|
+
onclick,
|
|
40
|
+
onclusterclick,
|
|
41
|
+
clusterRadius = 50,
|
|
42
|
+
clusterMaxZoom = 14,
|
|
43
|
+
showUnclustered = true,
|
|
44
|
+
onunclusteredchange,
|
|
45
|
+
onclusterloadingchange,
|
|
46
|
+
}: ClusterLayerProps = $props()
|
|
47
|
+
|
|
48
|
+
const ctx = getMapContext()
|
|
49
|
+
|
|
50
|
+
const instanceId = `shadcn-cluster-${Math.random().toString(36).slice(2)}`
|
|
51
|
+
const sourceId = `${instanceId}-source`
|
|
52
|
+
const clusterLayerId = `${instanceId}-cluster`
|
|
53
|
+
const clusterCountId = `${instanceId}-count`
|
|
54
|
+
const unclusteredLayerId = `${instanceId}-point`
|
|
55
|
+
|
|
56
|
+
let map: MapLibreMap | null = null
|
|
57
|
+
let pointsById = new Map<string, ClusterPoint>()
|
|
58
|
+
let detachEvents: (() => void) | null = null
|
|
59
|
+
let lastConfigKey = ''
|
|
60
|
+
let unclusteredUpdateScheduled = false
|
|
61
|
+
let lastUnclusteredKey = '__initial__'
|
|
62
|
+
let pointsKey = ''
|
|
63
|
+
let detachClusterStateEvents: (() => void) | null = null
|
|
64
|
+
let clusterLoading = false
|
|
65
|
+
|
|
66
|
+
let supercluster: Supercluster<{ id: string | number }, Supercluster.AnyProps> | null = null
|
|
67
|
+
let lastSuperclusterPointsKey = ''
|
|
68
|
+
|
|
69
|
+
function getIsDarkMode(): boolean {
|
|
70
|
+
if (typeof document === 'undefined') {
|
|
71
|
+
return true
|
|
72
|
+
}
|
|
73
|
+
return document.documentElement.classList.contains('dark')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getThemeColors() {
|
|
77
|
+
const isDark = getIsDarkMode()
|
|
78
|
+
return isDark
|
|
79
|
+
? {
|
|
80
|
+
clusterLow: '#3f3f46',
|
|
81
|
+
clusterMid: '#52525b',
|
|
82
|
+
clusterHigh: '#71717a',
|
|
83
|
+
clusterStroke: '#09090b',
|
|
84
|
+
clusterText: '#f4f4f5',
|
|
85
|
+
point: '#38bdf8',
|
|
86
|
+
pointStroke: '#0f172a',
|
|
87
|
+
}
|
|
88
|
+
: {
|
|
89
|
+
clusterLow: '#e4e4e7',
|
|
90
|
+
clusterMid: '#d4d4d8',
|
|
91
|
+
clusterHigh: '#a1a1aa',
|
|
92
|
+
clusterStroke: '#ffffff',
|
|
93
|
+
clusterText: '#18181b',
|
|
94
|
+
point: '#0284c7',
|
|
95
|
+
pointStroke: '#ffffff',
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type ClusterFeatureCollection = {
|
|
100
|
+
type: 'FeatureCollection'
|
|
101
|
+
features: Array<{
|
|
102
|
+
type: 'Feature'
|
|
103
|
+
geometry: {
|
|
104
|
+
type: 'Point'
|
|
105
|
+
coordinates: [number, number]
|
|
106
|
+
}
|
|
107
|
+
properties: Record<string, unknown> & { id: string | number }
|
|
108
|
+
}>
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function buildGeoJSON(): ClusterFeatureCollection {
|
|
112
|
+
return {
|
|
113
|
+
type: 'FeatureCollection',
|
|
114
|
+
features: points.map(point => ({
|
|
115
|
+
type: 'Feature',
|
|
116
|
+
geometry: {
|
|
117
|
+
type: 'Point',
|
|
118
|
+
coordinates: point.lngLat,
|
|
119
|
+
},
|
|
120
|
+
properties: {
|
|
121
|
+
id: point.id,
|
|
122
|
+
...point.properties,
|
|
123
|
+
},
|
|
124
|
+
})),
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function updatePointsLookup() {
|
|
129
|
+
pointsById = new Map(points.map(point => [String(point.id), point]))
|
|
130
|
+
const nextPointsKey = points.map(point => String(point.id)).sort().join('|')
|
|
131
|
+
if (nextPointsKey !== pointsKey) {
|
|
132
|
+
pointsKey = nextPointsKey
|
|
133
|
+
lastUnclusteredKey = '__initial__'
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function updateSourceData() {
|
|
138
|
+
const source = map?.getSource(sourceId) as GeoJSONSource | undefined
|
|
139
|
+
if (!source) {
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
source.setData(buildGeoJSON())
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function buildUnclusteredKey(ids: Set<string>) {
|
|
146
|
+
if (ids.size === 0) {
|
|
147
|
+
return ''
|
|
148
|
+
}
|
|
149
|
+
return Array.from(ids).sort().join('|')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function setClusterLoading(next: boolean) {
|
|
153
|
+
if (clusterLoading === next) {
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
clusterLoading = next
|
|
157
|
+
onclusterloadingchange?.(next)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function ensureSupercluster() {
|
|
161
|
+
const currentPointsKey = points.map(p => `${p.id}:${p.lngLat[0]},${p.lngLat[1]}`).join('|')
|
|
162
|
+
if (supercluster && currentPointsKey === lastSuperclusterPointsKey) {
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
supercluster = new Supercluster({
|
|
167
|
+
radius: clusterRadius,
|
|
168
|
+
maxZoom: clusterMaxZoom,
|
|
169
|
+
extent: 512,
|
|
170
|
+
map: props => ({ id: props.id }),
|
|
171
|
+
reduce: (acc, props) => { acc.id = props.id },
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
const features = points.map(point => ({
|
|
175
|
+
type: 'Feature' as const,
|
|
176
|
+
geometry: {
|
|
177
|
+
type: 'Point' as const,
|
|
178
|
+
coordinates: point.lngLat as [number, number],
|
|
179
|
+
},
|
|
180
|
+
properties: { id: point.id },
|
|
181
|
+
}))
|
|
182
|
+
|
|
183
|
+
supercluster.load(features)
|
|
184
|
+
lastSuperclusterPointsKey = currentPointsKey
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function emitUnclusteredIds() {
|
|
188
|
+
if (!map || !onunclusteredchange) {
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
ensureSupercluster()
|
|
193
|
+
if (!supercluster) {
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const currentZoom = Math.floor(map.getZoom())
|
|
198
|
+
|
|
199
|
+
const clusters = supercluster.getClusters([-180, -90, 180, 90], currentZoom)
|
|
200
|
+
|
|
201
|
+
const ids = new Set<string>()
|
|
202
|
+
for (const feature of clusters) {
|
|
203
|
+
const props = feature.properties
|
|
204
|
+
if (!('cluster' in props) || !props.cluster) {
|
|
205
|
+
const id = (props as { id?: string | number }).id
|
|
206
|
+
if (id !== undefined && id !== null) {
|
|
207
|
+
ids.add(String(id))
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const key = buildUnclusteredKey(ids)
|
|
213
|
+
if (key === lastUnclusteredKey) {
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
lastUnclusteredKey = key
|
|
217
|
+
setClusterLoading(false)
|
|
218
|
+
onunclusteredchange(ids)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function scheduleUnclusteredUpdate() {
|
|
222
|
+
if (!onunclusteredchange || unclusteredUpdateScheduled) {
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
unclusteredUpdateScheduled = true
|
|
226
|
+
|
|
227
|
+
const run = () => {
|
|
228
|
+
unclusteredUpdateScheduled = false
|
|
229
|
+
emitUnclusteredIds()
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
233
|
+
requestAnimationFrame(run)
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
setTimeout(run, 0)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function attachClusterStateEvents(currentMap: MapLibreMap) {
|
|
241
|
+
if (!onunclusteredchange) {
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
detachClusterStateEvents?.()
|
|
245
|
+
|
|
246
|
+
const handleZoomChange = () => {
|
|
247
|
+
emitUnclusteredIds()
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const handleViewChange = () => {
|
|
251
|
+
scheduleUnclusteredUpdate()
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
currentMap.on('zoom', handleZoomChange)
|
|
255
|
+
currentMap.on('zoomend', handleZoomChange)
|
|
256
|
+
currentMap.on('move', handleViewChange)
|
|
257
|
+
currentMap.on('moveend', handleViewChange)
|
|
258
|
+
|
|
259
|
+
detachClusterStateEvents = () => {
|
|
260
|
+
currentMap.off('zoom', handleZoomChange)
|
|
261
|
+
currentMap.off('zoomend', handleZoomChange)
|
|
262
|
+
currentMap.off('move', handleViewChange)
|
|
263
|
+
currentMap.off('moveend', handleViewChange)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function attachLayerEvents(currentMap: MapLibreMap) {
|
|
268
|
+
detachEvents?.()
|
|
269
|
+
|
|
270
|
+
const handleClusterClick = (event: MapLayerMouseEvent) => {
|
|
271
|
+
const feature = event.features?.[0]
|
|
272
|
+
const clusterId = Number(feature?.properties?.cluster_id)
|
|
273
|
+
if (!Number.isFinite(clusterId)) {
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const source = currentMap.getSource(sourceId) as GeoJSONSource | undefined
|
|
278
|
+
const coordinates = (feature?.geometry as { coordinates?: [number, number] } | undefined)?.coordinates
|
|
279
|
+
if (!source || !coordinates) {
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
Promise.resolve(source.getClusterExpansionZoom(clusterId))
|
|
284
|
+
.then((zoom) => {
|
|
285
|
+
const center = coordinates as [number, number]
|
|
286
|
+
currentMap.easeTo({ center, zoom })
|
|
287
|
+
onclusterclick?.(clusterId, zoom)
|
|
288
|
+
})
|
|
289
|
+
.catch(() => {})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const handlePointClick = (event: MapLayerMouseEvent) => {
|
|
293
|
+
const feature = event.features?.[0]
|
|
294
|
+
const id = feature?.properties?.id
|
|
295
|
+
const point = pointsById.get(String(id))
|
|
296
|
+
if (point) {
|
|
297
|
+
onclick?.(point)
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const coordinates = (feature?.geometry as { coordinates?: [number, number] } | undefined)?.coordinates
|
|
302
|
+
if (!coordinates) {
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
onclick?.({
|
|
307
|
+
id: id ?? `${coordinates[0]}-${coordinates[1]}`,
|
|
308
|
+
lngLat: coordinates as [number, number],
|
|
309
|
+
properties: { ...feature?.properties },
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const handleMouseEnter = () => {
|
|
314
|
+
currentMap.getCanvas().style.cursor = 'pointer'
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const handleMouseLeave = () => {
|
|
318
|
+
currentMap.getCanvas().style.cursor = ''
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
currentMap.on('click', clusterLayerId, handleClusterClick)
|
|
322
|
+
if (showUnclustered) {
|
|
323
|
+
currentMap.on('click', unclusteredLayerId, handlePointClick)
|
|
324
|
+
}
|
|
325
|
+
currentMap.on('mouseenter', clusterLayerId, handleMouseEnter)
|
|
326
|
+
currentMap.on('mouseleave', clusterLayerId, handleMouseLeave)
|
|
327
|
+
if (showUnclustered) {
|
|
328
|
+
currentMap.on('mouseenter', unclusteredLayerId, handleMouseEnter)
|
|
329
|
+
currentMap.on('mouseleave', unclusteredLayerId, handleMouseLeave)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
detachEvents = () => {
|
|
333
|
+
currentMap.off('click', clusterLayerId, handleClusterClick)
|
|
334
|
+
if (showUnclustered) {
|
|
335
|
+
currentMap.off('click', unclusteredLayerId, handlePointClick)
|
|
336
|
+
}
|
|
337
|
+
currentMap.off('mouseenter', clusterLayerId, handleMouseEnter)
|
|
338
|
+
currentMap.off('mouseleave', clusterLayerId, handleMouseLeave)
|
|
339
|
+
if (showUnclustered) {
|
|
340
|
+
currentMap.off('mouseenter', unclusteredLayerId, handleMouseEnter)
|
|
341
|
+
currentMap.off('mouseleave', unclusteredLayerId, handleMouseLeave)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function addLayers(currentMap: MapLibreMap) {
|
|
347
|
+
if (currentMap.getSource(sourceId)) {
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
currentMap.addSource(sourceId, {
|
|
352
|
+
type: 'geojson',
|
|
353
|
+
data: buildGeoJSON(),
|
|
354
|
+
cluster: true,
|
|
355
|
+
clusterRadius,
|
|
356
|
+
clusterMaxZoom,
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
const colors = getThemeColors()
|
|
360
|
+
|
|
361
|
+
currentMap.addLayer({
|
|
362
|
+
id: clusterLayerId,
|
|
363
|
+
type: 'circle',
|
|
364
|
+
source: sourceId,
|
|
365
|
+
filter: ['has', 'point_count'],
|
|
366
|
+
paint: {
|
|
367
|
+
'circle-color': [
|
|
368
|
+
'step',
|
|
369
|
+
['get', 'point_count'],
|
|
370
|
+
colors.clusterLow,
|
|
371
|
+
10,
|
|
372
|
+
colors.clusterMid,
|
|
373
|
+
50,
|
|
374
|
+
colors.clusterHigh,
|
|
375
|
+
],
|
|
376
|
+
'circle-radius': ['step', ['get', 'point_count'], 16, 10, 20, 50, 24],
|
|
377
|
+
'circle-stroke-width': 1,
|
|
378
|
+
'circle-stroke-color': colors.clusterStroke,
|
|
379
|
+
},
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
currentMap.addLayer({
|
|
383
|
+
id: clusterCountId,
|
|
384
|
+
type: 'symbol',
|
|
385
|
+
source: sourceId,
|
|
386
|
+
filter: ['has', 'point_count'],
|
|
387
|
+
layout: {
|
|
388
|
+
'text-field': '{point_count_abbreviated}',
|
|
389
|
+
'text-font': ['Noto Sans Regular'],
|
|
390
|
+
'text-size': 12,
|
|
391
|
+
},
|
|
392
|
+
paint: {
|
|
393
|
+
'text-color': colors.clusterText,
|
|
394
|
+
'text-opacity': 1,
|
|
395
|
+
},
|
|
396
|
+
})
|
|
397
|
+
currentMap.setPaintProperty(clusterCountId, 'text-opacity-transition', { duration: 0, delay: 0 })
|
|
398
|
+
currentMap.setPaintProperty(clusterCountId, 'text-color-transition', { duration: 0, delay: 0 })
|
|
399
|
+
|
|
400
|
+
if (showUnclustered) {
|
|
401
|
+
currentMap.addLayer({
|
|
402
|
+
id: unclusteredLayerId,
|
|
403
|
+
type: 'circle',
|
|
404
|
+
source: sourceId,
|
|
405
|
+
filter: ['!', ['has', 'point_count']],
|
|
406
|
+
paint: {
|
|
407
|
+
'circle-color': colors.point,
|
|
408
|
+
'circle-radius': 6,
|
|
409
|
+
'circle-stroke-width': 2,
|
|
410
|
+
'circle-stroke-color': colors.pointStroke,
|
|
411
|
+
},
|
|
412
|
+
})
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
attachLayerEvents(currentMap)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function removeLayers(currentMap: MapLibreMap) {
|
|
419
|
+
detachEvents?.()
|
|
420
|
+
detachEvents = null
|
|
421
|
+
|
|
422
|
+
if (currentMap.getLayer(clusterCountId)) {
|
|
423
|
+
currentMap.removeLayer(clusterCountId)
|
|
424
|
+
}
|
|
425
|
+
if (currentMap.getLayer(clusterLayerId)) {
|
|
426
|
+
currentMap.removeLayer(clusterLayerId)
|
|
427
|
+
}
|
|
428
|
+
if (currentMap.getLayer(unclusteredLayerId)) {
|
|
429
|
+
currentMap.removeLayer(unclusteredLayerId)
|
|
430
|
+
}
|
|
431
|
+
if (currentMap.getSource(sourceId)) {
|
|
432
|
+
currentMap.removeSource(sourceId)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function ensureLayers() {
|
|
437
|
+
if (!map || !map.isStyleLoaded()) {
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const configKey = `${clusterRadius}-${clusterMaxZoom}-${showUnclustered}`
|
|
442
|
+
if (configKey !== lastConfigKey) {
|
|
443
|
+
removeLayers(map)
|
|
444
|
+
lastConfigKey = configKey
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
addLayers(map)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
onMount(() => {
|
|
451
|
+
map = ctx.map
|
|
452
|
+
if (!map) {
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
updatePointsLookup()
|
|
457
|
+
attachClusterStateEvents(map)
|
|
458
|
+
|
|
459
|
+
const handleStyleLoad = () => {
|
|
460
|
+
lastConfigKey = ''
|
|
461
|
+
lastUnclusteredKey = '__initial__'
|
|
462
|
+
setClusterLoading(true)
|
|
463
|
+
ensureLayers()
|
|
464
|
+
updateSourceData()
|
|
465
|
+
scheduleUnclusteredUpdate()
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const handleStyleData = () => {
|
|
469
|
+
if (!map || !map.isStyleLoaded()) {
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
if (!map.getSource(sourceId)) {
|
|
473
|
+
handleStyleLoad()
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
map.on('style.load', handleStyleLoad)
|
|
478
|
+
map.on('styledata', handleStyleData)
|
|
479
|
+
|
|
480
|
+
if (map.isStyleLoaded()) {
|
|
481
|
+
handleStyleLoad()
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return () => {
|
|
485
|
+
map?.off('style.load', handleStyleLoad)
|
|
486
|
+
map?.off('styledata', handleStyleData)
|
|
487
|
+
detachClusterStateEvents?.()
|
|
488
|
+
detachClusterStateEvents = null
|
|
489
|
+
if (map) {
|
|
490
|
+
removeLayers(map)
|
|
491
|
+
}
|
|
492
|
+
map = null
|
|
493
|
+
}
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
// Explicitly read points to track as dependency
|
|
497
|
+
const pointsArray = $derived(points)
|
|
498
|
+
|
|
499
|
+
$effect(() => {
|
|
500
|
+
// Access pointsArray to ensure dependency tracking
|
|
501
|
+
void pointsArray
|
|
502
|
+
if (!map || !ctx.loaded) {
|
|
503
|
+
return
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
updatePointsLookup()
|
|
507
|
+
ensureLayers()
|
|
508
|
+
updateSourceData()
|
|
509
|
+
scheduleUnclusteredUpdate()
|
|
510
|
+
})
|
|
511
|
+
</script>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface ClusterPoint {
|
|
2
|
+
/** Unique identifier */
|
|
3
|
+
id: string | number;
|
|
4
|
+
/** Position [lng, lat] */
|
|
5
|
+
lngLat: [number, number];
|
|
6
|
+
/** Additional properties */
|
|
7
|
+
properties?: Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
export interface ClusterLayerProps {
|
|
10
|
+
/** Array of points to cluster */
|
|
11
|
+
points: ClusterPoint[];
|
|
12
|
+
/** Click callback for individual points */
|
|
13
|
+
onclick?: (point: ClusterPoint) => void;
|
|
14
|
+
/** Click callback for clusters */
|
|
15
|
+
onclusterclick?: (clusterId: number, zoom: number) => void;
|
|
16
|
+
/** Cluster radius in pixels */
|
|
17
|
+
clusterRadius?: number;
|
|
18
|
+
/** Max zoom to cluster at */
|
|
19
|
+
clusterMaxZoom?: number;
|
|
20
|
+
/** Whether to render unclustered points */
|
|
21
|
+
showUnclustered?: boolean;
|
|
22
|
+
/** Callback with unclustered point ids */
|
|
23
|
+
onunclusteredchange?: (ids: Set<string>) => void;
|
|
24
|
+
/** Callback when cluster source is loading */
|
|
25
|
+
onclusterloadingchange?: (loading: boolean) => void;
|
|
26
|
+
}
|
|
27
|
+
declare const ClusterLayer: import("svelte").Component<ClusterLayerProps, {}, "">;
|
|
28
|
+
type ClusterLayer = ReturnType<typeof ClusterLayer>;
|
|
29
|
+
export default ClusterLayer;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
<script lang='ts' module>
|
|
2
|
+
import type { Snippet } from 'svelte'
|
|
3
|
+
|
|
4
|
+
export interface DetailsPanelProps {
|
|
5
|
+
/** Whether panel is visible */
|
|
6
|
+
open?: boolean
|
|
7
|
+
/** Close callback */
|
|
8
|
+
onclose?: () => void
|
|
9
|
+
/** Additional CSS classes */
|
|
10
|
+
class?: string
|
|
11
|
+
/** Accessible label */
|
|
12
|
+
ariaLabel?: string
|
|
13
|
+
/** Height mode - 'fit' for content height, 'full' for nearly full height */
|
|
14
|
+
height?: 'fit' | 'full'
|
|
15
|
+
/** Children */
|
|
16
|
+
children?: Snippet
|
|
17
|
+
}
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<script lang='ts'>
|
|
21
|
+
import { onMount } from 'svelte'
|
|
22
|
+
|
|
23
|
+
const {
|
|
24
|
+
open = false,
|
|
25
|
+
onclose,
|
|
26
|
+
class: className = '',
|
|
27
|
+
ariaLabel = 'Details panel',
|
|
28
|
+
height = 'full',
|
|
29
|
+
children,
|
|
30
|
+
}: DetailsPanelProps = $props()
|
|
31
|
+
|
|
32
|
+
let isMobile = $state(false)
|
|
33
|
+
|
|
34
|
+
onMount(() => {
|
|
35
|
+
if (typeof window === 'undefined') {
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const media = window.matchMedia('(max-width: 640px)')
|
|
40
|
+
const update = () => {
|
|
41
|
+
isMobile = media.matches
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
update()
|
|
45
|
+
media.addEventListener('change', update)
|
|
46
|
+
|
|
47
|
+
return () => {
|
|
48
|
+
media.removeEventListener('change', update)
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// Keyboard escape to close
|
|
53
|
+
$effect(() => {
|
|
54
|
+
if (typeof window === 'undefined' || !open) {
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const handleKeydown = (event: KeyboardEvent) => {
|
|
59
|
+
if (event.key === 'Escape') {
|
|
60
|
+
onclose?.()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
window.addEventListener('keydown', handleKeydown)
|
|
65
|
+
|
|
66
|
+
return () => {
|
|
67
|
+
window.removeEventListener('keydown', handleKeydown)
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
{#if open}
|
|
73
|
+
<div
|
|
74
|
+
class='shadcn-details-panel {className}'
|
|
75
|
+
data-device={isMobile ? 'mobile' : 'desktop'}
|
|
76
|
+
data-height={height}
|
|
77
|
+
role='complementary'
|
|
78
|
+
aria-label={ariaLabel}
|
|
79
|
+
>
|
|
80
|
+
<button
|
|
81
|
+
type='button'
|
|
82
|
+
class='shadcn-details-close'
|
|
83
|
+
aria-label='Close panel'
|
|
84
|
+
onclick={() => onclose?.()}
|
|
85
|
+
>
|
|
86
|
+
<svg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'>
|
|
87
|
+
<path d='M18 6 6 18' /><path d='m6 6 12 12' />
|
|
88
|
+
</svg>
|
|
89
|
+
</button>
|
|
90
|
+
|
|
91
|
+
<div class='shadcn-details-content'>
|
|
92
|
+
{#if children}
|
|
93
|
+
{@render children()}
|
|
94
|
+
{/if}
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
{/if}
|
|
98
|
+
|
|
99
|
+
<style>
|
|
100
|
+
.shadcn-details-panel {
|
|
101
|
+
position: absolute;
|
|
102
|
+
background: oklch(var(--card));
|
|
103
|
+
color: oklch(var(--card-foreground));
|
|
104
|
+
border: 1px solid oklch(var(--border));
|
|
105
|
+
border-radius: 12px;
|
|
106
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2), 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
107
|
+
display: flex;
|
|
108
|
+
flex-direction: column;
|
|
109
|
+
z-index: 10;
|
|
110
|
+
pointer-events: auto;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* Desktop: Left side floating panel */
|
|
114
|
+
.shadcn-details-panel[data-device='desktop'] {
|
|
115
|
+
top: 12px;
|
|
116
|
+
left: 12px;
|
|
117
|
+
width: min(90vw, 360px);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.shadcn-details-panel[data-device='desktop'][data-height='full'] {
|
|
121
|
+
bottom: 52px;
|
|
122
|
+
max-height: calc(100% - 64px);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.shadcn-details-panel[data-device='desktop'][data-height='fit'] {
|
|
126
|
+
bottom: auto;
|
|
127
|
+
max-height: calc(100% - 64px);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* Mobile: Bottom floating card */
|
|
131
|
+
.shadcn-details-panel[data-device='mobile'] {
|
|
132
|
+
left: 12px;
|
|
133
|
+
right: 12px;
|
|
134
|
+
bottom: 52px;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.shadcn-details-panel[data-device='mobile'][data-height='full'] {
|
|
138
|
+
max-height: 45vh;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.shadcn-details-panel[data-device='mobile'][data-height='fit'] {
|
|
142
|
+
max-height: 60vh;
|
|
143
|
+
height: auto;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.shadcn-details-close {
|
|
147
|
+
position: absolute;
|
|
148
|
+
top: 12px;
|
|
149
|
+
right: 12px;
|
|
150
|
+
width: 32px;
|
|
151
|
+
height: 32px;
|
|
152
|
+
display: flex;
|
|
153
|
+
align-items: center;
|
|
154
|
+
justify-content: center;
|
|
155
|
+
border: none;
|
|
156
|
+
background: oklch(var(--muted));
|
|
157
|
+
color: oklch(var(--muted-foreground));
|
|
158
|
+
border-radius: 8px;
|
|
159
|
+
cursor: pointer;
|
|
160
|
+
transition: background-color 0.15s, color 0.15s;
|
|
161
|
+
z-index: 1;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.shadcn-details-close:hover {
|
|
165
|
+
background: oklch(var(--accent));
|
|
166
|
+
color: oklch(var(--accent-foreground));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.shadcn-details-close:focus-visible {
|
|
170
|
+
outline: 2px solid oklch(var(--ring));
|
|
171
|
+
outline-offset: 2px;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.shadcn-details-content {
|
|
175
|
+
padding: 20px;
|
|
176
|
+
padding-top: 16px;
|
|
177
|
+
overflow: auto;
|
|
178
|
+
height: 100%;
|
|
179
|
+
}
|
|
180
|
+
</style>
|