URL Permalink / Deep Linking
TIP
How-to here, contract elsewhere. This page explains how to use the feature. The contract — scope, configuration, exposed API, boundaries — lives in permalink.md. Where the two pages disagree, the capability sheet wins.
Applies to:
@geoleaf/corev2.0.0+
Table of contents
- Overview
- Activation
- URL parameters
- Encoding modes
- JavaScript API
- Practical examples
- Security
- Known limitations
Overview
WARNING
BREAKING (v3.0.0) — the poi field (gl_poi) is removed: it made a URL→state→URL round trip without ever influencing the behaviour of the application (a relic of the POI era, since dissolved).
The Permalink module serialises the current map state (centre, zoom, layer visibility, active filter) into the browser URL. This makes it possible to:
- Share a link pointing at a precise map view
- Reload the page and land on exactly the same view
- Embed a specific view in a marketing campaign (GA4 / Matomo)
Synchronisation uses history.replaceState() — no entry is added to the browser history, and the "Back" button is unaffected.
Internal architecture
capabilities/permalink/
├── public-api.ts // Stateful facade — bound to GeoLeaf.Permalink
├── permalink-url.ts // Reading and writing the URL (readUrl, buildUrl)
├── permalink-field-filters.ts // The per-field filter grammar
├── permalink-sync.ts // Applying a state to the map, continuous synchronisation
└── permalink-restore.ts // Restoring layers, filters and theme after bootThe module is also exposed as the Permalink named ESM export of @geoleaf/core.
Activation
Through the JSON profile (geoleaf.config.json or profile.json)
{
"modules": {
"permalink": {
"enabled": true,
"mode": "hash"
}
}
}The permalink is enabled by default: it is opt-out, and a profile turns it off with enabled: false. It has no performance impact while disabled.
Configuration options
| Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Enables the permalink. |
mode | "hash" | "query" | "compact" | "hash" | URL encoding strategy (see below). |
fields | string[] | ["layers", "shownLayers", "filter", "categories", "tags", "rating", "theme"] | Optional facets to include in the URL. |
Valid values for fields: "layers", "shownLayers", "filter", "categories", "tags", "rating", "theme". The view — latitude, longitude, zoom — is always written and cannot be listed: an empty list keeps the view alone.
Four of the names gate the filter, by the kind of its fields (modules.filter.fields[].kind): "filter" the text fields, "categories" the taxonomy ones, "tags" the tag ones, "rating" the range ones. A filter field is written to the URL, and restored from it, only when the name of its kind is listed.
Example — map position only, no filters
{
"modules": {
"permalink": {
"enabled": true,
"mode": "hash",
"fields": []
}
}
}URL parameters
In hash or query mode, the following parameters are used. All of them are prefixed with gl_ to avoid collisions with other fragments in the URL.
| Parameter | Example | Description |
|---|---|---|
gl_lat | 48.857445 | Latitude of the map centre (6 decimal places). |
gl_lng | 2.347211 | Longitude of the centre (6 decimal places). |
gl_zoom | 13 | Zoom level (integer). |
gl_layers | layer1,layer2 | IDs of the layers hidden by the user, comma-separated. |
gl_shown | layer3 | IDs of the layers shown by the user beyond the theme. |
gl_theme | environment | ID of the active data theme. |
gl_f.<id> | see below | One parameter per constrained field of the filter panel. |
In compact mode, all of these parameters are replaced by a single gl parameter encoded as base64 JSON.
The filter — one parameter per field
Since 3.15.0, each constrained field of the filter panel has its own parameter, named by the field's id in modules.filter.fields:
| Field kind | Parameter | Value |
|---|---|---|
text | gl_f.search | the query, as typed |
tag | gl_f.tags | free,family — the selected values, comma-separated |
range | gl_f.altitude | 100..500 — an open bound is left empty: 100.., ..500 |
taxonomy | gl_f.categories | water,forest,spring — the checked values |
gl_f.categories.sub | water/spring — each checked sub-category, with its category |
https://mymap.example.com/#gl_lat=48.857445&gl_lng=2.347211&gl_zoom=13&gl_f.search=lake&gl_f.altitude=100..500Two fields of the same kind keep each their own value, a range keeps both its bounds, and a sub-category is restored under the category it was checked in. boolean and proximity fields are not carried. In a list, a ,, / or % inside a value is escaped (%2C, %2F, %25).
Links shared before 3.15.0 keep working. They ranged the filter by kind — gl_filter for the text, gl_cats, gl_tags, and gl_rating for the lower bound of a range — and are restored as they were: every field of a kind receives that kind's value. The application no longer writes those four parameters; a link carrying both forms is read per field.
Encoding modes
"hash" (default — recommended)
The state is encoded in the URL fragment (#). No HTTP request is generated when the page is reloaded.
https://mymap.example.com/#gl_lat=48.857445&gl_lng=2.347211&gl_zoom=13Best for: static deployments (Nginx, GitHub Pages, S3, CDN).
"query"
The state is encoded in the query string (?). The server must return the same HTML whatever the query string is.
https://mymap.example.com/?gl_lat=48.857445&gl_lng=2.347211&gl_zoom=13Best for: server applications able to pass the query string through to the client.
"compact"
The state is encoded as base64 JSON in the fragment. URLs are shorter, but no longer human-readable.
https://mymap.example.com/#gl=eyJsYXQiOjQ4Ljg1NywibG5nIjoyLjM0Nywiem9vbSI6MTN9Best for: complex states (many hidden layers, long filter) or sharing through a QR code.
Transparent auto-compact
In "hash" and "query" modes, when the state grows beyond 200 characters (many hidden layers, for instance), GeoLeaf automatically switches to the compact format, transparently for the user. The switch is silent.
JavaScript API
The module is reachable through GeoLeaf.Permalink.* (CDN/global) or as a named ESM export.
ESM import
import { Permalink } from "@geoleaf/core";GeoLeaf.Permalink.init(config)
Initialises the module with the configuration taken from the active profile. Called automatically at boot — not needed in normal use.
GeoLeaf.Permalink.init({ enabled: true, mode: "hash" });GeoLeaf.Permalink.readAndStore()
Reads the current URL and caches the parsed state. Called automatically before the map is created.
GeoLeaf.Permalink.applyStoredState(map)
Applies the cached state to the map and the UI. Called automatically once every module is initialised.
Parameter: map — the map adapter, as GeoLeaf.Core.getMap() returns it (IMapAdapter), not the native MapLibre map.
GeoLeaf.Permalink.startSync(map)
Starts continuous synchronisation (listens to the MapLibre moveend event). Called automatically — not needed in normal use.
GeoLeaf.Permalink.getState()
Returns the permalink state currently loaded (parsed from the URL at start-up), or null when no permalink was present.
const state = GeoLeaf.Permalink.getState();
// → { lat: 48.857, lng: 2.347, zoom: 13, fieldFilters: { search: "coffee" } }
// → nullfieldFilters holds the per-field filter parameters, keyed by field id, valued as written in the URL. A link shared before 3.15.0 gives filter, categories, tags and rating instead.
GeoLeaf.Permalink.buildUrl(state?)
Serialises a state (or the current state) into a URL string.
// Current stored state
const url = GeoLeaf.Permalink.buildUrl();
// → "#gl_lat=48.857445&gl_lng=2.347211&gl_zoom=13"
// Explicit state
const url = GeoLeaf.Permalink.buildUrl({ lat: 44.0, lng: 3.0, zoom: 10 });
// → "#gl_lat=44.000000&gl_lng=3.000000&gl_zoom=10"GeoLeaf.Permalink._reset() (test only)
Resets the internal state. Reserved for tests.
Practical examples
Shareable link — copy the current URL
const permalinkUrl =
window.location.origin + window.location.pathname + GeoLeaf.Permalink.buildUrl();
navigator.clipboard.writeText(permalinkUrl);"Share" button in an application
<button id="share-btn">Share this view</button>document.getElementById("share-btn").addEventListener("click", () => {
const url = window.location.origin + window.location.pathname + GeoLeaf.Permalink.buildUrl();
if (navigator.share) {
navigator.share({ title: "Map view", url });
} else {
navigator.clipboard.writeText(url);
alert("Link copied to the clipboard");
}
});Analytics — send the shared view to GA4
document.addEventListener("geoleaf:map:ready", () => {
const state = GeoLeaf.Permalink.getState();
if (state) {
// Page opened via a permalink
gtag("event", "permalink_opened", {
map_lat: state.lat,
map_lng: state.lng,
map_zoom: state.zoom,
});
}
});Compact mode — sharing through a QR code
{
"modules": {
"permalink": {
"enabled": true,
"mode": "compact"
}
}
}Turning the permalink off
The permalink is active unless a profile says otherwise, in hash mode with every field included: a profile that declares nothing has it. To turn it off:
{
"modules": {
"permalink": { "enabled": false }
}
}Security
The Permalink module applies the following measures to prevent injection or exploitation through the URL:
- Numeric values (
lat,lng,zoom) are validated withvalidateCoordinates()andvalidateNumber()from thesecuritymodule (packages/core/src/kernel/security/index.ts). Any out-of-range or non-numeric value is silently ignored (permalink state =null). - Layer lists are capped at 100 entries.
- Text fields (
filter) are truncated to 200 characters. - The per-field filter parameters are capped in number and in length; a range must read as a finite interval, or it is ignored; a parameter name that would reach an object's prototype is dropped; and a parameter is applied only to a field whose kind the
fieldslist lets through. - In compact mode, the base64 data is parsed with
JSON.parse()inside atry/catch. Any invalid or malformed payload is ignored. - No
innerHTMLis used in this module.
Known limitations
| Limitation | Detail |
|---|---|
| Multiple tabs | Two tabs open on the same map change the same URL fragment without coordination. Expected behaviour. |
file:// protocol | history.replaceState() is not available on the file:// protocol. URL synchronisation is silently disabled. |
| SSR / Node.js | The module detects the absence of window and returns null — no error is raised in a server context. |
GeoLeaf Core — © 2026 Mattieu Pottier — MIT License
