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 ( +
+ {ACTIONS.map(({ action, name, path }, i) => { + const target = targets[i] + const label = `${displayName}: ${name}` + + 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 = ({
{layer.displayName} + {layer.navigation && ( + + )}
))} 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 }] }