feat: Timeline navigation controls for each layer in the timeline - #408
Conversation
layerBarHeight sets the pitch of both the sidebar row and the SVG row so names stay aligned with their bars; growing it to 20px makes room for the per-layer transport buttons a later task adds. LayerTimeline previously derived the drawn bar's y/height from that same row height with hardcoded arithmetic, so widening the row would have silently thickened every bar. Give the bar a fixed thickness and centre it in whatever row it's given.
Give the layer navigation model the arithmetic behind a row's first/previous/next/last controls: the instant each press moves the current time to, or null when a press has nowhere to go, which is how a row knows to draw a control disabled. A sparse layer moves between its stops, so one press reaches data sitting months from where the timeline is. A periodic layer steps through its extent by the timeline's granularity, matching the global step buttons, and stops at the extent's edge. Drop the cadence declaration; a layer periodicity field is not part of the layer configuration, so nothing fills it in and nothing reads it.
The two halves of a layer's extent are configured independently, and a layer that names only one of them still draws a bar: the timeline completes the missing side from its own window. Resolve that layer's navigation the same way, so every row that shows a bar carries working controls instead of a disabled set. A layer naming neither bound, and holding no listed days, still has nothing of its own to move through and resolves to nothing.
The first/previous/next/last model answered a jump unconditionally, so a control landing on the instant the timeline already holds looked live while re-committing the same time. Answering null there keeps a control's inert rule the single question of whether navigateLayer returns an instant, rather than each consumer re-deriving an equality check. Cover the edges the suite left open too: stepping in from an extent's edge, and off a sparse layer's outermost stop.
A viewer moving the timeline onto a particular layer's data has only the global scrubber to do it with, which means pixel-hunting for a bar the layer draws. Put first/previous/next/last on the row itself, asking the navigation model where each one leads and drawing it inert wherever the answer is nowhere. The controls overlay the end of the layer name and appear on hover, since a 160px sidebar has no room to carry four buttons per row permanently. They are hidden by opacity so they stay in the tab order, and the row reveals them on focus within it, which is how a keyboard reaches them.
resolveLayerTimeRanges and resolveLayerNavigation each parsed dataStartTime/dataEndTime and completed a missing or unreadable bound from the fallback, written differently but encoding the same rule. Pull it into resolveLayerExtent in timeUtils.ts, reporting whether the config named anything readable so the navigator can still tell a layer with no bound at all (nothing to navigate) from one missing only a side (extent completed from the fallback). Also make navigateLayer's sparse/periodic split visibly total by moving each half into its own named function dispatched by nav.kind, rather than a sparse branch that returns from every case and falls through to periodic arithmetic if nothing matched. Document that the mode parameter only matters to the periodic side.
Resolve a navigation model per visible layer and commit the instant a row's controls lead to, widening the window onto a target outside it rather than clamping the target back in.
A layer that configures one bound and leaves the other open had the open side completed from the timeline window without regard for ordering, so a window sitting wholly the far side of that bound ran the extent backwards and let a control commit an instant the layer has no data for.
A control that gained the disabled attribute mid keyboard walk was blurred by the browser, and the group, revealed on :focus-within, faded out with it. Inertness is stated with aria-disabled instead, and the click handler's guard keeps the press silent.
Widen the stored navigation type to the null the resolver returns, export the row's controls and the navigation type from the plugin barrel, say what the sparse model's bounds are for, hold each control's target until an input changes, and scan back for the preceding stop.
|
A final whole-branch review found one correctness defect and one keyboard-accessibility gap. Both are fixed in the three commits above.
Holding Enter on "next date" through a layer's last stop set
Suite: 136 files / 1976 tests passing (was 1969), Known limitation, not addressed here: on a zoomed-in chart, a widening press lands the target on a domain edge that the current zoom transform may map outside the visible area, clipping the scrubber from view. This pre-exists for the global first/last buttons, but per-layer navigation makes it routine. Fixing it means resetting the zoom transform on commit, which is separate machinery with its own regression risk — better as its own change than folded in here. |
Drop the comments the code already states, rewrite the ones that argued for a change into descriptions of current behaviour, and shorten the rest. The non-obvious reasons are kept: stops closing a day because the current time is a layer's query-window trailing edge, aria-disabled holding focus where the disabled attribute would drop it, and UTC arithmetic throughout. No behaviour change.
Scanning the days already kept for each new one costs a comparison per pair, over a list that can hold a day for every day of a mission. A set answers the same question once per day.
A layer naming both bounds the wrong way round describes a span it cannot hold data in, and the guards either side of this only cover a layer naming one. Read as it stood, first landed past last and next past prev, so the row carried four controls that disagreed with each other over a 2px bar. It carries none instead, and the config is named in a warning rather than passing in silence.
A sparse stop is its day's last instant, so a window opening exactly there met the trailing edge of that day's bar and left the whole of it off the left of the chart: pressing first date on a layer whose earliest date sat before the window landed on that date with nothing to see. The window now opens to revealStart, which holds a sparse target to its day and meets a periodic one exactly, its bar running inward from the bounds it names. A row reports the model beside the instant, so the adapter reads the difference from the model rather than from a bare date.
The navigator and the drawer parsed dataDates with the same chain written out twice, and had already drifted: a day listed twice was one stop to navigate but two boxes to draw, stacking into a bar darker than its neighbours. Both read resolveListedDays now, so what a row moves through and what its bar covers come from one answer. The adapter also asserts that stepping back onto an earlier stop opens the window past that day's midnight, alongside the jump that already covered it.
Summary
Each layer in the timeline gets its own navigation controls to go to first, last, prev and next available time. For periodic layers, it'll move based on granularity set (for now) and for sparse layers, it'll jump to available data time.
Details
The timeline moved by clock time, not by data. The scrubber landed wherever the pointer did and the step buttons advanced by a fixed calendar unit, so for a layer holding data on a scattered handful of days, landing on one of those days was a pixel-hunt across a domain spanning years — and stepping there cost one press per intervening day.
A global "snap to data" setting can't fix that, because it has to pick whose data it means: one timeline routinely carries sparse layers, continuously covered layers, and layers that ignore time entirely.
So the decision moves from configuration to the gesture. Each layer row carries its own first/previous/next/last controls, which move the global current time to that layer's data. The layer under the cursor is the scope, so there is no mode to set and nothing to configure.
The scrubber itself is unchanged — it stays free, with no snapping.
How it fits together
lib/utils/layerNavigation.tsresolveLayerNavigationturns a layer'stimeblock into a sparse-stops or periodic-extent model, ornullfor a layer with nothing to navigate.navigateLayeranswers where a button leads, ornullto disable it.lib/geo/LayerNavControls/navigateLayer, and uses the one answer for both the disabled state and the emitted instant.lib/geo/TimelineView/TimelineAdapter.tsxTwo details worth knowing when reading this:
Sparse stops land on a day's last instant, not its first.
TimeControl.updateLayersTimegives every time-enabled layerlayer.time.end = TimeControl.currentTime, so the current time is the trailing edge of the layer's query window rather than a point it is sampled at. Landing onT00:00:00Zwould close that window before anything acquired that day fell inside it — navigation would walk onto a data day and show nothing.resolveLayerExtentis shared with the drawing code. A layer configured with only one ofdataStartTime/dataEndTimeis a legitimate authoring state thatresolveLayerTimeRangesalready completes from the timeline's window, so it draws a bar. Both now read the bounds through one helper, so what a row navigates cannot drift from what it draws.The row pitch grew 15px → 20px to fit a button. The drawn range bar keeps its thickness through a
BAR_THICKNESSconstant instead of arithmetic against the row height, so the chart doesn't get heavier — and it is now genuinely centred, which the previousy + 4/height - 6was not.Test plan
npx vitest run— 136 files, 1969 tests passingnpx tsc --noEmitcleanUnit coverage includes each sparse boundary (before, between, exactly on, and past a stop), periodic reach-in from either side, stepping at several granularities, a DST-boundary step, and a process timezone pinned behind UTC so a locally-snapped day would surface as the wrong day.
Not covered by unit tests, verified by hand: that navigating to a layer's day actually renders that day's data, and the hover/
:focus-withinreveal, which jsdom cannot exercise.Notes for review
fix/sparse-layer-timeline), which this reuses thedataDatesparsing from. Retarget todevelopmentonce that merges.TimeControl.setTime, so an absolute widening does not stick there. The current time still lands on target and the rolling window follows it.Closes #407