Rocket OpenFreeMap Pro

Rocket is currently in beta.

An 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 keeps the style presets and current view in page state, then feeds them into the component as props. The component mounts MapLibre from refs.mapNode in onFirstRender() and uses prop observers for style, camera, and marker updates.

Usage Example #

 1<div data-signals='{"style":"liberty","styleUrl":"https://tiles.openfreemap.org/styles/liberty","view":{"center":[-115.172816,36.114647],"zoom":13.8,"bearing":0,"pitch":0}}'>
 2    <openfreemap-map
 3        data-attr:style-url="$styleUrl"
 4        data-attr:center="$view.center"
 5        data-attr:zoom="$view.zoom"
 6        data-attr:bearing="$view.bearing"
 7        data-attr:pitch="$view.pitch"
 8        data-attr:drag-rotate="$style == 'liberty-3d' || $style == 'dark-3d'"
 9        data-attr:cluster="true"
10        data-attr:cluster-max-zoom="14"
11    ></openfreemap-map>
12</div>

Rocket Component #

  1import { rocket } from 'datastar'
  2
  3const { default: maplibregl } = await import(
  4  'https://cdn.jsdelivr.net/npm/[email protected]/+esm'
  5)
  6
  7const markerEntriesToGeoJSON = (entries) => ({
  8  type: 'FeatureCollection',
  9  features: entries.map((entry, id) => ({
 10    type: 'Feature',
 11    properties: { id, label: entry.label, icon: entry.icon },
 12    geometry: { type: 'Point', coordinates: entry.coords },
 13  })),
 14})
 15
 16rocket('openfreemap-map', {
 17  props: ({ string, json, number, bool }) => ({
 18    styleUrl: string.default('https://tiles.openfreemap.org/styles/liberty'),
 19    center: json.default([-115.172816, 36.114647]),
 20    zoom: number.clamp(0, 22).default(9.5),
 21    bearing: number,
 22    pitch: number.clamp(0, 85),
 23    dragRotate: bool,
 24    cluster: bool,
 25    clusterMaxZoom: number.clamp(0, 22).default(12),
 26    clusterRadius: number.clamp(10, 100).default(50),
 27    markers: json.default([]),
 28  }),
 29  onFirstRender: ({ refs, cleanup, host, observeProps, props }) => {
 30    let map
 31    let markers = []
 32    let markerEntries = []
 33    let prevMarkers = ''
 34    let prevStyleURL = props.styleUrl
 35    let prevView = JSON.stringify({
 36      center: props.center,
 37      zoom: props.zoom,
 38      bearing: props.bearing,
 39      pitch: props.pitch,
 40    })
 41    let prevDragRotate = props.dragRotate
 42    let prevClusterSignal = JSON.stringify([
 43      props.cluster,
 44      Math.round(props.clusterMaxZoom),
 45      Math.round(props.clusterRadius),
 46    ])
 47    let prevAppliedClusterConfig = ''
 48
 49    const clusterSourceID = 'rocket-openfreemap-markers-cluster'
 50    const clusterLayerID = 'rocket-openfreemap-markers-clusters'
 51    const clusterCountLayerID = 'rocket-openfreemap-markers-cluster-count'
 52    const unclusteredLayerID = 'rocket-openfreemap-markers-unclustered'
 53
 54    const readMarkerEntries = () =>
 55      Array.isArray(props.markers)
 56        ? props.markers
 57            .map((m) => {
 58              const hasNestedCoords = Array.isArray(m?.[0])
 59              const coords = hasNestedCoords ? m[0] : [m?.[0], m?.[1]]
 60              const label = hasNestedCoords ? m?.[1] : m?.[2]
 61              const icon = hasNestedCoords ? m?.[2] : m?.[3]
 62              const [lng, lat] = coords.map(Number)
 63              if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null
 64              return {
 65                coords: [lng, lat],
 66                label: typeof label === 'string' ? label : '',
 67                icon: (typeof icon === 'string' && icon) || 'mdi:map-marker',
 68              }
 69            })
 70            .filter((m) => m != null)
 71        : []
 72
 73    const removeClusterLayersAndSource = () => {
 74      if (!map) return
 75      if (map.getLayer(clusterCountLayerID))
 76        map.removeLayer(clusterCountLayerID)
 77      if (map.getLayer(clusterLayerID)) map.removeLayer(clusterLayerID)
 78      if (map.getLayer(unclusteredLayerID)) map.removeLayer(unclusteredLayerID)
 79      if (map.getSource(clusterSourceID)) map.removeSource(clusterSourceID)
 80    }
 81
 82    const removeDomMarkers = () => {
 83      for (const marker of markers) marker.remove()
 84      markers = []
 85    }
 86
 87    const renderDomMarkers = () => {
 88      if (!map) return
 89      removeDomMarkers()
 90      markers = markerEntries.map((entry) => {
 91        const markerEl = document.createElement('div')
 92        markerEl.className = 'rocket-openfreemap-marker'
 93
 94        const iconEl = document.createElement('span')
 95        iconEl.className = 'rocket-openfreemap-marker-icon'
 96        if (entry.icon.includes(':')) {
 97          const iconifyEl = document.createElement('iconify-icon')
 98          iconifyEl.setAttribute('icon', entry.icon)
 99          iconifyEl.setAttribute('noobserver', '')
100          iconifyEl.setAttribute('aria-hidden', 'true')
101          iconEl.append(iconifyEl)
102        } else {
103          iconEl.textContent = entry.icon
104        }
105
106        markerEl.append(iconEl)
107        if (entry.label) {
108          const labelEl = document.createElement('span')
109          labelEl.className = 'rocket-openfreemap-marker-label'
110          labelEl.textContent = entry.label
111          markerEl.append(labelEl)
112        }
113
114        return new maplibregl.Marker({ element: markerEl, anchor: 'bottom' })
115          .setLngLat(entry.coords)
116          .addTo(map)
117      })
118    }
119
120    const ensure3DBuildings = () => {
121      if (!map || !props.dragRotate) return
122      const layers = map.getStyle()?.layers ?? []
123      if (layers.some((layer) => layer.type === 'fill-extrusion')) return
124      const buildingLayer = layers.find(
125        (layer) =>
126          layer.type === 'fill' &&
127          layer.source &&
128          layer['source-layer'] === 'building',
129      )
130      if (!buildingLayer || map.getLayer('rocket-openfreemap-3d-buildings'))
131        return
132      map.addLayer(
133        {
134          id: 'rocket-openfreemap-3d-buildings',
135          type: 'fill-extrusion',
136          source: buildingLayer.source,
137          'source-layer': 'building',
138          minzoom: 14,
139          paint: {
140            'fill-extrusion-color': '#b9b5ad',
141            'fill-extrusion-base': 0,
142            'fill-extrusion-height': [
143              'interpolate',
144              ['linear'],
145              ['zoom'],
146              14,
147              0,
148              16,
149              40,
150              18,
151              90,
152            ],
153            'fill-extrusion-opacity': 0.8,
154            'fill-extrusion-vertical-gradient': true,
155          },
156        },
157        layers.find((layer) => layer.type === 'symbol')?.id,
158      )
159    }
160
161    const ensureClusterLayersAndData = (config) => {
162      if (!map?.isStyleLoaded()) return
163      let source = map.getSource(clusterSourceID)
164      if (!source) {
165        map.addSource(clusterSourceID, {
166          type: 'geojson',
167          data: markerEntriesToGeoJSON(markerEntries),
168          cluster: true,
169          clusterMaxZoom: config.maxZoom,
170          clusterRadius: config.radius,
171        })
172        source = map.getSource(clusterSourceID)
173      } else {
174        source.setData(markerEntriesToGeoJSON(markerEntries))
175      }
176
177      if (!map.getLayer(clusterLayerID)) {
178        map.addLayer({
179          id: clusterLayerID,
180          type: 'circle',
181          source: clusterSourceID,
182          filter: ['has', 'point_count'],
183          paint: {
184            'circle-color': [
185              'step',
186              ['get', 'point_count'],
187              '#3b82f6',
188              6,
189              '#2563eb',
190              12,
191              '#1d4ed8',
192            ],
193            'circle-radius': [
194              'step',
195              ['get', 'point_count'],
196              32,
197              6,
198              40,
199              12,
200              48,
201            ],
202            'circle-stroke-color': '#ffffff',
203            'circle-stroke-width': 3,
204            'circle-opacity': 0.88,
205          },
206        })
207      }
208
209      if (!map.getLayer(clusterCountLayerID)) {
210        map.addLayer({
211          id: clusterCountLayerID,
212          type: 'symbol',
213          source: clusterSourceID,
214          filter: ['has', 'point_count'],
215          layout: {
216            'text-field': '{point_count_abbreviated}',
217            'text-size': 24,
218          },
219          paint: {
220            'text-color': '#ffffff',
221          },
222        })
223      }
224
225      if (!map.getLayer(unclusteredLayerID)) {
226        map.addLayer({
227          id: unclusteredLayerID,
228          type: 'circle',
229          source: clusterSourceID,
230          filter: ['!', ['has', 'point_count']],
231          paint: {
232            'circle-color': '#ad1529',
233            'circle-radius': 12,
234            'circle-stroke-color': '#ffffff',
235            'circle-stroke-width': 3,
236          },
237        })
238      }
239    }
240
241    const syncMarkers = () => {
242      if (!map) return
243      const clusterConfig = {
244        enabled: props.cluster === true,
245        maxZoom: Math.round(props.clusterMaxZoom),
246        radius: Math.round(props.clusterRadius),
247      }
248      const clusterConfigKey = JSON.stringify([
249        clusterConfig.enabled,
250        clusterConfig.maxZoom,
251        clusterConfig.radius,
252      ])
253      const zoom = map.getZoom()
254      const shouldCluster =
255        clusterConfig.enabled &&
256        markerEntries.length > 1 &&
257        (Number.isFinite(zoom) ? zoom : clusterConfig.maxZoom) <=
258          clusterConfig.maxZoom
259
260      if (shouldCluster) {
261        if (!map.isStyleLoaded()) return
262        if (clusterConfigKey !== prevAppliedClusterConfig) {
263          removeClusterLayersAndSource()
264          prevAppliedClusterConfig = clusterConfigKey
265        }
266        ensureClusterLayersAndData(clusterConfig)
267        removeDomMarkers()
268        return
269      }
270
271      removeClusterLayersAndSource()
272      prevAppliedClusterConfig = clusterConfigKey
273      renderDomMarkers()
274    }
275
276    if (refs.mapNode instanceof HTMLElement) {
277      markerEntries = readMarkerEntries()
278      prevMarkers = JSON.stringify(markerEntries)
279      map = new maplibregl.Map({
280        style: props.styleUrl,
281        center: props.center,
282        zoom: props.zoom,
283        bearing: props.bearing,
284        pitch: props.pitch,
285        container: refs.mapNode,
286        cooperativeGestures: true,
287        dragRotate: props.dragRotate,
288      })
289
290      map.addControl(
291        new maplibregl.NavigationControl({ showCompass: false }),
292        'top-right',
293      )
294      map.on('styleimagemissing', (evt) => {
295        if (!evt?.id || map.hasImage(evt.id)) return
296        try {
297          map.addImage(evt.id, {
298            width: 1,
299            height: 1,
300            data: new Uint8Array([0, 0, 0, 0]),
301          })
302        } catch {}
303      })
304      map.on('load', ensure3DBuildings)
305      map.on('style.load', ensure3DBuildings)
306      map.on('styledata', ensure3DBuildings)
307      map.on('zoomend', syncMarkers)
308      map.on('moveend', syncMarkers)
309      map.on('idle', syncMarkers)
310      map.on('style.load', syncMarkers)
311      map.on('click', clusterLayerID, async (evt) => {
312        const feature = map.queryRenderedFeatures(evt.point, {
313          layers: [clusterLayerID],
314        })[0]
315        const clusterID = Number(feature?.properties?.cluster_id)
316        if (!feature || !Number.isFinite(clusterID)) return
317        const source = map.getSource(clusterSourceID)
318        if (!source || typeof source.getClusterExpansionZoom !== 'function')
319          return
320        try {
321          const zoom = await source.getClusterExpansionZoom(clusterID)
322          const [lng, lat] = feature.geometry?.coordinates ?? []
323          if (!Number.isFinite(lng) || !Number.isFinite(lat)) return
324          map.easeTo({ center: [lng, lat], zoom })
325        } catch {}
326      })
327      map.on('mouseenter', clusterLayerID, () => {
328        map.getCanvas().style.cursor = 'pointer'
329      })
330      map.on('mouseleave', clusterLayerID, () => {
331        map.getCanvas().style.cursor = ''
332      })
333
334      new ResizeObserver(() => map.resize()).observe(host)
335      syncMarkers()
336    }
337
338    observeProps(() => {
339      if (!map || props.styleUrl === prevStyleURL) return
340      prevStyleURL = props.styleUrl
341      map.setStyle(props.styleUrl)
342    }, 'styleUrl')
343    observeProps(
344      () => {
345        if (!map) return
346        const next = JSON.stringify({
347          center: props.center,
348          zoom: props.zoom,
349          bearing: props.bearing,
350          pitch: props.pitch,
351        })
352        if (next === prevView) return
353        prevView = next
354        map.easeTo({
355          center: props.center,
356          zoom: props.zoom,
357          bearing: props.bearing,
358          pitch: props.pitch,
359          duration: 500,
360        })
361      },
362      'center',
363      'zoom',
364      'bearing',
365      'pitch',
366    )
367    observeProps(() => {
368      if (!map || props.dragRotate === prevDragRotate) return
369      prevDragRotate = props.dragRotate
370      if (props.dragRotate) {
371        map.dragRotate.enable()
372        ensure3DBuildings()
373      } else {
374        map.dragRotate.disable()
375      }
376    }, 'dragRotate')
377    observeProps(
378      () => {
379        const next = JSON.stringify([
380          props.cluster,
381          Math.round(props.clusterMaxZoom),
382          Math.round(props.clusterRadius),
383        ])
384        if (next === prevClusterSignal) return
385        prevClusterSignal = next
386        syncMarkers()
387      },
388      'cluster',
389      'clusterMaxZoom',
390      'clusterRadius',
391    )
392    observeProps(() => {
393      const nextMarkers = readMarkerEntries()
394      const next = JSON.stringify(nextMarkers)
395      if (next === prevMarkers) return
396      prevMarkers = next
397      markerEntries = nextMarkers
398      syncMarkers()
399    }, 'markers')
400
401    cleanup(() => {
402      removeDomMarkers()
403      removeClusterLayersAndSource()
404      map?.remove()
405    })
406  },
407  render: ({ html }) => html`
408    <link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/maplibre-gl.css" />
409    <style>
410      :host {
411        display: block;
412        width: 100%;
413        height: 100%;
414        min-height: 320px;
415        position: relative;
416        overflow: hidden;
417        --rocket-openfreemap-marker-icon-bg: #ad1529;
418        --rocket-openfreemap-marker-icon-fg: #ffffff;
419        --rocket-openfreemap-marker-label-bg: rgba(20, 21, 23, 0.84);
420        --rocket-openfreemap-marker-label-fg: #ffffff;
421        --rocket-openfreemap-marker-border: rgba(255, 255, 255, 0.28);
422      }
423
424      .map-node {
425        width: 100%;
426        height: 100%;
427        min-height: inherit;
428      }
429
430      .rocket-openfreemap-marker {
431        display: inline-flex;
432        align-items: center;
433        gap: 0.6rem;
434        transform: translateY(-8px);
435      }
436
437      .rocket-openfreemap-marker-icon {
438        width: 3rem;
439        height: 3rem;
440        border-radius: 999px;
441        background: var(--rocket-openfreemap-marker-icon-bg);
442        color: var(--rocket-openfreemap-marker-icon-fg);
443        border: 2px solid var(--rocket-openfreemap-marker-border);
444        box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
445        display: inline-flex;
446        align-items: center;
447        justify-content: center;
448        font-size: 1.7rem;
449        line-height: 1;
450        user-select: none;
451      }
452
453      .rocket-openfreemap-marker-icon iconify-icon {
454        width: 1.9rem;
455        height: 1.9rem;
456        display: block;
457      }
458
459      .rocket-openfreemap-marker-label {
460        color: var(--rocket-openfreemap-marker-label-fg);
461        background: var(--rocket-openfreemap-marker-label-bg);
462        border: 2px solid var(--rocket-openfreemap-marker-border);
463        border-radius: 999px;
464        padding: 0.24rem 0.84rem;
465        font-size: 1.4rem;
466        font-weight: 600;
467        line-height: 1.2;
468        white-space: nowrap;
469        backdrop-filter: blur(1px);
470        user-select: none;
471      }
472    </style>
473    <div class="map-node" data-ref:map-node data-ignore-morph></div>
474  `,
475})