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.

Demo

Style URL:

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>