GeoLeaf-JS — API Reference
Package: @geoleaf/coreVersion: 3.0.0 License: MIT
Auto-generated HTML docs: Run
npm run docs:apiinpackages/coreto generate the TypeDoc API reference atpackages/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
- ESM named exports (27)
- Extension contracts (TypeScript)
- Profile schemas and types
- GeoLeafAPI — top-level API
- The application lifecycle —
GeoLeaf.mount()andGeoLeaf.boot() - Core — map lifecycle
- Layers — feature data
- Taxonomy — the point symbol
- UI — interface controls
- ThemeSelector — the theme bar
- Filter — the filter panel (singular)
- Filters — removed in v3.1 (plural)
- Table — data table
- Legend — legend panel
- LayerManager — GeoJSON layers
- Baselayers — base tiles
- Helpers
- Validators
- API sub-modules
- Log
- Errors — typed error classes
- CONSTANTS
- Utils
- Config
- Geocoding — address search
- Notifications — toast notifications
- Popup — popup action buttons
- PWA — install prompt
- Labels — layer labels
- Composing a lighter bundle
- Global namespace (window.GeoLeaf.*)
- TypeScript types
ESM named exports
All symbols available via import { … } from "@geoleaf/core":
// 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-coreroutecapability),TableandThemes(extracted / removed), andSearch(dead engine purged, with theflexsearchdependency).
Many facades are global-only.
GeoLeaf.Layers,GeoLeaf.Taxonomy,GeoLeaf.Filter,GeoLeaf.Cluster,GeoLeaf.FeatureInfoandGeoLeaf.Introspectionare mounted onwindow.GeoLeafat boot but are not named ESM exports —import { Layers } from "@geoleaf/core"does not work.
GeoJSONis not a named ESM export — butGeoLeaf.GeoJSONdoes exist. It is mounted onwindow.GeoLeafas soon as the bundle is imported — beforeGeoLeaf.boot()— and typed in the ambient declarations the package ships (dist/types/global.d.ts). UseGeoLeaf.Layersfor per-layer feature data (LayerDataApi), andGeoLeaf.GeoJSONfor 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:
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:
import type { ILifecycleModule } from "@geoleaf/core/contracts/core-module.contract.js";| Subpath | Types |
|---|---|
./contracts/core-module.contract.js | ICoreModule, ILifecycleModule, IUISlotModule, IModuleRegistry, IModuleUISlot |
./contracts/capability.contract.js | ICapabilityDeclaration, ICapabilityRegistry, ICapabilitySchema, ICapabilityFieldSchema, ICapabilityConfigGate |
./contracts/config.contract.js | IGeoLeafConfig |
./contracts/map-adapter.contract.js | IMapAdapter and the geometry types it uses |
./contracts/layer-data.contract.js | LayerDataApi, LayerFeatureState |
./contracts/event-bus.contract.js | GeoLeafEventMap, 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 atypescondition and emit no JavaScript:import typeworks, and a value import fails outright rather than resolving to nothing.
ICoreModule is a union — implement ILifecycleModule
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:
// 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.
| Subpath | Content |
|---|---|
./schemas/<name>.schema.json | a JSON Schema (draft-07), one per profile file kind — ten, $id geoleaf/<name> |
./schemas | type-only: the ten root types, one per schema |
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: inconfig/core/mapping.json, a_comment…key types only as a string. Amodules.<id>block is opaque — its keys belong to the capability or plugin<id>.
./schemasistype-only, like./contracts/*:import typeworks, 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
| Method | Signature | Description |
|---|---|---|
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) => void | Apply a visual theme |
createMap | (id: string, options?: object) => object | null | Create a new managed map instance |
getMap | (id: string) => object | null | Get 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 | null | Get a registered module by name |
hasModule | (name: string) => boolean | Check if a module is registered |
getNamespace | (name: string) => object | null | Get a top-level GeoLeaf namespace by name |
getHealth | () => object | null | Get APIController health/metrics |
getMetrics | () => object | null | Alias for getHealth() |
Properties
| Property | Type | Description |
|---|---|---|
version | string | Current GeoLeaf version (e.g. "3.0.0") |
CONSTANTS | object | Reference to GeoLeaf.CONSTANTS |
BaseLayers | object | Alias 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:
| Member | Signature | Description |
|---|---|---|
ready | Promise<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) => () => void | Subscribes 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 | null | The 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:
| Plugin | What unmount() does | Since |
|---|---|---|
navigation | ends the guidance session — banner, position watch and screen wake lock | navigation 1.0.3 |
offline-ui | closes the offline window and returns its listeners; the button opens a new one | offline-ui 1.7.0 |
routing | removes the itinerary pane; the next opening builds one pane, on the new map | @geoleaf/core 3.15.0 |
cog | nothing is left; a handle of the previous map removes without error | — |
file-import | nothing is left | — |
flatgeobuf | nothing is left — an auto-refreshing layer stops asking for its file | — |
websocket | nothing, 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.fetchkeeps 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.
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(runnpm run docs:apiinpackages/core)
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.
| Method | Signature | Description |
|---|---|---|
init | (options: object) => IMapAdapter | null | Initialise a map. Requires options.mapId (returns null + logs otherwise). Re-init of an existing mapId returns the existing instance. |
getMap | (mapId?: string) => IMapAdapter | null | With mapId, the targeted instance; without arg, the first active instance (backward compatible for single-map apps). |
getAdapter | (mapId?: string) => IMapAdapter | null | Alias of getMap. |
destroy | (mapId: string) => boolean | Destroy the instance (map.remove() + free the slot). true if found. The map alone: an application unmounts with GeoLeaf.mount()'s handle. |
hasMap | (mapId: string) => boolean | Whether an instance is registered under mapId. |
listMaps | () => string[] | Ids of all active map instances. |
isAttached | (mapId: string) => boolean | Registered and its container is still in the document. Stronger than hasMap — see below. false after destroy(), never throws. |
reattach | (mapId, parent: HTMLElement) => boolean | Move a live map into another parent, without destroying it. The whole container moves. ⚠️ The panels do not follow — see below. |
setTheme | (theme: string) => void | Apply a theme to the map container. |
getTheme | () => string | Get 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:
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:
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:
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 callingdestroy()leaves a registered map that renders nowhere, andhasMap()still returnstrue.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 leavemap.getContainer()pointing at the old node. It calls the adapter's optionalresize()afterwards, so the WebGL canvas picks up the new container size.
⚠️ The shell does not follow the map.
#gl-right-paneland the mobile pill live in the shell, not inside the map container, soreattach()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
GeoLeaf.Layers.getFeatures("hebergements");Global only — not an ESM named export.
Layersis mounted onwindow.GeoLeafat 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
| Method | Signature | Description |
|---|---|---|
getFeatures | (layerId) => Feature[] | All features of a layer — [] when the layer is unknown. |
getFeatureById | (layerId, id) => Feature | null | One feature by stable id (feature.id or properties.id). |
getFeatureCount | (layerId) => number | Number of features currently held. |
listLayerIds | () => string[] | Ids of every layer known to the store. |
hasLayer | (layerId) => boolean | true when a layer with this id exists. |
Find and focus — since 3.10.0
| Method | Signature | Description |
|---|---|---|
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? }?) => boolean | Frames 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. |
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.
focusclears the previously focused feature, on whatever layer, before selecting the next. Selection is a feature-state addressed byproperties.id: a feature without one is framed, not selected, and a whole-collection write to its layer (setData, a filter re-feed) clears it. Theselectedstate 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
| Method | Signature | Description |
|---|---|---|
isVisible | (layerId) => boolean | Physical: the layer is painted right now. A zoom range can force this false while the layer is still switched on. |
isEnabled | (layerId) => boolean | Intent: the layer is switched on, whatever the map is painting. This is what a visibility toggle must reflect. |
isUserOverridden | (layerId) => boolean | Authorship: a user action set this state, rather than a theme, a zoom threshold or the initial load. |
getVisibilitySource | (layerId) => "user" | "theme" | "zoom" | "system" | null | Which 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
isVisiblefor "what is on screen" — populating a list of layers the user can currently see, for instance. AskisEnabledto 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. AskisUserOverriddenbefore letting an automatic rule move a layer: it says whether a human already decided, which the other two cannot. AskgetVisibilitySourcewhen "not the user" is not precise enough — it is the only one that tells a theme from a zoom threshold.
getVisibilitySourceis the PULL route, and the push one already exists. Every change emitsgeoleaf:layer:togglecarrying the samesource, 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
| Method | Signature | Description |
|---|---|---|
getStyle | (layerId) => { id, label } | null | Which 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
currentStylewhose shape depends on which writer spoke last: applying a style assigns the flattened paint handed to the adapter, which carries noidand nolabel, 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.getStylereturns the two fields that survive both writers.⚠️ The paint is deliberately not exposed.
styleandstyleRulesare precisely the part whose shape is unstable, and published subpaths are added, never removed — engraving them here would make that instability permanent.
Write — visibility
| Method | Signature | Description |
|---|---|---|
setVisibility | (layerId, visible, source) => boolean | Switches 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.setVisibilityexists 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.
// 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
| Method | Signature | Description |
|---|---|---|
create | (def) => Promise<CreatedLayer|null> | Creates a layer the active profile does not declare. See below. |
setData | (layerId, features) => void | Replaces the base features, re-renders the source, emits. |
clear | (layerId) => void | Empties 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.
inlineDatais 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 whatdataFileexists 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.
// 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");
});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
| Method | Signature | Description |
|---|---|---|
addFeature | (layerId, feature) => void | Appends one feature. |
removeFeature | (layerId, id) => boolean | Removes one feature by id; true when one was removed. |
updateFeatureId | (layerId, oldId, newId) => void | Re-keys a feature (temp id → server id), on id and properties.id. |
patchFeature | (layerId, id, patch, opts?) => void | Merges patch into properties (baked — survives source rebuilds). Silent by default; pass { rerender: true } to re-render now. |
Filtered display — without mutating the base dataset
| Method | Signature | Description |
|---|---|---|
setVisibleSubset | (layerId, predicate) => void | Displays 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) => void | Shows 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) => void | Hides 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
| Method | Signature | Description |
|---|---|---|
setFeatureState | (layerId, id, state) => void | Ephemeral 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) => void | Upserts 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
// `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
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.
| Method | Signature | Description |
|---|---|---|
isEnabled | () => boolean | Whether the capability is active. |
getIcons | () => TaxonomyIconsConfig | null | The 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) => TaxonomyFieldMappings | Declarative field → value → categoryId mapping (raw attributes, e.g. OSM fclass). |
resolvePoiIcon | (feature) => ResolvedIcon | Resolves a feature's map icon. |
getIconVariants | () => TaxonomyIconVariant[] | The tinted icon variants to register as MapLibre images. |
resolveMarkerPaint | (layerId, existingPaint) => Record<string, unknown> | null | Composes the disc paint; null when taxonomy overrides nothing. |
resolveTitleIcon | (layerId, feature, surface) => string | null | Icon next to the title in a popup / tooltip / sidepanel. |
resolveBadgeStyle | (layerId, feature, surface, field) => ResolvedBadgeStyle | null | Pill badge colours (needs render.<surface>.colorBadges). |
ensureSprite | () => void | Ensures 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
import { UI } from "@geoleaf/core";
// or: GeoLeaf.UI (CDN/global)UI manages controls, panels, content builder, and notification system.
ThemeSelector — the theme bar
// GeoLeaf.ThemeSelector (global namespace)
await GeoLeaf.ThemeSelector.setTheme("nature");
await GeoLeaf.ThemeSelector.setTheme("nature", { keepView: true });| Method | Signature | Description |
|---|---|---|
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 | null | The 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)
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.
| Method | Signature | Description |
|---|---|---|
isEnabled | () => boolean | Whether the capability is active. |
getConfig | () => object | The resolved modules.filter block. |
getActiveFilter | () => object | The active filter state — serialisable (used by permalink). |
applyFilter | (state) => void | Writes a state onto the panel's controls, then applies it. |
applyNow | () => void | Applies the current panel state immediately. |
reset | () => void | Clears all filters. |
hasActiveFilters | () => boolean | Whether 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,categorybeing the category it is listed under. A sub-category id is unique only within its category, so the pair says which one was checked. Everyvalueis also invaluesand nothing is deduplicated: the checked categories arevaluesminussubValues[].value, counted with multiplicity.
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
Filtersnamespace 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 fromGeoLeaf.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
Filtercapability above (getActiveFilter(),applyFilter(state)) — unchanged;- for filtering a plain array in your own code, use
Array.prototype.filter. That is allfilterRouteListever did.Earlier removals from this namespace (v3.0.0, no consumer):
filterPoiListand the six statistics helpersgetUniqueCategories,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
import { Legend } from "@geoleaf/core";
// or: GeoLeaf.Legend (CDN/global) — mounted at boot if `modules.legend` is enabledLegend is lazy-loaded.
Note:
GeoLeaf.LegendandGeoLeaf.LayerManagerare independent modules with separate implementations.Legendmanages visual legend panels generated from layer style data.LayerManagermanages GeoJSON layer loading and visibility controls.
| Method | Signature | Description |
|---|---|---|
init | (mapInstance: any, options?: object) => boolean | Initialise the module with a map instance |
loadLayerLegend | (layerId: string, styleId: string, layerConfig: object) => void | Load and display the legend of a layer |
setLayerVisibility | (layerId: string, visible: boolean) => void | Show / hide a layer from the legend |
getAllLayers | () => Map<string, object> | Return every layer known to the legend |
hideLegend | () => void | Hide the legend panel |
removeLegend | () => void | Remove the legend panel from the DOM |
isLegendVisible | () => boolean | Return the current visibility of the panel |
toggleAccordion | (layerId: string) => void | Does 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
import { LayerManager } from "@geoleaf/core";
// or: GeoLeaf.LayerManager (CDN/global) — posted at importLayerManager manages GeoJSON layers loaded from profiles. It is the primary way to work with GeoJSON data in GeoLeaf.
Note:
GeoLeaf.GeoJSONis 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
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">whosesrcisgeoleaf.esm.js— the bundle's own directory, unless another such tag, from another directory, comes after it in the page (a plugin'sgeoleaf-*.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.
| Call | Effect |
|---|---|
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 value | TypeError |
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
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
import { Helpers } from "@geoleaf/core";
// or: GeoLeaf.Helpers (CDN/global)Validators
import { Validators } from "@geoleaf/core";
// or: GeoLeaf.Validators (CDN/global)API sub-modules
import {
APIController,
APIFactoryManager,
APIInitializationManager,
APIModuleManager,
PluginRegistry,
BootInfo,
showBootInfo,
} from "@geoleaf/core";PluginRegistry
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 yetshowBootInfo / BootInfo
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
import { Log } from "@geoleaf/core";
// or: GeoLeaf.Log (CDN/global)
Log.info("message");
Log.warn("message");
Log.error("message", error);Errors — typed error classes
import { Errors } from "@geoleaf/core";
// or: GeoLeaf.Errors (CDN/global)9 typed error classes (see errors/GeoLeaf_Errors_README.md).
CONSTANTS
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 listWARNING
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
import { Utils } from "@geoleaf/core";
// or: GeoLeaf.Utils (CDN/global)9 utility sub-modules registered on GeoLeaf.Utils:
| Sub-module | Description |
|---|---|
DOMSecurity | Safe DOM manipulation |
ErrorLogger | Error logging utilities |
EventListenerManager | Managed event listeners |
EventBus | Pub/sub event bus |
FetchHelper | Fetch with cache/timeout |
MapHelpers | MapLibre GL JS map utilities |
PerformanceProfiler | Timing/perf profiling |
TimerManager | Managed timers/debounce |
ObjectUtils / ScaleUtils | Object & 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
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:
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).
// 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");Geocoding — address search
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).
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.
| Method | Signature | Description |
|---|---|---|
init | (config: PermalinkConfig) => void | Initialise with the modules.permalink config block |
readAndStore | () => void | Parse current URL and cache the state |
applyStoredState | (map: IMapAdapter) => void | Restore the cached state to the map adapter |
startSync | (map: IMapAdapter) => void | Begin continuous URL synchronisation on map move/zoom |
getState | () => PermalinkState | null | Return the last cached state (read-only) |
buildUrl | (state?: PermalinkState | null) => string | Serialise a state to a URL fragment/query string |
// 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 syncThe 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 kind | Parameter and value |
|---|---|
text | gl_f.<id>=<query> |
tag | gl_f.<id>=a,b,c |
range | gl_f.<id>=<min>..<max> — an open bound is left empty: 100.., ..500 |
taxonomy | gl_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,zoomare validated viavalidateNumber()/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 thefieldswhitelist lets through.
Notifications — toast notifications
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.
| Method | Signature | Description |
|---|---|---|
notify | (msg, typeOrOpts?, ms?) => Toast | Display a toast (generic) |
show | (msg, typeOrOpts?, ms?) => Toast | Alias of notify |
success | (msg, msOrOpts?) => Toast | Green success toast |
error | (msg, msOrOpts?) => Toast | Red error toast (5 s default, persistent option) |
warning | (msg, msOrOpts?) => Toast | Yellow warning toast |
info | (msg, msOrOpts?) => Toast | Blue informational toast |
dismiss | (toastEl: HTMLElement) => void | Dismiss a specific toast |
clearAll | () => void | Remove all toasts and clear the queue |
getStatus | () => NotifyStatus | Return 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.
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);Popup — popup action buttons
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.
// 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:
actionIdis 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 inlineonclick).
PWA — install prompt
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.
| Method | Signature | Description |
|---|---|---|
init | (config: PWAConfig) => void | Initialise PWA features (called by boot — opt-in only) |
isInstallable | () => boolean | true when the app can still be installed on this device — see below |
isUpdateWaiting | () => boolean | true when a newer version is installed and this page has not been reloaded onto it |
applyUpdate | () => boolean | Applies 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:
if (GeoLeaf.PWA.isInstallable()) {
myInstallButton.hidden = false;
}It mirrors the platform split of the banner:
- iOS Safari —
truewhen running on iOS and not already installed. iOS never firesbeforeinstallprompt, so this is the only signal available there. - Android / Chrome / Edge —
trueonce 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.
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()isfalseat a first install, whenmodules.pwa.enabledis nottrue, and until the service worker has been registered — at idle, up to three seconds after the boot. It staystruein a tab whose update was applied from another tab: the worker changed under that page, which still runs the previous version's code.applyUpdate()returnstruewhen 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 returnsfalse, and does nothing, when nothing was waiting.@geoleaf-plugins/offline-uiasks the question for you, with a « Reload » banner. Without that plugin, nothing asks: the new version waits until the tabs are closed.geoleaf:sw:updatedsays something else — a worker activated — and also fires at a first install.
See PWA — When a new version is deployed.
// 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 onlysw-core.js— a service worker your own application registered is never touched, at boot or at teardown.
Labels — layer labels
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.
| Method | Signature | Description |
|---|---|---|
isEnabled | () => boolean | true unless modules.labels.enabled is false. |
getConfig | () => object | The resolved modules.labels block, merged over the defaults. |
areLabelsEnabled | (layerId) => boolean | true when this layer is currently showing its labels. |
toggleLabels | (layerId) => boolean | Flips the labels of one layer; returns the new state (false if the style forbids them). |
refreshLabels | (layerId) => void | Re-renders the labels themselves after their source or style changed. |
syncLayerControl | (layerId) => void | Repaints 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()andGeoLeaf._loadAllSecondaryModules()no longer exist, and nothing replaces them. Thelitebuild 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.)
| Property | Description |
|---|---|
GeoLeaf.boot() · GeoLeaf.mount() | Start the application — mount() returns the handle that unmounts it: see The application lifecycle |
GeoLeaf.Core | Map lifecycle |
GeoLeaf.Layers | Feature data — see Layers |
GeoLeaf.UI | UI controls |
GeoLeaf.LayerManager | Layer management |
GeoLeaf.Baselayers | Base tile layers |
GeoLeaf.Helpers · GeoLeaf.Validators | Helpers, input validators |
GeoLeaf.Events | DOM event bus (on() returns its unsubscribe) |
GeoLeaf.Storage | Offline storage and the write queue — inert until modules.offline loads the engine: see Offline write cycle |
GeoLeaf.I18n | registerDict / getLabel / t |
GeoLeaf.Utils · GeoLeaf.CONSTANTS | Utilities, constants |
GeoLeaf.Log · GeoLeaf.Errors | Logging, typed error classes |
GeoLeaf.Security | XSS protection |
GeoLeaf.Config | Config access |
GeoLeaf.Introspection | Capability schemas, and their activation verdict |
GeoLeaf.plugins | Plugin registry |
GeoLeaf.notify() | Notification primitive (buffered) |
GeoLeaf._version | Version 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.
| Property | Gate | Description |
|---|---|---|
GeoLeaf.Taxonomy | modules.taxonomy | The point symbol |
GeoLeaf.Filter | modules.filter | Filter panel |
GeoLeaf.Cluster | modules.cluster | Point clustering (read-only) |
GeoLeaf.Legend | modules.legend | Legend panel |
GeoLeaf.Permalink | modules.permalink | URL deep linking |
GeoLeaf.Share | modules.permalink.share | Share dialog |
GeoLeaf.Notifications | modules.toast-renderer | Rich toast surface |
GeoLeaf.PWA | modules.pwa | PWA install prompt |
GeoLeaf.Labels | modules.labels | Layer 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._loadAllSecondaryModuleswere removed in v3 — see Composing a lighter bundle.
TypeDoc (generated): Run
npm run docs:apiinpackages/coreto generate complete API documentation from TSDoc comments intodocs/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:
| Method | Erases | Leaves alone | Resolves 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:cleared | Entities, edits waiting to be sent, photos, archived conflicts | Nothing. 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 store | Everything else | true 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 version | Every 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:
// tsconfig.json path (built declarations)
"types": ["@geoleaf/core"]
// Resolves to: node_modules/@geoleaf/core/dist/types/bundle-esm-entry.d.tsTIP
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:
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.)
