Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Optimizing Leaflet Marker Rendering with Canvas and Spatial Indexing

Tech May 8 3

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. Set shouldRedraw to false during 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...
    }
  }
});
Tags: Leaflet

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.