Skip to content

GeoLeaf-JS — API Reference ​

Package: @geoleaf/coreVersion: 3.0.0 License: MIT

Auto-generated HTML docs: Run npm run docs:api in packages/core to generate the TypeDoc API reference at packages/core/docs/api/. This produces per-module HTML pages for all named exports including full method signatures, parameter types, and return types. Published at geoleaf.dev/docs/api/.


Table of contents ​

  1. ESM named exports (27)
  2. Extension contracts (TypeScript)
  3. Profile schemas and types
  4. GeoLeafAPI — top-level API
  5. The application lifecycle — GeoLeaf.mount() and GeoLeaf.boot()
  6. Core — map lifecycle
  7. Layers — feature data
  8. Taxonomy — the point symbol
  9. UI — interface controls
  10. ThemeSelector — the theme bar
  11. Filter — the filter panel (singular)
  12. Filters — removed in v3.1 (plural)
  13. Table — data table
  14. Legend — legend panel
  15. LayerManager — GeoJSON layers
  16. Baselayers — base tiles
  17. Helpers
  18. Validators
  19. API sub-modules
  20. Log
  21. Errors — typed error classes
  22. CONSTANTS
  23. Utils
  24. Config
  25. Geocoding — address search
  26. Notifications — toast notifications
  27. Popup — popup action buttons
  28. PWA — install prompt
  29. Labels — layer labels
  30. Composing a lighter bundle
  31. Global namespace (window.GeoLeaf.*)
  32. TypeScript types

ESM named exports ​

All symbols available via import { … } from "@geoleaf/core":

ts
// Kernel facades
import { Core } from "@geoleaf/core"; // Map lifecycle
import { GeoLeafAPI } from "@geoleaf/core"; // Unified top-level API
import { UI } from "@geoleaf/core"; // UI controls
import { LayerManager } from "@geoleaf/core"; // GeoJSON layer management
import { Baselayers } from "@geoleaf/core"; // Base tile layers
import { Helpers } from "@geoleaf/core"; // Utility helpers
import { Validators } from "@geoleaf/core"; // Input validators
import { Events } from "@geoleaf/core"; // DOM event bus

// API sub-modules
import { APIController } from "@geoleaf/core";
import { APIFactoryManager } from "@geoleaf/core";
import { APIInitializationManager } from "@geoleaf/core";
import { APIModuleManager } from "@geoleaf/core";
import { PluginRegistry } from "@geoleaf/core";
import { BootInfo } from "@geoleaf/core";
import { showBootInfo } from "@geoleaf/core";

// Utilities
import { Log } from "@geoleaf/core"; // Logging
import { Errors } from "@geoleaf/core"; // Typed error classes
import { CONSTANTS } from "@geoleaf/core"; // Constants
import { Utils } from "@geoleaf/core"; // Utility modules
import { applyCssText } from "@geoleaf/core"; // CSP-safe CSSOM style helper
import { ensureGeoLeaf } from "@geoleaf/core"; // The typed global namespace, created if absent
import { Config } from "@geoleaf/core"; // Config access

// Capability system
import { CapabilityRegistry } from "@geoleaf/core"; // Declare / gate a capability

// Capability facades (this entry bundles all 18 in-core capabilities)
import { Legend } from "@geoleaf/core"; // Legend panel
import { Permalink } from "@geoleaf/core"; // URL deep linking
import { Share } from "@geoleaf/core"; // Share dialog
import { Notifications } from "@geoleaf/core"; // Toast notifications
import { PWA } from "@geoleaf/core"; // PWA install prompt

export default GeoLeaf; // default export: window.GeoLeaf (CDN/global passthrough)

Removed in v3.1 (breaking): Filters — see the section below.

Removed in v3 (breaking): POI (subsystem dissolved — see Layers), Route (now the in-core route capability), Table and Themes (extracted / removed), and Search (dead engine purged, with the flexsearch dependency).

Many facades are global-only. GeoLeaf.Layers, GeoLeaf.Taxonomy, GeoLeaf.Filter, GeoLeaf.Cluster, GeoLeaf.FeatureInfo and GeoLeaf.Introspection are mounted on window.GeoLeaf at boot but are not named ESM exports — import { Layers } from "@geoleaf/core" does not work.

GeoJSON is not a named ESM export — but GeoLeaf.GeoJSON does exist. It is mounted on window.GeoLeaf as soon as the bundle is imported — before GeoLeaf.boot() — and typed in the ambient declarations the package ships (dist/types/global.d.ts). Use GeoLeaf.Layers for per-layer feature data (LayerDataApi), and GeoLeaf.GeoJSON for layer-level operations (getLayerById, showLayer, setLayerStyle…) and for the URL of its worker (setWorkerUrl).


Extension contracts (TypeScript) ​

Since v3.0.0. The interfaces a plugin must implement are published. Before that release they were reachable through no channel at all: a plugin implementing ICoreModule had to re-declare it, and the declaration drifted from the core's.

All 19 types are re-exported from the entry point, which is the form to prefer:

ts
import type { ICoreModule, IMapAdapter, GeoLeafEventMap } from "@geoleaf/core";

Each contract is also reachable one by one, when you want to depend on exactly one file:

ts
import type { ILifecycleModule } from "@geoleaf/core/contracts/core-module.contract.js";
SubpathTypes
./contracts/core-module.contract.jsICoreModule, ILifecycleModule, IUISlotModule, IModuleRegistry, IModuleUISlot
./contracts/capability.contract.jsICapabilityDeclaration, ICapabilityRegistry, ICapabilitySchema, ICapabilityFieldSchema, ICapabilityConfigGate
./contracts/config.contract.jsIGeoLeafConfig
./contracts/map-adapter.contract.jsIMapAdapter and the geometry types it uses
./contracts/layer-data.contract.jsLayerDataApi, LayerFeatureState
./contracts/event-bus.contract.jsGeoLeafEventMap, GeoLeafRawEventMap, IEventBus

PluginMetadata (the metadata argument of GeoLeaf.plugins.register) and UtilsNamespace are exported from the entry point only.

These six modules are type-only. They declare a types condition and emit no JavaScript: import type works, and a value import fails outright rather than resolving to nothing.

ICoreModule is a union — implement ILifecycleModule ​

ts
type ICoreModule = ILifecycleModule | IUISlotModule;

GeoLeaf.registry.register() has always accepted two shapes: a full lifecycle module, or a bare UI slot { id, ui } — which is what any plugin does when it adds a toolbar button without running code at startup. TypeScript does not allow an implements clause on a union, so:

ts
// Correct
class MyModule implements ILifecycleModule {
    id = "my-module";
    dependencies = [];
    init() {}
    destroy() {}
}

// Also valid — registered as a UI slot, no lifecycle
GeoLeaf.registry.register({ id: "my-toolbar-button", ui: myElement });

// Invalid — TypeScript error: a class cannot implement a union type
class MyModule implements ICoreModule {}

ICoreModule remains the name of register()'s parameter. Nothing changed at runtime — the type moved to match the registry, not the other way round.

Registering after init() — what a bundle loaded on demand does. A lifecycle module is not initialised for the application already running, which it joined by itself; since 3.14.2 its destroy() is called when the application is unmounted, and it is initialised like the others from the next boot on. A UI slot registered then is not drawn before the next mount — the toolbar is built once per boot — and a warning says so: a button that must exist before its bundle loads is declared with GeoLeaf.plugins.registerLazyForAction(), before the boot. Since 3.15.0 the member is typed in the ambient namespace, and its descriptor takes what the toolbars read: legacyProfileKey on both slots — the visibility key read when profileKey is absent from the configuration — and variant ("icon" or "tab") on the desktop one.


Profile schemas and types ​

Since v3.13.0. The JSON Schemas that judge the files of a profile ship with the package — byte for byte the schemas the repository validates its own profiles with — and so do TypeScript types generated from them at build time.

SubpathContent
./schemas/<name>.schema.jsona JSON Schema (draft-07), one per profile file kind — ten, $id geoleaf/<name>
./schemastype-only: the ten root types, one per schema
ts
import type { GeoLeafProfile, GeoLeafLayerConfig, GeoLeafLayerStyle } from "@geoleaf/core/schemas";

