Changelog
All notable changes to @geoleaf/core are documented here.
Format: Keep a Changelog — Semantic Versioning.
[3.19.0] - 2026-10-23
Changed
- The built-in basemaps no longer join a profile's.
street,topoandsatellitewere registered on everyBaselayers.init, before the profile's basemaps: a profile that declared two offered five, the built-in three with their English labels — among them an imagery served from a third-party host the profile had not chosen. They are now registered only when the profile declares no basemap. An integrator who relied on them without declaring them, next to basemaps of their own, must now declare them. A declared entry namedstreet,topoorsatellitethat brings nourl,tilesorstylestill takes the built-in one's tiles and overrides only what it carries (label,apiKey…). One that brings its own source is taken as is: it used to be merged with the built-in one, whosetileswon over the declaredurl. - Without a
defaultBasemap, the first declared basemap is active. The boot fell back tostreet, which a profile that declares its basemaps no longer has. The configuration guide already said "the first of the list".
Added
themeable, a key of a layer's definition.falseputs the layer out of the themes' reach: applying a theme neither hides it for not listing it, nor changes its visibility or style when it lists it. For a technical layer whose visibility no theme decides — a clustering support, a computation layer — that a theme switched off for not listing it, and that nothing switched back on when it had no row in the layer manager. Absent ortrue, nothing changes.--gl-color-scheme(light,darkunder.gl-theme-dark), read by the layer manager's selects as theircolor-scheme, so that the browser draws their lists in GeoLeaf's scheme and not the host page's.
Fixed
- A host page's generic rules no longer repaint GeoLeaf's fields and the basemap credit. Every GeoLeaf rule lives in a
gl.*cascade layer, and an unlayered rule beats every layered one: a dark host theme'sselect { color: …; background: transparent }left light text on the layer manager's light panel — 1.24:1 for the basemap select and the style selects. The credit inherited the host's light text on the engine's half-white box. A new sheet, the only one outside the layers, hands the colour and background of GeoLeaf's fields back to GeoLeaf's layers (revert-layer); a host rule that names a GeoLeaf class, andgl.overrides, still apply. The credit now carries the engine's link colour, and the basemap select its own colours. @geoleaf-plugins/table1.2.2. The layer selector's two placeholders were hard-coded in English, in an otherwise translated table: they now come from the plugin's dictionaries, with a new key,ui.table.no_visible_layer, in its six languages. And the panel no longer covers the host page's dialogs: it sat atz-index1100, above the 1050 of common modal dialogs, so a record opened from a table row had its bottom hidden. It now reads--gl-z-table-panel(default--gl-z-panel, 1040), and its export menu--gl-z-table-panel-overlay(default--gl-z-panel-overlay, 1045).
@geoleaf-plugins/print 1.3.6 - 2026-10-23
Published without a core release: @geoleaf/core stays at 3.18.0.
Security
- The bundled DOMPurify moves to 3.4.17.
printships jsPDF's optional sanitizer in a lazy chunk, loaded on the first PDF export. 1.3.5 carried DOMPurify 3.4.16, covered by three advisories fixed in 3.4.17: GHSA-cg2g-xh64-v4q7, GHSA-mrv2-3pqr-j5jr and GHSA-c6gr-qvv4-5v3f. The plugin's own code is the same as 1.3.5's.
[3.18.0] - 2026-10-20
Fixed
- A queued write no longer follows a profile switch. The write target of a queued capture was resolved from the profile active when the queue drained, and switching profile reloads the page, whose first pass sends what the previous page left. A capture made under one profile thus went to the
writetarget of the other profile's layer of the same id, with that profile's credentials and no signal. Or, when the other profile had no such layer, it was set aside aslayerNoLongerWritable. Each capture now carries the profile it was made under, and a pass sends only the active profile's. The others wait, untouched — not sent, not set aside, and spending nothing of their attempts — until their profile is active again. A capture queued by an earlier version carries no profile and is sent as before. - A capture is no longer drawn under another profile. The queue keeps every profile's work, and the device store keeps one copy of each entity per layer id, whatever the profile. After a switch to a profile with a layer of the same id, that layer showed the other profile's work: its creations and edits, restored at boot and read from the device when the layer reads offline, and its deletions, replayed on the layer at boot. A layer now draws the active profile's work only. A layer that only the other profile's work fills is read as never downloaded, and loads from the network. One limit remains: an entity the other profile edited or deleted stays off this profile's layer until that edit is sent. A capture queued by an earlier version carries no profile and is drawn under every profile, as before.
- A download no longer takes another profile's downloaded data. The same device store holds what every profile downloaded, one copy per layer id. Two profiles of the same origin declaring a layer of the same id over two different sources met there: a complete download under one removed what the other had downloaded, the layer then showed the other profile's entities, and a layer only the other profile had downloaded read as downloaded and empty instead of loading from the network. Each downloaded entity now records the source that served it, and so does the download status of each layer: a layer shows, and a download removes, only what its own source served. Two profiles over the same source still share what they downloaded. One limit remains: when both sources serve an entity of the same id, the last download keeps its copy, and the other profile's layer misses that entity until its own next download, which is complete. Data downloaded by an earlier version records no source and is shown under every profile, as before.
- An archived conflict stays with its profile.
Storage.listConflicts()listed, andStorage.clearConflicts()purged, the conflicts of every profile of the origin: a conflict met by a write queued under one profile showed under another whose layer merely shared the id, and a purge there erased it unread. Both now cover the active profile's conflicts only. A conflict archived by an earlier version has no profile and is covered under every profile, as before. - Two tabs no longer drain the queue at the same time, where the browser provides the Web Locks API. The same capture could leave twice: the server's answer on
local_idkept a duplicate row from being created, but the capture spent its attempts twice. - A profile without the PWA no longer takes the service worker from another profile of the same origin. With
modules.pwa.enabledoff, GeoLeaf unregistered its worker at boot, even when another configuration of the same origin — another page, a configuration passed in memory — enabled the PWA. That one lost its offline mode on its next load without network. A profile that enables the PWA is now recorded inlocalStorage["gl-pwa-holders"], and the worker is unregistered only once no profile holds it. A profile that turns the PWA off still removes the worker it had. If that storage cannot be read, the worker is unregistered, as before. - After a measurement, dragging the map shows
grabbingagain (@geoleaf-plugins/measure1.0.13). Leaving a distance, area, circle, rectangle, annotation or custom tool wrotecursor: grabon the map canvas. At rest that looks the same, but it overrode the engine's own rule, and the cursor stayed ongrabwhile the map was dragged. The tools now leave no cursor of their own behind. - The editor's strokes follow a palette switch (
@geoleaf-plugins/editor1.6.2). The editor takes its stroke colour from the palette's accent, but it watched for a change in a place no switch touches: once the editor had started, a new palette never reached its strokes. It now listens togeoleaf:palette-changed. Light and dark still leave the stroke colour unchanged, on purpose: the light theme's accent is a pale interface tone, which a stroke on the map would lose. @geoleaf-plugins/routing1.0.5 and@geoleaf-plugins/table1.2.1 — test comments only. A routing test said its fixture-capture script was the only code of the repository that talks to a provider; the isochrone plugin's capture script is the second. Four table test comments and titles named their module by a path that does not exist (table/panel.tsforsrc/panel.ts,table/table-layer.tsforsrc/table-layer.ts). Both packages ship their sources, hence the versions. No runtime change.
Added
OutboxEntry.profileId— the profile a queued capture was made under. Optional: an entry queued before 3.18.0 has none, and the database schema does not change.ConflictRecord.profileId, and{ allProfiles: true }forStorage.listConflicts()andStorage.clearConflicts()— the profile a conflicting write was queued under, and the option that reads or purges every profile's conflicts, aslistPendingEditsdoes.FeatureRecord.sourceKey— the source a downloaded entity came from (its URL and collection). Optional: a record written before 3.18.0 has none, and the database schema does not change.GeoLeaf.Storage.DB.listPendingEdits({ allProfiles: true })lists every profile's pending edits, each with itsprofileId. Without the option it lists the active profile's only, asgetSyncCounts()andgetSyncStatus()count them. An export of field work passes the option (@geoleaf-plugins/offline-ui1.7.1 does).profile, an option ofGeoLeaf.boot()andGeoLeaf.mount(), decides which profile boots. Until now, a profile the user had picked with the profile switcher — stored in the browser for the whole origin — won over thedata.activeProfileof a configuration passed by the integrator, whileconfigwas documented as "applied as-is".profilewins over that stored choice. Without it, nothing changes, and the documentation ofconfignow says so.
Removed
useSharedCluster, from the clustering decision (ClusterStrategyResult) and from a loaded layer's data. It said whether a point layer joined a cluster shared with other layers, but no layer is clustered with another: every clustered layer has its own source, and nothing read the flag. The capability's schema descriptions and the configuration guide no longer describe a shared cluster.
@geoleaf-plugins/isochrone 1.0.2 - 2026-10-20
Published without a core release: @geoleaf/core stays at 3.17.0.
Changed
@geoleaf-plugins/isochroneis published with a provenance attestation. 1.0.0 and 1.0.1 were published by hand, without one. 1.0.2 goes through the release workflow like the other plugins, and the registry carries its signed SLSA provenance. The code is the same as 1.0.1's.
[3.17.0] - 2026-10-18
Added
- Exclusive mode on the adapter:
IMapAdapter.enterExclusiveMode,exitExclusiveModeandisExclusiveMode. A tool that takes over the map's clicks — measuring, drawing, picking a point — enters exclusive mode; until it exits, the core opens no feature popup on click and its hover handlers leave the cursor alone.enterExclusiveMode("crosshair")also paints that cursor on the map canvas and restores the previous one on exit; without an argument the cursor is left as it is. There is no ownership token: tools exclude each other, the last entry wins and any exit releases the mode. Until now the only way in was to set an undocumented flag on the engine map. The three members are optional, so an adapter written outside the library stays valid without them: call them asadapter.enterExclusiveMode?.(…).
Changed
MapConfig.paddingaccepts the object form. The type was[number, number]; it is now[number, number] | { top?, right?, bottom?, left? }, the two forms the core reads. An edge the object omits is0. The profile schema still admits an array of three or four numbers, of which only the first two are read: the type keeps refusing them.UIConfig.showGeocodingis declared, next toshowPrintandshowMeasure. The three are aliases ofmodules.<plugin>.showButton, read only when that key is absent — their documentation now says so.@geoleaf-plugins/measurenow requires@geoleaf/core3.17.0 or later (@geoleaf-plugins/measure1.0.12). ⚠️ The peer range moves from^3.0.0to^3.17.0: a host still on 3.16 gets a peer-resolution refusal. Its tools now enter and exit exclusive mode through the adapter (IMapAdapter.enterExclusiveMode, 3.17.0) instead of setting an undocumented flag on the engine map. Nothing changes on screen: while a tool is armed, a click still opens no feature popup and the tool keeps its own cursor. On an adapter without exclusive mode the tools still work, without it.@geoleaf-plugins/editornow requires@geoleaf/core3.17.0 or later (@geoleaf-plugins/editor1.6.1). ⚠️ The peer range moves from^3.0.0to^3.17.0: a host still on 3.16 gets a peer-resolution refusal. An armed editor tool enters exclusive mode through the adapter instead of setting an undocumented flag on the engine map, and the layer picker stands aside for another plugin's tool by asking the adapter (IMapAdapter.isExclusiveMode). Nothing changes on screen. On an adapter without exclusive mode the editor still works, without it.@geoleaf-plugins/isochronenow requires@geoleaf/core3.17.0 or later (@geoleaf-plugins/isochrone1.0.1). ⚠️ The peer range moves from^3.15.0to^3.17.0: a host still on 3.15 or 3.16 gets a peer-resolution refusal. Picking a point on the map now holds the crosshair and keeps a click from opening a feature's sheet through the adapter (IMapAdapter.enterExclusiveMode), and the plugin no longer reaches the engine map at all. Leaving the picking now always releases exclusive mode: it no longer hands it back to a tool that held it before, since tools exclude each other. On an adapter without exclusive mode the picking still works, without the crosshair and without that guard.
Fixed
Three public subpaths of
@geoleaf/core/capabilities/*can be imported.capabilities/legend/public-api,capabilities/pwa/public-apiandcapabilities/filter/engine/indexshipped their declarations but no module: since 3.0.0,import { Legend } from "@geoleaf/core/capabilities/legend/public-api.js"type-checked, then threwERR_MODULE_NOT_FOUND. The three modules are now emitted, with the exports their declarations announce (Legend,PWA, and the ten filter-engine functions). The packaging check now refuses any published declaration that exports a value without its module. The type-only modules under the same subpath (*/types) are unchanged:import typefrom them works, as before.The documentation of
IMapAdapter.addGeoJSONLayernames members the adapter has. It sent the reader tosetLayerVisibility(), which the contract does not declare: a layer is shown and hidden byshowLayer()/hideLayer(). It also said the data was validated by the adapter; it is handed to the engine's GeoJSON source as it is.
Removed
IMapAdapter.createClusterGroup, without a deprecation period. ⚠️ It was a REQUIRED member of the adapter contract, so removing it in a minor release breaks semver for TypeScript code that calls it. We accept this on purpose. An adapter written outside the library may keep the method, which nothing in the library calls. No caller is known, and the group it created was registered at rank 100 but drawn on top of the style as it stood, so its rank did not say where it was drawn. To cluster a layer, pass theclusteroption toaddGeoJSONLayer; that path is unchanged. The internal POI cluster renderer that only this member reached goes with it.
[3.16.3] - 2026-10-17
Fixed
- A layer's
attributionoption is shown.GeoLeafLayerOptions.attributionwas declared by the adapter contract, documented as shown in the attribution control, and read by nothing: a credit passed toaddGeoJSONLayernever appeared. It is now set on the layer's source, and the engine's attribution control shows it. - A
map.paddingobject naming only some edges frames the map at start-up as a theme view does. The start-up handed the object through as declared, and the adapter reads four edges only whentoporbottomis a number:{ "left": 320 }started the map with no padding at all, while a theme framing itsviewkept the 320 pixels. Both now readmap.paddingthrough one function — each edge it names applies, an edge it does not name is0; the array form[horizontal, vertical]is read as before. - The profile schema accepts
ui.showPrint,ui.showMeasureandui.showGeocoding. The toolbar has always read them, as fallbacks ofmodules.print.showButton,modules.measure.showButtonandmodules.geocoding.showButtonread only when the module key is absent — butui.schema.jsonrefused them, so an editor validating a profile through its$schemacalled a working profile invalid. They are declared as those aliases, and the profile types generated from the schemas (@geoleaf/core/schemas) carry them. Nothing changes at run time; a new profile should still write the module key. - The side panel's theme toggle is not posted a second time into the mobile toolbar. The guard meant to find a toggle already there looked for a variant no button carries, and only the panel's own reference kept the duplicate out. It now looks for the toggle itself — and not for any mobile button: the language switcher and the palette post theirs into the same toolbar.
Removed
- The theme applier's fit on the layers' extent, which could no longer run. It was armed by an internal
fitBoundsoption no caller passed and by a constant set tofalse, and even re-enabled it would have found nothing to fit — the layer loader records no extent. Themes frame the view they declare (themes[].view, 3.15.0). No public option changes; the profile keyperformance.fitBoundsOnThemeChangewas already refused by the schema.
Security
- Map credits are cleaned before they reach the attribution control. A basemap's
attribution(profile) and a layer'sattributionoption go through one sanitizer: inline formatting (a,span,strong,em,b,i,br) and links whose URL is accepted are kept — a kept link opens in a new tab withrel="noopener noreferrer"—, any other markup becomes text. MapLibre cleans too, but the peer range admits every 6.x, and up to 6.10 its attribution control cleaned by blocklist then re-injected the result throughinnerHTML. A credit that relied on other markup (an image, a paragraph) now shows as text.
[3.16.2] - 2026-10-16
Fixed
A basemap that displays is no longer reported as missing something (
@geoleaf/core3.16.2). Loading a profile warnedbasemaps.<id>: "url" or "style" is requiredfor every basemap carrying neither — yet the basemap registry has always drawn more than that: a basemap made oftilesalone,type: "maplibre", and the four types configured by their own block,image,hillshade,wmtsandwms. Those basemaps displayed, and the console said otherwise. The check now asks exactly what the registry accepts, through the same test; it still warns for an entry with nothing to draw, an emptytileslist or an unknown type, and its message names the four ways to be drawable. Unchanged for a host: no API, configuration key or event is added or removed.Asking again for the active basemap cancels a switch still waiting (
@geoleaf/core3.16.2). A basemap asked while the map's style loads waits for it. Basemap A active, B asked in that window, then A asked again: the second request returned on « already active » and left B waiting — and B applied when the style got ready, against the last choice. The active basemap asked again now supersedes any waiting switch, as any other request does.setBaseLayer()says it: the last call wins. Unchanged for a host: no API, configuration key or event is added or removed.A basemap asked while the style updates no longer makes MapLibre throw (
@geoleaf/core3.16.2). A basemap asked while the map's style is not loaded waits for it, and wakes on the engine'sstyledata,sourcedataoridle. MapLibre 6.7.0 fires onesourcedatafrom inside its own style update — when layers hidden for a frame are shown again — and the waiting basemap replaced the style right there, under the engine's loop:TypeError: Cannot read properties of undefined (reading 'used'), uncaught, out of the render loop. The gesture was ordinary: a theme that shows layers, then a basemap picked at once. The waiting basemap now applies right after the engine's emission, in a microtask — still before the next frame — and a request that came in between wins. Unchanged for a host: no API, configuration key or event is added or removed.Going back to a vector basemap while another one downloads finishes the switch (
@geoleaf/core3.16.2). Vector basemap A shown, B asked and its style still downloading, A asked again: A's style aborted B's download and, once through, equalled the style the map held — MapLibre 6.7.0 then applies nothing and emits nothing, nostyle.load. The active basemap read A, butgeoleaf:basemap:changewas never emitted, and whatever A puts on when it lands was skipped. The switch is now finished when the style has passed through and the live style has the shape it rendered; a style the engine refuses finishes nothing. Unchanged for a host: no API, configuration key or event is added or removed.
[3.16.1] - 2026-10-15
Added
- The zones reachable from a point within a given time:
@geoleaf-plugins/isochrone(@geoleaf-plugins/isochrone1.0.0, first publication). A new MIT plugin, mounted asGeoLeaf.Isochroneand configured undermodules.isochrone. It asks a routing engine for the zones reachable from a point — placed on the map, or the user's position read from the core's GPS — by car, on foot or by bike, one zone per duration, and draws them as a stack the user reorders, recolours, hides and removes from a floating window, every action with a named button. The plugin computes nothing itself: the built-in provider asks a Valhalla instance, by default a shared public server with no guarantee, which stops at 60 minutes; an application in service declares its ownendpoint,https://only. A key is never written in a profile: it is given at runtime,GeoLeaf.Isochrone.configure({ endpoint, query }), and kept out of the plugin's own traces. The zones live strictly betweenmodules.isochrone.zIndexand the next rank, among the profile's layers, and move on the map throughIMapAdapter.setLayerZIndex(3.15.0). Peers:@geoleaf/core3.15.0 or later,maplibre-gl6. The plugin's README lists every key, member and failure reason.
Fixed
A GPS measurement no longer switches off a GPS the user had switched on (
@geoleaf-plugins/measure1.0.11). The GPS tool read whether the core's geolocation was on from a field the core has never exposed: it always answered no, so the tool always clicked the core's geolocation control — and that click toggles it. Started with the GPS already on, a measurement switched it off, and the measurement then stopped itself, since it follows the GPS being switched off from outside. The tool now reads the core's state through@geoleaf/host-runtime's shared GPS seam: it switches the GPS on only when it is off, and off on leaving only when it switched it on. Unchanged for a host: no API, configuration key or event is added or removed.« Your position » in the routing panel routes from the user's position (
@geoleaf-plugins/routing1.0.4). The button always answered that the position was not known yet, whatever the GPS: the plugin read the core's geolocation state under three names the core has never exposed. It now reads it through@geoleaf/host-runtime's shared GPS seam: it uses the fix the core holds, and when the GPS is off it switches it on — through the core's own control, the only path that asks for the permission — and waits for the first fix, 20 s at most. Three answers stay distinct: geolocation unavailable or turned off by the profile; the permission refused, read through the Permissions API; no fix. A second press while a fix is awaited is ignored. Unchanged for a host: no API, configuration key or event is added or removed.A write no longer makes the engine report the icons it names as missing (
@geoleaf/core3.16.1). Since 3.16.0 a point layer created empty and filled afterwards draws its icons — and MapLibre logged, once per image the write named,Image "<id>" could not be loaded. Please make sure you have added the image before it is needed with map.addImage()…. Nothing was missing on screen: the features reach the source carrying theirsymbolIdbefore the images are registered, the engine asked in between, and got them a moment later. It showed whenever a write left before the profile's sprite had been registered — a firstGeoLeaf.Layers.setDatasent as soon as the map is ready, typically — and forGeoLeaf.GeoJSON.updateLayerData,addFeatureandmergeFeaturesalike. The map now carries a missing-image resolver (map.setMissingStyleImageResolver): asked for one of the profile's icons, it waits for the sprite and registers it, so the engine gets every image in its first answer and has nothing to report. Unchanged for a host:properties.symbolIdis written as in 3.16.0, and nogeoleaf:event is added or removed. Three things to know. An icon the sprite does not hold is still reported, as before. While the sprite is on its way, the points of that layer are drawn WITH their icons when it lands instead of bare first — bounded to five seconds, after which they are drawn bare and the icons follow as in 3.16.0. And the map holds a single resolver: a host that sets its own on the native map replaces this one.
[3.16.0] - 2026-10-12
Added
geoleaf:filters:appliedsays why the pass ran:detail.reason(@geoleaf/core3.16.0). Since 3.15.0 an active filter judges a layer again each time its store is written (geoleaf:layer:updated), andgeoleaf:filters:appliedis emitted at the end of that pass as at the end of any other — although the filter itself has not changed. The event carried an empty detail, so a listener could not tell the two apart. It now carries{ reason: "filter" | "data" }:"filter"when the filter was applied — Apply, Reset, a control of the panel,GeoLeaf.Filter.applyFilter,applyNow,reset— and"data"when the same filter is replayed because a layer was written. A control touched while a write's pass is waiting makes it a"filter"pass."filter"does not prove the state differs from the previous pass: Apply pressed twice emits twice. Non-breaking — the event fires exactly when it did, and a listener that ignores the detail is unchanged; the type of the detail inGeoLeafEventMapmoves fromRecord<string, never>toGeoLeafFiltersAppliedDetail. Do not rewrite a layer in answer to"data": a write firesgeoleaf:layer:updated, which replays the pass, which emits this event again. A host that relayed everygeoleaf:filters:appliedto a server search, and wrote the result back withGeoLeaf.GeoJSON.updateLayerData, looped without end from 3.15.0 on — 22 requests every ten seconds, measured. Reload on"filter".
Fixed
- A layer created empty hears a click on whatever it is filled with afterwards (
@geoleaf/core3.16.0). A GeoJSON layer created with no feature is built with the sub-layers of the three geometry families, since nothing says yet what it will hold — and its click and its hover were bound to ONE of them, chosen by precedence: the fill. Filled later with points (GeoLeaf.GeoJSON.updateLayerData,GeoLeaf.Layers.setData), the layer drew them, turned the cursor into a pointer over whatever lay under them, and never heard them: a click on such a point emitted nogeoleaf:feature:clickfor its layer, so the popup that opened was the one of another layer's polygon underneath. The same layer created with a first batch of points worked, which is what made it look intermittent. A layer's gestures are now bound to every sub-layer that draws features — fill, extrusion, line, circle, icon — through one listener for the whole layer. What you may see: a gesture on a feature still emits exactly one event per layer, however many sub-layers render it; on a layer holding several geometries, the feature reported is the one drawn ON TOP — a point lying on a polygon of the same layer answers as the point, where it used to answer as the polygon; a point that draws its icon is clickable over the whole box of the icon, not only over its circle; a polygon is hovered and clicked on the outer half of its outline too; andgeoleaf:feature:hoverno longer saysleavethenmovewhen the pointer crosses from a polygon's surface to its outline. - A layer created with one geometry draws another one it receives later (
@geoleaf/core3.16.0). A layer's sub-layers were decided once, from its first batch: a layer created with points had nothing to draw a polygon with, so a polygon written to it afterwards was stored, listed in the table, counted by the filter — and never drawn. Whether a layer could hold several geometries therefore depended on what its FIRST batch happened to contain. A write that shows a geometry the layer does not draw — a whole collection (GeoLeaf.GeoJSON.updateLayerData,GeoLeaf.Layers.setData) or features added (addFeature,mergeFeatures) — now gives the layer the sub-layers it lacks: built from the options the layer was created with, inserted at their place in the layer's stack, with its current visibility, zoom range, style and filters, and bound to its click and hover. What you may see: geometries that were silently absent from the map now appear; a layer's sub-layer list (getLayerRegistry().getSubLayerIds(id)) may grow after its creation. A write only ever adds what is missing. Not covered: a vector-tile layer, and a CLUSTERED layer — a clustered source indexes points alone, so lines and polygons written to it are still not drawn. - Between two layers under a click, the one drawn on top answers — and it alone (
@geoleaf/core3.16.0). Each interactive layer under the pointer emitted its owngeoleaf:feature:click, in the order the layers had been CREATED, and the popup shown was the last one's: a point lying on another layer's polygon opened the polygon's popup whenever the polygon layer happened to be the younger of the two. The core now asks the engine which feature is drawn on top at the clicked point, among every interactive layer, and only that feature's layer emits. What you may see — and it removes events: a click on a place where two interactive layers overlap now emits ONEgeoleaf:feature:click, where it emitted one per layer; a listener that relied on the click of the layer UNDERNEATH no longer receives it there. What decides is the drawing order, hencezIndex— a point layer meant to answer over a polygon layer must be drawn above it. A layer that is not interactive (interactiveShape: false) covers nothing, however opaque; neither does a cluster bubble. Hover is unchanged: every layer under the pointer still emitsgeoleaf:feature:hover, with thezIndexthe tooltip ranks by. - A point layer created empty draws its icons and its clusters once filled (
@geoleaf/core3.16.0). Two things were decided once, at a layer's creation, from its first batch — so a layer created with no feature and filled afterwards never got them, and a host had to create it with a first feature to work around both. Icons: a layer withshowIconsOnMapdraws the icon of the points that carry asymbolId, which only the first load injected, and the sprite's images were registered only when the layer was added. A collection written afterwards reached the map bare: discs, no icon — on a layer created empty, and equally on a layer loaded with its icons, which lost them at the first whole-collection write.GeoLeaf.GeoJSON.updateLayerDataandGeoLeaf.Layers.setDatanow give each point the icon its taxonomy resolves, as the first load does, and register the images it names. Clusters: a source is clustered or not for good, and the strategy answered "no" to a collection holding no point — which an empty one is. A layer that ships EMPTY is now clustered when it DECLARES a point geometry (geometryorgeometryType:point,multipoint) and the cluster configuration asks for it. What you may see: such a layer now shows icons and bubbles where it showed discs;properties.symbolIdis written into the features you pass toupdateLayerDataon an icon layer, as it already was into those of a first load. Not changed: a layer that ships empty and declares no geometry, or another one, stays unclustered — a clustered source draws points only, so guessing would cost the lines and polygons written to it later; data that shows features is still believed over the declaration. addFeatureandmergeFeaturesgive the features they add their icon (@geoleaf/core3.16.0). The unit mutations reach the engine as a diff, by another road than a whole-collection write, and that road named no icon either: on a layer withshowIconsOnMap, a feature added throughGeoLeaf.Layers.addFeatureorGeoLeaf.Layers.mergeFeaturesdrew a bare disc. Each added feature is now given the icon its taxonomy resolves, in the diff sent to the engine and in the store alike, and the images it names are registered. Only the features being added are resolved, never the whole layer. What you may see:properties.symbolIdis written into the features you pass to these two methods on an icon layer.
[3.15.0] - 2026-10-12
Added
The table says when a row is activated:
geoleaf:table:rowActivated(@geoleaf/core3.15.0,@geoleaf-plugins/table1.2.0). A tenth table event, emitted when the user double-clicks a row or presses Enter while the row has the focus — the table had no event for that gesture, so a host that wanted to open a record from a row had nothing to listen to. The name states an intention, not a gesture, and the table opens nothing itself. The payload is{ layerId, featureId, properties }:featureIdis the identifierselectionChangedlists inselectedIds(a string, synthetic —__gl_row_<n>— when the feature has none of its own), andpropertiesare the feature's complete properties, as a shallow copy holding no DOM node. Like the nine others it goes out ondocumentand on the map, and is typed inGeoLeafEventMap. The checkbox cell and the sort header never activate. Non-breaking, with three things an integrator can observe: every data row is now a Tab stop (tabindex="0"), with a focus ring; a double-click no longer changes the selection — it used to toggle it twice, which destroyed a multiple selection — and aselectionChangedsubscriber sees two emissions around it, the toggle then the restore; and the plugin's peer range on@geoleaf/coremoves from^3.0.0to^3.15.0, the first core whose event map declares the name.A basemap's relief can follow the tilt of the map:
terrain.activation: "pitch"(@geoleaf/core3.15.0). A terrain was put on in one way only: by the selection of its basemap, when the block declaresdefault3D— and the camera was then posted to the block'spitchandbearing, and eased back flat when the basemap was left. A map meant to open FLAT and still offer its relief could only be writtendefault3D: truewithpitch: 0, which keeps the terrain on at all times: every visitor downloads the elevation tiles, whether they ever tilt the map or not. Measured in a browser on such a basemap, a flat map at a country-wide zoom: ten elevation tiles requested as the basemap opens.With
"activation": "pitch"the terrain follows the camera. Flat, there is no terrain and no elevation tile is requested — the source is only declared the first time the terrain is put on. Tilted, the terrain is put on once the camera comes to rest atactivateFromPitchdegrees or more (12 by default), and removed once it comes to rest underdeactivateBelowPitch(half of it by default); between the two it stays as it was, so that a camera that stops around one threshold does not flip it at every movement.The decision is taken when a movement ends, never during it. While a terrain is on, the engine keeps a copy of the camera for as long as a movement lasts: a terrain removed then put back inside one gesture resumes from that stale copy, and one put on in the middle of an animation is given an elevation prepared by an earlier one. The relief therefore comes a moment after the gesture is released. Whatever tilts the camera counts — a guidance that follows a route included.
The camera is never moved in this mode: not when the terrain comes, and not when the basemap is left — it is the visitor who tilted the map, and a basemap switch has no business flattening their view.
pitch,bearinganddefault3D: truedescribe the activation by selection and have no role here: the schema REFUSES them next to"activation": "pitch", as it refuses the two thresholds without it, and the kernel names them in the log when the basemap is registered. A lower threshold that is not under the upper one — which a schema cannot express — is reported at start-up and replaced by its default.The terrain survives what the engine does to it: a style rebuilt by the engine resets the terrain without a word, and it is put back as soon as the style is in place; a basemap reached through a style change gets its relief back under a camera that stayed tilted, without the engine having to rebuild the style; two basemaps in this mode hand the relief over with no write when they read the same elevation source at the same
exaggeration. A profile that declares none of the new keys behaves as before, the posted camera included. The published schema and the profile types generated from it carry the three keys.A hillshade overlay can follow the relief:
hillshade.onlyWithTerrain(@geoleaf/core3.15.0). A hillshade overlay is always drawn, and reads an elevation source of its own: on a basemap whose relief follows the tilt, a flat map would still request elevation tiles, for the shading. With"onlyWithTerrain": truethe overlay is shown only while a terrain is on the map, and hidden the rest of the time — hidden, it requests none. A flat map then stays exactly what it is without either block; tilted, it gets its relief and its shading together. The overlay learns of the terrain from the engine's ownterrainevent, so it follows a terrain put on by the selection of its basemap just as well. Without the key nothing changes: shading a flat map is a use of its own. A basemap that declares the key without an enabledterrainnever shows its overlay, and is told so at start-up.A basemap can carry its relief shading as an overlay:
hillshade.overlay(@geoleaf/core3.15.0). Ahillshadeblock was read on a basemap oftype: "hillshade"only, where the shading is the basemap. On any other basemap it was ignored — although the schema accepts it on every entry — and a vector basemap withterrainshowed its slopes as flat colours, the ridge line alone readable. With"overlay": truethe block is drawn over the basemap, which stays what it is: vector, tiles, WMS or WMTS.json{ "basemaps": { "plan-relief": { "id": "plan-relief", "label": "Plan with relief", "type": "maplibre", "style": "https://tiles.openfreemap.org/styles/liberty", "terrain": { "enabled": true, "demUrl": "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png", "default3D": true, "pitch": 60 }, "hillshade": { "overlay": true, "beforeLayer": "waterway_tunnel", "demUrl": "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png", "exaggeration": 0.45 } } } }On an image basemap the shading sits right above the basemap's layer, under the data layers. On a vector basemap it is inserted before the style layer the new
hillshade.beforeLayernames — the first water or road layer, so that it covers the land fills and stays under water, roads and labels. Without that key, or when the style has no such layer (a warning is logged), it goes before the style's firstsymbollayer: the labels stay readable, water and roads are shaded too. The block's existing settings — colours,exaggeration, direction and anchor of the light — apply as they are.The overlay follows the basemap: put back after every style change, removed when the active basemap declares none, left alone when the next basemap asks for the same one. It reads an elevation source of its own, never the terrain's: MapLibre forces a doubled tile size on the source it drapes the terrain over, so a shading read from it is one zoom level coarser. Measured in a browser over a massif, with both on one basemap: 86 elevation tiles requested with two sources, 43 with a shared one. Elevation tiles are not part of the offline preparation.
Nothing changes for an existing profile:
type: "hillshade"behaves as before, and a block withoutoverlayis still ignored on the other types. Thetourismdemo profile gains a vector basemap, "Plan relief 3D", that shows the three together; its "Satellite" basemap takes the relief when the map is tilted (terrain.activation: "pitch").A layer can change rank in place:
IMapAdapter.setLayerZIndex(@geoleaf/core3.15.0). A layer's rank was given once, when it was added. Nothing in the adapter moved a layer already on the map, so putting one above another meant removing it and adding it again — its source rebuilt, its data re-tiled.adapter.setLayerZIndex?.(id, zIndex)moves a GeoJSON layer to where an insertion at that rank would put it now: under the lowest layer of strictly higher rank, above those already holding the rank. Its sub-layers move together and in order, and the source, its data, the filters, the zoom range, the visibility and the listeners are kept; the order obtained crosses a basemap switch. It answers a boolean, andfalsemeans nothing moved: an unknown layer, a rank that is not a finite number, a vector-tile layer, a cluster group, a layer none of whose sub-layers is in the style, or a layer to go under that is not in the style — MapLibre takes a layer out of the drawing order before it notices that one is missing, so the adapter looks first and never asks. The caller then removes the layer and adds it again at the rank it wants. Asking for the rank a layer already holds answerstrueand moves nothing: the layer is not lifted above the layers sharing that rank. What does not follow: thezIndexa layer declares is not rewritten — the layer manager still orders its rows by it, and the hover tooltip still arbitrates two overlapping layers by it —, a layer's labels do not move with it, and no event is emitted. The member is optional: writtenadapter.setLayerZIndex?.(id, zIndex) === true, a call on an older core reads as a refusal.@geoleaf/host-runtimenames it on the adapter a plugin reaches (HostMapAdapter).GeoLeaf.Isochroneis declared, andmodules.isochroneis named in the profile schema (@geoleaf/core3.15.0). The namespace@geoleaf-plugins/isochronemounts is an optional member ofGeoLeafGlobal, typed by the plugin throughGeoLeafPluginApislike every other plugin namespace; and the profile schema names the plugin's block, open, so a profile that declares it validates. The core reads neither: both exist for the plugin, which lives in its own package and is not published yet.A theme can frame the map:
themes[].view(@geoleaf/core3.15.0). A theme said what the map shows, never where: the extent was the profile's —map.bounds, fitted when the map is created and again at the reveal — and switching theme never moved the camera. A theme may now declareview, written like the profile'smapblock: an extent to fit (bounds,[[south, west], [north, east]]), or a point with its zoom (centeras[lat, lng], andzoom). The themes schema admits the key, on primary and secondary themes alike. At start-up, the map opens on the view of the theme the profile starts on, when that theme declares one; otherwise on the profile extent, as before. A shared address that carries a view still wins. On a switch, activating a theme that declares a view moves the camera there, without animation and before its layers load, so that a layer loaded by extent asks for the area the theme frames; a theme that declares none leaves the camera where it is. What does not move: undermap.positionFixedthe navigation limit stays the profile extent, and the padding staysmap.padding; a theme view reaching outside that limit is named in the console at start-up.GeoLeaf.ThemeSelector.setTheme(id, { keepView: true })activates a theme without touching the camera — which is what the permalink now does for the theme of a shared address, whose own view must hold. A profile that declares no view behaves exactly as before. ⚠️ Themes read from a legacy root-levelthemes.json, fetched once the map exists, frame on activation only.--gl-font-family: the application's font stack, as a token (@geoleaf/core3.15.0,offline-ui1.7.0). The stack was set on the application's root,.gl-page. A node put ondocument.bodysits outside that root and inherited the host page's default instead — a serif, in a document that declares none: measured, the share dialog and every toast were drawn in Times New Roman. The stack is now declared on:root(system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif) and read by.gl-page, the share dialog, the toasts, the two install banners, the filter panel when it mounts onbody, and the offline plugin's update banner. A host may override it to give those surfaces its own font; the rest of the interface follows, since.gl-pagereads the same token.GeoLeaf.Utilsnames five more members, andGeoLeaf.Core.getMap()says what it returns (@geoleaf/core3.15.0).Utils.applyCssText,validateUrl,getDistance,FormattersandDOMSecurityare mounted by the kernel and were reachable asunknown;Core.getMap()andCore.getAdapter()returnedunknownand now returnIMapAdapter | null. Types only. ⚠️Utils.validateUrlreturns the URL resolved, ornull— not a boolean; andUtils.getDistanceanswers in kilometres.GeoLeaf.Storage.DBnames eleven more relays, typed from the engine (@geoleaf/core3.15.0):getLayer,cacheLayer,removeLayer,getLayersByProfile,getStorageStats,storeImageLocally,getLocalImage,getPendingImages,updateImageUploadStatus,bindLocalImageandcleanUploadedImages. They were reachable through the index signature ofStorage.DB, asunknown— so a caller declared its own view of them, and that view was checked against nothing: a status passed as a string where the engine reads an object went unreported once. Types only; nothing changes at run time.getLayernow returns the cached record instead ofunknown, andcacheLayerdeclares thenullprofile it has always been given by the theme cache.--gl-color-accent-text: the accent as a text colour (@geoleaf/core3.15.0). The accent is a fill. Written as a text or icon colour on a surface, it fell under WCAG AA in seven theme contexts out of nine — 1.46:1 in the light theme, 2.80:1 with no theme class, 2.86:1 under the green palette. Each theme and each palette now declares the accent's text counterpart, set to reach 4.5:1 on its surface, its muted surface and the accent's own soft veil:#b93b0bby default and in the light theme,#fb923cin the dark theme,#1d4ed8and#38bdf8under the blue palette,#166534and#4ade80under the green one. The forty-eight declarations that wrote the accent as a text colour read it — the active entry of the mobile toolbar, of the right panel and of the theme selector, links and counters of the feature panel, and their counterparts ineditor,measure,navigation,offline-ui,print,routing,connectorandfield-renderer, which fall back on--gl-color-accentunder an older core.--gl-color-accentitself is unchanged: no accent background moves. What you may see: those texts and icons turn a darker orange in the light theme and a lighter one in the dark theme. A host that overrides--gl-color-accentshould set--gl-color-accent-textwith it, or the text keeps the default orange.GeoLeaf.<Plugin>is typed for whoever installs the plugin (@geoleaf/core3.15.0 and the fourteen plugins that mount a namespace:cog1.0.6,connector1.4.0,editor1.6.0,file-import1.0.7,flatgeobuf1.0.5,geocoding1.1.4,measure1.0.10,navigation1.0.3,position-share1.0.3,print1.3.5,realtime-layer1.0.8,routing1.0.3,table1.2.0,websocket1.0.3). The core declaredGeoLeaf.Table,GeoLeaf.Editorand the twelve others asunknown: reachable, never callable from TypeScript without a cast. Each plugin's published declarations now add its API to theGeoLeafPluginApisregistry those namespaces read, and export it as a named type (TableApi,EditorApi,CogApi,WsApi…). Importing the package is enough; a namespace whose plugin is not installed staysunknown. A member the plugin does not have is now a compile error, where it was silentlyunknown.PLUGIN_DEVELOPMENT_GUIDE.mdgives the recipe for a plugin written outside this repository.A
rangefilter can offer its upper bound (@geoleaf/core3.15.0). The filter engine has always appliedmin ≤ value ≤ max; the panel offered one slider, the lower bound, and an upper bound could only come from a host'sGeoLeaf.Filter.applyFilter(). A field ofmodules.filter.fieldsnow declares it —"bounds": "both"— and gets two sliders that cannot cross, each named for a screen reader in the six languages. One slider stays the default: a panel that declares nothing is unchanged.An entry set aside says why, and its exits are announced (
@geoleaf/core3.15.0).GeoLeaf.Storage.DB.listPendingEdits()now returnsquarantine— the motive — andquarantineStatus— the HTTP status of the refusal, when a server answer caused it — on an entry instate: "quarantined"; both are absent from an entry the queue still holds. The motive was only reachable through an internal member, so a host interface could not say why a capture was stuck. And a new event,geoleaf:offline:quarantine-exited, is dispatched once per gesture byrequeueQuarantined(),requeueAll()anddiscardQuarantined(), withexit("requeued"or"discarded") and theentriesthat left. A gesture that moves nothing — a refusal, an empty batch — announces nothing.GeoLeaf.Storage.prefetchVerdict(url)— will the offline preparation download this, and if not, why (@geoleaf/core3.15.0). The origin rule ofmodules.offline.dataOriginswas only applied during the download, and its refusal was a console warning: an interface offering a basemap could not know beforehand that it would be skipped. The member returnsallowed, theoriginjudged and, on a refusal, areason—undeclared,notPrefetchable,unparsable, orengineUnavailablewhen no offline engine is wired. A tile URL template is judged on the concrete tile the preparation would request. And what a download left out is now kept:preparation.refusedOrigins, in the result ofcacheProfile()and in the cache manifest, names each refused source with its origin (TilePreparationTrace). That result is typed:GeoLeaf.Storage.CacheManager.cacheProfile(profileId, options?)resolves with aCacheProfileResult, exported withCacheProfileOptionsby the sync contract (@geoleaf/core/contracts/sync.contract.js) — the ambient type gaveunknownand took no options.GeoLeaf.plugins.registerLazyForAction()is typed, and its descriptor takes what the toolbars read (@geoleaf/core3.15.0). The member fell into the untyped tail ofGeoLeaf.plugins, although the API reference and a registry warning name it as the way to declare a button that must exist before its bundle. Its descriptor refused two fields the reference application declares and both toolbars read:legacyProfileKey, the visibility key read whenprofileKeyis absent, andvarianton the desktop slot. Both are typed now, andlegacyProfileKeyjoins the publishedIModuleUISlotcontract, where five plugins already declared it. Nothing changes at run time.The package's front door is typed (
@geoleaf/core3.15.0).import GeoLeaf from "@geoleaf/core"— what every guide teaches — handed over a value typedunknown: the import compiled, its first dereference did not. The default export is now the namespace under its ambient type.ensureGeoLeaf()is exported, for a module that mounts a member on the namespace; it existed and no entry of the package exported it. And the plugin namespaces the core declares (GeoLeaf.Table,GeoLeaf.Editor…) read a new augmentable interface,GeoLeafPluginApis: a plugin's own declarations add its member there, and the namespace is typed for whoever installs that plugin. A namespace nobody registered staysunknown, as before. Nothing changes at run time.map.targetandmap.idare declared by the profile schema (@geoleaf/core3.15.0).GeoLeaf.boot()reads both to find the element that hosts the map, and the guides teach them — but themapblock is closed, so a profile that declared either one failedvalidate:profiles. They are now properties of the schema:targetis the id of the element, without#, andidis its alias, read whentargetis absent. Absent both, the boot still looks forgeoleaf-map. Nothing changes at run time.A profile may declare a vector-tile layer
mixed(@geoleaf/core3.15.0). A tile source-layer can hold points, lines and polygons at once; declaredgeometry: "mixed", the layer builds a fill, a line and a circle sub-layer over it, each confined to what it draws. The value was reachable throughGeoLeaf.Layers.create()only: both enumerations of the layer schema refused it, sovalidate:profilesfailed on a layer the engine renders. It is now admitted — bygeometryand by its aliasgeometryType— on a layer that names its tiles (data.vectorTiles.tilesUrl), and still refused on any other, where it names no geometry. The API reference now lists the spellingsLayers.create()reads beyond the profile vocabulary. Nothing changes at run time.performance.themeBatchSizeis declared by the profile schema (@geoleaf/core3.15.0). The theme applier reads it from the active profile — how many layers of a theme are revealed at a time,6when absent — and the profile root, which is closed, did not declareperformance: a profile setting the one key the code reads there failedvalidate:profiles. The block is declared with that key alone, an integer of at least 1. Nothing changes at run time.GeoLeaf.Editor.cancelDrawing()— abandon a shape being traced (@geoleaf-plugins/editor1.6.0). A shape still in progress — vertices laid, not yet closed — had no public way out:setActiveTool(null)clears the menu's highlight and leaves the drawing engine armed, so the next click added a vertex, anddiscardDraft()only knows a finished shape. The new member disarms the tool and removes the vertices already laid, as closing the menu does, and returns whether a tool was armed. A finished shape waiting for its form is not touched.A refused sign-in says why (
@geoleaf-plugins/connector1.4.0, typed by@geoleaf/core3.15.0). The connector's login window read nothing of a refusal but its status: a403showed "Authentication failed (403)", and a server's motive — a second factor required, an account locked — never reached the user or the application. A non-2xx answer declaredapplication/problem+json(RFC 9457) is now read: the window shows itsdetail, or itstitle, under its own message — as text, cut at 300 characters, in the server's language. And a new event,geoleaf:connector:login-failed, carriesstatusandproblem(type,title,detail, and the whole parsedbody) to the host, for every sign-in that is refused or cannot conclude; it is distinct from:auth-error, which says an established session ended. Optional for the server: a refusal without that body reads as before. The server contract (§3.2) says so.The offline window acts on the captures set aside (
@geoleaf-plugins/offline-ui1.7.0). Its sync block said how many captures the drain had set aside and offered nothing to do about them — the two exits were reachable from the editor's window only, so an application without the editor showed a red count and no gesture. Under that block, each motive now has its row: how many captures, why, a Retry where the core says a retry can work (GeoLeaf.Storage.requeueableReasons()), and a Discard, confirmed, which says how many captures go. What a retry left aside because its cause is still there is said, not hidden behind the success. Public facade only; the block repaints from what the core announces.« Export the log », from the offline window (
@geoleaf-plugins/offline-ui1.7.0). The journal the core keeps — bounded, redacted — was offered to the user by the boot failure screen alone; once the application had started it took a console to read it. The offline window now ends with a button that downloadsGeoLeaf.Log.exportDiagnostic()as a JSON file. No profile key, nothing added to the boot.GeoLeaf.PWA.isUpdateWaiting(),GeoLeaf.PWA.applyUpdate()andgeoleaf:sw:update-waiting(@geoleaf/core3.15.0). The application's updates now wait for the user (see Changed): the event says a new version is installed and waits, the first member reads the same fact at any time, and the second applies it — the new service worker takes over, then the page reloads.geoleaf:sw:update-waitingis typed inGeoLeafEventMap;geoleaf:sw:updatedkeeps its meaning, a worker activated.« A new version is ready », a banner in the offline plugin (
@geoleaf-plugins/offline-ui1.7.0). It appears when an update waits — at the top of the page, without taking the focus — with Reload, which applies it, and Later, which leaves it waiting until the next visit or the next update. No profile key. An application that does not embed the plugin offers the gesture itself, with the two members above.
Changed
A focus ring, and a border that is the only sign of a state, are drawn to be seen (
@geoleaf/core3.15.0,offline-ui,routing,table,field-renderer). The accent is a fill: as a ring on the light theme's surface it reaches 1.46:1, where a graphical sign of a state is held to 3:1. Eleven outlines wrote it — the tabs of the right panel, the popup's action, a toast, the language, palette and profile selectors, the share link, the boot failure button, the routing panel's buttons, the gallery's drop target. They read--gl-color-focus-ring, the token each theme already sets and that the base-layer selector,printandoffline-uialready read. Nine form controls remove their outline and mark focus by their border alone — the style selects of the layer manager and of the offline window, the filter panel's controls, the table's search and select, the routing address field, the form inputs, tags and image drop zone offield-renderer: that border reads the same token. Three borders that are the only sign of a state outside focus — the active gallery thumbnail, the active GeoJSON badge, the list row under a drag — read--gl-color-accent-text. What you may see: those focus rings and borders turn from a pale orange to the theme's focus blue (#2684ffby default). Accent backgrounds, hover borders and borders that come with a fill do not move. Under the green palette with no theme class, the ring itself went from#16a34a(2.86:1 on that surface) to#15803d. A host that wants its own ring sets--gl-color-focus-ring.The boot frames the profile BEFORE it announces the application ready (
@geoleaf/core3.15.0). At the reveal, the boot emittedgeoleaf:map:readythengeoleaf:app:ready, and re-fitted the profile bounds in a 120 ms timer — so a host setting its view atgeoleaf:app:readysaw the view it asked for, then saw it jump back. Measured: a zoom of 9.5 set at the announcement was read back at 4.23. The order is now: the engine is given its container's size, the bounds are fitted — unless a permalink carries a view —, and only then the two events leave. Nothing of the boot moves the camera after the announcement. What you may have to change: code that waited some delay aftergeoleaf:app:readybefore touching the camera no longer needs to.A layer's entities are pulled only from a declared origin, in a profile that declares (
@geoleaf/core3.15.0). The pull ofoffline.source.urlapplied the origin rule to no source: a profile listing itsmodules.offline.dataOriginsstill pulled from an origin it had not listed. From the first declaration on, a source is pulled when its origin is declared or is the page's own; otherwisepullLayer()sends nothing and reportsrefused: "originUndeclared", and the offline preparation records it with the layer. Neithercacheablenorprefetchis read — the layer naming its source is the request — so anauthenticatedorigin is pulled like any other. A profile that declares no origin is unchanged. ⚠️ A profile that declares origins and pulls a layer from a third-party origin it left out must add that origin todataOrigins.The same rule now reaches what a profile names one by one, and the pages a source renders (
@geoleaf/core3.15.0). Three resources of the offline preparation took their URL as written, third parties included: a layer's directurl, a field'sfetchOptionslist and the icon sprite (icons.spriteUrl). And a pull judgedoffline.source.urlonce — the pages after the first were asked at the URL the server rendered in itsnextlink, or at the cursorcursorPathnames, whatever its origin. In a profile that declaresmodules.offline.dataOrigins, all of them now obey the entities' rule: a declared origin or the page's own, nothing else. A resource left out is named in the log and inpreparation.refusedOrigins. A pull whose next page names an undeclared origin stops there and reportscursorRefused: true, a new member ofpullLayer()'s report: the run is partial, removes nothing from the device and leaves no delta mark.streamOgcApiFeatures()takes the judge as an optional sixth argument,followCursor, and reports a refusal incursorRefused; the online display passes none and is unchanged. A profile that declares no origin is unchanged. ⚠️ A profile that declares origins and takes a sprite, an option list or a GeoJSON file from a third party it left out must add that origin todataOrigins.A capture that needs no session no longer waits behind one that does (
@geoleaf/core3.15.0). The drain stopped at the first capture of awrite.auth: "bearer"layer met with no session open, and everything queued behind it waited too — a capture of a"none"layer included. The pass now walks past the held captures and sends the rest. From the first one held, everybearercapture of the pass waits, even if the session opens meanwhile: the writes of one entity keep their order.The offline preparation presents what the display presents — vector tiles and glyphs carry the connector's token (
@geoleaf-plugins/connector1.4.0). The connector'sfetchpatch left.mvtand.pbfURLs out, "because the MapLibre bridge handles them". It does, for the map, whose tiles load in a worker the patch never sees; the exclusion only ever applied to the offline preparation, which downloads tiles and glyphs on the main thread — an authenticated vector source was prepared without its token, and refused. Every request underbaseUrlnow carries the token, whatever its format. The server contract also says what was true and unwritten: the preparation sets nocredentialsmode, so a request to the page's own origin carries that origin's cookies, and no other request does.The defaults of
geocodingand of the export keys oftableare written once, in each plugin's table (@geoleaf-plugins/geocoding1.1.4,@geoleaf-plugins/table1.2.0).provider,debounceMs,minChars,resultLimit,positionandflyToZoomon one side,exportFormats,csvSeparatorandcsvIncludeGeometryon the other, were applied where the value was read —resultLimitandflyToZoomat two places each. Nothing changes for a profile: the values are the same, and a key writtennullstill keeps its default.The server recipes shipped with a deployable folder pin by folder, not by extension (
nginx.conf.example,.htaccessand the server contract page ofdeploy-coreanddeploy-full). Both recipes sentmax-age=31536000, immutableto every.mjs,.js,.css,.pngand.svg, wherever the file lived. Onlydist/earns it: each of its files is named after its content or requested with its token. Three families kept a stable name under that rule — the map engine (vendor/), the application's icons (icons/) and the sprite sheet of each profile — so a returning visitor kept the old engine for up to a year after an upgrade, and a sprite you had just rewritten did not reach them, whilecurlreturned the new file. The recipes now sendno-cacheforvendor/(revalidated at each visit, a304when unchanged), a day foricons/, and an hour for everything underprofiles/but its root file — what the table of the server contract page already promised for profile data, and what neither recipe delivered for a.svg, a.qmlor a.fgb. The Apache recipe now matches on the request line (<If "%{THE_REQUEST} …">, Apache 2.4): it rewrites a request to its.gztwin, and a<FilesMatch>judges the file served. It also types its precompressed files withForceType: measured on an Apache 2.4, the formerAddType text/javascript .mjs.gz .js.gztyped nothing, and every.gztwin left asapplication/x-gzip— a type a browser refuses for a module script. What to do: if your server configuration was copied from an earlier recipe, replace its per-extension cache block with the folder blocks of the new one. Visitors who already received a pinned file keep it until it expires — nothing sent now can shorten a header already stored.What a deployable folder pins for a year is requested by its content (
deploy-core,deploy-full, and theirnginx.conf.example). Two defects of the folder's cache, fixed together. The nginx recipe did not deliver theno-cacheits own table promises toinit.jsandsw-core.js: its block by extension — a year,immutable— was written above the block that names them, and nginx stops at the first regular expression that matches. The entry points now come first, and the recipe says that block must stay there. If you copied the earlier recipe, move the block of the entry points to the top: a browser that cachedinit.jsfor a year does not see a new version. The Apache recipe was right — the last block wins there. And three files were pinned for a year under a name that did not change with their content: the GeoJSON worker, the stylesheet, and the pluginsinit.jsloads on demand. Each is now requested with a token computed from its bytes, and the worker joins the service worker's pre-cache.A new version no longer takes over a page that is in use (
@geoleaf/core3.15.0). The service worker skipped the wait at every install: a deployment replaced the worker under whoever had the application open, and its activation purged the caches of the version that page was running — the lazy chunks it had not loaded yet included, offline or not. It now skips the wait at a first install only. At an update the new worker installs, then waits untilGeoLeaf.PWA.applyUpdate()is called or every tab of the application is closed — the browser's own rule. What changes for a deployment: users on@geoleaf-plugins/offline-uiare asked, by a banner; an application without it receives a new version at its next cold start instead of mid-session, unless it offers the gesture (PWA — When a new version is deployed). A page already open on the previous worker when this version is deployed shows no banner — its code predates it — and gets the new version once closed and reopened.A permalink writes the filter one parameter per field (
@geoleaf/core3.15.0). The filter was ranged by kind, in four slots —gl_filter,gl_cats,gl_tags,gl_rating: two fields of the same kind shared a slot and were restored with the same value, a range kept its lower bound only, and a taxonomy field's sub-categories lost the category they were checked under. Each constrained field now has its own parameter,gl_f.<id>, named by its id inmodules.filter.fields—100..500for a range,a,b,cfor a list, the sub-categories undergl_f.<id>.sub— andGeoLeaf.Permalink.getState()exposes them asfieldFilters. Links already shared keep working: the four slots are still read, and restored as they were. The application no longer writes them, so the filter of a link copied from 3.15.0 is not read by an earlier version — its view still is.permalink.fieldsgates as before, its four filter names now reading « the fields of that kind ».booleanandproximityfields are still not carried.write.authis read (@geoleaf/core3.15.0,@geoleaf-plugins/connector1.4.0). The key was declarative: the schema and the published type acceptedbearerandnone, and no code read either — a layer could say its endpoint takes no token and still have the session's token attached, or say it requires one and have its captures sent without. Both values now have an effect.none: every request of the layer's writes carries the declaration (thegeoleafWriteAuthmember of itsinit), and the connector attaches no token to it, even under itsbaseUrl.bearer: while the session reader (GeoLeaf.Sync.registerSessionReader) says no session is open, the capture is not sent — it stays queued, untouched, and the pass counts it in a new member,heldForSession, ofpushOutbox()'s report and ofgeoleaf:offline:outbox-drained. The pass does not stop there: a capture of a layer that needs no session, queued behind it, leaves. Unlike anauthRequiredhalt, a held capture does not pause the automatic triggers: it leaves on its own once the session is back. A layer that declares nothing behaves as before, and so does abearerlayer on a page where nobody registered a session reader.geoleaf:layer:updatedis announced by every writer of a whole collection (@geoleaf/core3.15.0). The event left from the calls ofGeoLeaf.Layersalone. A real-time layer's ticks, an OGC layer's auto-refresh and a host's ownGeoLeaf.GeoJSON.updateLayerData()rewrite the same store around it, unannounced: an open table kept its rows, and an active filter did not re-judge what arrived — a newcomer that passed stayed hidden — until the user's next gesture. They are announced now, once per write, where they all converge; the calls ofGeoLeaf.Layersstill announce once each. An OGC auto-refresh also resets the layer's incremental-update verdict, which it left stale. A plugin that feeds its source without writing the store is still not announced.A requeue out of quarantine asks for a pass (
@geoleaf/core3.15.0).requeueQuarantined()andrequeueAll()used to put entries back in the queue and stop there: on a calm queue nothing left before the next trigger, and the documentation said to callpushOutbox()afterwards. The entries are now sent at once, a dead session included — requeueingauthRequiredis saying the session is back. ApushOutbox()left after a requeue is harmless: the two passes are serialised.A declared origin is judged on its declaration by the preparation of basemaps and tiled layers — the page's own included (
@geoleaf/core3.15.0). The preparation of basemaps and tiled layers admitted the page's own origin before readingmodules.offline.dataOrigins: an own origin declaredauthenticated, orcacheablewithoutprefetch, was downloaded all the same. The declarations are now read first. Left undeclared, the page's own origin is still prepared, so a portable profile changes nothing. Declared, it is prepared only withcacheable: trueandprefetch: true— and since roles are not consulted there, declaring it for a same-origin data API also decides for the tiles it serves. The contract said both things in two places (DataOriginDeclaration); it now says this one.Storage.DBrelays are checked against the module they forward to (@geoleaf/core3.15.0). Every sub-module of the IndexedDB engine was typed with one index signature that accepted any argument for any method, so a relay could drift from its module without an error — that is how a string once reached a parameter read as an object. The relays are now typed from the module registry. Two of them tighten as a result:Storage.DB.cacheLayer()takes a string, anArrayBufferor an object, andStorage.DB.storeImageLocally()takes the image record — both wereunknown. Nothing changes at run time for a JavaScript caller.The style loader no longer judges label keys the renderer does not read (
@geoleaf/core3.15.0). Three checks ran at load on keys with no effect:label.font.weighthad to be an integer from 0 to 100,label.buffer.opacitya number from 0 to 1, andlabel.backgroundan object. A failed check refused the whole style — so a style written on the CSS scale ("weight": 700), for a key that changed nothing, did not load. The three checks are gone; the keys are ignored.font.sizePt,color,opacity,buffer.color,buffer.sizePxandoffset.*are judged as before. In the types,backgroundleavesLabelUserConfigand the resolved label style, and the font and buffer sub-objects name only what is read; they keep their index signature, so an object that still carries the old keys compiles.BREAKING — the connector's HTTPS rule judges where the credential goes, and covers the login endpoint (
@geoleaf-plugins/connector1.4.0).http://was tolerated whenever the PAGE was served from a development host, so a page onlocalhostcould send its token in cleartext to any host, remote ones included; andauth.endpoint, which receives the password, was only checked for being non-empty. The rule now reads the URL itself:baseUrl,auth.endpoint,auth.signupUrlandauth.forgotPasswordUrlmust behttps://, orhttp://toward a development host —localhost,*.localhost, the loopback range, a name under.test. What breaks: a local page whose connector targets a LAN address or a remote host overhttp://no longer starts —configure()throws aConfigError. To migrate, serve that backend over HTTPS, or reach it through a name under.test. What loosens: a URL toward a development host is tolerated whatever page asks. A relative URL targets the page's own host, as before.The editor carries
@geoleaf/host-runtimeonce (@geoleaf-plugins/editor1.6.0). Its bundle held two copies of the dialog and modal-shell modules: its own, and the one inlined in the built@geoleaf/field-rendererit bundles. The form library is now bundled from its sources, so both sides resolve to the same modules — the entry chunk loses 5.4 KB (0.9 KB gzipped), and a stylesheet adopted "once per key" is adopted once. Nothing changes in the editor's API, nor for an integrator using@geoleaf/field-rendereron its own.
Removed
A deliverable no longer carries a second copy of each profile sprite, nor the source logo (deployable application). The build flattened every profile's sprite into
icons/, next to the copy underprofiles/<profile>/icons/— byte-identical, compressed twice, and referenced by nothing: a profile's taxonomy names the copy inside the profile (icons.spriteUrl), and that is the one the core fetches.icons/logo.png, the source the PWA icons are generated from, was shipped and asked for by no page, manifest or worker. ⚠️ A host page that readsicons/sprite_<profile>.svgat the root of a deliverable must readprofiles/<profile>/icons/sprite_<profile>.svg.The Profiles Guide and the Configuration Guide are gone (
@geoleaf/core3.15.0). Both taught the layout of a profile from before its files were reorganised, and were frozen with thirty examples that the schemas refuse. Three pages replace them, each with something that keeps it true: the Quickstart Tutorial, replayed from its own blocks; the Profile JSON Reference, a reading guide; and the parameter reference generated from the schemas. Links to the two pages are rewritten across the documentation;/PROFILES_GUIDEand/CONFIGURATION_GUIDEon the documentation site no longer resolve.BREAKING (types only) —
GeoLeaf.Storageno longer accepts any name as a member (@geoleaf/core3.15.0). The ambient type ofGeoLeaf.Storageended with an index signature,[key: string]: unknown: any name was a member, typedunknown. A misspelled call —pushOutBox()forpushOutbox()— compiled down to a diagnostic that says nothing about the name, and no gate could tell a real member from an invented one. Every member of the façade is now named, typed from the façade itself, and the index signature is gone: a name the façade does not carry is a compile error. What breaks: TypeScript code reading an undeclared member ofGeoLeaf.Storagethrough the ambient type — an underscore-prefixed internal, or a property an application set on the façade itself. Nothing changes at run time, and a JavaScript caller is unaffected.GeoLeaf.Storage.DBkeeps its own index signature.⚠️ Versioning exception. This removal was not announced in an earlier published version, as the versioning policy asks. An index signature is not a symbol an editor can strike through, and no consumer follows a semver range today. The exception is recorded here and in the policy; it is not a precedent.
BREAKING (profile validation only) — six label keys that nothing rendered leave the schemas (
@geoleaf/core3.15.0).font.family,font.weight,font.bold,font.italic,buffer.opacityandbuffer.noFillwere accepted in a style file'slabelobject and in a layer'slabelsblock, and no renderer read them: the font stack of a label comes from the map style the engine has loaded, and a text halo has a colour and a width, nothing else. A profile could setbold: trueand wait for a bolder text that never came. Both schemas now declare only what is drawn —font.sizePt,color,opacity,buffer.enabled,buffer.color,buffer.sizePx,offset.*. What breaks: the published schemas (@geoleaf/core/schemas/*.schema.json) and the types generated from them refuse a style or a layer that still declares one of the six — so does a validator plugged on them, and so does a typed literal that carries one. What does not: at run time such a key is still ignored, and the rendering of a style that carried it is unchanged. To migrate, delete the six keys.⚠️ Versioning exception. This removal was not announced in an earlier published version, as the versioning policy asks. A JSON key has no
@deprecatedtag to carry, the labels reference already said of each of them that it was not rendered, and no consumer follows a semver range today. The exception is recorded here and in the policy; it is not a precedent.
Fixed
map.initialMaxZoomcaps the initial framing (@geoleaf/core3.15.0). The key was documented as the ceiling that keeps a small extent from opening at street level, and it capped nothing: it only gave the zoom the map is created at, which the framing onmap.boundsreplaced at once. A profile framing a single district started as close as the extent allowed, whatever the key said. A map that starts on an extent —map.bounds, or theview.boundsof the theme the profile starts on — now never frames closer than this zoom, at creation and again when the application is revealed. What you may see: a profile that declares the key on an extent smaller than that zoom shows now starts further out, at the zoom it asked for. A profile that does not declare it is unchanged — only a declared key caps — and so is one whose extent is larger than the ceiling. The ceiling does not apply to the user's zoom (map.maxZoom), nor to a later theme switch: a theme that must open at a given zoom declaresview.centerandview.zoom. The adapter contract gains the option that carries it:IMapAdapter.fitBounds(bounds, { maxZoom }).Scrolling a large table no longer keeps every row it scrolled past (
@geoleaf-plugins/table1.2.0). Past the virtual threshold the table re-creates its window of rows on every scroll event, and each row came with a click listener and a checkbox listener whose teardowns were only run at the next render — so every row ever scrolled past stayed in memory, detached, until the layer, the sort or the search changed. Measured in Chromium on a 30 000-row layer, after 2 000 scroll events: 98 122 rows alive for 52 in the document, and a heap grown from 31 to 58 MB. Row selection now goes through two listeners on the table body, like row activation: nothing is attached while scrolling, and a row that leaves the window is released. No behaviour changes.A second relief basemap no longer reads the elevation of the first (
@geoleaf/core3.15.0). A terrain put on by the selection of its basemap (terrain.default3D) declared itsterrain-demsource only when the id was free. The source of the basemap just left is still there at that moment — its removal waits for the next frame, and is then given up because the incoming basemap claimed it: a second basemap with anotherterrain.demUrldraped its relief over the elevation of the first, with nothing in the log. The source in place is now compared with the one the basemap asks for — kept when it is the very same, replaced otherwise. When a layer still reads a source of another elevation, the engine refuses to let it go: the terrain is not put on, the camera is not posted, and a warning says so. A removal the engine refuses when a basemap is left is logged as such; it used to be logged as done.A relief put on by the selection of its basemap comes back on a style the engine rebuilt (
@geoleaf/core3.15.0). The engine drops the terrain of a map whose style is replaced by one that does not declare it — measured in a browser: the terrain and its source gone, the basemap still the active one. Nothing put it back before the next basemap switch, andisTerrainActive()went on answeringtrue. The terrain now listens tostyle.loadand puts its source and itself back, the camera left where it is; a style replayed as it was, or a restored WebGL context, keeps its terrain and is not written again.isTerrainActive()andgetActiveTerrainBasemapKey()read the map instead of a flag.refreshBasemapno longer posts the camera of a relief basemap again (@geoleaf/core3.15.0). Re-applying the active basemap went through its whole activation,jumpToincluded. Measured in a browser, a camera the visitor had turned to 20° and 90° came back to the profile's 60° and 0° when the basemap was refreshed — which the editor does as it starts, when the basemap's source is missing. A basemap applied again on the same map has its source and its terrain checked, written only if they went missing; the camera is posted by the SELECTION of a basemap, and by nothing else.Unmounting an application no longer leaves its relief behind (
@geoleaf/core3.15.0). The basemap registry forgets its map when an application is unmounted; the terrain manager kept a state of its own — a relief is on, and for which basemap — that nothing reset. Unmounted while a basemap withterrain.default3Dwas active, the application left that state standing, and the next map inherited a relief it never had: the first basemap applied to it "left" that relief, and its camera was eased topitch: 0, bearing: 0— a view the application had just opened, flattened and un-rotated, with nothing logged. The state now goes with the map, and nothing is written on the map that is being torn down. Reachable only throughGeoLeaf.mount()and its teardown.Switching between two vector basemaps that share a style completes (
@geoleaf/core3.15.0). Two basemaps declaring the samestyleand the same credit — "Plan" and "Plan + relief", the second carrying aterrainblock — are the natural way to offer a map with and without relief. The switch asked the engine for the style again; the diff between a style and itself holds no operation, and MapLibre then fires nostyle.load, which is what finishes a style change here. Measured in a browser: the selector showed the second basemap, the map kept no relief, andgeoleaf:basemap:changewas not emitted. Some styles hid it: the filters patched on OpenFreeMap's "liberty" keep its diff from ever being empty. Such a switch now keeps the style the map holds and applies only what differs between the two basemaps — it is immediate, and the style is not downloaded again. Nostyle.loadoccurs on it: the event to listen to for a basemap change isgeoleaf:basemap:change.refreshBasemapno longer applies a WMTS basemap without its tiles (@geoleaf/core3.15.0). A WMTS basemap has no tile URL of its own: activating it resolves one from the service's GetCapabilities.GeoLeaf.Baselayers.refreshBasemap()— which a plugin calls when it finds the basemap source gone — re-applied the registered definition as it stood, with no tile. Called while the resolution was in flight, it took the slot the resolution was about to fill, whose own source was then refused on the identifier: the basemap stayed empty. Called after it, it put back a source that requests nothing. It now leaves a resolution in flight alone, and resolves the service again when the source is gone — so a WMTS basemap comes back asynchronously, where the other raster types come back before the call returns. A rejected resolution no longer reads as one still in flight.A profile basemap of type
image,wmsorhillshadekeeps its configuration (@geoleaf/core3.15.0). The application rebuilds each basemap of a profile key by key before registering it, and it handed over theterrainandwmtsblocks only:imageSource,wmsandhillshadenever reached the registry. A basemap of one of these three types declared inbasemaps.jsonwas therefore registered without the block its type requires, while the same definition passed toGeoLeaf.Baselayers.registerBaseLayerkept it. The five blocks now travel as they are; a block the basemap does not declare still sets no key.A vector-tile layer takes its rank among the other layers (
@geoleaf/core3.15.0). It was inserted right under the point layers whatever itszIndex, while still being recorded under that rank. Measured in a browser, with layers of rank 10 and 50, then a tile layer of rank 30, then layers of rank 20 and 40: the style read 10, 40, 50, 20, tiles — the tile layer above everything, and the rank-20 layer, anchored on it, above the rank-50 one. It now reads 10, 20, tiles, 40, 50. ⚠️ This changes what a map with tile layers shows: a tile layer no longer sits on top of every GeoJSON layer, and one that declares nozIndexhas rank 0 — under all the others. Give it the rank it should have.Four more surfaces are drawn in the application's font (
@geoleaf/core3.15.0,print,connector). Each was opened in a browser and read. The buttons of the two install banners were drawn in Arial, next to a banner in the application's font: a button does not inherit its font. The hint of the print extent selector took the map engine's font, the selector living inside the map container. The close button of the print window, and the fields and buttons of the sign-in window, were in the browser's own. All read the application's font now.Both server recipes type a layer's data (deployable application). Served by Apache behind the shipped
.htaccess, a.geojsonleft with noContent-Typewhile its precompressed twin left asapplication/json— two answers for one file, depending on what the client accepts; nginx sent it asapplication/octet-stream. Measured by serving a deliverable behind each recipe in a real server. Both recipes now declareapplication/jsonfor the extension. An integrator who copied a recipe before this version adds one line:AddType application/json .geojsonfor Apache,application/json geojson;in thetypesblock for nginx.The table panel is drawn in the application's font (
table). The panel sits ondocument.body, outside the application's root, and set no font: measured, its buttons, its search field and its selects were drawn in Arial, and its text in the host page's default — a serif, in a document that declares none. It reads--gl-font-family, and its form controls inherit it.Font stacks written by hand read
--gl-font-family(@geoleaf/core3.15.0,print,connector, and the modal shell the plugins share). The layer manager, the base-layer selector, the coordinates display, the popup, the lightbox and the branding block each wrote a stack of their own — two of them withRobotoandHelvetica Neue— and so did the print window, the login window and the shared modal shell: a host overriding the token changed the rest of the interface and not these. The offline badge reads it too.On a phone, what the network state shows stays in view (
@geoleaf/core3.15.0,offline-ui1.7.0). Three surfaces, measured at 375 and 390 px. The offline badge the network detector puts on the map sat on the row the theme selector takes below 768 px, half hidden for as long as the network was down: it now sits below that row. The sync strip scrolls sideways without a visible scrollbar and carried its action and its close button off-screen — the action ended at 444 px of a 375 px screen; the status row of the offline window did the same in each of the six languages. Both keep their buttons pinned to the end of the row, the facts scrolling under them. A disabled action is dimmed by mixing its colours with the row's ground instead ofopacity, so nothing shows through it.The review form's submit button wears a variant the core defines (
field-renderer). It wrotegl-btn--primary, a class only the offline plugin's sheet defines: without that plugin the button had no fill. It writesgl-btn--accent, which the core's button system carries.The popup's link to the side panel is labelled in the interface language (
@geoleaf/core3.15.0). "Voir plus" was written in the code and served to the six languages. It is now the catalogue keyfeature-info.popup.more.The age of the last synchronisation names the day in the user's language (
@geoleaf/core3.15.0,offline-ui1.7.0). Past twenty-four hours, the sync strip and the status block of the offline window wrote the count followed byj, in the code: an English interface read "synced 4 j". The unit now comes from the catalogue, keyui.sync.unit_day—jin French,din English, Spanish and Portuguese,gin Italian,Tg.in German.minandhstay as they are: they are the unit symbols, shared by the six languages. Under a core that does not carry the key yet, the plugin keeps the unit it wrote before.A FlatGeobuf layer stops refreshing when it leaves the map, and draws the latest answer only (
flatgeobuf1.0.5). A layer loaded withautoRefreshkept itsmoveendlistener for the life of the page: afterremoveLayer, every map move still fetched the file for a source that no longer existed. The plugin now asks the adapter before each refresh and removes its listener once the layer is gone; a refresh still waiting on the debounce is dropped with it. And two refreshes answering out of order could leave the older extent on the map: only the answer to the latest request is drawn.A printed map asks for a drawing buffer it can read back (
print1.3.5). The offscreen map of the print flow passedpreserveDrawingBuffer: trueat the top level of its options. MapLibre 6 reads the context attributes fromcanvasContextAttributesonly, so the key was ignored and the context was created with the default,false— measured in Chromium: askedfalse, gotfalse. The capture held because the canvas happened to be read before the browser cleared it. The option is now passed where MapLibre reads it.An update of the service worker whose pre-cache fails no longer installs (
@geoleaf/core3.15.0). Theinstallhandler logged the failure and went on. For a first install that is intended. For an update, a worker with an empty static cache then waited, was announced and was offered by the update banner: accepting it replaced a working worker by one that had cached nothing, whose activation purged the caches of the one it replaced. When a worker already holds the scope, the failure is now rethrown: the browser discards the new worker, the one in place keeps serving, nothing is offered, and the next update check tries again. The page logs a warning when a worker is discarded before it finished installing. ⚠️ A file missing for good from the pre-cache list therefore holds every update back until a deployment restores it.A capture discarded from the quarantine leaves the map, or loses its badge, at once (
@geoleaf/core3.15.0).GeoLeaf.Storage.discardQuarantined()returned the local record to the server's truth and did not touch the layer: an abandoned creation stayed drawn, and an abandoned edit kept the "pending" badge of a capture that would never leave, until the page was reloaded. The entity now leaves its layer when the server has nothing to give back, and loses its badge otherwise. An abandoned edit keeps its content on screen until the next pull that carries the entity: the device holds no other version to draw. ⚠️ Reloading the page is not enough on a layer that reads the device before the network — measured, the entity is still drawn with the abandoned geometry after a reload.mapOptions.preserveDrawingBufferdeclared by a profile reaches the engine (@geoleaf/core3.15.0). The key — the only one ofconfig/core/features.json— was validated and read by nothing at boot: measured on a profile declaringtrue, the WebGL context was created withfalse. Two things were wrong. The boot built the engine's options from themapblock alone and never read the key; and the adapter passed the option at the top level of the engine's constructor, where MapLibre 6 ignores it — it readscanvasContextAttributes.preserveDrawingBuffer— so the print plugin's own automatic activation did nothing either. Both are fixed. The list stays closed: no other key of that block reaches the engine. What you may see: a profile that declarestruenow gets a context that keeps its buffer, which a canvas capture needs and which costs some rendering performance — and so does, without declaring anything, an application whose print plugin is registered before the map is created.The automatic refresh of an OGC layer prepares its data like the first load (
@geoleaf/core3.15.0). On a map move, a layer declaringautoRefreshhanded the source what the network returned: nodata.mapping, no conversion, no symbol ids. A layer drawing icons lost them at the first move.A layer's declared geometry is read the same way by the legend and by the map (
@geoleaf/core3.15.0). The legend knewline,polylineandpolygonand drew every other value as a point —multipolygon,multilineandfill-extrusionincluded. And a layer declaringgeometryalone, served empty, got no sub-layer for the features it would receive later: onlygeometryTypewas read when the layer was created. Both read the kernel's family table now.mixedkeeps the point symbol in the legend.Under writes closer together than their debounce, the table and an active filter follow (
@geoleaf/core3.15.0,table1.2.0). Both waited for a quiet moment aftergeoleaf:layer:updated— 150 ms and 300 ms — and each write pushed it back: a source writing faster left them on the state from before the burst for as long as it lasted. The wait is now bounded, 500 ms for the table and 1 s for the filter. Typing in the filter panel is unchanged.A photo delivered while its form is open, or its entity selected, keeps its address (
editor1.6.0). Two paths gave an entity back the token of a photo already on the server. The return of the network uploaded the photos of a form not yet saved; bound to no entity, they were purged once delivered, and the save wrote tokens designating nothing. Such a photo now waits for its form to be saved. And a move validated on an entity selected before its photo landed was built from the attributes as they were at the selection; they are now read from the layer when the move is validated.The offline window speaks the application's language (
offline-ui1.7.0). The cache panel and the export panel wrote some forty labels as literals, most of them in English, four in French. They go through the plugin's catalogue, in the six languages.On a phone, the theme pills no longer cover the sync strip (
@geoleaf/core3.15.0). The strip publishes its height in--gl-map-top-inset, and every surface anchored at the top adds it to its owntop— except the two rules that place the theme pills under 768 px, which restatedtopwithout it. With the strip shown, the pills painted over the count of writes owed. Both rules now add the token; nothing moves while the strip is hidden.A capture waiting for a session says so, and is not forgotten by the periodic retry (
@geoleaf/core3.15.0). A layer declaringwrite.auth: "bearer"holds its captures while no session is open. Two things were missing. The sync strip counted the capture among the writes owed and said nothing else: online, one pending, a "Synchronise" button that changed nothing. It now reads "sign-in required" for as long as the last pass held a capture (heldForSession > 0) and something is owed, in the six languages; learning it brings back a strip that was dismissed. It offers no sign-in button — who holds the session is not the core's to know. The periodic tick read such a pass as "nothing to replay", because a pass that holds everything makes no request and so reportsattempted: 0: the tick went to sleep on a queue that was not empty, and the capture then waited for a network change, a return to the tab or a new write. The tick now stays lit under that halt, as the contract said.A text written on the theme accent is readable in every theme and palette (
@geoleaf/core3.15.0,offline-ui1.7.0,print1.3.5,geocoding1.1.4,measure1.0.10,table1.2.0,editor1.6.0). Fourteen controls painted their background with--gl-color-accentand wrote on it a near-white —--gl-color-text-inverse,--gl-color-bg-surfaceor a literal#fff. The light theme's accent is pale: the primary button of the offline window measured 1.39:1, where WCAG AA asks for 4.5:1. Each now reads--gl-color-accent-contrast, the colour a theme declares readable on its own accent: the offline window (header, primary button, sync and re-queue actions), the print footprint's confirm button, the search and proximity submit buttons, the GPS dialog's primary button, the active tab of the right panel and of the table, the editor's queue badge, the sync strip's action and the share dialog's copy button. Three theme blocks were missing the tokens that make the pairing true: the dark theme now declares its own--gl-color-accent-hover(#9a4a07— the root hover left white text at 2.1:1), the dark blocks of the blue and green palettes redeclare--gl-color-accent-contrastand--gl-color-accent-hover(they inherited the base dark theme's white text on a light accent, 2.1:1 and 1.7:1), and the green palette's hover becomes#22c55e(4.0:1 before). In the offline window, three other texts fell under the threshold and are realigned: the active tab no longer uses the accent as a text colour, the cache state reads the theme's success and danger colours, and two columns lose a grey written inline. Its layer boxes and style selects now carry an accessible name. What you may see: on those controls, the text turns dark in the light theme, and the hover of an accent button darkens instead of lightening in the dark theme. A host that overrides--gl-color-accentshould set--gl-color-accent-contrastwith it.Sixteen declarations of the core's stylesheets read a colour token no theme defines (
@geoleaf/core3.15.0). An undefined custom property resolves to its hard-coded fallback — the same value in every theme — or, written without one, drops the declaration altogether. Four did: the hover of an accordion header in the gallery, the head of a table in the side panel, and the hover border and the scrollbar thumb of the filter panel were never painted. The others were pinned on their light value: in the dark theme the share dialog stayed white, and the title of an inactive legend group was black at half opacity on a dark panel. Each read is realigned on a token the themes set —--gl-color-bg-surface,--gl-color-bg-surface-muted,--gl-color-border-soft,--gl-color-border-strong,--gl-color-text-muted,--gl-color-text-main,--gl-color-surface-elevated. What you may see: in the light theme, a few greys move by a shade (#f7f7f7becomes#f9fafb,#666becomes#6b7280); in the dark theme, those surfaces follow the theme. If you had defined one of the old names in your own stylesheet —--gl-color-bg,--gl-color-bg-alt,--gl-color-bg-subtle,--gl-color-bg-hover,--gl-color-bg-surface-elevated,--gl-color-border-main,--gl-color-border-muted,--gl-color-text-disabled,--gl-color-text-primary,--gl-color-legend-header-inactive-bg,--gl-color-legend-text-inactive,--gl-radius-small,--gl-shadow-strong— it is no longer read: set the theme's token instead.--gl-color-on-accentis still read, as before.A list of the form can be reordered on a touch screen, and from the keyboard (
@geoleaf/field-renderer1.4.3, carried by@geoleaf-plugins/editor1.6.0). Reordering alistfield relied on HTML5 drag-and-drop alone, which no mobile browser fires on touch: on a phone the handle did nothing, in silence. Each row now carries two buttons, move up and move down — 44 px targets on a touch screen, named for a screen reader in the six languages, disabled at the end a row cannot cross. The focus follows the moved row, so the same entry can be moved again without walking the form. The drag stays, for the mouse.A photo taken where the browser refuses to store a
Blobis kept as a photo, not as base64 in the feature (@geoleaf/core3.15.0 with@geoleaf-plugins/editor1.6.0). WebKit refuses aBlobor aFilein IndexedDB for an ephemeral session, on purpose — a private tab is one. The local write failed, the editor fell back on a data-URL, and the whole photo went into the feature's attribute: kept, but out of reach of the upload that runs when the network is back. The write is now made in two steps — theBlob, and on its refusal the bytes with their type, which the same session stores — and the editor's upload retry and preview read both shapes. The attribute holds agl-img:token in both cases. The second step is taken for a caller that asks for it:storeImageLocally({ …, acceptBytes: true }), which the editor passes. Without it a refusedBlobrejects, as it always did. No migration: a record already written keeps itsBlob, and nothing changes where theBlobis accepted. ⚠️ A record of thelocal_imagesstore may now carrybytes(anArrayBuffer) in place ofblob: code that reads that store directly must accept both. ⚠️ The two packages go together to get the fix — an editor 1.6.0 on an older core still falls back on the data-URL, and so does an older editor on core 3.15.0: it does not ask for the bytes, so it is never handed a photo it cannot read.The offline window says which basemap will not be prepared (
@geoleaf-plugins/offline-ui1.7.0, with@geoleaf/core3.15.0). A basemap markedoffline: trueon an origin not declared for the preparation (modules.offline.dataOrigins) was listed, ticked and counted in the size estimate, then left out by the download with a console warning. Its row is now greyed, unticked and inert, and its tooltip names the origin and the reason; and when a download still leaves an origin out — the tiles a vector style names, for one — the closing notice says so and names it, instead of announcing a success. The plugin asks the core's rule (GeoLeaf.Storage.prefetchVerdict); on an older core the row is what it was.A confirmation dialog has finger-sized buttons wherever it opens (
@geoleaf-plugins/offline-ui1.7.0,@geoleaf-plugins/editor1.6.0). Under a coarse pointer the 44 px minimum of Cancel, Confirm and Delete came from the form library's touch rules: a dialog opened by a plugin that does not carry the form — the offline panel's confirmations — kept 36 px buttons. The rule now travels with the dialog itself.The form's close button keeps its shape after another plugin's dialog (
@geoleaf/field-renderer1.4.3, carried by@geoleaf-plugins/editor1.6.0). Every bundle adopts its copy of the dialog stylesheet at its first dialog; adopted after the form's own sheet, it won a tie and the round close button of the form turned square and changed colour for the rest of the page's life. The form library no longer carries a copy of the dialog's rules — it adopts the dialog's sheets — and what it adds on those classes no longer ties.Moving a point no longer puts back the token of a photo already delivered (
@geoleaf-plugins/editor1.6.0). When a photo taken off-network is uploaded, its URL replaces the token on the entity the device stores — and the layer drawing that entity kept its own copy, token included, until the next reload. A geometry edit is built from that copy: on a gallery holding one photo delivered and another still waiting, moving the point sent the two tokens back, and the URL was replaced, on the device and on the server, by a token whose file had been reclaimed. The upload now writes the URL onto the layer's copy too, announced like any other change of the layer. A gallery whose photos were all delivered was not affected; a gallery already damaged keeps its dead token — the file is no longer on the device.realtime-layerloaded on demand stops with the application (@geoleaf-plugins/realtime-layer1.0.8). Its lifecycle module was registered only before the first boot. A host evaluating the bundle once the application runs got neither half: afterunmount()the sources kept polling their feed, for layers that no longer existed, and the nextmount()started a second set. The module is now registered on both loading paths. As shipped the late path does not exist — the application preloads the plugin whenever its profile declares a realtime layer. Requires@geoleaf/core≥ 3.14.2 for the late path.The map follows a download (
@geoleaf/core3.15.0). A layer declaringoffline.enabledis drawn from the device's store, which it read once, when it loaded. A pull —GeoLeaf.Storage.pullLayer(), or the download window — rewrote that store and the layer was not told: an entity the server had deleted left the device and stayed on the map, an edited one kept its old drawing, and a first download left the display file on screen, until the page was reloaded. The layer is now given what the store holds as its pull ends, through the loader's own path, andgeoleaf:layer:updatedannounces it — an open table and an active filter follow. An entity still owed to the server keeps its pending mark. A layer that is not on the map, or is drawn from the network, is not touched.An entity owed to the server shows it at once, whatever its geometry (
@geoleaf/core3.15.0). The pending mark — an orange stroke — was set by the restore of pending edits alone, at boot: an entity captured or edited since was drawn like any other until the page was reloaded. And it was a point's circle stroke only: a line or a polygon owed to the server never showed it. The mark is now set as the edit is queued, when the entity is on its layer, and removed when the server accepts it; a line carries it on its stroke and a polygon on its outline, after a theme switch too. The guide to the offline write cycle presents it.The declared-field diagnostic speaks again when a layer is written (
@geoleaf/core3.15.0). A field a profile declares and the data does not carry is named in the log when the layer loads, and when its style changes — and never again: an OGC refresh, a realtime tick,GeoLeaf.Layers.setDataormergeFeaturescould bring data without a declared field, and its reader fell back to a default without a word. Every such write now has the layer judged again, on what it holds and under the style it wears; a burst of writes is judged once, and a field already reported is not reported twice. Nothing is blocked and nothing renders differently — it is a line inGeoLeaf.Log.« Reset » puts the value shown beside a range slider back too (
@geoleaf/core3.15.0). The filter panel's reset moved the slider to its minimum and left the number beside it at the value just cleared.A pane its plugin removed is no longer brought back by its host (
@geoleaf/core3.15.0). While it is open, a plugin's pane is moved into the mobile sheet or the desktop side panel, and given back to its original parent when the host lets go of it. Both hosts gave back every node they had moved, without checking that they still held it: a pane its plugin had just removed — atunmount(), the itinerary pane ofrouting— was re-attached to the page, and the next opening built a second pane beside it. A host now restores only what is still inside it.A guidance session ends with the application (
@geoleaf-plugins/navigation1.0.3). The plugin registered no lifecycle module: afterunmount()the guidance banner stayed in the page, the position watch and the screen wake lock stayed held, and the session went on following positions for a map that no longer existed.unmount()now stops the session, asGeoLeaf.Navigation.stop()does, and the next mount does not resume it. Requires@geoleaf/core≥ 3.14.2 when the plugin is loaded on demand.The offline window closes with the application (
@geoleaf-plugins/offline-ui1.7.0). Open atunmount(), the window stayed over the page with its listeners, and covered the next application. It is now closed and its listeners are returned; the toolbar button of the next application opens a new one.modules.pwa.offlineDetector.badgePositionis read whatever the offline engine's state (@geoleaf/core3.15.0). The corner of the connectivity badge was honoured when the offline engine was enabled, and written"topleft"when it was not — the mode where the badge is all the detector does. An application showing the badge alone can now place it.The offline preparation no longer stores a JSON resource that parses to
nullor to a bare value (@geoleaf/core3.15.0). Such a body was written to the cache and handed back as the resource. It is now a failed download, namedNothing storable in the response, counted with the others in the preparation's summary.The schema states the default the code applies for
data.enableProfilePoiMapping(@geoleaf/core3.15.0).geoleaf-config.schema.jsondeclaredfalse; the loader has always normalised the POI data unless the key is set tofalse, and the configuration reference said so. An editor completing from the schema offered the wrong default. Nothing changes at run time.
Documentation
- A reading of the public pages against the code, before this version (
@geoleaf/core3.15.0 and the plugins' READMEs). No behaviour changes; several pages said something the code does not do.map.paddingis[horizontal, vertical]— the schema, the profile reference and the configuration guide wrote it the other way round, so a profile written from them had its two margins swapped.map.initialMaxZoomcapped nothing, whatever three pages said — it does now, see Fixed;map.minZoomapplies with or withoutpositionFixed;map.targetis optional; a profile positions the map withboundsorcenterandzoom. The permalink is configured undermodules.permalink, notui.permalink, and the PWA undermodules.pwa, not a rootpwakey. The connector's HTTPS rule is described as it has been since 1.4.0 in the three core pages that still gave the earlier one. The page of CSS variables listed ten tokens that do not exist: it names the accent family, the focus ring and the font token.worker-src blob:is explained the same way everywhere. The editor README configured the plugin undereditorConfig, which is ignored: it ismodules.editor.Table.exportSelection()writes the file itself — the table guide said the application had to. The schema gave 12 pt as the default label size; it is 12 px, which is 9 pt.mapOptions.preserveDrawingBuffer,GeoLeaf.Baselayers.refreshBasemap(),GeoLeaf.ThemeSelector.setTheme(id, { keepView }),ensureGeoLeafandgeoleaf:sw:update-waitinggain the reference entry they lacked. One type follows:pullLayer()reportscursorRefused: falsewhen no offline engine is wired, where the member was missing. terrain.default3Dsays what the code does (@geoleaf/core3.15.0). Its TSDoc said the user could disable the relief manually: no control does that. The 3D terrain is activated when a basemap whosedefault3Dis true is selected, and deactivated when another one is; withoutdefault3D, anenabledterrain is never drawn. The type and the schema also saidterrainwas valid on vector and tile basemaps only — it is read whatever the basemap type. The profile reference listed a"custom"value forhillshade.demEncodingthat the schema has never accepted, and the configuration guide named none of thehillshade.*keys.- A typed read of
GeoLeaf.Tablefrom a page that is not the table plugin's is guarded (@geoleaf/core3.15.0). The plugin configuration guide calledGeoLeaf.Table.exportLayer()unguarded; the namespace is mounted by the plugin and absent from a page that does not load it. The ambient's own note onGeoLeafbeing declared| undefinedis rewritten: it said what the choice cost this repository's documentation, which is no longer true, and now says what it asks of an integrator. - Three pages describe the keys the code reads (
@geoleaf/core3.15.0). The scale page documented ascaleConfigblock nothing reads: the control readsmodules.scale, and three of its five defaults were announcedfalsewhere the code appliestrue. Its "disable" section turned nothing off — leaving the block out keeps the control,enabled: falseremoves it. The table page showed atableConfigblock withpageSizeandvirtualScrolling, two deprecated keys read by nothing, and a row limit of 1000 against 30000: it showsmodules.tablewith the plugin's defaults. The UI components page declaredui.controls,showScaleandscaleType: each control is a capability with its ownmodules.<name>block, and fullscreen is an action of the toolbar. - The permalink page writes its configuration where the core reads it (
@geoleaf/core3.15.0). Four blocks declaredui.permalink; the capability readsmodules.permalink. The section on "enabling" it is rewritten as what it is: the permalink is active unless a profile turns it off. The path given for the validators named a directory that no longer exists. attributes.titleFieldsays what it titles (@geoleaf/core3.15.0). The layer schema and the contract said the key "titles the popup and the side panel". It names one entry offields, and titles the surfaces that entry is displayed on: a side panel whosedisplay.surfacesdoes not list the field opens without a title — measured.- The plugins' READMEs and two core guides state the defaults the code applies (
@geoleaf/core3.15.0,@geoleaf-plugins/editor1.6.0,geocoding1.1.4,measure1.0.10,print1.3.5,table1.2.0). TheDefaultcolumn of a plugin README was read by nothing; it is now checked against the plugin's own table of defaults, for the eight plugins that have one, and so are the tables of the core guides that name the block they describe. What the check found:PLUGIN_CONFIGURATION_GUIDE.mdannounced'180px'and'80vh'as the minimum and maximum heights of the table panel; the plugin applies"20%"and"60%".ui/PERMALINK.mdsaid the permalink is disabled by default. It is enabled by default — a profile turns it off withmodules.permalink.enabled: false— and itsfieldsdefault is the list of seven facets, not "all fields" in prose.- The geocoding README gave one French sentence as the default of
placeholder; the plugin applies its translated labelgeocoding.control.placeholder. - The editor README had no row for
showAddPoiandpoiAddDefaultPosition, and the table README none forcsvSeparatorandcsvIncludeGeometry. - Five defaults were written as prose or in a notation no program reads (
enabledToolsof the editor and of measure,tooltipDefaultSize,decimals, themarginsof print) and are now the JSON value itself. modules.table.exportFormats,csvSeparatorandcsvIncludeGeometryenter the parameter inventory.
- The routing plugin's travel mode enters the parameter inventory (
@geoleaf/core3.15.0).modules.routing.profile—car,footorbike,"car"by default — was documented in the plugin's README and missing from the inventory. The inventory's rows for the plugins are now checked against each plugin's own table of defaults, which is how it was found; the same check repaired the row ofmodules.routing.labelField, where an unescaped|in the type cell had shifted every cell after it and made its default unreadable. - Comments, messages and reference pages no longer cite internal task numbers (
@geoleaf/core3.15.0,@geoleaf-plugins/editor1.6.0,@geoleaf/field-renderer1.4.3,@geoleaf-plugins/table1.2.0). Source comments, TSDoc, test titles, schema descriptions and several pages underdocs/referred to the numbered tasks of a planning document that is not part of this repository — a reference no reader could follow. Each one is replaced by the reason it stood for, or removed where it only recorded when the line was written. No behaviour changes: for@geoleaf-plugins/tablethis entry alone is one test comment. - The first example of the offline-ui README guards what is optional (
@geoleaf-plugins/offline-ui1.7.0). It calledGeoLeaf.Storage.isOffline()andgetStats()unguarded, although the façade is inert until the offline engine loads and its members are optional. The index signature removed above is what let it compile. - The quickstart tutorial boots as written (
@geoleaf/core3.15.0). Copied file for file, the project ofQUICKSTART_TUTORIAL.mddrew its map and nothing else: no tooltip, no popup, no side panel, no filter panel. Its layer declaredtooltip,popupandsidepanelConfig, three keys no code reads and the layer schema refuses; its taxonomy and itsui.jsonwere written in a layout that predates the current one; and two files it referenced — the style andcluster.json— were never given, so the browser logged a style error. The steps are rewritten in the form the code reads (attributesfor the three information surfaces, a named taxonomy,modules.filter), every file is given, and each block that is a whole profile file is now validated against its schema when the documentation is checked. The "Complete Example" ofconfig/LAYER_CONFIG.mdcarried the same three keys and follows. - Four parameters the code reads enter the configuration reference (
@geoleaf/core3.15.0).modules.offline.enabled— the gate of the offline capability itself —,modules.offline.banner.enabled,modules.offline.drain.pollIntervalMsandmodules.pwa.offlineDetector.badgePositionwere declared by their capability and listed in neither the configuration guide nor the parameter inventory. The inventory's rows for the in-core capabilities are now checked against what each capability declares, which is how the four were found; the same check corrected the default written formodules.permalink.fields, announced as ten fields for a list of seven. - Three
performancekeys that nothing reads leave the guides (@geoleaf/core3.15.0).maxConcurrentLayers,layerLoadDelayandfitBoundsOnThemeChangewere still shown in examples and recommended by two troubleshooting tips against slow loading — advice that changed nothing, in a block the profile schema refused. The two guides that carried them have since left this version altogether (see Removed);PROFILE_JSON_REFERENCE.md, which said the wholeperformanceblock was removed, describes its one key. - The configuration guide no longer calls
map.targeta CSS selector (@geoleaf/core3.15.0). It gave"#geoleaf-map"as the default and described bothmap.targetandmap.idas selectors; the boot resolves them withdocument.getElementById, so the value is a bare id and a leading#finds nothing. The guide also listedmap.mapOptionsas a pass-through to the engine's constructor: no code reads that key from a profile, and the row is gone.
[3.14.2] - 2026-10-04
Removed
- The
security.httpsOnlyconfiguration key, which nothing read (@geoleaf/core3.14.2). The key was typed (SecurityConfig,GeoLeafConfig.security) and shown by two configuration guides as the way to refusehttp:resources. No code ever read it, and the JSON schemas had already dropped it: a configuration that set it changed nothing while reading like a hardening. The type and the two passages are gone.validateUrl(url, base, { httpsOnly: true })is unchanged — it is an option of the call, and hardens the call that passes it; its error message no longer names a configuration key. To refusehttp:for a whole deployment, use the deployment layer: CSPupgrade-insecure-requests, HSTS.
Fixed
A vector-tile layer declared
polylineormultilinedraws its lines (@geoleaf/core3.14.2). Both names are admitted by the layer schema, andpolylineis the one the configuration guide gives a line layer. The vector-tile path chose its rendering from lists of its own, which held neither: such a layer was created without a single rendering layer — nothing on the map, no error, no warning. The vector-tile path now reads the same geometry vocabulary as a GeoJSON layer, so every value the schema admits is drawn. A geometry that names no family at all — a typo handed toGeoLeaf.Layers.create(), which does not validate against the schema — still draws nothing, and a warning now says so.An application mounted again gives its plugins back, and takes them down when it is unmounted (
@geoleaf/core3.14.2,@geoleaf-plugins/connector1.3.5,editor1.5.7,geocoding1.1.3,measure1.0.9,position-share1.0.2,print1.3.4,table1.1.3).GeoLeaf.mount()unmounts the application and boots it again; a plugin was reached by neither, unless it had been loaded before the first boot — and then for two plugins only.- The toolbar lost the button of every plugin loaded on demand. Both toolbars are rebuilt at each boot, and offered the slots declared with
registerLazyForAction()only for the plugins not loaded yet: once the user had opened the table, the editor, a measure or the print flow, the next mount drew the toolbar without them. The slots are now drawn whether the bundle is loaded or not; a plugin loaded before the boot still declares its own. - A plugin loaded after the boot was never torn down. The module registry only tore down the modules it had initialised itself. A lifecycle module registered after
init()is now torn down with the others, and initialised like them from the next boot on. - What stayed in the page after
unmount(): the table panel, open, withgl-table-openon<body>; the menus of the editor and of the measure tools, in the map container; a print area being selected, still catching clicks; the search bar of a geocoding plugin loaded on demand. Each is now removed with the application. - What did not work on the next map: the editor and the measure tools kept the map of the previous application, and a tool armed drew nothing; the credential button of the connector was not put back; a configured connector kept
window.fetchbut the tiles of the new map left without their token until a basemap switch. All are wired again at each boot. On the lazy path, the editor also wired itself a second time at the nextgeoleaf:map:ready— it is now wired once per map. - A position being shared kept being sent. The emission loop of
position-shareis a timer of the plugin:isEmitting()still answeredtrueonce the application was unmounted, and positions left again as soon as a location watch was active.unmount()stops it; emission started by hand is not resumed on the next application.
One thing is left alone on purpose:
unmount()does not undoGeoLeaf.Connector.configure(). The session is the host's, set once for the page —window.fetchkeeps its token, and the next application is authenticated without configuring again.On
@geoleaf/core3.13.0 to 3.14.1, a plugin of these versions loaded after the boot is not torn down, as before, and the registry logs a warning when it registers.- The toolbar lost the button of every plugin loaded on demand. Both toolbars are rebuilt at each boot, and offered the slots declared with
[3.14.1] - 2026-10-03
Fixed
A hatched layer keeps its pattern when a basemap switch makes the engine rebuild the style (
@geoleaf/core3.14.1). A hatch is an image GeoLeaf draws and hands to the engine, and an image is not part of the style. MapLibre first tries to diff the outgoing and incoming styles, and keeps its images when it can; when it cannot — an incoming vector style that declaresterrainorcenterAltitude— it rebuilds the style from scratch, with an empty image store. The layers were carried into the new style, the patterns they name were not: a pattern-only fill drew nothing until the page was reloaded. The patterns are now put back after every style change, for a profile with or without an icon sprite. A switch the engine can diff was never affected.A gallery's photos follow the same road as a single photo (
@geoleaf/field-renderer1.4.2,@geoleaf-plugins/editor1.5.6). The gallery field had been left out of it, on two counts. With an upload endpoint, a photo taken off-network was stored without the field it belongs to: once the network was back the file was uploaded, its local copy reclaimed, and nothing wrote the returned URL onto the feature — the feature kept agl-img:token that designated nothing, and sent it to the server. With no endpoint, the gallery wrote ablob:object URL into the attribute, a value that dies with the page. The gallery now hands the host its field, with or without an endpoint, and the editor writes each URL back in place of its token — in the list, found by value, so a gallery reordered or trimmed while a photo waited is left as the user arranged it. A token the attribute no longer holds is not written back at all, which also applies to a single photo replaced before its upload landed. Photos already lost to this defect are not recovered: their tokens designate files no longer on the device.The preview of a photo waiting for its upload is displayed (
@geoleaf/field-renderer1.4.2). A host that keeps a capture answers its token with an object URL on the stored file; the library ran that URL through the protocol check it applies to values read from data, which does not admitblob:, and the preview came out empty — a blank strip in the form, for the single photo and the gallery alike. An object URL answered by the host's preview resolver is now displayed, in the form and in the lightbox. Nothing else is admitted: any other answer of the resolver, and ablob:value read from the data, are checked as before.
[3.14.0] - 2026-10-02
Added
defaultVisibleandclusterare declared by the layer schema, for FlatGeobuf layers (@geoleaf/core3.14.0). The FlatGeobuf plugin reads both at the root of a layer's configuration, and its README shows them; the layer schema is closed and declared neither, so a configuration copied from that README failed profile validation. Both are now accepted, as booleans, on a layer declaringplugin: "flatgeobuf"— and refused on any other layer, where nothing reads them: visibility there belongs to the themes, and clustering to theclusteringobject. The published schema and the profile types generated from it carry the two keys. The plugin itself is unchanged.
[3.13.1] - 2026-10-01
Removed
- The "profile loaded" toast, which never showed. Its listener was laid while the modules start, after
geoleaf:profile:loadedhad fired — once per page, a profile switch reloading it. The library therefore never rendered that toast; only an event emitted by hand reached it. The listener, its retry ongeoleaf:map:readyand thetoast.profile.loadedlabel are gone. The event itself is unchanged, and the "theme applied" toast still follows a boot.
Fixed
@geoleaf-plugins/table1.1.2 — a KML or GPX export names a feature as the table does. A feature with no identifier of its own is given a synthetic one,__gl_row_<n>. The table counted the synthetic identifiers handed out so far; the KML and GPX exports passed the position in the exported array. On a layer mixing identified and unidentified features — and on any selection — the same feature was__gl_row_2in the table and__gl_row_5in the file. One function now numbers a layer for both, and a selection is exported with the identifiers its features already have. Only the<name>of a placemark or waypoint changes; GeoJSON, CSV and Excel exports never carried it.- The editor's drawing chunk carries one copy of Terra Draw, not two (
@geoleaf-plugins/editor1.5.5). The MapLibre adapter resolved itsterra-drawpeer against an older copy than the one the editor declares, and both were bundled in the chunk loaded at the first drawing tool. One copy remains — 1.32.3, the one the editor already ran. The chunk loses about 6 KB (1.3 KB gzipped). - The offline-ui README describes the plugin that ships (
@geoleaf-plugins/offline-ui1.6.3). It still listed the storage, the write queue and the connectivity detection as the plugin's — they are the core's —, said the core was adependenciesentry when it is a peer, and documentedgetStats()with async: { pending, failed }block and atileCacheSizethat do not exist. The statistics arefeatures,outboxandconflictscounts. - A vector-tile layer declared
"mixed"confines each of its sub-layers to its geometry. Its fill, line and circle sub-layers were built on the same source-layer with no filter, and the engine does not check the geometry type when it fills a bucket: a circle was drawn on every vertex of the lines and polygons. They now carry the geometry guard the GeoJSON layers already had. A layer of a single geometry kind is unchanged. Baselayers.init()'s example passed an option that does not exist. Its@example, its@paramand the Baselayers page saiddefaultKey; the option isactiveKey. The ghost key was read by nothing, and the example activated the first registered basemap instead of the one it named.pulledPartialno longer claims the runs the cap cuts short. Its description said a pull "the hard cap cut short" was reportedpulledPartial. Such a run goes to its end and is reportedpulled; the status is the one of a pull the caller aborted or the page left mid-run. The offline-ui overview (@geoleaf-plugins/offline-ui1.6.3) also described stored photos as base64 strings endingsynced: they are Blobs carrying an uploaded flag, purged once uploaded.- Three descriptions of the write path said something else than the code. The schema said a layer whose write target is disabled "has no outbox": its edits are kept locally, then set aside as
layerNoLongerWritable. It did not say that an absentwrite.propertiessends every property. And the editor's README (@geoleaf-plugins/editor1.5.5) describedpersistence.mode: "auto"as an online/offline detection, which it has not been for a while — it also now says thatpersistence.dialect(default"rest") and a layer'swrite.dialect(default"collection") govern two different paths. - A profile that declares no
themesno longer asks for athemes.json. The theme selector, mounted ongeoleaf:app:ready, still asked the theme loader, which fell back to the legacyprofiles/<id>/themes.json: a404, retried one second later, and a console error on every page load. A modular profile without athemesblock is now recognised before any request.geoleaf:themes:readyis still emitted for it, without anerror. - A shared link keeps its layers and its filter on a profile that declares no
themes. When the link carries no theme, the layer visibility and the filter it carries were restored on the firstgeoleaf:theme:applied— an event a profile withoutthemesnever emits, so they were lost without a word. They are now restored right aftergeoleaf:app:readywhen no theme was applied. - The offline database's v3 migration can no longer abort the upgrade. It rewrites the
uploadedflag of the stored images and runs only for a database older than v3. Its comment promised never to fail the upgrade over the migration, and it did not hold: a failed cursor read was logged without being cancelled, a failed rewrite had no handler, and an exception in its success handler was not caught — each aborts the version change, hence the open, and the engine falls back on a store with no persistence. The three are now guarded, as the v7 repair already was. @geoleaf-plugins/realtime-layer1.0.7 — installing the plugin no longer installs a command-line toolchain.gtfs-realtime-bindingswas declared as a runtime dependency although the plugin bundles it in itsgtfs-rtchunk and no published file imports it: every install pulled it, and with itprotobufjs-cliand its own tree, which nothing loads. It is now a development dependency. The published files are unchanged.@geoleaf-plugins/print1.3.3 bundles DOMPurify 3.4.16. The PDF export's lazy chunk embeds the copy its PDF library depends on; it moves from 3.4.13 to a release that closes a low-severity advisory. Nothing else of the print bundle changes.@geoleaf-plugins/print1.3.3 — a printed map credits its basemap. The footer asked the map for an attribution text through a method that does not exist, and the off-screen map is created without an attribution control: no printed page carried the credit of any provider. The footer now prints the credit the active basemap declares —attribution, or the legacyoptions.attribution— as plain text, before the description. A credited basemap reserves the footer band by itself, so the map zone is 10 mm shorter on a page that had no description.Core.destroy()called from ageoleaf:app:readylistener no longer leaves an uncaught error. The scale bar and the coordinates readout mount on that event; a destroy run by a listener laid before theirs let them mount on the map just destroyed — "map is not ready", thrown to the page. Both are disarmed when the last map is destroyed.Core.destroy()is still not an unmount: an application started byboot()is taken down byGeoLeaf.mount().- A creation the server acknowledges without its row is no longer "synchronised" with no identity. A
2xxanswered with[]came out a plain success: the entity was marked synchronised without a server identity, and every later edit of it was never sent. GeoLeaf now looks the row up by its client identity, as it does after a409: found, the identity is recorded; absent, the server wrote nothing and the edit is retried, then set aside asrejectedByServer; unreadable, it is retried. An answer with no readable body — a204— keeps its reading, which the server contract states. - An edit that fails is attempted four times, not three. The retry delays were announced as 30 s, then 2 min, then 8 min, and the last one was never waited: with three attempts in all, the third failure set the edit aside. A server away for three minutes therefore set the whole queue aside until someone pressed "retry all". With four attempts the three delays apply, and an edit is set aside after about ten minutes of failures instead of two and a half.
- "Sync now" is sent during the pause that follows a 401. The sync bar's button and the send button of the offline window went through the automatic path, which a dead session pauses: they did nothing, without a word, while still being offered. A press is the operator saying the session is back, and it now runs a pass. If the session is still dead, that pass stops at its first 401 and sets one more entry aside, as any pass does.
- A layer with clustering switched off keeps the filter's GPU path. The filter took any layer carrying a
clusterRadiusor adisableClusteringAtZoomfor a clustered one, without reading the decision: a layer declaringclustering: { enabled: false }beside its radius had its source re-fed whole on every filter pass. The decision is read first; a layer that says nothing is still treated as possibly clustered. - Switching from a vector basemap to a WMS, an image or a hillshade basemap shows it. After a vector basemap the incoming one was built by the XYZ tile constructor whatever its type; these three forms carry no tile template, so the map got an empty raster source and still announced the change. The switch now builds the source of the basemap's type, as a switch between raster basemaps always did.
- The shipped schema of a layer said that a layer reading the local store "is truncated just like the one that fills it". Only the pull reads
offline.maxFeatures: on a layer that declares nooffline.source,maxFeaturesandmaxAgeMshave no effect. The$commentof theofflineblock says so. - The contract described the
layerNoLongerWritablereason as "the layer stopped being editable between capture and push". Its only producer is awritetarget that is absent, switched off or without anendpoint;editionis not read again when an entry is pushed. The TSDoc ofQuarantineReasonsays what the reason covers. - A token store the browser cannot read no longer stops
configure()(@geoleaf-plugins/connector1.3.4). A failed read — a request that errors, or a store missing from a database of the same version — rejected instead of answering "nothing stored", and the rejection reachedconfigure()before the fetch interceptor was installed. The read now degrades as the write and the delete already did, and the database connection is closed on a failure as well. - A refused renewal is asked once, not three times (
@geoleaf-plugins/connector1.3.4). A refusal was forgotten as soon as it was received: on an expired sessionconfigure()asked for the renewal, the read before the first request asked again, and the401path of that request a third time, before the session was declared ended. The refusal is now remembered for as long as the refused token is the one stored — a sign-in, a sign-out or a reload forgets it. - A sign-in is held to the rules of a renewal (
@geoleaf-plugins/connector1.3.4). The 15-second budget of a sign-in stopped at the response headers: a body that stalled after them left the login window waiting for as long as the connection stayed open. It now covers the body. The answer was also read less strictly than a renewal's — anexpiresInof0or below, or atokenthat is not a string, was accepted and the session stored already expired. Both are refused, as a renewal refuses them. - Two login windows can no longer stack (
@geoleaf-plugins/connector1.3.4). Only the guided reconnection checked that a window was already open;configure()on an absent session,openLoginModal()and the credential button each opened their own. Two windows then carried the same element ids and one promise each: signing in through one left the other open, and closing the oneconfigure()had opened rejected it on a valid session. A call made while the window is on screen now joins it and shares its promise. - A request whose session could not be renewed gets the server's own
401back (@geoleaf-plugins/connector1.3.4). In every branch that does not replay the request — a refused renewal, one that could not conclude, a host with no token to give — the interceptor answered with a synthetic401carrying neither headers nor body. The server's response is returned untouched: aWWW-Authenticatechallenge now reaches the caller. - A form that is saving can no longer be closed under its write (
@geoleaf/field-renderer1.4.1, bundled in@geoleaf-plugins/editor1.5.5). WhileonSaveoronDeletewas in flight the three buttons were disabled, but the cross, Escape and a click on the backdrop still closed the form — a cancel, which withdrew the drawn shape while its write was landing. A non-forced close is now ignored until the write settles, and the cross is disabled with the buttons. - Two gestures of the form at the finger (
@geoleaf/field-renderer1.4.1, bundled in@geoleaf-plugins/editor1.5.5). A photo opened from the form showed BEHIND it — its lightbox carried an inlinez-indexbelow the form's overlay. And a tap on the tags zone, beside the pills, did not give the focus to its input although the zone shows a text cursor. - Ctrl+Z no longer undoes the creation whose form is open (
@geoleaf-plugins/editor1.5.5). The shortcut skipped text fields only: with the focus on a button of the form, or fallen back on the page after a click on the backdrop, it undid the shape under its own form, and a save then persisted a feature no longer on screen. Undo and redo are ignored while a modal dialog is open. GeoLeaf.Measurecalled before the map says so (@geoleaf-plugins/measure1.0.8). A call that came before the map existed returned without a word; one that came while the map style was still loading letaddSourcethrow — from a toolbar listener, an uncaught error on the page. Both now warn in the console, naming the signal to wait for, and the next call initialises the tool as before.- A download that did not happen is no longer announced as done (
@geoleaf-plugins/offline-ui1.6.3).cacheProfileresolves with anerrorwhen nothing was downloaded — a second click while a stopped download was still finishing its page, a cache switched off. The control readcancelledonly, and showed "0 resources", then "profile downloaded: 0 B". It now reports the failure, with the reason. - The "clear the cache" confirmation says what it deletes (
@geoleaf-plugins/offline-ui1.6.3). It asked to delete the offline cache "for this profile"; the same gesture also empties the basemap tile cache, which every profile shares. The message says so, in the six languages. What is deleted does not change. - The README of
@geoleaf-plugins/connector(1.3.4) documentsGeoLeaf.Connector.logout()and thegeoleaf:connector:signed-outevent, which it had never named.
[3.13.0] - 2026-09-29
Added
GeoLeaf.mount(el, options)— the application a host mounts, unmounts and mounts again. It boots asGeoLeaf.boot()does, with the same options, and returns at once a handle:ready(a promise settled by the boot: resolved ongeoleaf:app:ready, rejected with aGeoLeafMountError—reason,phase— on a failure, an abort or an unmount before it was ready),unmount(),on(event, handler)(the page's event bus, every subscription removed at unmount) andgetMap().unmount()takes the WHOLE application down — modules, controls, panels, map, and the state they held — and the nextmount()gives it back whole.elis the map container, an element or its id. Still one application per page: amount()while one is alive — mounted bymount()or byboot()— unmounts it first, and its handle goes inert.Core.destroy()is unchanged: it removes the map alone, and is not an unmount.boot({ maplibregl })(andmount(el, { maplibregl })): the MapLibre GL JS engine handed over as an option. MapLibre 6 no longer setsglobalThis.maplibregl; the core puts it there, where every reader looks for it.reason: "engine"ongeoleaf:boot:failed: no MapLibre GL JS engine was found when the map was built. It failed as"map"before — the answer also given for a missing container. The union is wider: aswitchthat covered every reason needs the new case.geoleaf:boot:abortedcarriesreason: "unmounted"when an application is unmounted while it is still starting.ModuleInitOptions.shouldContinue: asked before each module'sinit(),falsestops the initialisation there.ModuleRegistry.destroy()now tears down only the modules that started, and is refused whileinit()is running.- The profile schemas and their types ship with the package. The ten JSON Schemas that judge the files of a profile, at
@geoleaf/core/schemas/<name>.schema.json— byte for byte the ones the repository validates its own profiles with — and TypeScript types generated from them at@geoleaf/core/schemas(type-only):GeoLeafProfile,GeoLeafRootConfig,GeoLeafLayerConfig,GeoLeafLayerStyle,GeoLeafBasemaps,GeoLeafCoreFeatures,GeoLeafLayersIndex,GeoLeafDataMapping,GeoLeafThemes,GeoLeafUIConfig. A profile can now be validated with what npm gives. The types are an upper bound of the schemas — every file a schema accepts type-checks; conditional and presence rules are not expressed, the verdict stays with the schemas. They compile under ajv'sstrict: true, withallowUnionTypes. Theirdescriptions and$comments lost their pointers to the project's internal notes. Undermodules, the profile schema names exactly the plugin blocks a plugin reads, and leaves each one open: neither the core nor a plugin reports a key it does not know, so a misspelled key is silently ignored. - A field a profile names and its data does not carry is said when the layer loads. A profile names the properties of its data in free text, and a name the data lacks read
undefined: the taxonomy fell back to the category's icon, an attribute row vanished, a style rule never matched — without a word. Each field a layer's readers declare —categoryField/subCategoryFieldof its taxonomy, a style rule'swhen.field, a displayedattributes.fields[].field, the label'sfield,searchable.fields, thefield/subFieldof a filter descriptor that lists the layer — is now looked for in up to 1 000 of its features, evenly spread, under the rule of the code that reads it. One carried by none is named in oneLog.warnper layer, with its configuration key and, when only the case differs, the name the data carries. A property present withnullcounts as carried; an empty layer is not judged; each field is said once per page, and again after an unmount. A switched style is judged too. Nothing throws and nothing renders differently. Not judged: vector tiles, a layer loaded by a plugin, and the writes after the first load.
Changed
boot()after an unmount starts the application again.boot()while an application is mounted is still refused, as before.@geoleaf-plugins/realtime-layer1.0.6 and@geoleaf-plugins/geocoding1.1.2 unmount with the application: the real-time sources stop, the search bar goes. Both register their teardown before the first boot; loaded after it, they are not unmounted. A plugin that wires itself once, when its script loads, does not come back after a remount.
Fixed
- What an application left behind it once torn down — found by making the teardown reachable, each measured: the next map got no basemap; the permalink kept syncing the destroyed map; the theme was not fitted again;
onPerformanceMetricsfired on the first boot only; the proximity filter released none of its document listeners; the desktop panel's theme buttons left a listener onwindoweach; the reveal of a boot unmounted before it revealed could fire on the next boot; the configuration of the next boot was merged into the previous one; a capability whose teardown threw never started again. - The offline write cycle's members of
GeoLeaf.Storageare typed.applyEdit,mayEdit,canQueueWrites,pushOutbox,requeueQuarantined,requeueAll,discardQuarantined,requeueableReasons,listConflicts,clearConflicts,whenReadyandStorage.DB.listPendingEditswere typedunknownby the published namespace, so a TypeScript project could not call them without a cast. They now carry the facade's own signatures. - The documented example of
discardQuarantined()did nothing: it listed throughStorage.listPendingEdits, which does not exist (the method is onStorage.DB), and readidwhere the entry carriesentryId. Fixed, with the example ofrequeueableReasons(), which read a motive no public listing returns.
[3.12.0] - 2026-09-27
Added
geoleaf:layer:updated({ layerId }): a layer's store changed throughGeoLeaf.Layers— once per call ofsetData,clear,addFeature,removeFeature(when it removed something),updateFeatureId,mergeFeatures, andpatchFeaturewith{ rerender: true }. Not for a silentpatchFeature, a visible subset or a layer's load — nor for a writer of a whole collection outsideGeoLeaf.Layers: a real-time layer's ticks, an OGC layer's auto-refresh, a directGeoLeaf.GeoJSON.updateLayerData. The open table and the active filter now listen to it.GeoLeaf.Layers.hideFeatures(layerId, ids | null)hides features, matched onproperties.id— and, on a vector-tile layer, on the tile's own feature id too — without touching the store or the layer's filter: the layer shows what passes its filter and is not hidden (on a clustered layer, a hidden point still counts in its bubble). The editor hides the feature it edits this way.IMapAdapter.setLayerFilter(id, filter, owner?): a layer holds one filter per owner and draws what passes all of them. The default owner,"filter", is the one every existing call writes.THIRD_PARTY_LICENSES.txtin every package that bundles third-party code — the core and@geoleaf-plugins/cog1.0.5,editor1.5.4,file-import1.0.6,flatgeobuf1.0.4,measure1.0.7,navigation1.0.2,print1.3.2 andrealtime-layer1.0.5. Minification had stripped every license comment of the code these packages bundle, and their tarballs carried GeoLeaf's own license alone. The file lists what the built bundle actually carries, with each license text as its authors ship it — or, for a package that ships none, the standard text of the license it declares, said as such — followed by the license comments of the bundled source files, and, for a package published already built (jsPDF, Terra Draw), of the original sources its own source map carries: the notices of the code a package vendors (Adobe's JPEG encoder and Google's WebP decoder in jsPDF, rbush and Turf in Terra Draw, pdf.js code in geotiff, Zstandard in zstddec) live there and nowhere else. They are copied as those sources carry them — where an upstream notice is reduced to its copyright line or to an attribution, so is the copy.
Fixed
- A filter set on a clustered layer no longer draws its clusters as points and its points as bubbles (
IMapAdapter.setLayerFilter). The caller's filter replaced the predicates a clustered layer's sub-layers are built with, and clearing it removed them for good. The filter is now composed with the one each sub-layer was built with, which also keeps the geometry guard and an icon layer's own filter. A bubble carries none of its points' properties, so a filter that requires a property value still hides every bubble, while one that passes a feature lacking it —hideFeatures' exclusion — keeps them (the core's own filter re-feeds a clustered layer instead). - The open table follows its layer (
@geoleaf-plugins/table1.1.1, with core 3.12.0). A feature created, edited or deleted did not reach the open table until the next filter or visibility change. - An active filter judges a feature added, edited or restored. Such a feature was hidden even when it passed — or, on layers the filter re-feeds, shown even when it failed — and the « no feature matches » line kept the previous verdict.
- Editing a feature under an active filter no longer lifts the filter (
@geoleaf-plugins/editor1.5.4, with core 3.12.0). Selecting a feature put every filtered-out feature back on the map, and releasing it cleared the filter while the panel still showed it active. With an older core the editor behaves as before. - The editor deletes the selected feature from its interface (
@geoleaf-plugins/editor1.5.4). Deleting from the pill's button or with theDeletekey, then confirming, deleted nothing: nogeoleaf:editor:feature-deleted, no write, the feature still in its layer, and no message. Removing a selected shape makes the drawing engine deselect it first, and the editor's deselect handler put the original back as for an abandoned edit, then the engine threw. After a move, the deletion did leave, but the move was also written as an update and the feature announced bygeoleaf:editor:feature-saved. The selection is now released before the shape is removed: the deletion is the only write, the feature leaves its layer once the write answers, and deleting a creation still in the offline outbox withdraws it — nothing is sent. - A deletion confirmed from the keyboard is carried out (
@geoleaf-plugins/editor1.5.4). With the select tool armed,Enteron the confirmation's own button was taken for « leave the select tool »: the button did not activate, the selection dropped under a dialog still open, and its « Delete » then deleted nothing, without a word.Deletepressed inside the dialog opened a second confirmation over the first. A key pressed in a modal dialog now belongs to the dialog. And the drawing engine's ownDeletebinding, which removed a selected shape with no confirmation, no undo and no write, is switched off —Escapestill deselects. Enterpresses the tools pill's buttons (@geoleaf-plugins/editor1.5.4). With the select tool armed,Enteron the pill's Delete, Undo or Redo button left the select tool instead, and the button never activated.Enteron a button or a link is now the button's or the link's.- The pending-sync badge leaves an entity once it is pushed. At load, the entities still owed to the server are put back on their layer with the orange « pending » stroke. When the drain then pushed one, the server's answer reached the device's store but not the layer: the stroke stayed until the next load, saying « owed » of an entity the server already held. It now goes as soon as the push is answered — unless the entity was edited again while the push was in flight, in which case it is still owed and keeps it.
- A vector-tile layer is created once at boot, without an error. The profile loader reached the layer while the map's own style was still loading, and MapLibre refused the source with « Style is not done loading »: the layer was logged as failed, and only a later pass of the theme applier created it. Its creation now waits for the map's style.
- The editor no longer rewrites a map source it cannot find (
@geoleaf-plugins/editor1.5.4). When a saved or deleted feature was not in its layer's store, a fallback rewrote the map source instead — looking for a source name the core never uses, so it found nothing and only warned. It is removed: the original is shown back, and the write, which has already left, reaches the layer at its next load. On a vector-tile layer, which keeps no feature, this is said as information, not as a warning. - A device that edited existing features before 3.4.0 no longer draws them twice. Until 3.4.0, modifying an existing feature through the offline queue wrote a second record for it, which no later download could merge: the layer drew the feature twice. The local database moves to version 7, and its upgrade merges each pair once — keeping the record that carries unsent work, with its server identity back, and removing the other. When both carry unsent work, nothing is removed and the pair is logged.
[3.11.2] - 2026-09-27
Removed
storage.notif.cacheEvictedleaves the dictionaries of@geoleaf-plugins/offline-ui(1.6.2). The core announces a budget eviction itself, under the same key in its own six dictionaries, and a core label is read before a plugin one: the plugin's copy was never shown, with any core version. Nothing changes for an integrator — a profile'slabelsoverride of the key still applies.
Fixed
- A feature created with the editor is in its layer at once, under its identity (
@geoleaf-plugins/editor1.5.3). Modifying and deleting already went through the layer's store (GeoLeaf.Layers); creating did not. A point placed with « add a point » vanished with its marker until the next load. A drawn shape stayed in the drawing layer with no identity, so moving it was never saved, and deleting it left its creation queued, back at the next load. Nothing found either one: notGeoLeaf.Layers.search, notgetFeatureById, not the session export. The created feature now enters its layer's store under the identity its write returned, and the layer draws it. That identity is the server's id, or, for a write the offline outbox holds, the client key the core minted (loc:…), kept for the rest of the session. A drawn shape is then handed over to its layer: it is selected through the layer like any other feature, and its later edits reach the outbox or the server under that identity. The drawing stays only when the layer cannot take the feature: a vector-tile layer, a layer the store does not hold, or a backend answering a creation without an id. The open table and an active filter do not follow the new feature until their next refresh, as for an edit. geoleaf:editor:feature-savedcarries the identity of a creation.featureIdwas""for any creation the outbox held; it is now the client key (loc:…).- A queued creation comes back once, under its identity, at the next load. The device stores a creation as the form submitted it, with no
id. The restore of pending edits put it back on its layer without one: one more copy per restore pass (up to two per boot), none findable bygetFeatureById, none editable. The device-first read of a layer served it the same way. Both now give the feature its record's identity: the server's id once it has one, else its client key. - The client key never leaves as an attribute. An edit of a created feature sends its properties back, the client key among them. Stored as an attribute, it left for the server as
id: "loc:…"on any layer declaring no property whitelist. It is now kept out of the stored feature: the key already travels as the client identity (local_id). - An edit no longer sends back a photo token already delivered (
@geoleaf-plugins/editor1.5.3). Once a photo is uploaded, its URL is written on the stored feature, while the copy the layer holds still carries the token. An edit sent that token back, and it replaced the URL.
@geoleaf/field-renderer 1.4.0 · @geoleaf-plugins/editor 1.5.2 - 2026-09-27
Published without a core release: @geoleaf/core stays at 3.11.1.
Removed — BREAKING: the dropdown cascade of @geoleaf/field-renderer 1.4.0
createFieldRendererBridge no longer filters a dropdown's choices by the value of another one. A field carrying dependsOn and optionsByCategory now renders its own options, like any dropdown; the two keys are ignored.
Why it goes, and without an announcement. Its one reader, the category → subcategory adapter of the former @geoleaf-plugins/addpoi, left with that plugin. No profile can declare it: the layer schema never carried either key and refuses unknown ones. No consumer measured uses it. And it was wrong on the path 1.3.0 had just made usable off-network: when either list loaded from a URL (fetchOptions), the cascade kept listening to, or filling, a <select> the list had already replaced. It also filtered nothing when a form opened on a value already chosen, and without an emptyLabel it showed one choice while the saved value was empty. Fixing it meant rewriting it and opening two profile keys for a feature nobody asked for. As with createFocusTrap in this same library, the removal is direct.
@geoleaf-plugins/editor 1.5.2 bundles the library and ships the same change. Its forms never reached the cascade.
[3.11.1] - 2026-09-27
Fixed
- The filter panel says when the filter keeps no feature. A filter that matched nothing hid every feature without a word, and an empty map could not be told from a failure. The panel now reads « Aucune entité ne correspond aux filtres » (key
ui.filter_panel.no_match, in the six dictionaries, overridable through the profile'slabels) between its controls and its buttons, and clears it as soon as an application keeps a feature. The count covers every feature the filter judged: every loaded layer, a hidden one included, and no vector-tile layer. When nothing was judged (no layer loaded, or vector-tile layers only), the panel stays silent, since the filter is not what empties the map. A permalink restore (GeoLeaf.Filter.applyFilter) shows the same message. The line is a live region (role="status"), so a screen reader announces it.geoleaf:filters:appliedkeeps its empty payload.
[3.11.0] - 2026-09-26
Added
- The pre-departure check says the write session.
GeoLeaf.Storage.preflight()now carriessession—{ state, expiresAt },statebeing"valid","expired"or"absent"— as the one holding the token tells it, ornullwhen nobody does. A session expired or absent makes the verdictdegraded: captures made off-network will wait for a sign-in. A valid session leaves the verdict as it is: when it expires is reported, but whether that falls within the trip is not known here. A missing session never makes a devicenotReady, since no entity leaves the device with it.@geoleaf-plugins/connector1.3.3 tells the session it manages (auth.endpoint), read as stored and never renewed;@geoleaf-plugins/offline-ui1.6.1 shows it in the offline window. GeoLeaf.Sync.registerSessionReader(reader)— registers the reader of the write session;nullremoves it. The core authenticates nothing and reads no plugin's namespace: the one holding the token tells it. A host that hands its own token to the connector (getToken) is the only one who knows its expiry, and registers here to say it. One slot, the last registration wins. The reader must not renew the session; one that throws, answers outside the contract, or does not answer within two seconds leavessessionatnullwithout holding up the check. The shape is exported asWriteSessionfrom@geoleaf/core/contracts/sync.contract.js.
[3.10.6] - 2026-09-26
Fixed
- The desktop side panel's tabs follow
ui.language. Without a profile title, the three tabs of the panel (≥ 1440 px) were named « Filtres », « Couches » and « Legende » in every language, while the mobile sheets were translated. They now read the same translated labels as the sheets (sheet.title.filters,sheet.title.layers,sheet.title.legend), which a profile'slabelscan override. In French, the legend tab reads « Légende ». A title set by the profile (modules.filter.title,layerManagerConfig.title,modules.legend.title) still takes precedence, and it names the panel header, the mobile sheet and the tab in one language: to get translated tabs, leave these three keys out. - A profile without a default theme boots with its legend, scale bar and coordinates. When
themesdeclared nodefaultTheme(or when the profile had nothemesat all), the app was revealed before the legend, scale, coordinates, filter and theme-selector capabilities had started listening forgeoleaf:app:ready. None of them mounted,GeoLeaf.Legend.getAllLayers()stayed empty, and nothing was logged.geoleaf:app:readynow fires once every module has started, whichever way the profile declares its themes, and a capability that starts after it mounts at once. Declaring adefaultThemeis no longer needed as a workaround. - A profile whose
themesdeclares nodefaultThemestarts exactly as if it declared its first theme. The first theme was applied, but every other layer kept loading in the background and stayed visible over it. On the demo profile, that meant 19 to 21 layers for a 7-layer theme, and a different count from one load to the next. Only the first theme's layers now load at startup, the others load when their theme is chosen, and the loading veil lifts once the theme is applied. The legacyconfig.defautThemekey is honoured wherever a default theme is read, after the rootdefaultTheme. - A modular profile without
themesno longer keeps the loading veil up waiting forthemes.json. The default-theme step asked for that file although such a profile has none; the request could only fail, and it held the veil for one to two seconds. The step now skips it.
[3.10.5] - 2026-09-26
Fixed
- The mobile toolbar keeps its filter button whatever fixed buttons are turned off. The filter group was inserted at the seventh fixed button, but only four are always there: with geolocation, the theme selector or the layer manager turned off, the bar stopped at six and the filter button was never created — no error, while the filter panel was on. The group now sits immediately before the layers button, or after the last fixed button when there is none. A bar with every fixed button on keeps its layout.
[3.10.4] - 2026-09-26
Fixed
- The filter now reaches line layers. A text search, a category or any other field without
layersfiltered points and polygons and left every line layer drawn: the kernel skipped line layers unless they declaredsearch.enabled: true, a key the profile schema no longer accepts. Line layers are now filtered like every other geometry. To keep a layer out of a field, list the layers that field targets in itslayers.GeoLeaf.Layers.setVisibleSubset()had the same hole: it did nothing on a line layer, and now does. A leftoversearchblock on a layer has no effect. - The filter panel filters every loaded layer in one pass. It used to run three passes, one per geometry family, and a layer whose kind fitted none of them (
mixed, orunknownfor a layer created empty and filled later) was never filtered.geoleaf:filter:applyis therefore emitted once per application instead of three times, with the totalactiveCount.GeoLeaf.GeoJSON.filterFeatures(fn, { geometryType })folds every spelling a profile may declare into its family: apolyline,multilineorLineStringlayer joins"line", amultipolygonorfill-extrusionlayer joins"polygon". - A vector-tile layer draws the labels it declares. The label layer was added on the layer's
vectorsource without asource-layer. MapLibre refuses such a layer and reports it as anerrorevent on the map: the label toggle read "on" while nothing was drawn. It now names the source-layer the layer's own sub-layers draw. A vector-tile layer created throughGeoLeaf.Layers.create()also arms its declared labels now, as a GeoJSON layer does.
Documentation
- A vector-tile layer is not filtered by the filter panel, nor by
setVisibleSubset(): the predicate judges the features in memory, and a vector-tile layer keeps none. This is now written where the switch to tiles is decided.
[3.10.3] - 2026-09-25
Fixed
- Documentation that contradicted the code, corrected against it.
GeoLeaf.Storage.clearAll()promised to wipe thesync_queuestore, which no longer exists. It wipes the cached profiles pluspreferencesandmetadata, and never touches the field captures (features,outbox).GeoLeaf.init()'s contract taught adatakey. It readsmap(target,center,zoom,mapOptions) andui.theme, or the flat shorthand, and ignores the rest; the profile comes from the configurationboot()loads.- The sync contract said a write ending on 401 or 403 ends the session: only 401 does. A 403 is a missing right, which no sign-in lifts.
- The requeueable quarantine motives are five, not three:
authRequiredanddialectNotSupportedwere missing fromrequeueQuarantined's documentation. - A replay counter documented as surviving a requeue is reset by it.
ServerDeletionPolicypromised that an entity deleted on the server leaves the map. It stays on the device, with the operator's work, until the operator destroys the capture.
- Destroying a quarantined capture no longer leaves its entity on the device for good. The quarantine lives on the queue entry, so
Storage.discardQuarantined()removed that entry and left the local record claiming local work that no entry would push and that the pull never replaces. A layer reading the device before the network drew it on every load, online too: an entity the server deleted, a creation it refused, or an edit it refused in place of its own version. The record now returns to the server's truth. It is removed when the server has nothing to give back (a creation that never landed,deletedOnServer). Otherwise it is markedsynced, so the next pull replaces it. A record another queue entry still names is left untouched. - Two dead CSS rules removed from the feature-info side panel. They meant to shift MapLibre's bottom-right controls when the panel opens. MapLibre's stylesheet is unlayered and overrode them, and the controls already follow the map container, which the panel shifts. Nothing changes on screen.
@geoleaf-plugins/geocoding1.1.1 — its stylesheet source said a 3rem inset left room for MapLibre zoom controls, which GeoLeaf does not mount. The comment is corrected; the package ships its sources, hence the version. No runtime change.
[3.10.2] - 2026-09-25
Fixed
- A
layers.jsonentry'slabeloverrides its layer config's, as the schema promised. With aconfigFile, the loader built the layer from that file plus the entry'slayerManagerId, and dropped the entry'slabel. The override now reaches the layer, on the bundle path and on the HTTP cascade alike. An entry withoutconfigFileis the layer itself, and itslabelwas always read. layers.schema.jsonno longer acceptsorderordefaultVisibleon an entry. No code read either: a layer manager section lists its layers by descendingzIndex, so that the list mirrors the stacking on the map, and visibility at load follows the theme. A profile carrying them is now refused by the schema instead of being silently ignored. The schema is not part of the npm package, and a running map is unaffected.
[3.10.1] - 2026-09-25
Fixed
- Loading the bundle no longer writes
<html lang>, and no label is frozen before the boot. Two defaults were translated when their module was imported: the layer manager's title and the branding's default text. The first of them ran the page's first i18n resolution, before any host or profile had configured anything. That resolution froze both strings in French, whatever the profile'sui.languageorlabels. It also published<html lang>whileui.syncDocumentLangstill read its default, so an embedding host could not keep the page's attribute from being rewritten. Both defaults are now resolved when their control is built. Before the boot, a label follows the live configuration and writes nothing. The boot alone fixes the language and publishes the attribute, readingui.syncDocumentLangat that moment. GeoLeaf.Config.setkeeps a value set before anything has read the configuration.sethad no store guard, unlikeget,getAllandgetModuleConfig, and dropped such a value with a warning. This was hidden until now because the label resolved at import happened to read the configuration first. The TSDoc ofConfig.setand ofboot()'sbeforeBootnow states when such a value survives the boot. The application configuration is merged over it, and a modular profile replaces each top-level section it carries (uiamong them). A host overriding a profile value, such asui.language, does it inbeforeBoot.
[3.10.0] - 2026-09-25
Added
IMapAdapter.declareOwnedStyleIds(owner, ids)— engine layers placed outside the adapter can cross a basemap switch. A basemap switch that replaces the map's style carries into the incoming style what the adapter owns; a layer added to the engine directly — a plugin's own, or one an integrator adds throughgetNativeMap()— was erased by it. Declared through this member ({ layerIds, sourceIds }, keyed byowner;nullwithdraws), it is carried like the adapter's own, in place, with its data. Declarations are read when the switch runs and die with the map. The type of the ids,DeclaredStyleIds, comes withIMapAdapter's other types from@geoleaf/core/contracts/map-adapter.contract.js. The member is optional on the contract: an adapter without it carries nothing it did not create.GeoLeaf.Layers.search(query, opts?)— find a feature by its reference, off-network. A layer declaringsearchable: { fields }is searched on each feature's id and on those properties, accents, case and word order ignored — the text filter's own rule, now shared. Best first: exact id, exact value, id or value starting with the query, then every word found. It reads what the map holds, so it works with no network for a layer read from the device store; a layer never loaded and a vector-tile layer are not found. A result (FeatureMatch, exported with the contract's types) carrieslabel,lat,lngandbounds— the shape of an address result — pluslayerId,layerLabelandfeatureId.GeoLeaf.Layers.focus(layerId, id, opts?)— frame a feature and select it. A point is flown to (zoom 17 unlessopts.zoom), a line or a polygon fitted; itsselectedfeature-state is set, and the previous selection, on whatever layer, cleared: one per map. A feature withoutproperties.idis framed, not selected.The
selectedfeature-state is painted. Declared byLayerFeatureStateas a "selection halo" since the contract existed, it was read by no shipped paint: a point's circle stroke and a line's or a polygon outline's stroke now show it, at creation and after every re-style.searchablein a layer's configuration, validated by the profile schema: at least one field name. The key is notsearch— that former block stays out of the schema.@geoleaf-plugins/geocoding1.1.0 — the search box finds the map's own features, off-network first.modules.geocoding.providertakes an ordered list, and the name"layers"asksGeoLeaf.Layers.search:["layers", "nominatim"]lists features before addresses, and while the browser is offline the address service is not asked at all. Selecting a feature focuses it. Providers resolve through a registry —GeoLeaf.Geocoding.registerProvider(name, factory)adds one. A search that finds nothing now says "No results". Requires core 3.10.0 for"layers"; with an older core it answers nothing.GeoLeaf.Storage.resolveOptions(url)— a dropdown's choices, kept for off-network use. A field loading its choices from a URL (fetchOptions) had no copy of them the device keeps: off the network it offered its placeholder alone. The offline preparation (CacheManager.cacheProfile) now fetches every list the chosen layers declare — on the field or on itseditblock — and keeps them in thepreferencesstore, which the cache budget never evicts; any list resolved online is kept too. A held list is answered at once, and refreshed behind the answer while online. It answersnullwhen nothing can: the caller then fetches itself.@geoleaf/field-renderer1.3.0 —setOptionsResolver(fn). A dropdown asks its host for afetchOptionslist before the network; with no answer it fetches as before. The value an entity holds is always offered and selected, even when the list received no longer carries it, and a staticoptionslist declared besidefetchOptionsis the fallback when the list cannot load.@geoleaf-plugins/editor— its forms read the kept lists, throughsetOptionsResolverwired toGeoLeaf.Storage.resolveOptions. With an older core, a dropdown fetches as before.GeoLeaf.Storage.preflight()— "can I leave?", in one read. Before going off-network, the facts a device must be read for, assembled: whether the browser keeps this origin's data (navigator.storage.persisted(), which the PWA requested at boot and only logged — now read, as aStoragePersistenceRegime), the quota, each layer that declares a pull source and whether it is on the device, the write queue, and the last offline preparation. Averdictsays what they mean together:notReady,degradedorready. Never throws;nullwith no engine wired. The write session is deliberately not in it: its validity is the connector's knowledge.The offline preparation records what it leaves out. A zoom level skipped because it held too many tiles, or an enumeration stopped at the total tile cap, was only logged; it is now kept in the cache manifest (
TilePreparationTrace, exported with the sync contract's types), with the download zone asked for.@geoleaf-plugins/offline-ui1.6.0 — the offline window answers "can I leave?" Next to the download: the core's verdict, the persistence regime, each sourced layer flagged when it is not on the device, the zooms a preparation left out. Re-read after a download, a purge, and when the queue or the network moves. Requires core 3.10.0; hidden with an older core.
Deprecated
@geoleaf-plugins/geocoding— an unknown singleprovidervalue will be refused. A typo or anhttp://URL has always fallen back, silently, on the default address service — sending the user's input to it. From 1.1.0 the fallback is kept but NAMED in a warning; from the plugin's next minor version, the value will be refused and nothing asked for it. Inside the new list form, an unknown name is refused already.
Fixed
- Labels survive a basemap switch that replaces the style, and a vector default basemap at boot. A label layer is added to the engine directly, on its layer's source: a switch to or from a vector basemap carried the source and erased the label layer, and the capability, still believing the labels shown, never rebuilt them — zooming did not either. The same happened at boot with a vector default basemap, to every label built before its style arrived. The labels capability now rebuilds its label layers whenever the style is replaced, and reads their font from the incoming style, so that it is one the new glyph server serves. Labels the user switched off stay off. Raster-to-raster switches, which never replace the style, were not affected.
@geoleaf-plugins/measure1.0.6 — measures survive a basemap switch. A switch to or from a vector basemap erased the plugin's sources and layers, and every finished measure with them; they are now declared, and carried. Requires core 3.10.0; with an older core, the plugin behaves as before.@geoleaf-plugins/cog1.0.4 — a COG layer survives a basemap switch. Same defect, same remedy: the image source and raster layer are declared to the adapter driving the map passed toGeoLeaf.COG.addLayer, and withdrawn byremove(). Requires core 3.10.0.@geoleaf-plugins/editor1.5.1 — drawing after a basemap switch no longer throws. The drawing engine's layers were erased by a switch to or from a vector basemap, and its next render threw an uncaughtTypeError(Cannot read properties of undefined (reading 'setData')) — on the next click, or on a theme change. The layers it places when a tool is first armed are now declared, and carried. Requires core 3.10.0.@geoleaf-plugins/editor— a saved move or a deletion reaches the layer's data, not only the map. Saving an edited geometry or deleting a feature rewrote the map source directly and left the core's layer store as it was: the store is whatGeoLeaf.Layers, the filter and the table read, so a search recentred on a moved feature's old position and found a deleted one again. The same path rewrote the source from what it held, which under an active filter is a subset — the save froze that subset as the layer's data. Both now go through the store's own unit mutations. A feature CREATED in the session still reaches the store at the next load only.- The sync report no longer flags, for ever, a layer that has nothing to pull. It judged a layer "declared offline" on
offline.enabled, while the pull requiresoffline.source.url: a layer read from the local store with no source was reporteddeclaredNeverPulledon every device, permanently — nothing could ever pull it. Such a layer is nownotDeclared; the report and the pull share one predicate. getCacheStatus().cachedAtis a date. It read a manifest field nothing wrote, and was alwaysnull; the manifest now writes it, and an older manifest falls back on itsgeneratedAt.@geoleaf/field-renderer— a read-only dropdown stays read-only when its list fails to load. The failure path kept the loading<select>and re-enabled it, so a field meant to be read-only became editable; and the value the entity held matched no option, and vanished from view.
[3.9.0] - 2026-09-24
Added
- A layer can stay out of the layer manager:
showInLayerManager: false. Declared in a layer's config file or passed toGeoLeaf.Layers.create, it removes the layer's row from the panel — and nothing else: the layer still loads, follows its theme's visibility, keeps its legend and stays drivable throughGeoLeaf.GeoJSON.showLayer/hideLayer. It holds across theme switches, which re-list every configured layer. A section left with no listed layer is not created, unlesslayerManagerConfig.sectionsdeclares it (its title then shows with no rows). Absent ortruelists the layer, as before. @geoleaf-plugins/editor1.5.0 —GeoLeaf.Editor.discardDraft(featureId?)abandons the draft awaiting its form. A shape just drawn, or a point captured throughAddForm: the form closes without its unsaved-changes confirmation, the geometry and its undo/redo entries go, a placement marker is retired — nothing is persisted, no event is emitted. It can be called from ageoleaf:editor:feature-createdlistener, and the form then never opens: a host that persists new features by its own means no longer has to simulate a click on the form's cancel button. It returnsfalsewhen no draft is pending, when the draft is notfeatureId, or while its form is saving.
Fixed
Core.destroy()during the boot no longer fails the boot. A host unmounting right after mounting destroyed the map while the boot was still running: the remaining modules failed on it,geoleaf:boot:failedwas emitted, and the failure screen stayed up over the host's page — over a recreated map too. The boot now ends the way a refusedbeforeBootdoes:geoleaf:boot:abortedwithreason: "destroyed", the loading veil hidden, no failure screen.Core.destroy()right aftergeoleaf:app:readyno longer throws. The legend's debounced rebuild (150 ms) survived the destroy and mounted its control on the destroyed map — "MaplibreAdapter: map is not ready", uncaught.Core.destroy()now tears the legend down; its teardown was only reachable through the module registry, which the integrator's path never runs.- A vector basemap applied at boot no longer erases the layers created while its style downloads. With a vector default basemap whose
styleis a URL, any layer created after the switch began and before the style arrived — throughGeoLeaf.Layers.create, for instance — was erased when the style landed: its source and sub-layers were gone while the kernel still reported the layer present, soGeoJSON.updateLayerDatawrote into nothing. MapLibre runs thetransformStyleGeoLeaf passes tosetStyleonly once a style URL has downloaded, and the layers it carries over were read when the switch started. They are now read when the style arrives. A raster default basemap was never affected, and a switch made after the boot behaves as before. An adapter implementingIMapAdapter.buildStyleChangeTransformmust likewise read what it owns when the callback runs; the contract no longer describes anullreturn for an adapter that owns nothing yet. - The layer manager lands in the side panel's layers tab even when it is built after the panel is activated. The desktop side panel adopted the filter panel and the legend whenever they appeared, but moved the layer manager only if it already existed: a host calling
GeoLeaf.LayerManager.init()afterGeoLeaf.UI.activateDesktopPanel()got a layer manager outside its tab. It is now adopted as soon as it appears, in either order, and given back when the panel deactivates. The boot, which builds the layer manager first, was not affected. @geoleaf-plugins/measure1.0.5 — a measure finished just before a reload was lost. Measures are saved tolocalStorageafter a ~300 ms debounce, and a save still waiting for its timer when the page unloaded never ran. A pending save is now written at once when the page is hidden or unloaded (pagehide, orvisibilitychangetohidden); the debounce is unchanged otherwise.@geoleaf-plugins/editor1.5.0 — destroying the editor with its form open threw. The form's cancel removes its shape through the drawing engine, and the engine was stopped first: the destroy threw "Terra Draw is not enabled" and the rest of the teardown never ran. The form is now closed first.@geoleaf-plugins/editor1.5.0 — cancelling a form after an undo threw, and dropped the wrong history entry. An undo reaching the stack while the form was open removed the drawn shape; the cancel then asked the drawing engine to remove it again (which throws), and dropped whatever entry topped the stack. The cancel now removes only a shape the engine still holds, and only that shape's entries, from both the undo and redo stacks.
[3.8.0] - 2026-09-23
Added
GeoLeaf.Filter.getActiveFilter()says under which category each checked sub-category is checked. Ataxonomyfield of the returned state now carriessubValuesbesidevalues: one{ value, category }per checked sub-category checkbox, in panel order —valuethe sub-category id,categorythe id of the category it is listed under. The key is absent when no sub-category is checked.valuesdoes not change: the checked categories, then the checked sub-categories, flat — the map is still filtered on it, and a permalink still stores only it. A sub-category id is unique only within its category, and the flat list could tell neither a checked category from a checked sub-category, nor, for an id listed under two categories, which one was checked. Nothing insubValuesis deduplicated, so the checked categories arevaluesminussubValues[].value, counted with multiplicity.applyFilter(state)reads the key back: a shared id is restored under the category it was checked under, where both used to be checked. Entries that are not{ value, category }strings are ignored. A state withoutsubValues— saved by an earlier version, or restored from a permalink — restores exactly as before. The entry type is exported asTaxonomySubValue.
Fixed
@geoleaf-plugins/editor1.4.2 — pressing a selected shape near an edge moves the shape, instead of inserting a vertex. Terra Draw resolves a drag on a selected shape in a fixed order — resize, vertex, edge midpoint, then the whole shape — each within a capture radius the editor did not set for this mode, hence Terra Draw's default of 40 px, eight times the drawn midpoint dot. A press 35-38 px from an edge's midpoint therefore inserted a vertex there instead of moving the shape — about a third of a 122-vertex polygon's fill at zoom 6, and more on a shape small on screen. The select mode now reaches 12 px, the drawing modes' radius: within it, a vertex is dragged or a vertex is inserted as before; anywhere else, the whole shape moves.
[3.7.0] - 2026-09-23
Added
GeoLeaf.GeoJSON.setWorkerUrl(url)— a host sets the URL of the GeoJSON worker. By default the worker isgeojson-worker.js, resolved once, when the bundle loads: next to the last<script src>whose URL names ageoleaf…jsfile, without its query string — and against the PAGE when there is none, which is the case of a bundle imported from an inline module script with no plugin loaded by<script src>. A host loading the bundle that way had no way to point at the worker, and a host serving its files under a content token (?v=…) with a long cache lifetime had no way to version its URL: a new release could run against a cached worker from the previous one, apostMessageprotocol mismatch that nothing reports. The URL set is read at every worker construction, so it may be set after the bundle has loaded — beforeGeoLeaf.boot()to cover the first worker.nullrestores the default; any other value throws aTypeError. Setting a URL also clears an earlier worker failure, which used to send every later load to the main thread for the rest of the session. Without a call, nothing changes.A layer's
labelsblock is read — labels can be declared on the layer itself. The block takes the same object, with the same meaning, as a style file'slabel. A style that carries its ownlabelobject keeps priority; the layer's block applies otherwise, including when the default style file is missing. It was half-wired:labels.enabledmade the loader initialise the labels, and every reader after it looked at the style alone — so a layer whose style file 404'd showed no labels, with its labels button greyed out. The key is now declared inlayer-config.schema.jsonand typed onLayerDefinition, soLayers.create()takes it too.visibleByDefaultdefaults tofalse, as in a style file.
Fixed
A GeoJSON worker that fails no longer fails the loads it was built for. When the worker's script did not load — a 404 fires its
erroronly once the first loads are pending — or when it crashed, the loads in flight were rejected, although the handler meant to send them to the main thread: the layers of the first load of a page rendered nothing, and only the later ones fell back. They are now replayed on the main thread, unless some of their chunks already reached anonChunkcallback — replaying would deliver those features twice, so that request is still rejected. And the failure of a worker built from a previous URL no longer sends the later loads to the main thread when a host has set another URL since: that URL is tried at the next load, and the error log names the URL that actually failed.Three buttons read a colour token no theme defines. The boot-failure screen's primary button and the popup's action buttons took their text colour from
--gl-color-on-accent, which is set nowhere: they always fell back to white — 1.46:1 on the light theme's peach accent, about 2.8:1 on the default orange. They now fall back to--gl-color-accent-contrast, the token every theme sets for text on the accent. A host that defines--gl-color-on-accentkeeps its value.@geoleaf-plugins/editor1.4.1 — the drawing engine is really loaded on the first tool use. Its source already imported Terra Draw withimport(), but the build's chunking rule also captured the plugin's own adapter module, which the entry imports statically: the whole engine chunk left with the plugin, on every page that loaded it — about 47 KB gz before anything was drawn. The rule now captures the vendor packages only, and the plugin's entry no longer imports the engine but throughimport(). Because the engine is now fetched when first needed, that fetch can fail — offline, or after a redeploy: the failure is reported in the console and the armed tool is disarmed. The plugin does not keep the failure: the next tool use asks for the engine again. Whether the browser fetches it again is not the plugin's to decide — some keep a failed module fetch until the page reloads — and after a redeploy the old engine chunk no longer exists: in both cases, only a reload recovers.@geoleaf-plugins/connector1.3.2 —http://is tolerated on every development host, not onlylocalhostand127.0.0.1. The rule keeps the token out of cleartext in production, and it refused any other name: a development instance served over HTTP under*.testthrew atconfigure(), so the map started without data. The tolerated names are now exactly those no production host can carry —localhost,*.localhost, the loopback addresses (127.0.0.0/8,::1), which a browser itself treats as secure contexts, and names under.test, reserved for testing (RFC 6761)..localstays refused: it names real intranets. The page's host is judged, as before.The connector's documentation described options that do not exist. The plugin configuration guide documented
auth.type("bearer"/"apikey"/"cookie"),auth.tokenandauth.headerName: none exists, and such a configuration fails. There are two modes,getTokenandauth, and the connector's README now points to what the server must answer (§3 of the server contract) and to the way out for a server speaking another dialect (§7.6 of the security guide). It also said the core is adependency: it is a peer dependency.The label documentation taught options that were never read.
enableLabels()examples usedproperty,template,directionandstyleFile— the first three are not read, the last one is refused — and a labeloffset.angleDegand abackgroundblock that no schema accepts; its label object also wrotefieldas"properties.nom", which displays no text — a field names the feature property as it is. The labels page also described the module of an earlier major version (permanent tooltips, CSS style files, planned features); it now describes the MapLibresymbollayer the labels are, and whentoggleLabels()and the layer manager's button act. The TSDoc ofenableLabels()also saidshowImmediatelydefaults tofalse(it istrue) and that the call is only asynchronous with astyleFile(it always returns a promise).
[3.6.1] - 2026-09-22
Fixed
The default basemap could wait for the user's first gesture — and then tilt the camera into it. When
setBaseLayerran before the style had finished loading, it deferred the activation onto the map's one-shotidleevent.idlefires only once the map is FULLY settled — style, sources, tiles, no transition in flight — which a rich profile may never reach: measured on a 43-layer profile with third-party tiles, noidleoccurred at boot at all, and the first one was provoked ~20 s later by the user's own first gesture. The basemap was applied at that moment, and a basemap declaringterrain.default3Dthen ranmap.easeTo({ pitch: 60 })— tilting the map mid-gesture and losing it. The deferral now re-testsisStyleLoaded()onstyledata,sourcedataANDidle, and detaches from all three as soon as it applies. The three are needed together:isStyleLoaded()covers the style and its sources, and measurement shows the laststyledatacan land before the predicate flips — the flip is then carried by asourcedata, withidleleft as a net. A deferral superseded by a later basemap choice still stands down, and it now unsubscribes when it does.A 3D basemap stayed flat: the tilt was animated, and the animation was cut. A basemap declaring
terrain.default3DpostedsetTerrain()and theneaseTo({ pitch }).easeTois cancelled by ANY later camera command, and the application posts one when it reveals the app — it re-frames the profile bounds shortly after the loading veil lifts. Whichever landed last won, and the order was a race: measured on a plain boot with nothing acting on the page, a basemap asking forpitch: 60reached it ONCE in six runs and stopped at 6–9° the other five. The "Relief 3D" basemap the user had selected therefore rendered essentially flat, with the terrain active and nothing logged. The tilt is now posted withjumpTo, which has no animation to cut; at boot it happens behind the veil, so there is no visible jump. Measured after the fix: 6 runs out of 6 at the requested angle.Re-framing the map no longer redresses the camera.
fitBoundscomputes a camera from scratch and, given nopitch/bearing, applies zero — so any re-framing silently flattened a tilted map. The adapter now carries the current angle over, which makes a re-framing answer the question it is asked: what is on screen, never from which angle. ⚠️init()is deliberately unaffected — it establishes the first camera, where zero is right.
[3.6.0] - 2026-09-20
Added
ui.syncDocumentLang— a host can keep its own<html lang>.initI18n()published the resolved language to the document element unconditionally, the only guard being the absence ofdocument. That attribute is PAGE-wide and a map does not always own the page: embedded in a host that sets it for its own session, a map mounted in a form rewrote the locale of everything around it. Defaults totrue, so a standalone application is unaffected, and opting out suppresses only the write —?lang=, the stored preference andui.languagestill resolve exactly as before, labels included. The guard sits inside_syncDocumentLang()rather than at the boot call site, becausegetLabel()re-entersinitI18n()when a label is resolved before boot and a guard placed around the boot call would leave that path writing.Layers.getVisibilitySource(id)— which authority set a layer's visibility. Returns"user","theme","zoom","system", ornullfor an unknown layer.isUserOverriddenonly answers "not the user", which leaves a theme indistinguishable from a zoom threshold, and a caller reconstructing the value from it writes the same thing for both. The same value already leaves the core on every change through the typedgeoleaf:layer:toggleevent; this is the PULL route, for code that mounted after the change — a panel rebuilt on navigation, a host re-attaching to a live map. ⚠️ The recorded source lags a REFUSED write: a change blocked because a higher-ranking source holds the layer leaves the previous source in place. Read it as "who decided what is displayed", not "who asked last".Layers.getStyle(id)— which style a layer wears, as{ id, label }. A normalised projection, and the narrowness is the contract: the runtime entry'scurrentStylehas two shapes depending on which writer spoke last — the flattened paint handed to the adapter, which carries noidand nolabel, or the full style document the style selector restores over it. Reading that field directly gives one or the other with no way to tell, a defect already shipped once where the labels toggle greyed out for good. Both fields arestring | nulland always present;labelisnullwhen the applied style carries no display name, including when the underlying field holds a map-label config object rather than a name. The paint (style,styleRules) is deliberately not exposed — it is precisely the unstable part, and published subpaths are added, never removed.
Changed
@geoleaf-plugins/table1.1.0 — the table searches and selects on its MODEL, not on the rendered rows. Past 150 rows the table renders only a window, while search and selection read the DOM: on a 30 000-row layer "select all" took 33 rows, and a search evaporated on the first scroll. Both now read the model the plugin already held. A range selection starts from a real anchor — the last single click — instead of the last entry of aSet, and it no longer no-ops when the anchor has scrolled out of view. The header checkbox compares the selection to the visible count rather than to the rendered one. Under an active search, "select all" takes the result, and an / totalcounter says the view is a subset.A layer's
table.searchFieldsis read. It was accepted by the layer schema and read by nobody, while the configuration guide promised that "only these properties are interrogated". Both published shapes are accepted —["properties.name"]and["name"]— so neither document is left false. Absent or empty still means every column.maxRowsPerLayerdefaults to30000, and the cut now warns the user, once per layer and per session, naming both the kept count and the real total. Its only channel was aconsole.warn: a table reduced to its first rows looks exactly like a complete one. A declared0also means zero —||silently replaced it with the default.
Deprecated
modules.table.virtualScrollingis inert and marked. Virtualisation is decided by a row-count threshold, never by this key;profiles/_referencedeclared itfalsewhile the virtual renderer kept running. It left the defaults and the shipped profiles but stays in the publishedTableConfigtype, marked, because deleting a member of a published type breaks the compilation of an integrator who wrote it. Same treatment as its twinpageSize, for the same written reason. Neither has a replacement, so neither entersDEPRECATIONS.json.
Fixed
A table row's identity no longer drifts as you scroll. The id written to
data-feature-idcame from a module counter reset only by a full render, while the virtual scroller re-created rows without that reset. After a scroll a row displaying one entity carried the id of another — a key of the lookup map, but the wrong one, so selecting that row selected a different feature and zoom flew elsewhere. Identity is now resolved once per load and travels with the row.A sort no longer desynchronises the selection from the data. The id→index map was built before the sort reordered the features in place, so every entry pointed at a pre-sort position: zoom, highlight and export-of-selection all handed back different features than the user had selected. Measured: 300 desynchronised entries out of 300. Order and index are now derived in one pass.
The table search input is unbound on teardown. Its listener was registered with a raw
addEventListener, sodestroy()left it — and its pending timer — attached to a detached input.A layer a pull emptied no longer falls back to the display source. The local store answered
nullwhen it held nothing, and the loader read that as "never pulled": it fetcheddata.*instead — a static display file, possibly months old — and presented it as the user's own entities, silently. The case was rare while a pull only ever wrote; the convergence of 3.5.0 made it ordinary, a complete pull now removing what it did not return. An empty store left by a COMPLETE pull now answers an empty collection, and the layer is displayed empty. A pull that failed or was cut still hands the read back to the network: its store is empty because the run stopped, not because the source is.
[3.5.0] - 2026-09-19
Added
The download zone bounds the entity pull. The zone drawn in the download window (
@geoleaf-plugins/offline-ui) was persisted with the selection and reachedCacheManager.cacheProfile()whole, but it only bounded the vector tiles: each layer declaringoffline.sourcewas still pulled as its whole collection, capped byoffline.maxFeaturesalone. Its extent now leaves with every pull as the OGCbboxparameter,[west, south, east, north]. Without a zone — or with one whose bounds are not finite numbers — nothing is guessed and the pull covers the whole collection, as before.A second pull rewrites only what changed, and says so:
unchangedon the pull report. The freshness marker read fromoffline.source.versionPropertywas stored on every record from the first pull on — for the write cycle's conflict filter — and never compared by the pull itself: every synchronised record was rewritten on every pull, and countedwritten. A record the source serves under the marker already stored is now left as it is, and counted in the newunchangedfield ofGeoLeaf.Storage.pullLayer()'s report;writtencounts only real writes. A row served without its marker is always rewritten — nothing says it did not change.An entity deleted on the server leaves the device:
removedon the pull report. The pull only ever wrote. An entity the source had stopped serving stayed in the local store forever — still drawn on the offline map, with no row left for any edit of it to reach. A COMPLETE pull (neither cut bymaxFeatures, nor aborted, nor failed) now removes the synchronised records carrying a server identity that its answer did not return, and counts them in the newremovedfield. Local work is never removed — an edited entity the server deleted is left to the write queue, which sets it aside asdeletedOnServer— and neither is a record without a server identity. A pull that is cut, aborted or failed removes nothing.Freshness and deletions declared per layer:
offline.source.delta. A layer whose source serves changes and deletions declares both, together —{ "freshness": "datetime", "deletedProperty": "<property>" }, the schema refusing either half alone, since a freshness filter never sees a deletion. Its first pull is complete; the next ones, for the same source and extent, ask only fordatetime=<mark>/..— the OGC API Features Part 1 temporal filter, the mark being the greatest freshness marker already served — and reportmode: "delta". A served entity whose declared property is set is a tombstone: never written, its synchronised copy leaves the device. A delta removes nothing else. The mark is dropped — and the next pull is complete again — by a purge of the local cache, a change of zone or source, a delta cut bymaxFeatures, or a marker that is not a readable instant. Without the declaration every pull stays complete, and converges as described above.streamOgcApiFeatures()gains the optionaldatetimeargument this needs.⚠️ No server of this repository speaks it yet. The mechanism is proven against a source with state in the unit suite and on the shipped bundle, never against a real server; what a server must guarantee for it to hold is written in the server contract page.
pullLayer()reportsmode—"full","delta", ornullwhen refused before any request.
Changed
- A complete pull defines the layer's synchronised content — a bounded one included. With a
bbox(the download zone, orpullLayer(id, { bbox })), the answer is what the source holds for that extent, and what lies outside it leaves the device too: successive pulls of different zones no longer accumulate, the last complete one wins. Decided with the convergence rule above; a caller that pulled several zones in turn to build a union must pull their enclosing extent.
Fixed
A photo written back after the fact no longer erases the entity that owns it. A photo taken off-network is uploaded when the network returns, and the editor then writes its URL onto the entity with an edit that carries that one attribute. The local store took such an edit as the WHOLE entity: the record kept the URL and lost its position and every other attribute — the point vanished from the offline map. While the create was still queued, the two coalesced and the create would have left with nothing but the URL and the client identity. A partial edit is now MERGED over the stored entity: its
propertiesoverride the stored ones key by key, and the stored geometry is kept when the edit carries none. Observed on the shipped bundle before the fix, in the order the field takes — the photo is uploaded before the queue drains —: the create left with the URL and the client identity alone.⚠️ What
GeoLeaf.Storage.applyEdit()now does with a partial entity is the rule the store already announced ("the store keeps what the edit does not bring"). Omitting an attribute no longer removes it from the local record — it never removed it from the server row either, which aPATCHleaves untouched. To clear an attribute, send it withnull.A cap that lands exactly on a page boundary is reported as a cut. The OGC API Features walk stops as soon as it holds
maxFeaturesentities, before asking for the next page — and it flagged the result as truncated only when it held MORE than the cap. When the cap fell exactly on a page end, the walk stopped on a page that announced more and reported itself complete: the "N out of M" notice stayed silent on the display path, and the offline pull reportedcapped: falsefor a subset. The page the walk stops on is now read for a next page (thenextrelation, or the declaredcursorPath), without following it.« Stop » now stops the entity pull too.
CacheManager.cancelDownload()only reached the resource downloader, whose controller exists while the resources download and no longer. A stop pressed while the entities of the layers declaringoffline.sourcewere being pulled reached nothing: the panel said the download had stopped while the pull ran to its end. And a complete pull removes what it did not return, so the zone just drawn still replaced the previous one. A stop pressed while the resources were still being enumerated was lost the same way, and every resource was downloaded. Each download now has its own controller, andcancelDownload()raises it:- a pull under way ends at its page, keeps what it wrote and removes nothing;
- after a stop, no further layer is pulled;
- a stop pressed before the resources start means nothing is downloaded, and the manifest an earlier download left is not overwritten.
The result of
cacheProfile()sayscancelled: true.@geoleaf-plugins/offline-ui1.5.1 — "profile area" offers the profile's extent, not the current view. The download window's zone selector asked the map for itsgetMaxBounds(), and the map it receives — the GeoLeaf map adapter — has no such method: the button fell back togetBounds(), the current view, and the profile's extent was never proposed. It now reads the extent the profile declares,map.bounds, unpadded;getMaxBounds()and then the view remain the fallbacks when a profile declares none, or declares one that is malformed.@geoleaf-plugins/offline-ui1.5.1 — the download zone is no longer lost to the layer list. The window keeps its selection in one record, written by two paths that did not wait for each other: the layer list reads it when its save starts and writes the whole record back after its DOM work and its estimates, so a zone persisted in between was overwritten — and the window itself starts one of those saves when a profile has no selection yet. The download then left with no extent at all: the whole collection pulled, the whole map tiled. Both paths now go through one serialised read-modify-write, each re-reading what the previous one wrote. It serialises this tab only: two tabs on one profile can still overwrite each other, as before.@geoleaf-plugins/offline-ui1.5.1 — the confirmation of « Stop » is reachable with a mouse. The window sat above the layer the confirmation dialogs use, so the confirmations it opens — « Stop », « Clear cache » — opened UNDERNEATH it: their « Confirm » was covered by the window's own rows, and only a keyboard could reach it, blind. The window now shares that layer, and the dialogs it opens stack above it.@geoleaf-plugins/offline-ui1.5.1 — a stopped download is no longer announced as a success. Once « Stop » had shown "stopped" and handed the button back, the download window still announced a success whencacheProfile()resolved: the bar at 100 %, the ✅ count, a success notice. It now reads thecancelledthe result carries, and then only re-reads the status and the icons of the cached layers.
[3.4.0] - 2026-09-19
Added
A settled conflict now KEEPS the version it crushed.
lastWriteWinsis the policy and it is unchanged — what the device holds wins, a deletion included. What changed is that the drain reads the server's row before overwriting it, stores it in a newconflictsobject store, and emitsgeoleaf:offline:write-conflictnaming the entry. Until now the overwrite was blind: after a settled update the server still held a row, but after a settled delete it held nothing and neither did anything else — the only irrecoverable loss of the write cycle.⚠️ The re-read never decides whether the write happens. A server granting
UPDATEwithoutSELECTis an ordinary permission split; making the overwrite conditional on the read would turn every conflict into a spent budget and then a quarantine. When the read cannot conclude, the record is stored withreadOutcome: "unreadable"and the overwrite proceeds — the gap is recorded rather than invented.⚠️ The local database moves to version 6. The upgrade is additive and was proven against a real v5 base: the seven existing stores, their indexes and the outbox's
autoIncrementgenerator all survive it. No integrator action is required.Storage.listConflicts(layerId?)andStorage.clearConflicts(layerId?)— the archive is readable and purgeable. One record per entity, keyed[layerId, localId]: a second conflict on the same entity replaces the first. That key is also the store's bound, since nothing evicts it.Storage.getStats()gains aconflicts.counttally for the same reason.Storage.requeueableReasons()— which quarantine motives an operator's gesture can lift. An interface offering "retry all (motive X)" has to know which motives that sentence is true for before the click; without this read every surface would hard-code the list, and a plugin cannot import the core.The GeoJSON worker's header hook may answer with a promise. A plugin authenticates the worker's requests through
globalThis.__GEOLEAF_WORKER_HEADERS_HOOK__, which the core read synchronously, right before posting to the worker: a token provider that answers asynchronously — an identity SDK — could not reach it, and a GeoJSON layer loaded by URL left without its token. The core now passes{ acceptsPromise: true }as the hook's second argument and, when the hook answers with a promise, posts the request once it settles. A synchronous answer is still posted at once; a hook that throws or rejects lets the request go without headers.@geoleaf-plugins/connector1.3.1 relies on it ingetTokenmode.⚠️ A hook should answer with a promise only when told. An older core hands the answer straight to
postMessage, which cannot clone a promise — the connector answers with one only after reading the capability, and gives no header otherwise.
Fixed
GeoLeaf no longer unregisters service workers it did not register. With the
pwacapability disabled, boot used to callgetRegistrations()and unregister every worker on the origin. Mounted inside a host web application, that took down the host's service worker: measured on a real host, which lost its offline mode and its app install with no message on either side. The selection is now made on each registration'sscriptURL— onlysw-core.js, GeoLeaf's own worker, is unregistered, at boot and at_reset()alike.⚠️ The match is on the last path segment, not on a suffix. A host worker named
vendor-sw-core.jsis not ours and stays registered. Sub-path deploys are unaffected: the scope is"./", so the origin still has to be enumerated — what changed is which entries are kept.⚠️ Integrators who relied on the sweep to clear an unrelated worker must do it themselves. Nothing in GeoLeaf's own deployments depended on it: the variants ship exactly one worker.
@geoleaf-plugins/connector1.3.1 — a renewal that cannot conclude no longer ends the session. Inauth.endpointmode the renewal answerednullfor any failure, and the session was erased on it: one minute of an unavailable authentication server signed the device out. Only a refusal ends a session now — a non-2xx other than 408, 429, 500, 502, 503 and 504 (the statuses the write drain waits out), or a 2xx received whole but unusable. A renewal that could not conclude keeps the session: the request gets its401, and the renewal is tried again when the network returns, when the page comes back to the foreground and when a capture enters the offline queue — a success resumes the queue as before.⚠️
configure()no longer throws, nor opens the login window, for a stored session whose renewal cannot be reached — offline at start-up with an expired token, for instance. It resolves, with or withoutauth.ui; aConfigErrorstill names the absence of any session, or a refusal. An integrator withoutauth.uiwho relied on thatConfigErroroffline now gets a resolvedconfigure(), and a session renewed when the network returns.@geoleaf-plugins/connector1.3.1 — the host's token reaches every loading path. IngetTokenmode, GeoJSON layers loaded by the worker and vector tiles left without the host's token: both paths read a store only theauth.endpointmode fills. They now pull the host's provider at each request — a promise reaches the tiles on MapLibre 5.21 or later, and the worker through the header hook's new capability. PMTiles archives carried a token in NEITHER mode: thepmtileslibrary reads them through the page'sfetch, which the connector excluded for them. They are intercepted now, theirRangeheader kept.@geoleaf-plugins/connector1.3.1 — a session renewed before the offline engine is wired still resumes the queue. Aconfigure()called beforeGeoLeaf.boot()renews an expired session whileGeoLeaf.Storagehas no engine yet:requeueAllansweredengineUnavailable, and the capture a previous session set aside underauthRequiredstayed quarantined — the core's arming pass replays pending and failed entries, never a quarantine. The resume now waits forStorage.whenReady(), lets the core's own arming pass end, then requeues and drains. One wait per page; a core withoutwhenReadygets a single attempt, as before.@geoleaf-plugins/connector1.3.1 — an instance ofcreateConnector()no longer touches the page's renewal. The token store kept one renewal delegate for the page. An instance built withauth.endpointinstalled its own in place of theconfigure()singleton's — the singleton's session was then renewed against the instance's endpoint — and itsdestroy()removed the page's delegate whoever had installed it: the singleton's next401found nothing to renew with, and the session ended,auth-errorincluded, with no server having refused it. An instance now renews its own reads against its ownauth.endpointand installs nothing the page shares;destroy()only deactivates its reads. Onlyconfigure()installs and removes the page's renewal.@geoleaf-plugins/connector1.3.1 — the queue resume and the renewal relaunch answer their own session only. Both listened togeoleaf:connector:token-refreshedandgeoleaf:connector:authenticatedwhatever API the event named: the renewal of another API's session — an instance ofcreateConnector(), another copy of the plugin — resumed the singleton's offline queue, and disarmed its relaunch while its own session was still waiting for the network. An event naming anotherbaseUrlis now ignored; one naming none still counts.@geoleaf-plugins/connector1.3.1 — a server that refuses the token it has just renewed no longer makes the offline queue loop. Every401renewed with success,token-refreshedresumed the queue, and the next401renewed again: as soon as the resume opened a new pass — which a requeue slower than the replay of the refused request makes it do — nothing bounded the renewals. A renewed token the server refuses at once now holds the renewals for one exchange window, as an outage does: the session is kept, the request gets its401, and the relaunch's moments still retry.@geoleaf-plugins/print1.3.1 — a printed map asks for its tiles with the live map's headers. The off-screen map the print renders was always created without a request transform: the plugin read a field of MapLibre's request manager that MapLibre does not write, behind a cast that kept the compiler from seeing it. Tiles only a transform authenticates — the vector tiles@geoleaf-plugins/connectorauthenticates, or an integrator'stransformRequest— went to print without their headers, and a protected layer printed empty. The off-screen map now hands each request to the live map's transform, read at request time.A device that went through the offline write cycle can now clear its quarantine.
requeueQuarantinedanddiscardQuarantinedhad existed since 02/08/2026 with no caller in any interface: the only way to use them was to list the queue in a console and paste an identifier.@geoleaf-plugins/editor1.4.0 wires both into the pending-operations window.dialectNotSupportedjoinsQuarantineReason. A layer declaringwrite.dialect: "rest"— a dialect the core does not write — used to spend its entries' replay budget against a hole no replay fills, then set them aside underretryBudgetExhausted, which says the server never answered although no request had been made. Such an entry is now set aside at once under its own motive, andStorage.requeueQuarantinedaccepts it only once the layer has stopped declaring that dialect.An action button's visual weight reaches the DOM.
varianton anactionfield —"primary","secondary"or"danger", flat on the legacy descriptor and underoptions.variantin theattributesblock — sets thegl-poi-popup__action--<variant>modifier and adata-gl-variantattribute, on the popup and the side panel alike. Any other value keeps the default look and warns once. No earlier release read the key: every action rendered the same button.Consecutive actions share one row in the popup —
.gl-poi-popup__actions, a wrapping flex row. They used to stack one per line.⚠️ The buttons are no longer direct children of
.gl-poi-popup__body. A stylesheet that reached them with a child selector (.gl-poi-popup__body > .gl-poi-popup__action) no longer matches: target them through the row, or with a descendant selector.modules.feature-info.popup.closeButtonshows MapLibre's close cross on the popup. The default is unchanged (false): Escape and a click on the map still close it.A boot that cannot complete says so.
geoleaf:boot:failedis dispatched —reasonconfig,profile,webgl,map,module,timeoutorinternal— and the app shell's#gl-loaderveil turns into a failure screen offering « Reload » and a diagnostic to copy or download. The event is cancelable: an application that callspreventDefault()draws its own interface, and a page without the veil gets no DOM at all. Before, every one of these failures left the loading spinner turning forever.A profile resource that fails is named.
geoleaf:profile:failedlists the resources the profile declares and could not obtain — an HTTP error, invalid JSON, or a declared section the profile bundle does not carry. When a map can still show, the reveal is held on a screen that names them, with « Continue anyway ». A resource the profile does not declare is never reported.A missing WebGL2 is named. MapLibre's
GPUInitializationErrornow reaches the boot (MaplibreAdapter.initError,MaplibreAdapter.isGpuInitializationError) and becomesreason: "webgl", with a message the user can act on.A boot watchdog —
GeoLeaf.boot({ watchdogMs }), default 45 s,0disables it, paused whilebeforeBootruns. A boot that stops progressing gets a provisionaltimeoutand a « Reload » screen, which a late reveal removes.A module that throws no longer takes every later module down.
ModuleRegistry.init()accepts{ onModuleError }(ModuleInitOptions,ModuleInitFailure): answering"continue"skips the failed module's dependents — left out ofgetActiveModules(), never destroyed — and starts every other module. Without the option,init()still rejects at the first failure. The boot passes it: a module the interface depends on fails the boot (geoleaf:boot:failed,reason: "module",modulenaming it); any other is reported bygeoleaf:module:failed(cancelable), and the reveal is held on a screen that names it — or, once the application is revealed, a warning names it.The log keeps a record support can read.
GeoLeaf.Log.getEntries()returns the latest 500 entries — warnings and errors whatever the level,infoanddebugwhen they reach the console, the boot logger's lines and, fromGeoLeaf.boot()on, uncaught errors and unhandled rejections — formatted into bounded text and redacted when read: URL credentials, parameters named like a secret,Bearerand encodedBasiccredentials, JSON Web Tokens, JSON members whose key names a secret, e-mail addresses (thetcache token is kept).GeoLeaf.Log.exportDiagnostic()returns them as a JSON document. The boot failure screen's diagnostic now carries these entries and the modules that failed, every string redacted.GeoLeaf.Events.on()andonce()return the function that removes the listener — what the publishedIEventBus.oncontract already promised, while the facade returnedundefined. An anonymous handler can now be removed; the function returned byonce()cancels a listener that has not fired yet. It removes the very handler reference the call registered, sooff(event, handler)keeps working unchanged. Without adocument, the function does nothing.prefetchon a declared data origin (modules.offline.dataOrigins[].prefetch,DataOriginDeclaration.prefetch) — the integrator's statement that an origin's terms allow its resources to be downloaded ahead of use. It is distinct fromcacheable, which only keeps what the page already requested; it is ignored withoutcacheable: trueand dropped from anauthenticateddeclaration.
Changed
A profile's
write.dialectnow defaults tocollection, the dialect the core writes. The schema announcedrest, which the offline drain refuses: a layer that did not name a dialect was documented as one whose writes the core would set aside. No profile of this repository declares one, and a declared value is unchanged.A profile whose
profile.jsonfails no longer boots on the base configuration. The boot stops on the failure screen instead of starting a map without its profile, in silence.A module that throws during the boot no longer stops the modules that do not depend on it. They used to be skipped with it, with nothing more than a console warning. Its dependents are still skipped; when the interface depends on it, the boot fails and names it.
A
beforeBoothook that throws now hides the#gl-loaderveil. It used to stay up forever, over whatever the application drew next.geoleaf:boot:abortedis unchanged, and is now typed inGeoLeafRawEventMap.The offline preparation no longer downloads tiles from an undeclared third-party origin. A basemap flagged
offline: true, or a tiled layer, used to be downloaded whatever its origin — includingtile.openstreetmap.org, whose usage policy states that offline use is not permitted. Its tiles, style, glyphs and sprite now come only from the page's own origin, or from an origin declared withcacheable: trueandprefetch: true; anything else is skipped, with a warning naming the origin, and a refused style is not even fetched.enableTileCachekeeps its default.⚠️ Migration: an application that prepares tiles served from another origin — its own tile server on a separate host, or a provider it holds a licence for — adds
{ "origin": "https://…", "roles": ["tiles"], "cacheable": true, "prefetch": true }tomodules.offline.dataOrigins. Declare an origin only if its terms allow downloading ahead of use.The coordinates readout is no longer shown under a coarse pointer unless the profile asks for it. It follows the mouse only: on a touch screen it showed « Lat : --, Lng : -- » for good. An absent
modules.coordinates.enablednow means shown on a fine pointer and not under(pointer: coarse);trueshows it everywhere,falsehides it everywhere.GeoLeaf.Coordinates.isEnabled()andgetConfig().enabledreport that resolved value.The basemap credit is displayed. The map was created with MapLibre's attribution control turned off, and nothing replaced it: the
attributionof a basemap never reached the screen, although tile providers require it. MapLibre's control is now on, in the bottom-right corner — a button below 640 px wide (expanded at load, collapsed at the first drag), expanded where it fits. Theattributiona profile declares on a vector basemap (type: "maplibre"), read by no code until now, is added to the incoming style's sources that declare none. From 1440 px wide, the corner steps out of the right-hand panel.IMapAdapter.buildStyleChangeTransformaccepts{ attribution }.⚠️ An application that displayed the credit itself — through
modules.branding.text, for instance — now shows it twice: drop its own copy.
Removed
BREAKING —
GeoLeaf.Security.CSRFTokenand thecsrfwrite authentication. The module minted its token in the browser and checked it in the same context: no server could verify it, so it protected nothing while reading like a protection. Removed with it: thegeoleaf:csrf:refreshedandgeoleaf:csrf:rotatedevents, and the"csrf"value ofWriteAuthand of the profile schema'swrite.auth, a value no code read.@geoleaf-plugins/editorno longer sends anX-CSRF-Tokenheader with an image upload.⚠️ Removed without a prior announcement — an exception to the versioning policy, decided on 2026-09-13: nothing the module did could be relied on. Migration: authenticate writes with the bearer token of
@geoleaf-plugins/connector, which adds it to the requests it intercepts. A profile that declaredwrite.auth: "csrf"no longer validates: use"bearer"or"none"— the key is declarative, and no code reads it.
Fixed
Modifying or deleting an existing entity reaches its server row. With the offline capability on,
@geoleaf-plugins/editorqueues every write, and it names an existing entity by the id the map shows — its server identity. The store keyed that entity differently, so the edit landed on a second record with no server identity and the request went out as?id=eq.null: every modification and deletion of an existing entity missed its row, whether the layer had been downloaded or not.Storage.applyEditnow resolves such an identity to the stored entity of the same layer, or files a never-downloaded one undersrv:<id>; its report returns the key the edit landed on. A modified entity is no longer drawn twice offline, a deleted one leaves the offline map, and a modification whose entity has no server row yet is no longer sent.⚠️ Captures already set aside by this defect carry the server's refusal (
rejectedByServer), which is not requeueable. Editing the entity again sends a fresh edit that does reach the row; the old entry is then discarded throughStorage.discardQuarantined. A device that had them keeps one extra record per entity — the one the defect wrote — and a downloaded layer draws that entity twice until it is removed.A dead session no longer sets aside one capture a minute. The drain stops at the first
401, which is right, but every trigger afterwards met the same dead session and set the next capture aside: a tour went to quarantine one entry at a time, and nothing brought them back. The core's automatic triggers — network return, wake-up, periodic tick, a fresh write — now wait while a pass has stopped that way, and start again on the first pass that does not. A send asked for by hand is never gated.@geoleaf-plugins/connector1.3.1 closes the loop: on a successful sign-in or a renewed token it callsStorage.requeueAll("authRequired")thenStorage.pushOutbox()— the two public gestures an application that signs in by its own means makes too.A deletion that meets a concurrent change is no longer lost in silence. The zero-row answer was read for a modification only, so a filtered
DELETEanswered200 []— the row had changed since the capture — came out a plain success: the entry left the queue, the record left the store, and the row stayed on the server until the next download brought the entity back. The conflict is now detected and logged for a deletion as it is for a modification, then settled by the same policy —lastWriteWins, so the deletion is sent again without its filter and wins.A write no longer turns the next one into a conflict. The record kept the freshness marker it had before being sent, while the server moves it on every write: the next modification of the same entity was filtered on a stale marker and counted the device's own write as someone else's. The record now takes the marker returned with the written row, and an edit made while the previous write was on the wire is based on that write.
An edit made while its entity was being sent is kept. The drain wrote back the copy of the record it had read before the request, so a correction made during the request was overwritten, marked as sent, and the queued correction then sent the old state.
The Quick Start of the README creates a map. It passed
Core.init()the{ map: { target } }shape, which onlyGeoLeaf.init()reads, and never put the engine onglobalThis.maplibregl, the one place GeoLeaf reads it from: the call logged an error and returnednull. It now installsmaplibre-gl, setsObject.assign(globalThis, { maplibregl })and passesmapId. The npm recipes of the documentation had the second defect, and the COOKBOOK and FAQ loaded a profile throughCore.init({ configUrl }), an optionCore.initignores — they now useGeoLeaf.boot({ configUrl }).Core.inititself gains the TSDoc its declaration was published without..gl-poi-popup__herohas a rule — full width, height capped at 160 px, cover crop, rounded corners. The class was emitted with no stylesheet.An invalid inline configuration no longer hangs the boot. It rejected the loading promise of
GeoLeaf.loadConfigwithout callingonError, and the boot waited forever for a callback.Full-height panels fit a mobile browser tab.
.gl-page, the feature side panel, the filter panel and the share dialog were sized in100vh— the LARGEST viewport, browser toolbar hidden — so their bottom band sat under the toolbar, out of reach. They now use100dvh, with100vhkept as the fallback.A stalled photo upload no longer loses the photo.
@geoleaf-plugins/editorsent it with no time limit: on a network that stopped answering, the upload never ended, the photo was neither sent nor kept, and closing the form lost it. The upload now gives up after 30 s plus the time a 256 kbit/s uplink needs to send the file, and the photo is kept on the device and sent again later, as after any other failure.
[3.3.0] - 2026-09-09
Added
geoleaf:print:preview:readyjoins the typed event map (GeoLeafEventMap). Emitted by@geoleaf-plugins/printwhen the preview image is actually painted.⚠️ It is not a synonym of
geoleaf:print:render:end, and the distinction matters if you automate the print modal.render:endfires when the off-screen map goes idle, while the capture copy, the page composition and the JPEG encoding are all still ahead — around 17 Mpx of synchronous work at A3/300 dpi. Code that waited onrender:endto mean "the preview is ready" resumed inside that block. Wait forpreview:readyinstead.render:start/render:endare unchanged and keep bracketing the off-screen render.
[3.2.0] - 2026-09-08
Fixed — the offline drain no longer loses, orphans or condemns a field capture
Four defects on the same nominal path — a capture, a network cut, a return of network — each of which lost or condemned a capture without saying so.
An entry left inFlight by a dead session is now reclaimed. inFlight is written before the call and cleared by its outcome; a tab killed in between — the ordinary case on a phone that sleeps mid-sync — left the entry in a state nothing replays and no gesture exits. The capture was never lost, which is the worse half: the operator watched a due count that would not go down, on a queue whose "Retry" did nothing for that entry. The drain reclaims it in head of pass, and it is the entry's AGE that decides (new OutboxEntry.inFlightAt): a young one belongs to another tab that is sending it, and re-sending a DELETE earns a 404 — an immediate quarantine on an operation that had succeeded.
A 409 on a create now re-reads the server identity. A 409 says "I already have it"; it does not say WHICH row. The entry left the queue with serverId still null, so the entity was reputed synchronised locally and unreachable server-side, every later update filtering on id=eq.null. The create now re-reads through local_id — the key the UNIQUE constraint refused. If no row comes back, the 409 came from another constraint and nothing was written, so it is a refusal; if the re-read cannot conclude, nothing is decided and the entry stays replayable.
The second send of a lastWriteWins conflict is verified. The re-send drops the freshness filter, and that outcome was never checked: a 200 [] — the form in which this server says "zero rows" — emptied the queue while nothing had been written. Unfiltered, zero rows means the row is GONE, so it takes the same motive a 404 does. A null body still proves nothing and is not read as zero.
The replay budget counts time, not gestures. Three total attempts is defensible for a network coming back and absurd for three drains fired inside one minute — and the drain fires on network return and on the "Retry" button, with attempts persisted. Each failure now writes a nextAttemptAt on the entry (30 s, then ×4, capped at 8 min) that the drain walks past. Re-editing a failed capture resets its budget: a correction is a new intention, not a replay.
Fixed — an expired session no longer destroys field captures
A token that expires during a tour was unrecoverable, and the loss was silent. Three mechanisms had to be wrong at once, and they were.
The refresh could not be reached. On a 401 the interceptor cleared the token — RAM and IndexedDB — before attempting anything; the re-read that followed found nothing, so the refresh delegate was never called. And the delegate itself read a cache that evicts expired entries, so even when reached it gave up before the network, in exactly the case it exists for. The renewal is now attempted first and the token erased only after it fails; the delegate reads the stored record, expiry included — the server is the one that arbitrates.
The renewal request was being intercepted by the patch it travels through, which would have made it await its own in-flight promise and would have overwritten the Authorization header it carries — the expired token it exists to present. The authentication endpoint is now out of the interception perimeter.
A 401 was reported as rejectedByServer, the one quarantine motive the contract defines as "replay cannot fix" — so a capture was set aside with destruction as its only exit, for a cause that lifts by itself. It now takes the new authRequired motive, which is replayable, and it halts the drain: everything queued behind meets the same dead session, and in token mode the requests that follow no longer carry an Authorization header at all. 403 deliberately stays out of this: a known identity lacking a right is not fixed by signing back in.
And the drain reported a complete pass over a queue it had barely touched. skipped was hard 0 under a comment stating the drain "skips nothing" — true when written, false since the retry backoff landed. It now reports what was walked past.
Added
GeoLeaf.UI.destroyMobileToolbar()— the mobile pill had no teardown, and therefore no path back.Core.reattach()documented a rebuild recipe for#gl-right-panelonly, while writing "and its siblings": those siblings are four nodesinitMobileToolbarappends to the same shell, and a host following the recipe repaired the right half and broke the left. There are now two recipes, one per surface —destroyDesktopPanel()→initDesktopPanel()→activateDesktopPanel()for the panel,destroyMobileToolbar()→initMobileToolbar()for the pill. 🛑 Call either destroy BEFORE dropping the shell: both hand back nodes they had moved elsewhere — the mobile sheet holds the filter panel, the legend and the layer manager while open — and they can only hand them back to a parent that still exists.initMobileToolbar()is now idempotent per shell. The guard tests ownership (parentElement === glMain), not presence in the document: a host that mounts a new view before unmounting the old leaves two live shells, and a presence guard would find the old pill, do nothing, and leave the new view without a toolbar.GeoLeaf.Connector.logout()— ending a session was impossible: the surface carriedconfigureandopenLoginModalonly, and a device handed back kept a valid Bearer until expiry. It is a no-op ingetTokenmode, where the host owns the token and the plugin holds no copy.geoleaf:connector:signed-out— its counterpart:auth-errorsays the session DIED; this one says it was closed. A host must be able to tell them apart: one calls for a login window.- Guided reconnection:
:auth-errorhad two emitters and no listener, so the session ended in silence at the worst moment. The login window now reopens — once per failure, and only whenauth.uiwas asked for. QuarantineReason.authRequired, requeueable, andpushOutbox()reportshaltedBy.GeoLeaf.Storage.requeueAll(reason?)— the batch exit from quarantine. The per-entryrequeueQuarantined(id)takes a contract identifier that nothing displays, which is why it had no caller; yet what produces quarantines is never one entry. It delegates to the per-entry exit, so the rule that decides keeps one author, and what that rule refuses is counted inskipped.pushOutbox()reportsdeferred— entries walked past because their retry delay had not elapsed. Without it, a drain that does nothing is indistinguishable from an empty queue.OutboxEntry.inFlightAtandOutboxEntry.nextAttemptAt, both optional and additive.
Fixed — a locally-held photo has a return address, and the store can be read back
local_images records now carry endpoint, layerId, localId and fieldPath, and the module gains bindLocalImage(id, owner) and getLocalImage(id). No schema version change: these are unindexed fields on an existing store.
storeImageLocally rebuilt its record field by field, so endpoint — which the edit plugin did pass — was dropped in silence. Its retry loop opens with if (!img.endpoint) continue, so it skipped every image and reported a successful pass having attempted nothing. And no record referenced the feature carrying it, so an upload that did succeed had nowhere to send the resulting URL back to — which is why that URL used to be discarded.
The address is written after the fact, and it cannot be otherwise: a photo is taken while the form is open, hence before the feature exists — off-network its client identity is only minted when the edit is enqueued. Binding an image the caller no longer knows about is a no-op rather than an error: a token pointing at an already-purged record is an ordinary outcome of a long session, and raising there would fail a save.
getLocalImage had been removed on 08/08/2026 as redundant with the base64 data-URL storeImageLocally also wrote into the entity's data. That data-URL is gone — it was the defect, since it put the whole photo inside the attribute — so this read is no longer a second path to the bytes: it is the only one.
updateImageUploadStatus takes an object, and its relay declared the parameter string. The module reads status.uploaded and status.url; a caller passing the literal "uploaded" therefore wrote the record back as still pending, never stored the URL, and left cleanUploadedImages — a cursor over the index at 1 — with nothing to reclaim, indefinitely. The same photo left again on every reconnection and every boot.
[3.1.0] - 2026-09-01
Fixed — a mixed-geometry layer no longer paints its features in EVERY sub-layer
One declared GeoJSON layer becomes several MapLibre layers — fill, casing, line, circle, symbol — over a single source. Each of them now carries a geometry-type filter, so a sub-layer only paints the geometry it was built for.
Why it changed. MapLibre performs no geometry-type check when it fills a bucket: FillBucket triangulates the rings it is handed — a LineString included, which it closes into a filled polygon — and CircleBucket walks every point of every ring, so one line contributes one circle per vertex. A layer carrying more than one geometry kind therefore rendered each of its features in all of its sub-layers. Seen on a computed itinerary: the line drawn correctly, an opaque black polygon closing it, and a hundred dots where there were three stops. It also made the fill layer intercept clicks over the whole enclosed area.
Who is affected. Any layer whose source carries several geometry kinds, and any layer whose data ships empty and is written at runtime through GeoLeaf.Layers.setData() — such a layer cannot be scanned at creation, so all three sub-layer families are built for it. Homogeneous layers are unaffected: the filter matches everything they hold.
⚠️ setLayerFilter() composes with the guard instead of replacing it, and clearing a filter (setLayerFilter(id, null)) restores the guard rather than removing every filter. A caller reading back map.getFilter() on a GeoLeaf sub-layer will see the composition, not the expression it passed. Vector-tile layers are untouched — a source-layer is homogeneous by construction.
Changed — a colour a style does not declare falls back to #cccccc, no longer to MapLibre's black
fill-color, line-color and circle-color were simply not emitted when the style declared no fillColor / color, which left MapLibre's own default in place: an opaque #000000. A layer with an incomplete style therefore rendered as solid black, which reads as data rather than as an omission. The styleRules path never had that hole — it already fell back to the same #cccccc — so the two paths now agree.
⚠️ Size defaults are deliberately left alone. circle-radius, line-width and circle-stroke-width still fall back to MapLibre's values: most styles in the wild omit radius, and moving that default would resize nearly every point layer for a defect that is not this one.
Changed — a layer's declared geometryType is finally read, and it ADDS rather than restricts
The adapter's fast path only ever accepted GeoJSON names ("Polygon"), while the profile schema allows nothing but the lowercase vocabulary ("polygon", "polyline", "point") — so no profile ever reached it. Both vocabularies are now understood.
🛑 A declared kind adds sub-layers, it never removes any: only the data may say "unknown". A layer's declared kind is its semantic kind — the legend and the layer manager read the same field — and it can be narrower than its content (a computed itinerary is a polyline that also carries its stops). Declaring a kind now guarantees a sub-layer exists for what the boot data cannot show; it never narrows a layer down to that kind alone.
Added — boot({ config }) and boot({ configUrl }): the configuration is now INJECTED, not deduced
GeoLeaf.boot() accepts two new options. config hands the configuration over in memory: the boot applies it directly and issues no request for it. configUrl names an explicit URL to fetch it from. config wins over configUrl; without either, the path is still derived from the host page, unchanged — no existing integration moves.
Why it changed. The configuration path was an implied location, and an embedding application cannot always make the page imply it. Measured against a host router that answers unknown paths with its own HTML document in HTTP 200: the derived path returned HTML where JSON was expected, and the failure surfaced five errors away from its cause. config removes the request; configUrl removes the deduction. Both make the location a value the caller states, which is the only form an embedding host can control.
// The configuration is already in memory — no request is issued for it.
GeoLeaf.boot({ config: { map: { center: [4.85, 45.75], zoom: 12 } } });
// Served from somewhere the page cannot imply.
GeoLeaf.boot({ configUrl: "/assets/geoleaf/config.json" });Migration — possible, not required. _app.getProfilesBasePath stays in place, stays called, and returns the same string. What changes is its status, not its behaviour: it is no longer the only way to say where the configuration lives. An override installed solely to point the configuration elsewhere may be replaced by boot({ configUrl }), or dropped entirely if the configuration is held in memory. Doing nothing is a valid choice.
⚠️ This entry is late, and the lateness left a visible trace. The entry below on the rejecting configuration load referred to GeoLeaf.boot({ config }) as "new in this same release" while no entry described it — a dangling reference in a published changelog, pointing at something a reader could not find. Nothing in ci:local compares a cross-reference to the entry it names.
Removed — BREAKING: GeoLeaf.Sync.getHandlers() (pre-adoption window)
The plural accessor is gone from the Sync façade and from the internal registry seam. registerHandler(id, handler) and getHandler(id) are the whole contract — they are what the offline replay path actually uses, one id at a time.
Why it could go in a minor. This lands under the pre-adoption window of VERSIONING_POLICY.md: no consumer follows a semver range today, and the one measured consumer manifest never names the member. The accessor was introspection-only — every one of its ten call sites lived under __tests__/ with the member as its own oracle — and keeping it pinned a registration-order promise nothing needed.
If you did adopt it, enumerate your own registrations instead: you know the ids you registered; resolve them with getHandler(id).
Fixed — the proximity radius slider dropped fractional values
A profile is free to declare fractional radius bounds on its kind: "proximity" filter field — radiusStep: 0.1 for 100 m granularity, radiusDefault: 0.5 for a 500 m starting radius. Those bounds reached the <input type="range"> intact, then two parseInt reads truncated them.
What you saw. Every radius below 1 km collapsed to zero: the circle vanished and the filter kept nothing. Above it, values were rounded down — 1.4 km became 1.0 km. With radiusStep: 0.1 over a 0.1–10 km range, the slider offered 99 notches and produced 11 distinct radii, the first 9 of them dead. The first click on the map was worst: the toolbar button read the slider's default the same way, so proximity armed at a radius of 0 before the slider had been touched at all.
And the label disagreed with the map. It was written from three different sources — the configured number when the bar was built, the truncated integer while dragging, the raw defaultValue string on reopen. So the pill could read "0.5 km" next to a circle of radius zero, then flip to "0 km" on the first movement. The displayed radius and the applied radius are now read from one number.
Also fixed. GeoLeaf.Filter.proximity.toggle(map, 0) used to pass the zero straight through to the circle — the ?? 10 default only caught null and undefined. Any non-finite or non-positive radius now falls back to 10 km.
The geometry was never at fault: the drawn polygon and the filter predicate already stood on the same EARTH_RADIUS_M, and still do.
No action needed. Integer bounds behave exactly as before, and the in-core defaults (min: 1, max: 50, step: 1, default: 10) are unchanged.
Changed — the proximity radius label goes through the dictionary
The pill's radius label was built from an inline `${…} km` template in three places, while format.proximity.radius sat declared in all six language files with no consumer. It is wired now, so the unit is overridable like every other label.
BREAKING — a failed configuration load now REJECTS instead of degrading
GeoLeaf.Config.loadUrl() used to swallow its failure: an unreachable URL, invalid JSON, or a content type refused by strictContentType were logged, and the configuration already in place was returned. The promise resolved. It rejects now, and the boot stops.
Why it changed. Because the resolution made the failure unattributable. Measured in a real browser against a host router answering its own HTML document in HTTP 200 on the configuration path: the console carried two precise errors — then, because the promise had resolved, the initialisation manager announced "Configuration loaded successfully", and five consequence errors followed. None of them named the cause. An integrator read a success, then five map failures, and went hunting the map. Logging the cause made the failure legible; only rejecting makes the contradiction impossible.
Who this breaks. Any caller that relied — knowingly or not — on the boot continuing after a failed configuration load. There is no silent path any more: the failure surfaces where it happens.
What to do. If the configuration is already held in memory, GeoLeaf.boot({ config }) (new in this same release) removes the request entirely and the question does not arise. Otherwise, handle the rejection where you call loadUrl.
⚠️ This entry is late, and that is worth recording. The change landed on 20/08/2026; this entry was written after a review found that the published .d.ts still promised the opposite behaviour ("Failure is contained, not thrown"). The deprecation policy added below exists because three removals had already shipped under that same silence — this is the first case that tested it, and it failed the test until the review caught it. Nothing in ci:local can see a sentence that stopped being true.
Added — a deprecation policy, and the gate that makes it verifiable
VERSIONING_POLICY.md gains a Deprecation section. Until now the file said a breaking change lands in a MAJOR without saying how one gets there — so nothing described what an announcement is, how long it must stand, or what counts as one. Three removals had already happened under that silence, each leaving a trace in a different form, none of them machine readable, and two of them broke an external consumer without warning.
The policy names three artifacts, all required: the @deprecated TSDoc tag on the symbol, an entry here, and an entry in docs/reference/consumers/DEPRECATIONS.json. Only the first travels inside the npm tarball — it rides in the emitted .d.ts — which is why it is required rather than recommended.
The rule on timing: the announcement must survive at least one published minor, and removeIn is the next MAJOR. A release that both announces and removes announces nothing.
This is enforced rather than declared. CC-10 of scripts/verify-consumer-contract.cjs refuses a path that leaves the consumed public surface without a register entry and a real tag on its symbol.
Changed — three maxRetries aliases are no longer tagged @deprecated
CacheManagerConfig.maxRetries, RetryConfig.maxRetries and RetryOptions.maxRetries are kept aliases, not deprecations: they are normalised into maxAttempts on read, and nothing schedules their removal. They carried a @deprecated tag, which struck the key through in editors and promised a removal that was never planned.
The 3.0.0 entry below describes maxRetries as "a deprecated, normalised alias". The normalisation is unchanged and no configuration behaves differently; what changes is that the tag no longer claims a removal. Under the new policy, @deprecated marks a symbol that is going away — a kept alias is a different statement, and conflating the two makes the tag lie to autocompletion.
Added — Core.isAttached() and Core.reattach()
GeoLeaf.Core gains two members for hosts that move a map around their layout instead of destroying and rebuilding it — a full-screen toggle, a tab switch, a panel that re-mounts.
const Core = GeoLeaf?.Core;
if (Core && !Core.isAttached("main")) {
Core.reattach("main", document.getElementById("fullscreen-slot")!);
}isAttached(mapId): boolean— the map is registered and its container is still in the document. Deliberately distinct fromhasMap(), which only answers the first half: a host that removes the map's subtree without callingdestroy()leaves a registered map that renders nowhere, andhasMap()still saystrue. Returnsfalseafterdestroy(), never throws.reattach(mapId, parent): boolean— moves the live map into another parent. The whole container is re-parented; its children are not moved individually. That is imposed by the engine: MapLibre memorises the element it was constructed with, so moving the children leavesmap.getContainer()pointing at the old node and every subsequent measurement wrong.
⚠️ The panels do not follow the map. #gl-right-panel and its siblings live in the shell, not inside the map container, so they stay where they are. Rebuilding them at the new location is the host's call, through three already-public exports: GeoLeaf.UI.destroyDesktopPanel() → initDesktopPanel() → activateDesktopPanel(). Making them follow would tie this API to the shell's DOM — the coupling it exists to remove.
The map adapter contract gains an optional resize?(): void, which reattach() calls after the move so the WebGL drawing buffer picks up the new container size. Optional, like getNativeMap?(), so test doubles stay conformant. Custom adapters that do not implement it are accepted; their canvas will keep its pre-move dimensions.
Changed — GeoLeaf.getMap() and GeoLeaf.getAllMaps() return the live map
The top-level shortcuts read the same registry as GeoLeaf.Core. They previously returned null and [] for every live map, whatever the application did: they read a mirror registry that only GeoLeaf.createMap() ever filled, and the boot path does not go through it. The mirror is gone.
GeoLeaf?.getMap?.("main") === GeoLeaf?.Core?.getMap("main"); // true — was null === adapter
GeoLeaf?.getAllMaps?.().length === GeoLeaf?.Core?.listMaps().length; // true — was 0Integrator impact. This is a widening: code that received null now receives an adapter. A fallback written against the old behaviour — GeoLeaf.getMap(id) ?? somethingElse — stops taking its second branch. Nothing that worked before stops working.
APIFactoryManager.removeMapInstance() changes with it: it used to drop the mirror entry and leave the real map running, and now destroys the map, as its name always claimed.
Changed — geoleaf:popup:action carries button, setBusy() and close(), and is no longer JSON
The detail of geoleaf:popup:action gains the three members that GeoLeaf.Popup.registerActionHandler provided before ADR-07 removed it: button (the clicked node), setBusy(busy) (toggles disabled, aria-busy and gl-poi-popup__action--busy) and close() (closes the surface the button was rendered in — the popup or the side panel, never both). A host action can finally show a pending state, close on success, and report a failure in place.
GeoLeaf.Events.on("geoleaf:popup:action", (e) => {
const d = e.detail;
if (d.actionId !== "tickets:create-request") return;
d.setBusy(true);
void createRequest(d.featureId)
.then(() => d.close())
.finally(() => d.setBusy(false));
});Integrator impact — the payload is no longer serialisable. JSON.stringify(e.detail) now throws (circular DOM reference), and passing the detail to postMessage or a Worker throws DataCloneError. If you log, forward or persist the detail, copy the fields you need first: const { actionId, layerId, featureId, properties } = e.detail;. Subscribing is unaffected — GeoLeaf.Events.on/off/once accepts this key exactly as before, with the same type inference.
The event is dispatched as a raw CustomEvent and its key moved to GeoLeafRawEventMap, joining geoleaf:toolbar:action and geoleaf:layer-manager:panel. Routing it through the sanitising bus would have delivered button as {} and the two functions as undefined without any error — which is why the map is split rather than the payload.
⚠️ Two corrections to previously published documentation, both of which were wrong before this release:
EVENTS_API.mdannounced thatpropertiesdefaults toid,name,title,label. It never did: withoutpayloadFields,propertiesis{}, and has been since the feature shipped on 2026-07-29. The default goes to confidentiality — this is adocumentevent any script on the page can hear. If you relied on the documented default, declarepayloadFieldsexplicitly.- Action buttons were attributed to a plugin named
@geoleaf-plugins/feature-info. No such package exists;feature-infois a capability built into@geoleaf/core.
Fixed at the same time: an action button clicked in the side panel emitted featureId: null and no lngLat, because that surface passed only layerId to the render context. Both surfaces now pass the full context. The popup was unaffected.
⚠️ Widened attack surface, stated rather than implied. The detail now carries a function that closes the popup, on a document event. Any script on the page can call it — as it could already call document.querySelector(".gl-poi-popup__action").click(). The confidentiality rule on properties is what guards this channel, and it is unchanged.
Changed — the six @geoleaf-plugins/connector events are now prefixed geoleaf:connector:
connector:authenticated, connector:token-refreshed, connector:auth-error, connector:credential-button-clicked, connector:signup-requested and connector:forgot-password-requested become geoleaf:connector:…. They were the only public events in the product outside the geoleaf: naming domain.
This was not cosmetic. The repository's event coverage gate is anchored on ^geoleaf:, so all six were structurally invisible to it — never typed, never listed, never counted. Prefixing them made the gate demand all six in the same run, and they are now typed in GeoLeafEventMap alongside every other public event.
Integrator impact — this breaks listeners. Any document.addEventListener("connector:…") must be renamed; the old names are no longer emitted. The payloads are unchanged, and the two cancelable events (signup-requested, forgot-password-requested) keep their contract: preventDefault() still blocks the link navigation.
⚠️ If you also run the downstream connector plugin, note that geoleaf:connector:* is now a shared namespace. The six names above do not collide with the six it emits (ready, bbox-loading, bbox-loaded, data-version-changed, error, auth-required), but nothing prevents a future clash — check both lists before adding a name.
Added — geoleaf:panel:opened / geoleaf:panel:closed, and two POI panel events that finally fire
The desktop tab panel (layers / filters / legend) now reports both directions, with { tabId }. Switching tab emits closed then opened, in that order; opening from a closed panel emits opened alone, and closing an already-closed panel emits nothing. Both the pointer path and GeoLeaf.UI.openPanel() are covered — an event describing only the programmatic path would be half a contract.
geoleaf:poi:panel:open and geoleaf:poi:panel:close were declared in the published types since v1 and never emitted: a listener compiled and never ran. They are now emitted by the feature-information side panel. poiName resolves through the same path as the displayed title, so the event carries what the user reads.
⚠️ A feature without a stable id emits nothing rather than a forged poiId — two anonymous features would otherwise be indistinguishable to a listener.
⚠️ Two panel families, and they are different panels: geoleaf:poi:panel:* is the feature drawer, keyed by poiId; geoleaf:panel:* is the desktop tab strip, keyed by tabId.
Added — nine geoleaf:table:* events and geoleaf:geojson:visibility-changed are typed
The table plugin's nine events (opened, closed, layerChanged, sortChanged, selectionChanged, zoomToSelection, exportSelection, exportLayer, highlightSelection) now appear in GeoLeafEventMap with their payloads. They were emitted all along, but their names were assembled at runtime, so no tooling could see them.
No behaviour changed and no name changed — only the declarations. Each of the nine is emitted on both document and the MapLibre bus: subscribe to one, never both.
geoleaf:geojson:visibility-changed is typed too. Prefer geoleaf:layer:toggle for new work: it carries the same payload and fires for every source, whereas visibility-changed is not re-dispatched on document when the change comes from a zoom recalculation.
Fixed — a click on a feature now emits one geoleaf:feature:click, whatever its geometry
The kernel bound the click handler to every sub-layer of a layer, while it bound hover to a single one. Sub-layers are stacked per geometry and cumulatively, so a single click emitted 2 events on an icon point, 2 on a cased line, 3 on a cased polygon and 4 on a vector tile. Downstream, feature-info closes and reopens its popup on each event, so the popup flickered and the second render replaced the first for the same feature.
Click and hover now share one selection (fill → circle → line → all), so a gesture cannot be reported twice. Integrator impact: a listener on geoleaf:feature:click that de-duplicated events itself can drop that workaround; one that counted events will now count fewer.
⚠️ On an icon point the clickable area becomes the circle rather than the icon's collision box. Nothing becomes unreachable — every point renders a -circle sub-layer unconditionally — and nothing is lost on the axes; only the diagonal ring of the icon's padding is.
Fixed — pre-cached assets are served offline again (PWA second load)
The service worker wrote its pre-cache into one bucket and looked for it in another: install derived its scope from the injected asset list, while fetch decided from a file-extension list that does not accept json. Two pre-cached entries — manifest.json and the root profile config — were therefore stored and never found, and the application could not boot offline on a second load for want of its configuration.
The router now resolves membership against the pre-cache itself, so what install writes is what fetch reads. No configuration change is required.
Changed — an HTTP 501 from the editor backend is no longer treated as a network failure
@geoleaf-plugins/editor classified every non-4xx response as "network", which auto-adapter treats as retryable — so a 501 (the server does not implement the verb) was queued for retry and the user was told to try again. It never would have succeeded.
A 501 now raises PersistenceError with the new kind "capability", is never queued, and surfaces a dedicated message (editor.error.operationNotSupported, added in all six locales). This aligns the editor with the decision the core already applies to its own offline queue. Integrator impact: code branching on err.kind === "network" to detect a 501 must branch on "capability" instead.
[3.0.0] - 2026-08-12
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; where a migration path exists it is written next to the entry that needs it.
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.
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.
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, 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: the Configuration Guide (§12) extended 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.
