Skip to content

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) ​

ts
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) ​

ts
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) ​

ts
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 nameDispatched whenKey payload fields
geoleaf:app:readyApp fully initialisedversion, timestamp
geoleaf:map:readyMapLibre map created—
geoleaf:profile:loadedJSON profile fully loadedprofileId, data
geoleaf:profile:failedDeclared profile resources failedprofileId, fatal, failures[] (resource, url, required, message) — cancelable
geoleaf:boot:failedThe boot cannot completereason, phase, message, module?, provisional, timestamp — cancelable
geoleaf:module:failedA module failed, the app goes onmodule, message, skipped[], timestamp — cancelable
geoleaf:boot:abortedThe host ended the bootreason — 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:changeBasemap changedkey, previousKey
geoleaf:theme:appliedTheme applied (layers loaded)themeName, layerCount
geoleaf:poi:click🛑 Declared, never emittedpoiId, layerId, source — subscribing to it triggers nothing
geoleaf:poi:panel:openSide panel opened on a POIpoiId, poiName
geoleaf:poi:panel:closeSide panel closedpoiId
geoleaf:panel:openedDesktop tab panel opened a tabtabId
geoleaf:panel:closedDesktop tab panel closed a tabtabId
geoleaf:layer:toggleLayer shown or hiddenlayerId, visible, source
geoleaf:layer:updatedA 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:applyFilter applied to features — once per applicationlayerIds, geometryType?, activeCount
geoleaf:filter:resetFilter reset (everything visible)layerIds
geoleaf:map:moveMap panned (MapLibre moveend)center.lat, center.lng, zoom
geoleaf:map:zoomZoom changed (MapLibre zoomend)zoom, oldZoom, center
geoleaf:plugin:loadedPlugin registered synchronouslyname, version
geoleaf:plugin:lazy-loadedLazy plugin loaded asynchronouslyname
geoleaf:plugin:failedLazy plugin failed to loadname, error
geoleaf:popup:actionPopup action button clickedactionId, layerId, featureId, properties, lngLat?, button, setBusy(), close()
geoleaf:offline:outbox-queuedAn edit entered the write queue, was absorbed by an entry, or cancelled onelayerId, localId, kind, queued, annulled
geoleaf:offline:quarantine-exitedEntries set aside left quarantine — requeued, or discarded on confirmationexit, entries
geoleaf:offline:outbox-drainedA drain pass ended — every pass, the empty one includedattempted, pushed, failed, deferred, conflicts, haltedBy, heldForSession
geoleaf:offline:write-conflictA conflict was settled by the last write — once per conflictlayerId, localId, serverId, kind, entryId, detectedAt, baseVersion, serverVersion, serverFeature, readOutcome, settledBy
geoleaf:offline:pull-progressOne page of a layer pull landedlayerId, current, total, totalIsKnown, percentage
geoleaf:sw:update-waitingA 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:updatedA 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 panel families, and they are not the same panel. geoleaf:poi:panel:* is the feature information drawer, opened by clicking a POI and identified by poiId. geoleaf:panel:* is the desktop tab panel (layers / filters / legend), identified by tabId. Switching tab emits closed then opened, in that order; opening from a closed panel emits opened alone.

⚠️ geoleaf:poi:panel:* fires only for a feature carrying a stable id: poiId is typed string, and a layer whose source has no id emits nothing rather than a forged identifier.

⚠️ geoleaf:boot:failed, geoleaf:profile:failed and geoleaf:module:failed fire DURING GeoLeaf.boot() — or GeoLeaf.mount() —, and they are cancelable. Subscribe before calling boot(); with mount(), the handle's on() subscribes in time. Calling event.preventDefault() keeps the default screen out of the app shell's #gl-loader veil — your application then owns that interface; a page without the veil never gets one. reason is config, profile, engine (no MapLibre GL JS engine — since 3.13.0), webgl, map, module, timeout or internal, and message is English, meant for logs and support. provisional: true exists only for timeout: the boot may still complete, and geoleaf:app:ready then follows. geoleaf:profile:failed precedes geoleaf:profile:loaded; with fatal: true, profile.json itself failed and geoleaf:profile:loaded does not follow. geoleaf:module:failed means 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", with module naming it. The screens' diagnostic carries the log's recent entries, redacted: the record GeoLeaf.Log.getEntries() reads.

⚠️ geoleaf:geojson:visibility-changed is typed too, but it is the historical form of geoleaf:layer:toggle and carries the same payload. New integrations take layer:toggle: it fires for every source, where visibility-changed is not re-dispatched on document when 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):

FieldTypeDescription
actionIdstringOpaque action identifier (token ^[A-Za-z0-9:_-]{1,64}$), as defined in the configuration.
layerIdstringIdentifier of the layer/source of the clicked feature.
featureIdstringIdentifier of the feature (GeoJSON feature or POI), or null when the source has none.
propertiesobjectSubset of the feature properties, bounded by payloadFields. Empty by default — see below.
lngLatobject?{ lat, lng } of the popup anchor point (optional).
buttonHTMLElementThe button that was clicked — a live node, for host-owned visual state.
setBusyfunctionsetBusy(busy: boolean) — toggles disabled, aria-busy and the gl-poi-popup__action--busy modifier.
closefunctionCloses the surface the button was rendered in — the popup or the side panel, never both. Idempotent.

⚠️ properties is {} unless the button declares payloadFields. The default goes to confidentiality, not convenience: this is a document event 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, label until 14/08/2026. That was never what the code did — render/widget-dispatch.ts has read payloadFields ?? [] 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: button is a DOM node and two fields are functions. JSON.stringify(e.detail) throws (circular reference), and passing the detail to postMessage or a Worker throws DataCloneError. Copy the fields you need instead. The channel is dispatched as a raw CustomEvent for exactly this reason — routing it through the sanitising bus would have delivered button as {} and the two functions as undefined, without any error.

ts
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:

ts
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 ​

ts
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 ​

ts
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 ​

ts
// With the returned function — works for an anonymous handler too
const unsubscribe = GeoLeaf.Events.on("geoleaf:poi:panel:open", (e) => {
    /* ... */
});

// Later:
unsubscribe();
ts
// 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 raw CustomEvents rather than through the sanitising bus: geoleaf:popup:action, geoleaf:toolbar:action and geoleaf:layer-manager:panel. A DOM reference in a document event widens what any script on the page can reach; for geoleaf:popup:action that trade is stated in its section above.
  • The error carried by plugin:failed is truncated to 200 characters to avoid leaking stack traces.
  • SSR-safe: calls are silent when document is undefined.

Released under the MIT License.