Skip to content

feat: Timeline navigation controls for each layer in the timeline - #408

Merged
BhattaraiSijan merged 19 commits into
developmentfrom
feature/407-layer-timeline-navigation
Sep 9, 2026
Merged

feat: Timeline navigation controls for each layer in the timeline#408
BhattaraiSijan merged 19 commits into
developmentfrom
feature/407-layer-timeline-navigation

Conversation

@sandesh-sp

@sandesh-sp sandesh-sp commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

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.

  • A sparse layer walks the days it holds data on; first/last jump to its earliest and latest.
  • A continuous layer steps across its coverage by the timeline's current granularity; first/last go to the ends of its extent.
  • A layer that ignores time shows no controls, since it has nothing to navigate to.
  • A control whose target lies outside the timeline's window widens the window to reach it, rather than stopping at the edge.
  • A control with nowhere left to go is disabled, not silently inert.

The scrubber itself is unchanged — it stays free, with no snapping.

How it fits together

Piece Role
lib/utils/layerNavigation.ts resolveLayerNavigation turns a layer's time block into a sparse-stops or periodic-extent model, or null for a layer with nothing to navigate. navigateLayer answers where a button leads, or null to disable it.
lib/geo/LayerNavControls/ The row's four buttons. No time arithmetic of its own — it asks navigateLayer, and uses the one answer for both the disabled state and the emitted instant.
lib/geo/TimelineView/ Renders a row's controls beside its name, and reports the target upward on a path separate from the scrubber's.
TimelineAdapter.tsx Resolves a model per visible layer, and commits a target — widening the window when it falls outside.

Two details worth knowing when reading this:

Sparse stops land on a day's last instant, not its first. TimeControl.updateLayersTime gives every time-enabled layer layer.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 on T00:00:00Z would close that window before anything acquired that day fell inside it — navigation would walk onto a data day and show nothing.

resolveLayerExtent is shared with the drawing code. A layer configured with only one of dataStartTime/dataEndTime is a legitimate authoring state that resolveLayerTimeRanges already 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_THICKNESS constant instead of arithmetic against the row height, so the chart doesn't get heavier — and it is now genuinely centred, which the previous y + 4 / height - 6 was not.

Test plan

  • npx vitest run — 136 files, 1969 tests passing
  • Timeline plugin: 7 files, 98 tests (from a 32-test baseline)
  • npx tsc --noEmit clean
  • Booted locally and smoke-tested against a live mission

Unit 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-within reveal, which jsdom cannot exercise.

Notes for review

  • Stacked on [405] Draw a box per data date for sparsely covered layers #406 (fix/sparse-layer-timeline), which this reuses the dataDates parsing from. Retarget to development once that merges.
  • Layer periodicity/cadence is deliberately out of scope — continuous layers step by the timeline's granularity until that config field lands separately.
  • A mission configured with relative start/end has its window recomputed by 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

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.
@sandesh-sp

Copy link
Copy Markdown
Collaborator Author

A final whole-branch review found one correctness defect and one keyboard-accessibility gap. Both are fixed in the three commits above.

3cd88e76 — a layer naming one bound outside the window inverted its extent.

resolveLayerExtent fills the unconfigured side from the timeline window without checking ordering, so a layer configured with only dataEndTime: 2020-12-31 viewed through a 2024 window resolved to start = 2024-01-01, end = 2020-12-31. first was live and committed 2024-01-01 — months after the layer's data ended. And once prev widened the window back onto the data, the model re-resolved to start === end and all four controls went inert for the rest of the session.

resolveLayerNavigation now closes the extent on the bound the layer actually names when the fallback would place the open side the wrong side of it, so the unknown direction is correctly inert rather than wrong. ResolvedLayerExtent.hasOwnBound split into hasOwnStart / hasOwnEnd to make that decidable; resolveLayerTimeRanges destructures only start/end and its drawing behaviour is unchanged.

fc81aa52 — a keyboard walk lost focus at its end.

Holding Enter on "next date" through a layer's last stop set disabled on the focused button. Browsers blur an element that becomes disabled, so focus fell to <body>; since the group is revealed only on :hover / :focus-within, it then faded out entirely and the user had to re-tab from the top of the panel. The buttons now carry aria-disabled instead, keeping focus while still announcing the inert state, with the click handler's existing guard keeping the press silent.

778f75a4navigation typed | null to match what the adapter assigns (it only compiled because strict: false), LayerNavControls and the LayerNavigation type added to the barrel, a doc comment corrected about duplicate dataDates, and the four navigation targets memoised since from changes on every scrubber pointermove.

Suite: 136 files / 1976 tests passing (was 1969), tsc --noEmit clean. No existing test was modified except LayerNavControls.spec.tsx, which the aria-disabled change required.

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.
@sandesh-sp
sandesh-sp marked this pull request as ready for review September 9, 2026 14:10
@sandesh-sp
sandesh-sp changed the base branch from fix/sparse-layer-timeline to development September 9, 2026 14:11
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.
@sandesh-sp sandesh-sp changed the title Move the timeline's current time to a layer's own data from its row feat: Timeline navigation controls for each layer in the timeline Sep 9, 2026
@sandesh-sp sandesh-sp linked an issue Sep 9, 2026 that may be closed by this pull request
8 tasks
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.
@BhattaraiSijan
BhattaraiSijan merged commit 0d15c74 into development Sep 9, 2026
4 checks passed
@BhattaraiSijan
BhattaraiSijan deleted the feature/407-layer-timeline-navigation branch September 9, 2026 15:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Timeline: move the current time to a layer's own data from its row

2 participants