GeoLeaf Events API
Module: @geoleaf/core — GeoLeaf.Events
GeoLeaf dispatches native DOM events (CustomEvent) on document. They are typed through GeoLeafEventMap.
INFO
Casing — GeoLeaf.Events and GeoLeaf.events. Both are mounted and equivalent (GeoLeaf.Events === GeoLeaf.events). Events is the canonical form: it is the one used throughout this reference and the one carried by the exported facade. events is a historical alias, kept for the long term and not deprecated — same contract as Baselayers / BaseLayers.
WARNING
Before v3.0.0, only events existed at runtime while the type definitions declared Events: GeoLeaf.Events.on(...) compiled and then threw a TypeError. Code that worked around this by writing events keeps working unchanged.
Integrators subscribe only — dispatching is internal to GeoLeaf.
Public API
GeoLeaf.Events.on(event, handler)
const unsubscribe = GeoLeaf.Events.on("geoleaf:poi:panel:open", (e) => {
console.log("POI panel opened:", e.detail.poiId);
});Registers a listener for event, called on every dispatch until it is removed. Returns the function that removes it — the only way to remove an anonymous handler. Calling it twice is harmless. It is off(event, handler) bound to this subscription: the DOM keeps one listener per handler reference, so two on() calls with the same function register it once, and either returned function removes it.
GeoLeaf.Events.off(event, handler)
GeoLeaf.Events.off("geoleaf:poi:panel:open", myHandler);Removes a previously registered listener. The same function reference must be passed.
GeoLeaf.Events.once(event, handler)
const cancel = GeoLeaf.Events.once("geoleaf:app:ready", () => {
console.log("App ready");
});Listener fired once, then removed automatically. Returns a function that cancels it if it has not fired yet — the teardown of a component unmounted before the event arrives.
INFO
Since 3.4.0. Before, on() and once() returned undefined, although the published IEventBus.on contract already promised the unsubscribe function. Code that removes its listeners with off() keeps working unchanged. Without a document (server-side rendering), nothing is registered and the returned function does nothing.
Full event reference
| Event name | Dispatched when | Key payload fields |
|---|---|---|
geoleaf:app:ready | App fully initialised | version, timestamp |
geoleaf:map:ready | MapLibre map created | — |
geoleaf:profile:loaded | JSON profile fully loaded | profileId, data |
geoleaf:profile:failed | Declared profile resources failed | profileId, fatal, failures[] (resource, url, required, message) — cancelable |
geoleaf:boot:failed | The boot cannot complete | reason, phase, message, module?, provisional, timestamp — cancelable |
geoleaf:module:failed | A module failed, the app goes on | module, message, skipped[], timestamp — cancelable |
geoleaf:boot:aborted | The host ended the boot | reason — whatever beforeBoot threw, "destroyed" when Core.destroy() removed the map during the boot, or "unmounted" when the application was unmounted while it started (since 3.13.0) |
geoleaf:basemap:change | Basemap changed | key, previousKey |
geoleaf:theme:applied | Theme applied (layers loaded) | themeName, layerCount |
geoleaf:poi:click | 🛑 Declared, never emitted | poiId, layerId, source — subscribing to it triggers nothing |
geoleaf:poi:panel:open | Side panel opened on a POI | poiId, poiName |
geoleaf:poi:panel:close | Side panel closed | poiId |
geoleaf:panel:opened | Desktop tab panel opened a tab | tabId |
geoleaf:panel:closed | Desktop tab panel closed a tab | tabId |
geoleaf:layer:toggle | Layer shown or hidden | layerId, visible, source |
geoleaf:layer:updated | A layer's store changed — once per write, whole-collection writers included (3.15.0) | layerId — not for a silent patchFeature, a visible subset or a layer's load |
geoleaf:filter:apply | Filter applied to features — once per application | layerIds, geometryType?, activeCount |
geoleaf:filter:reset | Filter reset (everything visible) | layerIds |
geoleaf:map:move | Map panned (MapLibre moveend) | center.lat, center.lng, zoom |
geoleaf:map:zoom | Zoom changed (MapLibre zoomend) | zoom, oldZoom, center |
geoleaf:plugin:loaded | Plugin registered synchronously | name, version |
geoleaf:plugin:lazy-loaded | Lazy plugin loaded asynchronously | name |
geoleaf:plugin:failed | Lazy plugin failed to load | name, error |
geoleaf:popup:action | Popup action button clicked | actionId, layerId, featureId, properties, lngLat?, button, setBusy(), close() |
geoleaf:offline:outbox-queued | An edit entered the write queue, was absorbed by an entry, or cancelled one | layerId, localId, kind, queued, annulled |
geoleaf:offline:quarantine-exited | Entries set aside left quarantine — requeued, or discarded on confirmation | exit, entries |
geoleaf:offline:outbox-drained | A drain pass ended — every pass, the empty one included | attempted, pushed, failed, deferred, conflicts, haltedBy, heldForSession |
geoleaf:offline:write-conflict | A conflict was settled by the last write — once per conflict | layerId, localId, serverId, kind, entryId, detectedAt, baseVersion, serverVersion, serverFeature, readOutcome, settledBy |
geoleaf:offline:pull-progress | One page of a layer pull landed | layerId, current, total, totalIsKnown, percentage |
geoleaf:sw:update-waiting | A new version is installed and waits for the user (3.15.0) — once per waiting worker, never at a first install | — see the PWA guide; read GeoLeaf.PWA.isUpdateWaiting() as well, a late listener may have missed it |
geoleaf:sw:updated | A service worker activated — at a first install too; not "an update is available" | — |
The five
geoleaf:offline:*events are those of the offline write queue — what fires them, and what to read instead when an event says nothing, is the offline write cycle.
⚠️ Two
panelfamilies, and they are not the same panel.geoleaf:poi:panel:*is the feature information drawer, opened by clicking a POI and identified bypoiId.geoleaf:panel:*is the desktop tab panel (layers / filters / legend), identified bytabId. Switching tab emitsclosedthenopened, in that order; opening from a closed panel emitsopenedalone.⚠️
geoleaf:poi:panel:*fires only for a feature carrying a stable id:poiIdis typedstring, and a layer whose source has no id emits nothing rather than a forged identifier.⚠️
geoleaf:boot:failed,geoleaf:profile:failedandgeoleaf:module:failedfire DURINGGeoLeaf.boot()— orGeoLeaf.mount()—, and they are cancelable. Subscribe before callingboot(); withmount(), the handle'son()subscribes in time. Callingevent.preventDefault()keeps the default screen out of the app shell's#gl-loaderveil — your application then owns that interface; a page without the veil never gets one.reasonisconfig,profile,engine(no MapLibre GL JS engine — since 3.13.0),webgl,map,module,timeoutorinternal, andmessageis English, meant for logs and support.provisional: trueexists only fortimeout: the boot may still complete, andgeoleaf:app:readythen follows.geoleaf:profile:failedprecedesgeoleaf:profile:loaded; withfatal: true,profile.jsonitself failed andgeoleaf:profile:loadeddoes not follow.geoleaf:module:failedmeans a module threw and the application goes on without it and without the modules that depend on it (skipped): the reveal is held on a screen that names it, or a warning names it once the application is revealed. A module the interface depends on fails the boot instead —geoleaf:boot:failed,reason: "module", withmodulenaming it. The screens' diagnostic carries the log's recent entries, redacted: the recordGeoLeaf.Log.getEntries()reads.⚠️
geoleaf:geojson:visibility-changedis typed too, but it is the historical form ofgeoleaf:layer:toggleand carries the same payload. New integrations takelayer:toggle: it fires for every source, wherevisibility-changedis not re-dispatched ondocumentwhen the change comes from a zoom recalculation.
geoleaf:popup:action
Dispatched on document on every click of an action button (renderer type: "action" in popup.fields[]), on the popup and the side panel alike — both surfaces share one render table. This is the only channel for reacting to such an action — loosely coupled listeners (analytics, host integration, backend).
Payload (e.detail):
| Field | Type | Description |
|---|---|---|
actionId | string | Opaque action identifier (token ^[A-Za-z0-9:_-]{1,64}$), as defined in the configuration. |
layerId | string | Identifier of the layer/source of the clicked feature. |
featureId | string | Identifier of the feature (GeoJSON feature or POI), or null when the source has none. |
properties | object | Subset of the feature properties, bounded by payloadFields. Empty by default — see below. |
lngLat | object? | { lat, lng } of the popup anchor point (optional). |
button | HTMLElement | The button that was clicked — a live node, for host-owned visual state. |
setBusy | function | setBusy(busy: boolean) — toggles disabled, aria-busy and the gl-poi-popup__action--busy modifier. |
close | function | Closes the surface the button was rendered in — the popup or the side panel, never both. Idempotent. |
⚠️
propertiesis{}unless the button declarespayloadFields. The default goes to confidentiality, not convenience: this is adocumentevent that any script on the page can hear, and a "send everything" default would leak the full property bag. There is no mode that sends the un-whitelisted bag, and its absence is not a configuration gap.This table announced a default of
id,name,title,labeluntil 14/08/2026. That was never what the code did —render/widget-dispatch.tshas readpayloadFields ?? []since 29/07/2026. Correcting it is the point: a downstream integrator had written its own handler contract around "the full properties", on the strength of this line.
⚠️ The payload is NOT JSON-serialisable, since 14/08/2026:
buttonis a DOM node and two fields are functions.JSON.stringify(e.detail)throws (circular reference), and passing the detail topostMessageor aWorkerthrowsDataCloneError. Copy the fields you need instead. The channel is dispatched as a rawCustomEventfor exactly this reason — routing it through the sanitising bus would have deliveredbuttonas{}and the two functions asundefined, without any error.
GeoLeaf.Events.on("geoleaf:popup:action", (e) => {
const { actionId, layerId, featureId } = e.detail;
if (actionId === "host:open-form") {
_paq.push(["trackEvent", "Popup", "Action", actionId]);
}
});Showing progress and closing on success — what GeoLeaf.Popup.registerActionHandler used to offer before ADR-07 removed it:
GeoLeaf.Events.on("geoleaf:popup:action", (e) => {
const d = e.detail;
if (d.actionId !== "tickets:create-request") return;
d.setBusy(true);
void createRequest(d.featureId)
.then(() => d.close())
.finally(() => d.setBusy(false));
});Integration examples
Analytics — Matomo
GeoLeaf.Events.on("geoleaf:poi:panel:open", (e) => {
_paq.push(["trackEvent", "Map", "POI Panel", e.detail.poiId]);
});
GeoLeaf.Events.on("geoleaf:filter:apply", (e) => {
_paq.push(["trackEvent", "Map", "Filter Apply", e.detail.activeCount.toString()]);
});Analytics — Google Analytics 4
GeoLeaf.Events.on("geoleaf:map:move", (e) => {
gtag("event", "map_pan", { lat: e.detail.center.lat, lng: e.detail.center.lng });
});
GeoLeaf.Events.on("geoleaf:layer:toggle", (e) => {
gtag("event", "layer_toggle", { layer_id: e.detail.layerId, visible: e.detail.visible });
});Removing listeners
// With the returned function — works for an anonymous handler too
const unsubscribe = GeoLeaf.Events.on("geoleaf:poi:panel:open", (e) => {
/* ... */
});
// Later:
unsubscribe();// With off() — the handler must be the same function reference
const handlePoiPanel = (e) => {
/* ... */
};
GeoLeaf.Events.on("geoleaf:poi:panel:open", handlePoiPanel);
// Later:
GeoLeaf.Events.off("geoleaf:poi:panel:open", handlePoiPanel);Security notes
- Payloads contain primitives only (
string,number,boolean) — with three named exceptions, which carry live DOM nodes and are dispatched as rawCustomEvents rather than through the sanitising bus:geoleaf:popup:action,geoleaf:toolbar:actionandgeoleaf:layer-manager:panel. A DOM reference in adocumentevent widens what any script on the page can reach; forgeoleaf:popup:actionthat trade is stated in its section above. - The error carried by
plugin:failedis truncated to 200 characters to avoid leaking stack traces. - SSR-safe: calls are silent when
documentis undefined.
