<ScriptMapLibreGeoJson>
Adds a GeoJSON source and one or more MapLibre style layers. Replacing data updates the existing source. A paint, layout or filter change updates the layers in place. A change to cluster, clusterRadius or clusterMaxZoom updates the source in place. Any other layer or source option change rebuilds the source and layers in a controlled order.
Changing source-id removes the previously owned source and layers before recreating them under the new ID.
sourceIdstring required dataGeoJSON | string required sourceOptionsOmit<MapLibre.GeoJSONSourceSpecification, 'type' | 'data'>layersScriptMapLibreGeoJsonLayer[] required Style layers backed by this source. A paint, layout or filter change updates the layers in place. Any other change rebuilds the source and the layers.
beforeIdstringcursorstringEach layer gets the component's source-id unless it explicitly supplies another source. The component also restores its source and layers after the map style changes.
Pointer Events
The component emits click, dblclick, mouseenter, mousemove and mouseleave for its own layers. Each event carries features, topmost first.
MapLibre treats the layers as one group. mouseenter fires when the pointer enters the group. mouseleave fires when it leaves every feature. Neither fires when the pointer slides from one feature onto a touching feature.
To track the feature under the pointer, read it on mousemove and clear it on mouseleave:
<script setup lang="ts">
import type { MapLayerMouseEvent } from 'maplibre-gl'
const hoveredId = ref<string | number>()
function onMove(event: MapLayerMouseEvent) {
hoveredId.value = event.features?.[0]?.id
}
</script>
<template>
<ScriptMapLibreGeoJson
source-id="depots"
:data="depots"
:layers="depotLayers"
cursor="pointer"
@mousemove="onMove"
@mouseleave="hoveredId = undefined"
/>
</template>Double Click
A double click also runs MapLibre's double-click zoom. Call event.preventDefault() in the dblclick handler to stop it.
MapLibre stops every camera move that starts inside its own event handler. To replace the zoom with your own move, start it on the next tick:
function onDoubleClick(event: MapLayerMouseEvent) {
event.preventDefault()
nextTick(() => mapRef.value?.easeTo({ center: event.lngLat, zoom: 14 }))
}Restore Feature State
A style swap removes every source, and MapLibre clears feature state with it. The component re-adds its source and layers, then emits sourceready. It also emits sourceready after the first add and after every rebuild.
Restore feature state in that handler. The map's styleload emit fires before the source is back, so it is too early.
<script setup lang="ts">
import type { ScriptMapLibreGeoJsonEmits } from '@nuxt/scripts'
function onSourceReady({ map, sourceId }: ScriptMapLibreGeoJsonEmits['sourceready'][0]) {
if (selectedId.value !== undefined)
map.setFeatureState({ source: sourceId, id: selectedId.value }, { selected: true })
}
</script>
<template>
<ScriptMapLibreGeoJson
source-id="depots"
:data="depots"
:layers="depotLayers"
@sourceready="onSourceReady"
/>
</template>Errors
The error emit reports a source or layer this component could not apply.
MapLibre does not throw for an invalid paint, layout or filter value. It skips the value and fires an error event on the map. The component catches that event and emits it, so a typo in an expression does not leave a silent blank layer.
- If the first add or a rebuild fails, the component removes its own source and layers.
- If a paint, layout or filter update fails, the layers stay on the map with their last valid values.
- If a cluster option update fails, the source stays on the map. The next option change rebuilds it.
- The component emits only errors for its own source and layers. Basemap, tile and other layer errors reach the
erroremit on<ScriptMapLibreMap>.
<ScriptMapLibreGeoJson
source-id="depots"
:data="depots"
:layers="depotLayers"
@error="error => layerError = error.message"
/>A validation message names the value, for example layers.depot-point.paint.circle-color: color expected, array found.
Remote fetching is intentionally outside the component. Fetch and validate data with useFetch, then pass the resulting object to data so loading and failure states remain explicit.
Source Options
sourceOptions is the MapLibre GeoJSON source specification without type and data. The component supplies those two. The rest are yours:
| Option | Type | What it does |
|---|---|---|
cluster | boolean | Groups nearby points into cluster features. |
clusterRadius | number | Cluster radius in pixels. Defaults to 50. |
clusterMaxZoom | number | Highest zoom that still clusters. Defaults to one below maxzoom. |
clusterMinPoints | number | Points needed to form a cluster. Defaults to 2. |
clusterProperties | object | Aggregated properties on each cluster feature. |
promoteId | string | object | Property to use as the feature ID for feature state. |
generateId | boolean | Assigns each feature an ID from its array index. The first ID is 0. Prefer promoteId for feature state. |
filter | FilterSpecification | Drops features before tiling. |
maxzoom | number | Highest zoom that still builds tiles. Defaults to 18. |
buffer | number | Tile buffer in pixels. Defaults to 128. |
tolerance | number | Simplification tolerance. Defaults to 0.375. |
lineMetrics | boolean | Required by line layers that use line-gradient. |
attribution | string | Attribution text shown for this source. |
Clustering
Set cluster: true in sourceOptions. MapLibre then adds cluster, cluster_id, point_count, and point_count_abbreviated to each cluster feature. Render clusters and single points with three layers:
- a
circlelayer filtered on['has', 'point_count']for the cluster bubble, - a
symbollayer filtered the same way for the count label, - a
circlelayer filtered on['!', ['has', 'point_count']]for unclustered points.
getClusterExpansionZoom returns the zoom at which a cluster splits. Read it from the source in a ready handler, then ease the camera there.
<script setup lang="ts">
import type { FeatureCollection, Point } from 'geojson'
import type {
CircleLayerSpecification,
GeoJSONSource,
Map as MapLibreMap,
SymbolLayerSpecification,
} from 'maplibre-gl'
import type { ShallowRef } from 'vue'
type DepotLayer = Omit<CircleLayerSpecification, 'source'> | Omit<SymbolLayerSpecification, 'source'>
const depots: FeatureCollection<Point> = {
type: 'FeatureCollection',
features: [
{ type: 'Feature', properties: { depotId: 'west-melbourne', parcels: 12 }, geometry: { type: 'Point', coordinates: [144.9495, -37.8101] } },
{ type: 'Feature', properties: { depotId: 'docklands', parcels: 4 }, geometry: { type: 'Point', coordinates: [144.9538, -37.8151] } },
{ type: 'Feature', properties: { depotId: 'southbank', parcels: 9 }, geometry: { type: 'Point', coordinates: [144.9632, -37.8227] } },
{ type: 'Feature', properties: { depotId: 'flinders-lane', parcels: 7 }, geometry: { type: 'Point', coordinates: [144.9687, -37.8154] } },
// ...more depots
],
}
const depotSourceOptions = {
cluster: true,
clusterRadius: 60,
clusterMaxZoom: 14,
clusterProperties: {
parcels: ['+', ['get', 'parcels']],
},
}
const depotLayers: DepotLayer[] = [
{
id: 'depot-clusters',
type: 'circle',
filter: ['has', 'point_count'],
paint: {
'circle-color': '#2563eb',
'circle-opacity': 0.85,
'circle-radius': ['step', ['get', 'point_count'], 16, 10, 22, 50, 30],
},
},
{
id: 'depot-cluster-count',
type: 'symbol',
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-font': ['Noto Sans Bold'],
'text-size': 12,
},
paint: { 'text-color': '#ffffff' },
},
{
id: 'depot-point',
type: 'circle',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': '#f97316',
'circle-radius': 7,
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 2,
},
},
]
function onMapReady({ map }: { map: ShallowRef<MapLibreMap | undefined> }) {
const instance = map.value
if (!instance)
return
instance.on('click', 'depot-clusters', async (event) => {
const feature = event.features?.[0]
const source = instance.getSource<GeoJSONSource>('depots')
if (!feature || !source)
return
const zoom = await source.getClusterExpansionZoom(feature.properties.cluster_id as number)
instance.easeTo({
center: (feature.geometry as Point).coordinates as [number, number],
zoom,
})
})
}
</script>
<template>
<ScriptMapLibreMap
:center="[144.9631, -37.8136]"
map-style="https://tiles.openfreemap.org/styles/liberty"
:zoom="11"
width="100%"
:height="480"
aria-label="Depot locations across Melbourne"
@ready="onMapReady"
>
<ScriptMapLibreGeoJson
source-id="depots"
:data="depots"
:source-options="depotSourceOptions"
:layers="depotLayers"
/>
</ScriptMapLibreMap>
</template>layers and source-options as constants. If you write either one inline in the template, every parent render passes a new array or object. The component then removes and re-adds the source. That discards the cluster index and flashes the map.text-font must name a fontstack the tile service serves. OpenFreeMap serves Noto Sans Regular, Noto Sans Bold, and Noto Sans Italic. If the glyph request fails, MapLibre draws the text with a local browser font instead. The label still renders, but in the wrong typeface. Check the console for Unable to load glyph range.Change Cluster Options
MapLibre can update three cluster options on a live source: cluster, clusterRadius and clusterMaxZoom. If only these options change, the component calls setClusterOptions(). The source keeps its data, and the layers stay on the map.
Any other sourceOptions change rebuilds the source. This includes clusterMinPoints and clusterProperties. It also includes removing clusterRadius or clusterMaxZoom, because MapLibre keeps the old value when the option is missing.
<script setup lang="ts">
const radius = ref(60)
const depotSourceOptions = computed(() => ({ cluster: true, clusterRadius: radius.value }))
</script>
<template>
<input v-model.number="radius" type="range" min="10" max="120">
<ScriptMapLibreMap map-style="https://tiles.openfreemap.org/styles/liberty" :center="[144.9631, -37.8136]">
<ScriptMapLibreGeoJson
source-id="depots"
:data="depots"
:source-options="depotSourceOptions"
:layers="depotLayers"
/>
</ScriptMapLibreMap>
</template>MapLibre applies the new options in a worker, so the update finishes later. MapLibre runs one worker update at a time, and the component tracks which operation each running update carries. If a cluster update fails, the component emits error once. The next option change then rebuilds the source. If a newer change supersedes an update, the component ignores the failure of the older update. A failure of another operation, like a data change, keeps its own error emit.
Cluster inspection lives on the source. getClusterExpansionZoom, getClusterChildren, and getClusterLeaves all need the raw map. Raw Map Instance covers that, plus layer clicks, hover state, and the camera.
<ScriptMapLibreNavigationControl>
Adds MapLibre's navigation control to the nearest parent map. Use options to choose whether zoom, compass, and pitch visualization controls are shown.
useScriptMapLibre
Use useScriptMapLibre() when you need direct access to the MapLibre namespace or are building your own map component.