Skip to content

Commit d657ffb

Browse files
authored
feat(time-slider): drive a layer's internal time dimension via a generic temporal adapter (#1450)
* feat(time-slider): drive a layer's internal time dimension via a temporal adapter The Time Slider understood two kinds of temporal layer: a vector layer filtered by a timestamp property, and a raster time series of dated sources. Neither fits a data cube, which is one store whose time is an internal dimension the timeline should index into. Zarr layers therefore could not join the timeline at all, and each ended up with its own bespoke time control. Add a third binding kind: a generic per-layer temporal adapter. - `TemporalLayerAdapter` (getTimeValues + setTime) with a registry, and a `SelectorTimeBinding` persisted on `layer.metadata.timeBinding` beside the vector binding so it survives a project round-trip. The time values themselves stay transient: they belong to the data store, not the project. - The slider owns the snapping, so it skips a tick that lands on the slice already showing and applies at most one change per throttle interval per layer. A bound cube shares the track with vector bindings and dated overlays: the range is their union and the widest dataset sets the stepping granularity. - `addZarrLayer` registers an adapter for a store's `time` axis, decoding CF `units` ("days since 1970-01-01") from the store's own metadata. The units are consulted before the generic reader because a raw `1980` is a plausible calendar year *and* a plausible days-since offset. A calendar Gregorian arithmetic cannot decode (360_day, noleap) reports no axis rather than a drifting one. - Plugins can register an adapter for their own custom layers via `app.registerTemporalLayer(layerId, adapter, { bind: true })`. - The Layers panel offers "Bind time dimension to Time Slider" for any layer with an adapter, binding in one step with no property to pick. Fixes #1448 * Address CodeRabbit review feedback - Fix an unregister that escaped its guard: the Zarr control's `layerremove` handler dropped *every* cube's temporal adapter when any one cube was removed, so a second cube silently stopped tracking the timeline. The new two-cube test CodeRabbit asked for is what caught it. - Serialize `setTime` per layer. Throttled ticks fired detached async tasks, so two chunk fetches for the same layer could resolve out of order and strand the renderer on the older slice while the memo recorded the newer index. Applies now chain per layer, so the last requested index is also the last applied. - Key the in-flight add context by store URL instead of two module-level slots. `addCloudNetcdfLayer` runs off `addZarrRasterLayer`'s queue, so overlapping adds could clobber each other's headers or kerchunk refs and resolve a cube's time axis against the wrong credentials. - Stop caching a metadata lookup that found nothing, so a transient outage (or a first, unauthenticated add of a store later added with credentials) no longer permanently disables the binding for that store. - Bound each metadata fetch with `AbortSignal.timeout`, so a store that accepts the connection and never answers cannot leave the cached promise -- and every later add awaiting it -- pending forever. - Clear a layer's transient `timeFilter` when a selector binding replaces a vector one, matching what the vector bind dialog already does. - Reuse each locale's established term for the Time Slider rather than inventing a second name for the same dock, and fix the Arabic gender agreement (`هذا الطبقة` -> `هذه الطبقة`). * Address CodeRabbit review feedback (round 2) Same-URL concurrent adds could still cross each other. `addCloudNetcdfLayer` ran off the queue that `addZarrRasterLayer` uses, so two adds of one store could both be inside `control.addLayer` at once. The control's `layeradd` carries no correlation id, so each add's capture handler then latched onto whichever event fired last: one layer got two adapters (the later registration winning, with the wrong context) and the other got none. Keying the pending context by URL, as the previous round did, could not fix this -- the URL is the same. So: - Route every programmatic add through one `queueZarrAdd` helper, which is what `addZarrRasterLayer` already did and what its own comment says is needed ("two overlapping adds would each see the other's event and could return the wrong layer"). `addCloudNetcdfLayer` simply was not on it. - Drop the URL-keyed context map. Each add now registers its own layer's adapter after its add resolves, with its own headers/references in lexical scope. A refcounted set of in-flight programmatic URLs is all that remains, so the shared handler knows to leave those layers alone and only registers the Zarr panel's own adds (which carry no context). The new test asserts the guarantee directly -- `addLayer` is never re-entered -- and fails against the un-queued version. * feat(zarr): add a time-series sample dataset to the Zarr Layer panel The panel's only sample was the CarbonPlan climate cube, whose non-spatial dimensions are `band` and a bare 1-12 `month` climatology with no CF `units`. That is not a series of instants, so no temporal adapter is registered for it and the Layers panel correctly offers no bind action -- leaving the Time Slider binding from this PR with nothing to demonstrate. Add NOAA OI SST V2 monthly means (1981-2023, 1-degree global, public domain), published to Source Cooperative and chunked one time step per chunk (~165 KiB) so stepping the timeline is a single small read. The ARCO weather archives are chunked along time instead, which is right for extracting a series at a point but means drawing one instant pulls the whole series for every spatial tile. The entry carries its own variable, color limits, colormap and selector, because the panel's `default*` options are the CarbonPlan sample's: `sst` against a 0-300 ramp is a flat white wash, and `{ band, month }` names dimensions this store does not have. That needs maplibre-gl-components 0.28.0 (opengeos/maplibre-gl-components#123), bumped here. `ZARR_COLORMAP_STOPS` moves above `ZARR_OPTIONS` because that initializer now resolves a ramp; a const referenced before its declaration would throw at module evaluation.
1 parent 25b2426 commit d657ffb

