Overview
MapLibre GL JS is the dynamic browser-mapping part of this site’s toolchain. It should sit beside, not replace, static SVG figures and shell-based data preparation.
The four jobs stay distinct:
Why it matters
MapLibre is well suited to a static knowledge site because the page can keep a small application surface: one map container, a few data sources, and explicit layer logic that remains readable in source form.
When to use
Use MapLibre when:
- the reader needs to pan and inspect
- the layer stack is meaningful at multiple zoom levels
- basemap choice matters
- popup inspection adds analytical value
Prefer a static SVG map when the page is mostly explaining a method or preserving a stable figure.
Connect local data in Observable
const temaneExtent = [
[34.6, -22.45],
[35.6, -21.2]
];
const mapRoads = await FileAttachment("../../data/examples/temane-roads-local.geojson").json();
const mapWater = await FileAttachment("../../data/examples/temane-water.geojson").json();
const mapCoastline = await FileAttachment("../../data/examples/temane-coastline-local.geojson").json();
const mapPowerLines = await FileAttachment("../../data/examples/temane-power-lines-local.geojson").json();
const mapPowerPoints = await FileAttachment("../../data/examples/temane-power-points-local.geojson").json();
const mapAmenities = await FileAttachment("../../data/examples/temane-amenities-local.geojson").json();
const mapPlaceLabels = await FileAttachment("../../data/examples/temane-place-labels-local.geojson").json();
({
roads: mapRoads.features.length,
water: mapWater.features.length,
coastline: mapCoastline.features.length,
powerLines: mapPowerLines.features.length,
powerPoints: mapPowerPoints.features.length,
amenities: mapAmenities.features.length,
places: mapPlaceLabels.features.length
})
Live MapLibre browser map
const visibleMapLibreLayers = view(Inputs.checkbox(
["Water", "Roads", "Power lines", "Power points", "Amenities", "Places"],
{label: "Visible layers", value: ["Water", "Roads", "Power lines", "Power points", "Amenities", "Places"]}
));
const maplibreContainer = html`<div class="dynamic-map-frame"><div class="dynamic-map" style="height: 560px"></div></div>`;
const mapTarget = maplibreContainer.firstElementChild;
const map = new maplibregl.Map({
container: mapTarget,
style: "https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json",
center: [35.1, -21.75],
zoom: 9.8,
attributionControl: true,
maplibreLogo: true
});
map.addControl(new maplibregl.NavigationControl(), "top-right");
map.on("load", () => {
map.addSource("water", {type: "geojson", data: mapWater});
map.addSource("coastline", {type: "geojson", data: mapCoastline});
map.addSource("roads", {type: "geojson", data: mapRoads});
map.addSource("power-lines", {type: "geojson", data: mapPowerLines});
map.addSource("power-points", {type: "geojson", data: mapPowerPoints});
map.addSource("amenities", {type: "geojson", data: mapAmenities});
map.addSource("places", {type: "geojson", data: mapPlaceLabels});
map.addLayer({
id: "water-fill",
type: "fill",
source: "water",
layout: {visibility: visibleMapLibreLayers.includes("Water") ? "visible" : "none"},
paint: {"fill-color": "#4b90b3", "fill-opacity": 0.26}
});
map.addLayer({
id: "coastline-line",
type: "line",
source: "coastline",
layout: {visibility: visibleMapLibreLayers.includes("Water") ? "visible" : "none"},
paint: {"line-color": "#86c0db", "line-width": 1.4, "line-opacity": 0.95}
});
map.addLayer({
id: "roads-line",
type: "line",
source: "roads",
layout: {visibility: visibleMapLibreLayers.includes("Roads") ? "visible" : "none"},
paint: {"line-color": "#d7b97a", "line-width": 1.1, "line-opacity": 0.78}
});
map.addLayer({
id: "power-lines-line",
type: "line",
source: "power-lines",
layout: {visibility: visibleMapLibreLayers.includes("Power lines") ? "visible" : "none"},
paint: {"line-color": "#9ec3a7", "line-width": 2, "line-opacity": 0.88}
});
map.addLayer({
id: "power-points-circle",
type: "circle",
source: "power-points",
layout: {visibility: visibleMapLibreLayers.includes("Power points") ? "visible" : "none"},
paint: {
"circle-color": "#f1d391",
"circle-radius": 3.6,
"circle-stroke-color": "#14211f",
"circle-stroke-width": 0.6
}
});
map.addLayer({
id: "amenities-circle",
type: "circle",
source: "amenities",
layout: {visibility: visibleMapLibreLayers.includes("Amenities") ? "visible" : "none"},
paint: {
"circle-color": "#e08bb6",
"circle-radius": 5,
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 1
}
});
map.addLayer({
id: "places-circle",
type: "circle",
source: "places",
layout: {visibility: visibleMapLibreLayers.includes("Places") ? "visible" : "none"},
paint: {
"circle-color": "#f4f4ee",
"circle-radius": 4.4,
"circle-stroke-color": "#182220",
"circle-stroke-width": 1
}
});
map.addLayer({
id: "places-label",
type: "symbol",
source: "places",
layout: {
visibility: visibleMapLibreLayers.includes("Places") ? "visible" : "none",
"text-field": ["coalesce", ["get", "name"], ""],
"text-size": 12,
"text-offset": [0.8, 0],
"text-anchor": "left"
},
paint: {
"text-color": "#f4f4ee",
"text-halo-color": "#101716",
"text-halo-width": 1.2
}
});
const popupLayers = ["power-points-circle", "amenities-circle", "places-circle"];
for (const layerId of popupLayers) {
map.on("mouseenter", layerId, () => {
map.getCanvas().style.cursor = "pointer";
});
map.on("mouseleave", layerId, () => {
map.getCanvas().style.cursor = "";
});
}
map.on("click", "power-points-circle", (event) => {
const feature = event.features?.[0];
if (!feature) return;
new maplibregl.Popup()
.setLngLat(event.lngLat)
.setHTML(
popupTable([
{label: "Layer", value: "Power"},
{label: "Power tag", value: feature.properties?.power},
{label: "Name", value: feature.properties?.name},
{label: "OSM id", value: feature.properties?.["@id"]}
])
)
.addTo(map);
});
map.on("click", "amenities-circle", (event) => {
const feature = event.features?.[0];
if (!feature) return;
new maplibregl.Popup()
.setLngLat(event.lngLat)
.setHTML(
popupTable([
{label: "Amenity", value: feature.properties?.amenity},
{label: "Name", value: feature.properties?.name ?? feature.properties?.religion},
{label: "OSM id", value: feature.properties?.["@id"]}
])
)
.addTo(map);
});
map.on("click", "places-circle", (event) => {
const feature = event.features?.[0];
if (!feature) return;
new maplibregl.Popup()
.setLngLat(event.lngLat)
.setHTML(
popupTable([
{label: "Place", value: feature.properties?.name},
{label: "Class", value: feature.properties?.place},
{label: "Source", value: "Temane folio / OSM-derived places"}
])
)
.addTo(map);
});
map.fitBounds(temaneExtent, {padding: 30, duration: 0});
});
invalidation.then(() => map.remove());
maplibreContainer
Source and layer pattern
The important MapLibre lesson is not just “show a map.” It is to keep the layer model readable.
const sourcePattern = {
water: {type: "geojson", data: "temane-water.geojson"},
coastline: {type: "geojson", data: "temane-coastline-local.geojson"},
roads: {type: "geojson", data: "temane-roads-local.geojson"},
"power-lines": {type: "geojson", data: "temane-power-lines-local.geojson"},
"power-points": {type: "geojson", data: "temane-power-points-local.geojson"},
amenities: {type: "geojson", data: "temane-amenities-local.geojson"},
places: {type: "geojson", data: "temane-place-labels-local.geojson"}
};
sourcePattern
Static SVG counterpart
The same local sources can also be rendered as a stable figure. That is still the better pattern for method-heavy pages and long-form documentation.
renderGeojsonMap({
title: "Static D3-rendered Temane source stack",
subtitle: "The same local layers rendered as an SVG figure for documentation and report-friendly pages.",
width: 820,
height: 520,
extent: temaneExtent,
layers: [
{data: mapWater, fill: "#4b90b3", stroke: "none", opacity: 0.26},
{data: mapCoastline, stroke: "#86c0db", strokeWidth: 1.4, opacity: 0.95},
{data: mapRoads, stroke: "#d7b97a", strokeWidth: 0.8, opacity: 0.72},
{data: mapPowerLines, stroke: "#9ec3a7", strokeWidth: 1.8, opacity: 0.88},
{data: mapPowerPoints, fill: "#f1d391", stroke: "#14211f", pointRadius: 1.8, strokeWidth: 0.3, opacity: 0.85},
{data: mapAmenities, fill: "#e08bb6", stroke: "#ffffff", pointRadius: 4.2, strokeWidth: 0.8},
{
data: mapPlaceLabels,
fill: "#f4f4ee",
stroke: "#182220",
pointRadius: 4.4,
strokeWidth: 1,
labels: (feature) => feature.properties?.name,
labelDx: 8,
labelDy: -8,
labelSize: 13
}
]
})
Local and open execution model
Use a local open workflow when you want the page to be reproducible:
node scripts/temane/export_temane_folio_layers.mjs
npm run dev
That pattern keeps the analytical layers local, the browser map open-source, and the static site deployable without a proprietary backend.
Limitations
MapLibre still depends on a basemap source unless you package your own tiles. It also does not fix weak input data. It simply makes layer quality, schema quality, and popup choices much more visible.