Skip to content

Commit 0d2c916

Browse files
authored
fix(time-slider): honor temporal adapter cadence (#1502)
* fix(time-slider): honor temporal adapter cadence * fix(time-slider): reconcile display unit constraints * test(time-slider): cover display unit restoration
1 parent f16b4ee commit 0d2c916

5 files changed

Lines changed: 174 additions & 4 deletions

File tree

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -686,7 +686,10 @@ export function bindTemporalLayer(
686686
adapter: TemporalLayerAdapter,
687687
mapControllerRef?: RefObject<MapController | null>,
688688
): boolean {
689-
const binding = buildSelectorTimeBinding(adapter.dimension ?? "time", adapter.getTimeValues());
689+
const binding = buildSelectorTimeBinding(adapter.dimension ?? "time", adapter.getTimeValues(), {
690+
granularity: adapter.granularity,
691+
displayUnits: adapter.displayUnits,
692+
});
690693
if (!binding) return false;
691694
const store = useAppStore.getState();
692695
const layer = store.layers.find((item) => item.id === layerId);

packages/plugins/src/plugins/maplibre-time-slider.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ import {
1818
getTemporalLayerAdapter,
1919
isSelectorTimeBinding,
2020
nearestTimeIndex,
21+
resolveSelectorDisplayUnits,
2122
subscribeTemporalLayers,
23+
TIME_GRANULARITIES,
2224
toEpochMsAxis,
2325
type SelectorTimeBinding,
2426
type TemporalLayerAdapter,
@@ -466,6 +468,7 @@ let preBindingRange: {
466468
// value (setRange treats a null/undefined end as open).
467469
end: string | undefined;
468470
granularity: TimeBinding["granularity"];
471+
granularities: TimeGranularity[];
469472
} | null = null;
470473
// Guards our own timeFilter writes from re-entering the store subscription.
471474
let applyingBoundFilters = false;
@@ -758,7 +761,11 @@ function reconcileBoundLayers(control: TimeSliderControl): void {
758761
) {
759762
granularity = pickGranularity(max - min);
760763
}
761-
const rangeKey = `${min}|${max}|${granularity}`;
764+
const orderedDisplayUnits = resolveSelectorDisplayUnits(
765+
selectors.map(({ binding }) => binding),
766+
granularity,
767+
);
768+
const rangeKey = `${min}|${max}|${granularity}|${orderedDisplayUnits?.join(",") ?? ""}`;
762769
if (rangeKey !== lastBoundRangeKey) {
763770
// Capture the range the control had before any binding overrode it, so it
764771
// can be restored when every binding is later removed.
@@ -768,10 +775,17 @@ function reconcileBoundLayers(control: TimeSliderControl): void {
768775
start: config.startDate,
769776
end: config.endDate,
770777
granularity: config.granularity,
778+
granularities: [...(config.granularities ?? TIME_GRANULARITIES)],
771779
};
772780
}
773781
lastBoundRangeKey = rangeKey;
774782
control.setRange(new Date(min), new Date(max), undefined, granularity);
783+
control.setGranularities(
784+
orderedDisplayUnits
785+
? orderedDisplayUnits
786+
: (preBindingRange?.granularities ??
787+
control.getConfig().granularities ?? [...TIME_GRANULARITIES]),
788+
);
775789
}
776790
} else {
777791
// The last binding/frame was removed: restore the pre-binding range so any
@@ -783,6 +797,7 @@ function reconcileBoundLayers(control: TimeSliderControl): void {
783797
undefined,
784798
preBindingRange.granularity,
785799
);
800+
control.setGranularities(preBindingRange.granularities);
786801
}
787802
preBindingRange = null;
788803
lastBoundRangeKey = null;
@@ -798,6 +813,11 @@ function reconcileBoundLayers(control: TimeSliderControl): void {
798813
applyTimeOverlayVisibility(control, frames);
799814
}
800815

816+
/** Exercise binding reconciliation with a stub control in unit tests. */
817+
export function __reconcileBoundLayersForTests(control: TimeSliderControl): void {
818+
reconcileBoundLayers(control);
819+
}
820+
801821
/**
802822
* Wire the control's date changes and the GeoLibre store together so bound
803823
* layers track the timeline. Returns a detacher that also clears every applied

packages/plugins/src/plugins/temporal-layers.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { parseTimeValue, pickGranularity, type TimeGranularity } from "./time-slider-binding";
22

3+
export const TIME_GRANULARITIES: readonly TimeGranularity[] = ["hour", "day", "month", "year"];
4+
35
/**
46
* A layer whose time is an **internal dimension** rather than a feature property
57
* or a separate dated source: a Zarr data cube with a `time` axis, a plugin's
@@ -28,6 +30,16 @@ export interface TemporalLayerAdapter {
2830
* axis the timeline was bound to. Defaults to `"time"`.
2931
*/
3032
dimension?: string;
33+
/**
34+
* Stepping unit for the Time Slider. When omitted, GeoLibre derives one from
35+
* the full axis span.
36+
*/
37+
granularity?: TimeGranularity;
38+
/**
39+
* Units offered by the Time Slider's granularity controls. For example, a
40+
* daily cube can use `["day"]` to keep the track and labels at its cadence.
41+
*/
42+
displayUnits?: TimeGranularity[];
3143
}
3244

