Changelog
All notable changes to @geoleaf/core are documented here.
Format: Keep a Changelog — Semantic Versioning.
[Unreleased]
Changed — BEHAVIOURAL BREAKING CHANGE: analyzeMemoryLeaks() no longer returns normal on a browser that measures nothing
GeoLeaf.Utils.PerformanceProfiler.analyzeMemoryLeaks() returned a verdict that was a constant. It computes growthRate from samples of getMemoryUsage(), which reads performance.memory.usedJSHeapSize — a value Chrome quantises and freezes for the lifetime of the page unless --enable-precise-memory-info is set. The samples were therefore strictly equal, growthRate was exactly 0, and warning / critical were unreachable.
The deciding fact is a measurement: on a page deliberately retaining 9.0 to 15.1 MB (confirmed outside the page, through CDP Runtime.getHeapUsage after a forced GC), the API returned {"status":"normal","growthRate":0,"recommendation":"No action needed"} — six times out of six.
What changes
statuscan now be"unavailable", together with areason:"heap-readings-constant"(every sample in the window equal to the byte) or"heap-api-unavailable"(the window opens on a null reading). Two arithmetic tests, with no threshold to tune."heap-api-unavailable"closes a second blind spot: outside Chromium,performance.memorydoes not exist, every sample is0, and the former computation did(0 − 0) / 0 = NaN. SinceNaNis greater than no threshold, the verdict fell back tonormal— with agrowthRate: NaNpublished as is.generateReport()carries the unavailability in its recommendations: that is the path an integrator actually reads, and it kept reading "all is well".
What does not change, deliberately
status remains typed string — it is not narrowed to a union, which would break an existing consumer's switch. The only type addition is reason?: string, optional. No signature is modified: the break is one of behaviour, not of compilation.
INFO
A window that moves and returns to its starting point is still normal, with growthRate: 0. Same figure as the frozen case, opposite verdict — because the input did vary.
WARNING
unavailable must not be read as "no leak". It says the browser gave nothing to judge. A caller that treated "normal" as a green light must now handle "unavailable" separately: on a standard Chrome, this API will never return normal again. It has stopped lying, it has not learnt to see — and it loses the right to confirm good health. For a real figure, measure outside the page (DevTools, or CDP Runtime.getHeapUsage after HeapProfiler.collectGarbage).
Added — modules.offline.cache.maxTileCacheEntries, and geoleaf:cache:evicted typed in the contract
The Service Worker tile cache was bounded by nothing. It lives in the Cache API, on the same origin as IndexedDB — and browsers evict per origin, not per store. An unbounded tile cache could therefore take sync_queue down with it under disk pressure, that is, field entries that are not yet synchronised and have no other copy.
{ "modules": { "offline": { "cache": { "maxTileCacheEntries": 2000 } } } }A declarable ceiling, default 2,000, 0 disables eviction. Above the ceiling the oldest entries go first; under origin quota pressure the trim becomes markedly more aggressive.
WARNING
Counted in ENTRIES, not bytes — the Cache API exposes the size of no entry, and storage.estimate() measures the whole origin rather than a single store. Not to be confused with maxCacheBytes, which bounds IndexedDB in bytes (default 250 MB): these are two distinct budgets against the same origin quota.
geoleaf:cache:evicted now carries a typed detail, GeoLeafCacheEvictedDetail (contracts/event-bus.contract.ts): { evicted, freedBytes?, totalBefore?, totalAfter? }.
WARNING
The event has two producers, deliberately: cache-manager.ts for IndexedDB, sw-core.js → sw-register.ts for the Cache API. The two stores evict from contexts that can share no code — a Service Worker has no document and can import neither the contract nor the bus. freedBytes is absent on the Cache API side, for that reason: a consumer must handle its absence rather than assume a 0.
Browser measurements
Taken on both deployed variants, Service Workers blocked, first load:
| Measurement | deploy-core | deploy-full |
|---|---|---|
| Third-party origins at boot (unpkg, Google Fonts) | 0 | 0 |
| CSP violations | 0 | 0 |
| Eager chunks started after the entry is received | 0 / 3 | 0 / 3 |
DOMContentLoaded | ~75 ms | ~120 ms |
| Connections opened in addition to the navigation | 0 (h2) | 0 (h2) |
WARNING
This is not a paired before/after. The "before" artefact (third-party origins, MapLibre 5) no longer exists on disk, and rebuilding it would mix four separate efforts into a single delta. The values above are the measured state, not an attributed gain. The millisecond figures vary from one run to the next and carry no threshold.
Added — GeoLeaf.Introspection.getCapabilityStatus()
One question, one answer: what is switched on, and why. Returns, for each declared capability, { id, embarked, enabled, gate, hasModule }.
To be distinguished from its two neighbours, which answer something else:
getAllCapabilities()returns what is declared (the schema);getActiveModules()returns what runs (the initialised modules);getCapabilityStatus()returns the configuration verdict.
enabled is re-read on every call against GeoLeaf.Config, therefore against the merged configuration as soon as the profile is loaded. Before boot(), no configuration exists and each gate answers its enableWhenAbsent — the exact answer to "with what is configured right now", not a fallback value.
WARNING
embarked says where the declaration comes from, not whether the capability appears in the full manifest: true when it comes from a preset installer (the build), false through the runtime channel (GeoLeaf.plugins.registerCapability). Comparing against the full manifest would require importing it, which would cancel the tree-shaking that a purpose-built entry buys.
WARNING
hasModule is a structural fact — the capability contributes an ICoreModule — never a runtime fact, and the id of that module is not necessarily the id of the capability (permalink contributes share). For "is it running", the answer is getActiveModules().
Changed — BREAKING: registerPresetDeclarations() requires a noteInstaller
Its second parameter must now carry noteInstaller(id, facts) in addition to register(decl). This only affects code that composes its own preset by calling presets/apply-preset.js directly; the contract is hardened rather than made optional so that an omission is a compilation error and not a silent embarked: false.
Removed — BREAKING: @geoleaf-plugins/addpoi merged into @geoleaf-plugins/editor
There is now only one editing plugin. GeoLeaf.AddPOI disappears WITH NO ALIAS — a deliberate decision: the application has no deployed users, and an adoption path would be code to write, test and then delete, for zero beneficiaries.
WARNING
There is nothing to deprecate: @geoleaf-plugins/addpoi was never published. The npm registry returns E404 for it. No version was ever installable, so nobody can have written GeoLeaf.AddPOI against a published package. The migration table below describes an internal switch, not an upgrade path for an integrator.
DANGER
The real breaking change for an integrator is elsewhere, and it is ui.showAddPoi — that key existed in the schema of @geoleaf/core 2.1.8, the published version, and disappears in 3.0.0. It is the only item in this section that a real consumer may have in their profile.
Migration note:
| Before | After |
|---|---|
GeoLeaf.AddPOI.AddForm.openAddForm(ll) | GeoLeaf.Editor.AddForm.openAddForm(ll) |
GeoLeaf.AddPOI.PlacementMode.activate() | GeoLeaf.Editor.PlacementMode.activate() |
ui.showAddPoi | modules.editor.showAddPoi |
modules.addpoi.defaultPosition | modules.editor.poiAddDefaultPosition |
ui.showPoiExport · ui.showPoiSubmit | modules.editor.showExport — see below |
<script src="geoleaf-addpoi.plugin.js"> | nothing: editor loads lazily |
WARNING
ui.showAddPoi, ui.showPoiExport and ui.showPoiSubmit are removed from the UIConfig type and from the schema. The last two were declared in no schema at all while ui.schema.json is additionalProperties: false: writing them made profile validation fail. One button was therefore visible without being hideable, the other hidden without being showable. Their replacements live under modules.editor.* and are declared.
WARNING
Default change: ui.showAddPoi was false (opt-in); modules.editor.showAddPoi is true (opt-out), like the plugin's other lazy slots. A profile that did not set the key and did not load addpoi had no button; if it loads editor, it will have one.
What the merge changes in the core:
- The
kernel/ui/poi-addform-seam.tsseam and itscontracts/poi-addform.contract.tscontract are removed (180 lines). The core no longer resolves a plugin namespace in order to draw a button: the button is a lazy toolbar slot, declared by the host application. GeoLeaf.Utils.poiToFeaturestays — it is a public core API, independent of the plugin.- Deployment variants go from 3 to 2:
deploy-addpoidisappears,deploy-fullcarries both editing and offline support.
Changed — BREAKING: enableEdition / enableEditionFull become an edition block
The two editability flags of a layer are replaced by a per-operation block. enableEditionFull did not mean "full editing": it was usefully read only once, as canDelete() — it was the right to delete. Nobody guesses that from the name.
Failure mode: layer-config.schema.json is additionalProperties: false, so a profile still carrying the old keys fails validation, with the key named. A clean stop, not a silent degradation.
| Before | After |
|---|---|
"enableEdition": true | "edition": { "create": true, "update": true } |
"enableEditionFull": true | add "delete": true to the same block |
"enableEditionFull": false | omit delete (or write it false) |
WARNING
The majority case is the most surprising one, and that is intended: a profile that only had enableEdition: true breaks too. It must write create and update separately, and decide about deletion. That is precisely what the former pair could not express.
Semantics — absent means REFUSED, and no key implies another:
edition absent → create=false, update=false, delete=false
edition: {} → same — declaring the block grants NOTHING
edition: {create:true} → create only; update and delete stay falseWARNING
No layer changes state through omission: a layer that declared nothing was not editable before either. The restrictive default therefore takes editing away from nobody — a permissive default would have granted it, silently, to the 42 layers out of 48 that declare nothing.
WARNING
The gate becomes per-operation where enableEdition governed everything. This is not iso-behavioural for create and update: they can now be refused independently.
DANGER
What this block does NOT do, to be read before relying on it. edition.delete is applied on the offline write path only. Online, the editing plugin's REST adapter issues an unconditional DELETE, and its toolbar gates the delete tool on its own configuration, never on the layer. A layer declaring delete: false therefore remains deletable by a signed-in user. This is not an end-to-end authorisation model.
- Rule A14 is re-anchored: an
attributes.fields[].editfield now requiresedition.update: true(no longerenableEdition: true), still accompanied by awriteblock.updateand notcreate, becauseeditdescribes modifying an existing value.
Changed — BREAKING for layer configuration
Attribute rendering is declared in an
attributesblock at the ROOT of the layer. Thecapabilities["feature-info"]block and its three parallel lists (tooltip/popup/sidepanel) are removed from the schema: a layer that still carries them no longer validates.json{ "attributes": { "titleField": "properties.nom", "fields": [ { "field": "properties.nom", "label": "Nom", "primitive": "string", "widget": "text", "display": { "surfaces": ["tooltip", "popup", "sidepanel"], "presentation": { "emphasis": "title" } } } ] } }A single list, where each field names the surfaces it appears on, instead of three lists of different shapes that had to be kept parallel by hand. The block is strict: it used to live under
capabilities, which wasadditionalProperties: true, so a typo passed silently.The type is declared in two columns —
primitivesays what the value IS in the GeoJSON,widgetsays how it is shown. That pairing is what letsvalidate:profilesrefuse, at build time, a number asked to be displayed as a date; a single "representation" column would have nothing to check against.The
"all"mode is removed, and it triggered on SILENCE."all", a missing surface and anullsurface all went through the same branch: a layer that simply omitted a surface exposed all the properties of its features there — technical identifiers and working columns included. A layer that declares nothing now paints nothing.If you relied on that behaviour, declare the wanted fields explicitly. The flat list of what a profile declares is generated by
npm run gen:attributes-report.
Fixed
The same data was displayed differently depending on the surface. A
pricefield rendered a formatted amount in the side panel and[object Object]in the popup;badgeandlinkhad the same defect in the tooltip. The three surfaces now share one single value projection.An
actionfield declared on the side panel was neither rendered nor reported. The cause was not a missing branch but a payload: the panel received only the layer id, whilegeoleaf:popup:actionpromisesfeatureIdandlngLat. Both interactive surfaces now render the button, with its complete payload.coordinatesandhoursdisappeared from the popup, rendered by the panel alone.date,urlandemailwere declared and rendered nowhere — a field carrying one of them disappeared without warning. They now render:datethroughIntl, honouring a per-field locale,urlandemailas links. Amailto:address that the URL validator refuses degrades to readable text rather than being dropped — sanitisation is never bypassed.An unknown
widgetis no longer silent: it is not rendered, it is logged, and it is never fatal — an incorrect profile must not empty the map.
Added
@geoleaf/corenow publishes its ambient namespace.dist/types/global.d.tsis emitted at build time and referenced by the entry point (/// <reference path="./global.d.ts" />), soGeoLeaf.*resolves for the integrator without a manual declaration — in TypeScript as well as in editor auto-completion.ts// No more `declare const GeoLeaf: any;` in your project. GeoLeaf.Layers.getFeatures("my-points"); // typedPublic facades carry their documentation in the shipped types. The five offline IndexedDB sub-modules (
LayersDBInstance,SyncDBInstance,ImagesDBInstance,BackupsDBInstance,PreferencesAPI), the legend types,ProgressData,ResolverZone,ConfigFacadeand theUtilsexport now document what they do and why — visible directly in the editor.Utilscarries a clarification that was missing: the ESM export and theGeoLeaf.Utilsglobal have the same shape but are two distinct objects. Mutating one does not affect the other.
Fixed
The package's npm shop window (
README.md) taught a dissolved API and a subpath that does not resolve. It carried a copy-pasteableGeoLeaf.POI.add({…})—GeoLeaf.POIhas been removed since 3.0.0 — animport { Core, POI, Filters }of which two members no longer exist, and twoimport "@geoleaf/core/dist/…"that throwERR_PACKAGE_PATH_NOT_EXPORTED: theexportsmap does not opendist/.js// The CSS is imported through the declared subpath: import "@geoleaf/core/style.css";It also announced "Platform V2 / 2.0.0" on a package at 3.0.0,
node ≥18for an engine that requires 22.13, a CDN pin at@2.0.0, and a dead link todocs/poi/. Rewritten as a shop window plus pointers — duplication withdocs/was the mechanical cause of the drift.repositoryandbugsin the manifest pointed at a frozen mirror repository. They targetedGeoLeaf-Core, whose automatic synchronisation is gone; they now point at the real repository. The "Repository" and "Issues" links on the npm page therefore lead to a living repository.Two public declarations of
CacheMetricsdepended on a compiler flag or on a non-exported type. No internal consumer exercised either one, so the repository's sixteentsc -ppasses came out green while an integrator no longer compiled.getStorageQuota()— the properties synthesised by a multi-branch ternary went from?: undefinedto?: neverdepending on whetherexactOptionalPropertyTypeswas set. The return type is now explicitly annotated: the published declaration no longer depends on a compilation setting.estimateProfileSize()— its inferred return referenced a non-exportedinterface, therefore one without an implicit index signature.const r: Record<string, number> = estimate.byTypestopped compiling, and the type still shipped indist/types/under a name nobody could write. The type is now exported.
The getting-started tutorial did not work if followed to the letter.
QUICKSTART_TUTORIAL.mdasked forlayers.json,ui.jsonandbasemaps.jsonto be created at the profile root, while theprofile.jsonon the same page declared them underconfig/core/. Structure,Fileskey and step titles are now aligned.Two dead profile keys were taught as living ones —
clusteringConfigandpoiConfig. Neither exists in the schema, neither is read by the code. Clustering is configured through the capability: one default in the profile (Files.modules.cluster→config/plugins/cluster.json), one per-layer override.jsonc// config/plugins/cluster.json { "clustering": true, "clusterStrategy": "unified", "clusterRadius": 80 }Corrected in
QUICKSTART_TUTORIAL,PLUGIN_CONFIGURATION_GUIDEandGEOJSON_LAYERS_GUIDE, where four passages still presented them as the current mechanism.Examples in the published documentation called
GeoLeaf.POI.*, removed from the core in 3.0.0. Migrated toGeoLeaf.Layers.*inUSER_GUIDE,helpers/andsecurity/.The
configSchemadescriptions returned byGeoLeaf.Introspection.getCapabilitySchema()quoted source line numbers. Afile:linecitation goes stale at the first insertion upstream, and these shipped in the published package. Removed.
Removed
BREAKING — the
@geoleaf/core/presets/preset.contract.jssubpath is removed. It becomes@geoleaf/core/contracts/preset.contract.js; the module, its types and its content are unchanged, only the address moves.ts// before import type { PresetManifest } from "@geoleaf/core/presets/preset.contract.js"; // after import type { PresetManifest } from "@geoleaf/core/contracts/preset.contract.js";Why. This file is 100% type-only and it was the only one of the repository's 15
.contract.tsfiles to live outsidecontracts/. The check that verifies this family remains a pure type surface readscontracts/without recursion, so the file escaped it entirely, and nothing would have flagged it acquiring executable value — which 21 capability installers would then have pulled into the bundle. Moving it puts it under guard.The subpath was not kept as an alias:
./presets/*would have kept announcing a directory the file has left, which is exactly the defect being corrected.
Added
Three interface selectors, as gated capabilities —
GeoLeaf.ProfileSwitcher,GeoLeaf.LanguageSwitcherandGeoLeaf.ThemePalette. Each is switched on bymodules.<id>.enabledand is opt-in: absent from your configuration, it adds neither interface nor weight (the code AND its CSS are tree-shaken).tsGeoLeaf.ProfileSwitcher.list(); // available profiles (collected at deploy time) GeoLeaf.ProfileSwitcher.switchTo("france-rail"); // persists + reloads GeoLeaf.LanguageSwitcher.list(); // available languages GeoLeaf.LanguageSwitcher.switchTo("en"); // persists + reloads with ?lang= GeoLeaf.ThemePalette.list(); // available palettes GeoLeaf.ThemePalette.set("green"); // applies LIVE, without a reloadprofile-switcher— dataset selector at the top of the layer manager. The list comes fromdata.availableProfiles, generated at deploy time from theprofile.jsonfiles (a browser cannot enumerate a server directory). Shown only from 2 profiles upwards.language-switcher— language button in the tab strip. No translation added: the 6 dictionaries are already in the core.display: "code"replaces flags withFR/ENon platforms that do not draw them.theme-palette— accent colour (orange / green / blue), carried bydata-gl-paletteon<html>. Orthogonal to light/dark mode and to map themes. The configureddefaultapplies even when the selector stays hidden.
profile.jsonacceptsdisplayLabelandicon(both optional) — short label and icon of the profile selector. The existinglabelis unchanged.Extension contracts are now public. Six
types-only subpaths expose the interfaces a plugin must implement, and the same symbols are re-exported from the main entry point:tsimport type { ICoreModule, IMapAdapter, GeoLeafEventMap } from "@geoleaf/core"; // or, in long form: import type { ICoreModule } from "@geoleaf/core/contracts/core-module.contract.js";Published subpaths:
core-module.contract.js(ICoreModule,ILifecycleModule,IUISlotModule,IModuleRegistry,IModuleUISlot),capability.contract.js(ICapabilityDeclaration,ICapabilityRegistry,ICapabilitySchema, …),config.contract.js(IGeoLeafConfig),map-adapter.contract.js(IMapAdapterplus the geometry types),layer-data.contract.js(LayerDataApi,LayerFeatureState),event-bus.contract.js(GeoLeafEventMap,GeoLeafRawEventMap,IEventBus). These modules are type-only: they declare only atypescondition and emit no JavaScript —import typeworks, a value import is refused outright.PluginMetadata(the metadata ofGeoLeaf.plugins.register) is also re-exported from the entry point.Until now these types were reachable through no channel at all: a plugin implementing
ICoreModulehad to redeclare it.CapabilityRegistry— exported from@geoleaf/coreand@geoleaf/core/kernel. The capability registry (register,isEnabled,isLoaded,ensureLoaded,getSchema,getAllSchemas) was complete but reachable from no ESM entry point: declaring a capability was only possible throughGeoLeaf.plugins.registerCapability(decl), untyped, sinceICapabilityDeclarationwas not exposed either.geoleaf:toolbar:actionis typed —GeoLeaf.Events.on("geoleaf:toolbar:action", …)now givesdetail: { action: string; element: HTMLElement }. This is the extension point through which a plugin reacts to a click on its toolbar button; until now it required a rawdocument.addEventListenerwith a hand-written cast.The event lives in a second map,
GeoLeafRawEventMap, not inGeoLeafEventMap. The distinction is functional: the internal bus clones its payloads through JSON, which would destroy theelementreference.Events.on/off/onceaccept keys from both maps; emission remains reserved for serialisable events.GeoLeafHost(@geoleaf/host-runtime) describes six more members —GeoJSON,Utils,Log,Sync,Notifications,Layers. Until now they fell into the[key: string]: unknowntail, soGeoLeaf.GeoJSON.getLayerById(id)returnedunknown. An additive change, no break.GeoLeaf.Print.registerSlot(slot)(@geoleaf-plugins/print) — extension point for adding content to the composed canvas (cartouche, extra fields) at one of thetitle/legend/footer/overlay-tl|tr|bl|brplacements. Re-registering the sameidreplaces the previous slot. The function and itsComposeSlottype already existed and the specification documented them as callable that way, but they were not mounted on the facade:GeoLeaf.Print.registerSlotwasundefinedat runtime, so no custom slot could be drawn. An additive change, no break.GeoLeaf.Permalink.stopSync()— new public method that stops the URL synchronisation started bystartSync()and detaches every listener it had attached (mapmoveendplus threedocumentlisteners). Useful for SPA hosts that recreate the map.startSync()has also become idempotent: a second call tears down the previous session instead of stacking listeners.GeoLeaf.Notifications.show(message, typeOrOptions?, duration?)— the method was missing from the facade while the rendering engine, the historicalGeoLeaf.UI.showNotificationsurface and the plugin documentation all named it. Calling it reachedundefinedand failed silently (the?.show?.()guard swallowed the error, no toast appeared). It is an alias ofnotify().
Removed
BREAKING — the
GeoLeaf.Filters(plural) namespace is removed, on both channels. The named exportimport { Filters } from "@geoleaf/core"and theGeoLeaf.Filtersglobal disappear together, along with their single methodfilterRouteList(baseRoutes, filterState).Why. Its size did not condemn it, its name did: one letter separated it from
GeoLeaf.Filter(singular), a different 8-member object that drives the filter panel and carries the permalink serialisation contract. The asymmetry made the trap all the surer — the typed one (Filter) was not on the root ESM entry, the untyped one (Filters) was. AndfilterRouteListhad no caller: not in the core, not in the 13 plugins, not in the application, not in the profiles, not in the examples — nowhere outside its own definition.Migration. There is no direct replacement, and none is needed:
- to filter the map, use the
GeoLeaf.Filtercapability (singular) —getActiveFilter(),applyFilter(state),reset()— unchanged; - to filter an array in your own code, use
Array.prototype.filter. That is allfilterRouteListdid.
Gone with it, for lack of a reachable reader: the
route-filterengine (~320 lines) and the contribution seam through which theroutecapability injected it. Theroutecapability itself — decorating route start and end points, driven bymodules.route.*— is unchanged.- to filter the map, use the
The catch-all
@geoleaf/core/dist/*subpath is removed. It made any file underdist/importable — internal chunks and implementation declarations included — which bypassed the whole set of subpaths defined next to it. If you were loading the stylesheet through@geoleaf/core/dist/geoleaf-main.min.css, use@geoleaf/core/style.css: that subpath already exists, designates exactly the same file, and is the one the documentation now uses. No other path underdist/was documented.
Changed
BREAKING — two
@geoleaf/core/capabilities/*subpaths change name. They remain published, under a name that describes what they do:Before After @geoleaf/core/capabilities/permalink/permalink-manager.js@geoleaf/core/capabilities/permalink/permalink-sync.js@geoleaf/core/capabilities/offline/core-config.js@geoleaf/core/capabilities/offline/config-seam.jsWhy.
permalink-managerexported noPermalinkManagerobject — only four free functions for capturing and synchronising the URL;permalink-syncnames them.core-configwas one of three files with that name exporting three different functions, to the point that its own header had to warn "Not to be confused withcapabilities/feature-info/utils/core-config.ts" — a note that patches an ambiguous name is the symptom of the defect, not its cure.config-seamjoins the*-seam.tsfamily of guarded runtime accessors.Who is affected. Nobody, except a deep import that names these two paths explicitly. They appear in no README or example, and are re-exported by no main entry point. No other rename in this batch touches the published surface: the six
<cap>-types.jsfiles renamed totypes.jsare type-only modules whose.jswas never emitted (their types remain resolvable under the new name).BREAKING — the
@geoleaf-plugins/storageplugin becomes@geoleaf-plugins/offline-ui. The npm package, the bundle (geoleaf-storage.plugin.js→geoleaf-offline-ui.plugin.js) and the runtime plugin id ("storage"→"offline-ui") change together.Why. The package no longer contained any storage. The offline engine — IndexedDB, cache, download, synchronisation — has been moved into the core (
capabilities/offline) and the plugin only ships the interface: cache button, layer selector, synchronisation panel. Its own header already said so ("Entry point (offline UI) […] the offline engine is in-core"), and "storage" designated three things at once in the codebase: this plugin, theGeoLeaf.Storagefacade and the engine.offline-uipairs withcapabilities/offlineand makes the name true.Migration.
npm install @geoleaf-plugins/offline-ui(and uninstall the old one);import "@geoleaf-plugins/offline-ui";<script>tag:dist/geoleaf-offline-ui.plugin.js;GeoLeaf.plugins.isLoaded("storage")→isLoaded("offline-ui"), and likewise for the value returned bygetLoadedPlugins();- if you target the button in the DOM or in tests: the attribute goes from
data-gl-toolbar-action="storage"todata-gl-toolbar-action="offline-ui".
What does NOT change — deliberately:
- the
GeoLeaf.Storagefacade is unchanged: it belongs to the core, not to the plugin, and remains the entry point for offline support; - the i18n keys keep their
storage.*prefix. That is a namespace distinct from the package identity, and above all a profile override surface: renaming it would break custom translations without clarifying anything; - the profile configuration is unchanged — it already goes through
modules.offline.*andconfig/plugins/offline.json.
The interface language is remembered. Resolution order:
?lang=→localStorage['gl-lang']→ui.language→fr. The URL parameter stays first, so that a shared link shows the same language to its recipient as to its author.The active profile is remembered (
localStorage['gl-profile']).sessionStorage['gl-selected-profile']keeps its priority and its one-shot behaviour: the existing contract is not broken.ICoreModulebecomes a union —ILifecycleModule | IUISlotModule. The type declareddependencies,initanddestroyas mandatory, whereasGeoLeaf.registry.register()has always accepted two shapes: a full lifecycle module, or a plain{ id, ui }interface slot (what every plugin does when it adds a toolbar button without shipping start-up code). The contract described the first and rejected the second — published as such, it would have refused the eight real registration points in this repository.If you wrote
class MyModule implements ICoreModule, writeimplements ILifecycleModule: TypeScript does not accept animplementsclause on a union type. The nameICoreModuleremains the one used by theregister()parameter and has not changed role. No runtime behaviour change — it is the type joining the runtime, not the other way round.exportsnow enumerates the./facades/*and./presets/*subpaths instead of announcing them through a glob. A glob promised everythingdist/types/contained, including modules that were never emitted (see Fixed). The field now lists the 15 facades actually shipped —branding,cluster,coordinates,featureinfo,filter,geolocation,labels,layers,legend,permalink,scale,share,sync,taxonomy,theme-toggle— and the 2 executable presets.@geoleaf/core/presets/preset.contract.jsnow declares only atypescondition: it is a type-only module,import typeworks, a value import is now refused outright instead of resolving into the void.The PWA install banners (Android and iOS) now display the configured application name —
modules.pwa.short_name, otherwisemodules.pwa.name(falling back to "GeoLeaf") — instead of a hard-coded "GeoLeaf", and all their labels are translated (fr/en/es/pt/it/de) through the core i18n dictionary. A profile that defines neithershort_namenornamesees unchanged text.Notifications.notify()/show()/success()/error()/warning()/info()now return the toast element (HTMLElement | null | undefined) instead ofvoid. That is whatNotifications.dismiss(toast)has always expected: without a return value there was no way to obtain the reference, and the documented example was inapplicable. A type widening — no existing call is broken.
Fixed
BREAKING —
GeoLeaf.BaseLayerswasundefinedin production. The backwards-compatible alias ofGeoLeaf.Baselayerswas correctly mounted, then overwritten withundefineda few statements later.GeoLeaf.BaseLayers === GeoLeaf.Baselayersnow returnstrue.Why it happened. The alias was written
get BaseLayers() { return this.Baselayers; }inside the literal of anObject.assign.Object.assignreads the own properties of the source, so it invokes the getter — withthisbound to the literal, which does not declareBaselayers. It wroteundefined, over the correct value set just before by the other half of the namespace assembly.Why nobody had seen it. The module carrying that getter was believed to be eliminated by tree-shaking — a comment had claimed so for several versions. It is not: it is present in the shipped bundle, and it runs last. And no internal check could detect it: they compare member names, and the
BaseLayerskey did exist — withundefinedas its value.This fix is marked breaking out of caution: if your code tests
GeoLeaf.BaseLayersto decide on a fallback toGeoLeaf.Baselayers, that fallback will no longer trigger. This is the behaviour expected all along; the value was the wrong part.GeoLeaf.getMetrics()threw as soon as it was detached from the namespace.const { getMetrics } = GeoLeaf; getMetrics();producedTypeError: Cannot read properties of undefined (reading 'getHealth'), because the shipped method was writtenthis.getHealth(). It no longer depends onthis: destructuring, passing it as a callback andmap(GeoLeaf.getMetrics)all work.GeoLeaf.getHealth()was and remains correct.The
GeoLeafnamespace is typed at 81% instead of 31%. Forty-four public keys fell into the[key: string]: unknowntail of the ambient declaration: in TypeScript,GeoLeaf.init({...})returnedunknownand was not checked. The eleven capability facades (Branding,Cluster,Coordinates,FeatureInfo,Labels,NotificationSystem,PWA,Permalink,Scale,Share,ThemeToggle), the twelve kernel facades (API,BaseLayers/Baselayers,CONSTANTS,Errors,Events/events,Helpers,LayerManager,ThemeCache,Validators,version) and the twenty-one top-level methods (init,boot,setTheme,loadConfig,createMap,getMap,getAllMaps,getModule,hasModule,getNamespace,getHealth,getMetrics,fetch,get,post,bootInfo,mark,measure,getPerformanceReport,establishBaseline,notify) are now declared.The
[key: string]: unknowntail stays in place: no existing access stops compiling. What changes is that accesses to the keys above are now checked — a call whose arguments were wrong and passed silently may now be reported by your compiler. That is the intended effect.Two clarifications about shapes the documentation presented differently:
GeoLeaf.init(options)requiresmap.target(or itstarget/mapIdshorthand) and throws without it. The expected shape is{ map: { target }, data: { activeProfile, profilesBasePath } }.GeoLeaf.init()andGeoLeaf.boot()are not interchangeable:boot()starts the profile-driven application,init()is the manual wrapper aroundGeoLeaf.Core.init(). The start-up path never callsGeoLeaf.init().
registry.getModuleSchema()andgetActiveModules()returneddependencies: undefinedfor every interface slot registered after start-up — that is, for every plugin loaded on demand. Their own return type (IModuleInfo.dependencies) nevertheless announces a non-optional array. Both now readdependencies ?? [], as the registry's topological sort already does.Thirteen
@geoleaf/core/facades/*subpaths compiled and then failed at import. Theexportsfield announced./facades/*through a glob whosetypesbranch resolved 28 files and whoseimportbranch resolved only 15: Rollup does not emit a module for a facade that is nothing but a re-export shell, and rightly so — it has no code. As a result,import { Core } from "@geoleaf/core/facades/core.js"passed type-checking then threwERR_MODULE_NOT_FOUNDat runtime. The affected ones wereapi,baselayers,constants,core,events,filters,helpers,introspection,layer-manager,pwa,storage,ui,validators. The corresponding symbols remained — and remain — reachable from the main entry point:import { Core, UI, Events } from "@geoleaf/core"../facades/legend.js, the only subpath the documentation cited, was never affected.@geoleaf-plugins/tableappeared in no start-up report. The name"table"was still listed among the internal core modules excluded fromreportPlugins(), a leftover from the time when Table was bundled. The plugin registered normally but stayed invisible in the boot console.The plugins' TypeScript declarations were published but unreachable. Eleven packages (
@geoleaf-plugins/cog,editor,file-import,flatgeobuf,geocoding,measure,print,realtime-layer,table,websocketand@geoleaf/connector) did ship their.d.tsfiles in the npm tarball but declared notypescondition in theirexports: animport "@geoleaf-plugins/table"from a TypeScript project failed with TS7016 ("Could not find a declaration file"), while the types were right there, next to it. The condition is now declared by all eleven. No API has changed — what changes is that your editor sees them.@geoleaf-plugins/addpoiand@geoleaf-plugins/storageno longer ship the core's internal declarations. These two packages compile core sources, which made their declarations come out at a wrong root: theaddpoitarball contained 514.d.tsfiles (2.6 MB), 483 of which belonged to@geoleaf/core, and its own entry point sat at a path no configuration could designate. These two packages now expose no types at all — they are consumed as side-effect bundles (import "@geoleaf-plugins/storage"mountsGeoLeaf.Storage), which is unchanged. Typing will return once their coupling to the core is untangled.@geoleaf-plugins/print— the printed scale is at last the one that was locked. The map re-rendered off-screen covers the whole usable page; it was stretched into the map area, which any band (title, legend, footer) shrinks. As soon as a title was entered — the nominal case — the map was squashed vertically: 1:25,000 printed as 1:26,331 with a title, as 1:30,240 with title, legend and description, while the scale bar kept announcing the requested denominator. The capture is now cropped to the centre, pixel for pixel;computeBbox()accounts for the bands, so annotations carried over from@geoleaf-plugins/measureand the payload of the server fallback are placed correctly. This fixes the preview modal as well asexportImage()/exportPDF().Permalink.startSync()no longer leaks listeners on re-initialisation (map recreation / SPA): the session is idempotent and its listeners are released bystopSync()/ the internal reset.The Android and iOS install banners no longer leak global listeners or pending timers between two initialisations.
A burst of error toasts no longer exceeds the
maxVisiblelimit. When several notifications were queued and processed in one go, each error "freed" a slot by targeting a toast already being removed — an operation with no effect, but counted as a success. Toasts stacked beyond the configured limit (observed: 4 displayed formaxVisible: 2). Eviction now targets only a toast that can really be removed.The coloured edge of toasts is at last displayed. The four types (
success,error,warning,info) are meant to be distinguished by a coloured bar on the left: the base rule referenced a non-existent theme variable (--gl-accentinstead of--gl-color-accent), which invalidated the declaration and cancelled the border style — the modifiers setting only a colour, no edge was rendered at all. The toast focus ring (:focus-within) was missing for the same reason, which also made it an accessibility defect.In dark theme, the toast background follows the theme variables again. A second rule, of identical specificity and declared later in the stylesheet, imposed a fixed grey and defeated the computed background and dark shadow that the file nevertheless defines explicitly.
The form image viewer (
@geoleaf/field-renderer, AddPOI plugin) no longer disappears after 150 ms. The core published a.gl-lightbox { opacity: 0 }rule inherited from an older viewer it no longer uses (its own is.gl-poi-lightbox-*), on a class that actually belongs tofield-renderer. Since its opening animation has noanimation-fill-mode, opacity fell back to that rule at the end of the animation: the image appeared, then faded out. The rule is removed.When the filter panel opens, the bottom-left block (branding, scale) and the coordinate display shift again. Both offset rules were overridden by a margin reset applied with
!importanton the shared container.The
modules.permalink.fieldsallow-list is at last honoured by compact URLs. The permalink encodes state in base64 (#gl=…) whenmode: "compact"is requested or, automatically, as soon as the verbose URL exceeds 200 characters — and that path ignoredfieldsin both directions: when reading, a forged compact URL drove every facet (filter, theme, categories, tags, rating, layers), including those a profile had excluded; when writing,buildUrl()published the whole state although the verbose path had just filtered it. Both encodings now apply the same allow-list. No injection risk was involved (the values feed lookups over known lists), butfieldsdid not keep its promise.A view parameter that is present but empty no longer silently recentres the map on 0,0.
#gl_lat=&gl_lng=2.35&gl_zoom=12passed the presence check (the value is"", not absent), thenNumber("")was0: the map opened off the Gulf of Guinea instead of ignoring an invalid permalink. An empty or blank value is now treated as absent — the URL is rejected and the profile view applies. An explicitgl_lat=0obviously remains valid.gl_ratingis validated like the other numeric fields. It was the only one not going through the shared validator:#gl_rating=Infinity(or a1e400slipped into a compact payload, whichJSON.parseturns intoInfinity) was accepted as the minimum rating. Non-finite and negative values are now rejected.A permalink containing non-Latin text no longer breaks the URL. The compact format (
#gl=…) encoded to base64 through a routine that fails on any character beyond Latin-1: a filter in Japanese or Russian — or plain typographic ellipses — made the encoding fail. The failure was silent: URL synchronisation swallows the error, so the address simply stopped following the map, with no message. That path is moreover taken automatically as soon as the classic URL exceeds 200 characters, so without the profile requesting compact mode. Encoding now goes through UTF-8; compact links already shared remain readable, the old format being recognised and accepted on read.A vector tile layer with a casing no longer leaves a residue on each rebuild. On a basemap or theme change, the casing sub-layer was not removed — the clean-up list targeted an id the builder does not produce. Each rebuild therefore left the previous one behind, and the source was released while a layer still referenced it.
Offline download progress no longer exceeds 100%. The emitted percentage was not clamped: a resource counted twice — a retry recorded both as a success and as a failure, an enumeration producing a duplicate — pushed the display beyond 100 (observed: 150% and 200%). The value is now clamped to
[0, 100].The offline synchronisation queue empties again.
GeoLeaf.Storage.DBexposed neitherupdateSyncQueueStatus()norremoveSyncQueueEntry(), although the field collection plugins (AddPOI, Storage, Editor) call them to mark and then remove each replayed operation. Depending on the call site, the attempt threw aTypeErroror did nothing at all — either way the queue was never purged, and already-synchronised operations stayed in it indefinitely. Both methods are now delegated to the synchronisation engine, like the four other queue operations that already were.maxRetries: 0no longer fails a download without attempting it. The obvious spelling of "do not retry" made the attempt loop unreachable: the operation was never called and the offline cache reported a failure with no cause. The field is renamedmaxAttempts— it has always counted the total number of attempts, not the number of retries.maxRetriesis still accepted as a deprecated, normalised alias, so no existing profile silently falls back to the default value.The offline cache no longer grows indefinitely because of themes. Entries cached without a profile — which is the case for every theme — were stored outside the eviction index: they were neither counted in the budget nor ever deleted. A cache made only of themes declared itself empty and evicted nothing, whatever the configured quota.
Offline download no longer reports dangerous-scheme URLs, it skips them. Two fetch points (style resolution, extent computation) followed URLs coming from a remote style or TileJSON without any check; an entry using
javascript:,data:,file:orblob:was reachable there. They are now refused before any network call, and enumeration continues with the following entries instead of stopping.Finishing an offline download no longer fails in a DOM-less context (service worker, server rendering): emitting the progress event threw after every resource had been fetched, turning a successful operation into a failure.
Notifications no longer go mute after a map reset. When the notification capability was destroyed and then recreated (SPA host, profile reload), the engine came back disabled:
notify()and its variants threw nothing, displayed nothing, and did not fall back to the console either — the kernel still believed it had a renderer, so messages were lost rather than degraded. A reset now brings back an active engine, and messages emitted in the meantime are queued and then delivered.Layer labels react to zoom again after a basemap or theme change. When the map adapter was replaced without being destroyed first, the zoom subscription stayed attached to the old map: labels silently stopped recomputing until the page was reloaded.
Label buttons and the share button no longer survive the destruction of their capability. They stayed in the DOM after a
destroy(); clicking a label button still called the destroyed module.Permalink id lists are capped in length, not only in count.
gl_layers/gl_shown/gl_cats/gl_tagsalready limited the number of items to 100, but a single item could weigh megabytes; each item is now truncated to 200 characters, like text fields.The filter panel announced a label to screen readers that differed from the visible one. When a profile does not define
modules.filter.title, the region announced "Filter" while its visible title showed "Filtrer" — an accessible name that does not match the visible label (WCAG 2.5.3). Both now come from a single source.The filter panel is at last translated. Its title, its Apply/Reset buttons, its close button and its "no category" message were hard-coded French strings — while the corresponding translations already existed, complete, in the six dictionaries. An English- or German-speaking user saw French. Profile-defined labels still take precedence.
The details side panel and the layer manager speak the chosen language. Two side panel translation keys were missing from the six dictionaries, and three layer manager keys were missing in Spanish, Portuguese, Italian and German: those elements fell back to French whatever the configured language.
Four shipped profiles at last display the search label their author wrote.
france-risques-inondation,france-urbanisme-btp,guyane-biodiversiteandtourismset a specific prompt ("Rechercher une station…", "Rechercher un bâtiment, chantier…", "Rechercher un nom, une parcelle…", "Rechercher un POI…") inmodules.filter.searchPlaceholder— the key nobody read — while leaving the generic "Rechercher..." in the one actually rendered. Those labels have been moved onto the search field concerned, where they also feed its accessible name.The legend panel title is translated. Its default was the English string "Legend", served as such to all six languages inside an otherwise French interface. It now comes from the dictionary and follows
ui.language. A profile that setsmodules.legend.titlestill takes precedence.GeoLeaf.Storage: a sanitiser that does not sanitise can no longer write. When a validator rejected a value and asanitizefunction was supplied, the sanitised result was stored without being revalidated — so the validator became advisory as soon as a sanitiser existed. The result is now revalidated once, and refused if it is still invalid.The
min/maxbounds of a validation schema are enforced. They were ignored, without a message, if the field did not also declaretype: "number": a schema written{ min: 5 }checked nothing at all.JSON serialisation no longer returns
undefinedwhere it promises a string. For a value JSON cannot represent (undefined, a function, a symbol),JSON.stringifyreturnsundefinedinstead of throwing — so the fallback was never taken, and the caller receivedundefinedfrom a function declared to return a string.The legend's default values are at last announced by introspection.
modules.legendapplied three defaults (title,position,collapsedByDefault) that its schema did not declare: a configuration tool could not display them. The default fortitleis the English string "Legend" while the rest of the interface is in French — the schema now states this explicitly instead of leaving it to be discovered in use.Taxonomy configuration is at last visible to introspection.
modules.taxonomydeclared 1 key out of the ~19 it consumes: a configuration tool querying the schema saw neither the icons, nor the per-surface rendering options, nor the taxonomies themselves. The four sub-trees (icons,render,taxonomies,layers) are now declared with their real default values.The runtime/build split of
modules.pwais documented in the schema.description,theme_colorandbackground_colorare read only when generatingmanifest.json— never at runtime — whereasnameandshort_nameare read on both sides. Nothing said so: each key now carries the corresponding note.
Changed — BREAKING
modules.filter.searchPlaceholderis removed. The key was declared, typed and written by profiles, but no code ever read it: the filter panel has no global search field, only a text-kindfields[]descriptor carries a placeholder (fields[].placeholder), which is indeed rendered. No visible change: a map already showed the per-field placeholder.What this means for you: nothing at runtime — a JSON profile that keeps the key sees it ignored, exactly as before. In TypeScript, a configuration typed
FilterConfigthat mentions it no longer compiles; the replacement isfields[].placeholderon the search field concerned.modules.permalink.fieldsno longer accepts"lat","lng"or"zoom". These three values were inert: the view state is written unconditionally and required when parsing (a permalink without a view restores nothing), so removing them from the list changed nothing. Rather than let the type promise a granularity the runtime does not honour, the view is declared mandatory and taken out of the enumeration —fieldsnow contains only what it actually governs:layers,shownLayers,filter,categories,tags,rating,theme.What this means for you: nothing at runtime — a JSON profile still listing
"lat"/"lng"/"zoom"keeps working identically, those entries are simply ignored. In TypeScript, a configuration typedPermalinkConfigthat mentions them no longer compiles: remove them. The view is always serialised, including with"fields": [].
Removed
GeoLeaf.UI.PanelBuilderdocumentation removed. That page described an API absent from the code: none of the documented functions (createPlainSection,renderText,renderTable…) exists any more, and the.gl-poi-panel__*CSS classes it presented were produced by nothing. The corresponding stylesheet is deleted along with it. Integrators composing their own detail panels should useGeoLeaf.FeatureInfo.
Internal
- Stylesheets trimmed by ~24% (
geoleaf-main.min.css: 127 → 97 kB). Three stylesheets of thefeature-infocapability were loaded twice — one being the exact concatenation of the other two — and roughly 850 lines of rules matched no element produced by the code. No appearance change expected; the documented public classes are unchanged. maxPersistentis at last applied. The field was accepted and documented (@default 2) butinit()never read it: the constructor value applied whatever happened, and configuring it changed nothing on screen.
Removed — BREAKING
The two plugin report methods are replaced by
GeoLeaf.plugins.reportPlugins(). They partitioned the registry into two categories. There is only one now: every plugin is MIT and published on npmjs.The split was moreover wrong. Each report relied on a hard-coded list of names that overrode the
typefield declared by the plugin, and the two lists contradicted it in opposite directions:storageandeditoreach appeared in the list opposite to the one they declared. As a result, those two plugins were shown by both reports, and every start-up counted them twice.What this means for you: replace both calls with
GeoLeaf.plugins.reportPlugins(). There is now a single report, without duplicates. A plugin whosehealthCheck()fails is flagged on its line but no longer triggers aconsole.warn— one of the two old reports did, the other did not, and the second behaviour is the one kept:connectoris legitimately not connected at boot, and warning on every page load would be a false alarm.The
typefield of theplugins.register()manifest is removed. It served only the two reports above, and after their merge nothing read it any more. The Plugin Contract v1 (rule PC-03) no longer requires it.What this means for you: remove
typefrom yourplugins.register()call. Leaving it has no effect — unknown fields are ignored — but it will no longer be validated or read.
Changed
The start-up message now simply lists the loaded plugins (
<plugins>, oropen sourceif none). The old format split them into two groups based on a hard-coded prefix list (storage,addpoi) which omittedcogand ignored thetypefield: it mislabelled the plugins it knew and missed the one it did not.The legend resolves the icon of a taxonomy category through the
svgIdfield alone. It aligns with the rest of the taxonomy capability (map icons, badge pills) and with the shared resolver, which have never read anything butsvgId. The inheritedicon/iconIdfields of a category entry are no longer read by the legend (they still were, as a fallback). No shipped profile was affected — all 9 declaresvgId. What this means for you: if a custom profile seticonoriconIdin itstaxonomy.jsoncategories for the legend, rename them tosvgId(already the documented form).modules.cluster.clusterRadiusandmodules.cluster.disableClusteringAtZoomat last drive clustering of GeoJSON point layers. These two keys were exposed by the introspection schema but never applied: the real radius of a GeoJSON layer stayed fixed at 80 px (max-zoom 14) whatever the value configured at profile level, the key being used internally only for a strategy comparison. Precedence is now per-layer override →modules.cluster.*→ default. The schema is corrected accordingly:clusterRadiusannounces its real default80(introspection reported50, which matched nothing applied) anddisableClusteringAtZoomdeclares its default14. What this means for you: if you were settingmodules.cluster.clusterRadius/disableClusteringAtZoomexpecting them to act, they now act — check the value you want. A profile that did not define them keeps exactly the current rendering (80 / 14). The shared POI cluster radius (50 px) is unchanged and stays independent of this key.
Added
GeoLeaf.Eventsat last exists at runtime.index.d.tsdeclaredGeoLeaf.Eventsand theEVENTS_API.mdpage used it in 18 examples, but nothing mounted that casing: onlyGeoLeaf.events(lower-case) was set on the global. As a result,GeoLeaf.Events.on(...)compiled — the typings said so — then threw aTypeErrorat runtime.Both forms are now mounted and strictly equivalent (
GeoLeaf.Events === GeoLeaf.events).Eventsis the canonical casing;eventsremains a permanent alias and is not deprecated, exactly likeBaselayers/BaseLayers.What this means for you: nothing to do. If you followed the documentation, your code now works as written; if you worked around the problem by writing
events, it keeps working identically.GeoLeaf.Utils.wktToGeoJSON()at last exists. It had been announced in the changelog since v2 and documented as a member of the namespace, but was never set at runtime: it lived only on an object assembled by a module that became unreachable when the UMD builds were dropped (v2.0.0). Calling it threw aTypeError. It is now really mounted.import { Utils }andwindow.GeoLeaf.Utilsat last expose the same thing. The ESM export carried only 12 members where the global carried 27: the same name designated two objects of different shapes depending on how it was reached, and nothing stopped them drifting further. Both surfaces are now composed in the same place and locked by a test.They are still two distinct objects — the global must stay modifiable and re-applicable by the module lifecycle — but their members are identical.
What this means for you:
import { Utils }gives access to 16 members it did not carry (DOMSecurity,FetchHelper,ObjectUtils,ScaleUtils,TimerManager,createElement…). Nothing is removed.GeoLeaf.Utilsis at last typed. Its public interface was declared{ [key: string]: unknown }: no completion, every member typedunknown(soUtils.debounce(fn)required a cast), and above all no gap between documentation and runtime was detectable at compile time. All 28 members are now declared.IMapAdapterexposesgetMarkerHandle(id). A new optional method of the adapter contract: it returns a typed handle (GeoLeafMarkerHandle—getLngLat()+on(event, cb)) on a marker created throughcreateMarker(), ornull. It exists for the two interactions that id-based management does not cover — reading a marker's position after the user has dragged it, and subscribing to its own events.What this means for you: nothing to do. The method is optional, so a custom adapter that does not implement it stays compliant. If you write an adapter for an engine with a per-marker event model, you can now expose it cleanly instead of forcing the caller around the contract.
Fixed
A proximity filter without a radius no longer empties the list. A proximity search enabled with a centre but without a radius rejected every route (the comparison used an
undefinedradius), instead of behaving like an absent criterion. A missing radius is now treated the way a missing centre already is: the criterion does not apply, and nothing is wrongly filtered out.An empty tag selection no longer filters everything out. On route lists, enabling the tag filter without ticking any tag emptied the result, whereas the general filtering engine treats an empty selection as "no constraint" and lets everything through. Both paths now answer identically — an empty selection excludes nothing.
A title written
variant: "title"is at last honoured in the side panel. A detail field declares itself a title in two ways,variant: "title"orstyle: "title", and the schema accepts both everywhere. The popup treated them equally; the side panel recognised onlystyle. A field authored withvarianttherefore lost its required-field status there — it disappeared when its value was empty — as well as its category icon, while the same field displayed correctly in the popup. Both spellings are now equivalent on both surfaces. No rendering changes for existing profiles: the current authoring convention did not hit the defect.Focus no longer escapes the image viewer with the keyboard. The lightbox focus trap recognised only buttons: a viewer containing a link (photo credit, source link) let
Tableave the dialogue. Links, input fields, drop-down lists and text areas are now taken into account. Same correction on the share modal, which conversely included disabled elements —Tabcould get focus stuck there on an element unable to receive it.Labels no longer disappear for good after a basemap change. When the map style is reloaded (basemap or theme change), removing a label layer can fail. On the zoom-triggered path, that error was not caught: it interrupted processing, and the layer was still considered "labels shown" — so it never rebuilt them again for the rest of the session.
The theme you choose at last survives a reload. Boot re-applied the profile theme by writing it into
localStorage, overwriting the preference it had just read from it: no chosen theme held. Precedence is now explicit — stored user choice → profileui.theme→prefers-color-scheme— and boot never writes any more. Only an explicit action (the theme button, orGeoLeaf.setTheme()) persists.The button of a layer hidden by zoom at last switches it off.
toggleLayer()decided from physical visibility, which zoom can force to "invisible", whereas the button reflects the logical state. On a layer you had enabled but which the current zoom hid, the click re-enabled it instead of switching it off.Rapid basemap switching: no more stale result. Switching from a WMTS basemap to a raster one while the WMTS request was still in flight let the stale result apply over the new basemap. The request is now cancelled and its result discarded.
Contrast of the basemap buttons on hover. In light theme, the label dropped to a ratio of 1.10:1 on hover — that is, unreadable (WCAG AA requires 4.5:1). Corrected to 15.48:1. The dark theme was not affected.
The CSRF token degrades instead of crashing. Its automatic refresh was unguarded: if the cryptographic source disappeared after start-up, the timer threw an error every ~55 minutes, in a queue where nothing could catch it.
Changed
style.shapeis restricted to"circle". The schema declared it as free text and the documentation announced"square"and"triangle"— neither has ever been rendered. Points are drawn by a MapLibrecirclelayer, which draws only circles. The key is moreover inert (no code reads it) and stays reserved. To differentiate categories, usestyleRulesor the taxonomy.What this means for you: a profile declaring
"shape": "square"is now rejected by validation, instead of being accepted and then silently ignored.Layer manager labels are translatable. "Gestionnaire de layers" (Franglais) becomes "Gestionnaire de couches" and goes, like "Fond de carte" and "Couches GeoJSON", through overridable i18n keys.
Removed
GeoLeaf.ensureMap(),GeoLeaf.requireMap(),GeoLeaf.hasMap()andGeoLeaf.Utils.MapHelpersare removed. They were declared in no typings, documented nowhere, and had no caller in the whole monorepo (plugins, demos, e2e, profiles).They were above all wrong: their duck-typing required
setView, a Leaflet method that does not exist in the MapLibre API.GeoLeaf.ensureMap(myMapLibreMap)therefore returnednull. They did not validate "is this a map?" but "is this the GeoLeaf adapter?", without saying so.The resolver to use is
GeoLeaf.Utils.ensureMap()— the documented one, exported in ESM and actually called. It has taken over the validation this pair carried, minus the Leaflet requirement.Three documentation pages described APIs that did not exist. They are corrected or removed. None was callable: copy-pasting their examples threw a
TypeError.GeoLeaf.Helpers.createElement()— removed in v3 (no caller, and its options shape silently diverged from the canonical factory: it readstyleswhere the other readsstyle, and madeinnerHTMLwin overtextContent). The documentation still presented it. Migration:GeoLeaf.Utils.createElement(tag, props, ...children), renamingstyles→style.GeoLeaf.Utils.escapeHtml()— was never mounted at runtime (same cause aswktToGeoJSONabove). UseGeoLeaf.Security.escapeHtml(), which is mounted, tested and documented.- The
AbstractRendererpage described a class and a source file deleted during the purge of Leaflet-era code. Page removed.
Changed
GeoLeaf.Utils.ensureMap()now validates its argument. It previously returned as-is any non-empty argument:ensureMap("foo")was"foo", although its documentation promises "the MapLibre GL map instance" and the example goes on withmap.fitBounds(...). The failure therefore only appeared at the first method call, far from the cause.An argument that is not a map now gives
null, like an absent map. The duck-typing coversgetCenter/getBounds/on/off— present both on a GeoLeaf adapter and on a rawmaplibregl.Map.What this means for you: nothing if you already tested the return value (
if (map) …, the documented form). If you were routing something other than a map through this function, pass it directly.Prototype pollution protection — single source. The
__proto__/constructor/prototypeguards were copied into four modules, in four diverging forms, three of them silent. They are now all backed by a single canonical blocklist. Three writes reachable from themodulesblock of a profile (mergeModulesBag,mergeModuleBags, theFiles.modulesloader) were not guarded and now are; a module id equal to__proto__in a profile can no longer reparent the configuration bag. No API change — legitimate identifiers are unaffected.
Fixed
GeoLeaf.Filters.filterRouteList()— the proximity radius was sometimes kilometres, sometimes metres. The function resolved its distance throughGeoLeaf.Utils.getDistance ?? haversine. Both satisfy the same signature(lat1, lng1, lat2, lng2) => number, but the first returns kilometres and the second metres: the radius was therefore interpreted with a factor of 1000 depending on whetherGeoLeaf.Utilshad been loaded. In a full bundle the kilometre branch won; the test suite, however, never setGeoLeaf.Utils, so it had always validated the metre branch — hence a defect that stayed invisible.proximity.radiusis now in metres, unconditionally, consistent with the filter engine predicate and with theradiusKm * 1000conversion already done by the panel and the permalink serialiser. The unit is now documented in the typings (FilterStateInput.proximity), which it was nowhere.What this means for you: if you called
filterRouteList()with an active proximity and compensated for the kilometre behaviour, divide your radius by 1000. The category, sub-category, tag, rating and search filters are unchanged, and the built-in panel's proximity filtering was not affected (it already went through the engine, correct all along).Public typings —
GeoLeaf.Errorsdescribed signatures that were not those of the code. Six discrepancies, all corrected inindex.d.tswithout touching the runtime:createError()was declared(message, code?, context?)whereas its first argument is the error class:createError(Errors.ValidationError, "message"). TypeScript code written from the typings passed a message where the runtime expects a constructor.safeErrorHandler()was declared(error, handler)— the runtime expects(handler, error).GeoLeafError.timestampwas typednumber; it is an ISO-8601 string.- The constructor was declared
(message, code?, context?); it is(message, context?)— a three-argument call compiled and filed the code into the context. codewas declared required although the base class never assigns it.normalizeError()omitted its second parameterdefaultMessage.
Public typings —
GeoLeaf.Helpersdeclared four methods that do not exist.debounce,throttle,fetchWithTimeoutandbatchDomOperationsappeared inHelpersAPIbut have never been present on the runtime object: calling them compiled and then threwTypeError. Same defect as_UIComponents.clearElement()/createEmptyMessage(), fixed in v3.0.0. Declarations removed.Migration:
debounceandthrottledo exist — onGeoLeaf.Utils, not onHelpers.fetchWithTimeouthas no equivalent: useHelpers.createAbortController(timeout)withfetch. Nor doesbatchDomOperations:Helpers.createFragment()covers the case.Conversely, six genuinely real methods were missing from the typings and are now declared:
applyCssText,lazyLoadImage,lazyExecute,addEventListener,addEventListeners,delegateEvent. No runtime change:GeoLeaf.Helpersexposes the same 23 members as before.GeoLeaf.Core.setTheme()/getTheme()were out of sync with the theme engine. Both methods kept their own internal state, never updated by the canonical engine (GeoLeaf.UI) — the one driven by the theme button, byGeoLeaf.setTheme()and by the boot sequence. Observable consequences:Core.getTheme()returned"light"on a dark-themed page from the very first frame, and stayed wrong after any change made outsideCore.setTheme().Core.setTheme()wrote only thedocument.bodyclass: nolocalStoragepersistence, no class on the#geoleaf-mapcontainer (wrong theme in full screen), noaria-pressedupdate on the theme button (an accessibility defect), nogeoleaf:ui-theme-changedemission.
Both methods now delegate to the canonical engine when
GeoLeaf.UIis present, and fall back to thebodyclass otherwise.GeoLeaf.Core.setTheme(),GeoLeaf.setTheme()andGeoLeaf.UI.applyTheme()are therefore really interchangeable — which the documentation already claimed without it being true.No signature or surface change. The only behaviour modified is the one that was wrong:
getTheme()now returns the theme actually applied. The warning message for an invalid value becomes[GeoLeaf.Core] setTheme() ignored an invalid theme: {value}.The chosen theme still does not survive a reload, for a distinct and pre-existing reason: the boot sequence re-applies the initialisation theme, overwriting the stored value.
setTheme()now does writelocalStorage, but boot comes after it. This point is tracked separately and is not changed by this version.GeoLeaf.Storage.OfflineDetector— three defects. (1) The abort timer of the connectivity ping was cancelled only on the nominal path: each failed ping left a 5 s timer alive, accumulating for as long as the network stayed down. (2) The initial state was read fromnavigator.onLineat module import, which threw in any environment withoutnavigator(SSR, Node test without jsdom); the read now happens ininit(), under a guard. (3)init()is now idempotent: a second call tears down the previous one instead of stacking a second set ofwindowlisteners.
Removed
BREAKING —
GeoLeaf.BusandGeoLeaf.Utils.createEventBus()removed. An in-memory pub/sub, mounted at boot and never read: no read in the library, the profiles, the integration tests or the plugins, and it was declared neither inindex.d.tsnor in the ambient typings. It moreover shared the file name of the real core event bus, which produced two mistaken diagnoses in review.Migration. The real event system is unchanged and remains the only supported route:
GeoLeaf.Events.on(name, handler)/.off()/.once()for listening (26 events documented inEVENTS_API.md). If you needed a generic application bus, the browser'sEventTargetcovers the case in three lines — this module added nothing beyond it.BREAKING —
GeoLeaf.DOMSecurity.createElement()removed. Despite its namespace, this function sanitised nothing: it did not callsetSafeHTML, did not handleinnerHTML, and wrote unknown attributes throughelement[key] = valuewithout any guard. Its name promised a protection it did not provide — all the more serious a trap since the plugin contract explicitly directs authors toGeoLeaf.DOMSecurity.*for sensitive DOM operations. It had no caller.Migration. Use
GeoLeaf.Utils.createElement(tag, props, ...children): it guards property writes and routesinnerHTMLthroughDOMSecurity.setSafeHTML(). The rest ofGeoLeaf.DOMSecurity(setSafeHTML,setTextContent,clearElement,clearElementFast,createSVGIcon,getIcon,SVG_ICONS) is unchanged and remains the recommended route.BREAKING —
GeoLeaf.Helpers.createElement()removed. No caller, and its options shape silently diverged from the canonical factory: it readstyleswhere the other readsstyle, and madeinnerHTMLwin overtextContent(the reverse precedence). Since both interfaces carried an index signature, no type check would have flagged a substitution.Migration.
GeoLeaf.Utils.createElement()— take care to renamestylestostyleif you passed a style object. The rest ofGeoLeaf.Helpersis unchanged.
Security
Hardening —
data:URLs are now validated against the same allow-list everywhere.GeoLeaf.Validators.validateUrl()tested theimage/prefix, accepting any sub-type (data:image/bmp,data:image/x-anything-at-all), whereGeoLeaf.Security.validateUrl()applied an exact allow-list of six types (png,jpeg,jpg,gif,svg+xml,webp). The same URL therefore received opposite verdicts depending on the entry point. Both functions now share the allow-list and the same MIME type parser.Observable effect.
GeoLeaf.Validators.validateUrl("data:image/bmp;base64,…")now returns{ valid: false }instead of{ valid: true }. The six allowed types are unchanged. Signatures, return shapes and error messages do not change.Fixed along the way: MIME type extraction returned
image/png;base64instead ofimage/png(invisible against a prefix test, blocking against an exact allow-list).
Fixed
GeoLeaf.Validators.validateUrl()returned an invented domain for relative URLs. The function resolved against a hard-coded base (http://dummy.com), sovalidateUrl("/api/data.json")returned{ valid: true, url: "http://dummy.com/api/data.json" }. It now resolves against the current origin, likeGeoLeaf.Security.validateUrl().Public typings — the
ValidatorsAPIinterface was unusable.index.d.tsdeclared all eight methods (validateUrl,validateCoordinates,validateEmail,validatePhone,validateZoom,validateRequiredFields,validateGeoJSON,validateColor) with avoidreturn type, while all of them return a{ valid, error }object. Any TypeScript code writingif (GeoLeaf.Validators.validateUrl(u).valid)failed to compile. The declared signatures now match the implementations, options included (ValidatorOptions,ValidateUrlOptions,ValidateZoomOptions,ValidationOutcomeare exported).BREAKING —
GeoLeaf._UIDomUtilsremoved from the namespace. This internal module (_prefix) exposed only two members, both with no caller in the library, the profiles or the integration tests — but both documented with examples inGeoLeaf_UI_Components_README.md, published on npm. It is that page, not the code, that made it a contract: the removal is therefore treated as a breaking change, just asattachAccordionBehavior()was in its time.Migration.
GeoLeaf._UIDomUtils.resolveField(obj, path)was only an alias of the canonical internal helper; it never had a public equivalent. An integrator using it to read a nested property can replace it with a dependency-free line:const at = (o, p) => p.split(".").reduce((v, k) => (v == null ? undefined : v[k]), o);GeoLeaf._UIDomUtils.getActiveProfileConfig()already delegated toGeoLeaf.Config.getActiveProfile()— call that one directly, it is public and unchanged.
GeoLeaf.UI._getActiveProfileConfig(), the legacy facade wrapping the second, is removed in the same move (same absence of caller, same migration path).Documentation corrected —
GeoLeaf._UIComponents.clearElement()andcreateEmptyMessage()never existed. Both were described with a copy-pasteable example inGeoLeaf_UI_Components_README.mdwhile neither is implemented: calling them threw aTypeError. Sections removed. (GeoLeaf.Utils.DOMSecurity.clearElement()does exist and is quite real — but that is another namespace.)BREAKING —
GeoLeaf.LayerManager: removal ofupdateSections(),addSection(),toggleCollapse()andisCollapsed(). These four methods were documented but absent from the typings (index.d.tsnever declared anything butinit()andrefresh()), and none had a caller — not in the library, not in the integration tests, not in the profiles.toggleCollapse()was moreover broken after aCore.destroy(): it dereferenced_container, reset tonullon teardown.Migration. Collapsing the panel is still driven by its header button (no action required). Sections are declared in the JSON profile (
layerManagerConfig.sections) — that is the nominal path, and it was already the only one used. Adding a section dynamically has no programmatic replacement: if you had a real use for it, open an issue and the method will be reintroduced with typings and end-to-end coverage.GeoLeaf.LayerManager.init()andrefresh()are unchanged.GeoLeaf._LayerManagerSharedremoved from the namespace. An internal key (_prefix, outside the documented public API) exposing a state object with no reader: the layer manager's real state lives in the module itself. Itsreset()therefore cleaned nothing, and the panel survived adestroy → recreatecycle. Fixed: teardown now acts on the real state.
Fixed
- The layer manager no longer survives a
Core.destroy()→Core.init()cycle. The map, the control and the accumulated sections persisted from one instance to the next, and a pending deferred refresh could fire on a detached DOM.
Security
Feature URL validation now applies a protocol allow-list. The
link,photoandurlproperties of a GeoJSON feature were checked by a plainnew URL()in atry/catch— andnew URL("javascript:alert(1)")succeeds. As a result, a value usingjavascript:,vbscript:,data:text/htmlorfile://was considered valid and raised no warning. It was the library's only URL check without an allow-list; it now delegates to the canonical security validator (http:,https:,data:images).Exact scope: this is a validation warning, not an injection flaw. Those values did not reach the DOM through this path — rendering (
renderLink/renderImage) already validated the URL at the sink and displayed nothing for a disallowed protocol. The defect is that feature validation kept quiet about a malicious profile instead of reporting it.What this means for you: new console warnings if your data contains these protocols. Relative paths (
/img/a.png,./a.png,../a.png), protocol-relative URLs (//host/a.png),data:images and contact schemes (mailto:,tel:) are still accepted identically. Three cases now warn where they passed before:ftp://,blob:anddata:image/*outside the MIME allow-list. All stay atwarningseverity — no feature is invalidated, nothing renders differently.Prototype pollution hardening of the configuration write path.
Config.Storage.setValueByPath()now refuses any path segment named__proto__,constructororprototype— including the last segment (a single-segment path bypassed the guard). The same check is applied toGeoLeaf.Utils.setNestedValue(), which had none. The other config writers (set,merge,deepMerge) were already protected.Concrete impact: a profile
mapping.jsonwhosemappingkey targeted a prototype could graft an inherited property onto every normalised POI, which then propagated to feature properties, popups and table columns. Global pollution ofObject.prototypewas not reachable — the scope was limited to the POI objects being built. No shipped profile was affected.Not breaking for legitimate configurations: only paths targeting a prototype are refused, and the write becomes a no-op with a warning. If you used
GeoLeaf.Utils.setNestedValue()with such paths, the call no longer modifies the object.Security documentation (
docs/SECURITY.md,docs/security/SECURITY_CONTRACT.md) rewritten and checked against the code: it attributed several vectors to a non-existent function.
Fixed
- Two French
aria-labelvalues contained leftover Franglais ("Afficher / hide la layer" and "Afficher/hide les étiquettes"). Corrected to idiomatic French ("Afficher / masquer la couche" and "Afficher/masquer les étiquettes"). Only the default French dictionary was affected — the five other locales were already correct, and both the i18n keys (aria.layer.toggle,aria.labels.toggle) and thegetLabel()API are unchanged.
[3.0.0] - 2026-07-16
Major release v3.0.0. It consolidates the whole v3 effort: dissolution of the POI subsystem into generic point layers, taxonomy v3 (the point symbol), extraction of the optional modules into in-core capabilities and MIT plugins, multi-instance rework, security hardening (strict
style-srcCSP) and hard removal of all legacy (deprecated API aliases, re-export shims, format fallbacks, legacy configuration keys).The jump from the last version published on npm (2.1.8, 2026-05-13) is a large one: breaking changes are numerous, and each carries its own "Migration" note.
WARNING
taxonomy and feature-info are capabilities built into @geoleaf/core, not separate npm packages. Entries below that mention @geoleaf-plugins/feature-info and @geoleaf-plugins/taxonomy as external plugins to install or load through a <script> tag are obsolete: both capabilities ship inside the core bundle (geoleaf.esm.js + dist/geoleaf-main.min.css) and are enabled by configuration — no extra install, no extra <script> tag. Migration: remove the <script src="dist/geoleaf-taxonomy.plugin.js"> and <script src="dist/geoleaf-feature-info.plugin.js"> tags.
Fixed
- The AddPOI form showed NO category or sub-category field — on every profile. The plugin looked for the taxonomy in a
taxonomykey at the root of the active profile, which the profile loader has never produced: the read therefore always returned "nothing", the form builder concluded "this profile has no taxonomy" and added neither the category list nor the sub-category one. Silently: no error, no warning. The taxonomy is bound to the layer (modules.taxonomy.layers.<id>.use): the form now resolves it from the layer on which the POI is created or edited, and reloads it when you change layer in the selector. - A profile's icon sprite was NEVER cached for offline use — on every profile. The resource enumerator looked for
spriteUrlat the root of the profile, where it lived before the taxonomy v3 rework; since then it is declared in the capability's configuration file (config/plugins/taxonomy.json). The config file was downloaded — but not the SVG it points to, so icons were missing once offline. Resolution now goes through theFilesmanifest, as it does for layers, and the URL is requested exactly as the engine requests it online (a rewritten URL would have been stored under a key that is never looked up). A disabledtaxonomycapability still downloads nothing. - A style without an
idmade its layer fail to load — the layer never appeared. The schema stopped requiringid(the file name serves instead, the case for roughly 20% of style files), but the runtime validator still required it: the style was rejected, the loader threw, and the layer was never created. No message pointed at the real cause. 15 styles across 3 demonstration profiles were in that state. The validator is aligned with the schema, and the loader now derives theidfrom the file name — the derivation the schema documented without anyone implementing it. An explicitly declaredidstill wins, and its format is still validated. - A
map.centerwritten[lng, lat]passed without a word.centeris[lat, lng], likebounds. Three profiles had it inverted ([-53, 4]placed French Guiana in the Southern Ocean), with no visible effect as long as they also declaredbounds— which the loader prefers. Corrected, and the profile loader now warns when a centre falls outside its own bounds but falls inside once swapped, or when its latitude leaves[-90; 90]. A simple range check was not enough:-53is a perfectly valid latitude. - A layer declaring a zoom threshold in
zoomConfig.minZoom/maxZoomwas invisible at ALL zoom levels. The field was calledminZoom/maxZoombut the engine read its content as a scale denominator (theXin1:X): aminZoom: 6was therefore understood as "scale 1:6", that is a zoom of about 27 — out of reach (MapLibre caps at 24). The layer never appeared; ticked manually in the layer manager, it appeared but then respected no threshold at all. 18 layers across 3 demonstration profiles were affected. The field is renamedscaleConfig.minScale/maxScale, whose name states the unit, and the validator now rejects any value<= 24as well as the oldzoomConfigblock (see Breaking Changes below). A profile whose thresholds were already denominators was not affected and keeps its values as they are. import { Config } from "@geoleaf/core"shipped aConfigWITHOUT.get(),.set(),.getAll(),.loadUrl()or.getSection(). Thedist/esm/build — the artefactexports["."]resolves, therefore what every bundler consumer (Vite, webpack, Rollup) receives — pruned the three modules that put these methods on theConfigsingleton. The CDN bundle (dist/geoleaf.esm.js,<script type="module">, unpkg/jsdelivr) was never affected: if you use it, there is nothing to do. If you import@geoleaf/corefrom a bundler, update: this is a fix, not an API change.package.json#sideEffectsprotected nothing. All its entries targetedsrc/**/*.ts— a directoryfilesdoes not publish. For your bundler, the whole package was declared side-effect free, which allowed it to drop the modules that populatewindow.GeoLeaf.*. Rewritten against the published paths, and now derived from the code and checked at every build.
Added
GeoLeaf.Taxonomy.getLayerCategories(layerId): Record<string, TaxonomyCategory>— the categories bound to one layer, resolvingmodules.taxonomy.layers.<id>.usefor you. Returns{}if the layer has no binding, if the binding names an unknown taxonomy, or if the capability is disabled (enabled: false).- Prefer it to
getCategories(ref)as soon as you start from a layer:getCategoriesexpects the name of a taxonomy, which only thelayerstable knows. Reading it yourself amounts to reimplementing binding resolution — which cost the AddPOI form its category lists, empty on every profile without a single message (fixed in this version, see Fixed).
- Prefer it to
GeoLeaf.PWA.isInstallable(): boolean— to display your own install button instead of the built-in banner. It follows the same routing as the banner: on iOS Safari,trueif the app runs on iOS without already being installed (iOS never emitsbeforeinstallprompt, it is the only available signal); on Android/Chrome/Edge,trueonce the browser has offered a deferred install prompt.- On Android, the answer means "a prompt is available", not "this browser could install the app": the deferred prompt is only captured if
installPrompt.enabledistrue. Banner disabled ⇒falseeven on an installable Chrome. iOS is not concerned. - Note: the internal specification announced this method (as well as
prompt(),dismiss()andgetInstallState()) since v2.1.0, while none was exposed. OnlyisInstallable()had an implementation; it is now wired, the other three never existed and are removed from the internal documentation.
- On Android, the answer means "a prompt is available", not "this browser could install the app": the deferred prompt is only captured if
Stable
exportssubpaths, to compose your own entry point and ship only what you list:subpath contents @geoleaf/core/kernelthe kernel facades ( Core,Config,UI,LayerManager,Events, …)@geoleaf/core/globalsside effect: populates window.GeoLeaf.*and pulls the kernel sheet@geoleaf/core/helpersside effect: wires GeoLeaf._app@geoleaf/core/bootinstallBoot(manifest)@geoleaf/core/capabilities/<id>/install.jsthe installer of one capability ( LEGEND_INSTALLER, …)@geoleaf/core/facades/<name>.jsLegend,Permalink,Share@geoleaf/core/presets/manifest.full.jsthe manifest of the 18 shipped capabilities All typed. See COOKBOOK.md, Recipe 8.
The CSS follows the code. Each capability imports its stylesheet from its
install.ts: the CSS is a node of the module graph and tree-shakes with the capability. An entry point withoutfilterships neither its JS nor the CSS of its proximity bar (measured: −19% CSS on the 9-capability example).Explicit cascade —
@layer gl.reset, gl.tokens, gl.kernel, gl.capabilities, gl.overrides. The order no longer depends on how your bundler concatenates.gl.overridesis reserved for you: a rule you place there wins without!importantand without a specificity war.
Changed
GeoLeaf.registry.getAll()and introspection list 6 kernel modules instead of 8 —securityandapino longer appear. Nothing is removed from the public API:GeoLeaf.Security,GeoLeaf.CSRFToken,GeoLeaf.DOMSecurity,GeoLeaf.API.*and the other facades are unchanged, and are even available earlier (see below). Those two entries were only lifecycle wrappers around subsystems that have neither a map nor a configuration to wait for: theirinit()/destroy()were empty. You are only affected if you enumerate the registry's modules (diagnostics, tooling) — not if you call their facades.- The kernel facades are set at bundle import time, before
GeoLeaf.boot(). The surface available from import goes from 64 to 88 keys:GeoLeaf.GeoJSON,GeoLeaf.ThemeCache, the_LayerManager*/_UI*/_Theme*helpers,_OfflineDetector,_StyleUtils… No key has disappeared. Concretely, a plugin or script loaded beforeboot()can again callGeoLeaf.I18n.registerDict(),GeoLeaf.notify()orGeoLeaf.Utils.*at its own top level without the call being silently lost. This restores a behaviour that an internal rework (v2.x) had removed without documenting it — and whose loss was mute: the plugin still mounted, only its labels disappeared. - Switching raster basemap no longer recreates the source when only the tiles change. The old switch destroyed the source and the layer to rebuild them identically; it now mutates the URLs in place (
setTiles), which avoids flickering. The previous behaviour is still used as soon as anything else changes (tile size, attribution, zoom bounds): those properties are frozen at source creation and a mutation would silently lose them. - The coordinate display is written at most once per frame. The cursor emits far more events than the screen displays frames: intermediate writes were invisible by construction. The displayed position remains the last known one — the reading does not freeze on a stale position.
Added
- A warning when a layer displays many points without clustering (beyond 1000). A heavy profile degraded the browser without the slightest signal. The message names the layer, its number of points and the option to enable. It is a warning only: rendering is not modified, clustering is not forced — the decision is yours.
Changed
A layer's scale window is now carried by the engine, and it is the law.
scaleConfig.minScale/maxScaleare converted into MapLibreminzoom/maxzoomand set on every sub-layer: the layer appears and disappears during the zoom, instead of waiting for the end of the gesture. Two visible consequences:- Ticking a layer outside its scale range no longer displays it. Previously, a click in the layer manager overrode the threshold and the layer stayed visible at every zoom. That workaround is what hid the
zoomConfigbug fixed above for three months. The behaviour is now that of a GIS: a layer has a range, and the range wins. - Clustered layers at last respect their range: cluster bubbles were the only sub-layers not receiving the bounds, and kept displaying outside the window.
- Since the conversion depends on latitude (the same 1:X does not fall at the same zoom in French Guiana and in Norway), the bounds are recomputed when the map moves far enough in latitude. Zooming triggers no recomputation.
- Ticking a layer outside its scale range no longer displays it. Previously, a click in the layer manager overrode the threshold and the layer stayed visible at every zoom. That workaround is what hid the
BREAKING — the "compose your own entry point" recipe has changed paths. The ones the COOKBOOK displayed (
@geoleaf/core/src/…) never worked:src/is not published. Use the subpaths in the table above.BREAKING —
GeoLeaf._loadModule()andGeoLeaf._loadAllSecondaryModules()are REMOVED, and nothing replaces them. The whole lazy-loading machinery disappears (src/lazy/, the dispatcher,_app._ensureModule). Migration: delete the call. What these functions went to fetch is already in the bundle by the time your script runs — they answered a build question at runtime.@geoleaf/core/style.css(→dist/geoleaf-main.min.css): path, name and content unchanged. The file is now produced by Rollup instead ofpostcss-cli, but it still contains the kernel and the 18 capabilities. Nothing to change if you load it through<link>.- They no longer kept their promise anyway. Every in-core capability anchors itself through its
install.tssince v3: its code is in the eager closure. The chunks still served were therefore re-export shells over code already present — Rollup emitted them empty (Generated empty chunks), and the browser downloaded them all the same on every page load. Boot made an asynchronous round trip for nothing. - The module name table in
COOKBOOK.mdwas already wrong 7 times out of 9 (poi,poiCore,poiExtras,legend,labels,themes,searchno longer existed): a call with any of those names already fell into aconsole.warn("Unknown module"). - If what you wanted was a SMALLER bundle (rather than a deferred one), that is now a build choice, and it is supported: compose your own entry point from the capability installers you need — the rest is tree-shaken, not deferred: absent. Full, tested recipe:
examples/minimal/entry.ts(9 capabilities out of 18, −15% boot payload, measured at every build). SeeCOOKBOOK.md— Recipe 8: Shipping less than the whole library. - A configuration flag (
modules.<id>.enabled) disables a capability; it cannot remove its code from the file the browser has downloaded. Only the build choice can.
- They no longer kept their promise anyway. Every in-core capability anchors itself through its
BREAKING —
taxonomyv3: the capability now owns the POINT SYMBOL, and nothing else. It owns the icon, its colour, the badge disc (fill / border) and the colour of the category / sub-category pill badges on the feature-info surfaces. The geometry colour (polygon fill, polyline stroke, and business colour of points) as well as the point size go back to each layer'sstyleRules.- What disappears from the configuration:
categories.<val>.colorFill,.colorStroke,.color,.colorRoute. These keys were documented as drivingfill-color/line-colorper category — they painted nothing: the module that read them was never registered (its gate read a profile key loaded after the gate was evaluated). Migration: express geometry colour in the layer'sstyleRules(when.fieldaccepts any attribute, includingproperties.categoryId). modules.taxonomy.enabledbecomes OPT-OUT (defaulttrue) and really gates everything:falseswitches off map icons, the badge disc, the pills, legend icons and per-category filter options. Until now the key was opt-in and disabled nothing — setting it tofalsehad no observable effect. A profile relying on that non-effect must now remove the key.- Composition rule: taxonomy replaces the default value of the point paint; the layer's
styleRuleskeep priority. Cascade:styleRules > sub-category > category > layer default.
- What disappears from the configuration:
BREAKING — removal of
GeoLeaf.Helpers.StyleResolver,GeoLeaf.Helpers.getColorsFromLayerStyle()andGeoLeaf.Helpers.resolvePoiColors(). These three helpers resolved a POI colour from a layer'sstyleRules, but hard-coded the column namesproperties.categoryId/properties.subCategoryIdand had no caller in the core.styleRulesresolution is handled by the adapter's style converter, which accepts any field. No replacement API — these functions had no documented use.BREAKING — removal of
GeoLeaf.UI.hasActiveFilters(),GeoLeaf.UI.getActiveFilters()andGeoLeaf.UI.resetAllFilters(). These three methods already answered nothing useful: they read an internal state (_UIFilterStateManager) whose only writer was conditioned on a profile key,filtersat the root, that no profile has ever declared — not even the reference profile. In practicehasActiveFilters()therefore always returnedfalse,getActiveFilters()always[], andresetAllFilters()reset nothing. It is the last remnant of the pre-capability filter UI.- Migration: use the
filtercapability, which reads the real panel —GeoLeaf.Filter.hasActiveFilters(),GeoLeaf.Filter.getActiveFilter(),GeoLeaf.Filter.reset(). If your code called theGeoLeaf.UI.*versions, it already received a constant answer: the replacement fixes a behaviour, it does not reproduce it. - The corresponding configuration schema (
profile.filters) no longer exists either: filters are declared undermodules.filter(a profile'sconfig/plugins/filter.jsonfile).
- Migration: use the
Added
- Point symbol —
iconColor+marker(non-breaking, opt-in): a category (or sub-category) now acceptsiconColor(glyph tint; absent ⇒ white, the historical rendering) andmarker— either{ fill, stroke, strokeWidth }for a disc under the icon, orfalsefor a bare icon (no fill, no border). Absent ⇒ taxonomy overrides nothing and the layer style keeps control. No radius: point size belongs to the layer (the same category serves layers with different radii). Tinted icons are rasterised and registered as distinct MapLibre images — a profile that declares no colour keeps byte-identical symbol identifiers. modules.taxonomy.render.<surface>.colorBadges(non-breaking, opt-in, defaultfalse): colours the category / sub-category pill badges of the surface (popup/tooltip/sidepanel) with the colours of the categorymarker— the pill and the symbol on the map read as one and the same object. New facade methodGeoLeaf.Taxonomy.resolveBadgeStyle(layerId, feature, surface, field)(taxonomy decides, feature-info places the DOM).modules.taxonomy.icons.iconSize(non-breaking, default0.5): MapLibreicon-sizeof the symbol sub-layer. The default is exactly the value hard-coded until now.GeoLeaf.Taxonomy.getIconVariants()and.resolveMarkerPaint(layerId, paint)(non-breaking): consumed by the MapLibre adapter to register tinted icons and to compose the disc paint, respectively.
Fixed
The pill badges were unreadable. An unscoped CSS rule in the side panel stylesheet overrode the popup one and imposed near-white text with no background at all, on both surfaces. The coloured variants existed in the stylesheet but were never emitted, and their two colour sets were swapped. Pills now have a background, a verified contrast, and the
gl-poi-badge--category/--subcategoryclasses.The offline synchronisation badge disappeared when the theme changed. The restyling path rebuilt the point paint without reapplying the synchronisation decoration. It is now reapplied, as it is at layer creation.
Sprite icons defined by alias (
<use href="#…">): they showed in popups but stayed invisible on the map (the rasteriser did not follow the reference). The alias is now resolved.GeoLeaf.Taxonomy.getIcons()+modules.taxonomy.icons.showOnMapkey (non-breaking): theGeoLeaf.Taxonomyfacade now exposesgetIcons()(returns themodules.taxonomy.iconsblock —spriteUrl/symbolPrefix/defaultIcon/showOnMap— ornull), the single source of the icon configuration read by the POI sprite injector and the legend (in-core successor of the formerGeoLeaf.Config.getIconsConfig(), removed — see §Removed). New optional keymodules.taxonomy.icons.showOnMap(boolean, default "on" when absent): the gate for displaying category icons in the legend (parity with the former legacy gate).Category icon next to the title in popups / tooltips / side panels —
modules.taxonomy.render(non-breaking, opt-in): new configuration blockmodules.taxonomy.render.{popup,tooltip,sidepanel}.{showIconCategory,showIconSubcategory}(6 boolean flags, defaultsfalse) displaying the POI icon next to the title of the info surfaces (behaviour restored after the POI dissolution), symmetrically withshowIconsOnMap(icons on the map). The icon appears when the taxonomy is enabled, the layer is bound (modules.taxonomy.layers.<id>.use), a flag of the surface istrue, and an icon resolves — priority sub-category → category → default icon. New facade methodsGeoLeaf.Taxonomy.resolveTitleIcon(layerId, feature, surface)andGeoLeaf.Taxonomy.ensureSprite()(thetaxonomycapability resolves, thefeature-infocapability injects the sprite's<use>glyph, CSP-safe). Behaviour is byte-identical as long as no profile enables a flag.Clustering — in-core capability
GeoLeaf.Cluster+modules.cluster(internal reclassification, additive facade): point clustering (POI + GeoJSON point layers, native MapLibrecluster:true) is now a declared built-in capability (cluster), introspectable throughGeoLeaf.Introspection.getCapabilitySchema("cluster"), active by default (opt-out throughmodules.cluster.enabled: false). New read-only facadeGeoLeaf.Cluster(isEnabled(),getConfig()). Clustering is native end to end (no externalsuperclusterdependency); rendering and behaviour are unchanged.Geometry interaction events (non-breaking): two new DOM events
geoleaf:feature:clickandgeoleaf:feature:hover({ layerId, featureId, properties, lngLat, point, zIndex }) emitted by interactive GeoJSON and vector-tile layers on click or hover. These events replace the internal popup/tooltip binders and let an external capability (for example@geoleaf-plugins/feature-info) react to geometry interactions without coupling to the core. POI behaviour (cursor, popup, side panel) is unchanged; POI keeps emittinggeoleaf:poi:clickon its own channel.Per-layer styling plumbing (for external capabilities) (non-breaking): new DOM event
geoleaf:layer:added({ layerId, sourceId, geometryTypes }), emitted once per layer as soon as its MapLibre sub-layers are created (GeoJSON layers and POI cluster groups). The MapLibre layer registry now exposes the real geometry type of a layer:geometryTypesfield plus agetGeometryTypes(layerId)accessor — distinct from the sub-layers created (a polygon also creates alinesub-layer for its outline). These additions let an external capability apply per-layer styling without polling. First consumer: the MIT plugin@geoleaf-plugins/taxonomy— geometry-agnostic per-category styling (POI icon, polygon fill, polyline stroke) driven by a declarativevalue → stylemapping (named reusable taxonomies, explicitcategoryField). The core POI/GeoJSON rendering engine is unchanged (the legacy POI taxonomy stays in place; its migration is planned with the POI dissolution).i18n seam
GeoLeaf.I18n.t(key, fallback?)(non-breaking): theGeoLeaf.I18nnamespace now exposest(key, fallback?)in addition toregisterDict/getLabel. It resolveskeythrough the registered dictionaries (core + plugins) and falls back tofallback(or the key) when unresolved. This seam — promised by the field-renderer contract but never mounted — lets capabilities (feature-info) and plugins translate their labels (aria-labels…); output is byte-identical to the fallback when no dictionary provides the key.Generic attribute filter — in-core capability
GeoLeaf.Filter+modules.filter(rework): the filter panel becomes a generic built-in capability, geometry-agnostic (point/line/polygon) and multi-source, driven by a declarative field descriptor (modules.filter.fields[], 6 kinds:taxonomy/tag/range/text/boolean/proximity), with opt-in per-layer scope (layers: absent ⟹ all layers, present ⟹ only those). New read-only facadeGeoLeaf.Filter(isEnabled(),getConfig()), introspectable throughGeoLeaf.Introspection.getCapabilitySchema("filter"), active by default (opt-out throughmodules.filter.enabled: false). The predicate is hybrid: native MapLibresetFilter(GPU, no re-tiling) fortaxonomy/boolean/range, JS fallback fortag/text/proximity(list, substring, haversine distance).taxonomyandtagshare a single engine (membership of a set of values on a field).Toast renderer — in-core capability
toast-renderer+modules.toast-renderer(internal reclassification, additive): the DOM rendering of notifications ("toasts") becomes a built-in capability (capabilities/toast-renderer/), active by default (opt-out throughmodules.toast-renderer.enabled: false). Thenotify()primitive stays in the kernel (GeoLeaf.notify(message, level)— a stable anchor mounted at boot, buffer plusconsole.*fallback); the capability is its pluggable renderer (throughregisterRenderer()) and re-mounts the rich surfaces (GeoLeaf.UI.Notifications,_UINotifications, ESM export{ Notifications }) — public API and behaviour unchanged by default. Without the capability (opt-out disabled),GeoLeaf.notify()degrades cleanly toconsole.*. Fix: the "default style not found" warning of a GeoJSON layer, silent until now (its internal readerGeoLeaf.Notificationswas never mounted), is now emitted through the primitive.
Changed
Offline — engine moved in-core (
modules.offline, dynamicimport()) (breaking — profiles + distribution): the offline engine (IndexedDB + cache + download + sync, ~9,000 lines) moves from@geoleaf-plugins/storageto@geoleaf/core(capabilities/offline/), loaded through a dynamicimport()(outside the boot budget —bootGzunchanged) on the opt-in gatemodules.offline.enabled(which depends onmodules.pwa.enabled). The configuration moves frommodules.storage.*→modules.offline.*({ enabled, cache: { enableProfileCache, enableTileCache } }). The residual plugin@geoleaf-plugins/storageis reduced to the offline selection UI (published on npmjs,access: public); it drives the core engine through theStorageContractseam (newwhenReady(): Promise<void>— the UI defers its actions while the engine is not initialised, and indefinitely ifmodules.offlineis disabled). The publicGeoLeaf.Storagefacade is unchanged (init/isOffline/getStats/downloadProfileForOffline/ …). Migration: renameconfig/plugins/storage.json→offline.json,Files.modules.storage→Files.modules.offline,modules.storage.cache.*→modules.offline.cache.*. Withmodules.offline.enabledabsent orfalse→ no engine chunk downloaded.Permalink — activation and configuration moved from
ui.permalinktomodules.permalink(breaking — profiles): state ↔ URL synchronisation (deep linking) becomes a built-in capability (permalink, opt-out — active unlessmodules.permalink.enabled: false), introspectable throughGeoLeaf.Introspection.getCapabilitySchema("permalink"). Theui.permalinkblock (config/core/ui.json) moves tomodules.permalink({ enabled, mode }), on themodules.share/modules.legendmodel. TheGeoLeaf.Permalinkfacade is unchanged (extended withisEnabled/getConfig). Behaviour change: opt-in → opt-out (permalink active by default; previously onlyui.permalink.enabled: trueenabled it). Internally, restoring the filter state now goes through the publicGeoLeaf.Filtercontract (no more DOM scraping or injection of fake hidden fields). The taxonomy is serialised into a single flatgl_catsparameter (the legacygl_subsis removed — aligned with the generic filter model where categories and sub-categories form a single set of values). Migration: replace"ui": { "permalink": { … } }with"modules": { "permalink": { … } }(or omit it — opt-out).GeoLeaf.Filter— new serialisation surface (non-breaking, additive): the facade exposesgetActiveFilter()(serialisable active filter state),applyFilter(state)(restoration without DOM),reset(),applyNow(),hasActiveFilters()andproximity.{setRadius,toggle}— a contract consumed by thepermalinkcapability and available for no-code use.Share button — enabled through
modules.permalink.share.enabled(breaking — profiles): the "Share this view" button (link modal + QR code) becomes a sub-feature of the permalink capability (share only makes sense with permalink, which encodes the shared URL). Opt-out gatemodules.permalink.share.enabled(active unlessfalse), introspectable throughGeoLeaf.Introspection.getCapabilitySchema("permalink")(sub-keyshare). The formerui.showShareButtonflag (config/core/ui.json) is removed. TheGeoLeaf.Sharefacade (openShareDialog/closeShareDialog/getShareUrl/isOpen/isEnabled/getConfig), thegeoleaf:toolbar:actionevent, the lazy QR code (qrcode-generator) and the rendering are unchanged. Migration: replace"ui": { "showShareButton": false }with"modules": { "permalink": { "share": { "enabled": false } } }(omitting it leaves the button active — opt-out). Internally, the code lives undercapabilities/permalink/share/.Taxonomy — unification: the core readers (legend, filter, POI icons) switched to the
GeoLeaf.Taxonomycapability; legacy taxonomy removed (breaking — profiles + internal API): the legend, thetaxonomyfilter and POI icon injection now read their categories through the in-core capabilityGeoLeaf.Taxonomy(modules.taxonomyblock), and no longer through the core's old "flat" taxonomy (config/core/taxonomy.jsonread viaGeoLeaf.Config.getCategories()). Activation by binding: a POI layer receives its icons/categories only if it is declared inmodules.taxonomy.layers({ "<layerId>": { "use": "poi-cat" } }); ataxonomyfilter must carrytaxonomyRef(e.g."poi-cat"). Removed (internal_/non-contractual API):GeoLeaf.Config.getCategories()/getCategory()/getSubcategory()/loadTaxonomy(),GeoLeaf._ConfigTaxonomy, and theConfigInitOptions.{mappingUrl, mappingHeaders, mappingStrictContentType}options. Unchanged: the publicGeoLeaf.Taxonomyfacade, taxonomy resolution for modular profiles. (Update:GeoLeaf.Config.getIconsConfig()and theconfig/core/taxonomy.jsonfile are now removed as well — see §Removed "Legacy taxonomy".) Migration: populatemodules.taxonomy.taxonomies(seeconfig/plugins/taxonomy.json), bind POI layers throughmodules.taxonomy.layers, addtaxonomyReftotaxonomyfilters; replace anyGeoLeaf.Config.getCategories()call withGeoLeaf.Taxonomy.getCategories("poi-cat"). Rendering is byte-identical (the model reuses the tree, labels and icons of the old one). Note:modules.taxonomy.icons.defaultIconis NOT applied to POI icon injection (parity with the old engine, which injected no default icon). Associated rendering fix: the icons of POI layers loaded through a data theme change (without a basemap change) are now correctly registered in the MapLibre rendering engine when the layer is added — previously the sprite was only (re)registered on a basemap change, so a POI layer appearing through a theme could display without its icons.Legend — activation and configuration moved from
ui.showLegend+legendConfigtomodules.legend(breaking — profiles): the map legend becomes a built-in capability (legend), gated throughCapabilityRegistry(opt-out: active unlessmodules.legend.enabled: false), introspectable throughGeoLeaf.Introspection.getCapabilitySchema("legend"). Theui.showLegendflag and thelegendConfigblock (config/core/ui.json) move to the dedicatedmodules.legendblock (config/plugins/legend.jsonfile:{ enabled, title, position, collapsedByDefault }), on themodules.table/modules.theme-selectormodel. Behaviour change:title/position/collapsedByDefault, previously ignored (overwritten by internal defaults), are now effectively applied to the control — a profile carrying the oldlegendConfigwill see its legend rendered with its configured title, position and collapsed state (previously frozen at "Legend", bottom-left, expanded). New DOM eventgeoleaf:legend:ready({ position, layerCount }) emitted once at the first mount of the control. TheGeoLeaf.Legendfacade is unchanged. Migration: createconfig/plugins/legend.json(carry over thelegendConfigkeys plusenabled: true), declare"legend": "config/plugins/legend.json"inprofile.json→Files.modules, removeui.showLegendandlegendConfigfromui.json.Theme selector — activation moved from
ui.showThemeSelectortomodules.theme-selector.enabled(breaking — profiles): the flag controlling the theme selection bar leavesconfig/core/ui.json(ui.showThemeSelector) for the dedicatedmodules.theme-selectorblock (config/plugins/theme-selector.jsonfile,{ "enabled": true }), on the same model asmodules.table/modules.filter. The selector becomes a declared built-in capability (theme-selector), gated throughCapabilityRegistry(opt-out: active unlessmodules.theme-selector.enabled: false), introspectable throughGeoLeaf.Introspection.getCapabilitySchema("theme-selector"). Migration: createconfig/plugins/theme-selector.jsonwith{ "enabled": true }, declare"theme-selector": "config/plugins/theme-selector.json"inprofile.json→Files.modules, removeui.showThemeSelectorfromui.json. TheGeoLeaf.ThemeSelectorfacade, the events, thegl_themepermalink field and the rendering are unchanged. Internally, layer loading is now decoupled from the theme system (through the registry:GeoJSONModuleloads the data,ThemeEngineModuleapplies the default theme) — data displays even without a declared theme or with the selector disabled; output is byte-identical when themes exist.Clustering configuration moved from
poiConfigtomodules.cluster(breaking — profiles): the global clustering keys leavepoiConfigfor the dedicatedmodules.clusterblock (config/plugins/cluster.jsonfile) —poiConfig.clustering→modules.cluster.clustering,poiConfig.clusterStrategy→…clusterStrategy,poiConfig.clusterRadius→…clusterRadius,poiConfig.disableClusteringAtZoom→…disableClusteringAtZoom,poiConfig.clusterStrategies→…clusterStrategies. The per-layer overrides (layers[].clustering.{enabled,maxClusterRadius,disableClusteringAtZoom}) are unchanged. The features keyclusteringConfig(never read at runtime) is removed (schema + profiles). Migration: move anypoiConfig.cluster*block toconfig/plugins/cluster.json, declare"cluster": "config/plugins/cluster.json"inprofile.json→Files.modules, removeclusteringConfig. Values are carried over identically (rendering unchanged); clustering is active by default without configuration. Removed along the way (internal_-prefixed, non-contractual):GeoLeaf._GeoJSONClustering,GeoLeaf.GeoJSON._getClusteringStrategy,GeoLeaf.GeoJSON._getPoiConfig.@geoleaf-plugins/feature-info— public APIGeoLeaf.FeatureInfocompleted to 5 methods (breaking, plugin only —@geoleaf/coreunaffected):openSidePanel(detail, layout?)andgetConfig(layerId)added (isEnabled/close/openPopupalready present).close()now closes the popup and the side panel.openPopup(detail)now requiresdetail.geometry(previously forced tonullinternally) — a scripted caller must supply that field (nullaccepted). The side panel is rewritten as standalone DOM (appended todocument.body), without any dependency onGeoLeaf.POI. Action buttons (type: "action") dispatchgeoleaf:popup:action(an already documented event) withpropertieslimited to the fields configured for the surface — never the raw set of feature properties.GeoLeaf.FeatureInfo.openPopupnow accepts a secondlayout?: SidePanelLayoutparameter (non-breaking — optional parameter added), mirroringopenSidePanel. It lets a caller without alayers.<id>.capabilities.feature-infoconfiguration (for instance POI injection) supply an explicit layout rather than falling back on generic auto-resolution (all properties as plain text).POI — popup and side panel fully delegated to
@geoleaf-plugins/feature-info(breaking): POI attribute rendering (popup on marker click, "See more" side panel) now requires the@geoleaf-plugins/feature-infoplugin to be loaded and enabled (modules.feature-info.enabled: true). There is no internal core fallback rendering any more — without the plugin, clicking a POI marker produces neither popup nor panel (the same convention as any absent optional plugin). Migration: make sure@geoleaf-plugins/feature-infois loaded (already required for GeoJSON/VT attribute rendering) and thatmodules.feature-info.enabledistruein the active profile.Routes — reworked into the in-core capability
modules.route(endpoint decorator); theGeoLeaf.Routefacade and theroutes[]model removed (breaking — profiles + API): the legacy Route module (imperativeGeoLeaf.Routefacade, top-levelroutes[]array) is dissolved into an in-core capability (route, gatemodules.route, opt-in), introspectable throughGeoLeaf.Introspection.getCapabilitySchema("route"). New model: the capability decorates an existing polyline layer with start / end markers derived automatically from each feature's geometry — the track stays a generic GeoJSONlinelayer (rendered by the engine). Per-layer bindingmodules.route.layers.<id>.{start,end,showStart,showEnd}(themodules.taxonomy.layersmodel). Event-driven (geoleaf:layer:added/geoleaf:map:ready). Removed: theGeoLeaf.Routefacade (init/loadFromConfig/loadGPX/loadGeoJSON/show/hide/filterVisibility), theroutes[]array, the_RouteLayerManager/_RouteLoaders/_RouteStyleResolverglobals, theRouteContractcontract, thegeoleaf:route:loadedevent, the lazyroutechunk. Reading GPX / KML / KMZ: out of scope — use@geoleaf-plugins/file-import. Migration: (1) declare"route": "config/plugins/route.json"inprofile.json→Files.modulesplus{ "enabled": true, "layers": { "<layerId>": { … } } }; (2) a dynamic route goes throughGeoLeaf.Layers.setData(layerId, featureCollection)(the capability re-derives the markers) instead ofGeoLeaf.Route.loadFromConfig. Track interactivity (click / hover) is still provided by the genericgeoleaf:feature:click/hoverseam of GeoJSON layers (unchanged).GeoJSON filtering applied on the GPU (non-breaking, performance improvement):
GeoLeaf.GeoJSON.filterFeatures(predicate)keeps its signature (the predicate remains a JS function — substring search, distance, nested fields all stay supported), but now applies the visible set throughmap.setFilteron the features'id(amatchexpression, without re-tiling the source) for non-clustered layers whose features all carry a uniqueproperties.id. Clustered layers, or layers whose features have no id, fall back to re-sending the data (the original behaviour, cluster counters preserved). Filtering by category or search in the panel is smoother on large datasets; no API or filtering-result change.Basemap change: native layer preservation (
transformStyle) (non-breaking in observable behaviour): switching a vector basemap now usesmap.setStyle(next, { diff: true, transformStyle })(MapLibre v5) to natively preserve the GeoLeaf sources and layers (GeoJSON, POI clusters, sentinel) instead of destroying everything and re-injecting it in JS. The reset-and-full-rebuild dance is over: a gain in correctness / leak avoidance (less churn, less transient layer loss) when switching. POI sprite icons (cleared bysetStyle) are re-registered after the swap. Rendering and switching behaviour are unchanged.POI — hover / selection halo (new UX, non-breaking): hovering a (non-clustered) POI marker draws a highlight border; clicking a marker selects it (persistent halo until another marker is clicked). Driven by
setFeatureState(reactive GPU paint), with no extra data and no configuration. The resting rendering (colours, status badge) is unchanged.POI — synchronisation badge without source rebuild (non-breaking, performance improvement): the "pending" badge (
GeoLeaf.POI.updatePoiSyncStatus, used by@geoleaf-plugins/addpoiand@geoleaf-plugins/storage) is now applied throughsetFeatureState(an O(1) mutation) instead of rebuilding the whole POIFeatureCollectionon every status change. The POI source declarespromoteId: "id"(a stable feature id). No API change and no visible rendering change. Per-POI style resolution no longer forces a style recomputation (getComputedStyle) per marker on mass display.Legend — "attribute → category" mapping driven by configuration (non-breaking for the core; potentially breaking for a profile relying on the former built-in tourism mapping): legend icon resolution for a
when.fieldrule on a raw attribute (for example OpenStreetMapfclass) now reads a declarativetaxonomy.fieldMappingsmapping ({ <field>: { <value>: { categoryId, subCategoryId } } }) instead of a tourism table hard-coded in the core. The MIT core no longer carries any business data. Migration: a profile whose legend depended on the built-infclassmapping (archaeological / museum / camp_site / hotel) must now declare it undertaxonomy.fieldMappings(thetourismdemonstration profile includes it).Labels (
GeoLeaf.Labels) — in-core capability +modules.labelsgate (breaking — configuration): layer labels (text, rendered as a native MapLibresymbollayer) are now a declared built-in capability (labels), introspectable throughGeoLeaf.Introspection.getCapabilitySchema("labels"), active by default (opt-out throughmodules.labels.enabled: false). The global gate leaves the root keylabels.enabledformodules.labels.enabled(the rootlabelskey is now reserved for the i18n override dictionary). Per-layer label styling (font, colour, halo, scale) stays unchanged in the style files (label.*key). Migration: a profile settinglabels.enabled: falseat the root must move it tomodules.labels.enabled: false. TheGeoLeaf.LabelsAPI, the rendering and the display button are unchanged.Filter configuration migrated from
searchConfig/ui.showFilterPaneltomodules.filter(breaking — profiles): the filter/search panel leavesui.json > searchConfig(plus theui.showFilterPanelflag) for the dedicatedmodules.filterblock (config/plugins/filter.jsonfile, declared inprofile.json→Files.modules.filter). Correspondences:ui.showFilterPanel→modules.filter.enabled(opt-out);searchConfig.title/searchPlaceholder/actions→modules.filter.*;searchConfig.radius{Min,Max,Step,Default}→ akind:"proximity"field;filters[] type:"search"|"proximity"|"tree"|"multiselect-tags"→fields[] kind:"text"|"proximity"|"taxonomy"|"tag". The "categories" filter becomes explicit (field/taxonomyRef/layers) and is only migrated if the features carry the category identifiers. The internalGeoLeaf.FilterPanelfacade is removed (replaced byGeoLeaf.Filter); the internal_UIFilterPanel*globals go from an eager mount to lazy shims mounted ongeoleaf:app:ready(permalink / mobile / ui-api consumers unchanged). Migration: move thesearchConfigblock toconfig/plugins/filter.jsonin thefields[]model, declare"filter": "config/plugins/filter.json"inFiles.modules, removesearchConfigandui.showFilterPanel.Filter — text search insensitive to accents and word order (non-breaking, improvement): the text field of the Filter panel (in-core
filtercapability,kind:"text") now normalises accents and case (NFD plus diacritic removal) and splits the query into words — a field matches if all the terms appear in it, in any order.reciffinds "Récif",gilles réciffinds "Le Récif — Saint-Gilles". A strict superset of the previous behaviour (every existing match stays valid), with no dependency and no new parameter. It covers common search needs without an external full-text engine (see §Removed —GeoLeaf.Search).
Fixed
- OGC API Features refresh (
autoRefresh): a layer configured withautoRefreshnow updates when the map moves (moveend). The source update call targeted a non-existent adapter method (a silent no-op) and the fetch result was discarded; new features are now applied. - POI clickable after a server id is assigned: after
GeoLeaf.POI.updatePoiId(oldId, newId)(the@geoleaf-plugins/addpoiflow — temporary id → permanent id), clicking the marker finds the POI again. The click resolution index (invalidated on list length alone) is now invalidated on rename. - Legend — fallback colour aligned with the map: a style rule without an explicit colour now shows the same grey (
#cccccc) in the legend and on the map (previously Leaflet blue#3388ffin the legend, grey on the map). - Legend (LayerManager) — robust layer registration: registering a layer in the LayerManager no longer depends on a geometry type detection inherited from Leaflet (the source of a latent crash); the type is derived from the cached
geometryType. - Timer leaks: the periodic polling of the mobile toolbar (a 2 s
setInterval) is now cleaned up by the lifecycle (no more leak on recreation); the offline detector no longer starts a polling timer when nopingUrlis configured (the browser'sonline/offlineevents are enough). - Basemap change: no more double firing of interaction events: after a basemap change (
map.setStyle), re-injected layers no longer duplicate their delegated listeners. A click or hover on a feature (POI, GeoJSON, cluster, track) no longer emitsgeoleaf:feature:click/geoleaf:feature:hover/geoleaf:poi:clicktwice — the delegated listeners are now detached before the style is rebuilt (and when the map is destroyed). No API impact. GeoLeaf.Notifications.*now mounted on the global: the full notification namespace (GeoLeaf.Notifications.{notify,success,error,warning,info,dismiss,clearAll,getStatus}) — documented (NOTIFICATIONS_API.md) and typed (index.d.ts→GeoLeafAPIRoot.Notifications: NotificationsAPI, non-optional) — was in fact never assigned onglobalThis.GeoLeaf(onlyGeoLeaf.UI.Notificationswas). AGeoLeaf.Notifications.success(...)over CDN, as documented, crashed (Cannot read properties of undefined). The facade is now mounted at boot, aligning runtime, documentation and types.GeoLeaf.notify()(the shorthand) and the ESM exportimport { Notifications }are unchanged. This also fixes ashow({message,type})example (wrong signature) in the@geoleaf-plugins/websocketREADME →show(message, type).
Removed
GeoLeaf._UIDomUtils.attachAccordionBehavior()andGeoLeaf.UI._attachAccordionBehavior()removed (breaking, internal_API with no consumer): both attached accordion behaviour (togglinggl-is-openon a click on a.gl-accordion__header) to a container. Neither had any caller in the product since the removal of the filter panel builderui/filter-panel/**, their only client. Only the first was documented, with an example, inGeoLeaf_UI_Components_README.md— that section is gone. Despite its name,GeoLeaf.UI._attachAccordionBehavior()was not an alias of the first: it delegated to_UIEventDelegation.attachAccordionEvents(), which stays in place. Migration: none for the product's accordions — the legend builds its own (GeoLeaf.Legend) and the side panel uses a native<details>, without JavaScript. An integrator who calledGeoLeaf._UIDomUtils.attachAccordionBehavior(container)on their own markup now attaches the listener themselves:container.addEventListener("click", (e) => e.target.closest(".gl-accordion__header")?.closest(".gl-accordion")?.classList.toggle("gl-is-open"))._UIFilterPanel*globals removed (_UIFilterPanelApplier/_UIFilterPanelStateReader/_UIFilterPanelAccordion/_UIFilterPanelProximity) (breaking, transitional internal_API): these runtime shims (installed bycapabilities/filter/compat.ts) exposed the filter panel to permalink, to the mobile toolbar and to the desktop control builder. All their consumers are now wired to the publicGeoLeaf.Filtercontract;compat.tsand the four_UIFilterPanel*globals are deleted. Migration:GeoLeaf._UIFilterPanelProximity.*→GeoLeaf.Filter.proximity.*;_UIFilterPanelApplier.applyFiltersNow(...)→GeoLeaf.Filter.applyNow()/.reset(). (The singular legacy global_UIFilterPanel— the older panel — is not concerned.)GeoLeaf.Taxonomy.resolveLabel()/resolveLayerLabel()removed (breaking, internal API with no consumer): these two taxonomy facade methods (readable label of a category value / of a layer badge field) had no consumer — they were intended for afeature-infobadge rendering that was never wired. Removed fromGeoLeaf.Taxonomy(they were needlessly shipped in the bundle). Migration: read the category directly throughGeoLeaf.Taxonomy.getCategories(ref)[value]?.label.GeoLeaf.Helpers.resolvePoiColors()— thecolorRoutefield removed from thePoiColorsreturn (minor breaking, dead field): the function returned{ colorFill, colorStroke, colorRoute }wherecolorRoutewas computed identically tocolorStrokeand never read (a leftover of the dissolvedroutemodule). The return is now{ colorFill, colorStroke }. Migration: usecolorStroke(a strictly identical value).Legacy taxonomy removed —
GeoLeaf.Config.getIconsConfig()plus theconfig/core/taxonomy.jsonfile (breaking — profiles + internal API): the core's old "flat" taxonomy is entirely deleted; themodules.taxonomycapability (config/plugins/taxonomy.jsonfile) is now the single source of icons and categories. Removed:GeoLeaf.Config.getIconsConfig()(plusProfileManager.getIconsConfig), theconfig/core/taxonomy.jsonfile and itsFiles.taxonomyFilemanifest entry (loaded at boot throughprofile-loader), the internalGeoLeafConfig.categoriesfield (plus its_validateCategoriesSectionvalidation and theCategoryIteminterface), and the dead internal APIGeoLeaf.UI._populateSelectOptionsFromTaxonomy(0 callers). The POI icon sprite and the legend'sshowOnMapgate now readmodules.taxonomy.iconsthroughGeoLeaf.Taxonomy.getIcons(). Migration: delete theconfig/core/taxonomy.jsonfile and theFiles.taxonomyFilekey fromprofile.json— icons and categories already live inconfig/plugins/taxonomy.json(modules.taxonomy.icons+modules.taxonomy.taxonomies); replace anyGeoLeaf.Config.getIconsConfig()call withGeoLeaf.Taxonomy.getIcons(). Rendering is byte-identical (same data, same sprite URL).The
geoleaf:style:rebuildDOM event removed (breaking): this event, emitted after amap.setStyle()so that modules could re-inject their layers, no longer has a purpose — the basemap switch now natively preserves GeoLeaf layers throughtransformStyle(see §Changed). The event is removed from the contract (GeoLeafEventMap) and its 3 internal listeners (GeoJSON, POI, taxonomy) are deleted, along with the associated rebuild functions (_rebuildGeoJSONLayers,_rebuildPoiClusterSource, the taxonomy sweep on rebuild). No distributed plugin listened to it. Migration: an integrator who listened togeoleaf:style:rebuildin order to restyle after a basemap change no longer needs it (layers and their paint survive); to react to a layer being added, usegeoleaf:layer:added. The optional adapter methodresetForStyleChange()is replaced bybuildStyleChangeTransform()+reregisterStyleImages().GeoLeaf._loadModule("poiRenderers")/"poiRenderers"removed fromLazyModuleName(breaking, lightly documented internal API): the lazypoi-rendererschunk (legacy POI attribute rendering — field/media/component renderers, section orchestrator, lightbox, UI behaviours) is deleted, not moved — that capability is now entirely provided by@geoleaf-plugins/feature-info.GeoLeaf._loadModule("poi")keeps working (it loadspoiCore+poiExtras, withoutpoiRenderers). A direct call toGeoLeaf._loadModule("poiRenderers")now fails silently (thedefaultbranch of the switch,console.warn).GeoJSON popup/tooltip binders removed from the core (breaking):
bindMapLibrePopup,bindMapLibreTooltip,_GeoJSONPopupTooltip,setupPopupTooltipDepsand the 4 source files (popup-tooltip.ts,popup-tooltip-core.ts,popup-tooltip-layer.ts,popup-tooltip-maplibre.ts) are no longer part of@geoleaf/core. Tooltip/popup/side-panel rendering on GeoJSON and vector-tile layers is now provided by the MIT plugin@geoleaf-plugins/feature-info. Migration: install@geoleaf-plugins/feature-infoand load its script aftergeoleaf.esm.js. GeoJSON layers now emitgeoleaf:feature:click/geoleaf:feature:hoverinstead. POI impact: POI popup behaviour is unchanged (a separategeoleaf:poi:clickchannel).GeoLeaf.Geocodingremoved from the core (breaking): address search (geocoding — Addok/BAN, Nominatim, Photon providers, custom HTTPS endpoint) is no longer built into@geoleaf/core. It is now provided by the MIT plugin@geoleaf-plugins/geocoding(public on npmjs.org). Removed from the core bundle: the named ESM exportGeocoding, theGeoLeaf.Geocodingfacade (isEnabled/search/selectResult/open/destroy), thegeoleaf:geocoding:resultevent and the.gl-geocoding-ctrlsearch control. Migration:npm install @geoleaf-plugins/geocoding, then load its script aftergeoleaf.esm.js(and beforeGeoLeaf.boot()).GeoLeaf.Tableremoved from the core (breaking): the data table (tabular panel — layer selector, sorting, selection ↔ map highlight, zoom, GeoJSON/CSV/KML/GPX/Excel export) is no longer built into@geoleaf/core. It is now provided by the MIT plugin@geoleaf-plugins/table(public on npmjs.org). Removed from the core bundle: the named ESM exportTable, theGeoLeaf.Tablefacade (show/hide/toggle/setLayer/sortByField/setSelection/zoomToSelection/exportSelection/exportLayer…), thegeoleaf:table:*events, the "Table" tab of the desktop panel and the OOXML writer for Excel export. Migration:npm install @geoleaf-plugins/table, then load its script aftergeoleaf.esm.js(and beforeGeoLeaf.boot()).GeoLeaf.Popupremoved from the core (breaking): theGeoLeaf.Popup.registerActionHandler()/unregisterActionHandler()facade (a registry of rich-context handlers for popup action buttons) is deleted, along with themodules/built-in/popup/action-registry.tsmodule and itsmodules/built-in/popup/popup-actions.tslistener. This registry had not been invoked since popup rendering moved to@geoleaf-plugins/feature-info, which only dispatches thegeoleaf:popup:actionevent — it had become a silent no-op. Migration: replace any call toGeoLeaf.Popup.registerActionHandler(actionId, handler)withGeoLeaf.events.on("geoleaf:popup:action", (e) => { if (e.detail.actionId === actionId) handler(e.detail); }). The rich context (button DOM reference,setBusy(),close()) is no longer available — only the JSON payload (actionId,layerId,featureId,properties,lngLat?) is.GeoLeaf.GeoJSON.updateLayerZIndex()removed (breaking, lightly documented internal API): this method had no caller in the product and failed systematically on MapLibre (a historical Leaflet implementation —state.map.getPane()does not exist on the MapLibre adapter). Layer display order is handled natively by the adapter registry.POI rendering routed through the generic seam +
GeoLeaf.POI.openSidePanelWithLayout()removed (breaking): clicking a POI marker now emitsgeoleaf:feature:click(the same seam as GeoJSON/VT layers) instead of a POI-specific rendering path;@geoleaf-plugins/feature-infoauto-resolves the layout fromlayers.<id>.capabilities.feature-info. The internal translation bridge (poi/feature-info-bridge.ts) and the POI popup delegate (poi/popup.ts) are deleted. TheGeoLeaf.POI.openSidePanelWithLayout(poi, customLayout)API and thecustomLayoutparameter ofGeoLeaf.POI.showPoiDetails()are removed (no callers — the layout now comes from thecapabilities.feature-infoconfiguration). Migration: configure POI rendering underlayers.<id>.capabilities.feature-info, like GeoJSON layers.GeoLeaf.POIremoved from the core — dissolution of the POI subsystem (breaking): the publicGeoLeaf.POInamespace and the named ESM exportPOIare deleted. A POI is now a generic GeoJSON point layer (GeoLeaf.Layers), styled bytaxonomy, clustered bycluster, rendered on click byfeature-info— all in-core capabilities. Removed from the bundle: the monolithic POI rendering pipeline (built-in/poi/**, thepoi-sourceaggregate), theresolveCategoryDisplayicon resolution (replaced byGeoLeaf.Taxonomy.resolvePoiIconwired onto the generic point icon resolver), and every data method (getAllPois/getPoiById/getDisplayedPoisCount/displayPois/reload/addPoi/add/setFilteredDisplay/updatePoiSyncStatus/updatePoiId/getLayer/loadAndMergeStoredPois…). The filter and the search now read the singleGeoLeaf.Layerssource (search rebuilds its index on demand from the layers — POIs created at runtime become searchable again). Migration: read and mutate point data throughGeoLeaf.Layers.getFeatures(layerId)/addFeature/mergeFeatures/setData; styling and click rendering are configured per layer (layers.<id>.capabilities.{taxonomy,cluster,feature-info}). For interactive creation of POIs (the addpoi plugin), see theGeoLeaf.POI.*→GeoLeaf.AddPOI.*migration below.GeoLeaf.Themesremoved from the core (dead per-layer theme facade) (breaking): theGeoLeaf.Themesfacade (applyTheme/loadTheme/toggleTheme/getAvailableThemes/initializeLayerTheme/getCurrentTheme/clearRememberedThemes/invalidateCache/init) and the named ESM exportThemesare deleted. A relic of the Leaflet era: a "per-layer theme" system loaded fromdata/profiles/<layer>/themes/index.jsonthat produced no effect on the MapLibre map (the manager stored the theme id in an internalMap, without calling the adapter or the style). No replacement — the real theme engine (composition/application,geoleaf:theme:appliedevent) stays internal and unchanged (ThemeApplierCore), driven by the profile configuration and the UI theme selector (GeoLeaf.UI, light/dark). Migration: remove anyGeoLeaf.Themes.*call — the visual theme is driven by the profile configuration plus the UI selector.GeoLeaf.Searchremoved from the core — the full-text search engine (flexsearch) purged (breaking, dormant engine): theGeoLeaf.Searchfacade (isReady/query/build/getEngine/clear), the named ESM exportSearch, theSearchRegistry(theflexsearchindex engine) and the lazysearchchunk are deleted, along with theflexsearchnpm dependency (removed from@geoleaf/core). That engine was dormant: no core code and no profile enabled it (profile.search.engine === "flexsearch"was never set,loadModule("search")was never called, the index was never built). The interface's actual text search (the "Rechercher un POI…" field of the Filter panel) is provided by the in-corefiltercapability (substring search), unchanged and now insensitive to accents and word order (see §Changed). Configuration removed along the way: the layersearchblock ({ enabled, indexingFields }), theui.showSearchflag, the rootsearchConfig.enginekey. The removal takesflexsearchout of the boot eager closure (the named export anchored it there, contrary to the "lazy" belief): boot −8 KB gz. Migration: no action for the UI (the filter covers search); an integrator callingGeoLeaf.Search.query()from a script must implement their own search (or index server-side) — the core no longer exposes a full-text engine.GeoLeaf.GeoJSON.addData/.loadUrl/.clear/.getLayerremoved (breaking, dead methods): these 4 methods inherited from Leaflet were inert in MapLibre mode — thegeoJsonLayer/layerGroupstate they manipulated is never assigned (alwaysnull):addDatalogged an error then drew nothing,loadUrlperformed a networkfetchthen discarded the result,clear/getLayerwere no-ops. The GeoJSON module remains fully functional: profile-driven loading (GeoLeaf.GeoJSON.loadFromActiveProfile()plus thelayersconfiguration), live updates throughupdateLayerData(layerId, data), reads throughgetLayerById/getLayerData/getLayerConfig/getAllLayers, filtering throughfilterFeatures/clearFeatureFilter. Migration: replace ad hocaddData/loadUrlwith the profile's layer configuration orupdateLayerData();getLayer()→getLayerById(id).GeoLeaf.Route.loadGPX/.loadGeoJSONremoved (breaking, dead methods): these two ad hoc route loading methods were broken in MapLibre mode — they took an internal path (_applyRoute→RouteLayerManager.applyRoute({ layerGroup })) whoselayerGroupis never assigned;loadGPXperformed afetchplusDOMParserparsing then drew nothing. Route loading is still provided by the living configuration pathGeoLeaf.Route.loadFromConfig(routes)(throughadapter.addGeoJSONLayer), distinct from these methods. Migration: declare routes in the profile configuration (loaded byloadFromConfig) rather than callingloadGPX/loadGeoJSON.GeoLeaf.Utils— dead quartet removed (AnimationHelper,EventHelpers,FileValidator,LazyLoader) plus top-level shortcuts (breaking, public surface without a reader):GeoLeaf.Utils.AnimationHelper/.EventHelpers/.FileValidator/.LazyLoader, the top-levelGeoLeaf.FileValidator, and theGeoLeaf.animate/.fadeIn/.fadeOut/.loadModule/.enableLazyImages/.dispatchEvent/.dispatchMapEventshortcuts are deleted (0 readers outside tests). Not to be confused: the secondary module loaderGeoLeaf._loadModule(name)(with an underscore) is unchanged and alive. The rest ofGeoLeaf.Utils(FetchHelper,MapHelpers,DOMSecurity,PerformanceProfiler,TimerManager,debounce,throttle,getDistance,ObjectUtils,ScaleUtils, …) is unchanged. Client-side file validation remains available in the plugins that use it (@geoleaf-plugins/addpoiand@geoleaf/field-renderership their own validator). Migration: replaceGeoLeaf.animate/fadeIn/fadeOutwith CSS transitions;GeoLeaf.dispatchEventwithGeoLeaf.eventsordocument.dispatchEvent; there is no core replacement forGeoLeaf.Utils.FileValidator.GeoLeaf.Filters.filterPoiListand the 6 statistical functions removed (getUniqueCategories,getUniqueSubCategories,getUniqueTags,countByCategory,countBySubCategory,getRatingStats) (breaking): 0 internal consumers.GeoLeaf.Filters.filterRouteListis unchanged. Migration: for POI filtering, use theGeoLeaf.Filtercapability (singular) —getActiveFilter()/applyFilter(state)/hasActiveFilters()— which drives the active in-core filter panel.GeoLeaf.Config.getActiveProfilePoi()removed (breaking, dead method): it always returned[](POIs having been dissolved out of the profile).ProfileManager._activeProfileData.{poi,routes}removed internally (theProfileDataPayloadtype that still typesprofiles[]is kept).The permalink
poiparameter removed (gl_poiin verbose mode) (minor breaking): this field made a URL→state→URL round trip without ever influencing application behaviour (a relic of the dissolved POI era). The other fields (lat/lng/zoom/layers/filter/categories/tags/rating/theme) are unchanged.
Breaking Changes (layer scale thresholds: zoomConfig → scaleConfig)
- The
zoomConfigblock oflayers/{layer}/styles/{style}.jsonis removed, replaced byscaleConfig:zoomConfig.minZoom/maxZoombecomescaleConfig.minScale/maxScale. This is not just a rename: it is the correction of a trap. The old name announced MapLibre zoom levels (0-24) while the engine has always read scale denominators (theXin1:X) — writingminZoom: 6therefore hid the layer at every zoom, silently. The new name states the unit that has always been the engine's.- No shim: the
zoomConfigblock is rejected at validation (additionalProperties: falseschema plus runtime validator), with a message naming the replacement. A profile that has not migrated fails loudly instead of silently losing its constraint. TheminZoom/maxZoomalias insidescaleConfigis rejected too — that alias is what let a zoom level through. - New guard: any bound in
(0; 24]is refused — such a denominator is unreachable at any zoom, so it is always a zoom level entered by mistake.0andnullremain valid ("constraint disabled"). - Migration — your values were already denominators (the normal case): rename the keys, values unchanged.
{ "zoomConfig": { "minZoom": 500000, "maxZoom": 10000 } }→{ "scaleConfig": { "minScale": 500000, "maxScale": 10000 } }. - Migration — your values were zoom levels (so your layers were invisible): convert with
1:X = 591,658,734 × cos(latitude) / 2^zoom, at the latitude of your area. For example at ~4°N: zoom 6 →9222148, zoom 18 →2252. Reference points: zoom 5 ≈ 1:18,444,296 · zoom 10 ≈ 1:576,384 · zoom 13 ≈ 1:72,048 · zoom 20 ≈ 1:563. - Reminder about meaning (counter-intuitive):
minScaleis the larger of the two numbers — it bounds the widest view, and a denominator grows as you zoom out.{ "minScale": 9222148, "maxScale": 2252 }= "visible from 1:9,222,148 to 1:2,252". labelScale.minScale/maxScale(label scale) is unchanged: same unit, same guard, but it targets labels and not the layer.
- No shim: the
Breaking Changes (geocoding → plugin)
- The
geocodingConfigconfiguration key (profile root) is removed: geocoding configuration moves to themodules.geocodingblock, declared inconfig/plugins/geocoding.jsonand referenced byFiles.modules.geocoding.GeoLeaf.Config.get("geocodingConfig")now returnsundefined. No compatibility shim — a profile keepinggeocodingConfigat the root no longer loads the geocoding configuration. Migration: move thegeocodingConfigblock toconfig/plugins/geocoding.json(keys unchanged:enabled,provider,debounceMs,minChars,resultLimit,position,placeholder,flyToZoom,bbox,countrycodes) and declare it inFiles.modules.geocoding. Details and examples: README of@geoleaf-plugins/geocoding.
Breaking Changes (table → plugin)
- The
tableConfig(profile root) andui.showTableconfiguration keys are removed: the global table configuration moves to themodules.tableblock, declared inconfig/plugins/table.jsonand referenced byFiles.modules.table.GeoLeaf.Config.get("tableConfig")now returnsundefined;ui.showTableis replaced bymodules.table.showButton. No compatibility shim — a profile keepingtableConfig/ui.showTableat the root no longer loads the table configuration. Migration: move the block toconfig/plugins/table.json(keys:enabled,showButton,defaultVisible,pageSize,maxRowsPerLayer,enableExportButton,virtualScrolling,defaultHeight,minHeight,maxHeight,resizable) and declare it inFiles.modules.table. The per-layer bindinglayer.config.table.*(columns, sorting, title) stays unchanged on the layer (layer-config.schema.jsonuntouched). Details and examples: README of@geoleaf-plugins/table.
Breaking Changes (POI dissolution → generic layers)
- The
poiConfigconfiguration key (features.json) is removed: the POI subsystem having been dissolved, the globalpoiConfigblock ({ enabled }) no longer has an object — it is removed from the 9 profiles, from thefeatures.schema.jsonschema and from the profile schema.GeoLeaf.Configno longer readspoiConfignor thepoi[]array (inline POIs). No shim — sincefeatures.schema.jsonisadditionalProperties:false, a profile keepingpoiConfigfails validation. Migration: remove thepoiConfigblock fromfeatures.json. Point layers are declared inlayers[]like any GeoJSON layer; clustering is already undermodules.cluster. @geoleaf-plugins/addpoiplugin API:GeoLeaf.POI.*→GeoLeaf.AddPOI.*(breaking, plugin):GeoLeaf.POIhaving been removed from the core, the addpoi plugin's public POI creation namespace moves toGeoLeaf.AddPOI.GeoLeaf.POI.AddForm.*→GeoLeaf.AddPOI.AddForm.*;GeoLeaf.POI.PlacementMode.*→GeoLeaf.AddPOI.PlacementMode.*(likewise forImageUpload). Migration: replace theGeoLeaf.POI.prefix withGeoLeaf.AddPOI.in your integration code. Created POIs are now written to the editable host layer (gl-src-<layerId>) throughGeoLeaf.Layers, alongside static features (fixing the split-brain).
Breaking Changes (per-layer attribute rendering → capabilities.feature-info)
- The
popup,tooltip,sidepanelConfig(blocks) andtooltipMode(root alias) keys of{id}_config.jsonare removed: the tooltip/popup/side-panel rendering configuration of a layer moves to thecapabilities.feature-infoblock (an opaque property of the@geoleaf-plugins/feature-infoplugin; keystitleField,tooltip,popup,sidepanel). The layer schema (additionalProperties: falseat the root) now rejects those keys. No shim — a profile keepingpopup/tooltip/sidepanelConfig/tooltipModeat the root of{id}_config.jsonfails AJV validation. Migration: move the fields undercapabilities.feature-info—tooltip.fields→capabilities.feature-info.tooltip,popup.fields→.popup,sidepanelConfig.detailLayout→.sidepanel, plus atitleField(the dotted path of the title). Details and examples: README of@geoleaf-plugins/feature-info. - POI taxonomy badges (temporary regression): without the removed translation bridge, a
badgefield bound to the taxonomy (categoryId/subCategoryId) displays the raw identifier instead of the label. Label/icon/sprite resolution by the taxonomy will be handled by a dedicated taxonomy rendering configuration (upcoming) — outside thecapabilities.feature-infoblock.
Security
- Strict
style-srcCSP —'unsafe-inline'removed: every inline style in the rendering (popup badges and tables, POI markers, legend, thestyleattribute of the SVG sprite, demo controls) is now applied through the CSSOM (element.style.setProperty) or through CSS classes, never through an inlinestyleattribute or<style>element. Thestyle-srcdirective of the deployment template no longer contains'unsafe-inline'. A guard end-to-end test checks for 0style-srcviolations at boot and when rendering a hostile POI. See the security integration guide for the recommended CSP. - XSS hardening in POI rendering: the
href/srcsinks of thelink/imagePOI sections now validate the URL throughGeoLeaf.Security.validateUrl()— a URL with a disallowed protocol (javascript:,vbscript:,data:text/html…) is no longer rendered (the element is omitted). No change for legitimatehttp(s)/data:imageURLs (the attribute is normalised to an absolute URL). GeoLeaf.Security.sanitizeSvgContent(): now strips SMIL animation elements (<animate>,<set>,<animateTransform>,<animateMotion>,<animateColor>,<mpath>) from untrusted SVG — they can mutate attributes at runtime (e.g.<set attributeName="href" to="javascript:…">).- Anti prototype pollution:
Config.merge()/set()filter out the__proto__/constructor/prototypekeys. - Profile sprite: the
taxonomy.icons.spriteUrlURL is validated (validateUrl) before thefetch. - CDN integrity (demo): MapLibre GL JS is loaded from unpkg with an
integrityattribute (SRI sha384) in the deployment template. - Anti-clickjacking: a recommendation for server headers (
X-Frame-Options: DENY+frame-ancestors 'self') has been added. See the new integrator security guide. (Thestyle-srcof the reference CSP no longer requires'unsafe-inline'— see the "Strictstyle-srcCSP" entry above.) - Removal of the
xlsx(SheetJS) dependency: Excel export now uses a minimal internal OOXML writer (write-only, no third-party dependency), eliminating 2 CVEs (prototype pollution CVE-2023-30533 plus a ReDoS) that were bundled in the export chunk. No API change (Excelremains a table export format). - Dependencies: patched versions pinned (
dompurify,markdown-it,protocol-buffers-schema); the remaining vulnerabilities concern development tooling only (not shipped at runtime).
Removed
GeoLeaf.Security.sanitizePoiProperties()deleted (breaking): this helper was wired on no production path — POI text is escaped at the rendering sinks (popupsetSafeHTML, side panelnormalizePoi) and URLs are validated throughvalidateUrl(). It maintained a false coverage signal (tested but never called). To sanitise external data before rendering it outside GeoLeaf, useescapeHtml()(text) plusvalidateUrl()(URLs), orsanitizeHTML(el, html)to inject HTML.- Legacy format fallbacks deleted (breaking — v3.0.0 clean slate): the runtime now accepts only the canonical form of the following keys:
sizePx(point size) → useradius: the alias is no longer normalised toradiusin flat styles. (Unrelated:label.buffer.sizePx— the thickness of a label halo — stays valid and unchanged.)vectorTiles.url→ usevectorTiles.tilesUrl:tilesUrlis the canonical key (already required by the profile schema); the input aliasurlis no longer recognised.layerScale(scale visibility) → usescaleConfig: the legacy alias and its deprecation warning are removed. The canonical form isscaleConfig.minScale/maxScale— see the "Breaking Changes (layer scale thresholds)" section above, which gives the unit and the conversion. (labelScaleremains supported — not affected.)pointStyle(marker style override, layer level) deleted: a legacy block with no MapLibre rendering, unused by the profiles; use the flatstyleformat (radius,fillColor…).data.useLegacyProfileDatadeleted: legacy "flat" profile loading (separatepoi.json/routes.json/mapping.json) no longer exists; only the modular profile format (config/core/*+Files) is loaded.- AddPOI plugin: deriving geometry from the legacy
latlng: [lat, lng]field is removed — POIs must carry a GeoJSONgeometry.
GeoLeaf.Utils.EventHelpers.debounce/.throttledeleted (minor breaking — v3.0.0 clean slate): these two methods were never-called duplicates in theEventHelpersnamespace (dedicated to dispatching and listening to DOM events). Use the canonical functionsGeoLeaf.Utils.debounce/GeoLeaf.Utils.throttle(unchanged). Note the slightly different defaults:debounce250 ms (instead of 300),throttle100 ms (instead of 300) — specify the delay at the call site if needed.
Breaking Changes (profile layout v2)
- New profile tree: the section files now live in
config/core/(taxonomy.json,themes.json,layers.json,basemaps.json,ui.jsonplus a newfeatures.json) and each plugin's configuration inconfig/plugins/<moduleId>.json.profile.jsonnow contains only the identity (id,label,description,version), themapsection and theFilesmanifest. Since paths are declared inFiles, an existing profile stays readable if it updates its manifest; the top-leveltaxonomyFile/themesFilefallbacks (outsideFiles), however, are deleted. Migration: move the 5 section files toconfig/core/, extractclusteringConfig/geocodingConfig/performance/poiConfigfromprofile.jsonintoconfig/core/features.json(referenced byFiles.featuresFile), extract the plugin blocks (storage,poiAddConfig,editorConfig…) intoconfig/plugins/<moduleId>.json(referenced byFiles.modules).
Added
GeoLeaf.Helpers.applyCssText(el, css)(plus the named ESM exportapplyCssText): applies a CSS declaration to an element property by property through the CSSOM (style.setProperty), in a CSP-safe way (not subject tostyle-src, unlikeel.style.cssText = …). A helper meant for code-owned dynamic styles. AlsoapplyDeferredStyles(root)(applies thedata-gl-styleattributes of a subtree after insertion). Used by the core and the plugins to work under a strictstyle-srcCSP (without'unsafe-inline').Files.featuresFile: a new section file for cross-cutting core features (clusteringConfig,geocodingConfig,performance,poiConfig,mapOptions), merged at the root of the consolidated profile likeuiFile/basemapsFile.Files.modules: a{ moduleId: filePath }dictionary — one configuration file per plugin, merged intomodules.<id>(Plugin Contract v1, opaque content for the core). An inlinemodules.<id>block inprofile.jsontakes precedence over the file (deepMerge; arrays are replaced, not merged).profile-bundle.jsonextended: the bundle generated at build time now embeds thefeaturesandmodulessections; booting a deployment still takes 3 requests (root config + profile.json + bundle).- Debug mode = cascade: when
debug: trueis active ingeoleaf.config.json, the loader ignoresbundleFileand loads the file cascade — this allows editing a deployed profile live without regenerating the bundle.
Fixed
Core.destroy(mapId)— real lifecycle teardown: when the last map is closed, the shared business state (POI, GeoJSON, LayerManager, active profile) is now cleaned up through an internal lifecycle seam. A laterCore.init()(React remount, SPA navigation, profile change) starts from a clean state — no more duplicated markers or layers, phantom profile or adapter leak. The public signature is unchanged. Validated in the browser.Legend: the taxonomy is read from the already-loaded active profile instead of being re-downloaded through a hard-coded path (
profiles/{id}/taxonomy.json) — this removes a redundant fetch and a latent 404 with layout v2 (the fetch fallback is kept for legacy profiles).Theme selector: the same fix — themes are read from the active profile instead of a re-fetch of the hard-coded path
profiles/{id}/themes.json.Popup action buttons: a new renderer type
type: "action"inpopup.fields[](GeoJSON layers) andpopup.detailPopup[](POI markers). A configurable button in the popup can trigger any host-side action — open a back-office record (Odoo…), call an API, emit an event — without coupling the core to a backend. Fields:actionId(required, opaque),labelKey/label,variant(primary/secondary/danger),order,href(opened by the core throughvalidateUrlif no handler is registered),confirm/confirmKey,requiresPlugin(button disabled if the plugin is absent),payloadFields(allow-list of payload properties). Scope for v1: popup only.GeoLeaf.Popup: a new public facade exposingregisterActionHandler(actionId | "*", fn)andunregisterActionHandler(actionId). Thefn(ctx)handler receives a rich context ({ actionId, feature?, poi?, layerId, featureId, properties, lngLat?, buttonEl, popup, setBusy, close }) and may return aPromise— the button then goes into a "busy" state until it resolves. Click precedence: exact handler → wildcard handler"*"→ opening the built-inhref. CSRF protection is the handler's responsibility (GeoLeaf.Security.CSRFToken.addTokenToHeaders()).geoleaf:popup:action: a new event emitted ondocumentat every click on a popup action button (emitted in all cases, whether or not a handler is registered). Payload:{ actionId, layerId, featureId, properties, lngLat? }—propertiesbounded bypayloadFields(default:id/name/title/label), functions and DOM references removed.Per-module configuration —
modules.<id>: plugin configuration is declared in amodules.<id>block of the profile (e.g.modules.storage,modules.print). The content of each block belongs to the plugin — the core treats it as opaque. This is now the only supported form (the fallback to legacy root keys has been removed, see Removed).GeoLeaf.Config.getModuleConfig(moduleId, key?, defaultValue?): an accessor readingmodules.<moduleId>.<key>. Dot-notation equivalent:GeoLeaf.Config.get("modules.<id>.<key>").style.paint(layer styles) is now merged: astyle.paintblock (native MapLibre properties —fill-color,circle-radius,line-dasharray…) declared in a style file is now merged into the layer paint, likeexpressionPaint(which still applies last). Previouslystyle.paintwas silently ignored (onlyexpressionPaintwas applied) — profiles that declared it now see their rendering applied.mapping.jsoncontract — single multi-source form:mapping.json(normalising raw data from an external source into the POI format) is now always an object of blocks named by source{ "<sourceId>": { mapping, … } }(a single source means a single block; the top-level{ mapping }form is gone). Eachmappingis flat ({ normalisedField: "sourceField" }, dotted paths allowed:location.lat,attributes.kind). Schema:mapping.schema.json.External source normalisation when loading a layer: declare
Files.mappingFilein the profile manifest, then point a GeoJSON layer at a source block throughdata.mapping: "<sourceId>"(plus the optionaldata.itemsPath, e.g."results", to extract the array from a nested response such as the GBIF API). At load time the raw data is normalised (mapping.json → POI format) then rendered as GeoJSON Point features. Numericidvalues (e.g. the GBIFkey) are coerced to strings. Theguyane-biodiversitedemo profile illustrates the case with theobservations_gbiflayer (GBIF API).New contractualised UI parameters:
ui.showSearch,ui.showShareButton(defaulttrue) andui.interactiveShapes(defaultfalse) are now declared in theuischema — previously read by the code but not configurable.data.vectorTiles.scheme("xyz"|"tms"): the vector tile grid scheme is now configurable (e.g."tms"for the IGN Géoplateforme).data.ogcApisource: an OGC API Features configuration block, now formally declared (urlrequired, pluscollectionId/bbox/maxFeatures/limit/autoRefresh/autoRefreshDebounce/headers).
Removed
- Breaking — root-level plugin configuration keys removed:
storage,poiAddConfig,printConfig,measureConfigandeditorConfigat the profile root are no longer recognised. The bidirectional mirror and the deprecation fallback have been deleted:modules.storage,modules.addpoi,modules.print,modules.measure,modules.editorare the only valid form (Plugin Contract v1, INV-CONFIG, now frozen). The corresponding interfaces have been removed from theGeoLeafConfigtype API. Migration: declare each plugin configuration undermodules.<id>(already the case for every shipped profile since layout v2) and read it throughGeoLeaf.Config.getModuleConfig(id, key, default)orConfig.get("modules.<id>.<key>"). - Breaking — the
lineColor/lineOpacity/lineWidthstyle aliases removed: these legacy keys had no effect (the converter reads onlycolor/opacity/weight). They are removed fromstyle.schema.json(and are now rejected at profile validation). Migration:lineColor→color,lineOpacity→opacity,lineWidth→weight. - Breaking — the top-level form of
mapping.jsonremoved: amapping.jsoncarryingmappingat the root is no longer valid; wrap it in a named source block (see "mapping.jsoncontract" and "External source normalisation" in Added). - Breaking —
GeoLeaf.UI.ScaleControlremoved: this scale control was a duplicate (driven byui.scaleType, not auto-initialised) of the active scale bar. Use the standard control, driven byscaleConfig(scaleGraphic/scaleNumeric/…) and initialised automatically at boot. Theui.scaleTypeparameter is deleted (replaced byscaleConfig).
Breaking Changes (multi-instance)
Coreis no longer a singleton:Core.init({ mapId })creates one instance permapIdinstead of recycling a single one.init()now requiresoptions.mapId(returnsnullplus an error log otherwise).Core.getMap(mapId?)accepts an optionalmapId— without an argument it returns the first active instance (backwards compatible). The legend and the theme stay global and apply to the first instance (a deliberate scope).GeoLeaf.removeMap(id)is deprecated and aliased toCore.destroy(id)(with a warning). Migration: single-map apps → no change; multi-map apps →const a = GeoLeaf.Core.init({ mapId: 'unique-id', center, zoom }); /* when the component unmounts */ GeoLeaf.Core.destroy('unique-id');.
Added (multi-instance)
Core.destroy(mapId): cleanly destroys a keyed instance — callsMaplibreAdapter.destroy()(map.remove(), purge of markers/controls/registry) then frees the registry slot. Returnstrueif an instance existed,falseotherwise. To be called on teardown by the consumer (e.g. React unmount).Core.hasMap(mapId)/Core.listMaps(): introspection of the instance registry (debugging, devtools, tests).- Multi-instance support: N MapLibre maps coexisting on the same page, each with an independent lifecycle (mount/unmount).
Changed
- Geocoding UI: the default
geocodingConfig.positionis now"top-left"(previously"top-right"). The geocoding pill adopts the visual style of the shared POI search bar (.gl-pill-search) with an SVG magnifier icon on the submit button. Explicitpositionvalues in profiles are still honoured unchanged. Integrator migration: no action required; to keep the former behaviour, declaregeocodingConfig.position: "top-right"in the JSON profile. - POI search: the
searchConfig.filters[].type: "search"filter is now rendered in a dedicated section at the top of the filter panel (it was previously hidden). The input is placed in[data-gl-filter-id="searchText"]— an unchanged selector forstate-reader. Live filtering triggers on every keystroke. - Mobile (≤ 768 px): the "search" button of the pill toolbar now opens the floating geocoding pill (address search) instead of the former POI bar. POI search on mobile is reachable through the FILTER tab/sheet, which holds the
searchTextpill in its header.
Removed
.gl-search-bar*(CSS) plus themobile-toolbar-searchbar.tsmodule (the POI floating search bar) and the CSS variables--gl-search-bar-height/--gl-search-bar-gap: replaced by the.gl-pill-searchcomponent shared between the geocoding pill and the POI search pill in the filter panel. Integrators who overrode these classes must move to.gl-pill-search,.gl-pill-search__input,.gl-pill-search__submit,.gl-pill-search__clear.
Added
@geoleaf-plugins/measurev1.0.0: an MIT map measurement plugin (distance, area, circle, georeferenced DOM tooltip annotations, GPS track) — published on npmjs.org. Public facade:GeoLeaf.Measure.activate(),deactivate(),clearAll(),exportGeoJSON(),importGeoJSON(),getPrintableAnnotations(),setMenuPosition(),getMenuHeight(). Configurable throughmeasureConfigin the GeoLeaf profile.@geoleaf-plugins/printv1.1.0: a conditional "Annotations" checkbox in the print modal — visible only if@geoleaf-plugins/measureis loaded; tooltip annotations are composed into the canvas export at their geographic coordinates throughGeoLeaf.Measure.getPrintableAnnotations(). New fieldprintConfig.includeAnnotations(boolean, defaulttrue). i18n in 6 languages.- CSS variables
--gl-color-tooltip-bg/--gl-color-tooltip-textingeoleaf-theme.css(:root,.gl-theme-light,.gl-theme-dark) — the tooltips of the pill bar and of the plugin-measure sub-menu buttons now follow the current theme instead of hard-coded colours. GeoLeaf.Share(view sharing): a new public facade exposingopenShareDialog(),closeShareDialog(),isOpen()andgetShareUrl(). It shows an accessible modal with the current permalink (window.location.href), a "Copy" button (navigator.clipboard.writeTextplus anexecCommandfallback) and a "Show QR code" button that lazy-loads theqrcode-generatorlibrary on the first click only (a separate chunk, ~12 KB gzip, no impact on the initial bundle).ui.showShareButton(boolean, defaulttrue) inIUIConfig: controls the display of the "Share" buttons (mobile pill bar + desktop tab strip).- Mobile "Share" button injected into the pill bar through the registry (
mobileIcon) — behaviour consistent with the print button. - Desktop "Share" button inserted in the tab strip between the separator and the theme toggle.
@geoleaf-plugins/printv1.0.0: an MIT map printing plugin (PDF/JPG export, A4/A3 at 300 DPI, interactive scale × paper format flow, off-screen re-render, 2D canvas composition, inline legend, optional server fallback) — published on npmjs.org.GeoLeaf.I18n.registerDict(namespace, dictsByLang): a core API letting plugins register their own i18n dictionaries.preserveDrawingBufferauto-detected inmaplibre-adapter.tswhen the print plugin registers — no manual configuration required.ui.showPrintinUIConfigplusprintConfiginGeoLeafConfig: integration of the print plugin's configuration into the GeoLeaf profile.
Fixed
gl_shownpermalink: opening a shared link containinggl_shown=<layerId>for a layer outside the active theme now displays that layer correctly. Restoration was previously a silent no-op becauseVisibilityManager.setVisibility()ignored layers absent fromGeoJSONShared.state.layers. A newrestoreShownLayerhelper (modules/built-in/permalink/permalink-layers.ts) lazy-loads the layer throughThemeApplierCore._loadLayerFromProfile()before applying the user override.
Breaking Changes (public type rename)
LeafletLayerLike→LayerLike: the public typeLeafletLayerLikehas been renamed toLayerLikein@geoleaf/core. This rename is a hard breaking change with no deprecation alias. Migration: replace everyimport { LeafletLayerLike } from '@geoleaf/core'withimport { LayerLike } from '@geoleaf/core'.
Changed
- Internal — cache UI moved out of the core: the
CacheSectionmodule (dead code, never rendered in production) and the associated CSS assets (geoleaf-cache.css,cache-modal.css) have been removed from@geoleaf/core. Theui.cache.*/toast.cache.*/aria.cache.*/format.cache.*i18n keys have been removed from the core as well. These elements now live exclusively in@geoleaf-plugins/storage(CSS bundled inline throughrollup-plugin-postcss, auto-injected when the plugin loads). No user-facing impact: theui.showCacheButtonflag still works (the plugin reads it directly fromcfg.ui) and the cache button still appears when@geoleaf-plugins/storageis loaded. Typing: theshowCacheButtonfield no longer appears explicitly inUIConfig— it is still accepted through the[key: string]: unknownpassthrough. - Internal export
GeoLeaf._LayerManagerCacheSection: removed (a private_-prefixed reference, never publicly documented).
Added
Multi-format table export (CSV, KML, GPX, Excel): the GeoLeaf table now supports 5 export formats — GeoJSON (existing), CSV, KML, GPX and Excel (.xlsx through SheetJS, lazy-loaded). The interface goes from a single "Export" button to two split-button dropdowns: "Export selection" (active only when there is a selection) and "Export layer" (always active when a layer is loaded). Each dropdown lists the configured formats.
Table.exportSelection(format?, options?): an existing method, extended. It now accepts anExportFormat('geojson' | 'csv' | 'kml' | 'gpx' | 'excel') and an optionalExportOptionsobject. Default:'geojson'(backwards compatible).Table.exportLayer(format?, options?): a new public method. It exports all the features of the active layer (without themaxRowsPerLayerlimit) in the requested format. Emitstable:exportLayerwith{ layerId, format, count }.TableConfigingeoleaf.config.jsonorprofile.json: a formalised TypeScript interface with the new keysexportFormats,csvSeparator,csvIncludeGeometry. See the dedicated section inPLUGIN_CONFIGURATION_GUIDE.md.table:exportLayer: a new event emitted ondocumentafter a whole-layer export. Payload:{ layerId: string, format: ExportFormat, count: number }.UIConfig.showCredentialButton: a new optionalbooleanfield inUIConfig(typing only). It lets the@geoleaf/connectorplugin ≥ 1.1.0 read whether the credential button is enabled from theui.jsonprofile. No core code consumes this field (no-plugin-in-corecompliance). From@geoleaf/connector1.2.1 onwards, this flag alone is enough to mount the button without a priorGeoLeaf.Connector.configure()call (UI-only auto-bootstrap on the plugin side, triggered bygeoleaf:profile:loaded/geoleaf:map:ready; read throughGeoLeaf.Config.getActiveProfile()).@geoleaf-plugins/realtime-layer—data.realtime.fallbackUrl: a new optional field of thedata.realtimeschema. A local CDN snapshot served automatically byPollingSourcewhen the primary URL returns a non-2xx HTTP status or fails (network error). The snapshot is emitted once per outage; the source keeps polling the primary and returns to it on its first success. Polling only.@geoleaf-plugins/cog: a new Cloud Optimized GeoTIFF (COG) rendering plugin. Native reading throughgeotiff@^3.0.5with automatic overview selection based on the viewport, multi-band rendering (1/3/4 channels, transparent nodata, colorMap LUT), injection as a MapLibre GL JSimagesource. API:GeoLeaf.COG.addLayer(url, map, opts?),GeoLeaf.COG.removeLayer(map, id),GeoLeaf.COG.getInfo(url, opts?). A separate bundle from the core, published on npmjs.@geoleaf-plugins/flatgeobuf: a new MIT FlatGeobuf loading plugin. Streaming through an async iterator (flatgeobufv4.4.0), spatial bbox filtering through an R-tree index plus HTTP Range requests, debounced auto-refresh on the viewport. API:GeoLeaf.FlatGeobuf.load(url),loadBbox(url, bbox),loadAsLayer(url, options?),loadBboxAsLayer(url, bbox, options?),loadLayerFromConfig(config)(declarative JSON configuration). A separate bundle from the core (~91 KB raw / 20 KB gzip). First examples in profiles:france-rail/zones_desserte(bbox + auto-refresh),tourism/eco_regions_fgb(local file; size gain −51% versus the source GeoJSON).@geoleaf-plugins/file-import: a new MIT geospatial file import plugin. Supported formats: GPX, KML, KMZ, CSV (lat/lng or WKT), TopoJSON. API:GeoLeaf.FileImport.convert(file),importAsLayer(file, options?),getSupportedFormats(),registerConverter(ext, converter). A separate bundle from the core (~276 KB raw).Geocoding: the 31st named ESM export. A lazy-loaded address search module (_loadModule("geocoding")), APIGeoLeaf.Geocoding. Four built-in providers:addok,nominatim,photon, custom URL.geoleaf:geocoding:result: a new event emitted when a geocoding result is selected — payload{ label, lat, lng, bounds? }.GeocodingConfiginui.jsonorgeoleaf.config.json: parametersenabled,provider,position,placeholder,minChars,resultLimit,debounceMs,flyToZoom.3D terrain: relief support on raster (
type: "tile") and vector basemaps (type: "maplibre"). Configuration throughbasemaps.{id}.terrain(enabled,demUrl,demEncoding,demMaxZoom,exaggeration,default3D,pitch,bearing). Automatic activation without a UI toggle —default3D: trueenables terrain when switching to the basemap,falsedisables it. DEM source validated in production: AWS Terrarium (~30 m).map.maxPitchinprofile.json: a configurable camera pitch ceiling. GeoLeaf raises the MapLibre GL JS default limit (60°) to 80°. Default value:80. Configurable throughprofile.json > map.maxPitch.Fill-extrusion: support for 3D polygons through the MapLibre GL JS
fill-extrusionlayer type. Setgeometry: "fill-extrusion"in the layer config file, then definefillExtrusionColor,fillExtrusionOpacity,fillExtrusionHeightandfillExtrusionBasein the style file.fillExtrusionHeightaccepts a fixed value (metres) or a feature field name (e.g."hauteur"). Validation is handled bystyle-validator-extrusion.ts: an error iffillExtrusionHeightis missing, a warning if the field is not found in the properties of the first feature.GeoLeaf.Utils.wktToGeoJSON(wkt): converts a WKT string into a GeoJSON geometry object. Supports the 7 standard types (Point,LineString,Polygon,MultiPoint,MultiLineString,MultiPolygon,GeometryCollection) in 2D and 3D/Z. Supports the SRID prefix (SRID=4326;…) and theZ/M/ZMqualifiers. Returnsnullwithout throwing if the input is invalid.OGC API Features: native support for loading GeoJSON layers from an OGC API Features endpoint. Configure
data.ogcApiin the layer definition:url,collectionId,bbox,maxFeatures,limit,autoRefresh,autoRefreshDebounce,headers. Automatic pagination throughnextlinks, amaxFeaturesguard, cancellation throughAbortController, automatic conversion of WKT geometries. WithautoRefresh: true: re-fetch onmoveendwith the current viewport bbox..topojsonand.fgbadded toFileValidatorvalidation.Basemap
type: "image": a new basemap type for static georeferenced images (the native MapLibreimageformat). Configuration throughbasemaps.{id}.imageSource(url,coordinates,opacity). The image is positioned by its 4 corners[lng, lat]; withoutcoordinates, world bounds are used by default.Basemap
type: "hillshade": relief shading through a MapLibrehillshadelayer. Configuration throughbasemaps.{id}.hillshade(demUrl,demEncoding,demMaxZoom,shadowColor,highlightColor,accentColor,exaggeration,illuminationDirection,illuminationAnchor). It automatically reuses theterrain-demDEM source if it is already present with the same URL (compatible withtype: "tile"3D terrain).Basemap
type: "wmts": support for OGC WMTS servers through dynamicGetCapabilitiesresolution. Configuration throughbasemaps.{id}.wmts(getCapabilitiesUrl,layer,tileMatrixSet,format). Namespace-safe XML parsing, in-memory cache of resolved URLs, cancellation throughAbortController.Basemap
type: "wms": support for OGC WMS servers (raster streams). Configuration throughbasemaps.{id}.wms(url,layers,version,crs,format,tileSize,transparent,styles). It builds the URL template with the{bbox-epsg-3857}placeholder, compatible with MapLibre GL JS.
Changed
- GPX extraction out of the core: the GPX→GeoJSON conversion (the
DataConverter.convertGpxToGeoJSON()method plus its private helpers) has been removed from@geoleaf/coreand migrated to@geoleaf-plugins/file-import. The route pipeline (route-utils.ts::parseGPX()) and normalisation (normalizer.ts::normalizeFromGPX()) are not affected. TheDataConverterLikeinterface is updated (convertGpxToGeoJSON()removed). Thesingle-layer.tsloader no longer has anisGpxbranch — a simpler loading flow.
Docs
- Complete geocoding guide:
CONFIGURATION_GUIDE.md §12extended with a provider selection guide (Addok = French local authorities, Nominatim = general worldwide use), a comparison table (coverage, quota, latency, attribution), the Nominatim usage policy (1 req/s, automatic User-Agent, automatic Accept-Language), the custom provider schema (fields read by the internal parser), the programmatic API (GeoLeaf.Geocoding.search/selectResult/destroy) and a security note. A new recipe inCOOKBOOK §11(4 variants: minimal Addok, worldwide Nominatim, custom event, search without UI), a newUSER_GUIDE §7.6section (keyboard navigation,flyToversusfitBounds), and 6FAQentries (API key, provider choice, Nominatim 429, area filtering, result event, custom provider). Additional note inAPI_REFERENCE: silent fallback to Addok when not on HTTPS,destroy()behaviour.
Fixed
@geoleaf-plugins/realtime-layer— readingdata.realtime:RealtimeManager.bootFromProfile()andstart()did not find the realtime configuration when it was nested inside thedatablock of the layer JSON (the canonical profile schema). The plugin only readconfig.realtimeat the root. The lookup now checksconfig.data.realtimefirst, then falls back toconfig.realtime.- Flat fill-extrusions on load:
fill-extrusionlayers displayed flat (height 0) on initial load and after a style change. Cause: the complete style object{ id, label, style: {…} }was passed straight to the rendering functions instead of the flat paint —toFillExtrusionPaint()did not find thefillExtrusionHeightand similar keys at the root. Fixed intheme-applier/visibility.tsandvector-tiles.ts. - Active style lost after a basemap change: GeoJSON layers (including fill-extrusion ones) systematically reverted to the default style after every basemap switch. Cause:
_rebuildGeoJSONLayers()read.defaultStyleon a flat paint, alwaysundefined. Fixed by readingcurrentStyle.style ?? currentStyle. - Spurious
linesub-layer on fill-extrusion layers: alinesub-layer was generated on top of the 3D volumes, producing a spurious rendering. Fixed by ageometry !== "fill-extrusion"guard inmaplibre-helpers.ts. - Vector tile layers invisible after a basemap switch: VT layers were not reloaded after a basemap change (they were skipped in
_rebuildGeoJSONLayersbecausefeatures: []). Fixed by a dedicatedisVectorTile === truebranch triggeringloadVectorTileLayer().
Earlier versions
The changelog for versions before 3.0.0 is not carried over here. The release notes for 2.0.0 — the first npm publication, which carried the move from Leaflet to MapLibre GL JS — remain available: Patchnote V2.0.0.
Every breaking change carries its own "Migration" note in the section of the version concerned above.