Each root type is named after its schema's title (GeoLeafProfile, GeoLeafRootConfig, GeoLeafLayerConfig, GeoLeafLayerStyle, GeoLeafBasemaps, GeoLeafCoreFeatures, GeoLeafLayersIndex, GeoLeafDataMapping, GeoLeafThemes, GeoLeafUIConfig). A schema's definitions are reachable by indexed access, not by name: NonNullable<GeoLeafLayerStyle["styleRules"]>[number].

The types are an upper bound of the schemas. Every file a schema accepts type-checks; the reverse is not promised — conditional rules, presence rules, patterns and bounds are not expressed. The verdict belongs to the schemas: validate the files with them. One exception, named in the shipped index.d.ts: in config/core/mapping.json, a _comment… key types only as a string. A modules.<id> block is opaque — its keys belong to the capability or plugin <id>.

./schemas is type-only, like ./contracts/*: import type works, a value import fails. The schemas themselves are JSON data, served by ./schemas/<name>.schema.json.

Which schema judges which file, and the validation recipe: JSON Schema documentation.


GeoLeafAPI — top-level API ​

The top-level GeoLeaf API. Available as GeoLeaf (CDN/global) or GeoLeafAPI (ESM).

Methods ​

MethodSignatureDescription
init(options: object) => Promise<void>Initialize GeoLeaf with a map container and options
loadConfig(input: string | object) => Promise<void>Load config from URL or inline object
setTheme(theme: string) => voidApply a visual theme
createMap(id: string, options?: object) => object | nullCreate a new managed map instance
getMap(id: string) => object | nullGet a map instance by container id — same registry as Core.getMap() since v3.1.0
getAllMaps() => object[]Get all active map instances — same registry as Core.listMaps() since v3.1.0
removeMap—Removed, not deprecated — the method does not exist. Use Core.destroy(id) instead.
getModule(name: string) => object | nullGet a registered module by name
hasModule(name: string) => booleanCheck if a module is registered
getNamespace(name: string) => object | nullGet a top-level GeoLeaf namespace by name
getHealth() => object | nullGet APIController health/metrics
getMetrics() => object | nullAlias for getHealth()

Properties ​

PropertyTypeDescription
versionstringCurrent GeoLeaf version (e.g. "3.0.0")
CONSTANTSobjectReference to GeoLeaf.CONSTANTS
BaseLayersobjectAlias for GeoLeaf.Baselayers (backward compatibility)

The application lifecycle — GeoLeaf.mount() and GeoLeaf.boot() ​

GeoLeaf.boot(options) starts the application of the page: configuration, profile, map, every module. GeoLeaf.mount(el, options) (since v3.13.0) starts the same application in the map container el — an element or its id — and returns a handle that takes it down again:

MemberSignatureDescription
readyPromise<void>Resolves on geoleaf:app:ready. Rejects with a GeoLeafMountError (name, reason, phase) when the boot fails, is aborted, or the application is unmounted before it was ready. A provisional timeout leaves it pending.
unmount()() => Promise<void>Takes the WHOLE application down — modules, controls, panels, map, and the state they held. Idempotent. The next mount() or boot() gives the application back whole.
on(event, handler)(event, handler) => () => voidSubscribes to a GeoLeaf event — the page's bus, the types of Events.on(). Removed at unmount(). A subscription made before the boot hears its first event.
getMap()() => IMapAdapter | nullThe map of this application, or null before it is built and once it is unmounted.

Both take the same options: config / configUrl, profile, beforeBoot, onPerformanceMetrics, watchdogMs, and maplibregl — the engine. MapLibre 6 no longer sets globalThis.maplibregl; the core puts the option there. Without it, the global is read as before; with neither, the boot fails with reason: "engine".

profile (core ≥ 3.18.0) decides which profile boots. Without it, a profile the user picked with the profile switcher — stored in the browser, for the whole origin — wins over the configuration's data.activeProfile. An application that passes its own configuration and must not inherit that choice passes profile. A value other than letters, digits, _ and - (at most 50) is refused with a console warning, and ignored.

One application per page. A mount() while an application is alive — mounted by mount() or by boot() — unmounts it first, and the previous handle goes inert: a view that mounts before the previous one unmounts, or React's StrictMode (mount, unmount, mount in one tick), needs no ordering from the host. boot() while an application is mounted is refused, as it always was; after unmount(), it boots again.

⚠️ Core.destroy() is not an unmount. It removes the map alone: the modules, their controls and their state stay, and Core.init() then gives a bare map.

Plugins across a remount (core ≥ 3.14.2). unmount() tears down every plugin that registered a lifecycle module with GeoLeaf.registry, whether its bundle was loaded before the boot or on demand, and the next mount draws every toolbar button again — those of the plugins loaded since the first boot included. connector 1.3.5, editor 1.5.7, geocoding 1.1.3, measure 1.0.9, position-share 1.0.2, print 1.3.4 and table 1.1.3 leave the page with the application and work again on the next map; realtime-layer stops with it — from its 1.0.6 when it was loaded before the boot, from its 1.0.8 when it was loaded on demand too. isochrone 1.0.0 removes its zones, their origin markers, its credit and its window, and its button opens a new window that computes on the new map — measured loaded on demand, as its button loads it.

The plugins that draw no button of their own were measured the same way, each brought to its built state, then unmounted and mounted again:

PluginWhat unmount() doesSince
navigationends the guidance session — banner, position watch and screen wake locknavigation 1.0.3
offline-uicloses the offline window and returns its listeners; the button opens a new oneoffline-ui 1.7.0
routingremoves the itinerary pane; the next opening builds one pane, on the new map@geoleaf/core 3.15.0
cognothing is left; a handle of the previous map removes without error—
file-importnothing is left—
flatgeobufnothing is left — an auto-refreshing layer stops asking for its file—
websocketnothing, on purpose — see below—

A layer added by a call (COG.addLayer(), FileImport.importAsLayer(), FlatGeobuf.loadBboxAsLayer()) belongs to the application that was unmounted: the next mount draws what the profile declares, and does not replay the call.

What unmount() leaves alone, because it is the host's and set once for the page:

  • GeoLeaf.Connector.configure() — window.fetch keeps its token, and the next application is authenticated without configuring again.
  • GeoLeaf.Ws.init() — the connection stays open across the unmount, and the next application finds it connected, subscriptions included. GeoLeaf.Ws.destroy() closes it.
  • GeoLeaf.Isochrone.configure() — the engine's address and query parameters stay, and the next computation sends them.

A position shared by hand (GeoLeaf.PositionShare.start()) is stopped, and is not resumed by the next mount; a guidance session is not resumed either.

ts
import * as maplibregl from "maplibre-gl";

const mount = GeoLeaf?.mount;
if (mount) {
    const app = mount("map", { configUrl: "/geoleaf/geoleaf.config.json", maplibregl });
    const off = app.on("geoleaf:layer:toggle", (e) => console.log(e.detail.layerId));
    app.ready.then(
        () => console.log("ready", app.getMap()),
        (err) => console.warn(err.name, err.reason, err.message)
    );
    // When the view goes away:
    off();
    void app.unmount();
}

Core — map lifecycle ​

Generated signatures: packages/core/docs/api/variables/Core.html (run npm run docs:api in packages/core)

ts
import { Core } from "@geoleaf/core";
// or: GeoLeaf.Core (CDN/global)

Since v3.0.0, Core manages a keyed registry of map adapters (Map<mapId, IMapAdapter>): N maps can coexist on one page, each with its own lifecycle. The former module-level singleton (≤ v2.1.x) is gone.

MethodSignatureDescription
init(options: object) => IMapAdapter | nullInitialise a map. Requires options.mapId (returns null + logs otherwise). Re-init of an existing mapId returns the existing instance.
getMap(mapId?: string) => IMapAdapter | nullWith mapId, the targeted instance; without arg, the first active instance (backward compatible for single-map apps).
getAdapter(mapId?: string) => IMapAdapter | nullAlias of getMap.
destroy(mapId: string) => booleanDestroy the instance (map.remove() + free the slot). true if found. The map alone: an application unmounts with GeoLeaf.mount()'s handle.
hasMap(mapId: string) => booleanWhether an instance is registered under mapId.
listMaps() => string[]Ids of all active map instances.
isAttached(mapId: string) => booleanRegistered and its container is still in the document. Stronger than hasMap — see below. false after destroy(), never throws.
reattach(mapId, parent: HTMLElement) => booleanMove a live map into another parent, without destroying it. The whole container moves. ⚠️ The panels do not follow — see below.
setTheme(theme: string) => voidApply a theme to the map container.
getTheme() => stringGet the current theme name.

Note (v3.0.0 scope): legend and theme remain global and bind to the first instance. Per-map legend/theme isolation is out of scope for this release.

Init options:

ts
Core.init({
    mapId: "map", // DOM element id (REQUIRED) — unique per map
    center: [lat, lng], // Initial center [lat, lng]
    zoom: 12, // Initial zoom level
    theme: "light", // Theme name (default: "light") — applied to the first map only
    mapOptions: {}, // A closed list, not a pass-through: minZoom, maxZoom, maxPitch, maxBounds, preserveDrawingBuffer
});

Multi-map example:

ts
const a = GeoLeaf.Core.init({ mapId: "map-1", center: [48.85, 2.35], zoom: 10 });
const b = GeoLeaf.Core.init({ mapId: "map-2", center: [45.76, 4.83], zoom: 11 });
GeoLeaf.Core.listMaps(); // ["map-1", "map-2"]
// On unmount:
GeoLeaf.Core.destroy("map-1");

Moving a map instead of rebuilding it — a full-screen toggle, a tab switch, a panel that re-mounts:

ts
const Core = GeoLeaf?.Core;
if (Core && !Core.isAttached("main")) {
    Core.reattach("main", document.getElementById("fullscreen-slot")!);
}
  • hasMap() says the registry holds an entry; isAttached() says that entry is still wired into the page. They diverge where it matters: a host that removes the map's subtree without calling destroy() leaves a registered map that renders nowhere, and hasMap() still returns true.
  • reattach() re-parents the whole container, never its children one by one. MapLibre memorises the element it was constructed with, so moving the children would leave map.getContainer() pointing at the old node. It calls the adapter's optional resize() afterwards, so the WebGL canvas picks up the new container size.

⚠️ The shell does not follow the map. #gl-right-panel and the mobile pill live in the shell, not inside the map container, so reattach() leaves them where they are. Making them follow would tie this API to the shell's DOM — exactly the coupling it exists to remove. Rebuilding them at the new location is the host's call, and there are two recipes, one per surface:

  • side panel — GeoLeaf.UI.destroyDesktopPanel() → initDesktopPanel() → activateDesktopPanel();
  • mobile pill and sheet — GeoLeaf.UI.destroyMobileToolbar() → initMobileToolbar(). When the shell itself survives, re-initialising alone is enough: the init is idempotent per shell and tears a stale toolbar down by itself.

🛑 Run the destroys BEFORE dropping the old shell. Both give back nodes they had moved elsewhere — the mobile sheet holds the filter panel, the legend and the layer manager while it is open — and they can only give them back to a parent that still exists.


Layers — feature data ​

js
GeoLeaf.Layers.getFeatures("hebergements");

Global only — not an ESM named export. Layers is mounted on window.GeoLeaf at boot. import { Layers } from "@geoleaf/core" does not work.

GeoLeaf.Layers is the single read/write surface for the feature data of any layer, whatever its geometry. It is what replaced GeoLeaf.POI, dissolved in v3: a "POI" is now simply a point feature of a normal layer, styled by the taxonomy and cluster capabilities and rendered on click by feature-info. Interactive POI creation lives in the @geoleaf-plugins/editor plugin (GeoLeaf.Editor.AddForm). The former @geoleaf-plugins/addpoi merged into it in v3 and GeoLeaf.AddPOI was removed without an alias.

A layer id is the one declared in the profile (config/core/layers.json).

Read

MethodSignatureDescription
getFeatures(layerId) => Feature[]All features of a layer — [] when the layer is unknown.
getFeatureById(layerId, id) => Feature | nullOne feature by stable id (feature.id or properties.id).
getFeatureCount(layerId) => numberNumber of features currently held.
listLayerIds() => string[]Ids of every layer known to the store.
hasLayer(layerId) => booleantrue when a layer with this id exists.

Find and focus — since 3.10.0

MethodSignatureDescription
search(query, { limit?, layerIds? }?) => FeatureMatch[]Features found by reference in the layers declaring searchable, best first — with no network. Default limit 10.
focus(layerId, id, { zoom? }?) => booleanFrames the feature — flies to a point (zoom 17 by default), fits a line or a polygon — and selects it. false when the layer does not hold it.
js
const [hit] = GeoLeaf.Layers.search("PT-4472");
if (hit?.featureId) GeoLeaf.Layers.focus(hit.layerId, hit.featureId);

A layer is searched only when its configuration declares searchable: { fields: [...] } — on each feature's id and on those properties. Accents, case and word order are ignored, with the text filter's own rule; an exact id comes first, then an exact value, then an id or value starting with the query, then every word found somewhere. A FeatureMatch carries label, lat, lng and, for a line or a polygon, bounds — the shape of an address-search result, so a search box or a route can take either — plus layerId, layerLabel and featureId.

It reads what the map holds, and only that. A layer read from the device store is held, so the search works off-network for what was prepared. A layer never loaded (active: false, not yet switched on) holds nothing, and a vector-tile layer keeps no feature: neither is found.

One selection per map. focus clears the previously focused feature, on whatever layer, before selecting the next. Selection is a feature-state addressed by properties.id: a feature without one is framed, not selected, and a whole-collection write to its layer (setData, a filter re-feed) clears it. The selected state is painted on a point's circle stroke and on a line's or a polygon outline's stroke — setFeatureState(layerId, id, { selected: true }) shows it too.

Read — visibility

MethodSignatureDescription
isVisible(layerId) => booleanPhysical: the layer is painted right now. A zoom range can force this false while the layer is still switched on.
isEnabled(layerId) => booleanIntent: the layer is switched on, whatever the map is painting. This is what a visibility toggle must reflect.
isUserOverridden(layerId) => booleanAuthorship: a user action set this state, rather than a theme, a zoom threshold or the initial load.
getVisibilitySource(layerId) => "user" | "theme" | "zoom" | "system" | nullWhich authority set it. null for an unknown layer.

Four accessors answering four different questions, and picking the wrong one is the mistake this split exists to prevent. Ask isVisible for "what is on screen" — populating a list of layers the user can currently see, for instance. Ask isEnabled to drive a toggle: it does not flip on its own when the viewer zooms out of the layer's range, so a control bound to it stays where the user put it. Ask isUserOverridden before letting an automatic rule move a layer: it says whether a human already decided, which the other two cannot. Ask getVisibilitySource when "not the user" is not precise enough — it is the only one that tells a theme from a zoom threshold.

getVisibilitySource is the PULL route, and the push one already exists. Every change emits geoleaf:layer:toggle carrying the same source, so a consumer subscribed from the start can keep its own table. What no subscription gives is the source of the state as it stands now, to code that mounted afterwards — a panel rebuilt on navigation, a host re-attaching to a live map. ⚠️ The recorded source lags a refused write: a change blocked because a higher-ranking source holds the layer leaves the previous source in place. Read it as "who decided what is displayed", not "who asked last".

Read — style

MethodSignatureDescription
getStyle(layerId) => { id, label } | nullWhich style the layer wears. null when none is applied, or the layer is unknown.

Both fields are string | null and always present. label is null when the applied style carries no display name.

A projection, and the narrowness is the point. The runtime entry holds a currentStyle whose shape depends on which writer spoke last: applying a style assigns the flattened paint handed to the adapter, which carries no id and no label, while the style selector restores the full style document over it. Reading that field directly gives you one or the other with no way to tell — a defect already shipped once, where the labels toggle greyed out for good. getStyle returns the two fields that survive both writers.

⚠️ The paint is deliberately not exposed. style and styleRules are precisely the part whose shape is unstable, and published subpaths are added, never removed — engraving them here would make that instability permanent.

Write — visibility

MethodSignatureDescription
setVisibility(layerId, visible, source) => booleanSwitches a layer and names who is asking. true when it changed.

source is one of "user", "theme", "zoom", "system", and it is required.

Most callers want GeoLeaf.GeoJSON.showLayer / hideLayer / toggleLayer, not this. The trio carries the user's own actions and records them as "user"; it is the right route for a click. setVisibility exists for the one thing the trio cannot express — attributing a change to something else: restoring a saved state as "system", applying your own rule as "theme". The distinction is not cosmetic: a layer switched on as "user" is marked as overridden, and automatic rules will then leave it alone. Restoring a session as "user" would silently pin every layer it touched.

⚠️ The physical state is recomputed against the current zoom before the call returns, so a layer outside its zoom range records its intent without being painted — the same two-step the trio performs. A false return means the layer is unknown, or a higher-priority source already holds it.

js
// Restoring a saved session — the map must not read this as the user's own doing.
for (const [layerId, visible] of Object.entries(savedLayerState)) {
    GeoLeaf.Layers.setVisibility(layerId, visible, "system");
}

Write — base dataset

MethodSignatureDescription
create(def) => Promise<CreatedLayer|null>Creates a layer the active profile does not declare. See below.
setData(layerId, features) => voidReplaces the base features, re-renders the source, emits.
clear(layerId) => voidEmpties a layer (same as setData(layerId, [])).

Creating a layer at runtime

create takes the same layer definition a profile declares — id, an optional label, and a source (url, dataFile, or a data block) — and loads it through the same path, so the result is indistinguishable from a profile-declared layer once mounted. A relative dataFile resolves against the active profile.

It is a create, not an upsert: an id that already names a layer throws rather than being silently overwritten. Use setData to rewrite the features of a layer that exists.

When you hold the data yourself — inlineData

A source does not have to be a URL. inlineData carries the payload itself, in the shape a fetched url would have returned, and replaces url/dataFile entirely — for a layer whose features the host fetches on its own (a bounding-box query against its own backend, say). It goes through the same mapping, conversion and rendering, and the layer lands in the layer manager like any other, so it can be toggled.

inlineData is runtime-only, and a profile may not carry it. Every other key of a layer definition is shared between profiles and runtime; this one is not, because a profile is a static document and a payload inlined there would be data frozen into configuration — which is what dataFile exists to avoid. The restriction is enforced, not just advised: the layer schema rejects unknown keys, so a profile declaring it fails validation.

Kept out of the layer manager — showInLayerManager: false

A definition carrying showInLayerManager: false gets no row in the layer manager. The layer is loaded and rendered exactly as without the key, and stays drivable through GeoLeaf.GeoJSON.showLayer / hideLayer: only the toggle is gone — for a layer the user is not meant to switch, such as a snapping support or a computation layer. A profile's layer file accepts the same key. Since 3.9.0.

The geometry of a definition — and the spellings only create reads

geometry, or its alias geometryType, takes the profile vocabulary: point, multipoint, line, polyline, multiline, polygon, multipolygon, fill-extrusion. create does not validate a definition against the layer schema, so it also reads what a profile may not write — those kinds in any letter case, linestring and multilinestring, and the GeoJSON type names as they are written (Point, LineString, MultiPolygon…).

mixed is for a vector-tile layer whose source-layer holds points, lines and polygons at once: the layer then draws all three, each sub-layer confined to its own geometry. Since 3.15.0 a profile may declare it too, on a layer that names its tiles (data.vectorTiles.tilesUrl), and on no other.

On a GeoJSON layer the data decides which sub-layers are built: a declared kind never narrows them, and a word that names no geometry — mixed included — adds none. On a vector-tile layer, where there is no data to scan, such a word draws nothing, and a warning says so.

⚠️ Without any of the four — url, dataFile, a data block or inlineData — the definition has no source, and create resolves to null rather than throwing. Check the result: a null here means the source did not resolve, which is a property of the data, not a caller error.

js
// A layer the host fills itself — no URL for GeoLeaf to fetch.
document.addEventListener("geoleaf:app:ready", async () => {
    const collection = await myBackend.fetchWithinBounds(GeoLeaf.Core.getMap().getBounds());
    const layer = await GeoLeaf.Layers.create({
        id: "features-in-view",
        label: "Features in view",
        geometry: "point",
        inlineData: collection,
    });
    if (!layer) console.warn("layer not created — no resolvable source");
});
js
document.addEventListener("geoleaf:app:ready", async () => {
    await GeoLeaf.Layers.create({
        id: "interventions-du-jour",
        label: "Interventions du jour",
        url: "/data/interventions-2026-09-04.geojson",
        geometry: "point",
    });
});

Write — unit mutations

MethodSignatureDescription
addFeature(layerId, feature) => voidAppends one feature.
removeFeature(layerId, id) => booleanRemoves one feature by id; true when one was removed.
updateFeatureId(layerId, oldId, newId) => voidRe-keys a feature (temp id → server id), on id and properties.id.
patchFeature(layerId, id, patch, opts?) => voidMerges patch into properties (baked — survives source rebuilds). Silent by default; pass { rerender: true } to re-render now.

Filtered display — without mutating the base dataset

MethodSignatureDescription
setVisibleSubset(layerId, predicate) => voidDisplays only matching features. The subset is re-derived from the full base on each apply (GPU setFilter id-match, JS predicate fallback). Every geometry is filtered alike, lines included; a vector-tile layer keeps no feature in memory and is left as it is.
clearVisibleSubset(layerId) => voidShows again what the subset hid — and lifts the panel's filter with it, which writes the same place. Features hidden by hideFeatures stay hidden.
hideFeatures(layerId, ids | null) => voidHides the listed features — matched on properties.id as a string, and on a vector-tile layer on the tile's own feature id too — without touching the store or the layer's filter (the panel's, or a visible subset, which share it): the layer shows what passes that filter and is not hidden. On a clustered layer, a hidden point still counts in its cluster's bubble. null or [] shows them again. For a feature being edited, whose copy is drawn elsewhere. Since 3.12.0.

Reactive paint & merge

MethodSignatureDescription
setFeatureState(layerId, id, state) => voidEphemeral GPU-side state (sync badge, hover, selection). Requires promoteId on the source, and is cleared by any source rebuild (setData) — use patchFeature for state that must persist.
mergeFeatures(layerId, features) => voidUpserts features, de-duplicated by id (offline replay).

Every mutation of the store through these methods — setData, clear, addFeature, removeFeature (when it removed something), updateFeatureId, mergeFeatures, and patchFeature with { rerender: true } — is announced once by geoleaf:layer:updated ({ layerId }, since 3.12.0). A silent patchFeature is not — it changes what is held without redrawing it —, nor are a visible subset and hideFeatures, which change what is drawn, not what is held. Since 3.15.0 a writer of a whole collection outside GeoLeaf.Layers is announced too, once per write: a real-time layer's ticks, an OGC layer's auto-refresh, a direct GeoLeaf.GeoJSON.updateLayerData — the open table and the active filter follow them.

Example — add a point after boot

js
// `Layers` needs the layer to exist: defer to app:ready.
document.addEventListener("geoleaf:app:ready", () => {
    GeoLeaf.Layers.addFeature("hebergements", {
        type: "Feature",
        geometry: { type: "Point", coordinates: [2.3522, 48.8566] },
        properties: { id: "paris", title: "Paris", categoryId: "hotel" },
    });
});

Migrating from GeoLeaf.POI

Removed (v2)Use instead
POI.getAllPois() / getPoiById() / getDisplayedPoisCount()Layers.getFeatures / getFeatureById / getFeatureCount
POI.displayPois() / reload()Layers.setData
POI.addPoi() / add()Layers.addFeature
POI.setFilteredDisplay()Layers.setVisibleSubset
POI.updatePoiId()Layers.updateFeatureId
POI.updatePoiSyncStatus()Layers.patchFeature (persisted) + setFeatureState (live badge)
POI.loadAndMergeStoredPois()Layers.mergeFeatures
POI.getLayer()Removed — it always returned null.
POI.resolveCategoryDisplay()Taxonomy.resolvePoiIcon
POI.init() / showPoiDetails() / openSidePanel()Removed — rendering is configured per layer under layers.<id>.capabilities.feature-info.

Taxonomy — the point symbol ​

js
GeoLeaf.Taxonomy.getLayerCategories("hebergements");

Global only — not an ESM named export. import { Taxonomy } from "@geoleaf/core" does not work.

Since v3, the taxonomy capability owns the symbol of the point, and nothing else: the icon, its colour, the marker disc (fill / stroke) and the colour of the category / sub-category pill badges. The colour of the geometry (polygon fill, line stroke, and the business colour of points) and the size of the point belong to the layer's styleRules.

Configured under modules.taxonomy (config/plugins/taxonomy.json). Opt-out: active unless modules.taxonomy.enabled: false — and the gate is total: disabled, every reader below returns empty, and map icons, disc, pills, legend icons and category filter options all switch off.

A layer receives a taxonomy only if it is bound to one: modules.taxonomy.layers.<id>.use.

MethodSignatureDescription
isEnabled() => booleanWhether the capability is active.
getIcons() => TaxonomyIconsConfig | nullThe modules.taxonomy.icons block (spriteUrl, symbolPrefix, defaultIcon, showOnMap, iconSize).
getCategories(ref: string) => Record<string, TaxonomyCategory>Categories of a named taxonomy (e.g. "poi-cat").
getLayerCategories(layerId: string) => Record<string, TaxonomyCategory>Categories bound to a layer — resolves layers.<id>.use for you. {} if unbound, unknown, or disabled.
getFieldMappings(ref: string) => TaxonomyFieldMappingsDeclarative field → value → categoryId mapping (raw attributes, e.g. OSM fclass).
resolvePoiIcon(feature) => ResolvedIconResolves a feature's map icon.
getIconVariants() => TaxonomyIconVariant[]The tinted icon variants to register as MapLibre images.
resolveMarkerPaint(layerId, existingPaint) => Record<string, unknown> | nullComposes the disc paint; null when taxonomy overrides nothing.
resolveTitleIcon(layerId, feature, surface) => string | nullIcon next to the title in a popup / tooltip / sidepanel.
resolveBadgeStyle(layerId, feature, surface, field) => ResolvedBadgeStyle | nullPill badge colours (needs render.<surface>.colorBadges).
ensureSprite() => voidEnsures the SVG sprite is fetched and injected.

WARNING

resolvePoiIcon and resolveTitleIcon are not interchangeable. resolvePoiIcon returns a tinted MapLibre atlas id (a raster image registered on the map); resolveTitleIcon returns a raw DOM id, for <use href="#…"> in an info surface. Swapping them renders nothing.

Prefer getLayerCategories(layerId) over getCategories(ref) whenever you start from a layer.getCategories expects the name of a taxonomy, which only the layers binding table knows. Reading that binding yourself means re-implementing the resolution — which is exactly what cost the AddPOI form its category lists, empty on every profile and without a single message (fixed in v3.0.0).


UI — interface controls ​

ts
import { UI } from "@geoleaf/core";
// or: GeoLeaf.UI (CDN/global)

UI manages controls, panels, content builder, and notification system.


ThemeSelector — the theme bar ​

ts
// GeoLeaf.ThemeSelector (global namespace)
await GeoLeaf.ThemeSelector.setTheme("nature");
await GeoLeaf.ThemeSelector.setTheme("nature", { keepView: true });
MethodSignatureDescription
setTheme(themeId: string, options?: { keepView?: boolean }) => Promise<void>Applies a theme; rejects when the selector is not initialised or the theme is unknown. A theme that declares a view frames the map on it; keepView leaves the camera where it is
getCurrentTheme() => string | nullThe active theme id, or null before a theme is applied

A theme's view is declared in the profile (themes[].view — see the profile reference).


Filter — the filter panel (singular) ​

js
GeoLeaf.Filter.getActiveFilter();

WARNING

Filter (singular) and Filters (plural) are two different things. Filter is the in-core filter capability — the panel your users actually interact with. Filters is a small legacy helper namespace (see below). If you are looking for text, category or proximity filtering, it is Filter.

Global only — not an ESM named export.

Configured under modules.filter (config/plugins/filter.json), opt-out (active unless modules.filter.enabled: false). Field kinds: taxonomy / tag / range / text / boolean / proximity.

MethodSignatureDescription
isEnabled() => booleanWhether the capability is active.
getConfig() => objectThe resolved modules.filter block.
getActiveFilter() => objectThe active filter state — serialisable (used by permalink).
applyFilter(state) => voidWrites a state onto the panel's controls, then applies it.
applyNow() => voidApplies the current panel state immediately.
reset() => voidClears all filters.
hasActiveFilters() => booleanWhether any filter is currently narrowing the display.
proximity{ setRadius, toggle }Geographic proximity sub-controls.

The state getActiveFilter() returns is { fields: [...] }: one entry per constrained field, { id, kind } plus the payload of its kind (text, values, range, bool, proximity). A taxonomy field carries:

  • values — the checked categories, then the checked sub-categories, flat. The map is filtered on this list.
  • subValues — only when at least one sub-category is checked: one { value, category } per checked sub-category, in panel order, category being the category it is listed under. A sub-category id is unique only within its category, so the pair says which one was checked. Every value is also in values and nothing is deduplicated: the checked categories are values minus subValues[].value, counted with multiplicity.
js
const { fields } = GeoLeaf.Filter.getActiveFilter();
// "NATURE" checked, which checks its two sub-categories:
// { id: "categories", kind: "taxonomy",
//   values: ["NATURE", "PARC", "LAC"],
//   subValues: [
//     { value: "PARC", category: "NATURE" },
//     { value: "LAC", category: "NATURE" },
//   ] }

applyFilter(state) takes the same shape back. It sets the panel's controls to state — a panel field left out of it is cleared — and filters the map on state alone; the proximity toolbar is not touched. With subValues, a sub-category id listed under several categories is checked only under those named; without it — a state saved by an earlier version, or a link written before 3.15.0 — the panel is restored from values alone, as before.

A range field carries range: { min?, max? } — only the bounds the user moved. Its panel control is one slider, the lower bound, unless the field declares bounds: "both" (since 3.15.0): two sliders that cannot cross. applyFilter() may hand an upper bound to a field that offers one slider: the map is filtered on it, and the slider shows the lower bound alone.


Filters — removed in v3.1 (plural) ​

BREAKING (v3.1.0) — the Filters namespace is gone, both as a named ESM export (import { Filters } from "@geoleaf/core") and as a global (GeoLeaf.Filters).

It held exactly one function, filterRouteList(baseRoutes, filterState), and that function had no caller anywhere. What made it worth removing rather than keeping was not its size but its name: one letter from GeoLeaf.Filter, a different object with eight members and a permalink serialisation contract. The typed one was not on the ESM root entry; the untyped one was.

Migration. There is no drop-in replacement, and none is needed:

  • for filtering the map, use the Filter capability above (getActiveFilter(), applyFilter(state)) — unchanged;
  • for filtering a plain array in your own code, use Array.prototype.filter. That is all filterRouteList ever did.

Earlier removals from this namespace (v3.0.0, no consumer): filterPoiList and the six statistics helpers getUniqueCategories, getUniqueSubCategories, getUniqueTags, countByCategory, countBySubCategory, getRatingStats.


Table — data table ​

INFO

The data table has been extracted from the core into the MIT plugin @geoleaf-plugins/table. See the plugin README for installation, configuration (modules.table.*), and migration.


Legend — legend panel ​

ts
import { Legend } from "@geoleaf/core";
// or: GeoLeaf.Legend (CDN/global) — mounted at boot if `modules.legend` is enabled

Legend is lazy-loaded.

Note: GeoLeaf.Legend and GeoLeaf.LayerManager are independent modules with separate implementations. Legend manages visual legend panels generated from layer style data. LayerManager manages GeoJSON layer loading and visibility controls.

MethodSignatureDescription
init(mapInstance: any, options?: object) => booleanInitialise the module with a map instance
loadLayerLegend(layerId: string, styleId: string, layerConfig: object) => voidLoad and display the legend of a layer
setLayerVisibility(layerId: string, visible: boolean) => voidShow / hide a layer from the legend
getAllLayers() => Map<string, object>Return every layer known to the legend
hideLegend() => voidHide the legend panel
removeLegend() => voidRemove the legend panel from the DOM
isLegendVisible() => booleanReturn the current visibility of the panel
toggleAccordion(layerId: string) => voidDoes not drive the accordion. Notification hook the renderer calls after it has toggled the accordion itself; its body is empty. Calling it neither opens nor closes anything. Drive the accordion by clicking, or by toggling gl-legend__accordion--collapsed on the element

LayerManager — GeoJSON layers ​

ts
import { LayerManager } from "@geoleaf/core";
// or: GeoLeaf.LayerManager (CDN/global) — posted at import

LayerManager manages GeoJSON layers loaded from profiles. It is the primary way to work with GeoJSON data in GeoLeaf.

Note: GeoLeaf.GeoJSON is public too — global-only, not a named ESM export: layer-level operations (getLayerById, showLayer, setLayerStyle…) and the URL of its worker, below.

The GeoJSON worker URL ​

js
GeoLeaf.GeoJSON.setWorkerUrl("/assets/geoleaf/geojson-worker.js?v=3f9a2c");

GeoJSON files are fetched and parsed off the main thread, in a Web Worker built from geojson-worker.js. Its default URL is looked up once, when the bundle loads, in the page's <script src> tags: the last one whose URL names a geoleaf…js file gives the directory, without its query string; when none does, geojson-worker.js resolves against the page.

  • A <script type="module"> whose src is geoleaf.esm.js — the bundle's own directory, unless another such tag, from another directory, comes after it in the page (a plugin's geoleaf-*.plugin.js).
  • An inline <script type="module"> that imports the bundle — the form of every HTML recipe in usage-cdn.md — adds no such tag: the default resolves against the page, unless the page loads a plugin by <script src> (geoleaf-*.plugin.js), whose directory is then used.
  • A bundler build — the result depends on the scripts the bundler emits.

setWorkerUrl is how a host whose default does not reach the worker points at it at all — and how a host that serves its files under a content token (?v=…) with a long cache lifetime keeps a new release from running against a cached worker from the previous one.

CallEffect
setWorkerUrl(url)The next worker is built from url, resolved by the browser against the page — same-origin
setWorkerUrl(null)Back to the default
any other valueTypeError

Call it once the bundle has loaded and before GeoLeaf.boot() to cover the first worker. A URL set later applies to the next worker built: a running worker keeps its script until it is torn down — after an idle delay, by dispose(), or by an error.

When a worker fails — its script did not load (a 404), or it crashed — the loads in flight none of whose chunks had arrived are replayed on the main thread; a load that had received chunks is rejected. A script that did not load is logged as a worker error naming the URL that worker was built from. Later loads go to the main thread for the rest of the session only if the URL that failed is still the current one: a URL set after that worker was built is tried at the next load, and any valid call to setWorkerUrl — a URL or null — clears an earlier failure. Since v3.7.0.


Baselayers — base tiles ​

ts
import { Baselayers } from "@geoleaf/core";
// or: GeoLeaf.Baselayers (CDN/global)

Baselayers manages raster tile layers and vector tile layers (via MapLibre GL). Its members — setBaseLayer, refreshBasemap and the others — are described in the Baselayers guide.


Helpers ​

ts
import { Helpers } from "@geoleaf/core";
// or: GeoLeaf.Helpers (CDN/global)

Validators ​

ts
import { Validators } from "@geoleaf/core";
// or: GeoLeaf.Validators (CDN/global)

API sub-modules ​

ts
import {
    APIController,
    APIFactoryManager,
    APIInitializationManager,
    APIModuleManager,
    PluginRegistry,
    BootInfo,
    showBootInfo,
} from "@geoleaf/core";

PluginRegistry ​

ts
GeoLeaf.plugins.isLoaded("storage"); // → boolean
GeoLeaf.plugins.getLoadedPlugins(); // → string[]
GeoLeaf.plugins.canActivate("editor"); // → boolean
GeoLeaf.plugins.register("name", metadata); // called by plugins
GeoLeaf.plugins.registerLazy("name", fn); // called by bundle-entry
GeoLeaf.plugins.registerLazyForAction("print", "print", ui); // before boot: a button for a plugin not loaded yet

showBootInfo / BootInfo ​

ts
import { showBootInfo } from "@geoleaf/core";

// The namespace is the FIRST parameter and is NOT optional. Called without it, the function
// returns immediately (`if (!GeoLeaf) return;`): it does not warn, it does nothing.
type BootNs = Parameters<typeof showBootInfo>[0];

showBootInfo(GeoLeaf as BootNs); // show the boot toast
showBootInfo(GeoLeaf as BootNs, { force: true, duration: 8000 }); // override `debug.showBootInfo`

WARNING

The cast is required today. showBootInfo is exported publicly, but the type of its parameter (BootInfoNamespace) is not — an integrator cannot even name it, hence the Parameters<…>[0] above. And GeoLeafGlobal does not satisfy it structurally: its _APIController is declared unknown where the contract expects { init(): boolean } | null. In practice this function is called by the core boot sequence; a manual call is rarely useful.


Log ​

ts
import { Log } from "@geoleaf/core";
// or: GeoLeaf.Log (CDN/global)

Log.info("message");
Log.warn("message");
Log.error("message", error);

Errors — typed error classes ​

ts
import { Errors } from "@geoleaf/core";
// or: GeoLeaf.Errors (CDN/global)

9 typed error classes (see errors/GeoLeaf_Errors_README.md).


CONSTANTS ​

ts
import { CONSTANTS } from "@geoleaf/core";
// or: GeoLeaf.CONSTANTS (CDN/global)

CONSTANTS.DEFAULT_CENTER; // Default map center
CONSTANTS.DEFAULT_ZOOM; // Default zoom level
CONSTANTS.MAX_ZOOM_ON_FIT; // Zoom ceiling applied by fitBounds
// … see constants/GeoLeaf_Constants_README.md for full list

WARNING

CONSTANTS carries no VERSION. The object is Object.freezed over 15 keys covering map, POI, route, GeoJSON and UI. The library version has no public accessor: it is posted on GeoLeaf._version, whose _ prefix says exactly what to make of it. Read package.json on the integrator side.


Utils ​

ts
import { Utils } from "@geoleaf/core";
// or: GeoLeaf.Utils (CDN/global)

9 utility sub-modules registered on GeoLeaf.Utils:

Sub-moduleDescription
DOMSecuritySafe DOM manipulation
ErrorLoggerError logging utilities
EventListenerManagerManaged event listeners
EventBusPub/sub event bus
FetchHelperFetch with cache/timeout
MapHelpersMapLibre GL JS map utilities
PerformanceProfilerTiming/perf profiling
TimerManagerManaged timers/debounce
ObjectUtils / ScaleUtilsObject & scale helpers

WARNING

PerformanceProfiler.analyzeMemoryLeaks() will normally answer unavailable, and that is the honest answer. Its only source of heap figures is performance.memory, which Chrome quantises and then caches for the lifetime of the page (and which no other browser exposes at all), so the samples it judges are usually identical to the byte. Rather than return a reassuring normal computed from an input that never moved, the method reports { status: "unavailable", reason } and explains itself in recommendation. Do not read unavailable as "no leak" — it means the browser gave nothing to judge. Timing marks, measures and the rest of generateReport() are unaffected.


Config ​

ts
import { Config } from "@geoleaf/core";
// or: GeoLeaf.Config (CDN/global)

Config.get("map"); // Get a config section
Config.get("ui"); // Get UI config
Config.set("key", value); // Set a config value

⚠️ A value set before boot() is not final. The boot loads the application configuration, merged over it key by key, then the active profile. A modular profile replaces each top-level section it carries (ui among them). layers, themes and mapping stay on the profile, and the modules bag is merged module by module. To override a value that the profile declares, set it in the beforeBoot hook, which runs after both loads and before any module reads the configuration:

ts
GeoLeaf.boot({
    beforeBoot: () => {
        GeoLeaf.Config?.set("ui.language", "en"); // the profile says "fr"
    },
});

Per-module configuration (modules.*) ​

Plugin configuration is declared under a modules.<id> block in the profile (e.g. modules.storage, modules.print). Legacy root keys (storage, poiAddConfig, printConfig, measureConfig, editorConfig) are deprecated but keep working during the transition (a one-time console warning is emitted per module).

ts
// Reads modules.<id>.<key>, falling back on the module's legacy root key
Config.getModuleConfig("storage", "cache.enableProfileCache", true);
// Dot-notation equivalent (no legacy fallback)
Config.get("modules.storage.cache.enableProfileCache");

WARNING

Extracted into a plugin. Address search (geocoding) is no longer part of @geoleaf/core — it is provided by the MIT plugin @geoleaf-plugins/geocoding (public on npmjs.org). Configuration moves from the root key geocodingConfig to modules.geocoding.* (declared in config/plugins/geocoding.json through Files.modules.geocoding) — a breaking migration, with no shim. The GeoLeaf.Geocoding API, the geoleaf:geocoding:result event and the search control are provided by the plugin. See the plugin README (packages/plugins/geocoding/README.md).

ts
import { Permalink } from "@geoleaf/core";
// or: GeoLeaf.Permalink (CDN/global)

Synchronises map state (center, zoom, active layers, filters) to the URL hash or query string. On by default; the profile configures it under modules.permalink — for instance { "modules": { "permalink": { "mode": "hash" } } }, or "enabled": false to switch it off.

MethodSignatureDescription
init(config: PermalinkConfig) => voidInitialise with the modules.permalink config block
readAndStore() => voidParse current URL and cache the state
applyStoredState(map: IMapAdapter) => voidRestore the cached state to the map adapter
startSync(map: IMapAdapter) => voidBegin continuous URL synchronisation on map move/zoom
getState() => PermalinkState | nullReturn the last cached state (read-only)
buildUrl(state?: PermalinkState | null) => stringSerialise a state to a URL fragment/query string
ts
// Typical lifecycle (handled automatically by the boot sequence):
Permalink.init(config.modules.permalink);
Permalink.readAndStore(); // before map creation
Permalink.applyStoredState(map); // after map + modules are ready
Permalink.startSync(map); // begin sync

The filter in a link — one parameter per field (since 3.15.0). Each constrained field of the filter panel is written under its own parameter, named by the field's id in modules.filter.fields:

Field kindParameter and value
textgl_f.<id>=<query>
taggl_f.<id>=a,b,c
rangegl_f.<id>=<min>..<max> — an open bound is left empty: 100.., ..500
taxonomygl_f.<id>=a,b,c, and its checked sub-categories under gl_f.<id>.sub=category/sub,…

boolean and proximity fields are not carried. In getState() these parameters are fieldFilters, a map of field id to the value as written.

A link written before 3.15.0 ranged the filter by kind — gl_filter, gl_cats, gl_tags, gl_rating — so two fields of the same kind shared one slot, and a range kept its lower bound. Those links are still restored, as they were; the application no longer writes them, and a link restored from them is rewritten per field at the next change. A link carrying both is read per field. Beyond 200 characters a link switches to the compact form (gl=…), as before.

Security: lat, lng, zoom are validated via validateNumber() / validateCoordinates(). Layer IDs are string-filtered (max 100 entries). Filter text is truncated to 200 chars. Per-field entries are capped in number and length, a range must read as a finite interval, and an entry is applied only to a field whose kind the fields whitelist lets through.


Notifications — toast notifications ​

ts
import { Notifications } from "@geoleaf/core";
// or: GeoLeaf.Notifications (CDN/global) — or shortcut: GeoLeaf.notify(msg, type)

Displays non-blocking toast messages. Queued automatically when the DOM is not yet ready.

Toast below is HTMLElement | null | undefined — the toast element, null when the queue rejected the notification, undefined when the renderer is not initialised.

MethodSignatureDescription
notify(msg, typeOrOpts?, ms?) => ToastDisplay a toast (generic)
show(msg, typeOrOpts?, ms?) => ToastAlias of notify
success(msg, msOrOpts?) => ToastGreen success toast
error(msg, msOrOpts?) => ToastRed error toast (5 s default, persistent option)
warning(msg, msOrOpts?) => ToastYellow warning toast
info(msg, msOrOpts?) => ToastBlue informational toast
dismiss(toastEl: HTMLElement) => voidDismiss a specific toast
clearAll() => voidRemove all toasts and clear the queue
getStatus() => NotifyStatusReturn current system state (activeToasts, queued)

The second argument is either a duration in milliseconds or an options object — never both. The third argument only applies to notify / show, and only when the second one is a type string.

ts
Notifications.success("Data loaded");
Notifications.error("Loading failed", { persistent: true, dismissible: true });
Notifications.notify("Info", "info", 8000); // positional: type + duration (ms)
Notifications.notify("Info", { type: "info", duration: 8000 }); // or an options object

// Keep the handle to dismiss a persistent toast yourself
const toast = Notifications.info("Import in progress…", { persistent: true });
if (toast) Notifications.dismiss(toast);

Action buttons (field renderer type: "action" in popup.fields[], rendered by the in-core feature-info capability on the popup and the side panel) dispatch the geoleaf:popup:action DOM event on click. Listen via GeoLeaf.Events.on("geoleaf:popup:action", …) (see EVENTS_API.md). There is no handler-registry facade — the core stays backend-agnostic and actionId semantics are entirely the host's.

Since 14/08/2026 the payload is no longer JSON-only: alongside the data fields it carries button (the clicked node), setBusy(busy) and close(). JSON.stringify(e.detail) therefore throws — copy the fields you need. And properties is empty unless the button declares payloadFields; both points are detailed in EVENTS_API.md.

ts
// Example — open a host form when a popup action button is clicked
GeoLeaf.Events.on("geoleaf:popup:action", async (e) => {
    const { actionId, layerId, featureId, properties } = e.detail;
    if (actionId !== "host:open-form") return;
    await fetch("/api/poi/open", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ id: featureId, layerId }),
    });
});

Security: actionId is validated (token ^[A-Za-z0-9:_-]{1,64}$) at render and dispatch; labels are HTML-escaped. Buttons render as <button type="button" data-action-id> (no inline onclick).


PWA — install prompt ​

ts
import { PWA } from "@geoleaf/core";
// or: GeoLeaf.PWA (CDN/global)

Manages the Progressive Web App install banner (iOS) and install prompt (Chrome/Android) — activated automatically by the boot sequence when modules.pwa.installPrompt.enabled: true in the profile — and the application's updates: whether a new version waits, and the gesture that applies it.

MethodSignatureDescription
init(config: PWAConfig) => voidInitialise PWA features (called by boot — opt-in only)
isInstallable() => booleantrue when the app can still be installed on this device — see below
isUpdateWaiting() => booleantrue when a newer version is installed and this page has not been reloaded onto it
applyUpdate() => booleanApplies the waiting version — the page reloads. false when nothing was waiting

isInstallable() ​

Use it to render your own install button instead of the built-in banner:

ts
if (GeoLeaf.PWA.isInstallable()) {
    myInstallButton.hidden = false;
}

It mirrors the platform split of the banner:

  • iOS Safari — true when running on iOS and not already installed. iOS never fires beforeinstallprompt, so this is the only signal available there.
  • Android / Chrome / Edge — true once the browser has offered a deferred install prompt.

WARNING

On Android this answers "a prompt is available", not "this browser could install the app". The deferred prompt is only captured when installPrompt.enabled is true — with the banner disabled, it returns false even on an installable Chrome. iOS is unaffected.

isUpdateWaiting() and applyUpdate() ​

A new version of the application does not take over a page that is in use: its service worker installs, then waits, and geoleaf:sw:update-waiting is emitted on document. It takes over when applyUpdate() is called, or once every tab of the application has been closed.

ts
const offerReload = () => {
    myReloadButton.hidden = false;
};
// Emitted once per waiting version — possibly before this listener exists.
document.addEventListener("geoleaf:sw:update-waiting", offerReload);
if (GeoLeaf.PWA.isUpdateWaiting()) offerReload();

myReloadButton.addEventListener("click", () => GeoLeaf.PWA.applyUpdate());
  • isUpdateWaiting() is false at a first install, when modules.pwa.enabled is not true, and until the service worker has been registered — at idle, up to three seconds after the boot. It stays true in a tab whose update was applied from another tab: the worker changed under that page, which still runs the previous version's code.
  • applyUpdate() returns true when an update is being applied: the new worker takes over, then the page reloads — once, and not instantly: the browser waits for the previous worker to finish the requests it is serving. Call it from a user gesture — what the page holds in memory is lost. It returns false, and does nothing, when nothing was waiting.
  • @geoleaf-plugins/offline-ui asks the question for you, with a « Reload » banner. Without that plugin, nothing asks: the new version waits until the tabs are closed.
  • geoleaf:sw:updated says something else — a worker activated — and also fires at a first install.

See PWA — When a new version is deployed.

ts
// Opt-in, in the profile:
// { "modules": { "pwa": { "installPrompt": { "enabled": true } } } }

// Manual check (advanced):
GeoLeaf.PWA.init({ installPrompt: { enabled: true } });

Note: PWA features are opt-in. Without installPrompt.enabled: true, GeoLeaf.PWA.init() is a no-op.

⚠️ The capability's own boot gate (modules.pwa.enabled) is not a no-op when closed: it unregisters GeoLeaf's service worker, so that a returning visitor really gets "no SW". It unregisters only sw-core.js — a service worker your own application registered is never touched, at boot or at teardown.


Labels — layer labels ​

js
GeoLeaf.Labels.syncLayerControl("hebergements");

GeoLeaf.Labels drives the text labels a layer paints, and the 🏷️ control that the layer manager shows for it. Gated by modules.labels.

MethodSignatureDescription
isEnabled() => booleantrue unless modules.labels.enabled is false.
getConfig() => objectThe resolved modules.labels block, merged over the defaults.
areLabelsEnabled(layerId) => booleantrue when this layer is currently showing its labels.
toggleLabels(layerId) => booleanFlips the labels of one layer; returns the new state (false if the style forbids them).
refreshLabels(layerId) => voidRe-renders the labels themselves after their source or style changed.
syncLayerControl(layerId) => voidRepaints the layer row's 🏷️ control so it agrees with the layer. See below.

When to call syncLayerControl

The rest of this facade drives the label state; syncLayerControl drives the control that displays it. The two part company whenever a host changes a layer's visibility or style through its own path rather than through this facade: the state is right, and the button still shows what was true before. Calling it puts the button back in agreement with the layer. It is idempotent, synchronous, and a no-op on a layer with no row on screen.

refreshLabels is not a substitute — it re-renders the labels on the map, never the toggle.

Composing a lighter bundle ​

REMOVED in v3 — GeoLeaf._loadModule() and GeoLeaf._loadAllSecondaryModules() no longer exist, and nothing replaces them. The lite build is gone too. Delete the calls.

Every in-core capability ships in geoleaf.esm.js. A config flag (modules.<id>.enabled) turns one off; only a build-time choice takes its code out of the file. To do that, compose your own entry from the capability installers you need — see COOKBOOK Recipe 8 and the tested recipe in examples/minimal/entry.ts.


Global namespace (window.GeoLeaf.*) ​

After loading dist/geoleaf.esm.js via CDN (<script type="module">), properties are available on window.GeoLeaf in two waves.

1. Kernel facades — posted at import, before GeoLeaf.boot()

A script or plugin loaded before boot() can already call them. (This restored a behaviour that an internal v2.x refactor had silently removed: the surface available at import went back from 64 to 88 keys in v3.)

PropertyDescription
GeoLeaf.boot() · GeoLeaf.mount()Start the application — mount() returns the handle that unmounts it: see The application lifecycle
GeoLeaf.CoreMap lifecycle
GeoLeaf.LayersFeature data — see Layers
GeoLeaf.UIUI controls
GeoLeaf.LayerManagerLayer management
GeoLeaf.BaselayersBase tile layers
GeoLeaf.Helpers · GeoLeaf.ValidatorsHelpers, input validators
GeoLeaf.EventsDOM event bus (on() returns its unsubscribe)
GeoLeaf.StorageOffline storage and the write queue — inert until modules.offline loads the engine: see Offline write cycle
GeoLeaf.I18nregisterDict / getLabel / t
GeoLeaf.Utils · GeoLeaf.CONSTANTSUtilities, constants
GeoLeaf.Log · GeoLeaf.ErrorsLogging, typed error classes
GeoLeaf.SecurityXSS protection
GeoLeaf.ConfigConfig access
GeoLeaf.IntrospectionCapability schemas, and their activation verdict
GeoLeaf.pluginsPlugin registry
GeoLeaf.notify()Notification primitive (buffered)
GeoLeaf._versionVersion string

2. Capability facades — mounted at boot, gated by configuration

Each is posted by its capability's installer and only if its gate is open (modules.<id>.enabled). Most are opt-out (active unless set to false); check the individual section.

PropertyGateDescription
GeoLeaf.Taxonomymodules.taxonomyThe point symbol
GeoLeaf.Filtermodules.filterFilter panel
GeoLeaf.Clustermodules.clusterPoint clustering (read-only)
GeoLeaf.Legendmodules.legendLegend panel
GeoLeaf.Permalinkmodules.permalinkURL deep linking
GeoLeaf.Sharemodules.permalink.shareShare dialog
GeoLeaf.Notificationsmodules.toast-rendererRich toast surface
GeoLeaf.PWAmodules.pwaPWA install prompt
GeoLeaf.Labelsmodules.labelsLayer labels

3. Plugin namespaces — after their script has loaded

GeoLeaf.Editor, GeoLeaf.Table, GeoLeaf.FeatureInfo, GeoLeaf.Geocoding, GeoLeaf.Measure, GeoLeaf.Print… — load the plugin script after geoleaf.esm.js and before GeoLeaf.boot().

GeoLeaf._loadModule / GeoLeaf._loadAllSecondaryModules were removed in v3 — see Composing a lighter bundle.

TypeDoc (generated): Run npm run docs:api in packages/core to generate complete API documentation from TSDoc comments into docs/api/.


Storage — what erases what ​

GeoLeaf.Storage is inert until modules.offline loads the engine. Its write queue is the offline write cycle; what a device keeps, and how a session ends, is §8 of SECURITY. Three of its methods erase something, and none erases it all:

MethodErasesLeaves aloneResolves with
Storage.clearAll()Every profile prepared for offline use — its cached files, and the cached basemap tiles — plus the engine's preferences and metadata stores. Emits geoleaf:storage:clearedEntities, edits waiting to be sent, photos, archived conflictsNothing. Rejects if the database transaction fails
Storage.clearConflicts(layerId?)The active profile's archived conflicts, of one layer or of every layer when layerId is omitted — every profile's with { allProfiles: true }. Nothing else ever purges that storeEverything elsetrue once purged, false when the engine is not loaded
Storage.discardQuarantined(id, confirmedLocalId)ONE entry set aside, on explicit confirmation: confirmedLocalId must be that entry's localId, read from Storage.DB.listPendingEdits(). Its local record then returns to the server's versionEvery other entry{ ok }, with the motive of a refusal

An edit otherwise leaves the device only when the server accepts it. Nothing is evicted to make room.

TypeScript types ​

@geoleaf/core ships TypeScript declarations. The canonical types entry point is:

ts
// tsconfig.json path (built declarations)
"types": ["@geoleaf/core"]
// Resolves to: node_modules/@geoleaf/core/dist/types/bundle-esm-entry.d.ts

TIP

The package exports 19 named types. See Extension contracts for the list and the per-contract subpaths. If you previously re-declared these interfaces in your own project, delete those declarations — that duplication is exactly what publishing the contracts was meant to end.

The generated declarations ship in dist/types/bundle-esm-entry.d.ts (resolved by package.json#types). Types that remain internal — LayerConfig, ThemeConfig and the rest of the source's type surface — are not part of the public API: do not import them by path, it is not a supported entry point. The rule is simple: if it is not re-exported from @geoleaf/core, from a ./contracts/* subpath or from ./schemas, it is not public.

Since 3.15.0 the default export is typed: import GeoLeaf from "@geoleaf/core" hands over the namespace under its ambient type, where it was unknown — the import compiled and its first dereference did not. The members of that type are optional, as they are on the global GeoLeaf: a façade exists once its module is mounted. ensureGeoLeaf() is exported too, for a module that mounts a member on the namespace:

ts
import GeoLeaf, { ensureGeoLeaf } from "@geoleaf/core";

GeoLeaf.Events?.on("geoleaf:map:ready", () => console.info("map ready"));
const loaded = ensureGeoLeaf().plugins?.isLoaded?.("table") ?? false;

A plugin's namespace — GeoLeaf.Table, GeoLeaf.Editor… — is declared by the core and typed by the plugin: its declarations add one member to the GeoLeafPluginApis interface, and the namespace takes that type for whoever installs the plugin. A namespace no installed plugin registered stays unknown: reachable, not callable.

The ambient GeoLeaf namespace types each façade member by member. Since 3.15.0 GeoLeaf.Storage carries no index signature: every member is named, and a name the façade does not carry is a compile error instead of an unknown. GeoLeaf.Storage.DB and some other namespaces (Core, plugins, Utils, Config, GeoJSON…) still end with one, so a misspelled member of theirs is not reported yet.

GeoLeaf.Storage.DB names twelve of the engine's relays: listPendingEdits, the layer cache (getLayer, cacheLayer, removeLayer, getLayersByProfile), getStorageStats, and the image store (storeImageLocally, getLocalImage, getPendingImages, updateImageUploadStatus, bindLocalImage, cleanUploadedImages). Each is typed from the engine itself, so an argument it does not take is a compile error: updateImageUploadStatus(id, status) takes an object, { uploaded, url? }, and cacheLayer(id, data, profileId) takes null for a layer that belongs to no profile. A relay it does not name is still reachable through its index signature, as unknown.

(There is no hand-written index.d.ts anywhere — the declarations are generated at build time: dist/types/ from the source, dist/schemas/ from the profile JSON Schemas. Those two generated trees are the only contract.)

Released under the MIT License.