This guide covers drawing multivariate glyphs on top of morphing geometries for both Leaflet and MapLibre, including renderer options, dynamic sizing, data access, and performance.
Glyphs are per-feature visual overlays (markers) that you render using your own HTML, SVG, Canvas, or library-generated DOM. The adapter keeps glyphs positioned and synchronized with the chosen geometry (regular, cartogram, or interpolated).
import { createLeafletGlyphLayer } from "geo-morpher";
const glyph = await createLeafletGlyphLayer({
morpher,
L,
map,
geometry: "interpolated",
morphFactor: 0.25,
drawGlyph: ({ data }) => ({
html: `<div class="dot">${data?.population ?? ""}</div>`,
className: "geomorpher-glyph",
iconSize: [48, 48],
iconAnchor: [24, 24],
}),
});
glyph.updateGlyphs({ morphFactor: 0.75 });
import maplibregl from "maplibre-gl";
import { createMapLibreGlyphLayer } from "geo-morpher";
const glyph = await createMapLibreGlyphLayer({
morpher,
map,
maplibreNamespace: maplibregl, // pass explicitly when not on globalThis
geometry: "cartogram",
drawGlyph: ({ data }) => ({
html: `<div class="badge">${data?.population ?? ""}</div>`,
className: "geomorpher-glyph",
iconSize: [40, 40],
iconAnchor: [20, 20],
markerOptions: { rotationAlignment: "map" },
}),
});
glyph.updateGlyphs({ geometry: "interpolated", morphFactor: 0.5 });
You can render glyphs from an arbitrary FeatureCollection or by passing a provider function that returns a collection based on the requested geometry and morph factor:
// Static collection
const glyphStatic = await createLeafletGlyphLayer({
drawGlyph,
L,
featureCollection: morpher.getRegularFeatureCollection(),
});
// Provider based on interpolated geometry
const glyphDynamic = await createMapLibreGlyphLayer({
map,
maplibreNamespace: maplibregl,
drawGlyph,
featureProvider: ({ geometry, morphFactor }) => morpher.getInterpolatedFeatureCollection(morphFactor),
});
// Update both independently
glyphStatic.updateGlyphs({ morphFactor: 0.5 });
glyphDynamic.updateGlyphs({ morphFactor: 0.5 });
createLeafletGlyphLayer({ morpher, L, map?, geometry='interpolated', morphFactor=0, drawGlyph, getFeatureId?, getGlyphData?, filterFeature?, markerOptions?, pane?, scaleWithZoom=false, featureProvider?, featureCollection? })
layer: L.LayerGroupfeatureProvider (dynamic) or featureCollection (static) as an alternative source of features when morpher is not used.updateGlyphs({ geometry?, morphFactor? })clear()getState(): { geometry, morphFactor, markerCount, scaleWithZoom }destroy()drawGlyph(context) may return:
null to skipHTMLElement{ html?, className?, iconSize?, iconAnchor?, pane?, markerOptions?, divIconOptions? }{ icon: L.Icon } (full control)Context fields: feature, featureId, data, morpher, geometry, morphFactor, zoom, featureBounds?
createMapLibreGlyphLayer({ morpher, map, drawGlyph, morphFactor=0, geometry='interpolated', getFeatureId?, getGlyphData?, filterFeature?, markerOptions?, scaleWithZoom=false, maplibreNamespace?, featureProvider?, featureCollection? })
updateGlyphs({ geometry?, morphFactor?, featureProvider?, featureCollection? }) (optionally override where the controller sources its features each update)clear()getState(): { geometry, morphFactor, markerCount, scaleWithZoom }destroy()drawGlyph(context) may return:
null to skipHTMLElement{ html?, element?, className?, iconSize?, iconAnchor?, markerOptions? }Context fields: feature, featureId, data, morpher, geometry, morphFactor, map, zoom, featureBounds?
Notes:
maplibreNamespace (e.g., maplibregl) in module-bundled builds where it is not on globalThis.setOffset, setRotation, setPitchAlignment, setRotationAlignment). Others must be encoded in your DOM.featureCollection to provide a static set of features to render glyphs for,
or featureProvider({ geometry, morphFactor }) to dynamically supply an arbitrary feature collection
(for example, an interpolated collection or an external dataset). If neither is supplied, the
adapters default to calling resolveCollection with a GeoMorpher instance passed via morpher.toDataURL() or return a prebuilt element.{ icon: L.icon({...}) } for full control.const drawPie = ({ data, feature }) => {
const p = (data?.data?.properties ?? feature.properties ?? {});
const values = [p.population, p.households].map(v => Number(v ?? 0));
if (!values.some(v => v > 0)) return null;
const total = values.reduce((a,b)=>a+b,0) || 1;
let acc = 0;
const slices = values.map((v,i)=>{
const start = (acc/total)*2*Math.PI; acc+=v; const end=(acc/total)*2*Math.PI;
const large = end-start > Math.PI ? 1 : 0;
const r = 26, cx=26, cy=26;
const x1 = cx + r*Math.cos(start), y1 = cy + r*Math.sin(start);
const x2 = cx + r*Math.cos(end), y2 = cy + r*Math.sin(end);
const colors = ["#4e79a7", "#f28e2c"];
return `<path d="M${cx},${cy} L${x1},${y1} A${r},${r} 0 ${large},1 ${x2},${y2} Z" fill="${colors[i]}"/>`;
}).join("");
return { html: `<svg width="52" height="52">${slices}</svg>`, iconSize:[52,52], iconAnchor:[26,26] };
};
const drawBars = ({ data }) => {
const vals = [data?.population ?? 0, data?.households ?? 0].map(Number);
const bars = vals.map((v,i)=>`<rect x="${i*20}" y="${60-v}" width="15" height="${v}" fill="steelblue"/>`).join("");
return { html: `<svg width="60" height="60">${bars}</svg>`, iconSize:[60,60], iconAnchor:[30,30] };
};
const drawSpark = ({ data }) => {
const canvas = document.createElement("canvas");
canvas.width = 80; canvas.height = 40;
const ctx = canvas.getContext("2d");
const values = data?.timeSeries ?? [];
ctx.strokeStyle = "#4e79a7"; ctx.lineWidth = 2; ctx.beginPath();
values.forEach((v,i)=>{const x=(i/(values.length-1))*80; const y=40-(v*40); i?ctx.lineTo(x,y):ctx.moveTo(x,y);});
ctx.stroke();
return { html: `<img src="${canvas.toDataURL()}"/>`, iconSize:[80,40], iconAnchor:[40,20] };
};
import * as d3 from "d3";
const drawD3 = ({ data }) => {
const div = document.createElement("div");
div.style.width = "80px"; div.style.height = "80px";
const svg = d3.select(div).append("svg").attr("width",80).attr("height",80);
svg.append("circle").attr("cx",40).attr("cy",40).attr("r",20).attr("fill","#f28e2c");
return div; // HTMLElement is accepted by both adapters
};
const drawIcon = () => ({
icon: L.icon({ iconUrl: "/markers/type.png", iconSize:[32,32], iconAnchor:[16,32] })
});
Enable scaleWithZoom: true to recalculate glyphs as users zoom. When enabled, featureBounds provides pixel width, height, and center for the current feature at the current zoom.
const glyph = await createLeafletGlyphLayer({
morpher, L, map,
geometry: "interpolated",
scaleWithZoom: true,
drawGlyph: ({ data, featureBounds }) => {
if (!featureBounds) return null;
const { width, height } = featureBounds;
const grid = 10;
const cell = Math.min(width, height) / grid;
const fillRatio = (data?.value ?? 0)/(data?.max ?? 1);
const filled = Math.floor(grid*grid*fillRatio);
const cells = [];
for (let i=0;i<grid;i++) for (let j=0;j<grid;j++) {
const idx = i*grid+j;
const on = idx < filled;
cells.push(`<rect x="${j*cell}" y="${i*cell}" width="${cell}" height="${cell}" fill="${on?"#4e79a7":"#e0e0e0"}"/>`);
}
return { html: `<svg width="${width}" height="${height}">${cells.join("")}</svg>`, iconSize:[width,height], iconAnchor:[width/2,height/2] };
},
});
const glyph = await createMapLibreGlyphLayer({
morpher, map, maplibreNamespace: maplibregl,
scaleWithZoom: true,
drawGlyph: ({ data, featureBounds }) => {
if (!featureBounds) return null;
const { width, height } = featureBounds;
const grid = 10;
const cell = Math.min(width, height) / grid;
const fillRatio = (data?.value ?? 0)/(data?.max ?? 1);
const filled = Math.floor(grid*grid*fillRatio);
const cells = [];
for (let i=0;i<grid;i++) for (let j=0;j<grid;j++) {
const idx = i*grid+j;
const on = idx < filled;
cells.push(`<rect x="${j*cell}" y="${i*cell}" width="${cell}" height="${cell}" fill="${on?"#4e79a7":"#e0e0e0"}"/>`);
}
return { html: `<svg width="${width}" height="${height}">${cells.join("")}</svg>`, iconSize:[width,height], iconAnchor:[width/2,height/2] };
},
});
Tips:
featureBounds and return null to skip tiny glyphs.scaleWithZoom: false and use constant iconSize/iconAnchor.By default, data is populated from morpher.getKeyData() keyed by featureId (defaults to feature.properties.code ?? feature.properties.id).
Customize data or visibility:
const glyph = await createLeafletGlyphLayer({
morpher, L, map,
getGlyphData: ({ featureId }) => externalStats[featureId],
filterFeature: ({ data }) => (data?.population ?? 0) > 0,
drawGlyph,
});
You can also change the geometry source at any time:
glyph.updateGlyphs({ geometry: "regular" });
glyph.updateGlyphs({ geometry: "interpolated", morphFactor: 0.6 });
pane to control z-index stacking of glyphs.markerOptions and divIconOptions pass through to Leaflet.{ icon: L.Icon } when you need total control over icon rendering.iconSize and iconAnchor translate to element size and computed offset.If maplibregl isn’t on globalThis, pass maplibreNamespace.
createLeafletIcon and createMapLibreMarkerData
if you need to convert the normalized drawGlyph result into platform-specific objects yourself.Example: using the shared normalization and helpers
import { normalizeRawGlyphResult } from 'geo-morpher/src/adapters/shared/glyphNormalizer.js';
import { createLeafletIcon } from 'geo-morpher/src/adapters/leaflet/index.js';
const normalized = normalizeRawGlyphResult({ result: { html: '<div>Hi</div>' } });
const icon = createLeafletIcon({ L, normalized, pane: 'glyphs' });
// icon is an L.divIcon instance you can use in tests or to create markers manually
drawGlyph returns compatible results.null from drawGlyph.CustomLayerInterface that batches drawing on the GPU; the marker-based approach is simple but not optimal at very high counts.morpher.prepare() has run; ensure featureId resolves and drawGlyph doesn’t return null.iconAnchor; for centered glyphs, use [width/2, height/2].scaleWithZoom: true and that your drawGlyph uses featureBounds.examples/leaflet/zoom-scaling-glyphs.html for a complete zoom-scaling demoREADME.md sections on glyphs for quick examplesGeoMorpher is prepared and that getKeyData() returns the metrics your glyphs will render.regular, cartogram, or interpolated) and whether the morph factor should influence styling (colours, numeric labels, etc.).drawGlyph with a focus on idempotency. The adapters call it frequently; returning a similar structure lets them reuse markers instead of tearing them down.getGlyphData to attach custom payloads.updateGlyphs together with updateMorphFactor so the overlay and geometry remain synchronized during animations or slider interactions.destroy() when removing them to clean up event listeners and markers.{ interactive: true } in markerOptions and attach mouseover/mouseout listeners on the returned L.Marker. In MapLibre, read marker.getElement() after creation and wire DOM events directly.drawGlyph (e.g., return { className: selected.has(featureId) ? "glyph glyph--selected" : "glyph" }). Remember to call updateGlyphs after state changes so markers refresh.role, aria-label, or keyboard handlers inside drawGlyph when building interactive overlays.updateMorphFactor.requestAnimationFrame), update the morph factor every frame but throttle glyph updates (e.g., every third frame) to balance smoothness and DOM churn.requestAnimationFrame within drawGlyph-generated components to avoid reconstructing DOM nodes on each tick.marker.getElement() and ensure it does not trigger layout thrashing (avoid querying layout-dependent properties during animation).drawGlyph. For example, precompute colour ramps or thresholds once per animation frame and reuse them.const drawGlyph = (context) => {
if (!context.data) {
console.warn("Missing glyph data", context.featureId, context);
return null;
}
// ...return glyph structure
};
context.feature.centroid. For MapLibre with scaleWithZoom, verify featureBounds.center matches expectations.map.latLngToContainerPoint can return stale values.drawGlyph outputs using a DOM testing library (e.g., @testing-library/dom) to ensure structural stability when refactoring.updateGlyphs, and assert the number of markers created for given filters.typeof window !== "undefined").filterFeature guard that drops low-importance features on slower hardware.Use this guide as a living reference while iterating on overlay designs. Combine it with docs/api.md for morpher lifecycle details and the examples directory for working end-to-end implementations.