Add route, area, cluster, city switcher and bubble map demos

This commit is contained in:
codecalm committed 2026-10-04 17:06:19 +02:00
1 parent b6904b93bd
commit 9af4173f86
6 files changed
+389 -3

No files matched your search

+1
View File
@@ -66,6 +66,7 @@ const selector = `#wysiwyg-${id}`
- `BEGIN … / END …` comments mark the block in the copied HTML. Use the component name in caps.
- A named `init…` function plus the `readyState` guard, never a bare call: the script may be parsed after `DOMContentLoaded` has already fired.
- No `await` (and no `async` function) inside a captured `<script is:inline>`. The Astro compiler sees the keyword and renders the page content asynchronously, `PageScripts` then drains the registry too early, and **every** captured script of the page disappears with no error. Use a `.then()` chain instead.
- The component takes a required `id` prop and derives ids and selectors from it in the frontmatter. Never query by class or by tag — a page renders many instances.
## 4. Passing data with `define:vars`
+7
View File
@@ -0,0 +1,7 @@
---
"@tabler/core": patch
"@tabler/preview": minor
"@tabler/docs": patch
---
Added route, area, clusters, city switcher and bubble map demos to the maps page, and fixed the `.map-marker` position on a map.
+5
View File
@@ -173,6 +173,11 @@
@include border-radius(var(--border-radius-pill));
}
// On a map the marker is positioned by MapLibre
&.maplibregl-marker {
position: absolute;
}
.icon {
--icon-size: var(--map-marker-icon-size);
}
+112
View File
@@ -284,6 +284,118 @@ new MutationObserver(function () {
Markers and popups stay on the map when the style changes. The maps on this page follow the theme this way.
### Layers
Lines, areas and large sets of points are drawn as layers. A layer reads its data from a GeoJSON source. Layers are part of the map style, so add them in the `style.load` event. It runs when the map starts and again each time the style changes, for example on a theme change.
```js
map.on('style.load', function () {
map.addSource('route', {
type: 'geojson',
data: { type: 'Feature', geometry: { type: 'LineString', coordinates: [[13.3695, 52.5251], [13.4132, 52.5219], [13.4397, 52.505]] } },
})
map.addLayer({
id: 'route',
type: 'line',
source: 'route',
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': '#066fd1', 'line-width': 4 },
})
})
```
For an area, use a `Polygon` geometry with a `fill` layer, and a `line` layer for its outline:
```js
map.addLayer({ id: 'area-fill', type: 'fill', source: 'area', paint: { 'fill-color': '#066fd1', 'fill-opacity': 0.16 } })
map.addLayer({ id: 'area-outline', type: 'line', source: 'area', paint: { 'line-color': '#066fd1', 'line-width': 2 } })
```
### Theme colors in layers
MapLibre draws layers on a canvas, so a CSS variable does not work in `paint`, and neither do the `oklch()` values Tabler colors are written in. Turn the variable into a plain `rgb()` color first:
```js
function mapColor(name) {
const context = document.createElement('canvas').getContext('2d')
const probe = document.body.appendChild(document.createElement('span'))
probe.style.color = 'var(' + name + ')'
context.fillStyle = getComputedStyle(probe).color
context.fillRect(0, 0, 1, 1)
probe.remove()
const pixel = context.getImageData(0, 0, 1, 1).data
return 'rgb(' + pixel[0] + ', ' + pixel[1] + ', ' + pixel[2] + ')'
}
map.addLayer({ id: 'route', type: 'line', source: 'route', paint: { 'line-color': mapColor('--tblr-primary'), 'line-width': 4 } })
```
Call it inside `style.load`, so the colors are read again after a theme change.
### Clusters
HTML markers work well for up to a few hundred places. For more, put the points in a GeoJSON source with `cluster: true`. MapLibre then groups nearby points and splits the groups as you zoom in.
```js
map.addSource('stores', { type: 'geojson', data: stores, cluster: true, clusterRadius: 40 })
// Circle for each cluster, larger for more points
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'stores',
filter: ['has', 'point_count'],
paint: { 'circle-color': '#066fd1', 'circle-radius': ['step', ['get', 'point_count'], 14, 10, 18, 25, 24] },
})
// Number of points in the cluster
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'stores',
filter: ['has', 'point_count'],
layout: { 'text-field': ['get', 'point_count_abbreviated'], 'text-font': ['Noto Sans Regular'], 'text-size': 12 },
paint: { 'text-color': '#ffffff' },
})
// Single points
map.addLayer({ id: 'store', type: 'circle', source: 'stores', filter: ['!', ['has', 'point_count']], paint: { 'circle-color': '#066fd1', 'circle-radius': 6 } })
```
`text-font` has to be a font the map style provides. All OpenFreeMap styles have `Noto Sans Regular`.
### Bubble map
To size circles by a value, read it from the feature with `['get', ...]` and map it to a radius with `interpolate`:
```js
map.addLayer({
id: 'cities',
type: 'circle',
source: 'cities',
paint: {
'circle-color': '#066fd1',
'circle-opacity': 0.4,
'circle-radius': ['interpolate', ['linear'], ['get', 'users'], 0, 4, 1600, 28],
},
})
```
### Moving the map
Move to another place with `flyTo`, for example from a row of buttons:
```js
button.addEventListener('click', function () {
map.flyTo({ center: [139.6917, 35.6895], zoom: 10 })
})
```
Live versions of the route, area, clusters, bubble map and city switcher are on the [maps preview page](https://preview.tabler.io/maps.html).
### Attribution
MapLibre adds the credit for the map data in the corner of the map. OpenFreeMap requires it, so do not hide it.
+260
View File
@@ -10,10 +10,57 @@ import DocsLink from '@ui/DocsLink.astro'
import Avatar from '@ui/Avatar.astro'
import Icon from '@ui/Icon.astro'
import { staticPath } from '@shared/lib/assets'
import CaptureScript from '@shared/components/CaptureScript.astro'
type MapData = { title?: string; card?: boolean }
const mapEntries = Object.entries(maps as Record<string, MapData>)
const route = [
[13.3695, 52.5251],
[13.3777, 52.5208],
[13.388, 52.5186],
[13.3989, 52.5176],
[13.405, 52.5192],
[13.4132, 52.5219],
[13.4215, 52.5165],
[13.43, 52.5105],
[13.4397, 52.505],
]
const area = [
[13.37, 52.53],
[13.42, 52.535],
[13.445, 52.52],
[13.43, 52.505],
[13.385, 52.503],
[13.365, 52.515],
[13.37, 52.53],
]
const cities = [
{ name: 'Berlin', lng: 13.405, lat: 52.52, users: 820 },
{ name: 'London', lng: -0.1276, lat: 51.5072, users: 1240 },
{ name: 'New York', lng: -74.006, lat: 40.7128, users: 1580 },
{ name: 'Tokyo', lng: 139.6917, lat: 35.6895, users: 960 },
{ name: 'São Paulo', lng: -46.6333, lat: -23.5505, users: 540 },
{ name: 'Sydney', lng: 151.2093, lat: -33.8688, users: 310 },
{ name: 'Lagos', lng: 3.3792, lat: 6.5244, users: 420 },
{ name: 'Mumbai', lng: 72.8777, lat: 19.076, users: 1100 },
]
// Stores scattered around a few European cities; the offsets are fixed, so every build draws the same map
const storeCities: [number, number][] = [
[13.405, 52.52],
[2.3522, 48.8566],
[-0.1276, 51.5072],
[12.4964, 41.9028],
[-3.7038, 40.4168],
[21.0122, 52.2297],
[16.3738, 48.2082],
[4.9041, 52.3676],
]
const stores = storeCities.flatMap(([lng, lat], city) => Array.from({ length: 5 + (city % 4) }, (_, index) => [lng + Math.sin(city * 7 + index * 3) * 0.6, lat + Math.cos(city * 5 + index * 2) * 0.4]))
const places = [
{ name: 'Museum Island', text: 'Five museums on the Spree, open daily.', photo: 'a-visit-to-the-bookstore.jpg', lat: 52.5169, lng: 13.4019, open: true },
{ name: 'Bellevue Palace', text: 'Residence by the Tiergarten park.', photo: 'book-on-the-grass.jpg', lat: 52.5176, lng: 13.3531, open: false },
@@ -23,6 +70,24 @@ const places = [
<DefaultLayout title="Maps" pageMenu="plugins.maps" pageLibs={['maplibre-gl']} description="Interactive MapLibre maps — plain, styled, with custom markers and popups.">
<DocsLink slot="page-header-actions" path="/ui/components/map" />
<CaptureScript>
<script is:inline>
// MapLibre draws on a canvas, so a layer needs a plain rgb() color instead of a CSS variable
window.tablerMapColor = (name) => {
const context = document.createElement('canvas').getContext('2d')
const probe = document.body.appendChild(document.createElement('span'))
probe.style.color = `var(${name})`
context.fillStyle = getComputedStyle(probe).color
context.fillRect(0, 0, 1, 1)
probe.remove()
const [red, green, blue] = context.getImageData(0, 0, 1, 1).data
return `rgb(${red}, ${green}, ${blue})`
}
</script>
</CaptureScript>
<div class="row row-cards">
{
mapEntries.map(([mapId, data]) =>
@@ -93,6 +158,201 @@ const places = [
</Card>
</div>
<div class="col-lg-6">
<Card>
<CardBody>
<CardTitle>Route</CardTitle>
<CardSubtitle>Draw a path with a GeoJSON line layer, and mark its ends with markers.</CardSubtitle>
<Map mapId="route" title="Route" center={[52.513, 13.4046]} zoom={11.6}>
<div data-lng="13.3695" data-lat="52.5251"><span class="map-marker map-marker-dot"></span></div>
<div data-lng="13.4397" data-lat="52.505" data-anchor="bottom"><span class="map-marker map-marker-pin"><Icon name="map-pin" /></span></div>
</Map>
<CaptureScript>
<script is:inline define:vars={{ route }}>
document.addEventListener('DOMContentLoaded', () => {
const map = window.tabler_map['map-route']
// Layers are part of the style, so add them again each time the style loads
map.on('style.load', () => {
map.addSource('route', { type: 'geojson', data: { type: 'Feature', geometry: { type: 'LineString', coordinates: route } } })
map.addLayer({
id: 'route',
type: 'line',
source: 'route',
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': tablerMapColor('--tblr-primary'), 'line-width': 4 },
})
})
})
</script>
</CaptureScript>
</CardBody>
</Card>
</div>
<div class="col-lg-6">
<Card>
<CardBody>
<CardTitle>Area</CardTitle>
<CardSubtitle>Show a zone with a GeoJSON polygon: a fill layer and a line layer for its outline.</CardSubtitle>
<Map mapId="area" title="Area" center={[52.519, 13.405]} zoom={11} />
<CaptureScript>
<script is:inline define:vars={{ area }}>
document.addEventListener('DOMContentLoaded', () => {
const map = window.tabler_map['map-area']
map.on('style.load', () => {
const color = tablerMapColor('--tblr-primary')
map.addSource('area', { type: 'geojson', data: { type: 'Feature', geometry: { type: 'Polygon', coordinates: [area] } } })
map.addLayer({ id: 'area-fill', type: 'fill', source: 'area', paint: { 'fill-color': color, 'fill-opacity': 0.16 } })
map.addLayer({ id: 'area-outline', type: 'line', source: 'area', paint: { 'line-color': color, 'line-width': 2 } })
})
})
</script>
</CaptureScript>
</CardBody>
</Card>
</div>
<div class="col-lg-6">
<Card>
<CardBody>
<CardTitle>Clusters</CardTitle>
<CardSubtitle>Group many points into clusters that split as you zoom in. Click a cluster to zoom to it.</CardSubtitle>
<Map mapId="clusters" title="Clusters" center={[48.5, 9]} zoom={3} style="positron" controls />
<CaptureScript>
<script is:inline define:vars={{ stores }}>
document.addEventListener('DOMContentLoaded', () => {
const map = window.tabler_map['map-clusters']
map.on('style.load', () => {
const color = tablerMapColor('--tblr-primary')
const border = tablerMapColor('--tblr-bg-surface')
map.addSource('stores', {
type: 'geojson',
data: { type: 'FeatureCollection', features: stores.map((coordinates) => ({ type: 'Feature', geometry: { type: 'Point', coordinates } })) },
cluster: true,
clusterRadius: 40,
})
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'stores',
filter: ['has', 'point_count'],
paint: { 'circle-color': color, 'circle-radius': ['step', ['get', 'point_count'], 14, 10, 18, 25, 24], 'circle-stroke-width': 2, 'circle-stroke-color': border },
})
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'stores',
filter: ['has', 'point_count'],
layout: { 'text-field': ['get', 'point_count_abbreviated'], 'text-font': ['Noto Sans Regular'], 'text-size': 12 },
paint: { 'text-color': tablerMapColor('--tblr-primary-fg') },
})
map.addLayer({
id: 'store',
type: 'circle',
source: 'stores',
filter: ['!', ['has', 'point_count']],
paint: { 'circle-color': color, 'circle-radius': 6, 'circle-stroke-width': 2, 'circle-stroke-color': border },
})
})
map.on('click', 'clusters', (event) => {
const cluster = event.features[0]
map
.getSource('stores')
.getClusterExpansionZoom(cluster.properties.cluster_id)
.then((zoom) => map.easeTo({ center: cluster.geometry.coordinates, zoom }))
})
})
</script>
</CaptureScript>
</CardBody>
</Card>
</div>
<div class="col-lg-6">
<Card>
<CardBody>
<CardTitle>City switcher</CardTitle>
<CardSubtitle>Move the map to a place with <code>flyTo</code> when a button is clicked.</CardSubtitle>
<div class="btn-group w-100 mb-3" role="group" aria-label="City" id="map-cities-switcher">
{
cities.slice(0, 4).map((city, index) => (
<button type="button" class:list={['btn', index === 0 && 'active']} data-lng={city.lng} data-lat={city.lat}>
{city.name}
</button>
))
}
</div>
<Map mapId="cities" title="City switcher" center={[52.52, 13.405]} zoom={10} ratio="21x9" />
<CaptureScript>
<script is:inline>
document.addEventListener('DOMContentLoaded', () => {
const map = window.tabler_map['map-cities']
const buttons = document.querySelectorAll('#map-cities-switcher .btn')
buttons.forEach((button) => {
button.addEventListener('click', () => {
buttons.forEach((item) => item.classList.toggle('active', item === button))
map.flyTo({ center: [Number(button.dataset.lng), Number(button.dataset.lat)], zoom: 10 })
})
})
})
</script>
</CaptureScript>
</CardBody>
</Card>
</div>
<div class="col-lg-12">
<Card>
<CardBody>
<CardTitle>Bubble map</CardTitle>
<CardSubtitle>Size circles by a value, for example users per city. Hover a circle to see the number.</CardSubtitle>
<Map mapId="bubbles" title="Bubble map" center={[20, 20]} zoom={1} style="positron" ratio="21x9" />
<CaptureScript>
<script is:inline define:vars={{ cities }}>
document.addEventListener('DOMContentLoaded', () => {
const map = window.tabler_map['map-bubbles']
const popup = new maplibregl.Popup({ closeButton: false, offset: 12 })
map.on('style.load', () => {
const color = tablerMapColor('--tblr-primary')
map.addSource('cities', {
type: 'geojson',
data: { type: 'FeatureCollection', features: cities.map((city) => ({ type: 'Feature', properties: city, geometry: { type: 'Point', coordinates: [city.lng, city.lat] } })) },
})
map.addLayer({
id: 'cities',
type: 'circle',
source: 'cities',
paint: { 'circle-color': color, 'circle-opacity': 0.4, 'circle-radius': ['interpolate', ['linear'], ['get', 'users'], 0, 4, 1600, 28], 'circle-stroke-width': 1, 'circle-stroke-color': color },
})
})
map.on('mousemove', 'cities', (event) => {
const city = event.features[0].properties
map.getCanvas().style.cursor = 'pointer'
popup.setLngLat([city.lng, city.lat]).setText(`${city.name}: ${city.users} users`).addTo(map)
})
map.on('mouseleave', 'cities', () => {
map.getCanvas().style.cursor = ''
popup.remove()
})
})
</script>
</CaptureScript>
</CardBody>
</Card>
</div>
<div class="col-lg-12">
<Card>
<CardBody>
+4 -3
View File
@@ -16,9 +16,10 @@ interface Props {
zoom?: number;
title?: string;
style?: string;
controls?: boolean;
}
const { mapId, ratio, center: centerProp, zoom: zoomProp, title, style: styleProp } = Astro.props;
const { mapId, ratio, center: centerProp, zoom: zoomProp, title, style: styleProp, controls: controlsProp } = Astro.props;
type MapData = {
title?: string | undefined;
@@ -29,13 +30,13 @@ type MapData = {
/** style used in dark mode, `dark` by default */
darkStyle?: string;
/** adds the zoom and compass buttons */
controls?: boolean;
controls?: boolean | undefined;
zoom?: number | undefined;
center?: [number, number];
markers?: { name?: string; center: [number, number] }[];
};
const data: MapData | undefined = (maps as unknown as Record<string, MapData>)[mapId] ?? (centerProp ? { title, center: centerProp, zoom: zoomProp, style: styleProp } : undefined);
const data: MapData | undefined = (maps as unknown as Record<string, MapData>)[mapId] ?? (centerProp ? { title, center: centerProp, zoom: zoomProp, style: styleProp, controls: controlsProp } : undefined);
const hasHtmlMarkers = Astro.slots.has('default');
const mapContainerId = `map-${mapId}`;