Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 4 additions & 2 deletions src/essence/Tools/FetchTimeseries/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ Bus-only — no core imports.
The range is seeded once per layer; a pick on another layer reseeds it. The chart below names the feature; the card
does not. It fetches the feature's series over that range and emits `seriesReady` with the
`ChartSeriesPayload` itself as the (flat, unenveloped) event payload (see
[`_shared/types/chartSeries.ts`](../_shared/types/chartSeries.ts)).
[`_shared/types/chartSeries.ts`](../_shared/types/chartSeries.ts)). A
declared **Cadence** rides on it as `cadence`, so the chart prints times
at the same step as the pickers.
Loading and failure (HTTP error, timeout, bad URL template, unusable
response shape) show on the card; neither is an event. A failure also
emits `seriesCleared`, so the previous chart never sits under an error.
Expand Down Expand Up @@ -125,7 +127,7 @@ vector-tile layer's page. The fields write the block below.
| `xKey` / `yKey` | no | Dot-paths to a point's time and value (defaults `datetime` and `value`), resolved at the point's top level and under `properties.`, so GeoJSON observation features need nothing set. Time values are ISO datetime strings or epoch milliseconds. |
| `groupBy` | no | Dot-path whose distinct values split the response into one series each (e.g. one line per measured parameter). |
| `unitKey` | no | Dot-path to a point's unit, carried onto its series; SeriesChart shows it in the card footer next to the variable name. |
| `cadence` | no | How often the series has a value: `hour` (default), `day`, `month` or `year`. Sets the step of the Start and End pickers. Declared here rather than read from the layer's Data Time Interval, since an hourly layer can feed a daily series. |
| `cadence` | no | How often the series has a value: `hour` (default), `day`, `month` or `year`. Sets the step of the Start and End pickers, and travels on the payload so the chart's tooltip and window dates print at the same step. Declared here rather than read from the layer's Data Time Interval, since an hourly layer can feed a daily series. |

## Working demo: EPA AQS stations (dev.openveda.cloud)

Expand Down
5 changes: 5 additions & 0 deletions src/essence/Tools/FetchTimeseries/lib/timeseries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import type { TimeMode } from '../../Timeline/lib'
import {
type ChartCadence,
isRecord,
type ChartPoint,
type ChartSeriesPayload,
Expand Down Expand Up @@ -546,13 +547,17 @@ export function buildPayload(args: {
const mapped = mapResponseSeries(args.response, args.config)
const pointCount = mapped.reduce((n, m) => n + m.points.length, 0)
const note = truncationNote(args.response, pointCount)
// Only a declared cadence travels: without one the chart keeps its own
// default, as the pickers keep theirs.
const cadence = parseCadence(args.config.cadence)
// Slugged group keys can collide (e.g. two all-non-ASCII parameter
// names) and the chart rejects duplicate ids — suffix them apart.
const usedIds = new Set<string>()
return {
chartId: args.chartId,
title: note ? `${args.title} (${note})` : args.title,
subtitle: args.layerDisplayName,
...(cadence ? { cadence: cadence.toLowerCase() as ChartCadence } : {}),
series: mapped.map((m) => {
const base = m.key === '' ? 'timeseries' : slug(m.key) || 'series'
let id = base
Expand Down
13 changes: 8 additions & 5 deletions src/essence/Tools/SeriesChart/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,17 @@ each value in the tooltip. One variable renders at a time: a
payload with several series gets a variable picker above the chart (see
`layout` below), and the chart, footer chip and CSV follow the pick. Mixed units therefore never need
a dual y-axis. The payload's `subtitle` renders under the title as the
section heading (FetchTimeseries sends the layer's display name); `meta` is
carried but not rendered.
section heading (FetchTimeseries sends the layer's display name); `cadence`
(hour, day, month or year) sets how precisely times print, see below;
`meta` is carried but not rendered.

Time axes render on a linear epoch-ms scale with UTC tick/tooltip
formatting; timezone-less ISO datetimes are read as UTC. The tooltip title
and the zoom window's ends print the date only (`Feb 28, 2026`), never a
clock: the feeds charted so far are daily summaries whose times are all
midnight. Axis ticks still show hours inside a one-day span.
and the zoom window's ends print at the payload's `cadence`: `Feb 28, 2026`
for a daily series, `Feb 28, 2026, 14:00` for an hourly one, `Feb 2026` for
a monthly one, `2026` for a yearly one, and the date when the payload
carries none. Axis ticks are not tied to it and still show hours inside a
one-day span.

## Configuration

Expand Down
64 changes: 43 additions & 21 deletions src/essence/Tools/SeriesChart/lib/chartData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
// counts; `useUTC` keeps those boundaries UTC, and our formatters own the
// label text so every viewer reads the same timestamps.

import type { ChartPoint, ChartSeries } from '../../_shared/types/chartSeries'
import type {
ChartCadence,
ChartPoint,
ChartSeries,
} from '../../_shared/types/chartSeries'
import type { ChartTheme } from './types'

const DAY_MS = 24 * 60 * 60 * 1000
Expand Down Expand Up @@ -71,18 +75,33 @@ export function makeTimeTickFormat(
return (ms) => fmt.format(new Date(ms))
}

const DATE_FMT = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
})
const UTC = { timeZone: 'UTC' } as const
const TIME_FMT: Record<ChartCadence, Intl.DateTimeFormat> = {
hour: new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
...UTC,
}),
day: new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
...UTC,
}),
month: new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'short', ...UTC }),
year: new Intl.DateTimeFormat('en-US', { year: 'numeric', ...UTC }),
}

