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.
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})