shadcn-map 0.1.0 → 0.1.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hegyi Áron Ferenc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # shadcn-map
2
+
3
+ A minimal and fast map library for Svelte 5 with [shadcn](https://shadcn-svelte.com/) design.
4
+ Uses [MapLibre GL](https://maplibre.org/) and [Protomaps](https://protomaps.com/) for vector tiles.
5
+
6
+ Recommended to use with [UnoCSS](https://unocss.dev/), and [unocss-preset-shadcn](https://github.com/Rettend/unocss-preset-shadcn/).
7
+
8
+ ## install
9
+
10
+ ```cmd
11
+ bun i shadcn-map
12
+ ```
13
+
14
+ Peer dependencies:
15
+
16
+ ```bash
17
+ bun i svelte@^5.0.0 bits-ui mode-watcher
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ### Basic Map
23
+
24
+ ```svelte
25
+ <script lang="ts">
26
+ import { Map, Marker, NavigationControl } from 'shadcn-map';
27
+ </script>
28
+
29
+ <div class="h-600px w-full rounded-lg border overflow-hidden">
30
+ <!-- Map data for development -->
31
+ <Map
32
+ center={[-74.006, 40.7128]}
33
+ zoom={12}
34
+ tiles="https://r2-public.protomaps.com/protomaps-sample-datasets/protomaps-basemap-opensource-20230408.pmtiles"
35
+ >
36
+ <NavigationControl position="top-right" />
37
+
38
+ <Marker
39
+ lngLat={[-74.006, 40.7128]}
40
+ label="New York"
41
+ color="primary"
42
+ />
43
+ </Map>
44
+ </div>
45
+ ```
46
+
47
+ ## Components
48
+
49
+ - **Map**:
50
+ - **`<Map>`**: The core container. Renders MapLibre GL.
51
+ - **`<Marker>`**: Styled pins with shadcn theme colors, icons and badges.
52
+ - **`<ClusterLayer>`**: Auto-clustering for markers.
53
+ - **UI**:
54
+ - **`<Popup>`**: In-place info popup.
55
+ - **`<DetailsPanel>`**: Full-height sidebar or bottom drawer.
56
+ - **Controls**:
57
+ - **`<NavigationControl>`**: Zoom and compass controls.
58
+ - **`<ScaleControl>`**: Distance scale bar.
59
+ - **`<GeolocateControl>`**: Show user location.
60
+
61
+ ## Styles
62
+
63
+ The map comes with two minimal styles:
64
+
65
+ - **Dark**: Dark zinc colors, subtle roads, dark blue water.
66
+ - **Light**: Clean white/zinc colors, minimal distractions.
67
+
68
+ Labels:
69
+
70
+ - **Minimal**: City names, roads, water.
71
+ - **Roads**: Show road names too.
72
+
73
+ ## License
74
+
75
+ MIT
@@ -66,15 +66,8 @@
66
66
  let supercluster: Supercluster<{ id: string | number }, Supercluster.AnyProps> | null = null
67
67
  let lastSuperclusterPointsKey = ''
68
68
 
69
- function getIsDarkMode(): boolean {
70
- if (typeof document === 'undefined') {
71
- return true
72
- }
73
- return document.documentElement.classList.contains('dark')
74
- }
75
-
76
69
  function getThemeColors() {
77
- const isDark = getIsDarkMode()
70
+ const isDark = ctx.resolvedMode === 'dark'
78
71
  return isDark
79
72
  ? {
80
73
  clusterLow: '#3f3f46',
@@ -508,4 +501,40 @@
508
501
  updateSourceData()
509
502
  scheduleUnclusteredUpdate()
510
503
  })
504
+
505
+ // Update cluster colors when resolvedMode changes
506
+ $effect(() => {
507
+ // Access resolvedMode to track as dependency
508
+ void ctx.resolvedMode
509
+ if (!map || !map.isStyleLoaded()) {
510
+ return
511
+ }
512
+
513
+ const colors = getThemeColors()
514
+
515
+ // Update cluster layer colors
516
+ if (map.getLayer(clusterLayerId)) {
517
+ map.setPaintProperty(clusterLayerId, 'circle-color', [
518
+ 'step',
519
+ ['get', 'point_count'],
520
+ colors.clusterLow,
521
+ 10,
522
+ colors.clusterMid,
523
+ 50,
524
+ colors.clusterHigh,
525
+ ])
526
+ map.setPaintProperty(clusterLayerId, 'circle-stroke-color', colors.clusterStroke)
527
+ }
528
+
529
+ // Update cluster count text color
530
+ if (map.getLayer(clusterCountId)) {
531
+ map.setPaintProperty(clusterCountId, 'text-color', colors.clusterText)
532
+ }
533
+
534
+ // Update unclustered point colors
535
+ if (showUnclustered && map.getLayer(unclusteredLayerId)) {
536
+ map.setPaintProperty(unclusteredLayerId, 'circle-color', colors.point)
537
+ map.setPaintProperty(unclusteredLayerId, 'circle-stroke-color', colors.pointStroke)
538
+ }
539
+ })
511
540
  </script>
@@ -96,17 +96,34 @@
96
96
  return document.documentElement.classList.contains('dark')
97
97
  }
98
98
 
99
+ // Track document dark mode for auto style
100
+ let documentIsDark = $state(getIsDarkMode())
101
+
102
+ // Resolved mode: for explicit 'dark'/'light' use that, for 'auto' follow document, for custom objects default to 'dark'
103
+ const resolvedMode = $derived.by((): 'dark' | 'light' => {
104
+ if (typeof style === 'object') {
105
+ return 'dark' // Custom style objects default to dark for UI
106
+ }
107
+ if (style === 'auto') {
108
+ return documentIsDark ? 'dark' : 'light'
109
+ }
110
+ return style // 'dark' or 'light'
111
+ })
112
+
99
113
  function getStyle() {
100
114
  if (typeof style === 'object') {
101
115
  return style
102
116
  }
103
117
 
104
- const mode: StyleMode = style === 'auto' ? (getIsDarkMode() ? 'dark' : 'light') : style
105
-
106
- return mode === 'dark' ? createDarkStyle(tiles, { labels }) : createLightStyle(tiles, { labels })
118
+ return resolvedMode === 'dark' ? createDarkStyle(tiles, { labels }) : createLightStyle(tiles, { labels })
107
119
  }
108
120
 
109
121
  const ctx = createMapContext()
122
+
123
+ // Keep context in sync with resolved mode
124
+ $effect(() => {
125
+ ctx.setResolvedMode(resolvedMode)
126
+ })
110
127
  const autoClusterPoints = $derived(
111
128
  autoCluster
112
129
  ? Array.from(ctx.markers.values())
@@ -183,17 +200,22 @@
183
200
  onzoomCallback?.(mapInstance.getZoom())
184
201
  })
185
202
 
186
- const observer = new MutationObserver(() => {
187
- mapInstance.setStyle(getStyle())
188
- })
203
+ // Only observe document dark class changes when style='auto'
204
+ let observer: MutationObserver | null = null
205
+ if (style === 'auto') {
206
+ observer = new MutationObserver(() => {
207
+ documentIsDark = getIsDarkMode()
208
+ mapInstance.setStyle(getStyle())
209
+ })
189
210
 
190
- observer.observe(document.documentElement, {
191
- attributes: true,
192
- attributeFilter: ['class'],
193
- })
211
+ observer.observe(document.documentElement, {
212
+ attributes: true,
213
+ attributeFilter: ['class'],
214
+ })
215
+ }
194
216
 
195
217
  return () => {
196
- observer.disconnect()
218
+ observer?.disconnect()
197
219
  maplibregl.removeProtocol('pmtiles')
198
220
  ctx.setMap(null)
199
221
  ctx.setLoaded(false)
@@ -202,7 +224,7 @@
202
224
  })
203
225
  </script>
204
226
 
205
- <div bind:this={container} class='shadcn-map {className}'>
227
+ <div bind:this={container} class='shadcn-map {className}' data-map-mode={resolvedMode}>
206
228
  {#if loaded && children}
207
229
  {#if autoCluster}
208
230
  <ClusterLayer
@@ -267,55 +289,94 @@
267
289
  box-shadow: none;
268
290
  }
269
291
 
270
- /* Dark mode theme variables */
271
- :global(.dark) .shadcn-map {
272
- --map-ui-surface: oklch(var(--muted));
273
- --map-ui-foreground: oklch(var(--foreground));
274
- --map-ui-border: oklch(var(--border));
292
+ /* Dark mode theme variables - hardcoded dark colors that don't depend on mode switcher */
293
+ .shadcn-map[data-map-mode='dark'] {
294
+ --map-ui-surface: #27272a;
295
+ --map-ui-foreground: #f4f4f5;
296
+ --map-ui-border: #3f3f46;
275
297
  }
276
298
 
277
- /* Navigation control group */
278
- :global(.dark) .shadcn-map :global(.maplibregl-ctrl-group) {
299
+ /* Light mode theme variables - hardcoded light colors */
300
+ .shadcn-map[data-map-mode='light'] {
301
+ --map-ui-surface: #ffffff;
302
+ --map-ui-foreground: #18181b;
303
+ --map-ui-border: #e4e4e7;
304
+ }
305
+
306
+ /* Navigation control group - dark mode */
307
+ .shadcn-map[data-map-mode='dark'] :global(.maplibregl-ctrl-group) {
279
308
  background-color: var(--map-ui-surface);
280
309
  border: 1px solid var(--map-ui-border);
281
310
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.45);
282
311
  }
283
312
 
284
- :global(.dark) .shadcn-map :global(.maplibregl-ctrl-group button) {
313
+ /* Navigation control group - light mode */
314
+ .shadcn-map[data-map-mode='light'] :global(.maplibregl-ctrl-group) {
315
+ background-color: var(--map-ui-surface);
316
+ border: 1px solid var(--map-ui-border);
317
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
318
+ }
319
+
320
+ .shadcn-map[data-map-mode='dark'] :global(.maplibregl-ctrl-group button) {
321
+ background-color: var(--map-ui-surface);
322
+ color: var(--map-ui-foreground);
323
+ }
324
+
325
+ .shadcn-map[data-map-mode='light'] :global(.maplibregl-ctrl-group button) {
285
326
  background-color: var(--map-ui-surface);
286
327
  color: var(--map-ui-foreground);
287
328
  }
288
329
 
289
- :global(.dark) .shadcn-map :global(.maplibregl-ctrl-group button + button) {
330
+ .shadcn-map[data-map-mode='dark'] :global(.maplibregl-ctrl-group button + button) {
290
331
  border-top-color: var(--map-ui-border);
291
332
  }
292
333
 
293
- :global(.dark) .shadcn-map :global(.maplibregl-ctrl-icon) {
334
+ .shadcn-map[data-map-mode='light'] :global(.maplibregl-ctrl-group button + button) {
335
+ border-top-color: var(--map-ui-border);
336
+ }
337
+
338
+ .shadcn-map[data-map-mode='dark'] :global(.maplibregl-ctrl-icon) {
294
339
  filter: invert(1);
295
340
  }
296
341
 
297
- :global(.dark) .shadcn-map :global(.maplibregl-ctrl-compass .maplibregl-ctrl-icon) {
342
+ .shadcn-map[data-map-mode='dark'] :global(.maplibregl-ctrl-compass .maplibregl-ctrl-icon) {
298
343
  filter: invert(1) brightness(1.8) contrast(1.3) drop-shadow(0 0 1px rgba(0, 0, 0, 0.6));
299
344
  }
300
345
 
301
- /* Scale control dark mode */
302
- :global(.dark) .shadcn-map :global(.maplibregl-ctrl-scale) {
346
+ /* Scale control - dark mode */
347
+ .shadcn-map[data-map-mode='dark'] :global(.maplibregl-ctrl-scale) {
303
348
  color: var(--map-ui-foreground);
304
349
  border-color: var(--map-ui-foreground);
305
350
  }
306
351
 
307
- /* Attribution text panel */
308
- :global(.dark) .shadcn-map :global(.maplibregl-ctrl-attrib) {
352
+ /* Scale control - light mode */
353
+ .shadcn-map[data-map-mode='light'] :global(.maplibregl-ctrl-scale) {
309
354
  color: var(--map-ui-foreground);
355
+ border-color: var(--map-ui-foreground);
356
+ }
357
+
358
+ /* Attribution text panel - dark mode */
359
+ .shadcn-map[data-map-mode='dark'] :global(.maplibregl-ctrl-attrib) {
360
+ color: var(--map-ui-foreground);
361
+ }
362
+
363
+ /* Attribution text panel - light mode */
364
+ .shadcn-map[data-map-mode='light'] :global(.maplibregl-ctrl-attrib) {
365
+ color: var(--map-ui-foreground);
366
+ }
367
+
368
+ .shadcn-map[data-map-mode='dark'] :global(.maplibregl-ctrl-attrib.maplibregl-compact-show) {
369
+ background-color: var(--map-ui-surface);
370
+ border-radius: 4px;
310
371
  }
311
372
 
312
- :global(.dark) .shadcn-map :global(.maplibregl-ctrl-attrib.maplibregl-compact-show) {
373
+ .shadcn-map[data-map-mode='light'] :global(.maplibregl-ctrl-attrib.maplibregl-compact-show) {
313
374
  background-color: var(--map-ui-surface);
314
375
  border-radius: 4px;
315
376
  }
316
377
 
317
378
  /* Attribution button - invert icon in dark mode */
318
- :global(.dark) .shadcn-map :global(.maplibregl-ctrl-attrib-button) {
379
+ .shadcn-map[data-map-mode='dark'] :global(.maplibregl-ctrl-attrib-button) {
319
380
  filter: invert(1);
320
381
  }
321
382
  </style>
@@ -302,6 +302,7 @@
302
302
  class:popup-open={anyPopupOpen}
303
303
  class:marker-active={isActive}
304
304
  data-color-mode={isThemeColor ? 'theme' : 'class'}
305
+ data-map-mode={ctx.resolvedMode}
305
306
  style:--marker-color={isThemeColor ? markerColorVar : 'currentColor'}
306
307
  style:--marker-text={isThemeColor ? markerTextVar : undefined}
307
308
  style:--marker-width='{sizeConfig.width}px'
@@ -395,14 +396,21 @@
395
396
  .shadcn-marker[data-color-mode='theme'] .marker-inner {
396
397
  background: oklch(var(--marker-color));
397
398
  color: oklch(var(--marker-text));
398
- border-color: oklch(var(--border));
399
+ }
400
+
401
+ .shadcn-marker[data-color-mode='theme'][data-map-mode='dark'] .marker-inner {
402
+ border-color: #3f3f46;
403
+ }
404
+
405
+ .shadcn-marker[data-color-mode='theme'][data-map-mode='light'] .marker-inner {
406
+ border-color: #e4e4e7;
399
407
  }
400
408
 
401
409
  .shadcn-marker[data-color-mode='class'] .marker-inner {
402
410
  border-color: rgba(0, 0, 0, 0.25);
403
411
  }
404
412
 
405
- :global(.dark) .shadcn-marker[data-color-mode='class'] .marker-inner {
413
+ .shadcn-marker[data-color-mode='class'][data-map-mode='dark'] .marker-inner {
406
414
  border-color: rgba(255, 255, 255, 0.25);
407
415
  }
408
416
 
@@ -528,7 +536,7 @@
528
536
  justify-content: center;
529
537
  }
530
538
 
531
- :global(.dark) .marker-badge {
539
+ .shadcn-marker[data-map-mode='dark'] .marker-badge {
532
540
  border-color: rgba(0, 0, 0, 0.3);
533
541
  }
534
542
 
@@ -581,13 +589,13 @@
581
589
  0 6px 20px rgba(0, 0, 0, 0.35);
582
590
  }
583
591
 
584
- :global(.dark) .fallback-ring {
592
+ .shadcn-marker[data-map-mode='dark'] .fallback-ring {
585
593
  box-shadow:
586
594
  0 0 0 4px rgba(50, 50, 50, 0.9),
587
595
  0 6px 20px rgba(0, 0, 0, 0.35);
588
596
  }
589
597
 
590
- :global(.dark) .has-label::after {
598
+ .shadcn-marker[data-map-mode='dark'].has-label::after {
591
599
  background: hsl(240 5.9% 90%);
592
600
  color: hsl(240 10% 3.9%);
593
601
  }
@@ -134,7 +134,7 @@
134
134
 
135
135
  /* Dark mode: override the filter: invert(1) from Map.svelte since we use inline SVG with currentColor.
136
136
  * Also explicitly set color so the SVG stroke inherits the light foreground. */
137
- :global(.dark .shadcn-map .shadcn-geolocate-btn.maplibregl-ctrl-icon) {
137
+ :global(.shadcn-map[data-map-mode='dark'] .shadcn-geolocate-btn.maplibregl-ctrl-icon) {
138
138
  filter: none !important;
139
139
  color: var(--map-ui-foreground);
140
140
  }
@@ -21,6 +21,8 @@ export interface MapContextStore {
21
21
  readonly clusteredMarkerIds: Set<string>;
22
22
  readonly clusteredVersion: number;
23
23
  readonly activePopupMarkerId: string | null;
24
+ /** The resolved theme mode for the map ('dark' or 'light') */
25
+ readonly resolvedMode: 'dark' | 'light';
24
26
  setMap: (map: maplibregl.Map | null) => void;
25
27
  setLoaded: (loaded: boolean) => void;
26
28
  registerMarker: (registration: MarkerRegistration) => void;
@@ -28,6 +30,7 @@ export interface MapContextStore {
28
30
  unregisterMarker: (id: string) => void;
29
31
  setClusteredMarkers: (ids: Set<string>) => void;
30
32
  setActivePopupMarker: (id: string | null) => void;
33
+ setResolvedMode: (mode: 'dark' | 'light') => void;
31
34
  flyTo: (lngLat: [number, number], options?: FlyToOptions) => void;
32
35
  getBounds: () => LngLatBounds | null;
33
36
  getCenter: () => [number, number] | null;
@@ -1,4 +1,4 @@
1
- import { getContext, setContext } from 'svelte';
1
+ import { getContext, setContext, untrack } from 'svelte';
2
2
  const MAP_CONTEXT_KEY = Symbol('shadcn-map-context');
3
3
  export function createMapContext() {
4
4
  let map = $state(null);
@@ -7,6 +7,7 @@ export function createMapContext() {
7
7
  let clusteredMarkerIds = $state(new Set());
8
8
  let clusteredVersion = $state(0);
9
9
  let activePopupMarkerId = $state(null);
10
+ let resolvedMode = $state('dark');
10
11
  const store = {
11
12
  get map() { return map; },
12
13
  get loaded() { return loaded; },
@@ -14,8 +15,10 @@ export function createMapContext() {
14
15
  get clusteredMarkerIds() { return clusteredMarkerIds; },
15
16
  get clusteredVersion() { return clusteredVersion; },
16
17
  get activePopupMarkerId() { return activePopupMarkerId; },
18
+ get resolvedMode() { return resolvedMode; },
17
19
  setMap: (m) => { map = m; },
18
20
  setLoaded: (l) => { loaded = l; },
21
+ setResolvedMode: (m) => { resolvedMode = m; },
19
22
  registerMarker: (registration) => {
20
23
  const next = new Map(markers);
21
24
  next.set(registration.id, registration);
@@ -44,7 +47,10 @@ export function createMapContext() {
44
47
  },
45
48
  setClusteredMarkers: (ids) => {
46
49
  clusteredMarkerIds = new Set(ids);
47
- clusteredVersion++;
50
+ // IMPORTANT: avoid tracking `clusteredVersion` reads in callers (e.g. $effect)
51
+ // otherwise an effect that calls `setClusteredMarkers` can accidentally
52
+ // become dependent on `clusteredVersion` and re-run forever.
53
+ clusteredVersion = untrack(() => clusteredVersion) + 1;
48
54
  },
49
55
  setActivePopupMarker: (id) => {
50
56
  activePopupMarkerId = id;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "shadcn-map",
3
3
  "type": "module",
4
- "version": "0.1.0",
4
+ "version": "0.1.2",
5
5
  "description": "A minimal, fast, dark map library for Svelte 5 built on MapLibre GL and shadcn-svelte aesthetics.",
6
6
  "author": "Hegyi Áron Ferenc",
7
7
  "license": "MIT",
@@ -37,6 +37,8 @@
37
37
  },
38
38
  "types": "./dist/index.d.ts",
39
39
  "files": [
40
+ "LICENSE",
41
+ "README.md",
40
42
  "dist"
41
43
  ],
42
44
  "scripts": {