Optimizing Leaflet Marker Rendering with Canvas and Spatial Indexing
Rendering thousands of DOM-based markers in Leaflet severely degrades browser performance due to excessive node creation, layout thrashing, and event listener overhead. By shifting the rendering pipeline to a single HTML5 Canvas element and leveraging spatial indexing for viewport culling, map interactions remain smooth even with datasets exceeding 100,000 points. This approach replaces individual DOM nodes with batched canvas draw calls, significantly reducing memory consumption and reflow calculations.
Layer Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
enableCollision |
boolean |
false |
Toggles bounding-box collision detection. When active, overlapping markers are culled to prevent visual clutter. |
refreshOnPan |
boolean |
false |
Forces a canvas redraw during map panning events rather than waiting for moveend. |
baseZIndex |
number |
null |
Sets the default stacking order for the canvas container within the map pane. |
layerOpacity |
number |
1 |
Controls the global alpha value for the canvas layer (range: 0.0 to 1.0). |
customDrawCallback |
function |
null |
Overrides default icon rendering. Receives (layerContext, markerRef, screenCoords, dimensions). |
Extended Marker & Icon Properties
zIndex(number): Overrides the rendering order for individual markers during the draw cycle.rotationAngle(number): Applies a radian-based rotation to the icon during the canvas draw phase (e.g.,Math.PI / 2).
API Methods
addLayer(marker): Inserts a single marker in to the spatial index.addLayers(markerArray): Batch-inserts multiple markers. Optimized for bulk operations to minimize endex rebuilds.removeLayer(marker, shouldRedraw): Extracts a marker from the index. SetshouldRedrawtofalseduring batch removals to defer repainting.redraw(): Forces a complete canvas repaint and spatial recalculation.addOnClickListener(handler): Attaches a global click callback. Handlers receive(nativeEvent, hitMarkers).addOnHoverListener(handler): Attaches a global mousemove callback for hover detection.addOnMouseDownListener(handler): Attaches a global mousedown callback.addOnMouseUpListener(handler): Attaches a global mouseup callback.
Usage Example
import L from 'leaflet';
export function initializeBulkMarkerRenderer(mapInstance) {
let canvasOverlay = null;
const renderDataset = () => {
console.time('canvas-batch-render');
canvasOverlay = L.canvasMarkerLayer({
enableCollision: false,
refreshOnPan: false,
customDrawCallback: (layerCtx, markerRef, screenCoords, dimensions) => {
const ctx = layerCtx._ctx;
ctx.beginPath();
ctx.arc(screenCoords.x, screenCoords.y, dimensions[0] / 2, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255, 50, 0, 0.4)';
ctx.fill();
ctx.closePath();
}
}).addTo(mapInstance);
const markerCollection = [];
for (let idx = 0; idx < 80000; idx++) {
const lat = 39.26203 + Math.random() * 1.2;
const lng = 122.58546 + Math.random() * 2.0;
const pointMarker = L.marker([lat, lng], {
icon: L.icon({
iconSize: [20, 20],
iconAnchor: [10, 9]
}),
zIndex: 2,
riseOnHover: true
});
pointMarker.bindTooltip(`Dynamic Point #${idx}`, {
permanent: false,
direction: 'top'
});
markerCollection.push(pointMarker);
}
canvasOverlay.addLayers(markerCollection);
console.timeEnd('canvas-batch-render');
canvasOverlay.addOnClickListener((evt, hitData) => console.log('Click:', hitData));
canvasOverlay.addOnHoverListener((evt, hitData) => console.log('Hover:', hitData[0]?.data));
};
return { renderDataset };
}
Core Implementation Structure
import RBush from 'rbush';
/**
* @typedef {Object} SpatialMarker
* @property {number} minX - Longitude
* @property {number} minY - Latitude
* @property {number} maxX - Longitude
* @property {number} maxY - Latitude
* @property {L.Marker} data - Leaflet marker instance
*/
/**
* @typedef {Object} PixelBounds
* @property {number} minX - Top-left X
* @property {number} minY - Top-left Y
* @property {number} maxX - Bottom-right X
* @property {number} maxY - Bottom-right Y
*/
export const CanvasMarkerLayer = L.Layer.extend({
options: {
baseZIndex: null,
enableCollision: false,
refreshOnPan: false,
layerOpacity: 1,
customDrawCallback: null
},
initialize(config) {
L.setOptions(this, config);
this._clickHandlers = [];
this._hoverHandlers = [];
this._downHandlers = [];
this._upHandlers = [];
this._globalIndex = new RBush();
this._globalIndex._pendingUpdates = 0;
this._globalIndex._totalEntries = 0;
this._viewportIndex = new RBush();
this._visibleMarkers = new RBush();
this._visibleBounds = new RBush();
},
setOptions(config) {
L.setOptions(this, config);
return this.redraw();
},
redraw() {
return this._repaint(true);
},
getEvents() {
const bindings = {
viewreset: this._reposition,
zoom: this._handleZoom,
moveend: this._reposition,
click: this._triggerHandlers,
mousemove: this._triggerHandlers,
mousedown: this._triggerHandlers,
mouseup: this._triggerHandlers
};
if (this._zoomAnimated) bindings.zoomanim = this._handleAnimatedZoom;
if (this.options.refreshOnPan) bindings.move = this._reposition;
return bindings;
},
addLayer(marker, autoRepaint = true) {
if (!marker.options.icon) {
console.warn('Invalid marker: missing icon configuration');
return this;
}
marker._map = this._map;
const coords = marker.getLatLng();
L.Util.stamp(marker);
this._globalIndex.insert({
minX: coords.lng, minY: coords.lat,
maxX: coords.lng, maxY: coords.lat,
data: marker
});
this._globalIndex._pendingUpdates++;
this._globalIndex._totalEntries++;
const inView = this._map.getBounds().contains(coords);
if (autoRepaint && inView) this._repaint(true);
return this;
},
addLayers(markerList, autoRepaint = true) {
console.time('bulk-canvas-insert');
markerList.forEach(m => this.addLayer(m, false));
if (autoRepaint) this._repaint(true);
console.timeEnd('bulk-canvas-insert');
return this;
},
removeLayer(marker, autoRepaint = true) {
const target = marker.minX ? marker.data : marker;
const coords = target.getLatLng();
const inView = this._map.getBounds().contains(coords);
this._globalIndex.remove({
minX: coords.lng, minY: coords.lat,
maxX: coords.lng, maxY: coords.lat,
data: target
}, (a, b) => a.data._leaflet_id === b.data._leaflet_id);
this._globalIndex._totalEntries--;
this._globalIndex._pendingUpdates++;
if (inView && autoRepaint) this._repaint(true);
return this;
},
clearLayers() {
this._globalIndex = new RBush();
this._globalIndex._pendingUpdates = 0;
this._globalIndex._totalEntries = 0;
this._viewportIndex = new RBush();
this._visibleMarkers = new RBush();
this._visibleBounds = new RBush();
this._repaint(true);
},
onAdd(mapInstance) {
this._map = mapInstance;
if (!this._container) this._setupCanvas();
const targetPane = this.options.pane ? this.getPane() : mapInstance._panes.overlayPane;
targetPane.appendChild(this._container);
this._reposition();
},
onRemove(mapInstance) {
const targetPane = this.options.pane ? this.getPane() : mapInstance.getPanes().overlayPane;
targetPane.removeChild(this._container);
},
_renderSingleMarker(markerRef, screenPoint) {
if (!this._imgCache) this._imgCache = {};
const pt = screenPoint || this._map.latLngToContainerPoint(markerRef.getLatLng());
const iconCfg = markerRef.options.icon.options;
const bounds = {
minX: pt.x - (iconCfg.iconAnchor?.[0] || 0),
maxX: pt.x - (iconCfg.iconAnchor?.[0] || 0) + iconCfg.iconSize[0],
minY: pt.y - (iconCfg.iconAnchor?.[1] || 0),
maxY: pt.y - (iconCfg.iconAnchor?.[1] || 0) + iconCfg.iconSize[1]
};
if (this.options.enableCollision) {
if (this._visibleBounds.collides(bounds)) return;
this._visibleBounds.insert(bounds);
const ll = markerRef.getLatLng();
this._visibleMarkers.insert({ ...bounds, lng: ll.lng, lat: ll.lat, data: markerRef });
}
if (iconCfg.iconUrl) {
// Image loading and canvas drawImage logic continues here...
}
}
});