32 files changed

Lines changed: 2279 additions & 45 deletions

apps/geolibre-desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
"maplibre-gl": "^5.24.0",
6565
"maplibre-gl-3d-tiles": "^0.5.4",
6666
"maplibre-gl-basemap-control": "^0.13.0",
67-
"maplibre-gl-components": "^0.27.0",
67+
"maplibre-gl-components": "^0.28.0",
6868
"maplibre-gl-duckdb": "^0.2.3",
6969
"maplibre-gl-earth-engine": "^0.4.2",
7070
"maplibre-gl-enviroatlas": "^0.1.1",

apps/geolibre-desktop/src/components/panels/LayerPanel.tsx

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
useEffect,
99
useMemo,
1010
useRef,
11+
useSyncExternalStore,
1112
useState,
1213
} from "react";
1314
import { useTranslation } from "react-i18next";
@@ -31,9 +32,12 @@ import {
3132
canEditLayerGeometry,
3233
detectTimePropertiesFromRecords,
3334
formatTimeExtentInput,
35+
getTemporalLayerAdapter,
36+
getTemporalLayersVersion,
3437
getLayerTimeBinding,
3538
isTileVectorLayer,
3639
isTimeSliderIdle,
40+
subscribeTemporalLayers,
3741
parseTimeValue,
3842
sampleTileFeatureRecords,
3943
BASEMAP_CONTROL_PLUGIN_ID,
@@ -61,7 +65,7 @@ import {
6165
placeholderMessage,
6266
} from "@geolibre/map";
6367
import { getIsMobileViewport } from "../../hooks/useIsMobileViewport";
64-
import { createAppAPI, usePluginRegistry } from "../../hooks/usePlugins";
68+
import { bindTemporalLayer, createAppAPI, usePluginRegistry } from "../../hooks/usePlugins";
6569
import { useDesktopSettingsStore } from "../../hooks/useDesktopSettings";
6670
import {
6771
clearFeatureSelection,
@@ -642,6 +646,11 @@ export function LayerPanel({
642646
// stale async scan or confirm (even for the same layer reopened) is dropped
643647
// when it no longer matches the latest token.
644648
const bindRequestRef = useRef(0);
649+
// A layer becomes temporal when its renderer finishes resolving a time axis
650+
// (a Zarr cube loads its `time` coordinate asynchronously), which happens
651+
// outside the store, so the menu subscribes to the adapter registry to offer
652+
// "Bind to Time Slider" as soon as one appears.
653+
useSyncExternalStore(subscribeTemporalLayers, getTemporalLayersVersion, getTemporalLayersVersion);
645654
const { isActive: isPluginActive, toggle: togglePlugin } = usePluginRegistry();
646655
const [internalCollapsed, setInternalCollapsed] = useState(getIsMobileViewport);
647656
// In the shared left-sidebar mode the parent owns collapse (controlled);
@@ -1713,6 +1722,23 @@ export function LayerPanel({
17131722
closeBindTimeSliderDialog,
17141723
t,
17151724
]);
1725+
// Bind a layer whose time is an internal dimension (a Zarr data cube's `time`
1726+
// axis, or a plugin's own custom layer). There is nothing to ask the user:
1727+
// the adapter already knows the axis, so the binding is written and the dock
1728+
// opens in one step rather than through the property-picking dialog.
1729+
const handleBindTemporalLayer = useCallback(
1730+
(layer: GeoLibreLayer) => {
1731+
const adapter = getTemporalLayerAdapter(layer.id);
1732+
if (!adapter) return;
1733+
if (bindTemporalLayer(layer.id, adapter, mapControllerRef)) return;
1734+
setRefreshStatuses((current) => ({
1735+
...current,
1736+
[layer.id]: { type: "error", message: t("layers.bindNoTimeDimension") },
1737+
}));
1738+
scheduleStatusClear(layer.id);
1739+
},
1740+
[mapControllerRef, scheduleStatusClear, t],
1741+
);
17161742

17171743
// Remove a layer's binding and clear its transient time filter so it shows
17181744
// every feature again. The Time Slider stays active for any other bindings.
@@ -2488,7 +2514,12 @@ export function LayerPanel({
24882514
// filter evaluated per feature as each tile decodes, so it needs no
24892515
// local copy of the data (see the bind dialog for how the timeline's
24902516
// extent is established without one).
2491-
const canBindTimeSlider = layer.type === "geojson" || isTileVectorLayer(layer);
2517+
// A layer whose time is an internal dimension (a Zarr data cube)
2518+
// binds through its registered temporal adapter instead, with no
2519+
// property to pick: see handleBindTemporalLayer.
2520+
const temporalAdapter = getTemporalLayerAdapter(layer.id);
2521+
const canBindTimeSlider =
2522+
layer.type === "geojson" || isTileVectorLayer(layer) || Boolean(temporalAdapter);
24922523
const timeBinding = getLayerTimeBinding(layer);
24932524
// Raster/COG layers backed by a downloadable file (a retained
24942525
// local-bytes blob URL or a source URL) export to GeoTIFF.
@@ -2924,6 +2955,8 @@ export function LayerPanel({
29242955
onSelect={() => {
29252956
if (timeBinding) {
29262957
handleUnbindTimeSlider(layer);
2958+
} else if (temporalAdapter) {
2959+
handleBindTemporalLayer(layer);
29272960
} else {
29282961
void openBindTimeSliderDialog(layer);
29292962
}
@@ -2932,7 +2965,9 @@ export function LayerPanel({
29322965
<CalendarClock className="me-2 h-3.5 w-3.5" />
29332966
{timeBinding
29342967
? t("layers.unbindFromTimeSlider")
2935-
: t("layers.bindToTimeSlider")}
2968+
: temporalAdapter
2969+
? t("layers.bindTimeDimensionToTimeSlider")
2970+
: t("layers.bindToTimeSlider")}
29362971
</DropdownMenuItem>
29372972
)}
29382973
{canExportLayer && (

apps/geolibre-desktop/src/hooks/usePlugins.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import {
66
import {
77
addCogRasterLayer,
88
addZarrRasterLayer,
9+
buildSelectorTimeBinding,
10+
registerTemporalLayer,
11+
unregisterTemporalLayer,
12+
TIME_SLIDER_PLUGIN_ID,
13+
type TemporalLayerAdapter,
914
setZarrLayerSelector,
1015
maplibreAnnotationsPlugin,
1116
maplibreBasemapControlPlugin,
@@ -625,6 +630,44 @@ function ensureExternalPluginsLoadedWithSettings(
625630
externalPluginsLoadPromise = loadPromise;
626631
return loadPromise;
627632
}
633+
/**
634+
* Bind a layer's internal time dimension to the Time Slider: persist the
635+
* binding on the layer's metadata (mirroring how a vector layer's `TimeBinding`
636+
* is stored, so it survives a project round-trip) and open the dock if it is not
637+
* already showing.
638+
*
639+
* Shared by the Layers panel's "Bind to Time Slider" action and the plugin API's
640+
* `registerTemporalLayer(..., { bind: true })`, so both write the same thing.
641+
*
642+
* @param layerId - The store layer to bind.
643+
* @param adapter - Its temporal adapter, whose time values set the timeline range.
644+
* @param mapControllerRef - Used to build the app API when activating the dock.
645+
* @returns True when the layer was bound; false when its time axis holds no
646+
* usable timestamp, or the layer is gone.
647+
*/
648+
export function bindTemporalLayer(
649+
layerId: string,
650+
adapter: TemporalLayerAdapter,
651+
mapControllerRef?: RefObject<MapController | null>,
652+
): boolean {
653+
const binding = buildSelectorTimeBinding(adapter.dimension ?? "time", adapter.getTimeValues());
654+
if (!binding) return false;
655+
const store = useAppStore.getState();
656+
const layer = store.layers.find((item) => item.id === layerId);
657+
if (!layer) return false;
658+
store.updateLayer(layerId, {
659+
metadata: { ...layer.metadata, timeBinding: binding },
660+
// A selector binding replaces whatever was on the layer before. Drop any
661+
// transient filter a previous vector binding left behind, or it would keep
662+
// hiding features alongside the adapter (matching what the vector bind
663+
// dialog does when it commits).
664+
timeFilter: undefined,
665+
});
666+
if (!manager.isActive(TIME_SLIDER_PLUGIN_ID)) {
667+
manager.toggle(TIME_SLIDER_PLUGIN_ID, createAppAPI(mapControllerRef));
668+
}
669+
return true;
670+
}
628671

629672
export function createAppAPI(mapControllerRef?: RefObject<MapController | null>) {
630673
const store = useAppStore.getState();
@@ -760,6 +803,20 @@ export function createAppAPI(mapControllerRef?: RefObject<MapController | null>)
760803
}),
761804
setZarrLayerSelector: (layerId: string, selector: Record<string, number | string>) =>
762805
setZarrLayerSelector(layerId, selector),
806+
// A layer whose time is an internal dimension joins the Time Slider through
807+
// an adapter rather than a filter or a source swap. Registering only makes
808+
// it bindable; `bind` writes the binding and opens the dock, which is what a
809+
// plugin that just loaded a cube usually wants.
810+
registerTemporalLayer: (
811+
layerId: string,
812+
adapter: TemporalLayerAdapter,
813+
options?: { bind?: boolean },
814+
) => {
815+
const detach = registerTemporalLayer(layerId, adapter);
816+
if (options?.bind) bindTemporalLayer(layerId, adapter, mapControllerRef);
817+
return detach;
818+
},
819+
unregisterTemporalLayer: (layerId: string) => unregisterTemporalLayer(layerId),
763820
getActiveBasemap: () => useAppStore.getState().basemapStyleUrl,
764821
onBasemapChange: (callback: (styleUrl: string) => void) =>
765822
useAppStore.subscribe((state, prev) => {

apps/geolibre-desktop/src/i18n/locales/ar.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4186,6 +4186,8 @@
41864186
"styleCopied": "تم نسخ النمط من \"{{name}}\".",
41874187
"stylePasted": "تم لصق النمط من \"{{name}}\".",
41884188
"bindToTimeSlider": "ربط بمنزلق الزمن…",
4189+
"bindTimeDimensionToTimeSlider": "ربط البُعد الزمني بمنزلق الزمن",
4190+
"bindNoTimeDimension": "لا تحتوي هذه الطبقة على بُعد زمني قابل للقراءة.",
41894191
"unbindFromTimeSlider": "فك الربط عن منزلق الزمن",
41904192
"bindDialogDescription": "تصفية معالم هذه الطبقة حسب خاصية تاريخ أو طابع زمني أثناء تحريك الخط الزمني. تحتفظ الطبقة بنمطها وعتامتها.",
41914193
"bindScanning": "جارٍ فحص المعالم بحثًا عن خصائص التاريخ والطابع الزمني…",

apps/geolibre-desktop/src/i18n/locales/de.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3955,6 +3955,8 @@
39553955
"styleCopied": "Stil von „{{name}}“ kopiert.",
39563956
"stylePasted": "Stil von „{{name}}“ eingefügt.",
39573957
"bindToTimeSlider": "An Zeitschieberegler binden…",
3958+
"bindTimeDimensionToTimeSlider": "Zeitdimension an Zeitschieberegler binden",
3959+
"bindNoTimeDimension": "Diese Ebene hat keine lesbare Zeitdimension.",
39583960
"unbindFromTimeSlider": "Von Zeitschieberegler lösen",
39593961
"bindDialogDescription": "Filtert die Objekte dieser Ebene anhand einer Datums- oder Zeitstempel-Eigenschaft, während Sie die Zeitleiste durchlaufen. Die Ebene behält ihre Symbolisierung und Deckkraft.",
39603962
"bindScanning": "Objekte werden nach Datums- und Zeitstempel-Eigenschaften durchsucht…",

apps/geolibre-desktop/src/i18n/locales/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4115,6 +4115,8 @@
41154115
"styleCopied": "Copied style from \"{{name}}\".",
41164116
"stylePasted": "Pasted style from \"{{name}}\".",
41174117
"bindToTimeSlider": "Bind to Time Slider…",
4118+
"bindTimeDimensionToTimeSlider": "Bind time dimension to Time Slider",
4119+
"bindNoTimeDimension": "This layer has no readable time dimension.",
41184120
"unbindFromTimeSlider": "Unbind from Time Slider",
41194121
"bindDialogDescription": "Filter this layer's features by a date or timestamp property as you scrub the timeline. The layer keeps its styling and opacity.",
41204122
"bindScanning": "Scanning features for date and timestamp properties…",

apps/geolibre-desktop/src/i18n/locales/es.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3966,6 +3966,8 @@
39663966
"styleCopied": "Estilo copiado de «{{name}}».",
39673967
"stylePasted": "Estilo pegado de «{{name}}».",
39683968
"bindToTimeSlider": "Vincular al control deslizante de tiempo…",
3969+
"bindTimeDimensionToTimeSlider": "Vincular la dimensión temporal al control deslizante de tiempo",
3970+
"bindNoTimeDimension": "Esta capa no tiene una dimensión temporal legible.",
39693971
"unbindFromTimeSlider": "Desvincular del control deslizante de tiempo",
39703972
"bindDialogDescription": "Filtra las entidades de esta capa por una propiedad de fecha o marca de tiempo mientras recorre la línea de tiempo. La capa conserva su estilo y opacidad.",
39713973
"bindScanning": "Buscando propiedades de fecha y marca de tiempo en las entidades…",

apps/geolibre-desktop/src/i18n/locales/fr.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3955,6 +3955,8 @@
39553955
"styleCopied": "Style copié depuis « {{name}} ».",
39563956
"stylePasted": "Style collé depuis « {{name}} ».",
39573957
"bindToTimeSlider": "Lier au curseur temporel…",
3958+
"bindTimeDimensionToTimeSlider": "Lier la dimension temporelle au curseur temporel",
3959+
"bindNoTimeDimension": "Cette couche n'a pas de dimension temporelle lisible.",
39583960
"unbindFromTimeSlider": "Délier du curseur temporel",
39593961
"bindDialogDescription": "Filtrer les entités de cette couche par une propriété de date ou d'horodatage lorsque vous parcourez la chronologie. La couche conserve son style et son opacité.",
39603962
"bindScanning": "Analyse des entités pour les propriétés de date et d'horodatage…",

apps/geolibre-desktop/src/i18n/locales/hi.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3955,6 +3955,8 @@
39553955
"styleCopied": "\"{{name}}\" से शैली कॉपी की गई।",
39563956
"stylePasted": "\"{{name}}\" से शैली पेस्ट की गई।",
39573957
"bindToTimeSlider": "टाइम स्लाइडर से बाइंड करें…",
3958+
"bindTimeDimensionToTimeSlider": "समय आयाम को टाइम स्लाइडर से बाइंड करें",
3959+
"bindNoTimeDimension": "इस लेयर में कोई पठनीय समय आयाम नहीं है।",
39583960
"unbindFromTimeSlider": "टाइम स्लाइडर से अनबाइंड करें",
39593961
"bindDialogDescription": "टाइमलाइन को स्क्रब करते समय इस लेयर के फ़ीचर को दिनांक या टाइमस्टैम्प प्रॉपर्टी के अनुसार फ़िल्टर करें। लेयर अपनी शैली और अपारदर्शिता बनाए रखती है।",
39603962
"bindScanning": "दिनांक और टाइमस्टैम्प प्रॉपर्टी के लिए फ़ीचर स्कैन किए जा रहे हैं…",

apps/geolibre-desktop/src/i18n/locales/id.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3897,6 +3897,8 @@
38973897
"styleCopied": "Gaya disalin dari \"{{name}}\".",
38983898
"stylePasted": "Gaya ditempel dari \"{{name}}\".",
38993899
"bindToTimeSlider": "Ikat ke Penggeser Waktu…",
3900+
"bindTimeDimensionToTimeSlider": "Ikat dimensi waktu ke Penggeser Waktu",
3901+
"bindNoTimeDimension": "Lapisan ini tidak memiliki dimensi waktu yang dapat dibaca.",
39003902
"unbindFromTimeSlider": "Lepas dari Penggeser Waktu",
39013903
"bindDialogDescription": "Filter fitur layer ini berdasarkan properti tanggal atau timestamp saat Anda menggeser linimasa. Layer mempertahankan gaya dan opasitasnya.",
39023904
"bindScanning": "Memindai fitur untuk properti tanggal dan timestamp…",

0 commit comments

Comments
 (0)