Rocket OpenFreeMap Pro
Rocket is currently in alpha – available in the Datastar Pro repo.
A Rocket component port of the OpenFreeMap Quick Start using MapLibre with the same default style URL and a Las Vegas Strip starting viewport.
Explanation #
This demo ports OpenFreeMap’s Quick Start map setup into a reusable Rocket component. The map style and camera settings stay reactive via Datastar signals, so style switching is just signal updates.
Usage Example #
1<rocket-openfreemap
2 data-attr:style-url="$styleUrl"
3data-attr:center="$view.center"
4data-attr:zoom="$view.zoom"
5data-attr:bearing="$view.bearing"
6data-attr:pitch="$view.pitch"
7data-attr:drag-rotate="$style == 'liberty-3d' || $style == 'dark-3d'"
8data-attr:cluster="true"
9 data-attr:cluster-max-zoom="14"
10></rocket-openfreemap>Rocket Component #
1<template data-rocket:rocket-openfreemap
2 data-schema:lng-lat="arr (num (clamp -180 180)) (num (clamp -90 90))"
3 data-prop:style-url="str (= 'https://tiles.openfreemap.org/styles/liberty')"
4 data-prop:center="lngLat (= [-115.172816,36.114647])"
5 data-prop:zoom="num (clamp 0 22) (= 9.5)"
6 data-prop:bearing="num"
7 data-prop:pitch="num (clamp 0 85)"
8 data-prop:drag-rotate="bool"
9 data-prop:cluster="bool"
10 data-prop:cluster-max-zoom="num (clamp 0 22) (= 12)"
11 data-prop:cluster-radius="num (clamp 10 100) (= 50)"
12 data-prop:markers="arr ((arr lngLat (str) (str (trim) (= 'mdi:map-marker'))))"
13 data-import:maplibregl="https://cdn.jsdelivr.net/npm/[email protected]/+esm"
14>
15 <script data-static>
16 const mapLibreCSSID = 'maplibre-gl-css'
17 if (!document.getElementById(mapLibreCSSID)) {
18 const link = document.createElement('link')
19 link.id = mapLibreCSSID
20 link.rel = 'stylesheet'
21 link.href = 'https://unpkg.com/maplibre-gl/dist/maplibre-gl.css'
22 document.head.appendChild(link)
23 }
24
25 const colorParserContext = document.createElement('canvas').getContext('2d')
26
27 function normalizeColorToken(token) {
28 if (typeof token !== 'string') return null
29 const value = token.trim()
30 if (!value) return null
31 if (!colorParserContext) return null
32 const sentinel = 'rgba(1, 2, 3, 0.4)'
33 colorParserContext.fillStyle = sentinel
34 colorParserContext.fillStyle = value
35 const normalized = colorParserContext.fillStyle
36 if (normalized === sentinel && value.toLowerCase() !== sentinel) return null
37 return normalized
38 }
39
40 function readColorFromStyleValue(value) {
41 if (typeof value === 'string') {
42 return normalizeColorToken(value)
43 }
44 if (Array.isArray(value)) {
45 for (const entry of value) {
46 const color = readColorFromStyleValue(entry)
47 if (color) return color
48 }
49 return null
50 }
51 if (value && typeof value === 'object') {
52 for (const nestedValue of Object.values(value)) {
53 const color = readColorFromStyleValue(nestedValue)
54 if (color) return color
55 }
56 }
57 return null
58 }
59
60 function isHexChar(ch) {
61 const code = ch.toLowerCase().charCodeAt(0)
62 return (code >= 48 && code <= 57) || (code >= 97 && code <= 102)
63 }
64
65 function parseColorToRGB(color) {
66 if (typeof color !== 'string') return null
67 const trimmed = color.trim()
68
69 if (trimmed.startsWith('#')) {
70 const value = trimmed.slice(1)
71 const isThree = value.length === 3
72 const isSix = value.length === 6
73 if (!isThree && !isSix) return null
74 for (const ch of value) {
75 if (!isHexChar(ch)) return null
76 }
77 if (value.length === 3) {
78 return [
79 Number.parseInt(value[0] + value[0], 16),
80 Number.parseInt(value[1] + value[1], 16),
81 Number.parseInt(value[2] + value[2], 16),
82 ]
83 }
84 return [
85 Number.parseInt(value.slice(0, 2), 16),
86 Number.parseInt(value.slice(2, 4), 16),
87 Number.parseInt(value.slice(4, 6), 16),
88 ]
89 }
90
91 const lower = trimmed.toLowerCase()
92 const isRGB = lower.startsWith('rgb(')
93 const isRGBA = lower.startsWith('rgba(')
94 if (!isRGB && !isRGBA) return null
95 if (!trimmed.endsWith(')')) return null
96
97 const inside = trimmed.slice(isRGBA ? 5 : 4, -1)
98 const channels = inside
99 .split(',')
100 .slice(0, 3)
101 .map((channel) => Number.parseFloat(channel.trim()))
102 if (channels.length !== 3 || channels.some((v) => !Number.isFinite(v))) return null
103 return channels.map((v) => Math.max(0, Math.min(255, Math.round(v))))
104 }
105
106 function rgbToHex(rgb) {
107 return (
108 '#' +
109 rgb
110 .map((value) =>
111 Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, '0'),
112 )
113 .join('')
114 )
115 }
116
117 function rgbToRGBA(rgb, alpha) {
118 return (
119 'rgba(' +
120 Math.max(0, Math.min(255, Math.round(rgb[0]))) +
121 ', ' +
122 Math.max(0, Math.min(255, Math.round(rgb[1]))) +
123 ', ' +
124 Math.max(0, Math.min(255, Math.round(rgb[2]))) +
125 ', ' +
126 Math.max(0, Math.min(1, Number(alpha))) +
127 ')'
128 )
129 }
130
131 function shiftRGB(rgb, delta) {
132 return rgb.map((channel) => Math.max(0, Math.min(255, Math.round(channel + delta))))
133 }
134
135 function mixRGB(from, to, ratio) {
136 return from.map((channel, idx) => channel + (to[idx] - channel) * ratio)
137 }
138
139 function relativeLuminance(rgb) {
140 const linear = rgb.map((value) => {
141 const channel = value / 255
142 return channel <= 0.04045
143 ? channel / 12.92
144 : ((channel + 0.055) / 1.055) ** 2.4
145 })
146 return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722
147 }
148
149 function contrastRatio(a, b) {
150 const luminanceA = relativeLuminance(a)
151 const luminanceB = relativeLuminance(b)
152 const lighter = Math.max(luminanceA, luminanceB)
153 const darker = Math.min(luminanceA, luminanceB)
154 return (lighter + 0.05) / (darker + 0.05)
155 }
156
157 function pickReadableTextColor(backgroundRGB) {
158 const darkTextRGB = [15, 23, 42]
159 const lightTextRGB = [248, 250, 252]
160 const darkContrast = contrastRatio(backgroundRGB, darkTextRGB)
161 const lightContrast = contrastRatio(backgroundRGB, lightTextRGB)
162 if (lightContrast >= darkContrast) {
163 return { rgb: lightTextRGB, hex: '#f8fafc' }
164 }
165 return { rgb: darkTextRGB, hex: '#0f172a' }
166 }
167
168 function findStylePaintColor(layers, matchesLayer, paintKeys) {
169 for (const layer of layers) {
170 if (!matchesLayer(layer)) continue
171 const paint = layer.paint || {}
172 for (const key of paintKeys) {
173 const color = readColorFromStyleValue(paint[key])
174 if (color) return color
175 }
176 }
177 return null
178 }
179
180 function readTextFontStack(layers) {
181 for (const layer of layers) {
182 if (layer.type !== 'symbol') continue
183 const textFont = layer.layout?.['text-font']
184 if (Array.isArray(textFont) && textFont.length > 0) {
185 return textFont
186 }
187 }
188 return null
189 }
190
191 function buildClusterPalette(layers, styleURL) {
192 const backgroundColor = findStylePaintColor(
193 layers,
194 (layer) => layer.type === 'background',
195 ['background-color'],
196 )
197 const waterColor = findStylePaintColor(
198 layers,
199 (layer) =>
200 /water/i.test(layer.id || '') || layer['source-layer'] === 'water',
201 ['fill-color', 'line-color'],
202 )
203 const roadColor = findStylePaintColor(
204 layers,
205 (layer) =>
206 /road|transport/i.test(layer.id || '') ||
207 layer['source-layer'] === 'transportation',
208 ['line-color', 'fill-color'],
209 )
210 const labelColor = findStylePaintColor(
211 layers,
212 (layer) => layer.type === 'symbol',
213 ['text-color', 'icon-color'],
214 )
215
216 const styleLooksDark = /\/dark(?:$|[/?#])/i.test(styleURL)
217 const fallbackBase = styleLooksDark ? '#5b7ca6' : '#2563eb'
218 const fallbackText = styleLooksDark ? '#f8fafc' : '#111827'
219 const fallbackBackground = styleLooksDark ? '#0f172a' : '#f8fafc'
220
221 const baseRGB =
222 parseColorToRGB(waterColor || roadColor || labelColor || fallbackBase) ||
223 parseColorToRGB(fallbackBase)
224 const labelRGB =
225 parseColorToRGB(labelColor || fallbackText) || parseColorToRGB(fallbackText)
226 const backgroundRGB =
227 parseColorToRGB(backgroundColor || fallbackBackground) ||
228 parseColorToRGB(fallbackBackground)
229
230 if (!baseRGB || !labelRGB || !backgroundRGB) {
231 return {
232 clusterSmall: '#3b82f6',
233 clusterMedium: '#2563eb',
234 clusterLarge: '#1d4ed8',
235 unclustered: '#ad1529',
236 stroke: '#ffffff',
237 text: '#ffffff',
238 opacity: 0.88,
239 textFont: readTextFontStack(layers),
240 markerIconBg: '#ad1529',
241 markerIconFg: '#ffffff',
242 markerLabelBg: 'rgba(20, 21, 23, 0.84)',
243 markerLabelFg: '#ffffff',
244 markerBorder: 'rgba(255, 255, 255, 0.28)',
245 }
246 }
247
248 const isDarkStyle =
249 relativeLuminance(backgroundRGB) < 0.45 || styleLooksDark
250 const clusterSmall = rgbToHex(
251 isDarkStyle ? shiftRGB(baseRGB, 18) : shiftRGB(baseRGB, -8),
252 )
253 const clusterMedium = rgbToHex(
254 isDarkStyle ? shiftRGB(baseRGB, 34) : shiftRGB(baseRGB, -22),
255 )
256 const clusterLarge = rgbToHex(
257 isDarkStyle ? shiftRGB(baseRGB, 52) : shiftRGB(baseRGB, -36),
258 )
259 const unclustered = rgbToHex(
260 isDarkStyle ? mixRGB(baseRGB, labelRGB, 0.33) : mixRGB(baseRGB, backgroundRGB, 0.14),
261 )
262 const iconText = pickReadableTextColor(parseColorToRGB(clusterMedium) || baseRGB)
263 const markerLabelBgRGB = mixRGB(
264 backgroundRGB,
265 isDarkStyle ? baseRGB : labelRGB,
266 isDarkStyle ? 0.42 : 0.28,
267 )
268 const markerLabelText = pickReadableTextColor(markerLabelBgRGB)
269 const stroke = rgbToHex(
270 mixRGB(iconText.rgb, backgroundRGB, 0.22),
271 )
272
273 return {
274 clusterSmall,
275 clusterMedium,
276 clusterLarge,
277 unclustered,
278 stroke,
279 text: iconText.hex,
280 opacity: isDarkStyle ? 0.9 : 0.84,
281 textFont: readTextFontStack(layers),
282 markerIconBg: clusterMedium,
283 markerIconFg: iconText.hex,
284 markerLabelBg: rgbToRGBA(markerLabelBgRGB, isDarkStyle ? 0.96 : 0.93),
285 markerLabelFg: markerLabelText.hex,
286 markerBorder: rgbToRGBA(
287 mixRGB(markerLabelText.rgb, backgroundRGB, 0.34),
288 isDarkStyle ? 0.72 : 0.54,
289 ),
290 }
291 }
292
293 function applyMarkerThemeVars(style, palette) {
294 if (!palette || !style) return
295 style.setProperty('--rocket-openfreemap-marker-icon-bg', palette.markerIconBg)
296 style.setProperty('--rocket-openfreemap-marker-icon-fg', palette.markerIconFg)
297 style.setProperty('--rocket-openfreemap-marker-label-bg', palette.markerLabelBg)
298 style.setProperty('--rocket-openfreemap-marker-label-fg', palette.markerLabelFg)
299 style.setProperty('--rocket-openfreemap-marker-border', palette.markerBorder)
300 }
301
302 function markerEntriesToGeoJSON(entries) {
303 return {
304 type: 'FeatureCollection',
305 features: entries.map((entry, idx) => ({
306 type: 'Feature',
307 properties: {
308 id: idx,
309 label: entry.label,
310 icon: entry.icon,
311 },
312 geometry: {
313 type: 'Point',
314 coordinates: [entry.coords[0], entry.coords[1]],
315 },
316 })),
317 }
318 }
319
320 </script>
321
322 <script>
323 const readView = () => ({
324 center: [$$center[0], $$center[1]],
325 zoom: $$zoom,
326 bearing: $$bearing,
327 pitch: $$pitch,
328 })
329
330 const initialStyleURL = $$styleUrl
331 const initialView = readView()
332
333 const map = new maplibregl.Map({
334 style: initialStyleURL,
335 center: initialView.center,
336 zoom: initialView.zoom,
337 bearing: initialView.bearing,
338 pitch: initialView.pitch,
339 container: $$mapNode,
340 cooperativeGestures: true,
341 dragRotate: $$dragRotate,
342 })
343
344 const clusterSourceID = 'rocket-openfreemap-markers-cluster'
345 const clusterLayerID = 'rocket-openfreemap-markers-clusters'
346 const clusterCountLayerID = 'rocket-openfreemap-markers-cluster-count'
347 const unclusteredLayerID = 'rocket-openfreemap-markers-unclustered'
348
349 function readClusterPalette() {
350 const style = map.getStyle()
351 return buildClusterPalette(style?.layers || [], $$styleUrl)
352 }
353
354 const ensure3DBuildings = () => {
355 if (!$$dragRotate) return
356 const style = map.getStyle()
357 const layers = style?.layers || []
358 if (!layers.length) return
359
360 const alreadyHasExtrusion = layers.some((layer) => layer.type === 'fill-extrusion')
361 if (alreadyHasExtrusion) return
362
363 const buildingLayer = layers.find(
364 (layer) => layer.type === 'fill' && layer.source && layer['source-layer'] === 'building',
365 )
366 if (!buildingLayer) return
367
368 const layerID = 'rocket-openfreemap-3d-buildings'
369 if (map.getLayer(layerID)) return
370
371 const beforeLayerID = layers.find((layer) => layer.type === 'symbol')?.id
372 const styleURL = $$styleUrl
373 const isDarkStyle = /\/dark(?:$|[/?#])/i.test(styleURL)
374 const buildingColor = buildingLayer.paint?.['fill-color'] ?? '#888888'
375 const buildingOpacity = buildingLayer.paint?.['fill-opacity']
376 const fallbackColor = isDarkStyle
377 ? [
378 'interpolate',
379 ['linear'],
380 ['zoom'],
381 14,
382 '#5e646b',
383 18,
384 '#808891',
385 ]
386 : '#b9b5ad'
387
388 map.addLayer(
389 {
390 id: layerID,
391 type: 'fill-extrusion',
392 source: buildingLayer.source,
393 'source-layer': 'building',
394 filter: buildingLayer.filter,
395 minzoom: 14,
396 paint: {
397 'fill-extrusion-color':
398 typeof buildingColor === 'string' && !isDarkStyle
399 ? buildingColor
400 : fallbackColor,
401 'fill-extrusion-base': [
402 'coalesce',
403 ['to-number', ['coalesce', ['get', 'render_min_height'], 0], 0],
404 ['to-number', ['coalesce', ['get', 'min_height'], 0], 0],
405 0,
406 ],
407 'fill-extrusion-height': [
408 'max',
409 10,
410 [
411 'coalesce',
412 ['to-number', ['coalesce', ['get', 'render_height'], 0], 0],
413 ['to-number', ['coalesce', ['get', 'height'], 0], 0],
414 20,
415 ],
416 ],
417 'fill-extrusion-opacity': isDarkStyle
418 ? 0.92
419 : Number.isFinite(buildingOpacity)
420 ? buildingOpacity
421 : 0.8,
422 'fill-extrusion-vertical-gradient': true,
423 },
424 },
425 beforeLayerID,
426 )
427 }
428
429 const removeClusterLayersAndSource = () => {
430 if (map.getLayer(clusterCountLayerID)) {
431 map.removeLayer(clusterCountLayerID)
432 }
433 if (map.getLayer(clusterLayerID)) {
434 map.removeLayer(clusterLayerID)
435 }
436 if (map.getLayer(unclusteredLayerID)) {
437 map.removeLayer(unclusteredLayerID)
438 }
439 if (map.getSource(clusterSourceID)) {
440 map.removeSource(clusterSourceID)
441 }
442 }
443
444 const readClusterConfig = () => ({
445 enabled: $$cluster === true,
446 maxZoom: Math.round($$clusterMaxZoom),
447 radius: Math.round($$clusterRadius),
448 })
449
450 const ensureClusterLayersAndData = (entries, config, palette) => {
451 if (!map.isStyleLoaded()) return
452
453 let clusterSource = map.getSource(clusterSourceID)
454 if (!clusterSource) {
455 map.addSource(clusterSourceID, {
456 type: 'geojson',
457 data: markerEntriesToGeoJSON(entries),
458 cluster: true,
459 clusterMaxZoom: config.maxZoom,
460 clusterRadius: config.radius,
461 })
462 clusterSource = map.getSource(clusterSourceID)
463 } else {
464 clusterSource.setData(markerEntriesToGeoJSON(entries))
465 }
466
467 if (!map.getLayer(clusterLayerID)) {
468 map.addLayer({
469 id: clusterLayerID,
470 type: 'circle',
471 source: clusterSourceID,
472 filter: ['has', 'point_count'],
473 paint: {
474 'circle-color': [
475 'step',
476 ['get', 'point_count'],
477 palette.clusterSmall,
478 6,
479 palette.clusterMedium,
480 12,
481 palette.clusterLarge,
482 ],
483 'circle-radius': [
484 'step',
485 ['get', 'point_count'],
486 32,
487 6,
488 40,
489 12,
490 48,
491 ],
492 'circle-stroke-color': palette.stroke,
493 'circle-stroke-width': 3,
494 'circle-opacity': palette.opacity,
495 },
496 })
497 }
498
499 if (!map.getLayer(clusterCountLayerID)) {
500 const clusterTextLayout = {
501 'text-field': '{point_count_abbreviated}',
502 'text-size': 24,
503 }
504 if (Array.isArray(palette.textFont) && palette.textFont.length > 0) {
505 clusterTextLayout['text-font'] = palette.textFont
506 }
507 map.addLayer({
508 id: clusterCountLayerID,
509 type: 'symbol',
510 source: clusterSourceID,
511 filter: ['has', 'point_count'],
512 layout: clusterTextLayout,
513 paint: {
514 'text-color': palette.text,
515 },
516 })
517 }
518
519 if (!map.getLayer(unclusteredLayerID)) {
520 map.addLayer({
521 id: unclusteredLayerID,
522 type: 'circle',
523 source: clusterSourceID,
524 filter: ['!', ['has', 'point_count']],
525 paint: {
526 'circle-color': palette.unclustered,
527 'circle-radius': 12,
528 'circle-stroke-color': palette.stroke,
529 'circle-stroke-width': 3,
530 },
531 })
532 }
533 }
534
535 const removeDomMarkers = () => {
536 for (const marker of markers) {
537 marker.remove()
538 }
539 markers = []
540 }
541
542 const renderDomMarkers = (entries) => {
543 removeDomMarkers()
544 markers = entries.map((entry) => {
545 const markerEl = document.createElement('div')
546 markerEl.className = 'rocket-openfreemap-marker'
547
548 const iconEl = document.createElement('span')
549 iconEl.className = 'rocket-openfreemap-marker-icon'
550 if (entry.icon.includes(':')) {
551 const iconifyEl = document.createElement('iconify-icon')
552 iconifyEl.setAttribute('icon', entry.icon)
553 iconifyEl.setAttribute('noobserver', '')
554 iconifyEl.setAttribute('aria-hidden', 'true')
555 iconEl.appendChild(iconifyEl)
556 } else {
557 iconEl.textContent = entry.icon
558 }
559 markerEl.appendChild(iconEl)
560
561 if (entry.label) {
562 const labelEl = document.createElement('span')
563 labelEl.className = 'rocket-openfreemap-marker-label'
564 labelEl.textContent = entry.label
565 markerEl.appendChild(labelEl)
566 }
567
568 return new maplibregl.Marker({ element: markerEl, anchor: 'bottom' })
569 .setLngLat(entry.coords)
570 .addTo(map)
571 })
572 }
573
574 map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right')
575 map.on('styleimagemissing', (evt) => {
576 const missingID = evt?.id
577 if (!missingID || map.hasImage(missingID)) return
578 try {
579 map.addImage(missingID, {
580 width: 1,
581 height: 1,
582 data: new Uint8Array([0, 0, 0, 0]),
583 })
584 } catch {
585 // no-op
586 }
587 })
588 map.on('load', ensure3DBuildings)
589 map.on('style.load', ensure3DBuildings)
590 map.on('styledata', ensure3DBuildings)
591 map.on('click', clusterLayerID, async (e) => {
592 const features = map.queryRenderedFeatures(e.point, { layers: [clusterLayerID] })
593 const feature = features[0]
594 const clusterID = Number(feature?.properties?.cluster_id)
595 if (!feature || !Number.isFinite(clusterID)) return
596
597 const source = map.getSource(clusterSourceID)
598 if (!source || typeof source.getClusterExpansionZoom !== 'function') return
599
600 try {
601 const zoom = await source.getClusterExpansionZoom(clusterID)
602 const coordinates = feature.geometry?.coordinates
603 if (!Array.isArray(coordinates)) return
604 const lng = Number(coordinates[0])
605 const lat = Number(coordinates[1])
606 if (!Number.isFinite(lng) || !Number.isFinite(lat)) return
607 map.easeTo({ center: [lng, lat], zoom })
608 } catch {
609 // no-op
610 }
611 })
612 map.on('mouseenter', clusterLayerID, () => {
613 map.getCanvas().style.cursor = 'pointer'
614 })
615 map.on('mouseleave', clusterLayerID, () => {
616 map.getCanvas().style.cursor = ''
617 })
618
619 const resizeObserver = new ResizeObserver(() => map.resize())
620 resizeObserver.observe(el)
621
622 let prevStyleURL = initialStyleURL
623 let prevView = JSON.stringify(initialView)
624 let prevDragRotate = $$dragRotate
625 let prevAppliedClusterConfig = ''
626 let prevClusterSignal = JSON.stringify(readClusterConfig())
627 let prevMarkers = ''
628 let markerEntries = []
629 let markers = []
630
631 const syncMarkers = () => {
632 const config = readClusterConfig()
633 const clusterConfig = JSON.stringify([config.enabled, config.maxZoom, config.radius])
634 const zoom = map.getZoom()
635 const zoomForCluster = Number.isFinite(zoom) ? zoom : config.maxZoom
636 const shouldClusterByZoom =
637 config.enabled && markerEntries.length > 1 && zoomForCluster <= config.maxZoom
638
639 if (map.isStyleLoaded()) {
640 applyMarkerThemeVars(el?.style, readClusterPalette())
641 }
642
643 if (shouldClusterByZoom) {
644 // Avoid flashing back to DOM markers during transient style/tile loading.
645 if (!map.isStyleLoaded()) return
646 const palette = readClusterPalette()
647 applyMarkerThemeVars(el?.style, palette)
648 if (clusterConfig !== prevAppliedClusterConfig) {
649 removeClusterLayersAndSource()
650 prevAppliedClusterConfig = clusterConfig
651 }
652 ensureClusterLayersAndData(markerEntries, config, palette)
653 removeDomMarkers()
654 return
655 }
656
657 removeClusterLayersAndSource()
658 prevAppliedClusterConfig = clusterConfig
659 renderDomMarkers(markerEntries)
660 }
661 map.on('zoomend', syncMarkers)
662 map.on('moveend', syncMarkers)
663 map.on('idle', syncMarkers)
664 map.on('style.load', syncMarkers)
665
666 effect(() => {
667 const styleURL = $$styleUrl
668 if (styleURL === prevStyleURL) return
669 prevStyleURL = styleURL
670 map.setStyle(styleURL)
671 })
672
673 effect(() => {
674 const view = readView()
675 const next = JSON.stringify(view)
676 if (next === prevView) return
677 prevView = next
678 map.easeTo({ ...view, duration: 500 })
679 })
680
681 effect(() => {
682 const dragRotate = $$dragRotate
683 if (dragRotate === prevDragRotate) return
684 prevDragRotate = dragRotate
685 if (dragRotate) {
686 map.dragRotate.enable()
687 ensure3DBuildings()
688 return
689 }
690 map.dragRotate.disable()
691 })
692
693 effect(() => {
694 const clusterSignal = JSON.stringify(readClusterConfig())
695 if (clusterSignal === prevClusterSignal) return
696 prevClusterSignal = clusterSignal
697 syncMarkers()
698 })
699
700 effect(() => {
701 const nextMarkers = Array.isArray($$markers)
702 ? $$markers
703 .map((m) => {
704 const hasNestedCoords = Array.isArray(m?.[0])
705 const coords = hasNestedCoords ? m[0] : [m?.[0], m?.[1]]
706 const label = hasNestedCoords ? m?.[1] : m?.[2]
707 const icon = hasNestedCoords ? m?.[2] : m?.[3]
708 const lng = Number(coords?.[0])
709 const lat = Number(coords?.[1])
710 if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null
711 return {
712 coords: [lng, lat],
713 label: typeof label === 'string' ? label : '',
714 icon:
715 typeof icon === 'string' && icon.length > 0
716 ? icon
717 : 'mdi:map-marker',
718 }
719 })
720 .filter((m) => m != null)
721 : []
722 const next = JSON.stringify(nextMarkers)
723 if (next === prevMarkers) return
724 prevMarkers = next
725 markerEntries = nextMarkers
726 syncMarkers()
727 })
728
729 onCleanup(() => {
730 resizeObserver.disconnect()
731 removeDomMarkers()
732 removeClusterLayersAndSource()
733 map.remove()
734 })
735 </script>
736
737 <style>
738 :host {
739 display: block;
740 width: 100%;
741 height: 100%;
742 min-height: 320px;
743 position: relative;
744 overflow: hidden;
745 --rocket-openfreemap-marker-icon-bg: #ad1529;
746 --rocket-openfreemap-marker-icon-fg: #ffffff;
747 --rocket-openfreemap-marker-label-bg: rgba(20, 21, 23, 0.84);
748 --rocket-openfreemap-marker-label-fg: #ffffff;
749 --rocket-openfreemap-marker-border: rgba(255, 255, 255, 0.28);
750 }
751
752 .map-node {
753 width: 100%;
754 height: 100%;
755 min-height: inherit;
756 }
757
758 .rocket-openfreemap-marker {
759 display: inline-flex;
760 align-items: center;
761 gap: 0.6rem;
762 transform: translateY(-8px);
763 }
764
765 .rocket-openfreemap-marker-icon {
766 width: 3rem;
767 height: 3rem;
768 border-radius: 999px;
769 background: var(--rocket-openfreemap-marker-icon-bg);
770 color: var(--rocket-openfreemap-marker-icon-fg);
771 border: 2px solid var(--rocket-openfreemap-marker-border);
772 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
773 display: inline-flex;
774 align-items: center;
775 justify-content: center;
776 font-size: 1.7rem;
777 line-height: 1;
778 user-select: none;
779 }
780
781 .rocket-openfreemap-marker-icon iconify-icon {
782 width: 1.9rem;
783 height: 1.9rem;
784 display: block;
785 }
786
787 .rocket-openfreemap-marker-label {
788 color: var(--rocket-openfreemap-marker-label-fg);
789 background: var(--rocket-openfreemap-marker-label-bg);
790 border: 2px solid var(--rocket-openfreemap-marker-border);
791 border-radius: 999px;
792 padding: 0.24rem 0.84rem;
793 font-size: 1.4rem;
794 font-weight: 600;
795 line-height: 1.2;
796 white-space: nowrap;
797 backdrop-filter: blur(1px);
798 user-select: none;
799 }
800 </style>
801
802 <div class="map-node" data-ref="mapNode"></div>
803</template>