3345
/**
@@ -48,6 +60,8 @@ export interface SelectorTimeBinding {
4860
max: number;
4961
/** Suggested stepping granularity derived from the axis span. */
5062
granularity: TimeGranularity;
63+
/** Units offered by the Time Slider while this binding is active. */
64+
displayUnits?: TimeGranularity[];
5165
}
5266

5367
/**
@@ -142,6 +156,7 @@ export function nearestTimeIndex(axis: readonly number[], targetMs: number): num
142156
export function buildSelectorTimeBinding(
143157
dimension: string,
144158
values: ReadonlyArray<Date | number | string> | null | undefined,
159+
options?: Pick<TemporalLayerAdapter, "granularity" | "displayUnits">,
145160
): SelectorTimeBinding | null {
146161
const axis = toEpochMsAxis(values);
147162
if (!axis) return null;
@@ -156,15 +171,42 @@ export function buildSelectorTimeBinding(
156171
// A single-slice cube still needs a non-zero span so the slider can move,
157172
// matching the vector path's treatment of a single-instant dataset.
158173
if (max <= min) max = min + 86_400_000;
174+
const granularity = options?.granularity ?? pickGranularity(max - min);
175+
const displayUnits = options?.displayUnits
176+
? TIME_GRANULARITIES.filter(
177+
(unit) => unit === granularity || options.displayUnits?.includes(unit),
178+
)
179+
: undefined;
159180
return {
160181
kind: "selector",
161182
dimension: dimension.trim() || "time",
162183
min,
163184
max,
164-
granularity: pickGranularity(max - min),
185+
granularity,
186+
...(displayUnits ? { displayUnits } : {}),
165187
};
166188
}
167189

190+
/**
191+
* Resolve display-unit constraints for a shared slider. Constrained selector
192+
* bindings contribute their intersection. If that intersection cannot expose
193+
* the active stepping unit, the active unit is the only safe shared control.
194+
*/
195+
export function resolveSelectorDisplayUnits(
196+
bindings: readonly SelectorTimeBinding[],
197+
activeGranularity: TimeGranularity,
198+
): TimeGranularity[] | undefined {
199+
const constrained = bindings.filter(
200+
(binding): binding is SelectorTimeBinding & { displayUnits: TimeGranularity[] } =>
201+
Boolean(binding.displayUnits?.length),
202+
);
203+
if (constrained.length === 0) return undefined;
204+
const intersection = TIME_GRANULARITIES.filter((unit) =>
205+
constrained.every((binding) => binding.displayUnits.includes(unit)),
206+
);
207+
return intersection.includes(activeGranularity) ? intersection : [activeGranularity];
208+
}
209+
168210
// ----- Registry --------------------------------------------------------------
169211
// Adapters are transient: they belong to a *live* layer instance, so they are
170212
// registered when the layer is created (natively by the Zarr flow, or by an

tests/temporal-layers.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
isSelectorTimeBinding,
99
nearestTimeIndex,
1010
registerTemporalLayer,
11+
resolveSelectorDisplayUnits,
1112
subscribeTemporalLayers,
1213
toEpochMsAxis,
1314
type TemporalLayerAdapter,
@@ -99,6 +100,54 @@ describe("buildSelectorTimeBinding", () => {
99100
assert.equal(binding.granularity, "day");
100101
});
101102

