mertani-web-toolkit 0.1.63 → 0.1.65
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/inputs/SelectInput/SelectInput.css +27 -6
- package/dist/components/inputs/SelectInput/SelectInput.svelte +40 -15
- package/dist/components/inputs/SelectInput/SelectInput.svelte.d.ts +2 -0
- package/dist/components/map/Map.d.ts +1 -0
- package/dist/components/map/Map.js +1 -0
- package/dist/components/map/Map.svelte +284 -0
- package/dist/components/map/Map.svelte.d.ts +44 -0
- package/dist/components/map/MapGeoJSON.svelte +60 -0
- package/dist/components/map/MapGeoJSON.svelte.d.ts +8 -0
- package/dist/components/map/MapMarker.svelte +424 -0
- package/dist/components/map/MapMarker.svelte.d.ts +25 -0
- package/dist/components/map/MapMarkerGroup.svelte +213 -0
- package/dist/components/map/MapMarkerGroup.svelte.d.ts +31 -0
- package/dist/components/map/MapTopoJSON.svelte +70 -0
- package/dist/components/map/MapTopoJSON.svelte.d.ts +9 -0
- package/dist/components/map/MapVelocity.svelte +152 -0
- package/dist/components/map/MapVelocity.svelte.d.ts +37 -0
- package/dist/components/map/PopupWrapper.svelte +8 -0
- package/dist/components/map/PopupWrapper.svelte.d.ts +7 -0
- package/dist/components/map/map.css +176 -0
- package/dist/icons/BsSliders.svelte +21 -21
- package/dist/icons/Crosshair.svelte +12 -0
- package/dist/icons/Crosshair.svelte.d.ts +26 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +6 -0
- package/dist/utils/mapContext.d.ts +27 -0
- package/dist/utils/mapContext.js +52 -0
- package/package.json +9 -2
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onDestroy, type Snippet, tick, mount, unmount } from 'svelte';
|
|
3
|
+
import type { LeafletMouseEvent } from 'leaflet';
|
|
4
|
+
import { useMapContext, useClusterContext, setMarkerItemContext } from '../../utils/mapContext.js';
|
|
5
|
+
import PopupWrapper from './PopupWrapper.svelte';
|
|
6
|
+
|
|
7
|
+
interface MarkerItem {
|
|
8
|
+
lat: number;
|
|
9
|
+
lng: number;
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
item?: MarkerItem;
|
|
15
|
+
lat?: number;
|
|
16
|
+
lng?: number;
|
|
17
|
+
popupHtml?: string;
|
|
18
|
+
popup?: Snippet;
|
|
19
|
+
title?: string;
|
|
20
|
+
iconUrl?: string;
|
|
21
|
+
iconSize?: [number, number];
|
|
22
|
+
iconAnchor?: [number, number];
|
|
23
|
+
onClick?: (item: MarkerItem | undefined, event: LeafletMouseEvent) => void;
|
|
24
|
+
zoomOnClick?: boolean;
|
|
25
|
+
zoomLevel?: number;
|
|
26
|
+
children?: Snippet;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let {
|
|
30
|
+
item,
|
|
31
|
+
lat,
|
|
32
|
+
lng,
|
|
33
|
+
popupHtml,
|
|
34
|
+
popup: popupSnippet,
|
|
35
|
+
title,
|
|
36
|
+
iconUrl,
|
|
37
|
+
iconSize = [32, 32],
|
|
38
|
+
iconAnchor,
|
|
39
|
+
onClick,
|
|
40
|
+
zoomOnClick = true,
|
|
41
|
+
zoomLevel = 15,
|
|
42
|
+
children
|
|
43
|
+
}: Props = $props();
|
|
44
|
+
|
|
45
|
+
const mapStore = useMapContext();
|
|
46
|
+
const clusterStore = useClusterContext();
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
48
|
+
let map: any = null;
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
50
|
+
let cluster: any = null;
|
|
51
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
52
|
+
let marker: any = null;
|
|
53
|
+
let markerElement: HTMLElement | null = $state(null);
|
|
54
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
55
|
+
let popupMountedInstance: any = null;
|
|
56
|
+
let unsub: (() => void) | null = null;
|
|
57
|
+
let clusterUnsub: (() => void) | null = null;
|
|
58
|
+
let popupCloseHandler: (() => void) | null = null;
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
60
|
+
let L: any = null;
|
|
61
|
+
|
|
62
|
+
if (item) {
|
|
63
|
+
setMarkerItemContext(item);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const markerLat = $derived(item?.lat ?? lat ?? 0);
|
|
67
|
+
const markerLng = $derived(item?.lng ?? lng ?? 0);
|
|
68
|
+
|
|
69
|
+
function createDefaultIcon() {
|
|
70
|
+
if (!L) {
|
|
71
|
+
throw new Error('Leaflet not initialized');
|
|
72
|
+
}
|
|
73
|
+
return L.divIcon({
|
|
74
|
+
html: `
|
|
75
|
+
<div style="
|
|
76
|
+
width: ${iconSize[0]}px;
|
|
77
|
+
height: ${iconSize[1]}px;
|
|
78
|
+
background-color: var(--color-bg-act-primary);
|
|
79
|
+
border-radius: 50% 50% 50% 0;
|
|
80
|
+
transform: rotate(-45deg);
|
|
81
|
+
border: 3px solid white;
|
|
82
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
83
|
+
position: relative;
|
|
84
|
+
">
|
|
85
|
+
<div style="
|
|
86
|
+
position: absolute;
|
|
87
|
+
top: 50%;
|
|
88
|
+
left: 50%;
|
|
89
|
+
transform: translate(-50%, -50%) rotate(45deg);
|
|
90
|
+
width: ${Math.round(iconSize[0] * 0.4)}px;
|
|
91
|
+
height: ${Math.round(iconSize[1] * 0.4)}px;
|
|
92
|
+
background-color: white;
|
|
93
|
+
border-radius: 50%;
|
|
94
|
+
"></div>
|
|
95
|
+
</div>
|
|
96
|
+
`,
|
|
97
|
+
iconSize: iconSize,
|
|
98
|
+
iconAnchor: iconAnchor ?? [Math.round(iconSize[0] / 2), iconSize[1]],
|
|
99
|
+
className: 'default-marker-icon'
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
104
|
+
function createIconFromElement(element: HTMLElement): any {
|
|
105
|
+
if (!L) return null;
|
|
106
|
+
|
|
107
|
+
const cloned = element.cloneNode(true) as HTMLElement;
|
|
108
|
+
cloned.style.position = '';
|
|
109
|
+
cloned.style.left = '';
|
|
110
|
+
cloned.style.top = '';
|
|
111
|
+
cloned.style.visibility = '';
|
|
112
|
+
cloned.style.pointerEvents = '';
|
|
113
|
+
cloned.style.display = '';
|
|
114
|
+
|
|
115
|
+
return L.divIcon({
|
|
116
|
+
html: cloned.outerHTML,
|
|
117
|
+
iconSize: iconSize,
|
|
118
|
+
iconAnchor: iconAnchor ?? [Math.round(iconSize[0] / 2), iconSize[1]],
|
|
119
|
+
className: 'custom-marker-icon'
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function createDefaultPopupContent(): string {
|
|
124
|
+
let content = '';
|
|
125
|
+
if (item) {
|
|
126
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
127
|
+
content += `<h3 style="margin: 0 0 8px 0; font-size: 16px; font-weight: 600;">${(item as any).name || title || 'Marker'}</h3>`;
|
|
128
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
129
|
+
if ((item as any).id) {
|
|
130
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
131
|
+
content += `<p style="margin: 0; font-size: 14px; color: #666;">ID: ${(item as any).id}</p>`;
|
|
132
|
+
}
|
|
133
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
134
|
+
if ((item as any).status) {
|
|
135
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
136
|
+
content += `<p style="margin: 4px 0 0 0; font-size: 14px; color: #666;">Status: ${(item as any).status}</p>`;
|
|
137
|
+
}
|
|
138
|
+
} else if (title) {
|
|
139
|
+
content += `<h3 style="margin: 0 0 8px 0; font-size: 16px; font-weight: 600;">${title}</h3>`;
|
|
140
|
+
} else {
|
|
141
|
+
content += `<p>Default Marker Popup</p>`;
|
|
142
|
+
}
|
|
143
|
+
return `<div style="padding: 12px;">${content}</div>`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
147
|
+
async function bindPopupWithMount(e: any) {
|
|
148
|
+
if (!popupSnippet || !marker || !L || !map) return;
|
|
149
|
+
|
|
150
|
+
const popupContent = document.createElement('div');
|
|
151
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
152
|
+
let element: any;
|
|
153
|
+
|
|
154
|
+
const onClose = () => {
|
|
155
|
+
if (element) {
|
|
156
|
+
unmount(element);
|
|
157
|
+
element = null;
|
|
158
|
+
}
|
|
159
|
+
if (marker) {
|
|
160
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
161
|
+
(marker as any).unbindPopup?.();
|
|
162
|
+
}
|
|
163
|
+
if (map && popupCloseHandler) {
|
|
164
|
+
map.off('popupclose', popupCloseHandler);
|
|
165
|
+
popupCloseHandler = null;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
element = mount(PopupWrapper, {
|
|
170
|
+
target: popupContent,
|
|
171
|
+
props: {
|
|
172
|
+
snippet: popupSnippet
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
popupMountedInstance = element;
|
|
177
|
+
|
|
178
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
179
|
+
(marker as any)
|
|
180
|
+
.bindPopup?.(popupContent, { closeButton: true, className: 'custom-popup', maxWidth: 320 })
|
|
181
|
+
.openPopup(e.latlng || [markerLat, markerLng]);
|
|
182
|
+
|
|
183
|
+
popupCloseHandler = onClose;
|
|
184
|
+
map.on('popupclose', onClose);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function ensureMarker() {
|
|
188
|
+
if (!map || marker) return;
|
|
189
|
+
|
|
190
|
+
if (!L) {
|
|
191
|
+
const leafletMod = await import('leaflet');
|
|
192
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
193
|
+
L = (leafletMod as any).default ?? leafletMod;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!L) return;
|
|
197
|
+
|
|
198
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
199
|
+
let icon: any = undefined;
|
|
200
|
+
|
|
201
|
+
if (children) {
|
|
202
|
+
if (markerElement) {
|
|
203
|
+
icon = createIconFromElement(markerElement);
|
|
204
|
+
} else {
|
|
205
|
+
await tick();
|
|
206
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
207
|
+
if (markerElement) {
|
|
208
|
+
icon = createIconFromElement(markerElement);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!icon && iconUrl && iconUrl.length) {
|
|
214
|
+
icon = L.icon({
|
|
215
|
+
iconUrl,
|
|
216
|
+
iconSize,
|
|
217
|
+
iconAnchor: iconAnchor ?? [Math.round(iconSize[0] / 2), iconSize[1]]
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!icon) {
|
|
222
|
+
icon = createDefaultIcon();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
marker = L.marker([markerLat, markerLng], { title, icon });
|
|
226
|
+
|
|
227
|
+
if (item) {
|
|
228
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
229
|
+
(marker as any)._item = item;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!popupSnippet && popupHtml) {
|
|
233
|
+
marker.bindPopup(popupHtml, {
|
|
234
|
+
closeButton: true,
|
|
235
|
+
className: 'custom-popup',
|
|
236
|
+
maxWidth: 320
|
|
237
|
+
});
|
|
238
|
+
} else if (!popupSnippet && !popupHtml) {
|
|
239
|
+
const defaultPopup = createDefaultPopupContent();
|
|
240
|
+
marker.bindPopup(defaultPopup, {
|
|
241
|
+
closeButton: true,
|
|
242
|
+
className: 'custom-popup',
|
|
243
|
+
maxWidth: 320
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (popupSnippet) {
|
|
248
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
249
|
+
marker.on('click', (e: any) => {
|
|
250
|
+
if (zoomOnClick && map) {
|
|
251
|
+
map.flyTo([markerLat, markerLng], zoomLevel, {
|
|
252
|
+
animate: true,
|
|
253
|
+
duration: 0.5
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (onClick) {
|
|
258
|
+
onClick(item, e);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
bindPopupWithMount(e);
|
|
262
|
+
});
|
|
263
|
+
} else {
|
|
264
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
265
|
+
marker.on('click', (e: any) => {
|
|
266
|
+
if (zoomOnClick && map) {
|
|
267
|
+
map.flyTo([markerLat, markerLng], zoomLevel, {
|
|
268
|
+
animate: true,
|
|
269
|
+
duration: 0.5
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (onClick) {
|
|
274
|
+
onClick(item, e);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (cluster) {
|
|
280
|
+
cluster.addLayer(marker);
|
|
281
|
+
} else {
|
|
282
|
+
marker.addTo(map);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function sync() {
|
|
287
|
+
if (!marker || !L) return;
|
|
288
|
+
marker.setLatLng([markerLat, markerLng]);
|
|
289
|
+
if (typeof title === 'string') {
|
|
290
|
+
marker.options.title = title;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (children && markerElement && L) {
|
|
294
|
+
const newIcon = createIconFromElement(markerElement);
|
|
295
|
+
if (newIcon) {
|
|
296
|
+
marker.setIcon(newIcon);
|
|
297
|
+
}
|
|
298
|
+
} else if (iconUrl && iconUrl.length && L) {
|
|
299
|
+
const newIcon = L.icon({
|
|
300
|
+
iconUrl,
|
|
301
|
+
iconSize,
|
|
302
|
+
iconAnchor: iconAnchor ?? [Math.round(iconSize[0] / 2), iconSize[1]]
|
|
303
|
+
});
|
|
304
|
+
marker.setIcon(newIcon);
|
|
305
|
+
} else if (!children && !iconUrl) {
|
|
306
|
+
marker.setIcon(createDefaultIcon());
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
$effect(() => {
|
|
311
|
+
if (marker) {
|
|
312
|
+
sync();
|
|
313
|
+
} else {
|
|
314
|
+
ensureMarker();
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
$effect(() => {
|
|
319
|
+
if (marker && children && markerElement && L) {
|
|
320
|
+
(async () => {
|
|
321
|
+
await tick();
|
|
322
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
323
|
+
|
|
324
|
+
if (markerElement && marker && L) {
|
|
325
|
+
const newIcon = createIconFromElement(markerElement);
|
|
326
|
+
if (newIcon) {
|
|
327
|
+
marker.setIcon(newIcon);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
})();
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
$effect(() => {
|
|
335
|
+
return () => {
|
|
336
|
+
if (popupMountedInstance) {
|
|
337
|
+
unmount(popupMountedInstance);
|
|
338
|
+
popupMountedInstance = null;
|
|
339
|
+
}
|
|
340
|
+
if (popupCloseHandler && map) {
|
|
341
|
+
map.off('popupclose', popupCloseHandler);
|
|
342
|
+
popupCloseHandler = null;
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
348
|
+
unsub = (mapStore as any).subscribe((m: any) => {
|
|
349
|
+
map = m;
|
|
350
|
+
if (!map && marker) {
|
|
351
|
+
if (cluster) {
|
|
352
|
+
cluster.removeLayer(marker);
|
|
353
|
+
} else {
|
|
354
|
+
marker.remove?.();
|
|
355
|
+
}
|
|
356
|
+
marker = null;
|
|
357
|
+
}
|
|
358
|
+
if (map) ensureMarker();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
if (clusterStore) {
|
|
362
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
363
|
+
clusterUnsub = (clusterStore as any).subscribe((c: any) => {
|
|
364
|
+
cluster = c;
|
|
365
|
+
if (marker) {
|
|
366
|
+
if (cluster) {
|
|
367
|
+
marker.remove?.();
|
|
368
|
+
cluster.addLayer(marker);
|
|
369
|
+
} else if (map) {
|
|
370
|
+
if (marker._icon && marker._icon.parentNode) {
|
|
371
|
+
marker._icon.parentNode.removeChild(marker._icon);
|
|
372
|
+
}
|
|
373
|
+
marker.addTo(map);
|
|
374
|
+
}
|
|
375
|
+
} else if (map) {
|
|
376
|
+
ensureMarker();
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
onDestroy(() => {
|
|
382
|
+
try {
|
|
383
|
+
unsub?.();
|
|
384
|
+
clusterUnsub?.();
|
|
385
|
+
} finally {
|
|
386
|
+
unsub = null;
|
|
387
|
+
clusterUnsub = null;
|
|
388
|
+
if (marker) {
|
|
389
|
+
marker.off?.();
|
|
390
|
+
if (cluster) {
|
|
391
|
+
cluster.removeLayer(marker);
|
|
392
|
+
} else {
|
|
393
|
+
marker.remove?.();
|
|
394
|
+
}
|
|
395
|
+
marker = null;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
</script>
|
|
400
|
+
|
|
401
|
+
{#if children}
|
|
402
|
+
<div
|
|
403
|
+
bind:this={markerElement}
|
|
404
|
+
style="position: absolute; left: -9999px; top: -9999px; visibility: hidden; pointer-events: none;"
|
|
405
|
+
>
|
|
406
|
+
{@render children()}
|
|
407
|
+
</div>
|
|
408
|
+
{/if}
|
|
409
|
+
|
|
410
|
+
<style>
|
|
411
|
+
:global(.default-marker-icon) {
|
|
412
|
+
background: transparent !important;
|
|
413
|
+
border: none !important;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
:global(.custom-marker-icon) {
|
|
417
|
+
background: transparent !important;
|
|
418
|
+
border: none !important;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
:global(.custom-popup .leaflet-popup-content) {
|
|
422
|
+
margin: 0;
|
|
423
|
+
}
|
|
424
|
+
</style>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type Snippet } from 'svelte';
|
|
2
|
+
import type { LeafletMouseEvent } from 'leaflet';
|
|
3
|
+
interface MarkerItem {
|
|
4
|
+
lat: number;
|
|
5
|
+
lng: number;
|
|
6
|
+
[key: string]: unknown;
|
|
7
|
+
}
|
|
8
|
+
interface Props {
|
|
9
|
+
item?: MarkerItem;
|
|
10
|
+
lat?: number;
|
|
11
|
+
lng?: number;
|
|
12
|
+
popupHtml?: string;
|
|
13
|
+
popup?: Snippet;
|
|
14
|
+
title?: string;
|
|
15
|
+
iconUrl?: string;
|
|
16
|
+
iconSize?: [number, number];
|
|
17
|
+
iconAnchor?: [number, number];
|
|
18
|
+
onClick?: (item: MarkerItem | undefined, event: LeafletMouseEvent) => void;
|
|
19
|
+
zoomOnClick?: boolean;
|
|
20
|
+
zoomLevel?: number;
|
|
21
|
+
children?: Snippet;
|
|
22
|
+
}
|
|
23
|
+
declare const MapMarker: import("svelte").Component<Props, {}, "">;
|
|
24
|
+
type MapMarker = ReturnType<typeof MapMarker>;
|
|
25
|
+
export default MapMarker;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onDestroy, type Snippet } from 'svelte';
|
|
3
|
+
import type { Map as LMap, Marker, Icon, DivIcon, Layer } from 'leaflet';
|
|
4
|
+
import { useMapContext, createClusterContext } from '../../utils/mapContext.js';
|
|
5
|
+
import type { Writable } from 'svelte/store';
|
|
6
|
+
|
|
7
|
+
type LeafletNamespace = typeof import('leaflet');
|
|
8
|
+
|
|
9
|
+
interface MarkerClusterGroup extends Layer {
|
|
10
|
+
getChildCount(): number;
|
|
11
|
+
getAllChildMarkers(): Marker[];
|
|
12
|
+
addLayer(layer: Marker): this;
|
|
13
|
+
removeLayer(layer: Marker): this;
|
|
14
|
+
clearLayers(): this;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ClusterOptions {
|
|
18
|
+
chunkedLoading?: boolean;
|
|
19
|
+
chunkInterval?: number;
|
|
20
|
+
chunkDelay?: number;
|
|
21
|
+
spiderfyOnMaxZoom?: boolean;
|
|
22
|
+
showCoverageOnHover?: boolean;
|
|
23
|
+
zoomToBoundsOnClick?: boolean;
|
|
24
|
+
maxClusterRadius?: number;
|
|
25
|
+
iconCreateFunction?: (cluster: MarkerClusterGroup) => Icon | DivIcon | null;
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ClusterIconCreateFunction {
|
|
30
|
+
(cluster: MarkerClusterGroup, markers: Marker[]): Icon | DivIcon | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let {
|
|
34
|
+
clusterOptions = {},
|
|
35
|
+
iconCreateFunction,
|
|
36
|
+
children
|
|
37
|
+
}: {
|
|
38
|
+
clusterOptions?: ClusterOptions;
|
|
39
|
+
iconCreateFunction?: ClusterIconCreateFunction;
|
|
40
|
+
children?: Snippet;
|
|
41
|
+
} = $props();
|
|
42
|
+
|
|
43
|
+
const mapStore = useMapContext() as Writable<LMap | null>;
|
|
44
|
+
const clusterStore = createClusterContext() as Writable<MarkerClusterGroup | null>;
|
|
45
|
+
let map: LMap | null = null;
|
|
46
|
+
let cluster: MarkerClusterGroup | null = null;
|
|
47
|
+
let L: LeafletNamespace | null = null;
|
|
48
|
+
let unsub: (() => void) | null = null;
|
|
49
|
+
|
|
50
|
+
// Default icon create function if none provided
|
|
51
|
+
function defaultIconCreate(cluster: MarkerClusterGroup): DivIcon {
|
|
52
|
+
if (!L) {
|
|
53
|
+
throw new Error('Leaflet not initialized');
|
|
54
|
+
}
|
|
55
|
+
const count = cluster.getChildCount();
|
|
56
|
+
const size = count < 10 ? 'small' : count < 100 ? 'medium' : 'large';
|
|
57
|
+
return L.divIcon({
|
|
58
|
+
html: `<div class="cluster-icon cluster-${size}"><span>${count}</span></div>`,
|
|
59
|
+
className: 'custom-cluster-icon',
|
|
60
|
+
iconSize: L.point(40, 40)
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function createCustomIcon(cluster: MarkerClusterGroup): Icon | DivIcon | null {
|
|
65
|
+
if (!iconCreateFunction || !L) {
|
|
66
|
+
return defaultIconCreate(cluster);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Get all markers in the cluster
|
|
70
|
+
const markers: Marker[] = [];
|
|
71
|
+
cluster.getAllChildMarkers().forEach((marker: Marker) => {
|
|
72
|
+
markers.push(marker);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const result = iconCreateFunction(cluster, markers);
|
|
77
|
+
return result || defaultIconCreate(cluster);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
console.error('Error creating custom cluster icon:', error);
|
|
80
|
+
return defaultIconCreate(cluster);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function initCluster() {
|
|
85
|
+
if (!map || cluster) return;
|
|
86
|
+
|
|
87
|
+
if (!L) {
|
|
88
|
+
const leafletMod = await import('leaflet');
|
|
89
|
+
L = (leafletMod as any).default ?? leafletMod;
|
|
90
|
+
|
|
91
|
+
await import('leaflet.markercluster');
|
|
92
|
+
await import('leaflet.markercluster/dist/MarkerCluster.css');
|
|
93
|
+
await import('leaflet.markercluster/dist/MarkerCluster.Default.css');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const defaultOptions: ClusterOptions = {
|
|
97
|
+
chunkedLoading: true,
|
|
98
|
+
chunkInterval: 200,
|
|
99
|
+
chunkDelay: 50,
|
|
100
|
+
spiderfyOnMaxZoom: true,
|
|
101
|
+
showCoverageOnHover: false,
|
|
102
|
+
zoomToBoundsOnClick: true,
|
|
103
|
+
maxClusterRadius: 80,
|
|
104
|
+
...clusterOptions
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
if (iconCreateFunction) {
|
|
108
|
+
defaultOptions.iconCreateFunction = (cluster: MarkerClusterGroup) => {
|
|
109
|
+
if (L) {
|
|
110
|
+
(window as typeof window & { L?: LeafletNamespace }).L = L;
|
|
111
|
+
}
|
|
112
|
+
return createCustomIcon(cluster);
|
|
113
|
+
};
|
|
114
|
+
} else if (!clusterOptions.iconCreateFunction) {
|
|
115
|
+
defaultOptions.iconCreateFunction = defaultIconCreate;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const MarkerClusterGroupClass = (
|
|
119
|
+
L as LeafletNamespace & {
|
|
120
|
+
markerClusterGroup: new (options?: ClusterOptions) => MarkerClusterGroup;
|
|
121
|
+
}
|
|
122
|
+
).markerClusterGroup;
|
|
123
|
+
cluster = new MarkerClusterGroupClass(defaultOptions);
|
|
124
|
+
map.addLayer(cluster);
|
|
125
|
+
clusterStore.set(cluster);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function destroyCluster() {
|
|
129
|
+
if (cluster && map) {
|
|
130
|
+
map.removeLayer(cluster);
|
|
131
|
+
cluster.clearLayers();
|
|
132
|
+
cluster = null;
|
|
133
|
+
}
|
|
134
|
+
clusterStore.set(null);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
unsub = mapStore.subscribe((m: LMap | null) => {
|
|
138
|
+
map = m;
|
|
139
|
+
if (!map) {
|
|
140
|
+
destroyCluster();
|
|
141
|
+
} else {
|
|
142
|
+
initCluster();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
onDestroy(() => {
|
|
147
|
+
try {
|
|
148
|
+
unsub?.();
|
|
149
|
+
} finally {
|
|
150
|
+
unsub = null;
|
|
151
|
+
destroyCluster();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
</script>
|
|
155
|
+
|
|
156
|
+
{@render children?.()}
|
|
157
|
+
|
|
158
|
+
<style>
|
|
159
|
+
:global(.custom-cluster-icon) {
|
|
160
|
+
background: transparent !important;
|
|
161
|
+
border: none !important;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
:global(.cluster-icon) {
|
|
165
|
+
display: flex;
|
|
166
|
+
align-items: center;
|
|
167
|
+
justify-content: center;
|
|
168
|
+
border-radius: 50%;
|
|
169
|
+
font-weight: bold;
|
|
170
|
+
color: var(--color-text-inverse);
|
|
171
|
+
text-align: center;
|
|
172
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
:global(.cluster-small) {
|
|
176
|
+
width: 40px;
|
|
177
|
+
height: 40px;
|
|
178
|
+
background-color: color-mix(in srgb, var(--color-bg-act-primary), transparent 30%) !important;
|
|
179
|
+
backdrop-filter: blur(2px);
|
|
180
|
+
font-size: 14px;
|
|
181
|
+
|
|
182
|
+
&:hover {
|
|
183
|
+
background-color: color-mix(in srgb, var(--color-bg-act-primary), transparent 5%) !important;
|
|
184
|
+
backdrop-filter: blur(2px);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
:global(.cluster-medium) {
|
|
189
|
+
width: 50px;
|
|
190
|
+
height: 50px;
|
|
191
|
+
background-color: color-mix(in srgb, var(--color-bg-act-primary), transparent 30%) !important;
|
|
192
|
+
backdrop-filter: blur(2px);
|
|
193
|
+
font-size: 16px;
|
|
194
|
+
|
|
195
|
+
&:hover {
|
|
196
|
+
background-color: color-mix(in srgb, var(--color-bg-act-primary), transparent 5%) !important;
|
|
197
|
+
backdrop-filter: blur(2px);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
:global(.cluster-large) {
|
|
202
|
+
width: 60px;
|
|
203
|
+
height: 60px;
|
|
204
|
+
background-color: color-mix(in srgb, var(--color-bg-act-primary), transparent 30%) !important;
|
|
205
|
+
backdrop-filter: blur(2px);
|
|
206
|
+
font-size: 18px;
|
|
207
|
+
|
|
208
|
+
&:hover {
|
|
209
|
+
background-color: color-mix(in srgb, var(--color-bg-act-primary), transparent 5%) !important;
|
|
210
|
+
backdrop-filter: blur(2px);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
</style>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type Snippet } from 'svelte';
|
|
2
|
+
import type { Marker, Icon, DivIcon, Layer } from 'leaflet';
|
|
3
|
+
interface MarkerClusterGroup extends Layer {
|
|
4
|
+
getChildCount(): number;
|
|
5
|
+
getAllChildMarkers(): Marker[];
|
|
6
|
+
addLayer(layer: Marker): this;
|
|
7
|
+
removeLayer(layer: Marker): this;
|
|
8
|
+
clearLayers(): this;
|
|
9
|
+
}
|
|
10
|
+
interface ClusterOptions {
|
|
11
|
+
chunkedLoading?: boolean;
|
|
12
|
+
chunkInterval?: number;
|
|
13
|
+
chunkDelay?: number;
|
|
14
|
+
spiderfyOnMaxZoom?: boolean;
|
|
15
|
+
showCoverageOnHover?: boolean;
|
|
16
|
+
zoomToBoundsOnClick?: boolean;
|
|
17
|
+
maxClusterRadius?: number;
|
|
18
|
+
iconCreateFunction?: (cluster: MarkerClusterGroup) => Icon | DivIcon | null;
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
interface ClusterIconCreateFunction {
|
|
22
|
+
(cluster: MarkerClusterGroup, markers: Marker[]): Icon | DivIcon | null;
|
|
23
|
+
}
|
|
24
|
+
type $$ComponentProps = {
|
|
25
|
+
clusterOptions?: ClusterOptions;
|
|
26
|
+
iconCreateFunction?: ClusterIconCreateFunction;
|
|
27
|
+
children?: Snippet;
|
|
28
|
+
};
|
|
29
|
+
declare const MapMarkerGroup: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
30
|
+
type MapMarkerGroup = ReturnType<typeof MapMarkerGroup>;
|
|
31
|
+
export default MapMarkerGroup;
|