From 7aa3e2a63724dcb48ab3bdbe2a8aa3f3d5680d17 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Mon, 19 Oct 2026 10:13:22 -0500 Subject: [PATCH] SeriesChart: the tooltip and window dates print at the series' cadence --- src/essence/Tools/FetchTimeseries/README.md | 6 +- .../Tools/FetchTimeseries/lib/timeseries.ts | 5 ++ src/essence/Tools/SeriesChart/README.md | 13 ++-- .../Tools/SeriesChart/lib/chartData.ts | 64 +++++++++++++------ .../lib/components/SeriesChartPanel.tsx | 17 +++-- .../Tools/_shared/types/chartSeries.ts | 10 ++- tests/unit/chartSeries.spec.js | 5 ++ tests/unit/fetchTimeseries.spec.js | 16 +++++ tests/unit/fetchTimeseriesTool.spec.js | 8 +++ tests/unit/seriesChartData.spec.js | 34 +++++++++- 10 files changed, 144 insertions(+), 34 deletions(-) diff --git a/src/essence/Tools/FetchTimeseries/README.md b/src/essence/Tools/FetchTimeseries/README.md index ebb4b041eb..274149996c 100644 --- a/src/essence/Tools/FetchTimeseries/README.md +++ b/src/essence/Tools/FetchTimeseries/README.md @@ -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. @@ -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) diff --git a/src/essence/Tools/FetchTimeseries/lib/timeseries.ts b/src/essence/Tools/FetchTimeseries/lib/timeseries.ts index 0291f36025..c66e90d5ca 100644 --- a/src/essence/Tools/FetchTimeseries/lib/timeseries.ts +++ b/src/essence/Tools/FetchTimeseries/lib/timeseries.ts @@ -5,6 +5,7 @@ import type { TimeMode } from '../../Timeline/lib' import { + type ChartCadence, isRecord, type ChartPoint, type ChartSeriesPayload, @@ -546,6 +547,9 @@ 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() @@ -553,6 +557,7 @@ export function buildPayload(args: { 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 diff --git a/src/essence/Tools/SeriesChart/README.md b/src/essence/Tools/SeriesChart/README.md index 3e4574d7e2..3e4c78d61a 100644 --- a/src/essence/Tools/SeriesChart/README.md +++ b/src/essence/Tools/SeriesChart/README.md @@ -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 diff --git a/src/essence/Tools/SeriesChart/lib/chartData.ts b/src/essence/Tools/SeriesChart/lib/chartData.ts index 0ee7fa4b5d..db5526c5ab 100644 --- a/src/essence/Tools/SeriesChart/lib/chartData.ts +++ b/src/essence/Tools/SeriesChart/lib/chartData.ts @@ -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 @@ -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 = { + 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. */ @@ -196,11 +215,12 @@ const HTML_ESCAPES: Record = { 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] @@ -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('
') + return [formatTooltipTime(list[0].value[0], cadence), ...rows].join('
') } } @@ -227,8 +247,9 @@ export function buildChartOption( s: ChartSeries, theme: ChartTheme, index: number, + cadence?: ChartCadence, ): Record { - return buildChart(s, theme, index).option + return buildChart(s, theme, index, cadence).option } export interface ChartModel { @@ -244,6 +265,7 @@ export function buildChart( s: ChartSeries, theme: ChartTheme, index: number, + cadence?: ChartCadence, ): ChartModel { const color = seriesColor(s, index, theme.palette) @@ -257,7 +279,7 @@ 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 = { @@ -265,7 +287,7 @@ export function buildChart( 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', diff --git a/src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx b/src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx index f20cc0a22f..4be7602209 100644 --- a/src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx +++ b/src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx @@ -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' @@ -201,7 +202,7 @@ function ReadyCard({ )}
- + (null) const [windowDates, setWindowDates] = useState<[string, string] | null>(null) @@ -252,7 +261,7 @@ function SeriesCanvas({ series, index }: { series: ChartSeries; index: number }) 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 @@ -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 ( diff --git a/src/essence/Tools/_shared/types/chartSeries.ts b/src/essence/Tools/_shared/types/chartSeries.ts index b3b32efaa3..082bdfffdb 100644 --- a/src/essence/Tools/_shared/types/chartSeries.ts +++ b/src/essence/Tools/_shared/types/chartSeries.ts @@ -32,6 +32,11 @@ 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. */ @@ -39,6 +44,7 @@ export interface ChartSeriesPayload { title: string /** Rendered under the title as the section heading. */ subtitle?: string + cadence?: ChartCadence series: ChartSeries[] meta?: ChartSeriesMeta } @@ -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, diff --git a/tests/unit/chartSeries.spec.js b/tests/unit/chartSeries.spec.js index b340a69781..7e34a8f1bb 100644 --- a/tests/unit/chartSeries.spec.js +++ b/tests/unit/chartSeries.spec.js @@ -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'], @@ -100,6 +104,7 @@ describe('chartSeries contract', () => { ], }, ], + ['an unknown cadence', { ...validPayload, cadence: 'fortnight' }], ])('rejects %s', (_name, payload) => { expect(isChartSeriesPayload(payload)).toBe(false) }) diff --git a/tests/unit/fetchTimeseries.spec.js b/tests/unit/fetchTimeseries.spec.js index 0487a1dfa9..eb62cdc8e4 100644 --- a/tests/unit/fetchTimeseries.spec.js +++ b/tests/unit/fetchTimeseries.spec.js @@ -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', diff --git a/tests/unit/fetchTimeseriesTool.spec.js b/tests/unit/fetchTimeseriesTool.spec.js index fb959fe9e7..eb2f73d9da 100644 --- a/tests/unit/fetchTimeseriesTool.spec.js +++ b/tests/unit/fetchTimeseriesTool.spec.js @@ -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' diff --git a/tests/unit/seriesChartData.spec.js b/tests/unit/seriesChartData.spec.js index 62d7dc51ab..3f8debe584 100644 --- a/tests/unit/seriesChartData.spec.js +++ b/tests/unit/seriesChartData.spec.js @@ -75,7 +75,7 @@ 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', ) @@ -83,6 +83,14 @@ describe('seriesChart chartData', () => { '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', () => { @@ -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
') + }) + test('the palette slot follows the variable index, explicit color wins', () => { const s = series() const first = buildChartOption(s, THEME, 0) @@ -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()