addImage)Two ways to put a custom marker on the map:
mapboxgl.Marker — a DOM element pinned to a lng/lat. Fine for a handful of
markers; doesn't cluster, doesn't batch, repaints in the DOM. Avoid for many points.map.addImage (this file) — register an image once, reference
it by name from a symbol layer's icon-image. Batches, clusters, GPU-drawn,
data-driven. Use this for anything beyond a few markers.Mapbox base styles ship sprite icons literally named parking, toilet, circle,
etc. map.hasImage("parking") returns true for the style's sprite even though
you never added it. If you guard addImage with that check, your icon is never
registered and the symbol layer silently falls back to the style's plain glyph.
Always prefix your names so they can't collide:
const PIN = g => "rcpin-" + g; // "rcpin-camera", "rcpin-parking", …
if (!map.hasImage(PIN(glyph))) map.addImage(PIN(glyph), bmp, {pixelRatio: 4});
A marker drawn from an <svg>/<canvas> has straight (non-premultiplied) alpha
at its anti-aliased edge. Feeding Mapbox a raw HTMLImageElement or ImageData
makes it bleed a white halo / fringe around every curved edge.
createImageBitmap() produces a premultiplied-alpha bitmap → clean edges. Fall
back to the raw element only if createImageBitmap throws:
const img = new Image(W, H);
img.onload = async () => {
try {
const bmp = await createImageBitmap(img); // premultiplied alpha → no fringe
if (map.hasImage(name)) map.updateImage(name, bmp);
else map.addImage(name, bmp, {pixelRatio});
} catch (e) {
if (!map.hasImage(name)) map.addImage(name, img, {pixelRatio}); // fallback
}
};
img.src = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svg)));
For a <canvas> source, createImageBitmap(canvas) works the same way.
To recolour a marker (e.g. a colour-picker in a dev panel), re-render the SVG/canvas
with the new colour and call map.updateImage(name, bmp) — same name, no layer
churn, the symbol layer repaints automatically. Rebuild all affected names:
function setPoiColor(c) { poiColor = c; buildAllPins(map, /*force*/ true); }
// addPin(): if hasImage → updateImage(name, bmp) else addImage(name, bmp, …)
To crop a photo into a disc, never use ctx.clip() — clip is a 1-bit hard mask
and leaves a jagged circle edge. Mask with an anti-aliased arc fill via
globalCompositeOperation = "destination-in" on a scratch canvas:
// scratch canvas sized to the photo disc
const sc = document.createElement("canvas"); sc.width = sc.height = d;
const sx = sc.getContext("2d");
sx.imageSmoothingEnabled = true; sx.imageSmoothingQuality = "high";
// cover-fit the photo
const scale = Math.max(d/iw, d/ih);
sx.drawImage(img, (d-iw*scale)/2, (d-ih*scale)/2, iw*scale, ih*scale);
// AA circular mask: keep only pixels inside the arc
sx.globalCompositeOperation = "destination-in";
sx.beginPath(); sx.arc(d/2, d/2, d/2, 0, 2*Math.PI); sx.fill();
// draw the masked disc into the marker canvas
ctx.drawImage(sc, cx - r, cy - r, 2*r, 2*r);
Full marker (frame + contact shadow + drop shadow + sheen + masked photo) in ../assets/circular_image_marker.js — copy-paste starter code, adapt the colour/box constants.
Same-origin caveat: if the photo is fetched cross-origin the canvas is tainted
and createImageBitmap/toDataURL throw. Serve the page and images from the same
origin, or set img.crossOrigin = "anonymous" and serve images with CORS headers.
Mapbox does not mipmap the icon atlas. A raster icon minified at low zoom aliases/shimmers — and thin, high-contrast features (a 1–2px white outline or photo rim) shimmer worst. Mitigations:
"icon-opacity": ["interpolate",["linear"],["zoom"], 11,0, 12,1].Render the SVG/canvas at a high DPI (4×–6×) and register with the matching
{pixelRatio} so the icon is crisp on retina. But don't let icon-size scale the
icon past its native pixel size — upscaling a raster icon blurs it. Size the source
big enough that your max icon-size still maps ≤ 1.0 of native.
const F = 6, W = 44*F, H = 52*F; // draw at 6×
map.addImage(name, bmp, {pixelRatio: F}); // declare the 6×
// icon-size ramp stays ≤ ~1.5 of the *logical* box, well within native pixels
Data-driven icon-offset is silently ignored in GL JS v3. A ["get", …] or
["case", …] expression for icon-offset does nothing — no error, no offset. Two
working approaches:
icon-offset — it's a layout constant that scales with
icon-size, so one value works across zooms. Pair it with matched padding baked
into every icon of that layer.Split into separate symbol layers, each with its own constant anchor/offset. This is what to do when marker families need different anchors. Example: glyph badges centred on the point vs photo bubbles whose tip sits on the point —
// glyph badges: disc centred on the point
map.addLayer({ id:"poi-glyph", type:"symbol", source:"pois",
filter:["all",["!",["has","point_count"]],["!=",["get","_kind"],"photo"]],
layout:{ "icon-image":["get","_iconimg"], "icon-anchor":"center",
"icon-size":[/* zoom ramp */], "icon-allow-overlap":true }});
// photo bubbles: padded tip lands on the point.
// icon drawn in a 44×52 box with the tip at y=44 → 8 units of shadow padding below.
// icon-anchor:"bottom" + constant icon-offset:[0,8] (× icon-size) cancels that pad.
map.addLayer({ id:"poi-photo", type:"symbol", source:"pois",
filter:["all",["!",["has","point_count"]],["==",["get","_kind"],"photo"]],
layout:{ "icon-image":["get","_iconimg"], "icon-anchor":"bottom",
"icon-offset":[0,8], "icon-size":[/* zoom ramp */],
"icon-allow-overlap":true }});
Rule of thumb for landing a padded marker's tip on the point: icon-anchor:"bottom"
icon-offset:[0, padUnits], where padUnits is the shadow padding drawn below the
tip (the offset is multiplied by icon-size, so it tracks zoom automatically).