diff --git a/src/essence/Tools/Timeline/Timeline.css b/src/essence/Tools/Timeline/Timeline.css
index be8d00d1f..12929debc 100644
--- a/src/essence/Tools/Timeline/Timeline.css
+++ b/src/essence/Tools/Timeline/Timeline.css
@@ -270,6 +270,9 @@
align-items: center;
padding: 0 10px;
box-sizing: border-box;
+ /* Containing block for the row's navigation controls, which overlay the
+ end of the layer name rather than taking width from it. */
+ position: relative;
}
.layer-color-dot {
diff --git a/src/essence/Tools/Timeline/TimelineAdapter.tsx b/src/essence/Tools/Timeline/TimelineAdapter.tsx
index 3dcaafe92..33d550a35 100644
--- a/src/essence/Tools/Timeline/TimelineAdapter.tsx
+++ b/src/essence/Tools/Timeline/TimelineAdapter.tsx
@@ -29,6 +29,8 @@ import {
clampDate,
resolveLayerTimeRanges,
} from './lib/utils/timeUtils'
+import { resolveLayerNavigation, revealStart } from './lib/utils/layerNavigation'
+import type { LayerNavigation } from './lib/utils/layerNavigation'
import './Timeline.css'
/** The wire shape of both 'time:changeRequested' and 'time:changed'. */
@@ -103,18 +105,55 @@ export const TimelineAdapter: React.FC = () => {
setResetZoomFn(() => fn)
}, [])
- /** Moves the scrubber and asks core to commit the same instant. */
- const commitTime = useCallback((next: Date) => {
+ /**
+ * Asks core to commit an instant, within the window given, and moves local
+ * state onto the same payload. All three fields are set here, not just the
+ * instant: core's echo of this commit is the one 'time:changed' skips, so
+ * a window emitted without being set locally would be lost on the way back.
+ */
+ const requestTime = useCallback((start: Date, end: Date, next: Date) => {
+ setStartTime((prev) => preserveIdentity(prev, start))
+ setEndTime((prev) => preserveIdentity(prev, end))
setCurrentTime((prev) => preserveIdentity(prev, next))
const payload: TimePayload = {
- startTime: startTimeRef.current.toISOString(),
- endTime: endTimeRef.current.toISOString(),
+ startTime: start.toISOString(),
+ endTime: end.toISOString(),
currentTime: next.toISOString(),
}
lastRequestedRef.current = payload
mmgisEmit('time:changeRequested', payload)
}, [])
+ /** Moves the scrubber and asks core to commit the same instant. */
+ const commitTime = useCallback(
+ (next: Date) => {
+ requestTime(startTimeRef.current, endTimeRef.current, next)
+ },
+ [requestTime]
+ )
+
+ /**
+ * Commits the instant a layer row's controls lead to, widening the window
+ * to reach it. A layer's data need not sit inside the window on screen, so
+ * the target is committed as given rather than clamped back in.
+ *
+ * The window opens to `revealStart` rather than to the target: a sparse
+ * target is a day's last instant, and a window starting there would meet
+ * the trailing edge of that day's bar and leave the whole of it off the
+ * left of the chart. Forwards needs no such allowance, since a bar ends on
+ * the instant its day does.
+ */
+ const handleLayerNavigate = useCallback(
+ (target: Date, navigation: LayerNavigation) => {
+ const reach = revealStart(navigation, target)
+ const start =
+ reach < startTimeRef.current ? reach : startTimeRef.current
+ const end = target > endTimeRef.current ? target : endTimeRef.current
+ requestTime(start, end, target)
+ },
+ [requestTime]
+ )
+
// Tool variables from the mission config. 'tool:getVars' is registered by
// Layers_.fina() during mission load, after this tool mounts.
const fetchVars = useCallback(async () => {
@@ -204,6 +243,14 @@ export const TimelineAdapter: React.FC = () => {
startTime,
endTime
),
+ // Same fallback bounds as the ranges above, so a row
+ // navigates the span it draws.
+ navigation: resolveLayerNavigation(
+ layer.time,
+ startTime,
+ endTime,
+ layerName
+ ),
})
})
@@ -477,6 +524,7 @@ export const TimelineAdapter: React.FC = () => {
layers={layers}
onCurrentTimeChange={handleCurrentTimeChange}
onCurrentTimePreview={handleCurrentTimePreview}
+ onLayerNavigate={handleLayerNavigate}
onResetZoomReady={handleResetZoomReady}
/>
)}
@@ -493,7 +541,7 @@ export const TimelineAdapter: React.FC = () => {
>
Timeline Controls
-
Scroll to zoom • Drag scrubber to change time • Click to jump
+
Scroll to zoom • Drag scrubber to change time • Click to jump • Hover a layer to step through its dates
diff --git a/src/essence/Tools/Timeline/__tests__/LayerNavControls.spec.tsx b/src/essence/Tools/Timeline/__tests__/LayerNavControls.spec.tsx
new file mode 100644
index 000000000..e23ae9cb1
--- /dev/null
+++ b/src/essence/Tools/Timeline/__tests__/LayerNavControls.spec.tsx
@@ -0,0 +1,249 @@
+import React, { act } from 'react'
+import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'
+import { createRoot, type Root } from 'react-dom/client'
+
+/**
+ * A layer row's first/previous/next/last controls: which are live, what each
+ * is called, and the instant a press reports.
+ *
+ * The process timezone is pinned behind UTC, so a control resolving its target
+ * locally surfaces as a wrong instant here rather than passing on a UTC host
+ * and failing for a viewer in the Americas.
+ */
+vi.hoisted(() => {
+ process.env.TZ = 'America/New_York'
+})
+
+import { LayerNavControls } from '../lib/geo/LayerNavControls/LayerNavControls'
+import type { LayerNavigation } from '../lib/utils/layerNavigation'
+import type { TimeMode } from '../lib/types'
+
+;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean })
+ .IS_REACT_ACT_ENVIRONMENT = true
+
+/** A sparse model whose stops close the listed days, as the resolver builds. */
+const sparseNav = (...days: string[]): LayerNavigation => {
+ const stops = days.map((day) => new Date(`${day}T23:59:59.999Z`))
+ return {
+ kind: 'sparse',
+ stops,
+ start: stops[0],
+ end: stops[stops.length - 1],
+ }
+}
+
+const periodicNav = (start: string, end: string): LayerNavigation => ({
+ kind: 'periodic',
+ start: new Date(start),
+ end: new Date(end),
+})
+
+const SPARSE = sparseNav('2020-01-02', '2020-03-04', '2020-11-02')
+
+describe('LayerNavControls', () => {
+ let container: HTMLElement
+ let root: Root
+ let committed: Date[]
+ let reported: LayerNavigation[]
+
+ beforeEach(() => {
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ committed = []
+ reported = []
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ })
+
+ const render = (
+ from: string,
+ navigation: LayerNavigation = SPARSE,
+ displayName = 'MODIS Daily',
+ timeMode: TimeMode = 'DAY'
+ ) => {
+ act(() => {
+ root.render(
+ {
+ committed.push(date)
+ reported.push(navigation)
+ }}
+ />,
+ )
+ })
+ }
+
+ const buttons = () =>
+ Array.from(container.querySelectorAll('button'))
+
+ const labels = () =>
+ buttons().map((button) => button.getAttribute('aria-label'))
+
+ const press = (label: string) => {
+ const button = buttons().find(
+ (candidate) => candidate.getAttribute('aria-label') === label,
+ )!
+ // Dispatched rather than clicked: a control disabled in name only,
+ // through aria-disabled, still delivers the event.
+ act(() => {
+ button.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+ }
+
+ test('draws the four controls in transport order', () => {
+ render('2020-05-01T00:00:00Z')
+
+ expect(labels()).toEqual([
+ 'MODIS Daily: first date',
+ 'MODIS Daily: previous date',
+ 'MODIS Daily: next date',
+ 'MODIS Daily: last date',
+ ])
+ })
+
+ test('names its own layer, so rows stay apart in a list of controls', () => {
+ // Every row carries the same four buttons; only the layer's name
+ // tells a listener which row they are on.
+ render('2020-05-01T00:00:00Z', SPARSE, 'Sentinel-2 True Color')
+
+ expect(labels()).toEqual([
+ 'Sentinel-2 True Color: first date',
+ 'Sentinel-2 True Color: previous date',
+ 'Sentinel-2 True Color: next date',
+ 'Sentinel-2 True Color: last date',
+ ])
+ })
+
+ /**
+ * A control with nowhere to go carries aria-disabled rather than the
+ * disabled attribute, so it keeps focus; see the keyboard test below.
+ */
+ const inert = () =>
+ buttons().map(
+ (button) => button.getAttribute('aria-disabled') === 'true',
+ )
+
+ test('leaves every control live from between the layer stops', () => {
+ render('2020-05-01T00:00:00Z')
+
+ expect(inert()).toEqual([false, false, false, false])
+ })
+
+ test('draws a control with nowhere to go inert', () => {
+ render('2020-01-02T23:59:59.999Z')
+
+ expect(inert()).toEqual([true, true, false, false])
+ })
+
+ test('goes inert in every direction on a layer holding one instant', () => {
+ render('2020-03-04T23:59:59.999Z', sparseNav('2020-03-04'))
+
+ expect(inert()).toEqual([true, true, true, true])
+ })
+
+ test('reports the instant the pressed control leads to', () => {
+ render('2020-05-01T00:00:00Z')
+ press('MODIS Daily: next date')
+
+ expect(committed.map((date) => date.toISOString())).toEqual([
+ '2020-11-02T23:59:59.999Z',
+ ])
+ })
+
+ test('reports each control its own instant', () => {
+ render('2020-05-01T00:00:00Z')
+ press('MODIS Daily: first date')
+ press('MODIS Daily: previous date')
+ press('MODIS Daily: last date')
+
+ expect(committed.map((date) => date.toISOString())).toEqual([
+ '2020-01-02T23:59:59.999Z',
+ '2020-03-04T23:59:59.999Z',
+ '2020-11-02T23:59:59.999Z',
+ ])
+ })
+
+ test('reports the model beside the instant, so a window knows what it opens onto', () => {
+ render('2020-05-01T00:00:00Z')
+ press('MODIS Daily: next date')
+
+ expect(reported).toEqual([SPARSE])
+ })
+
+ test('stays silent when a control with nowhere to go is pressed', () => {
+ render('2020-11-02T23:59:59.999Z')
+ press('MODIS Daily: next date')
+ press('MODIS Daily: last date')
+
+ expect(committed).toEqual([])
+ })
+
+ test('moves through a periodic layer by the timeline granularity', () => {
+ // The distance is the model's answer, not the row's: the same press
+ // moves an hour or a day with the mode.
+ render(
+ '2020-05-15T12:00:00Z',
+ periodicNav('2020-01-01T00:00:00Z', '2020-12-31T00:00:00Z'),
+ 'MODIS Daily',
+ 'HOUR',
+ )
+ press('MODIS Daily: next date')
+
+ render(
+ '2020-05-15T12:00:00Z',
+ periodicNav('2020-01-01T00:00:00Z', '2020-12-31T00:00:00Z'),
+ 'MODIS Daily',
+ 'MONTH',
+ )
+ press('MODIS Daily: next date')
+
+ expect(committed.map((date) => date.toISOString())).toEqual([
+ '2020-05-15T13:00:00.000Z',
+ '2020-06-15T12:00:00.000Z',
+ ])
+ })
+
+ test('keeps a live control reachable by keyboard', () => {
+ // The controls are revealed with opacity, so they stay in the tab
+ // order while unrevealed. This reaches the markup only: with no
+ // stylesheet applied, a reveal switched to display or visibility would
+ // still pass here. Only a real browser holds that half.
+ render('2020-05-01T00:00:00Z')
+ const next = buttons().find(
+ (button) =>
+ button.getAttribute('aria-label') === 'MODIS Daily: next date',
+ )!
+
+ expect(next.tabIndex).toBe(0)
+ next.focus()
+ expect(document.activeElement).toBe(next)
+ })
+
+ test('keeps the control a viewer walked to the end of a layer with', () => {
+ // Pressing "next date" to the last stop leaves that control with
+ // nowhere to go. A browser blurs an element the moment it gains the
+ // disabled attribute, and the row reveals on :focus-within, so
+ // disabling it would fade the group out from under the viewer.
+ render('2020-03-04T23:59:59.999Z')
+ const next = buttons().find(
+ (button) =>
+ button.getAttribute('aria-label') === 'MODIS Daily: next date',
+ )!
+ next.focus()
+
+ render('2020-11-02T23:59:59.999Z')
+
+ expect(next.getAttribute('aria-disabled')).toBe('true')
+ expect(next.disabled).toBe(false)
+ expect(next.tabIndex).toBe(0)
+ expect(document.activeElement).toBe(next)
+ })
+})
diff --git a/src/essence/Tools/Timeline/__tests__/LayerTimeline.spec.tsx b/src/essence/Tools/Timeline/__tests__/LayerTimeline.spec.tsx
index 9e4b6e4a2..bf84b2e4f 100644
--- a/src/essence/Tools/Timeline/__tests__/LayerTimeline.spec.tsx
+++ b/src/essence/Tools/Timeline/__tests__/LayerTimeline.spec.tsx
@@ -37,15 +37,15 @@ describe('LayerTimeline', () => {
container.remove()
})
- const render = (layer: LayerTimeData) => {
+ const render = (layer: LayerTimeData, y = 0, height = 20) => {
act(() => {
root.render(
)
@@ -100,8 +100,8 @@ describe('LayerTimeline', () => {
/**
* Zoomed out to a multi-year view a single day is narrower than a pixel.
- * The boxes are floored to a visible width rather than scaled away, so a
- * sparse layer still reads as having data on those days.
+ * Boxes are floored to a visible width, so a sparse layer still reads as
+ * having data on those days.
*/
test('keeps a sub-pixel day visible', () => {
const [rect] = render(
@@ -116,4 +116,40 @@ describe('LayerTimeline', () => {
expect(Number(rect.getAttribute('width'))).toBeGreaterThanOrEqual(2)
})
+
+ /**
+ * A row is sized to fit the transport buttons it carries, which is taller
+ * than the bar wants to be. The bar's thickness is fixed rather than
+ * scaled with the row, and it stays centred in whatever row it's given.
+ */
+ test('keeps the same bar thickness centred whether the row is 15px or 20px', () => {
+ const range = { start: new Date('2020-03-04T00:00:00Z'), end: new Date('2020-07-19T00:00:00Z') }
+
+ // React reuses the same host across renders on one root, so
+ // each height is read out before the next render overwrites it.
+ const [shortRowRect] = render(layerWith([range]), 0, 15)
+ const shortHeight = Number(shortRowRect.getAttribute('height'))
+ const shortY = Number(shortRowRect.getAttribute('y'))
+
+ const [tallRowRect] = render(layerWith([range]), 0, 20)
+ const tallHeight = Number(tallRowRect.getAttribute('height'))
+ const tallY = Number(tallRowRect.getAttribute('y'))
+
+ expect(shortHeight).toBe(9)
+ expect(tallHeight).toBe(9)
+
+ // Centred: the gap above the bar equals the gap below it.
+ expect(shortY).toBeCloseTo((15 - shortHeight) / 2)
+ expect(tallY).toBeCloseTo((20 - tallHeight) / 2)
+ })
+
+ test('centres the bar within a row offset from the SVG origin', () => {
+ const range = { start: new Date('2020-03-04T00:00:00Z'), end: new Date('2020-07-19T00:00:00Z') }
+
+ const [rect] = render(layerWith([range]), 100, 20)
+
+ const height = Number(rect.getAttribute('height'))
+ expect(height).toBe(9)
+ expect(Number(rect.getAttribute('y'))).toBeCloseTo(100 + (20 - height) / 2)
+ })
})
diff --git a/src/essence/Tools/Timeline/__tests__/TimelineAdapter.spec.tsx b/src/essence/Tools/Timeline/__tests__/TimelineAdapter.spec.tsx
index dc6efb37b..5b26b4a65 100644
--- a/src/essence/Tools/Timeline/__tests__/TimelineAdapter.spec.tsx
+++ b/src/essence/Tools/Timeline/__tests__/TimelineAdapter.spec.tsx
@@ -4,11 +4,10 @@ import { createRoot, type Root } from 'react-dom/client'
import { TimelineAdapter } from '../TimelineAdapter'
/**
- * The timeline's "Compare date" action is a hand-off, not a call: the timeline
- * knows nothing about the Comparison plugin beyond the name of the event it
- * announces, and a mission without that plugin is simply one where nobody
- * listens. What is covered here is that the action is offered at all, that
- * clicking it puts that event on the bus, and that it carries the window.
+ * The "Compare date" action is a hand-off, not a call: the timeline knows the
+ * Comparison plugin only by the name of the event it announces. Covered here:
+ * the action is offered, clicking it puts that event on the bus, and the
+ * event carries the window.
*/
;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean })
@@ -88,3 +87,175 @@ describe('TimelineAdapter compare hand-off', () => {
})
})
})
+
+/**
+ * Where a layer row's navigation controls put the timeline. Reaching a layer's
+ * data can mean leaving the window on screen, so the window follows the target
+ * out instead of clamping it back in, moving only the edge that has to move.
+ */
+
+// jsdom has no ResizeObserver; the view constructs one to follow the chart
+// area's width. The stub reports no size, leaving the starting width.
+class NoopResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+
+// Three scattered days — one before the window, one inside, one past its end
+// — so first/next/last each land differently against it.
+const BEFORE_WINDOW = '2023-11-05T23:59:59.999Z'
+// The window opens on the whole of the day a backwards stop names, so the bar
+// drawn over that day sits inside the chart rather than against its left edge.
+const BEFORE_WINDOW_DAY_START = '2023-11-05T00:00:00.000Z'
+const INSIDE_WINDOW = '2024-06-20T23:59:59.999Z'
+const PAST_WINDOW = '2025-03-20T23:59:59.999Z'
+
+const LAYER_CONFIGS = {
+ sparse: {
+ name: 'sparse',
+ display_name: 'Rover Images',
+ time: {
+ enabled: true,
+ dataDates: ['2023-11-05', '2024-06-20', '2025-03-20'],
+ },
+ },
+ basemap: {
+ name: 'basemap',
+ display_name: 'Basemap',
+ time: { enabled: false },
+ },
+}
+
+describe('TimelineAdapter layer navigation', () => {
+ let container: HTMLElement
+ let root: Root
+ let emits: Emit[]
+ let originalResizeObserver: unknown
+
+ beforeEach(async () => {
+ emits = []
+ originalResizeObserver = (globalThis as { ResizeObserver?: unknown })
+ .ResizeObserver
+ ;(globalThis as { ResizeObserver?: unknown }).ResizeObserver =
+ NoopResizeObserver
+ ;(window as unknown as { mmgisAPI: unknown }).mmgisAPI = {
+ request: async (name: string) => {
+ if (name === 'time:isEnabled') return true
+ if (name === 'time:getStart') return START
+ if (name === 'time:getEnd') return END
+ if (name === 'time:getCurrent') return CURRENT
+ if (name === 'tool:getVars') return {}
+ if (name === 'layers:getAllConfigs') return LAYER_CONFIGS
+ if (name === 'layers:getVisible')
+ return { sparse: true, basemap: true }
+ return null
+ },
+ hasHandler: () => true,
+ on: () => () => {},
+ emit: (event: string, payload?: unknown) => {
+ emits.push({ event, payload })
+ },
+ }
+
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ await act(async () => {
+ root.render()
+ })
+ // The layer configs arrive a request later than the first render.
+ await act(async () => {})
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ delete (window as { mmgisAPI?: unknown }).mmgisAPI
+ ;(globalThis as { ResizeObserver?: unknown }).ResizeObserver =
+ originalResizeObserver
+ })
+
+ const navButton = (name: string) =>
+ container.querySelector(
+ `[aria-label="Rover Images: ${name}"]`
+ )
+
+ const requests = () => emits.filter((e) => e.event === 'time:changeRequested')
+
+ test('gives the rows of layers that carry dates their own controls', () => {
+ expect(navButton('next date')).not.toBeNull()
+ // Nothing of the basemap's own to move through.
+ expect(
+ container.querySelector('[aria-label^="Basemap:"]')
+ ).toBeNull()
+ })
+
+ test('a target inside the window commits it and leaves the window be', () => {
+ act(() => {
+ navButton('next date')!.click()
+ })
+
+ expect(requests()).toHaveLength(1)
+ expect(requests()[0].payload).toEqual({
+ startTime: new Date(START).toISOString(),
+ endTime: new Date(END).toISOString(),
+ currentTime: INSIDE_WINDOW,
+ })
+ })
+
+ test('a target past the end widens the end onto it, and only the end', () => {
+ act(() => {
+ navButton('last date')!.click()
+ })
+
+ expect(requests()[0].payload).toEqual({
+ startTime: new Date(START).toISOString(),
+ endTime: PAST_WINDOW,
+ currentTime: PAST_WINDOW,
+ })
+ })
+
+ test('a target before the start opens the start onto its whole day, and only the start', () => {
+ act(() => {
+ navButton('first date')!.click()
+ })
+
+ expect(requests()[0].payload).toEqual({
+ startTime: BEFORE_WINDOW_DAY_START,
+ endTime: new Date(END).toISOString(),
+ currentTime: BEFORE_WINDOW,
+ })
+ })
+
+ test('a step back onto an earlier stop opens the window past that day\'s midnight', () => {
+ // The stop the current time steps back to is the one before the
+ // window, so the press both moves and widens.
+ act(() => {
+ navButton('previous date')!.click()
+ })
+
+ const { startTime, currentTime } = requests()[0].payload as {
+ startTime: string
+ currentTime: string
+ }
+ expect(currentTime).toBe(BEFORE_WINDOW)
+ expect(new Date(startTime).getTime()).toBeLessThanOrEqual(
+ new Date(BEFORE_WINDOW_DAY_START).getTime()
+ )
+ })
+
+ test('the help popover says the layer rows carry controls', () => {
+ act(() => {
+ container
+ .querySelector(
+ '[aria-label="Timeline controls help"]'
+ )!
+ .click()
+ })
+
+ expect(
+ document.querySelector('.timeline-info-tooltip-content')?.textContent
+ ).toMatch(/layer/i)
+ })
+})
diff --git a/src/essence/Tools/Timeline/__tests__/TimelineView.spec.tsx b/src/essence/Tools/Timeline/__tests__/TimelineView.spec.tsx
new file mode 100644
index 000000000..1ece85830
--- /dev/null
+++ b/src/essence/Tools/Timeline/__tests__/TimelineView.spec.tsx
@@ -0,0 +1,206 @@
+import React, { act } from 'react'
+import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'
+import { createRoot, type Root } from 'react-dom/client'
+
+/**
+ * How the sidebar carries a layer's navigation controls: which rows get them,
+ * and where a press is delivered.
+ *
+ * The process timezone is pinned behind UTC, so a row resolving its target
+ * locally surfaces as a wrong instant here rather than passing on a UTC host
+ * and failing for a viewer in the Americas.
+ */
+vi.hoisted(() => {
+ process.env.TZ = 'America/New_York'
+})
+
+import { TimelineView } from '../lib/geo/TimelineView/TimelineView'
+import type { LayerNavigation } from '../lib/utils/layerNavigation'
+import type { LayerTimeData, TimeMode } from '../lib/types'
+
+;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean })
+ .IS_REACT_ACT_ENVIRONMENT = true
+
+// jsdom has no ResizeObserver; the view constructs one to follow the chart
+// area's width. The stub reports no size, leaving the starting width.
+class NoopResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+
+const START = new Date('2020-01-01T00:00:00Z')
+const END = new Date('2020-12-31T23:59:59.999Z')
+const CURRENT = new Date('2020-05-01T00:00:00Z')
+
+/** A sparse model whose stops close the listed days, as the resolver builds. */
+const sparseNav = (...days: string[]): LayerNavigation => {
+ const stops = days.map((day) => new Date(`${day}T23:59:59.999Z`))
+ return {
+ kind: 'sparse',
+ stops,
+ start: stops[0],
+ end: stops[stops.length - 1],
+ }
+}
+
+const layer = (
+ name: string,
+ navigation?: LayerNavigation
+): LayerTimeData => ({
+ name,
+ displayName: name,
+ timeRanges: [{ start: START, end: END }],
+ color: '#00b3c8',
+ navigation,
+})
+
+describe('TimelineView layer navigation', () => {
+ let container: HTMLElement
+ let root: Root
+ let navigated: Date[]
+ let committed: Date[]
+ let originalResizeObserver: unknown
+
+ beforeEach(() => {
+ originalResizeObserver = (globalThis as { ResizeObserver?: unknown })
+ .ResizeObserver
+ ;(globalThis as { ResizeObserver?: unknown }).ResizeObserver =
+ NoopResizeObserver
+
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ navigated = []
+ committed = []
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ ;(globalThis as { ResizeObserver?: unknown }).ResizeObserver =
+ originalResizeObserver as typeof ResizeObserver
+ })
+
+ const render = (
+ layers: LayerTimeData[],
+ timeMode: TimeMode = 'DAY',
+ currentTime = CURRENT
+ ) => {
+ act(() => {
+ root.render(
+ committed.push(date)}
+ onLayerNavigate={(date) => navigated.push(date)}
+ />,
+ )
+ })
+ }
+
+ const rows = () =>
+ Array.from(container.querySelectorAll('.layer-item'))
+
+ const rowButtons = (index: number) =>
+ Array.from(rows()[index].querySelectorAll('button'))
+
+ const press = (index: number, label: string) => {
+ const button = rowButtons(index).find(
+ (candidate) => candidate.getAttribute('aria-label') === label,
+ )!
+ // Dispatched rather than clicked: a control disabled in name only,
+ // through aria-disabled, still delivers the event.
+ act(() => {
+ button.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+ }
+
+ test('gives a layer that carries a navigation model its four controls', () => {
+ render([layer('MODIS Daily', sparseNav('2020-01-02', '2020-11-02'))])
+
+ expect(
+ rowButtons(0).map((button) => button.getAttribute('aria-label')),
+ ).toEqual([
+ 'MODIS Daily: first date',
+ 'MODIS Daily: previous date',
+ 'MODIS Daily: next date',
+ 'MODIS Daily: last date',
+ ])
+ })
+
+ test('leaves a layer with nothing to navigate without controls', () => {
+ // A layer the resolver found no instant for carries no model, which
+ // is what opts its row out.
+ render([
+ layer('MODIS Daily', sparseNav('2020-01-02', '2020-11-02')),
+ layer('Basemap'),
+ ])
+
+ expect(rowButtons(0)).toHaveLength(4)
+ expect(rowButtons(1)).toHaveLength(0)
+ })
+
+ test('reports the instant the pressed control leads to', () => {
+ render([layer('MODIS Daily', sparseNav('2020-01-02', '2020-11-02'))])
+
+ press(0, 'MODIS Daily: next date')
+
+ expect(navigated.map((date) => date.toISOString())).toEqual([
+ '2020-11-02T23:59:59.999Z',
+ ])
+ })
+
+ test('moves a periodic layer by the granularity the timeline is on', () => {
+ // A periodic layer steps by the timeline's granularity, so the landing
+ // is a month on only because the view is in MONTH mode.
+ render(
+ [layer('Sea Surface Temperature', {
+ kind: 'periodic',
+ start: START,
+ end: END,
+ })],
+ 'MONTH',
+ )
+
+ press(0, 'Sea Surface Temperature: next date')
+
+ expect(navigated.map((date) => date.toISOString())).toEqual([
+ '2020-06-01T00:00:00.000Z',
+ ])
+ })
+
+ test('keeps a layer jump off the scrubber\'s commit path', () => {
+ // The two paths treat the window differently, so a jump must not
+ // arrive as though the scrubber had moved.
+ render([layer('MODIS Daily', sparseNav('2020-01-02', '2020-11-02'))])
+
+ press(0, 'MODIS Daily: first date')
+
+ expect(navigated).toHaveLength(1)
+ expect(committed).toEqual([])
+ })
+
+ test('keeps each sidebar row the height of the chart row beside it', () => {
+ // The two columns share one pitch: a row drifting from its bar leaves
+ // the sidebar naming the wrong layer.
+ render([
+ layer('MODIS Daily', sparseNav('2020-01-02', '2020-11-02')),
+ layer('Basemap'),
+ ])
+
+ const chartRows = Array.from(
+ container.querySelectorAll('.layer-row-bg'),
+ )
+
+ expect(chartRows).toHaveLength(rows().length)
+ rows().forEach((row, index) => {
+ expect(row.style.height).toBe(
+ `${chartRows[index].getAttribute('height')}px`,
+ )
+ })
+ })
+})
diff --git a/src/essence/Tools/Timeline/__tests__/layerNavigation.spec.ts b/src/essence/Tools/Timeline/__tests__/layerNavigation.spec.ts
new file mode 100644
index 000000000..16fa49a17
--- /dev/null
+++ b/src/essence/Tools/Timeline/__tests__/layerNavigation.spec.ts
@@ -0,0 +1,730 @@
+import { describe, test, expect, vi } from 'vitest'
+
+/**
+ * The navigation model behind a layer row's first/previous/next/last controls.
+ *
+ * The process timezone is pinned behind UTC, so a resolver snapping days
+ * locally surfaces as a wrong day here rather than passing on a UTC host and
+ * failing for a viewer in the Americas.
+ */
+vi.hoisted(() => {
+ process.env.TZ = 'America/New_York'
+})
+
+import {
+ navigateLayer,
+ resolveLayerNavigation,
+ revealStart,
+} from '../lib/utils/layerNavigation'
+import type { LayerNavigation } from '../lib/utils/layerNavigation'
+import type { LayerTimeConfig } from '../lib/utils/timeUtils'
+import type { TimeMode } from '../lib/types'
+
+/** The timeline's own window, standing in for a bound a layer leaves unset. */
+const windowStart = new Date('2018-01-01T00:00:00Z')
+const windowEnd = new Date('2022-01-01T00:00:00Z')
+
+const resolve = (
+ time: unknown,
+ fallbackStart = windowStart,
+ fallbackEnd = windowEnd,
+ layerName?: string
+) =>
+ resolveLayerNavigation(
+ time as LayerTimeConfig | undefined,
+ fallbackStart,
+ fallbackEnd,
+ layerName
+ )
+
+const iso = (dates: Date[] | undefined) =>
+ (dates ?? []).map((date) => date.toISOString())
+
+/** A sparse model whose stops close the listed days, as the resolver builds. */
+const sparseNav = (...days: string[]): LayerNavigation => {
+ const stops = days.map((day) => new Date(`${day}T23:59:59.999Z`))
+ return {
+ kind: 'sparse',
+ stops,
+ start: stops[0],
+ end: stops[stops.length - 1],
+ }
+}
+
+const periodicNav = (start: string, end: string): LayerNavigation => ({
+ kind: 'periodic',
+ start: new Date(start),
+ end: new Date(end),
+})
+
+const goTo = (
+ nav: LayerNavigation,
+ from: string,
+ action: 'first' | 'prev' | 'next' | 'last',
+ mode: TimeMode = 'DAY'
+) => navigateLayer(nav, new Date(from), action, mode)?.toISOString() ?? null
+
+describe('resolveLayerNavigation', () => {
+ test('gives a layer with no time configuration nothing to navigate', () => {
+ expect(resolve(undefined)).toBeNull()
+ })
+
+ test('gives a layer whose time is switched off nothing to navigate', () => {
+ expect(
+ resolve({
+ enabled: false,
+ dataStartTime: '2020-01-01T00:00:00Z',
+ dataEndTime: '2020-12-31T00:00:00Z',
+ dataDates: ['2020-03-04', '2020-07-19'],
+ })
+ ).toBeNull()
+ })
+
+ test('reads a layer that lists its days as sparse', () => {
+ const nav = resolve({
+ enabled: true,
+ dataDates: ['2020-03-04', '2020-07-19'],
+ })
+
+ expect(nav?.kind).toBe('sparse')
+ })
+
+ test('stops on the last UTC instant of each listed day', () => {
+ // The current time is the trailing edge of a layer's query window; a
+ // stop at midnight would exclude that day's data.
+ const nav = resolve({
+ enabled: true,
+ dataDates: ['2020-03-04', '2020-07-19'],
+ })
+
+ expect(iso(nav?.stops)).toEqual([
+ '2020-03-04T23:59:59.999Z',
+ '2020-07-19T23:59:59.999Z',
+ ])
+ })
+
+ test('bounds a sparse layer by its first and last stop', () => {
+ const nav = resolve({
+ enabled: true,
+ dataStartTime: '2019-01-01T00:00:00Z',
+ dataEndTime: '2021-12-31T00:00:00Z',
+ dataDates: ['2020-03-04', '2020-07-19'],
+ })
+
+ expect(nav?.start.toISOString()).toBe('2020-03-04T23:59:59.999Z')
+ expect(nav?.end.toISOString()).toBe('2020-07-19T23:59:59.999Z')
+ })
+
+ test('orders the stops ascending however the days were listed', () => {
+ const nav = resolve({
+ enabled: true,
+ dataDates: ['2020-07-19', '2020-01-02', '2020-03-04'],
+ })
+
+ expect(iso(nav?.stops)).toEqual([
+ '2020-01-02T23:59:59.999Z',
+ '2020-03-04T23:59:59.999Z',
+ '2020-07-19T23:59:59.999Z',
+ ])
+ })
+
+ test('gives a day listed more than once a single stop', () => {
+ const nav = resolve({
+ enabled: true,
+ dataDates: [
+ '2020-03-04',
+ '2020-03-04T06:00:00Z',
+ '2020-03-04T18:30:00Z',
+ '2020-07-19',
+ ],
+ })
+
+ expect(iso(nav?.stops)).toEqual([
+ '2020-03-04T23:59:59.999Z',
+ '2020-07-19T23:59:59.999Z',
+ ])
+ })
+
+ test('drops unreadable days and keeps the readable ones', () => {
+ const nav = resolve({
+ enabled: true,
+ dataStartTime: '2019-01-01T00:00:00Z',
+ dataEndTime: '2021-12-31T00:00:00Z',
+ dataDates: ['2020-03-04', 'not a date', '', '2020-07-19'],
+ })
+
+ expect(nav?.kind).toBe('sparse')
+ expect(iso(nav?.stops)).toEqual([
+ '2020-03-04T23:59:59.999Z',
+ '2020-07-19T23:59:59.999Z',
+ ])
+ })
+
+ test('reads days spaced out the way a comma-separated list leaves them', () => {
+ const nav = resolve({
+ enabled: true,
+ dataDates: ['2020-03-04', ' 2020-07-19', '2020-11-02 '],
+ })
+
+ expect(iso(nav?.stops)).toEqual([
+ '2020-03-04T23:59:59.999Z',
+ '2020-07-19T23:59:59.999Z',
+ '2020-11-02T23:59:59.999Z',
+ ])
+ })
+
+ test('accepts a single listed day given as a bare string', () => {
+ const nav = resolve({
+ enabled: true,
+ dataDates: '2020-03-04',
+ })
+
+ expect(nav?.kind).toBe('sparse')
+ expect(iso(nav?.stops)).toEqual(['2020-03-04T23:59:59.999Z'])
+ })
+
+ test('reads a layer with an extent and no listed days as periodic', () => {
+ const nav = resolve({
+ enabled: true,
+ dataStartTime: '2020-01-01T00:00:00Z',
+ dataEndTime: '2020-12-31T00:00:00Z',
+ })
+
+ expect(nav?.kind).toBe('periodic')
+ expect(nav?.start.toISOString()).toBe('2020-01-01T00:00:00.000Z')
+ expect(nav?.end.toISOString()).toBe('2020-12-31T00:00:00.000Z')
+ expect(nav?.stops).toBeUndefined()
+ })
+
+ test('falls back to the extent when every listed day is unreadable', () => {
+ const nav = resolve({
+ enabled: true,
+ dataStartTime: '2020-01-01T00:00:00Z',
+ dataEndTime: '2020-12-31T00:00:00Z',
+ dataDates: ['nonsense', ''],
+ })
+
+ expect(nav?.kind).toBe('periodic')
+ expect(nav?.start.toISOString()).toBe('2020-01-01T00:00:00.000Z')
+ })
+
+ test('still reads an extent written in a looser format than ISO 8601', () => {
+ const nav = resolve({
+ enabled: true,
+ dataStartTime: '2020-01-01 00:00:00',
+ dataEndTime: '2020-12-31T00:00:00Z',
+ })
+
+ expect(nav?.start.getTime()).toBe(
+ new Date('2020-01-01 00:00:00').getTime()
+ )
+ })
+
+ test('reads an end time of "now" as the present moment', () => {
+ const before = Date.now()
+ const nav = resolve({
+ enabled: true,
+ dataStartTime: '2020-01-01T00:00:00Z',
+ dataEndTime: 'now',
+ })
+
+ expect(nav?.kind).toBe('periodic')
+ expect(nav?.end.getTime()).toBeGreaterThanOrEqual(before)
+ expect(nav?.end.getTime()).toBeLessThanOrEqual(Date.now())
+ })
+
+ test('gives a layer with neither days nor an extent nothing to navigate', () => {
+ expect(resolve({ enabled: true })).toBeNull()
+ })
+
+ test('completes a missing end from the timeline window', () => {
+ // The same fallback the bar is drawn over, so the controls cover the
+ // span the row shows.
+ const nav = resolve({
+ enabled: true,
+ dataStartTime: '2020-01-01T00:00:00Z',
+ })
+
+ expect(nav?.kind).toBe('periodic')
+ expect(nav?.start.toISOString()).toBe('2020-01-01T00:00:00.000Z')
+ expect(nav?.end.toISOString()).toBe('2022-01-01T00:00:00.000Z')
+ })
+
+ test('completes a missing start from the timeline window', () => {
+ const nav = resolve({
+ enabled: true,
+ dataEndTime: '2020-12-31T00:00:00Z',
+ })
+
+ expect(nav?.kind).toBe('periodic')
+ expect(nav?.start.toISOString()).toBe('2018-01-01T00:00:00.000Z')
+ expect(nav?.end.toISOString()).toBe('2020-12-31T00:00:00.000Z')
+ })
+
+ test('completes an unreadable bound from the timeline window', () => {
+ const nav = resolve({
+ enabled: true,
+ dataStartTime: 'whenever',
+ dataEndTime: '2020-12-31T00:00:00Z',
+ })
+
+ expect(nav?.kind).toBe('periodic')
+ expect(nav?.start.toISOString()).toBe('2018-01-01T00:00:00.000Z')
+ expect(nav?.end.toISOString()).toBe('2020-12-31T00:00:00.000Z')
+ })
+
+ test('leaves a fully configured extent alone', () => {
+ const nav = resolve({
+ enabled: true,
+ dataStartTime: '2020-01-01T00:00:00Z',
+ dataEndTime: '2020-12-31T00:00:00Z',
+ })
+
+ expect(nav?.start.toISOString()).toBe('2020-01-01T00:00:00.000Z')
+ expect(nav?.end.toISOString()).toBe('2020-12-31T00:00:00.000Z')
+ })
+
+ test('leaves a layer listing its days bounded by its stops', () => {
+ const nav = resolve({
+ enabled: true,
+ dataDates: ['2020-03-04', '2020-07-19'],
+ })
+
+ expect(nav?.start.toISOString()).toBe('2020-03-04T23:59:59.999Z')
+ expect(nav?.end.toISOString()).toBe('2020-07-19T23:59:59.999Z')
+ })
+
+ test('gives a layer with an unreadable extent nothing to navigate', () => {
+ expect(
+ resolve({
+ enabled: true,
+ dataStartTime: 'whenever',
+ dataEndTime: 'whenever else',
+ })
+ ).toBeNull()
+ })
+})
+
+describe('navigateLayer over a sparse layer', () => {
+ const nav = sparseNav(
+ '2020-01-02',
+ '2020-03-04',
+ '2020-07-19',
+ '2020-11-02'
+ )
+ const single = sparseNav('2020-03-04')
+
+ test('moves to the stop that follows the current time', () => {
+ expect(goTo(nav, '2020-05-01T00:00:00Z', 'next')).toBe(
+ '2020-07-19T23:59:59.999Z'
+ )
+ })
+
+ test('moves to the stop that precedes the current time', () => {
+ expect(goTo(nav, '2020-05-01T00:00:00Z', 'prev')).toBe(
+ '2020-03-04T23:59:59.999Z'
+ )
+ })
+
+ test('reaches into the layer from before every stop', () => {
+ expect(goTo(nav, '2019-06-15T00:00:00Z', 'next')).toBe(
+ '2020-01-02T23:59:59.999Z'
+ )
+ })
+
+ test('reaches back into the layer from months past its last stop', () => {
+ expect(goTo(nav, '2021-06-15T00:00:00Z', 'prev')).toBe(
+ '2020-11-02T23:59:59.999Z'
+ )
+ })
+
+ test('moves off a stop the current time already sits on', () => {
+ expect(goTo(nav, '2020-03-04T23:59:59.999Z', 'next')).toBe(
+ '2020-07-19T23:59:59.999Z'
+ )
+ expect(goTo(nav, '2020-03-04T23:59:59.999Z', 'prev')).toBe(
+ '2020-01-02T23:59:59.999Z'
+ )
+ })
+
+ test('moves inward from an outermost stop rather than stalling on it', () => {
+ expect(goTo(nav, '2020-01-02T23:59:59.999Z', 'next')).toBe(
+ '2020-03-04T23:59:59.999Z'
+ )
+ expect(goTo(nav, '2020-11-02T23:59:59.999Z', 'prev')).toBe(
+ '2020-07-19T23:59:59.999Z'
+ )
+ })
+
+ test('has nowhere to go beyond either end', () => {
+ expect(goTo(nav, '2020-11-02T23:59:59.999Z', 'next')).toBeNull()
+ expect(goTo(nav, '2021-06-15T00:00:00Z', 'next')).toBeNull()
+ expect(goTo(nav, '2020-01-02T23:59:59.999Z', 'prev')).toBeNull()
+ expect(goTo(nav, '2019-06-15T00:00:00Z', 'prev')).toBeNull()
+ })
+
+ test('jumps to the outermost stops whatever the current time', () => {
+ expect(goTo(nav, '2020-05-01T00:00:00Z', 'first')).toBe(
+ '2020-01-02T23:59:59.999Z'
+ )
+ expect(goTo(nav, '2020-05-01T00:00:00Z', 'last')).toBe(
+ '2020-11-02T23:59:59.999Z'
+ )
+ expect(goTo(nav, '2025-01-01T00:00:00Z', 'first')).toBe(
+ '2020-01-02T23:59:59.999Z'
+ )
+ })
+
+ test('has nowhere to jump from the stop it already sits on', () => {
+ // Repeating the jump would re-commit the time already held.
+ expect(goTo(nav, '2020-01-02T23:59:59.999Z', 'first')).toBeNull()
+ expect(goTo(nav, '2020-11-02T23:59:59.999Z', 'last')).toBeNull()
+ })
+
+ test('bounds a layer holding a single day by that one stop', () => {
+ expect(goTo(single, '2020-05-01T00:00:00Z', 'first')).toBe(
+ '2020-03-04T23:59:59.999Z'
+ )
+ expect(goTo(single, '2020-05-01T00:00:00Z', 'last')).toBe(
+ '2020-03-04T23:59:59.999Z'
+ )
+ expect(goTo(single, '2020-01-01T00:00:00Z', 'next')).toBe(
+ '2020-03-04T23:59:59.999Z'
+ )
+ expect(goTo(single, '2020-03-04T23:59:59.999Z', 'next')).toBeNull()
+ expect(goTo(single, '2020-05-01T00:00:00Z', 'prev')).toBe(
+ '2020-03-04T23:59:59.999Z'
+ )
+ expect(goTo(single, '2020-03-04T23:59:59.999Z', 'prev')).toBeNull()
+ })
+
+ test('has nowhere to go at all from the one stop of a single-day layer', () => {
+ expect(goTo(single, '2020-03-04T23:59:59.999Z', 'first')).toBeNull()
+ expect(goTo(single, '2020-03-04T23:59:59.999Z', 'prev')).toBeNull()
+ expect(goTo(single, '2020-03-04T23:59:59.999Z', 'next')).toBeNull()
+ expect(goTo(single, '2020-03-04T23:59:59.999Z', 'last')).toBeNull()
+ })
+
+ test('lands on stops rather than stepping by the timeline granularity', () => {
+ // The gaps between stops are the layer's, not the axis unit's.
+ const modes: TimeMode[] = ['YEAR', 'MONTH', 'DAY', 'HOUR']
+ for (const mode of modes) {
+ expect(goTo(nav, '2020-05-01T00:00:00Z', 'next', mode)).toBe(
+ '2020-07-19T23:59:59.999Z'
+ )
+ expect(goTo(nav, '2020-05-01T00:00:00Z', 'prev', mode)).toBe(
+ '2020-03-04T23:59:59.999Z'
+ )
+ }
+ })
+})
+
+describe('navigateLayer over a periodic layer', () => {
+ const nav = periodicNav('2020-01-01T00:00:00Z', '2020-12-31T00:00:00Z')
+
+ test('reaches the near edge of the extent from outside it', () => {
+ expect(goTo(nav, '2019-06-15T00:00:00Z', 'next')).toBe(
+ '2020-01-01T00:00:00.000Z'
+ )
+ expect(goTo(nav, '2021-06-15T00:00:00Z', 'prev')).toBe(
+ '2020-12-31T00:00:00.000Z'
+ )
+ })
+
+ test('steps inward from an edge rather than stalling on it', () => {
+ expect(goTo(nav, '2020-01-01T00:00:00Z', 'next')).toBe(
+ '2020-01-02T00:00:00.000Z'
+ )
+ expect(goTo(nav, '2020-12-31T00:00:00Z', 'prev')).toBe(
+ '2020-12-30T00:00:00.000Z'
+ )
+ })
+
+ test('has nowhere to go beyond either edge', () => {
+ expect(goTo(nav, '2020-12-31T00:00:00Z', 'next')).toBeNull()
+ expect(goTo(nav, '2021-06-15T00:00:00Z', 'next')).toBeNull()
+ expect(goTo(nav, '2020-01-01T00:00:00Z', 'prev')).toBeNull()
+ expect(goTo(nav, '2019-06-15T00:00:00Z', 'prev')).toBeNull()
+ })
+
+ test('steps by the timeline granularity inside the extent', () => {
+ expect(goTo(nav, '2020-05-15T12:00:00Z', 'next', 'HOUR')).toBe(
+ '2020-05-15T13:00:00.000Z'
+ )
+ expect(goTo(nav, '2020-05-15T12:00:00Z', 'next', 'DAY')).toBe(
+ '2020-05-16T12:00:00.000Z'
+ )
+ expect(goTo(nav, '2020-05-15T12:00:00Z', 'next', 'MONTH')).toBe(
+ '2020-06-15T12:00:00.000Z'
+ )
+ expect(goTo(nav, '2020-05-15T12:00:00Z', 'prev', 'HOUR')).toBe(
+ '2020-05-15T11:00:00.000Z'
+ )
+ expect(goTo(nav, '2020-05-15T12:00:00Z', 'prev', 'DAY')).toBe(
+ '2020-05-14T12:00:00.000Z'
+ )
+ expect(goTo(nav, '2020-05-15T12:00:00Z', 'prev', 'MONTH')).toBe(
+ '2020-04-15T12:00:00.000Z'
+ )
+ })
+
+ test('steps in UTC across a local daylight-saving boundary', () => {
+ // The process runs behind UTC, where the local day of the spring
+ // change is 23 hours long; stepping locally would drift the clock.
+ expect(goTo(nav, '2020-03-07T12:00:00Z', 'next', 'DAY')).toBe(
+ '2020-03-08T12:00:00.000Z'
+ )
+ expect(goTo(nav, '2020-03-09T12:00:00Z', 'prev', 'DAY')).toBe(
+ '2020-03-08T12:00:00.000Z'
+ )
+ })
+
+ test('clamps a step that would overshoot the extent', () => {
+ expect(goTo(nav, '2020-12-15T00:00:00Z', 'next', 'MONTH')).toBe(
+ '2020-12-31T00:00:00.000Z'
+ )
+ expect(goTo(nav, '2020-05-15T00:00:00Z', 'next', 'YEAR')).toBe(
+ '2020-12-31T00:00:00.000Z'
+ )
+ expect(goTo(nav, '2020-01-15T00:00:00Z', 'prev', 'MONTH')).toBe(
+ '2020-01-01T00:00:00.000Z'
+ )
+ expect(goTo(nav, '2020-05-15T00:00:00Z', 'prev', 'YEAR')).toBe(
+ '2020-01-01T00:00:00.000Z'
+ )
+ })
+
+ test('jumps to the edges of the extent whatever the current time', () => {
+ expect(goTo(nav, '2020-05-15T00:00:00Z', 'first')).toBe(
+ '2020-01-01T00:00:00.000Z'
+ )
+ expect(goTo(nav, '2020-05-15T00:00:00Z', 'last')).toBe(
+ '2020-12-31T00:00:00.000Z'
+ )
+ expect(goTo(nav, '2025-01-01T00:00:00Z', 'first')).toBe(
+ '2020-01-01T00:00:00.000Z'
+ )
+ expect(goTo(nav, '2015-01-01T00:00:00Z', 'last')).toBe(
+ '2020-12-31T00:00:00.000Z'
+ )
+ })
+
+ test('has nowhere to jump from the edge it already sits on', () => {
+ expect(goTo(nav, '2020-01-01T00:00:00Z', 'first')).toBeNull()
+ expect(goTo(nav, '2020-12-31T00:00:00Z', 'last')).toBeNull()
+ })
+})
+
+describe('navigateLayer over a half-configured layer', () => {
+ test('navigates a layer with only a start across the timeline window', () => {
+ const nav = resolve({
+ enabled: true,
+ dataStartTime: '2020-01-01T00:00:00Z',
+ }) as LayerNavigation
+
+ expect(goTo(nav, '2019-06-15T00:00:00Z', 'next')).toBe(
+ '2020-01-01T00:00:00.000Z'
+ )
+ expect(goTo(nav, '2019-06-15T00:00:00Z', 'prev')).toBeNull()
+ expect(goTo(nav, '2020-05-15T00:00:00Z', 'last')).toBe(
+ '2022-01-01T00:00:00.000Z'
+ )
+ expect(goTo(nav, '2022-01-01T00:00:00Z', 'next')).toBeNull()
+ })
+
+ test('navigates a layer with only an end across the timeline window', () => {
+ const nav = resolve({
+ enabled: true,
+ dataEndTime: '2020-12-31T00:00:00Z',
+ }) as LayerNavigation
+
+ expect(goTo(nav, '2021-06-15T00:00:00Z', 'prev')).toBe(
+ '2020-12-31T00:00:00.000Z'
+ )
+ expect(goTo(nav, '2021-06-15T00:00:00Z', 'next')).toBeNull()
+ expect(goTo(nav, '2020-05-15T00:00:00Z', 'first')).toBe(
+ '2018-01-01T00:00:00.000Z'
+ )
+ expect(goTo(nav, '2018-01-01T00:00:00Z', 'prev')).toBeNull()
+ })
+})
+
+describe('navigateLayer over a layer whose data lies outside the window', () => {
+ /** A month-long window, with neither layer below holding any of it. */
+ const monthStart = new Date('2024-01-01T00:00:00Z')
+ const monthEnd = new Date('2024-02-01T00:00:00Z')
+
+ const endedBeforeWindow = () =>
+ resolve(
+ { enabled: true, dataEndTime: '2020-12-31T00:00:00Z' },
+ monthStart,
+ monthEnd
+ ) as LayerNavigation
+
+ const startsAfterWindow = () =>
+ resolve(
+ { enabled: true, dataStartTime: '2030-01-01T00:00:00Z' },
+ monthStart,
+ monthEnd
+ ) as LayerNavigation
+
+ test('holds a layer that ended before the window to the instant it names', () => {
+ // Completing the open start from a window beginning after the layer's
+ // end would run the extent backwards, through a span holding no data.
+ const nav = endedBeforeWindow()
+
+ expect(nav.start.toISOString()).toBe('2020-12-31T00:00:00.000Z')
+ expect(nav.end.toISOString()).toBe('2020-12-31T00:00:00.000Z')
+ })
+
+ test('holds a layer that starts after the window to the instant it names', () => {
+ const nav = startsAfterWindow()
+
+ expect(nav.start.toISOString()).toBe('2030-01-01T00:00:00.000Z')
+ expect(nav.end.toISOString()).toBe('2030-01-01T00:00:00.000Z')
+ })
+
+ test('reaches back to a layer that ended before the window, and no further', () => {
+ const nav = endedBeforeWindow()
+
+ expect(goTo(nav, '2024-01-15T00:00:00Z', 'first')).toBe(
+ '2020-12-31T00:00:00.000Z'
+ )
+ expect(goTo(nav, '2024-01-15T00:00:00Z', 'prev')).toBe(
+ '2020-12-31T00:00:00.000Z'
+ )
+ expect(goTo(nav, '2024-01-15T00:00:00Z', 'last')).toBe(
+ '2020-12-31T00:00:00.000Z'
+ )
+ expect(goTo(nav, '2024-01-15T00:00:00Z', 'next')).toBeNull()
+ })
+
+ test('reaches forward to a layer that starts after the window, and no further', () => {
+ const nav = startsAfterWindow()
+
+ expect(goTo(nav, '2024-01-15T00:00:00Z', 'first')).toBe(
+ '2030-01-01T00:00:00.000Z'
+ )
+ expect(goTo(nav, '2024-01-15T00:00:00Z', 'next')).toBe(
+ '2030-01-01T00:00:00.000Z'
+ )
+ expect(goTo(nav, '2024-01-15T00:00:00Z', 'last')).toBe(
+ '2030-01-01T00:00:00.000Z'
+ )
+ expect(goTo(nav, '2024-01-15T00:00:00Z', 'prev')).toBeNull()
+ })
+
+ test('goes inert in every direction from the one instant it knows', () => {
+ // The unconfigured direction names no instant, so it is inert rather
+ // than pointing into the window.
+ const ended = endedBeforeWindow()
+ const starts = startsAfterWindow()
+
+ for (const action of ['first', 'prev', 'next', 'last'] as const) {
+ expect(goTo(ended, '2020-12-31T00:00:00Z', action)).toBeNull()
+ expect(goTo(starts, '2030-01-01T00:00:00Z', action)).toBeNull()
+ }
+ })
+
+ test('stays reachable once a press has widened the window onto the layer', () => {
+ // The row re-resolves against the widened window. The extent is a
+ // single instant either way: reachable from anywhere else, inert only
+ // while the current time sits on it.
+ const nav = resolve(
+ { enabled: true, dataEndTime: '2020-12-31T00:00:00Z' },
+ new Date('2020-12-31T00:00:00Z'),
+ monthEnd
+ ) as LayerNavigation
+
+ expect(nav.start.toISOString()).toBe('2020-12-31T00:00:00.000Z')
+ expect(nav.end.toISOString()).toBe('2020-12-31T00:00:00.000Z')
+ expect(goTo(nav, '2022-06-15T00:00:00Z', 'first')).toBe(
+ '2020-12-31T00:00:00.000Z'
+ )
+ expect(goTo(nav, '2022-06-15T00:00:00Z', 'prev')).toBe(
+ '2020-12-31T00:00:00.000Z'
+ )
+ expect(goTo(nav, '2020-12-31T00:00:00Z', 'prev')).toBeNull()
+ expect(goTo(nav, '2020-12-31T00:00:00Z', 'next')).toBeNull()
+ })
+})
+
+describe('resolveLayerNavigation over a self-contradictory extent', () => {
+ const inverted = {
+ enabled: true,
+ dataStartTime: '2021-01-01T00:00:00Z',
+ dataEndTime: '2020-01-01T00:00:00Z',
+ }
+
+ test('gives a layer whose end precedes its start nothing to navigate', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ expect(resolve(inverted)).toBeNull()
+ warn.mockRestore()
+ })
+
+ test('names the layer and both bounds in the warning it raises', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ resolve(inverted, windowStart, windowEnd, 'rover_images')
+
+ expect(warn).toHaveBeenCalledTimes(1)
+ const message = warn.mock.calls[0][0] as string
+ expect(message).toContain('rover_images')
+ expect(message).toContain('2021-01-01T00:00:00.000Z')
+ expect(message).toContain('2020-01-01T00:00:00.000Z')
+ warn.mockRestore()
+ })
+
+ test('leaves an extent running the right way round alone and silent', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const nav = resolve({
+ enabled: true,
+ dataStartTime: '2020-01-01T00:00:00Z',
+ dataEndTime: '2021-01-01T00:00:00Z',
+ })
+
+ expect(nav?.kind).toBe('periodic')
+ expect(warn).not.toHaveBeenCalled()
+ warn.mockRestore()
+ })
+
+ test('still holds a layer naming one bound to that bound, without warning', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ // The window sits wholly after the only bound the layer names.
+ const nav = resolve(
+ { enabled: true, dataEndTime: '2015-01-01T00:00:00Z' },
+ windowStart,
+ windowEnd
+ )
+
+ expect(iso([nav!.start, nav!.end])).toEqual([
+ '2015-01-01T00:00:00.000Z',
+ '2015-01-01T00:00:00.000Z',
+ ])
+ expect(warn).not.toHaveBeenCalled()
+ warn.mockRestore()
+ })
+})
+
+describe('revealStart', () => {
+ test('opens a window onto the whole day a sparse stop closes', () => {
+ const nav = sparseNav('2023-11-05')
+ expect(revealStart(nav, nav.stops![0]).toISOString()).toBe(
+ '2023-11-05T00:00:00.000Z'
+ )
+ })
+
+ test('takes the day in UTC, not the timezone the process runs in', () => {
+ // 19:00 in New York on the 5th is already the 6th in UTC. Snapping
+ // locally would open the window a day early.
+ const nav = sparseNav('2023-11-06')
+ expect(revealStart(nav, new Date('2023-11-06T00:30:00Z')).toISOString()).toBe(
+ '2023-11-06T00:00:00.000Z'
+ )
+ })
+
+ test('meets a periodic target exactly, its bar running inward from it', () => {
+ const nav = periodicNav('2020-01-01T00:00:00Z', '2021-01-01T00:00:00Z')
+ const target = new Date('2020-01-01T00:00:00Z')
+ expect(revealStart(nav, target)).toEqual(target)
+ })
+})
diff --git a/src/essence/Tools/Timeline/__tests__/layerTimeRanges.spec.ts b/src/essence/Tools/Timeline/__tests__/layerTimeRanges.spec.ts
index 25ed372bf..77f4a2bd8 100644
--- a/src/essence/Tools/Timeline/__tests__/layerTimeRanges.spec.ts
+++ b/src/essence/Tools/Timeline/__tests__/layerTimeRanges.spec.ts
@@ -38,6 +38,24 @@ describe('resolveLayerTimeRanges', () => {
expect(ranges).toHaveLength(2)
})
+ test('draws a day listed more than once as one box', () => {
+ // Two boxes on one day would stack, and the pair would read darker
+ // than its neighbours through the bars' shared opacity.
+ const ranges = resolve({
+ enabled: true,
+ dataDates: [
+ '2020-03-04T01:00:00Z',
+ '2020-03-04T18:30:00Z',
+ '2020-07-19',
+ ],
+ })
+
+ expect(ranges.map((range) => range.label)).toEqual([
+ '2020-03-04',
+ '2020-07-19',
+ ])
+ })
+
test('covers a listed day from its first to its last UTC instant', () => {
const [range] = resolve({
enabled: true,
diff --git a/src/essence/Tools/Timeline/lib/geo/LayerNavControls/LayerNavControls.css b/src/essence/Tools/Timeline/lib/geo/LayerNavControls/LayerNavControls.css
new file mode 100644
index 000000000..985fe6274
--- /dev/null
+++ b/src/essence/Tools/Timeline/lib/geo/LayerNavControls/LayerNavControls.css
@@ -0,0 +1,118 @@
+/* The controls overlay the end of the layer name rather than sitting beside
+ it: the sidebar is 160px wide, 100px when narrow, so four permanent buttons
+ would leave the names no room. They paint the row's background over the
+ stretch of name they cover, and only while the row is being worked on. */
+.layer-nav-controls {
+ position: absolute;
+ top: 0;
+ right: 4px;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ gap: 1px;
+ padding-left: 4px;
+ background: var(--theme-color-white, #ffffff);
+ /* Opacity, not visibility or display: those would drop the buttons out of
+ the tab order, leaving the feature reachable by mouse alone. */
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.15s ease;
+}
+
+/* The name keeps the row's full width and runs on under the controls, whose
+ background would otherwise cut it mid-glyph at a hard vertical edge. This
+ strip softens that edge over a few pixels, fading in with the controls. */
+.layer-nav-controls::before {
+ content: '';
+ position: absolute;
+ right: 100%;
+ top: 0;
+ height: 100%;
+ width: 12px;
+ background: linear-gradient(
+ to right,
+ transparent,
+ var(--theme-color-white, #ffffff)
+ );
+ pointer-events: none;
+}
+
+/* Revealed by a pointer over the row, or by focus reaching any control in it
+ — how a keyboard tabbing through the sidebar sees them. */
+.layer-item:hover .layer-nav-controls,
+.layer-item:focus-within .layer-nav-controls {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .layer-nav-controls {
+ transition: none;
+ }
+}
+
+/* Sized to the 20px row and styled after the header's playback buttons, but
+ in the row's own text colour: the icon sits inline over the layer name and
+ reads as part of that row rather than as a header control. */
+.layer-nav-btn {
+ background: transparent;
+ border: none;
+ padding: 0;
+ height: 16px;
+ width: 16px;
+ flex-shrink: 0;
+ cursor: pointer;
+ color: var(--theme-color-base-darker, #3d4551);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.2s ease;
+}
+
+/* Hover shows the rectangle only; the icon keeps its colour. */
+.layer-nav-btn:hover:not(:disabled):not([aria-disabled='true']) {
+ background: var(--theme-color-primary-lightest, #e6f4f6);
+}
+
+/* A control with nowhere to go is dimmed. It carries aria-disabled rather
+ than the disabled attribute, to keep focus when a keyboard walk exhausts
+ it, so the dimming keys on both. */
+.layer-nav-btn:disabled,
+.layer-nav-btn[aria-disabled='true'] {
+ opacity: 0.35;
+ cursor: default;
+}
+
+/* A pointer that cannot hover has no way to reveal the controls: there is no
+ :hover, and a tap cannot raise :focus-within while the buttons are still
+ pointer-events: none. They stay out on such a device, covering more of the
+ name, since an unreachable control is worse than a shortened name. */
+@media (hover: none) {
+ .layer-nav-controls {
+ opacity: 1;
+ pointer-events: auto;
+ }
+}
+
+/* At full size the four buttons span almost the whole of a narrow row.
+ Tightening them and the fade holds a readable stretch of name open. */
+@media (max-width: 768px) {
+ .layer-nav-controls {
+ padding-left: 2px;
+ gap: 0;
+ }
+
+ .layer-nav-controls::before {
+ width: 8px;
+ }
+
+ .layer-nav-btn {
+ height: 14px;
+ width: 14px;
+ }
+
+ .layer-nav-btn svg {
+ width: 12px;
+ height: 12px;
+ }
+}
diff --git a/src/essence/Tools/Timeline/lib/geo/LayerNavControls/LayerNavControls.tsx b/src/essence/Tools/Timeline/lib/geo/LayerNavControls/LayerNavControls.tsx
new file mode 100644
index 000000000..39dcef56b
--- /dev/null
+++ b/src/essence/Tools/Timeline/lib/geo/LayerNavControls/LayerNavControls.tsx
@@ -0,0 +1,113 @@
+import React, { useMemo } from 'react'
+import { navigateLayer } from '../../utils/layerNavigation'
+import type { LayerNavigation } from '../../utils/layerNavigation'
+import { TimeMode } from '../../types'
+import './LayerNavControls.css'
+
+export interface LayerNavControlsProps {
+ /**
+ * Prefixes every control's accessible name. The same four buttons repeat
+ * on every row, so the layer is all that tells one row's set from another's
+ * when they are read out of their visual context.
+ */
+ displayName: string
+ navigation: LayerNavigation
+ /** The current time each control moves away from. */
+ from: Date
+ /** The timeline's granularity, the step a periodic layer moves by. */
+ timeMode: TimeMode
+ /**
+ * Given the instant the pressed control leads to, and the model it came
+ * from — which says whether that instant stands for a whole day or for
+ * itself, and so how far a window must open to show it.
+ */
+ onNavigate: (target: Date, navigation: LayerNavigation) => void
+}
+
+/** The global playback controls' transport glyphs, in the same 24-unit space. */
+const ACTIONS: {
+ action: 'first' | 'prev' | 'next' | 'last'
+ name: string
+ path: string
+}[] = [
+ {
+ action: 'first',
+ name: 'first date',
+ path: 'M7 6L7 18L5 18L5 6L7 6ZM18 6L18 18L9 12L18 6ZM16 9.75L12.6 12L16 14.25L16 9.75Z'
+ },
+ {
+ action: 'prev',
+ name: 'previous date',
+ path: 'M17 6L17 18L8 12L17 6ZM15 9.75L11.6 12L15 14.25L15 9.75Z'
+ },
+ {
+ action: 'next',
+ name: 'next date',
+ path: 'M7 18L7 6L16 12L7 18ZM9 14.25L12.4 12L9 9.75L9 14.25Z'
+ },
+ {
+ action: 'last',
+ name: 'last date',
+ path: 'M17 18L17 6L19 6L19 18L17 18ZM6 18L6 6L15 12L6 18ZM8 14.25L11.4 12L8 9.75L8 14.25Z'
+ }
+]
+
+/**
+ * One layer row's first/previous/next/last controls, moving the timeline's
+ * current time through that layer's own data. Both the target and the inert
+ * state come from `navigateLayer`; the row does no time arithmetic of its own.
+ */
+export const LayerNavControls: React.FC = ({
+ displayName,
+ navigation,
+ from,
+ timeMode,
+ onNavigate
+}) => {
+ // Held across renders: the current time moves with every frame of a
+ // scrubber drag and every playback tick, each of which renders every row.
+ const targets = useMemo(
+ () =>
+ ACTIONS.map(({ action }) =>
+ navigateLayer(navigation, from, action, timeMode)
+ ),
+ [navigation, from, timeMode]
+ )
+
+ return (
+
+ )
+}
diff --git a/src/essence/Tools/Timeline/lib/geo/LayerTimeline/LayerTimeline.tsx b/src/essence/Tools/Timeline/lib/geo/LayerTimeline/LayerTimeline.tsx
index 8ff8e3883..5be911102 100644
--- a/src/essence/Tools/Timeline/lib/geo/LayerTimeline/LayerTimeline.tsx
+++ b/src/essence/Tools/Timeline/lib/geo/LayerTimeline/LayerTimeline.tsx
@@ -9,12 +9,18 @@ export interface LayerTimelineProps {
height: number
}
+// Drawn thickness of a range bar, independent of the row height that sidebar
+// chrome also sets, so the chart stays as light as rows grow.
+const BAR_THICKNESS = 9
+
export const LayerTimeline: React.FC = ({
layer,
xScale,
y,
height,
}) => {
+ const barY = y + (height - BAR_THICKNESS) / 2
+
return (
{/* Time range bars */}
@@ -27,9 +33,9 @@ export const LayerTimeline: React.FC = ({
void
/** Live time while the scrubber is being dragged, for display only. */
onCurrentTimePreview?: (time: Date) => void
+ /**
+ * The instant a layer row's navigation controls lead to, with the model it
+ * came from. Separate from `onCurrentTimeChange`, which clamps to the
+ * timeline's window: a layer's data may sit outside the window shown.
+ */
+ onLayerNavigate: (target: Date, navigation: LayerNavigation) => void
onResetZoomReady?: (resetZoomFn: () => void) => void
}
@@ -29,6 +37,7 @@ export const TimelineView: React.FC = ({
layers,
onCurrentTimeChange,
onCurrentTimePreview,
+ onLayerNavigate,
onResetZoomReady,
}) => {
const containerRef = useRef(null)
@@ -44,7 +53,7 @@ export const TimelineView: React.FC = ({
const [zoomTransform, setZoomTransform] = useState(zoomIdentity)
const axisHeight = 24 // Space for the bottom axis
- const layerBarHeight = 15
+ const layerBarHeight = 20 // Row pitch, shared by the sidebar item and the SVG row
const topBarHeight = 24 // Space for top axis
const markerSize = 18 // Rendered size of the scrubber marker
@@ -283,6 +292,15 @@ export const TimelineView: React.FC = ({
))}
diff --git a/src/essence/Tools/Timeline/lib/index.ts b/src/essence/Tools/Timeline/lib/index.ts
index 8a71e12b7..dbfac85a8 100644
--- a/src/essence/Tools/Timeline/lib/index.ts
+++ b/src/essence/Tools/Timeline/lib/index.ts
@@ -1,6 +1,7 @@
// Components
export { FloatingPopover, type FloatingPopoverProps } from './FloatingPopover/FloatingPopover'
export { DateSelector, type DateSelectorProps } from './geo/DateSelector/DateSelector'
+export { LayerNavControls, type LayerNavControlsProps } from './geo/LayerNavControls/LayerNavControls'
export { LayerTimeline, type LayerTimelineProps } from './geo/LayerTimeline/LayerTimeline'
export { PlaybackControls, type PlaybackControlsProps } from './geo/PlaybackControls/PlaybackControls'
export { PlaybackSpeedControl, getNextPlaybackSpeed, PLAYBACK_SPEEDS, type PlaybackSpeedControlProps, type PlaybackSpeed } from './geo/PlaybackSpeedControl/PlaybackSpeedControl'
@@ -10,3 +11,5 @@ export { TimelineView, type TimelineViewProps } from './geo/TimelineView/Timelin
// Shared domain types
export { TIME_MODE_ORDER } from './types'
export type { TimeMode, TimeRange, LayerTimeData } from './types'
+export { revealStart } from './utils/layerNavigation'
+export type { LayerNavigation } from './utils/layerNavigation'
diff --git a/src/essence/Tools/Timeline/lib/types.ts b/src/essence/Tools/Timeline/lib/types.ts
index d46eb71a8..9e0979a1e 100644
--- a/src/essence/Tools/Timeline/lib/types.ts
+++ b/src/essence/Tools/Timeline/lib/types.ts
@@ -1,3 +1,5 @@
+import type { LayerNavigation } from './utils/layerNavigation'
+
export type TimeMode = 'YEAR' | 'MONTH' | 'DAY' | 'HOUR'
// Canonical display order, largest granularity first.
@@ -16,4 +18,8 @@ export interface LayerTimeData {
displayName: string
timeRanges: TimeRange[]
color: string
+ // Where the layer's own row can put the current time. Absent when the
+ // layer names no instant to move to, which is how a row goes without
+ // navigation controls.
+ navigation?: LayerNavigation | null
}
diff --git a/src/essence/Tools/Timeline/lib/utils/layerNavigation.ts b/src/essence/Tools/Timeline/lib/utils/layerNavigation.ts
new file mode 100644
index 000000000..0a46a5906
--- /dev/null
+++ b/src/essence/Tools/Timeline/lib/utils/layerNavigation.ts
@@ -0,0 +1,185 @@
+import moment from 'moment'
+import type { TimeMode } from '../types'
+import { resolveLayerExtent, resolveListedDays, stepTime } from './timeUtils'
+import type { LayerTimeConfig } from './timeUtils'
+
+/**
+ * Where a layer's navigation controls can put the timeline's current time. A
+ * sparse layer lists the days it holds data for and carries them as stops; a
+ * periodic layer holds data throughout its extent, so its bounds are enough.
+ */
+export interface LayerNavigation {
+ kind: 'sparse' | 'periodic'
+ /** Sparse only: one stop per listed day, sorted ascending, deduplicated. */
+ stops?: Date[]
+ /** The span covered — for a sparse layer, its outermost stops. */
+ start: Date
+ end: Date
+}
+
+/**
+ * The navigation model for a layer, or null when there is nothing to navigate:
+ * the layer is not time-enabled, or names no instant to move to.
+ *
+ * A list with nothing readable in it leaves the layer navigating its extent.
+ * A stop sits on the day's last UTC instant: the current time is assigned to
+ * each layer as `layer.time.end`, so a stop at midnight would close the query
+ * window before the day's data fell inside it.
+ *
+ * An unconfigured bound is completed from the timeline's window, so the
+ * controls move through the span the layer's bar is drawn over.
+ *
+ * `layerName` names the layer in the warning a self-contradictory extent
+ * raises, and is otherwise unread.
+ */
+export function resolveLayerNavigation(
+ time: LayerTimeConfig | undefined,
+ fallbackStart: Date,
+ fallbackEnd: Date,
+ layerName?: string
+): LayerNavigation | null {
+ if (!time || time.enabled !== true) return null
+
+ const stops = resolveListedDays(time).map((day) =>
+ day.clone().endOf('day').toDate()
+ )
+
+ if (stops.length > 0)
+ return {
+ kind: 'sparse',
+ stops,
+ start: stops[0],
+ end: stops[stops.length - 1],
+ }
+
+ const { start, end, hasOwnStart, hasOwnEnd } = resolveLayerExtent(
+ time,
+ fallbackStart,
+ fallbackEnd
+ )
+
+ if (!hasOwnStart && !hasOwnEnd) return null
+
+ // A window lying wholly to one side of the layer's single configured bound
+ // would complete the open side past it, running the extent backwards
+ // through a span the layer holds no data for. Close on the bound the layer
+ // names instead, leaving the open direction inert.
+ if (!hasOwnStart && start > end) return { kind: 'periodic', start: end, end }
+ if (!hasOwnEnd && end < start) return { kind: 'periodic', start, end: start }
+
+ // Both bounds named, and the end before the start: a span the layer cannot
+ // hold data in. Every direction through it contradicts another — first
+ // lands past last, next past prev — so the row goes without controls
+ // rather than carrying four that disagree, and the config is reported.
+ if (start > end) {
+ console.warn(
+ `[Timeline] Layer ${layerName ?? '(unnamed)'} has dataStartTime ` +
+ `(${start.toISOString()}) after dataEndTime ` +
+ `(${end.toISOString()}); its date navigation is switched off.`
+ )
+ return null
+ }
+
+ return { kind: 'periodic', start, end }
+}
+
+/**
+ * The earliest instant the timeline's window must include for what a target
+ * lands on to be visible in the chart. A sparse stop sits on its day's last
+ * instant, so a window opening there meets the trailing edge of that day's
+ * box and leaves the whole of it off screen; the day has to be inside. A
+ * periodic layer's bounds are instants rather than spans, and its bar runs
+ * inward from them, so the window meets them exactly.
+ */
+export function revealStart(nav: LayerNavigation, target: Date): Date {
+ return nav.kind === 'sparse'
+ ? moment.utc(target).startOf('day').toDate()
+ : target
+}
+
+/**
+ * The sparse half of `navigateLayer`. Moving lands on the nearest stop the
+ * other side of the current time, however far away, so one press reaches data
+ * sitting months from the timeline. Comparisons are strict, so a press from an
+ * instant already on a stop moves off it rather than stalling there.
+ */
+function navigateSparseLayer(
+ stops: Date[],
+ from: Date,
+ action: 'first' | 'prev' | 'next' | 'last'
+): Date | null {
+ if (stops.length === 0) return null
+
+ const at = from.getTime()
+ const firstStop = stops[0]
+ const lastStop = stops[stops.length - 1]
+
+ switch (action) {
+ case 'first':
+ return firstStop.getTime() === at ? null : firstStop
+ case 'last':
+ return lastStop.getTime() === at ? null : lastStop
+ case 'next':
+ return stops.find((stop) => stop.getTime() > at) ?? null
+ case 'prev':
+ // Scanned from the far end rather than filtered: a row asks on
+ // every frame of a drag, for a layer that can list a stop a day
+ // over years.
+ for (let i = stops.length - 1; i >= 0; i--)
+ if (stops[i].getTime() < at) return stops[i]
+ return null
+ }
+}
+
+/**
+ * The periodic half of `navigateLayer`. Data runs throughout the extent, so
+ * moving inside it steps by the timeline's granularity and stops short at the
+ * edges; one press from outside reaches the near edge.
+ */
+function navigatePeriodicLayer(
+ start: Date,
+ end: Date,
+ from: Date,
+ action: 'first' | 'prev' | 'next' | 'last',
+ mode: TimeMode
+): Date | null {
+ const at = from.getTime()
+ const startMs = start.getTime()
+ const endMs = end.getTime()
+
+ switch (action) {
+ case 'first':
+ return at === startMs ? null : start
+ case 'last':
+ return at === endMs ? null : end
+ case 'next':
+ if (at < startMs) return start
+ if (at >= endMs) return null
+ return new Date(Math.min(stepTime(from, mode, 1).getTime(), endMs))
+ case 'prev':
+ if (at > endMs) return end
+ if (at <= startMs) return null
+ return new Date(Math.max(stepTime(from, mode, -1).getTime(), startMs))
+ }
+}
+
+/**
+ * Where a layer's first/previous/next/last control puts the current time, or
+ * null when that control has nowhere to go — the signal a layer row draws it
+ * inert. `mode` is read only by the periodic side; a sparse layer moves
+ * between its own stops regardless of it.
+ *
+ * A jump to an outermost instant the current time already sits on is nowhere
+ * to go: repeating it would re-commit the time already held while the control
+ * went on looking live.
+ */
+export function navigateLayer(
+ nav: LayerNavigation,
+ from: Date,
+ action: 'first' | 'prev' | 'next' | 'last',
+ mode: TimeMode
+): Date | null {
+ return nav.kind === 'sparse'
+ ? navigateSparseLayer(nav.stops ?? [], from, action)
+ : navigatePeriodicLayer(nav.start, nav.end, from, action, mode)
+}
diff --git a/src/essence/Tools/Timeline/lib/utils/timeUtils.ts b/src/essence/Tools/Timeline/lib/utils/timeUtils.ts
index e195c6f63..1fe998c95 100644
--- a/src/essence/Tools/Timeline/lib/utils/timeUtils.ts
+++ b/src/essence/Tools/Timeline/lib/utils/timeUtils.ts
@@ -154,6 +154,87 @@ export interface LayerTimeConfig {
dataDates?: string[] | string
}
+/** A layer's extent, with either bound completed from the caller's fallback. */
+export interface ResolvedLayerExtent {
+ start: Date
+ end: Date
+ /** False when `start` is the fallback, `dataStartTime` naming no readable bound. */
+ hasOwnStart: boolean
+ /** False when `end` is the fallback, `dataEndTime` naming no readable bound. */
+ hasOwnEnd: boolean
+}
+
+/**
+ * A layer's `dataStartTime`/`dataEndTime` extent. `dataEndTime` of `'now'`
+ * resolves to the current instant, and a bound that is absent or fails to
+ * parse falls back to the one supplied. Parsing is lenient, since configs
+ * carry these in looser formats than ISO 8601.
+ */
+export function resolveLayerExtent(
+ time: LayerTimeConfig | undefined,
+ fallbackStart: Date,
+ fallbackEnd: Date
+): ResolvedLayerExtent {
+ const parsedStart = time?.dataStartTime ? new Date(time.dataStartTime) : null
+ const parsedEnd =
+ time?.dataEndTime === 'now'
+ ? new Date()
+ : time?.dataEndTime
+ ? new Date(time.dataEndTime)
+ : null
+
+ const start =
+ parsedStart && !isNaN(parsedStart.getTime()) ? parsedStart : null
+ const end = parsedEnd && !isNaN(parsedEnd.getTime()) ? parsedEnd : null
+
+ return {
+ start: start ?? fallbackStart,
+ end: end ?? fallbackEnd,
+ hasOwnStart: start !== null,
+ hasOwnEnd: end !== null,
+ }
+}
+
+/**
+ * The days a layer lists data on, one moment per day at its first UTC instant,
+ * ascending, with a day listed more than once collapsed to one — several
+ * instants on one day being one day of data. `dataDates` is accepted as a list
+ * or as a single bare string.
+ *
+ * Days are read in UTC, matching every other instant the plugin handles;
+ * reading them locally would shift each off the day it names by the viewer's
+ * offset. A listed day must be written as ISO 8601, give or take the
+ * surrounding whitespace a comma-separated list picks up — anything else is
+ * dropped rather than guessed at, so a mistyped date costs the layer that day
+ * rather than its whole row.
+ *
+ * The one reading of `dataDates`, so what a row navigates through cannot drift
+ * from what its bar draws.
+ */
+export function resolveListedDays(
+ time: LayerTimeConfig | undefined
+): moment.Moment[] {
+ const raw = time?.dataDates
+ const listed = Array.isArray(raw)
+ ? raw
+ : typeof raw === 'string'
+ ? [raw]
+ : []
+
+ return [
+ ...new Set(
+ listed
+ .map((date) =>
+ moment.utc(String(date).trim(), moment.ISO_8601, true)
+ )
+ .filter((day) => day.isValid())
+ .map((day) => day.startOf('day').valueOf())
+ ),
+ ]
+ .sort((a, b) => a - b)
+ .map((start) => moment.utc(start))
+}
+
/**
* The spans of the timeline a layer holds data for.
*
@@ -161,16 +242,9 @@ export interface LayerTimeConfig {
* its configured extent. A sparse layer — data on a scattered handful of days
* rather than throughout — lists those days instead, and gets one whole-day
* span each, so the timeline shows the gaps rather than implying coverage the
- * layer does not have. Listing no days keeps the single continuous span.
- *
- * Days are read and bounded in UTC, matching every other instant the plugin
- * handles; snapping them locally would shift each box off the day it names by
- * the viewer's offset. A listed day must be written as ISO 8601, give or take
- * the surrounding whitespace a comma-separated list picks up — anything else
- * is dropped rather than guessed at, so a mistyped date costs the layer that
- * box rather than its whole row, and a list with nothing readable in it falls
- * back to the continuous span. The extent either side of it is read leniently,
- * since configs carry values in looser formats.
+ * layer does not have. Listing no readable days keeps the single continuous
+ * span, whose extent is read leniently, since configs carry those bounds in
+ * looser formats than the days.
*/
export function resolveLayerTimeRanges(
time: LayerTimeConfig | undefined,
@@ -180,36 +254,15 @@ export function resolveLayerTimeRanges(
if (!time || time.enabled !== true)
return [{ start: fallbackStart, end: fallbackEnd }]
- const listed = Array.isArray(time.dataDates)
- ? time.dataDates
- : typeof time.dataDates === 'string'
- ? [time.dataDates]
- : []
-
- const days = listed
- .map((date) => moment.utc(String(date).trim(), moment.ISO_8601, true))
- .filter((day) => day.isValid())
- .sort((a, b) => a.valueOf() - b.valueOf())
+ const days = resolveListedDays(time)
if (days.length > 0)
return days.map((day) => ({
- start: day.clone().startOf('day').toDate(),
+ start: day.toDate(),
end: day.clone().endOf('day').toDate(),
label: day.format('YYYY-MM-DD'),
}))
- let start = fallbackStart
- let end = fallbackEnd
-
- if (time.dataStartTime) {
- const parsedStart = new Date(time.dataStartTime)
- if (!isNaN(parsedStart.getTime())) start = parsedStart
- }
- if (time.dataEndTime === 'now') end = new Date()
- else if (time.dataEndTime) {
- const parsedEnd = new Date(time.dataEndTime)
- if (!isNaN(parsedEnd.getTime())) end = parsedEnd
- }
-
+ const { start, end } = resolveLayerExtent(time, fallbackStart, fallbackEnd)
return [{ start, end }]
}