103+
it("honors an adapter's granularity and displayed slider units", () => {
104+
const values = Array.from({ length: 35 * 365 }, (_, i) => Date.UTC(1990, 0, 1) + i * DAY);
105+
const binding = buildSelectorTimeBinding("time", values, {
106+
granularity: "day",
107+
displayUnits: ["day"],
108+
});
109+
assert.ok(binding);
110+
assert.equal(binding.granularity, "day");
111+
assert.deepEqual(binding.displayUnits, ["day"]);
112+
});
113+
114+
it("keeps the active granularity in the displayed slider units", () => {
115+
const binding = buildSelectorTimeBinding("time", [Date.UTC(2020, 0, 1), Date.UTC(2020, 0, 2)], {
116+
granularity: "day",
117+
displayUnits: ["month"],
118+
});
119+
assert.deepEqual(binding?.displayUnits, ["day", "month"]);
120+
});
121+
122+
it("intersects shared display units and falls back to the active granularity", () => {
123+
const base = {
124+
kind: "selector" as const,
125+
dimension: "time",
126+
min: Date.UTC(2020, 0, 1),
127+
max: Date.UTC(2020, 0, 2),
128+
};
129+
assert.deepEqual(
130+
resolveSelectorDisplayUnits(
131+
[
132+
{ ...base, granularity: "day", displayUnits: ["day", "month"] },
133+
{ ...base, granularity: "month", displayUnits: ["month"] },
134+
],
135+
"month",
136+
),
137+
["month"],
138+
);
139+
assert.deepEqual(
140+
resolveSelectorDisplayUnits(
141+
[
142+
{ ...base, granularity: "day", displayUnits: ["day"] },
143+
{ ...base, granularity: "month", displayUnits: ["month"] },
144+
],
145+
"day",
146+
),
147+
["day"],
148+
);
149+
});
150+
102151
it("gives a single-slice cube a non-zero span so the slider can still move", () => {
103152
const binding = buildSelectorTimeBinding("time", [Date.UTC(2020, 0, 1)]);
104153
assert.ok(binding);

tests/time-slider-config.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import assert from "node:assert/strict";
22
import { afterEach, describe, it } from "node:test";
33
import { DEFAULT_LAYER_STYLE, useAppStore, type GeoLibreLayer } from "@geolibre/core";
44
import {
5+
__reconcileBoundLayersForTests,
56
configToOptions,
67
getLayerTimeBinding,
78
createStoreLayer,
89
isTimeSliderIdle,
910
maplibreTimeSliderPlugin,
1011
} from "../packages/plugins/src/plugins/maplibre-time-slider";
11-
import type { SourceSpec } from "maplibre-gl-time-slider";
12+
import { registerTemporalLayer } from "../packages/plugins/src/plugins/temporal-layers";
13+
import type { SourceSpec, TimeSliderControl } from "maplibre-gl-time-slider";
1214

1315
// applyProjectState / getProjectState touch no app methods while no control is
1416
// active (the plugin is never activated here), so a bare stub satisfies the type.
@@ -69,6 +71,60 @@ describe("Time Slider open-ended end date persistence", () => {
6971
});
7072
});
7173

74+
describe("Time Slider selector display-unit restoration", () => {
75+
it("restores the previous controls after the last selector is unbound", () => {
76+
const store = useAppStore.getState();
77+
const previousLayers = store.layers;
78+
const layer = {
79+
id: "restore-units",
80+
name: "Daily cube",
81+
type: "geojson",
82+
source: {},
83+
visible: true,
84+
opacity: 1,
85+
style: { ...DEFAULT_LAYER_STYLE },
86+
metadata: {
87+
timeBinding: {
88+
kind: "selector",
89+
dimension: "time",
90+
min: Date.UTC(2020, 0, 1),
91+
max: Date.UTC(2020, 0, 2),
92+
granularity: "day",
93+
displayUnits: ["day"],
94+
},
95+
},
96+
} satisfies GeoLibreLayer;
97+
const ranges: unknown[][] = [];
98+
const granularities: string[][] = [];
99+
const control = {
100+
getConfig: () =>
101+
baseConfig({
102+
granularities: ["year", "month"],
103+
}),
104+
setRange: (...args: unknown[]) => ranges.push(args),
105+
setGranularities: (units: string[]) => granularities.push(units),
106+
} as unknown as TimeSliderControl;
107+
const detach = registerTemporalLayer(layer.id, {
108+
getTimeValues: () => [Date.UTC(2020, 0, 1), Date.UTC(2020, 0, 2)],
109+
setTime: () => {},
110+
});
111+
112+
try {
113+
useAppStore.setState({ layers: [layer] });
114+
__reconcileBoundLayersForTests(control);
115+
assert.deepEqual(granularities.at(-1), ["day"]);
116+
117+
useAppStore.getState().updateLayer(layer.id, { metadata: {} });
118+
__reconcileBoundLayersForTests(control);
119+
assert.deepEqual(granularities.at(-1), ["year", "month"]);
120+
assert.equal(ranges.at(-1)?.[3], "year");
121+
} finally {
122+
detach();
123+
useAppStore.setState({ layers: previousLayers });
124+
}
125+
});
126+
});
127+
72128
describe("Time Slider mosaic source persistence", () => {
73129
const mosaicSource = (overrides: Record<string, unknown> = {}): Record<string, unknown> => ({
74130
type: "mosaic",

0 commit comments

Comments
 (0)