/** The date a point falls on, in UTC, for the tooltip title and the zoom
* window's ends. No clock: the feeds charted are daily summaries whose
* times are all midnight, and a clock that never moves reads as broken. */
export function formatTooltipTime(ms: number): string {
return DATE_FMT.format(new Date(ms))
/** The time a point falls on, in UTC, at the series' cadence: a daily
* series prints the date, an hourly one adds the clock, a monthly one
* drops the day. Without a cadence, the date. For the tooltip title and
* the zoom window's ends. */
export function formatTooltipTime(ms: number, cadence: ChartCadence = 'day'): string {
return TIME_FMT[cadence].format(new Date(ms))
}

/** Axis tick labels, sized for a card a few hundred pixels wide. */
Expand Down Expand Up @@ -196,11 +215,12 @@ const HTML_ESCAPES: Record<string, string> = {
const escapeHtml = (text: string) =>
text.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c])

/** Axis tooltip whose title is the hovered UTC date, not raw epoch ms,
* with the value carrying the series' unit. echarts writes this string
* with innerHTML, and the series name and unit come off the wire (a
* groupBy value from the remote API), so both are escaped. */
function makeTooltipFormatter(unit?: string) {
/** Axis tooltip whose title is the hovered UTC time at the series'
* cadence, not raw epoch ms, with the value carrying the series' unit.
* echarts writes this string with innerHTML, and the series name and unit
* come off the wire (a groupBy value from the remote API), so both are
* escaped. */
function makeTooltipFormatter(unit?: string, cadence?: ChartCadence) {
const suffix = unit ? ` ${escapeHtml(unit)}` : ''
return (params: TooltipParam[] | TooltipParam): string => {
const list = Array.isArray(params) ? params : [params]
Expand All @@ -210,7 +230,7 @@ function makeTooltipFormatter(unit?: string) {
? `${p.marker}${escapeHtml(p.seriesName)}: —`
: `${p.marker}${escapeHtml(p.seriesName)}: ${p.value[1]}${suffix}`,
)
return [formatTooltipTime(list[0].value[0]), ...rows].join('<br/>')
return [formatTooltipTime(list[0].value[0], cadence), ...rows].join('<br/>')
}
}

Expand All @@ -227,8 +247,9 @@ export function buildChartOption(
s: ChartSeries,
theme: ChartTheme,
index: number,
cadence?: ChartCadence,
): Record<string, any> {
return buildChart(s, theme, index).option
return buildChart(s, theme, index, cadence).option
}

export interface ChartModel {
Expand All @@ -244,6 +265,7 @@ export function buildChart(
s: ChartSeries,
theme: ChartTheme,
index: number,
cadence?: ChartCadence,
): ChartModel {
const color = seriesColor(s, index, theme.palette)

Expand All @@ -257,15 +279,15 @@ export function buildChart(
const windowText = (startPct: number, endPct: number): [string, string] | null => {
if (!xExtent) return null
const [start, end] = windowAt(xExtent, startPct, endPct)
return [formatTooltipTime(start), formatTooltipTime(end)]
return [formatTooltipTime(start, cadence), formatTooltipTime(end, cadence)]
}

const option = {
useUTC: true,
tooltip: {
trigger: 'axis' as const,
axisPointer: { type: 'cross' as const, label: { show: false } },
formatter: makeTooltipFormatter(s.unit),
formatter: makeTooltipFormatter(s.unit, cadence),
// The panel clips its overflow; on the body the tooltip floats
// over every panel instead of being cut off at the card's edge.
appendTo: 'body',
Expand Down
17 changes: 13 additions & 4 deletions src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useEffect, useId, useRef, useState } from 'react'
import * as echarts from 'echarts'
import type {
ChartCadence,
ChartSeries,
ChartSeriesPayload,
} from '../../../_shared/types/chartSeries'
Expand Down Expand Up @@ -201,7 +202,7 @@ function ReadyCard({
</label>
)}
<section className="series-chart__plot">
<SeriesCanvas series={picked} index={index} />
<SeriesCanvas series={picked} index={index} cadence={payload.cadence} />
<CardFooter
series={picked}
index={index}
Expand Down Expand Up @@ -244,15 +245,23 @@ function themeFromCss(el: HTMLElement): ChartTheme {
}
}

function SeriesCanvas({ series, index }: { series: ChartSeries; index: number }) {
function SeriesCanvas({
series,
index,
cadence,
}: {
series: ChartSeries
index: number
cadence?: ChartCadence
}) {
const hostRef = useRef<HTMLDivElement>(null)
const [windowDates, setWindowDates] = useState<[string, string] | null>(null)

useEffect(() => {
const host = hostRef.current
if (!host) return
const chart = echarts.init(host)
const { option, windowText } = buildChart(series, themeFromCss(host), index)
const { option, windowText } = buildChart(series, themeFromCss(host), index, cadence)
chart.setOption(option as never)
setWindowDates(windowText(0, 100))
// The window dates follow every zoom, whether from the strip or from
Expand All @@ -275,7 +284,7 @@ function SeriesCanvas({ series, index }: { series: ChartSeries; index: number })
observer.disconnect()
chart.dispose()
}
}, [series, index])
}, [series, index, cadence])

const { side, bottom } = WINDOW_LABEL_LAYOUT
return (
Expand Down
10 changes: 9 additions & 1 deletion src/essence/Tools/_shared/types/chartSeries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,19 @@ export interface ChartSeriesMeta {
featureId?: string | number
}

/** How often a series has a value. The chart prints times at this
* precision; without it, it prints dates. */
export type ChartCadence = 'hour' | 'day' | 'month' | 'year'
export const CHART_CADENCES: readonly ChartCadence[] = ['hour', 'day', 'month', 'year']

export interface ChartSeriesPayload {
/** Slot identity: a payload with the same chartId replaces the previous
* chart; distinct chartIds render as separate cards. */
chartId: string
title: string
/** Rendered under the title as the section heading. */
subtitle?: string
cadence?: ChartCadence
series: ChartSeries[]
meta?: ChartSeriesMeta
}
Expand Down Expand Up @@ -113,7 +119,9 @@ export function isChartSeriesPayload(value: unknown): value is ChartSeriesPayloa
typeof value.title === 'string' &&
Array.isArray(value.series) &&
value.series.length > 0 &&
value.series.every(isChartSeries)
value.series.every(isChartSeries) &&
(value.cadence === undefined ||
CHART_CADENCES.includes(value.cadence as ChartCadence))
if (!shapeOk) return false
// ids key card lists and labels key the legend picker/footer/CSV —
// duplicates in either collapse picker entries and mislabel the rest,
Expand Down
5 changes: 5 additions & 0 deletions tests/unit/chartSeries.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ describe('chartSeries contract', () => {
expect(isChartSeriesPayload(multi)).toBe(true)
})

test('accepts a cadence word', () => {
expect(isChartSeriesPayload({ ...validPayload, cadence: 'day' })).toBe(true)
})

test.each([
['null', null],
['non-object', 'nope'],
Expand Down Expand Up @@ -100,6 +104,7 @@ describe('chartSeries contract', () => {
],
},
],
['an unknown cadence', { ...validPayload, cadence: 'fortnight' }],
])('rejects %s', (_name, payload) => {
expect(isChartSeriesPayload(payload)).toBe(false)
})
Expand Down
16 changes: 16 additions & 0 deletions tests/unit/fetchTimeseries.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,22 @@ describe('fetchTimeseries lib', () => {
})
})

test('a declared cadence rides on the payload; none declared, none sent', () => {
const build = (config) =>
buildPayload({
chartId: 'c',
response: [{ datetime: '2026-01-01', value: 1 }],
config,
title: 'Station 42',
layerDisplayName: 'L',
layerName: 'uuid-1',
})
expect(build({ url: 'x', cadence: 'Day' }).cadence).toBe('day')
expect(build({ url: 'x', cadence: 'fortnight' })).not.toHaveProperty('cadence')
expect(build({ url: 'x' })).not.toHaveProperty('cadence')
expect(isChartSeriesPayload(build({ url: 'x', cadence: 'month' }))).toBe(true)
})

test('grouped responses become one labeled series per group', () => {
const payload = buildPayload({
chartId: 'vector-timeseries',
Expand Down
8 changes: 8 additions & 0 deletions tests/unit/fetchTimeseriesTool.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,14 @@ describe('FetchTimeseriesTool', () => {
expect(valueOf('End')).toBe('2026-09-24T23:59:59')
})

test('the emitted payload carries the layer cadence, and nothing when none is set', async () => {
await request()
expect(emittedFor(READY)[0]).not.toHaveProperty('cadence')
layerConfigs[LAYER].variables.timeseries.cadence = 'day'
await request()
expect(emittedFor(READY)[1].cadence).toBe('day')
})

test('an unreadable cadence warns and keeps the hour picker', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
layerConfigs[LAYER].variables.timeseries.cadence = 'fortnight'
Expand Down
34 changes: 33 additions & 1 deletion tests/unit/seriesChartData.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,22 @@ describe('seriesChart chartData', () => {
})

describe('formatTooltipTime', () => {
test('prints the UTC date only, no clock', () => {
test('prints the UTC date without a cadence, as for a daily one', () => {
expect(formatTooltipTime(Date.parse('2026-03-04T14:30:00Z'))).toBe(
'Mar 4, 2026',
)
expect(formatTooltipTime(Date.parse('2026-03-04T23:30:00-05:00'))).toBe(
'Mar 5, 2026',
)
})

test('prints at the cadence: a clock for hourly, no day for monthly, the year alone for yearly', () => {
const ms = Date.parse('2026-03-04T14:30:00Z')
expect(formatTooltipTime(ms, 'hour')).toBe('Mar 4, 2026, 14:30')
expect(formatTooltipTime(ms, 'day')).toBe('Mar 4, 2026')
expect(formatTooltipTime(ms, 'month')).toBe('Mar 2026')
expect(formatTooltipTime(ms, 'year')).toBe('2026')
})
})

describe('buildChartOption', () => {
Expand Down Expand Up @@ -144,6 +152,13 @@ describe('seriesChart chartData', () => {
expect(html).not.toContain('14:30')
})

test('an hourly cadence puts the clock in the tooltip title', () => {
const html = buildChartOption(series(), THEME, 0, 'hour').tooltip.formatter([
{ marker: '·', seriesName: 'S1', value: [Date.parse('2026-01-01T14:30:00Z'), 1] },
])
expect(html).toContain('Jan 1, 2026, 14:30<br/>')
})

test('the palette slot follows the variable index, explicit color wins', () => {
const s = series()
const first = buildChartOption(s, THEME, 0)
Expand Down Expand Up @@ -241,6 +256,23 @@ describe('seriesChart chartData', () => {
expect(start).toBe('Jan 1, 2026')
})

test('the window dates print at the cadence', () => {
const long = series({
points: [
{ x: '2026-01-01T00:00:00Z', y: 1 },
{ x: '2026-12-01T00:00:00Z', y: 2 },
],
})
expect(buildChart(long, THEME, 0, 'month').windowText(0, 100)).toEqual([
'Jan 2026',
'Dec 2026',
])
expect(buildChart(long, THEME, 0, 'hour').windowText(0, 100)).toEqual([
'Jan 1, 2026, 00:00',
'Dec 1, 2026, 00:00',
])
})

test('a series with no readable times has no window dates', () => {
const empty = series({ points: [{ x: 'not a time', y: 1 }] })
expect(buildChart(empty, THEME, 0).windowText(0, 100)).toBeNull()
Expand Down