Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
60ed761
[407] Resolve a layer's time config into a navigation model
sandesh-sp Sep 9, 2026
af69e3b
[407] Widen the layer row to 20px and decouple bar thickness from it
sandesh-sp Sep 9, 2026
d928dad
[407] Answer where a layer's navigation controls lead
sandesh-sp Sep 9, 2026
099599f
[407] Give a layer naming one bound a span to navigate
sandesh-sp Sep 9, 2026
82fd5c8
[407] Name what the layer row height measures
sandesh-sp Sep 9, 2026
354dfac
[407] Report a jump onto the current time as nowhere to go
sandesh-sp Sep 9, 2026
2e49346
[407] Give a layer row its own transport controls
sandesh-sp Sep 9, 2026
dad87b9
[407] Share one extent rule between the drawer and the navigator
sandesh-sp Sep 9, 2026
e8da135
[407] Carry a layer's navigation controls in its sidebar row
sandesh-sp Sep 9, 2026
009e56a
[407] Navigate the timeline through a layer's own dates
sandesh-sp Sep 9, 2026
71b2663
[407] Keep the layer controls reachable without a hovering pointer
sandesh-sp Sep 9, 2026
3cd88e7
[407] Hold a layer's extent to the bound it names
sandesh-sp Sep 9, 2026
fc81aa5
[407] Keep focus on a layer control that runs out of dates
sandesh-sp Sep 9, 2026
778f75a
[407] Tidy the layer navigation model's edges
sandesh-sp Sep 9, 2026
007af2c
[407] Trim the layer navigation comments to what the code does not say
sandesh-sp Sep 9, 2026
61c67ef
[407] Collapse a layer's listed days in one pass
sandesh-sp Sep 9, 2026
fdeb31b
[407] Refuse an extent that names its end before its start
sandesh-sp Sep 9, 2026
f6383d8
[407] Open the window onto the whole day a stop closes
sandesh-sp Sep 9, 2026
53abb1f
[407] Read a layer's listed days in one place
sandesh-sp Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/essence/Tools/Timeline/Timeline.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
58 changes: 53 additions & 5 deletions src/essence/Tools/Timeline/TimelineAdapter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'. */
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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
),
})
})

Expand Down Expand Up @@ -477,6 +524,7 @@ export const TimelineAdapter: React.FC = () => {
layers={layers}
onCurrentTimeChange={handleCurrentTimeChange}
onCurrentTimePreview={handleCurrentTimePreview}
onLayerNavigate={handleLayerNavigate}
onResetZoomReady={handleResetZoomReady}
/>
)}
Expand All @@ -493,7 +541,7 @@ export const TimelineAdapter: React.FC = () => {
>
<div className="timeline-info-tooltip-content">
<strong>Timeline Controls</strong>
<p>Scroll to zoom • Drag scrubber to change time • Click to jump</p>
<p>Scroll to zoom • Drag scrubber to change time • Click to jump • Hover a layer to step through its dates</p>
</div>
</FloatingPopover>
</div>
Expand Down
249 changes: 249 additions & 0 deletions src/essence/Tools/Timeline/__tests__/LayerNavControls.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(
<LayerNavControls
displayName={displayName}
navigation={navigation}
from={new Date(from)}
timeMode={timeMode}
onNavigate={(date, navigation) => {
committed.push(date)
reported.push(navigation)
}}
/>,
)
})
}

const buttons = () =>
Array.from(container.querySelectorAll<HTMLButtonElement>('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)
})
})
Loading
Loading