@oarer/leaflet-webgl-markers
v0.3.1
Published
Leaflet WebGL plugin for rendering one million+ (1M) point markers — GPU Mercator projection + FBO picking
Maintainers
Readme
leaflet-webgl-markers
Leaflet plugin for rendering one million+ (1M+) point markers on a single WebGL canvas: GPU Mercator projection + FBO picking + zero redraw while dragging/zooming.
Features
- Millions of markers: lat/lng lives in a vertex buffer and the Mercator
projection runs in the vertex shader. Dragging/zooming never rebuilds the
buffer; the canvas follows via CSS transform with zero JS redraw and only
redraws once on
moveend. - FBO color-coded picking: O(1) picking with a 3x3 Gaussian-weighted neighborhood to handle transparent icon edges.
- Incremental buffer: add/remove/update write in place via
bufferSubData(append-only, holes are never reused); growth orcompact()compacts in order. - No built-in visuals: the layer only renders, picks, and notifies. Hover highlight, selection glow, etc. are implemented in your event handlers.
- Coexists with native Leaflet layers: the canvas uses
pointer-events: noneplus map event delegation, and interaction events fire only when a marker is actually hit.
Why this plugin
Leaflet's built-in ways of drawing markers each hit a ceiling as data grows:
| Approach | Scale | Drag/zoom behavior | Picking |
|------|------|------|------|
| L.Marker (DOM) | hundreds to low thousands | native, but every marker is a DOM node | native DOM events |
| L.Canvas renderer | tens of thousands of vector shapes | CSS transform while moving; one CPU redraw of all paths on moveend | geometry-based |
| leaflet-webgl-markers | millions of points | CSS transform while moving, one redraw on moveend | FBO color-coded, O(1) |
This plugin moves projection into the vertex shader and keeps lat/lng in GPU buffers: 1M markers do not become 1M DOM nodes, and dragging does not re-project anything on the CPU. The trade-off is that it needs WebGL 1.0 and draws points from a single shared texture — it does not render arbitrary DOM content, so use it for large-scale point visualization rather than a handful of custom interactive DOM markers.
Demo
Live demo: https://tang-tc.github.io/leaflet-webgl-markers/ (1M markers, airports, flights, and an earthquake timeline).
Install
npm install leaflet-webgl-markersRequirements
- Leaflet 1.9.x (
^1.9.0, install it yourself as a peer dependency) - WebGL 1.0 with hardware acceleration enabled (the layer creates a
webglcontext; failures fire anerrorevent withstage: 'context') - No other runtime dependencies;
@types/leafletis shipped as a regular dependency so TypeScript users get the types automatically
Works in all modern browsers (Chrome, Edge, Firefox, Safari) on desktop and mobile devices. Environments without WebGL, or with hardware acceleration disabled, are not supported.
ESM-only: there is no
mainfield; you need a resolver that understands theexportsfield (modern bundlers).TypeScript:
moduleResolution: "bundler"or"nodenext"is recommended. If your tsconfig turnsskipLibCheckoff, enableesModuleInterop(orallowSyntheticDefaultImports) because the declarations use a default leaflet import.
Quick start
import L from 'leaflet'
import { WebGLMarker, WebGLMarkerLayer } from 'leaflet-webgl-markers'
const layer = new WebGLMarkerLayer({
iconSize: 38, // layer default icon size (CSS pixels)
textureUrl: '/airplane.png',
})
layer.addTo(map)
// Add markers
const m = new WebGLMarker({
latlng: [39.9, 116.4],
rotation: 0.5, // radians; 0 = north, positive = clockwise
color: [0.8, 0.3, 0.2], // RGB tint; [1, 1, 1] = no tint
size: 60, // optional; overrides layer iconSize (CSS pixels)
data: { flightId: 'CA1234' },
})
layer.addMarker(m)
// Events (pointer subset of Leaflet's interaction events: mouseover / mouseout / click / dblclick / contextmenu)
layer.on('click', (e) => {
/* Fires only when a marker is hit; e.marker is always non-null */
console.log(e.marker.data)
})
layer.on('mouseover', () => {
map.getContainer().style.cursor = 'pointer'
})
layer.on('mouseout', () => {
map.getContainer().style.cursor = ''
})Popup (optional submodule):
import { openMarkerPopup } from 'leaflet-webgl-markers/popup'
import 'leaflet-webgl-markers/popup.css'
const popup = openMarkerPopup(map, latlng, {
title: 'Flight',
rows: [['No.', '#123'], ['Latitude', '39.9']],
})
popup.close()API
WebGLMarker (pure data object)
new WebGLMarker(opts | [lat, lng])| Option | Type | Default | Description |
|------|------|------|------|
| latlng | L.LatLng \| [number, number] | — | Geographic position (required) |
| rotation | number | 0 | Rotation in radians; 0 = north, positive = clockwise |
| color | [number, number, number] | [1, 1, 1] | RGB tint; [1, 1, 1] = no tint |
| size | number \| null | null | Icon size in CSS pixels; null or <= 0 follows the layer iconSize |
| visible | boolean | true | When false, the vertex is pushed off screen: not rendered, not picked |
| opacity | number | 1 | Opacity 0..1 (clamped to [0, 1] before upload) |
| data | unknown | null | Application data; read via e.marker.data in event handlers |
- Markers are pure data objects: no EventEmitter, no DOM/GPU references; besides
latlng/datathey hold only ten scalar fields. - The readonly
idis unique per library copy and used only for identity/debugging; it does not participate in picking (picking uses layer-internal slots, see "Picking & multiple library copies"). - Markers carry no layer-private state (slots and selection live outside the marker), so one marker instance can safely join multiple layers.
WebGLMarkerLayer
Options
| Option | Type | Default | Description |
|------|------|------|------|
| iconSize | number | 40 | Layer default icon size (CSS pixels); fallback for marker.size |
| textureUrl | string | none | Icon texture URL; omit to use a built-in 1x1 white texture (solid-color squares) |
| capacityFactor | number | 1.2 | Buffer growth factor (>= 1). Larger = fewer rebuilds, more GPU memory |
Events
The event machinery reuses L.Evented (on / off / once / listens all work and
fire injects { type, target }). Every interaction event is
subscription-enabled — with no listeners there is no picking and no
readPixels cost. Interaction events do not bubble to the map by default (the
map already received the same DOM event, avoiding double firing). Parent event
propagation (addEventParent) is not wired and has been removed from the API.
Note this is a prototype mixin, not inheritance:
layer instanceof L.Evented === false (duck typing), so third-party tools that
rely on instanceof will not recognize it.
Interaction events (payload MarkerPointerEvent, aligned with Leaflet Path):
| Field | Type | Description |
|------|------|------|
| marker | WebGLMarker | The hit marker (always non-null) |
| latlng | L.LatLng | Pointer geographic position |
| containerPoint? | L.Point | Pointer position in container pixels |
| layerPoint? | L.Point | Pointer position in layer pixels |
| originalEvent? | MouseEvent | Original DOM event |
| reason? | 'move' \| 'remove' \| 'clear' | mouseout only: why the hover ended |
| Event | When it fires |
|------|----------|
| mouseover | Hover enters a marker |
| mouseout | Hover leaves / switches to another marker / pointer leaves the map container (reason:'move'); synthesized without originalEvent when the hovered marker is removed ('remove') or the data is cleared ('clear') |
| click | The point hits a marker; blank clicks are not layer semantics — listen to the map for those |
| dblclick | Double-click hits a marker |
| contextmenu | Right-click hits a marker |
mouseover / mouseout have transition semantics: moving within the same
marker does not re-fire, and a quick sweep in-and-out is never swallowed (rAF
frame coalescing). Hover is suspended automatically during drag/zoom animations
and resumes afterwards.
Lifecycle / ready / error events:
| Event | When it fires | Key payload |
|------|----------|--------------|
| add | Synchronously after addTo() mounts the canvas | — |
| remove | Synchronously on remove() (synthesizes mouseout first if hovering) | — |
| load | First successful render (GL ready + shaders compiled + texture loaded + first frame); fires again after a context-loss rebuild | — |
| error | Initialization / render failure; deduplicated per stage within one mount session | stage ('context' \| 'shader' \| 'texture' \| 'render'), message, error? |
on / off
layer.on('click', handler) // register
layer.on('click mouseover', handler) // space-separated event string
layer.on({ click: fn1, mouseover: fn2 }) // object form
layer.once('load', handler) // one-shot
layer.off('click', handler) // remove one handler
layer.off('click') // remove all handlers for an eventPicking (internal capability)
Picking is only the internal engine behind the interaction events — no public pick API:
mouseover / mouseout / click / dblclick / contextmenuhit-testing all goes through an internal_pick(containerPoint)that reads an atomically published snapshot (FBO pixels + decode table packed from the same frame), sharing the projection and FBO layout with the display pass;- hits are decided by texture
alpha, exactly like the display pass; transparent areas are not pickable; - while the view is moving (drag, inertia, panBy, flyTo, pinch, zoom animation)
picking returns
unavailable: it never guesses when the published frame does not match the current viewport; moveend / zoomendre-renders and publishes a fresh snapshot, after which picking recovers automatically;- with no interaction listeners, the pick FBO is neither created nor refreshed — zero picking overhead.
CRUD
| Method | Description |
|------|------|
| addMarker(marker) | Add (incremental write; marks dirty and rebuilds on first frame when GL is not ready). Adding the same instance twice is idempotent; the same id with a different instance throws |
| removeMarker(id) | Remove (writes a NaN tombstone; dead slots are never reused and are compacted in order on growth or compact(); synthesizes mouseout if hovering) |
| updateMarker(id, changes) | Update in place (WebGLMarkerUpdate = Partial<WebGLMarkerOptions>; latlng accepts L.LatLng or [lat, lng]) |
| setMarkers(markers) | Bulk replace (clears, marks dirty, rebuilds on next render; elements must be WebGLMarker instances with unique ids, otherwise it throws). Prefer this for bulk data |
| getMarker(id) | Look up by id |
| setIconSize(size) | Set the layer default icon size (only affects markers with size: null). Must be a finite number > 0, otherwise throws; setting the current value skips the redraw |
| compact() | Manually compact holes and rebuild a contiguous buffer (usually unnecessary; growth compacts automatically) |
| redraw() | Manually trigger one render (skipped while the view is moving; moveend re-renders) |
| remove() | Remove from the map and release GL resources |
| get count | Number of live markers (deleted markers excluded) |
Selection semantics (implemented by you)
The layer keeps no selection state and ships no selection helper — a few lines of app code suffice:
const selected = new Set<WebGLMarker>()
layer.on('click', (e) => {
if (selected.has(e.marker)) {
selected.delete(e.marker)
layer.updateMarker(e.marker.id, { color: [0.2, 0.5, 0.8] })
} else {
selected.add(e.marker)
layer.updateMarker(e.marker.id, { color: [1, 0.5, 0] })
}
})
// Single selection: clear the Set before adding; clean the Set in the same code path that removes markers.visible / opacity / size semantics
visible:falsewrites NaN coordinates; the vertex shader pushes the point off screen withgl_PointSize = 0(not rendered, not picked). Setting it back totruerestores it automatically.opacity: 0..1,0is fully transparent; out-of-range values are clamped to[0, 1]before upload. Both passes discardalpha < 0.05(not rendered, not picked).size: absolute pixels (CSS), overriding the layericonSize;nullor<= 0follows the layericonSize(changes withsetIconSize). A change only reaches the GPU afterupdateMarker(id, { size }).
CustomCanvasLayer (internal, not exported)
CustomCanvasLayer is the internal canvas-lifecycle and drag/zoom-sync
implementation behind WebGLMarkerLayer and is not exported. See the source
at src/overlay/CustomCanvasLayer.ts for details.
Behavior notes
Hover pipeline & suspension
- after data changes (add / remove / update / setMarkers / compact) and before the next frame renders, picking keeps the previous frame's semantics (what-you-see-is-what-you-hit) and can never mismatch across markers;
- pointers over popups / tooltips / controls / DOM markers are treated as blank
space (leaving the hovered marker fires
mouseout); - users who need mousemove-level tracking should listen to the map's
mousemovethemselves; this layer exposes no pick API.
Multiple layers (no automatic dedup)
When several WebGLMarkerLayers (or other map listeners) are stacked, the map
container's click / dblclick / contextmenu / mousemove are processed by every
layer independently — events are not deduplicated. Implement ownership yourself
in handlers if needed (e.g. via e.marker, a current-layer flag, or
originalEvent).
dblclick & doubleClickZoom
Leaflet enables doubleClickZoom by default. If you also listen to dblclick,
a marker double-click zooms the map first, then fires the event. Set
doubleClickZoom: false in the map options if you only want the event.
z-order
Later markers draw on top (slots grow in insertion order; draw order == insertion
order). Holes left by removals are compacted by growth or compact() with
relative order preserved; when markers overlap, picking also returns the
later-added one.
High DPI
Icon sizes are declared in CSS pixels and multiplied by devicePixelRatio to
form gl_PointSize, so icons do not shrink on dpr=2 displays.
CRS support
Only Leaflet's default EPSG:3857 (Web Mercator, L.CRS.EPSG3857) is supported:
the vertex shader inlines that projection's math. Passing another CRS (e.g. a
proj4leaflet custom projection) makes addTo throw immediately instead of
silently misplacing markers. CPU-side pre-projection for arbitrary CRS is planned
for a future version.
Low-zoom performance & LOD
GPU fill cost is proportional to "visible markers x icon area", and area grows
with size². With very many markers visible at once (e.g. 1M across the whole
map), one redraw can take hundreds of milliseconds (measured: 38px ~ 200ms,
8px ~ 50ms), which stutters the moveend redraw after a drag.
The package ships no zoom curves, level thresholds, or sizing policy — only the
setIconSize(number) capability. When and how to change the size is entirely
your decision, for example shrinking with zoom:
const layer = new WebGLMarkerLayer({ iconSize: 38 })
let current = 38
map.on('zoomend', () => {
const next = map.getZoom() <= 8 ? 8 : 38
if (next !== current) {
current = next
layer.setIconSize(next)
}
})- Keep a
currentchange-guard to avoid an extra full redraw on every zoom (setIconSizealready no-ops on the same value); setIconSizeonly affects markers withmarker.size === null; explicitmarker.sizevalues stay absolute pixels;- display and picking share the same size, so "what you see is what you hit" is preserved;
- to measure real GPU time, force synchronization with
redraw() + gl.readPixels()—gl.finish()does not truly wait for rasterization on some D3D11/ANGLE setups; - if the budget is still exceeded at the size floor, subsampling the marker count is the next lever; it is not built in.
Picking & multiple library copies
Pick colors encode the layer-internal storage slot index, not the global
marker id. Passing a marker across two loaded copies of the library is rejected
by the instanceof check in addMarker.
Context loss recovery
The layer listens to webglcontextlost / webglcontextrestored. On loss it drops
all GL resources and fires error (stage: 'context'); on restore it rebuilds
shaders + buffer + texture automatically and fires load again after a
successful rebuild, avoiding a black screen.
Accessibility
Markers are drawn on a WebGL canvas and are intentionally outside the DOM, so they are not keyboard-focusable and are not announced to screen readers. The plugin exposes pointer events only; keyboard navigation and screen-reader alternatives are left to the application (e.g. a fallback DOM list, or keyboard handlers built on your own data). If keyboard accessibility is a hard requirement, use a DOM-based marker plugin for the subset that needs it.
FAQ
Can it really render one million markers?
Yes. One million markers are used as a stress test in the demo: lat/lng lives in
a GPU vertex buffer, the projection runs in the vertex shader, and the canvas
uses a CSS transform while the map moves, so dragging does not redraw anything.
The real cost is the single redraw on moveend, which is fill-rate bound and
scales with visible markers × icon area (measured at 1M points: ~200 ms with
38px icons, ~50 ms with 8px icons).
How does it compare to L.Marker and the L.Canvas renderer?
L.Marker creates one DOM node per marker and works well for hundreds to low
thousands. Leaflet's L.Canvas renderer handles tens of thousands of vector
shapes, transforms its container while moving, then redraws every path on the
CPU at moveend, and hit-tests by scanning paths. This plugin keeps points in a
GPU buffer and picks in O(1) through a color-coded framebuffer. See the
comparison table above for the full picture.
How does picking work without DOM nodes?
When an interaction event is subscribed, the layer renders a second invisible
pass into a framebuffer, drawing each marker with a unique encoded color. A
pointer event reads back the pixel under the pointer (readPixels) and decodes
it into the marker, so picking is O(1) regardless of how many markers exist.
Does dragging redraw every frame?
No. While panning, the canvas follows the map with a CSS transform and no JS
runs per frame. Zoom animations and flyTo update a CSS transform each frame,
still without a redraw. One real redraw happens on moveend.
Does it support other projections or CRS values?
Not yet. Only Leaflet's default EPSG:3857 (Web Mercator) is supported, and the
projection math is inlined in the vertex shader. Passing another CRS makes
addTo throw immediately. CPU-side pre-projection for arbitrary CRS is planned
for a future version.
Can each marker use a different image?
No. All markers share the layer's one texture image and differ only by tint,
size, rotation, and opacity. This is deliberate: one shared texture keeps a
million points on one canvas. For a handful of custom interactive DOM markers,
use L.Marker alongside this layer.
What WebGL version is required?
WebGL 1.0 with hardware acceleration. The layer requests a webgl context and
fires an error event (stage: 'context') if it cannot be created.
Is the canvas accessible to keyboard and screen readers?
No. Canvas points are outside the DOM and pointer-only. Keyboard navigation and screen-reader alternatives are left to the application, for example a fallback DOM list built from the same data.
Development
npm run build # vite build + tsc declarations + copy static assets
npm run build:watch # watch mode (vite build --watch)