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:

Get the data Pull OSM, project, or conservation layers into a repeatable local folder structure.
Connect the data Load those layers into Observable with `FileAttachment` or documented remote sources.
Render static views Use D3 or Plot when the page needs a stable figure.
Render dynamic maps Use MapLibre when exploration and inspection matter.

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:

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.