From 6ac4f3661424ed95589cb89963aac47bc2a5b9c2 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Mon, 10 Aug 2026 14:30:49 -0500 Subject: [PATCH 01/10] Add SeriesChart: chart-series contract and single-variable chart plugin --- .../SeriesChart/MMGISSeriesChartAdapter.tsx | 130 ++++++++ src/essence/Tools/SeriesChart/README.md | 54 ++++ .../Tools/SeriesChart/SeriesChartTool.tsx | 39 +++ src/essence/Tools/SeriesChart/config.json | 48 +++ .../Tools/SeriesChart/lib/chartData.ts | 291 ++++++++++++++++++ .../lib/components/SeriesChartPanel.tsx | 174 +++++++++++ src/essence/Tools/SeriesChart/lib/index.ts | 5 + .../lib/styles/components/index.scss | 1 + .../lib/styles/components/series-chart.scss | 99 ++++++ .../Tools/SeriesChart/lib/styles/index.scss | 4 + src/essence/Tools/SeriesChart/lib/types.ts | 24 ++ .../Tools/_shared/types/chartSeries.ts | 132 ++++++++ tests/unit/chartSeries.spec.js | 91 ++++++ tests/unit/seriesChartData.spec.js | 271 ++++++++++++++++ 14 files changed, 1363 insertions(+) create mode 100644 src/essence/Tools/SeriesChart/MMGISSeriesChartAdapter.tsx create mode 100644 src/essence/Tools/SeriesChart/README.md create mode 100644 src/essence/Tools/SeriesChart/SeriesChartTool.tsx create mode 100644 src/essence/Tools/SeriesChart/config.json create mode 100644 src/essence/Tools/SeriesChart/lib/chartData.ts create mode 100644 src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx create mode 100644 src/essence/Tools/SeriesChart/lib/index.ts create mode 100644 src/essence/Tools/SeriesChart/lib/styles/components/index.scss create mode 100644 src/essence/Tools/SeriesChart/lib/styles/components/series-chart.scss create mode 100644 src/essence/Tools/SeriesChart/lib/styles/index.scss create mode 100644 src/essence/Tools/SeriesChart/lib/types.ts create mode 100644 src/essence/Tools/_shared/types/chartSeries.ts create mode 100644 tests/unit/chartSeries.spec.js create mode 100644 tests/unit/seriesChartData.spec.js diff --git a/src/essence/Tools/SeriesChart/MMGISSeriesChartAdapter.tsx b/src/essence/Tools/SeriesChart/MMGISSeriesChartAdapter.tsx new file mode 100644 index 000000000..3b054f523 --- /dev/null +++ b/src/essence/Tools/SeriesChart/MMGISSeriesChartAdapter.tsx @@ -0,0 +1,130 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { SeriesChartPanel } from './lib' +import type { CardState } from './lib/types' +import { mmgisOn, mmgisRequest } from '../_shared/adapters/mmgisAPI' +import { useMMGISHandlerReady } from '../_shared/adapters/useMMGISHandlerReady' +import { + seriesEvents, + isChartSeriesPayload, +} from '../_shared/types/chartSeries' + +const PLUGIN_ID = 'serieschart' + +/** + * Fetcher plugin ids the chart listens to by default. Overridable via the + * tool's `sources` variable — that config entry is how an app builder wires + * a new fetcher plugin into this chart without code changes. + */ +const DEFAULT_SOURCES = ['fetch-timeseries'] + +function chartIdOf(payload: unknown): string | null { + const id = (payload as { chartId?: unknown } | null)?.chartId + return typeof id === 'string' && id !== '' ? id : null +} + +/** + * Bridges the bus to the presentational panel: subscribes to each source + * plugin's series events and keeps one card per chartId. All payloads are + * treated as untrusted (other plugins emit them) — malformed ones warn and + * are dropped rather than crashing the panel. + */ +export function MMGISSeriesChartAdapter() { + const [sources, setSources] = useState(DEFAULT_SOURCES) + const [cards, setCards] = useState>({}) + + const refresh = useCallback(async () => { + try { + const vars = await mmgisRequest<{ sources?: unknown }>( + 'tool:getVars', + PLUGIN_ID, + ) + const list = Array.isArray(vars?.sources) + ? (vars?.sources ?? []).filter( + (s): s is string => typeof s === 'string' && s !== '', + ) + : [] + if (list.length > 0) setSources(list) + } catch (err) { + console.warn('[SeriesChart] tool:getVars unavailable:', err) + } + }, []) + // Registered by Layers_.fina() during mission load; wait so the initial + // read doesn't silently return null and stick with defaults forever. + useMMGISHandlerReady('tool:getVars', refresh) + + useEffect(() => { + const offs = sources.flatMap((sourceId) => { + const events = seriesEvents(sourceId) + return [ + mmgisOn(events.loading, (p) => { + const chartId = chartIdOf(p) + if (!chartId) return + const title = (p as { title?: unknown }).title + setCards((prev) => ({ + ...prev, + [chartId]: { + status: 'loading', + title: + typeof title === 'string' + ? title + : titleOf(prev[chartId]), + }, + })) + }), + mmgisOn(events.ready, (p) => { + const payload = (p as { payload?: unknown } | null)?.payload + if (!isChartSeriesPayload(payload)) { + console.warn( + `[SeriesChart] dropped malformed seriesReady from '${sourceId}'`, + p, + ) + return + } + setCards((prev) => ({ + ...prev, + [payload.chartId]: { status: 'ready', payload }, + })) + }), + mmgisOn(events.error, (p) => { + const chartId = chartIdOf(p) + if (!chartId) return + const message = (p as { message?: unknown }).message + setCards((prev) => ({ + ...prev, + [chartId]: { + status: 'error', + title: titleOf(prev[chartId]), + message: + typeof message === 'string' && message !== '' + ? message + : 'Could not load data.', + }, + })) + }), + mmgisOn(events.cleared, (p) => { + const chartId = chartIdOf(p) + if (!chartId) return + setCards((prev) => { + if (!(chartId in prev)) return prev + const next = { ...prev } + delete next[chartId] + return next + }) + }), + ] + }) + return () => offs.forEach((off) => off()) + }, [sources]) + + const cardList = Object.entries(cards).map(([chartId, state]) => ({ + chartId, + state, + })) + return +} + +function titleOf(state: CardState | undefined): string | undefined { + if (!state) return undefined + if (state.status === 'ready') return state.payload.title + return state.title +} diff --git a/src/essence/Tools/SeriesChart/README.md b/src/essence/Tools/SeriesChart/README.md new file mode 100644 index 000000000..b1a0cd999 --- /dev/null +++ b/src/essence/Tools/SeriesChart/README.md @@ -0,0 +1,54 @@ +# SeriesChart plugin + +Generic, presentation-only chart panel. It renders whatever chart-series +payloads arrive on the bus and knows nothing about data sources — any plugin +that emits the shared contract can drive it. Bus-only, no core imports. + +## The contract + +Defined in [`_shared/types/chartSeries.ts`](../_shared/types/chartSeries.ts). +A fetcher plugin with id `` emits (names via `seriesEvents('')`): + +- `plugin::seriesLoading` `{ chartId, title? }` → card shows a spinner +- `plugin::seriesReady` `{ payload: ChartSeriesPayload }` → card renders the chart +- `plugin::seriesError` `{ chartId, message }` → card shows the message +- `plugin::seriesCleared` `{ chartId }` → card is removed + +One card per `chartId`; a new payload with the same `chartId` replaces the +previous chart. Malformed payloads are dropped with a console warning +(`isChartSeriesPayload` guard) — they never crash the panel. + +Payload capabilities: multiple series per chart, `time`/`linear`/`category` +x-axes, `y: null` gaps (not interpolated), per-series `line`/`area`/`bar` +style and color, and per-series `unit` — exactly two distinct units split +onto left/right y-axes. + +Time axes render on a linear epoch-ms scale with UTC tick/tooltip formatting +(no Chart.js date-adapter dependency); timezone-less ISO datetimes are read +as UTC. + +## Configuration + +`variables.sources` — array of fetcher plugin ids to listen to +(default `["fetch-timeseries"]`). Wiring a new fetcher into the chart is a +config entry, not a code change: + +```json +{ "sources": ["fetch-timeseries", "fetch-raster-timeseries"] } +``` + +## Smoke test (devtools console) + +```js +window.mmgisAPI.emit('plugin:fetch-timeseries:seriesReady', { payload: { + chartId: 'demo', title: 'Station 42', xType: 'time', + series: [{ id: 'no2', label: 'NO₂', points: [ + { x: '2026-01-01T00:00:00Z', y: 1.2 }, + { x: '2026-02-01T00:00:00Z', y: 2.4 }, + { x: '2026-03-01T00:00:00Z', y: 1.8 }, + ] }], +} }) +``` + +See [FetchTimeseries](../FetchTimeseries/README.md) for a working +end-to-end demo against live AQS station data. diff --git a/src/essence/Tools/SeriesChart/SeriesChartTool.tsx b/src/essence/Tools/SeriesChart/SeriesChartTool.tsx new file mode 100644 index 000000000..4c39f2725 --- /dev/null +++ b/src/essence/Tools/SeriesChart/SeriesChartTool.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { MMGISSeriesChartAdapter } from './MMGISSeriesChartAdapter' + +let _root: Root | null = null + +const SeriesChartTool = { + height: 0, + width: 400 as number | 'full', + targetId: null as string | null, + made: false, + + make: function (targetId?: string) { + this.targetId = typeof targetId === 'string' ? targetId : 'toolPanel' + const container = document.getElementById(this.targetId) + if (!container) { + console.error(`SeriesChartTool: container ${this.targetId} not found`) + return + } + _root = createRoot(container) + _root.render() + this.made = true + }, + + destroy: function () { + if (_root) { + _root.unmount() + _root = null + } + this.targetId = null + this.made = false + }, + + getUrlString: function () { + return '' + }, +} + +export default SeriesChartTool diff --git a/src/essence/Tools/SeriesChart/config.json b/src/essence/Tools/SeriesChart/config.json new file mode 100644 index 000000000..66936e03c --- /dev/null +++ b/src/essence/Tools/SeriesChart/config.json @@ -0,0 +1,48 @@ +{ + "defaults": { + "variables": { + "sources": ["fetch-timeseries"] + } + }, + "defaultIcon": "chart-line", + "description": "Generic chart panel: renders time/line charts published by fetcher plugins over the mmgisAPI Event Bus.", + "descriptionFull": { + "title": "Subscribes to plugin::seriesLoading/seriesReady/seriesError/seriesCleared for each plugin id listed in variables.sources and renders one chart card per chartId. Knows nothing about data sources — any plugin emitting the shared chart-series payload (src/essence/Tools/_shared/types/chartSeries.ts) can drive it, e.g. FetchTimeseries for vector feature time series.", + "example": { + "sources": ["fetch-timeseries"] + } + }, + "hasVars": true, + "name": "SeriesChart", + "toolbarPriority": 7, + "width": 400, + "height": 0, + "paths": { + "SeriesChartTool": "essence/Tools/SeriesChart/SeriesChartTool" + }, + "metadata": { + "icon": "chart-line", + "requiredOrientation": "vertical", + "compatiblePositions": ["left", "right"], + "preferredPosition": "right", + "modernLayoutSupport": true, + "width": 400, + "height": 0 + }, + "config": { + "rows": [ + { + "components": [ + { + "field": "variables.sources", + "name": "Source plugins (JSON array)", + "description": "Plugin ids whose series events this chart renders, e.g. [\"fetch-timeseries\"]. Adding a new fetcher plugin here wires it to this chart with no code change.", + "type": "json", + "width": 12, + "height": "120px" + } + ] + } + ] + } +} diff --git a/src/essence/Tools/SeriesChart/lib/chartData.ts b/src/essence/Tools/SeriesChart/lib/chartData.ts new file mode 100644 index 000000000..bcca9e1d2 --- /dev/null +++ b/src/essence/Tools/SeriesChart/lib/chartData.ts @@ -0,0 +1,291 @@ +// Pure payload → ECharts option translation. No DOM, no echarts import — +// the output is a plain option object the rendering component hands to +// `chart.setOption(...)`, which keeps everything here unit-testable. +// +// Time axes deliberately use a VALUE axis over epoch milliseconds with our +// own tick/tooltip formatting: echarts' native 'time' axis renders labels in +// the viewer's local zone, and epoch-value with UTC formatters keeps every +// viewer seeing the same timestamps. + +import type { + ChartPoint, + ChartSeries, + ChartSeriesPayload, +} from '../../_shared/types/chartSeries' +import type { ChartTheme } from './types' + +const DAY_MS = 24 * 60 * 60 * 1000 + +export interface XyPoint { + x: number + y: number | null +} + +/** Timezone-less ISO datetimes (common in OGC feature APIs) are read as UTC — + * Date.parse would use the viewer's local zone, shifting points per user. */ +const TZ_LESS_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/ + +/** + * Converts a series' points for a time axis: ISO datetime → epoch ms, + * dropping unparseable x values (a bad timestamp shouldn't sink the series). + * `y: null` gaps pass through — Chart.js breaks the line there. + */ +export function toTimePoints(points: ChartPoint[]): XyPoint[] { + const out: XyPoint[] = [] + for (const p of points) { + const ms = + typeof p.x === 'number' + ? p.x + : Date.parse(TZ_LESS_ISO.test(p.x) ? `${p.x}Z` : p.x) + if (Number.isNaN(ms)) continue + out.push({ x: ms, y: p.y }) + } + return out.sort((a, b) => a.x - b.x) +} + +export function toLinearPoints(points: ChartPoint[]): XyPoint[] { + const out: XyPoint[] = [] + for (const p of points) { + const x = typeof p.x === 'number' ? p.x : Number(p.x) + if (Number.isNaN(x)) continue + out.push({ x, y: p.y }) + } + return out.sort((a, b) => a.x - b.x) +} + +/** + * Category alignment: labels are the union of every series' x values in + * first-appearance order; each dataset's data aligns to those labels with + * null where a series has no value for a label. + */ +export function toCategoryData(series: ChartSeries[]): { + labels: string[] + rows: Array> +} { + const labels: string[] = [] + const indexOf = new Map() + for (const s of series) { + for (const p of s.points) { + const key = String(p.x) + if (!indexOf.has(key)) { + indexOf.set(key, labels.length) + labels.push(key) + } + } + } + const rows = series.map((s) => { + const row: Array = labels.map(() => null) + for (const p of s.points) { + row[indexOf.get(String(p.x)) as number] = p.y + } + return row + }) + return { labels, rows } +} + +/** + * Tick formatter for an epoch-ms axis, granularity picked from the span: + * hours within ~2 days, month+day up to ~1.5 years, month+year beyond. + * Always UTC, matching the project's datetime conventions. + */ +export function makeTimeTickFormat( + minMs: number, + maxMs: number, +): (ms: number) => string { + const span = maxMs - minMs + const opts: Intl.DateTimeFormatOptions = + span <= 2 * DAY_MS + ? { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' } + : span <= 550 * DAY_MS + ? { month: 'short', day: 'numeric' } + : { month: 'short', year: 'numeric' } + const fmt = new Intl.DateTimeFormat('en-US', { ...opts, timeZone: 'UTC' }) + return (ms) => fmt.format(new Date(ms)) +} + +const TOOLTIP_FMT = new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + timeZone: 'UTC', +}) + +export function formatTooltipTime(ms: number): string { + return TOOLTIP_FMT.format(new Date(ms)) +} + +function seriesBase(s: ChartSeries, i: number, theme: ChartTheme) { + const color = s.color || theme.palette[i % theme.palette.length] + return { + name: s.label, + type: s.style === 'bar' ? ('bar' as const) : ('line' as const), + ...(s.style === 'area' ? { areaStyle: {} } : {}), + itemStyle: { color }, + lineStyle: { width: 2 }, + symbolSize: 5, + connectNulls: false, + } +} + +/** + * The complete ECharts option for a payload. Typed loosely on purpose: + * echarts' own option generics add nothing here and the object is validated + * by rendering it. + * + * One variable is visible at a time (`visibleLabel`, default: the first + * series); the single-select legend is the picker and the y-axis is named + * for the visible variable's unit. + */ +export function buildChartOption( + payload: ChartSeriesPayload, + theme: ChartTheme, + visibleLabel?: string, +): Record { + const visible = visibleLabel ?? payload.series[0]?.label + const selected: Record = {} + for (const s of payload.series) selected[s.label] = s.label === visible + + const activeSeries = payload.series.find((s) => s.label === visible) + const yTitle = payload.yLabel ?? activeSeries?.unit ?? null + + const yAxis = [ + { + type: 'value' as const, + scale: true, + name: yTitle ?? undefined, + nameLocation: 'middle' as const, + nameGap: 42, + nameTextStyle: { color: theme.textColor }, + axisLabel: { color: theme.textColor }, + splitLine: { lineStyle: { color: theme.gridColor } }, + }, + ] + + const common = { + legend: { + show: true, + type: 'scroll' as const, + selectedMode: 'single' as const, + top: 0, + left: 8, + right: 8, + textStyle: { color: theme.textColor }, + selected, + }, + grid: { left: 56, right: 16, top: 32, bottom: 44 }, + dataZoom: [ + { type: 'inside' as const, xAxisIndex: 0 }, + { + type: 'slider' as const, + xAxisIndex: 0, + // Slim range-input look: grey track, solid primary fill, + // round handles, no border, no data preview, no move grip. + height: 8, + bottom: 10, + showDataShadow: false, + brushSelect: false, + borderColor: 'transparent', + backgroundColor: theme.gridColor, + fillerColor: theme.palette[0], + handleIcon: 'circle', + handleSize: 14, + handleStyle: { + color: theme.palette[0], + borderColor: theme.surface, + borderWidth: 1, + }, + moveHandleSize: 0, + }, + ], + yAxis, + } + + if (payload.xType === 'category') { + const { labels, rows } = toCategoryData(payload.series) + return { + ...common, + tooltip: { trigger: 'axis' as const }, + xAxis: { + type: 'category' as const, + data: labels, + axisLabel: { color: theme.textColor }, + }, + series: payload.series.map((s, i) => ({ + ...seriesBase(s, i, theme), + data: rows[i], + })), + } + } + + const isTime = payload.xType === 'time' + const series = payload.series.map((s, i) => { + const points = isTime ? toTimePoints(s.points) : toLinearPoints(s.points) + return { + ...seriesBase(s, i, theme), + data: points.map((p) => [p.x, p.y]), + } + }) + const xs = series.flatMap((s) => s.data.map((d) => d[0] as number)) + const tickFormat = + isTime && xs.length > 0 + ? makeTimeTickFormat(Math.min(...xs), Math.max(...xs)) + : null + + return { + ...common, + // Drag labels on the slider show real dates, not epoch ms. + dataZoom: common.dataZoom.map((z) => + z.type === 'slider' && tickFormat + ? { ...z, labelFormatter: (v: number) => tickFormat(v) } + : z, + ), + tooltip: { + trigger: 'axis' as const, + axisPointer: { type: 'cross' as const, label: { show: false } }, + ...(isTime + ? { + formatter: ( + params: + | Array<{ + marker: string + seriesName: string + value: [number, number | null] + }> + | { + marker: string + seriesName: string + value: [number, number | null] + }, + ) => { + const list = Array.isArray(params) ? params : [params] + if (list.length === 0) return '' + const rows = list.map( + (p) => + `${p.marker}${p.seriesName}: ${p.value[1] ?? '—'}`, + ) + return [formatTooltipTime(list[0].value[0]), ...rows].join( + '
', + ) + }, + } + : {}), + }, + xAxis: { + type: 'value' as const, + min: 'dataMin' as const, + max: 'dataMax' as const, + axisLabel: { + color: theme.textColor, + hideOverlap: true, + ...(tickFormat + ? { formatter: (v: number) => tickFormat(v) } + : {}), + }, + splitLine: { show: false }, + }, + series, + } +} diff --git a/src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx b/src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx new file mode 100644 index 000000000..1ffac6a06 --- /dev/null +++ b/src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx @@ -0,0 +1,174 @@ +import React, { useEffect, useRef } from 'react' +import * as echarts from 'echarts' +import type { ChartSeriesPayload } from '../../../_shared/types/chartSeries' +import type { ChartCard, ChartTheme } from '../types' +import { buildChartOption } from '../chartData' + +export interface SeriesChartPanelProps { + cards: ChartCard[] +} + +/** Presentational panel: one card per chartId; placeholder when idle. */ +export function SeriesChartPanel({ cards }: SeriesChartPanelProps) { + return ( +
+ {cards.length === 0 && ( +

+ Select something on the map to chart it here. +

+ )} + {cards.map(({ chartId, state }) => ( +
+ {state.status === 'loading' && ( + <> + +
+
+ + )} + {state.status === 'error' && ( + <> + +

+ {state.message} +

+ + )} + {state.status === 'ready' && } +
+ ))} +
+ ) +} + +function CardHeader({ + title, + onResetZoom, +}: { + title: string + onResetZoom?: () => void +}) { + return ( +
+
+

{title}

+ {onResetZoom && ( + + )} +
+
+ ) +} + +function ReadyCard({ payload }: { payload: ChartSeriesPayload }) { + const chartRef = useRef(null) + return ( + <> + + chartRef.current?.dispatchAction({ + type: 'dataZoom', + start: 0, + end: 100, + }) + } + /> + + + ) +} + +/** Theme colors come from the page's --theme-* custom properties so the chart + * follows the active USWDS theme bundle; fallbacks are the USWDS defaults. */ +function themeFromCss(el: HTMLElement): ChartTheme { + const styles = getComputedStyle(el) + const v = (name: string, fallback: string) => + styles.getPropertyValue(name).trim() || fallback + return { + palette: [ + v('--theme-color-primary', '#005ea2'), + v('--theme-color-accent-cool', '#00bde3'), + v('--theme-color-accent-warm', '#fa9441'), + v('--theme-color-secondary', '#d83933'), + ], + gridColor: v('--theme-color-base-lighter', '#dfe1e2'), + textColor: v('--theme-color-base-dark', '#565c65'), + surface: v('--theme-color-white', '#ffffff'), + } +} + +function SeriesCanvas({ + payload, + chartRef, +}: { + payload: ChartSeriesPayload + chartRef?: React.MutableRefObject +}) { + const hostRef = useRef(null) + + useEffect(() => { + const host = hostRef.current + if (!host) return + const chart = echarts.init(host) + if (chartRef) chartRef.current = chart + const theme = themeFromCss(host) + let visible = payload.series[0]?.label + chart.setOption(buildChartOption(payload, theme, visible) as never) + + // Single-select legend is the variable picker; rebuild so the y-axis + // renames to the picked variable's unit, keeping the zoom range. + // Re-clicking the active chip would empty the chart — keep it on. + chart.on('legendselectchanged', (e) => { + const event = e as { + name: string + selected: Record + } + visible = event.selected[event.name] ? event.name : visible + const zoom = ( + chart.getOption() as { dataZoom?: Array<{ start?: number; end?: number }> } + ).dataZoom?.[0] + const option = buildChartOption(payload, theme, visible) + if (zoom && Array.isArray(option.dataZoom)) { + option.dataZoom = option.dataZoom.map((z: Record) => ({ + ...z, + start: zoom.start, + end: zoom.end, + })) + } + chart.setOption(option as never, { notMerge: true }) + }) + + const observer = new ResizeObserver(() => chart.resize()) + observer.observe(host) + return () => { + observer.disconnect() + if (chartRef?.current === chart) chartRef.current = null + chart.dispose() + } + }, [payload, chartRef]) + + return
+} + +export default SeriesChartPanel diff --git a/src/essence/Tools/SeriesChart/lib/index.ts b/src/essence/Tools/SeriesChart/lib/index.ts new file mode 100644 index 000000000..c11b3362e --- /dev/null +++ b/src/essence/Tools/SeriesChart/lib/index.ts @@ -0,0 +1,5 @@ +export { SeriesChartPanel } from './components/SeriesChartPanel' +export type { CardState, ChartCard, ChartTheme } from './types' + +// Side-effect import of compiled styles +import './styles/index.scss' diff --git a/src/essence/Tools/SeriesChart/lib/styles/components/index.scss b/src/essence/Tools/SeriesChart/lib/styles/components/index.scss new file mode 100644 index 000000000..af2931eb5 --- /dev/null +++ b/src/essence/Tools/SeriesChart/lib/styles/components/index.scss @@ -0,0 +1 @@ +@use 'series-chart'; diff --git a/src/essence/Tools/SeriesChart/lib/styles/components/series-chart.scss b/src/essence/Tools/SeriesChart/lib/styles/components/series-chart.scss new file mode 100644 index 000000000..6ae5b557e --- /dev/null +++ b/src/essence/Tools/SeriesChart/lib/styles/components/series-chart.scss @@ -0,0 +1,99 @@ +.series-chart { + display: flex; + flex-direction: column; + gap: var(--theme-spacing-2, 1rem); + padding: var(--theme-spacing-2, 1rem); + height: 100%; + overflow-y: auto; + box-sizing: border-box; +} + +.series-chart__placeholder { + margin: 0; + padding: var(--theme-spacing-2, 1rem); + color: var(--theme-color-base-dark, #565c65); + font-size: var(--theme-font-size-sm, 0.875rem); + text-align: center; +} + +.series-chart__card { + background: var(--theme-color-base-lightest, #f2f5f7); + border: 1px solid var(--theme-color-base-lighter, #dfe1e2); + border-radius: var(--theme-border-radius-md, 4px); + padding: var(--theme-spacing-2, 1rem); +} + +.series-chart__card-header { + display: flex; + flex-direction: column; + gap: var(--theme-spacing-05, 0.25rem); + margin-bottom: var(--theme-spacing-1, 0.5rem); +} + +.series-chart__card-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--theme-spacing-1, 0.5rem); +} + +.series-chart__reset-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + flex-shrink: 0; + padding: 0; + border: none; + border-radius: var(--theme-border-radius-md, 4px); + background: transparent; + color: var(--theme-color-primary, #005ea2); + cursor: pointer; + transition: background 0.12s ease; + + &:hover { + background: var(--theme-color-base-lighter, #dfe1e2); + } +} + +.series-chart__title { + margin: 0; + font-size: var(--theme-font-size-md, 1rem); + color: var(--theme-color-ink, #1b1b1b); +} + + +.series-chart__status { + display: flex; + align-items: center; + gap: var(--theme-spacing-1, 0.5rem); + color: var(--theme-color-base-dark, #565c65); + font-size: var(--theme-font-size-sm, 0.875rem); +} + +.series-chart__spinner { + width: 1em; + height: 1em; + border: 2px solid var(--theme-color-base-lighter, #dfe1e2); + border-top-color: var(--theme-color-primary, #005ea2); + border-radius: 50%; + animation: series-chart-spin 0.9s linear infinite; +} + +@keyframes series-chart-spin { + to { + transform: rotate(360deg); + } +} + +.series-chart__error { + margin: 0; + color: var(--theme-color-error, #b50909); + font-size: var(--theme-font-size-sm, 0.875rem); +} + +.series-chart__canvas-wrap { + position: relative; + height: 280px; +} diff --git a/src/essence/Tools/SeriesChart/lib/styles/index.scss b/src/essence/Tools/SeriesChart/lib/styles/index.scss new file mode 100644 index 000000000..ec60ccfdd --- /dev/null +++ b/src/essence/Tools/SeriesChart/lib/styles/index.scss @@ -0,0 +1,4 @@ +// Theming (USWDS framework + theme tokens + :root --theme-* custom properties) +// is provided by MMGIS's per-theme bundles at dist/.css, loaded at +// runtime. Component partials reference --theme-* directly. +@forward 'components'; diff --git a/src/essence/Tools/SeriesChart/lib/types.ts b/src/essence/Tools/SeriesChart/lib/types.ts new file mode 100644 index 000000000..de4c9fc2e --- /dev/null +++ b/src/essence/Tools/SeriesChart/lib/types.ts @@ -0,0 +1,24 @@ +// Presentation-side state for the SeriesChart panel. Framework-agnostic — the +// only import is the shared series contract (types only). + +import type { ChartSeriesPayload } from '../../_shared/types/chartSeries' + +export type CardState = + | { status: 'loading'; title?: string } + | { status: 'ready'; payload: ChartSeriesPayload } + | { status: 'error'; title?: string; message: string } + +export interface ChartCard { + chartId: string + state: CardState +} + +/** Colors resolved from the page theme (CSS custom properties). */ +export interface ChartTheme { + /** Series colors, cycled when a series declares no color of its own. */ + palette: string[] + gridColor: string + textColor: string + /** Card/panel surface color (--theme-color-white). */ + surface: string +} diff --git a/src/essence/Tools/_shared/types/chartSeries.ts b/src/essence/Tools/_shared/types/chartSeries.ts new file mode 100644 index 000000000..19b42bf9b --- /dev/null +++ b/src/essence/Tools/_shared/types/chartSeries.ts @@ -0,0 +1,132 @@ +// The generic chart-series contract between fetcher plugins (FetchTimeseries, +// future raster/forecast fetchers) and the SeriesChart plugin. Fetchers map +// their responses into ChartSeriesPayload; the chart renders it and knows +// nothing about where the data came from. Types only + pure helpers — no +// MMGIS, no DOM. + +/** One data point. `x` is an ISO datetime string when xType is 'time'; + * `y: null` marks a gap the chart must not interpolate across. */ +export interface ChartPoint { + x: string | number + y: number | null +} + +export interface ChartSeries { + id: string + label: string + points: ChartPoint[] + style?: 'line' | 'area' | 'bar' + /** CSS color; omitted → chart theme palette. */ + color?: string + /** Measurement unit (e.g. "Parts per million"). When a payload carries + * exactly two distinct units, the chart puts the second on a right-hand + * y-axis so mixed-magnitude series stay readable. */ + unit?: string +} + +export interface ChartSeriesMeta { + /** Plugin id of the emitter, e.g. 'fetch-timeseries'. */ + sourcePlugin?: string + layerName?: string + featureId?: string | number +} + +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 + subtitle?: string + xType: 'time' | 'linear' | 'category' + yLabel?: string + series: ChartSeries[] + meta?: ChartSeriesMeta +} + +export interface SeriesLoadingPayload { + chartId: string + /** Optional label shown while loading (e.g. the clicked feature's name). */ + title?: string +} + +export interface SeriesErrorPayload { + chartId: string + /** Human-readable; rendered verbatim in the chart card. */ + message: string +} + +export interface SeriesClearedPayload { + chartId: string +} + +/** Event-name suffixes, exported for docs/tests. */ +export const SERIES_EVENT_SUFFIXES = { + loading: 'seriesLoading', + ready: 'seriesReady', + error: 'seriesError', + cleared: 'seriesCleared', +} as const + +export interface SeriesEventNames { + loading: string + ready: string + error: string + cleared: string +} + +/** + * Full bus event names for a fetcher plugin id, following the established + * `plugin::` convention (see FetchStats). The chart's `sources` + * config lists plugin ids; this maps each id to the events to subscribe to. + */ +export function seriesEvents(pluginId: string): SeriesEventNames { + const prefix = `plugin:${pluginId}:` + return { + loading: prefix + SERIES_EVENT_SUFFIXES.loading, + ready: prefix + SERIES_EVENT_SUFFIXES.ready, + error: prefix + SERIES_EVENT_SUFFIXES.error, + cleared: prefix + SERIES_EVENT_SUFFIXES.cleared, + } +} + +function isRecord(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value) +} + +function isChartPoint(value: unknown): value is ChartPoint { + if (!isRecord(value)) return false + const xOk = typeof value.x === 'string' || typeof value.x === 'number' + const yOk = value.y === null || typeof value.y === 'number' + return xOk && yOk +} + +function isChartSeries(value: unknown): value is ChartSeries { + if (!isRecord(value)) return false + return ( + typeof value.id === 'string' && + value.id !== '' && + typeof value.label === 'string' && + Array.isArray(value.points) && + value.points.every(isChartPoint) + ) +} + +/** + * Guard the chart runs on every incoming `seriesReady` payload — emitters are + * other plugins, so malformed input must degrade to a console warning, never + * a crashed panel. + */ +export function isChartSeriesPayload(value: unknown): value is ChartSeriesPayload { + if (!isRecord(value)) return false + return ( + typeof value.chartId === 'string' && + value.chartId !== '' && + typeof value.title === 'string' && + (value.xType === 'time' || + value.xType === 'linear' || + value.xType === 'category') && + Array.isArray(value.series) && + value.series.length > 0 && + value.series.every(isChartSeries) + ) +} diff --git a/tests/unit/chartSeries.spec.js b/tests/unit/chartSeries.spec.js new file mode 100644 index 000000000..93d409ef9 --- /dev/null +++ b/tests/unit/chartSeries.spec.js @@ -0,0 +1,91 @@ +import { describe, test, expect } from 'vitest' +import { + seriesEvents, + isChartSeriesPayload, +} from '../../src/essence/Tools/_shared/types/chartSeries.ts' + +const validPayload = { + chartId: 'vector-timeseries', + title: 'Station 42', + xType: 'time', + series: [ + { + id: 'no2', + label: 'NO₂', + points: [ + { x: '2026-01-01T00:00:00Z', y: 1.5 }, + { x: '2026-01-02T00:00:00Z', y: null }, + { x: '2026-01-03T00:00:00Z', y: 2 }, + ], + }, + ], +} + +describe('chartSeries contract', () => { + describe('seriesEvents', () => { + test('builds the four plugin-prefixed event names', () => { + expect(seriesEvents('fetch-timeseries')).toEqual({ + loading: 'plugin:fetch-timeseries:seriesLoading', + ready: 'plugin:fetch-timeseries:seriesReady', + error: 'plugin:fetch-timeseries:seriesError', + cleared: 'plugin:fetch-timeseries:seriesCleared', + }) + }) + }) + + describe('isChartSeriesPayload', () => { + test('accepts a valid single-series payload with null gaps', () => { + expect(isChartSeriesPayload(validPayload)).toBe(true) + }) + + test('accepts multi-series payloads (raster/forecast cases)', () => { + const multi = { + ...validPayload, + series: [ + validPayload.series[0], + { + id: 'forecast', + label: 'Forecast', + style: 'line', + color: '#888', + points: [{ x: 1, y: 2 }], + }, + ], + } + expect(isChartSeriesPayload(multi)).toBe(true) + }) + + test.each([ + ['null', null], + ['non-object', 'nope'], + ['missing chartId', { ...validPayload, chartId: undefined }], + ['empty chartId', { ...validPayload, chartId: '' }], + ['missing title', { ...validPayload, title: undefined }], + ['bad xType', { ...validPayload, xType: 'datetime' }], + ['empty series', { ...validPayload, series: [] }], + ['series not array', { ...validPayload, series: {} }], + [ + 'series missing id', + { ...validPayload, series: [{ label: 'x', points: [] }] }, + ], + [ + 'point with undefined y', + { + ...validPayload, + series: [{ id: 'a', label: 'a', points: [{ x: 1 }] }], + }, + ], + [ + 'point with string y', + { + ...validPayload, + series: [ + { id: 'a', label: 'a', points: [{ x: 1, y: '2' }] }, + ], + }, + ], + ])('rejects %s', (_name, payload) => { + expect(isChartSeriesPayload(payload)).toBe(false) + }) + }) +}) diff --git a/tests/unit/seriesChartData.spec.js b/tests/unit/seriesChartData.spec.js new file mode 100644 index 000000000..40d27d22e --- /dev/null +++ b/tests/unit/seriesChartData.spec.js @@ -0,0 +1,271 @@ +import { describe, test, expect } from 'vitest' +import { + toTimePoints, + toLinearPoints, + toCategoryData, + makeTimeTickFormat, + formatTooltipTime, + buildChartOption, +} from '../../src/essence/Tools/SeriesChart/lib/chartData.ts' + +const THEME = { + palette: ['#111111', '#222222'], + gridColor: '#dddddd', + textColor: '#555555', + surface: '#eeeeee', +} + +const DAY = 24 * 60 * 60 * 1000 + +function payloadWith(series, xType = 'time') { + return { chartId: 'c1', title: 'T', xType, series } +} + +describe('seriesChart chartData', () => { + describe('toTimePoints', () => { + test('parses ISO datetimes to epoch ms and keeps null gaps', () => { + const pts = toTimePoints([ + { x: '2026-01-02T00:00:00Z', y: 2 }, + { x: '2026-01-01T00:00:00Z', y: null }, + ]) + expect(pts).toEqual([ + { x: Date.parse('2026-01-01T00:00:00Z'), y: null }, + { x: Date.parse('2026-01-02T00:00:00Z'), y: 2 }, + ]) + }) + + test('drops unparseable x values instead of sinking the series', () => { + const pts = toTimePoints([ + { x: 'garbage', y: 1 }, + { x: '2026-01-01T00:00:00Z', y: 3 }, + ]) + expect(pts).toHaveLength(1) + expect(pts[0].y).toBe(3) + }) + + test('passes numeric x through as ms', () => { + expect(toTimePoints([{ x: 1000, y: 1 }])).toEqual([{ x: 1000, y: 1 }]) + }) + + test('reads timezone-less ISO datetimes as UTC, not viewer-local', () => { + expect(toTimePoints([{ x: '2017-12-31T00:00:00', y: 1 }])).toEqual([ + { x: Date.parse('2017-12-31T00:00:00Z'), y: 1 }, + ]) + }) + }) + + describe('toLinearPoints', () => { + test('coerces numeric strings and drops NaN', () => { + expect( + toLinearPoints([ + { x: '2', y: 1 }, + { x: 'nope', y: 5 }, + { x: 1, y: 0 }, + ]), + ).toEqual([ + { x: 1, y: 0 }, + { x: 2, y: 1 }, + ]) + }) + }) + + describe('toCategoryData', () => { + test('unions labels in first-appearance order and aligns rows', () => { + const { labels, rows } = toCategoryData([ + { + id: 'a', + label: 'A', + points: [ + { x: 'jan', y: 1 }, + { x: 'feb', y: 2 }, + ], + }, + { + id: 'b', + label: 'B', + points: [ + { x: 'feb', y: 20 }, + { x: 'mar', y: 30 }, + ], + }, + ]) + expect(labels).toEqual(['jan', 'feb', 'mar']) + expect(rows).toEqual([ + [1, 2, null], + [null, 20, 30], + ]) + }) + }) + + describe('makeTimeTickFormat', () => { + const t0 = Date.parse('2026-03-04T14:30:00Z') + test('uses hours+minutes within a two-day span', () => { + expect(makeTimeTickFormat(t0, t0 + DAY)(t0)).toBe('14:30') + }) + test('uses month+day within ~a year', () => { + expect(makeTimeTickFormat(t0, t0 + 100 * DAY)(t0)).toBe('Mar 4') + }) + test('uses month+year for multi-year spans', () => { + expect(makeTimeTickFormat(t0, t0 + 800 * DAY)(t0)).toBe('Mar 2026') + }) + }) + + describe('formatTooltipTime', () => { + test('formats a full UTC datetime', () => { + expect(formatTooltipTime(Date.parse('2026-03-04T14:30:00Z'))).toBe( + 'Mar 4, 2026, 14:30', + ) + }) + }) + + describe('buildChartOption', () => { + const series = (over = {}) => ({ + id: 's1', + label: 'S1', + points: [ + { x: '2026-01-01T00:00:00Z', y: 1 }, + { x: '2026-01-02T00:00:00Z', y: 2 }, + ], + ...over, + }) + + test('time axis: value scale over epoch ms with a UTC tick formatter', () => { + const opt = buildChartOption(payloadWith([series()]), THEME) + expect(opt.xAxis.type).toBe('value') + expect(typeof opt.xAxis.axisLabel.formatter).toBe('function') + expect(opt.series[0].data[0]).toEqual([ + Date.parse('2026-01-01T00:00:00Z'), + 1, + ]) + }) + + test('legend and zoom are always available', () => { + const opt = buildChartOption(payloadWith([series()]), THEME) + expect(opt.legend.show).toBe(true) + expect(opt.dataZoom.map((z) => z.type)).toEqual(['inside', 'slider']) + }) + + test('the zoom scrubber is a slim primary-filled range slider', () => { + const opt = buildChartOption(payloadWith([series()]), THEME) + const slider = opt.dataZoom.find((z) => z.type === 'slider') + expect(slider.fillerColor).toBe('#111111') + expect(slider.handleStyle.color).toBe('#111111') + expect(slider.handleStyle.borderColor).toBe('#eeeeee') + expect(slider.backgroundColor).toBe('#dddddd') + expect(slider.handleIcon).toBe('circle') + expect(slider.showDataShadow).toBe(false) + expect(slider.moveHandleSize).toBe(0) + }) + + test('cycles the theme palette and honors explicit series color', () => { + const opt = buildChartOption( + payloadWith([ + series(), + series({ id: 's2', label: 'S2' }), + series({ id: 's3', label: 'S3', color: '#abcdef' }), + ]), + THEME, + ) + const colors = opt.series.map((s) => s.itemStyle.color) + expect(colors).toEqual(['#111111', '#222222', '#abcdef']) + }) + + test('series style maps to type and area fill', () => { + const opt = buildChartOption( + payloadWith([ + series({ style: 'bar' }), + series({ id: 's2', label: 'S2', style: 'area' }), + ]), + THEME, + ) + expect(opt.series[0].type).toBe('bar') + expect(opt.series[1].type).toBe('line') + expect(opt.series[1].areaStyle).toBeDefined() + }) + + test('gaps are not connected', () => { + const opt = buildChartOption(payloadWith([series()]), THEME) + expect(opt.series[0].connectNulls).toBe(false) + }) + + test('time tooltip titles with UTC datetime, not raw epoch', () => { + const opt = buildChartOption(payloadWith([series()]), THEME) + const html = opt.tooltip.formatter([ + { + marker: '·', + seriesName: 'S1', + value: [Date.parse('2026-01-01T00:00:00Z'), 1], + }, + ]) + expect(html).toContain('Jan 1, 2026') + expect(html).toContain('S1: 1') + }) + + test('category axis uses aligned labels', () => { + const opt = buildChartOption( + payloadWith( + [series({ points: [{ x: 'jan', y: 1 }] })], + 'category', + ), + THEME, + ) + expect(opt.xAxis.type).toBe('category') + expect(opt.xAxis.data).toEqual(['jan']) + expect(opt.series[0].data).toEqual([1]) + }) + + test('only the first variable starts visible; legend is single-select', () => { + const opt = buildChartOption( + payloadWith([ + series({ unit: 'µg/m³' }), + series({ id: 's2', label: 'S2', unit: 'ppm' }), + series({ id: 's3', label: 'S3', unit: 'ppm' }), + ]), + THEME, + ) + expect(opt.legend.selectedMode).toBe('single') + expect(opt.legend.selected).toEqual({ S1: true, S2: false, S3: false }) + expect(opt.yAxis).toHaveLength(1) + expect(opt.yAxis[0].name).toBe('µg/m³') + }) + + test('the y-axis renames to the picked variable unit', () => { + const opt = buildChartOption( + payloadWith([ + series({ unit: 'µg/m³' }), + series({ id: 's2', label: 'S2', unit: 'Knots' }), + ]), + THEME, + 'S2', + ) + expect(opt.legend.selected).toEqual({ S1: false, S2: true }) + expect(opt.yAxis[0].name).toBe('Knots') + }) + + test('no in-canvas toolbox: reset zoom lives in the card header', () => { + const opt = buildChartOption(payloadWith([series()]), THEME) + expect(opt.toolbox).toBeUndefined() + }) + + test('a single unit becomes the y-axis name', () => { + const one = buildChartOption( + payloadWith([series({ unit: 'ppm' })]), + THEME, + ) + expect(one.yAxis[0].name).toBe('ppm') + expect(one.yAxis).toHaveLength(1) + expect(one.legend.selected).toEqual({ S1: true }) + }) + + test('yLabel wins as the y-axis name', () => { + const withLabel = buildChartOption( + { ...payloadWith([series()]), yLabel: 'NO₂' }, + THEME, + ) + const without = buildChartOption(payloadWith([series()]), THEME) + expect(withLabel.yAxis[0].name).toBe('NO₂') + expect(without.yAxis[0].name).toBeUndefined() + }) + }) + +}) From cbf0fcbe69fd175140bf21dae9b75fb3987197e4 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Mon, 10 Aug 2026 14:40:27 -0500 Subject: [PATCH 02/10] Guard against double make without destroy --- src/essence/Tools/SeriesChart/SeriesChartTool.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/essence/Tools/SeriesChart/SeriesChartTool.tsx b/src/essence/Tools/SeriesChart/SeriesChartTool.tsx index 4c39f2725..42df5bd43 100644 --- a/src/essence/Tools/SeriesChart/SeriesChartTool.tsx +++ b/src/essence/Tools/SeriesChart/SeriesChartTool.tsx @@ -17,6 +17,10 @@ const SeriesChartTool = { console.error(`SeriesChartTool: container ${this.targetId} not found`) return } + if (_root) { + _root.unmount() + _root = null + } _root = createRoot(container) _root.render() this.made = true From 23bf1438ce1c120152e4470a2ca76e8c986fdd88 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Mon, 10 Aug 2026 14:49:04 -0500 Subject: [PATCH 03/10] Regenerate mission configs for the SeriesChart tool --- .../generated/full-demo-mission.json | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/mission-profiles/generated/full-demo-mission.json b/mission-profiles/generated/full-demo-mission.json index 3a0536210..c52548740 100644 --- a/mission-profiles/generated/full-demo-mission.json +++ b/mission-profiles/generated/full-demo-mission.json @@ -273,6 +273,29 @@ "on": true, "variables": {} }, + { + "name": "SeriesChart", + "icon": "chart-line", + "js": "SeriesChartTool", + "on": false, + "variables": { + "sources": [ + "fetch-timeseries" + ] + }, + "metadata": { + "icon": "chart-line", + "requiredOrientation": "vertical", + "compatiblePositions": [ + "left", + "right" + ], + "preferredPosition": "right", + "modernLayoutSupport": true, + "width": 400, + "height": 0 + } + }, { "name": "FetchStats", "js": "FetchStatsTool", From 2e8718ff56c9002b12eb542c96378bc89cfd4c91 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Tue, 11 Aug 2026 11:20:23 -0500 Subject: [PATCH 04/10] SeriesChart: stacked per-variable layout behind a config toggle --- .../SeriesChart/MMGISSeriesChartAdapter.tsx | 15 +- src/essence/Tools/SeriesChart/README.md | 6 + src/essence/Tools/SeriesChart/config.json | 13 +- .../Tools/SeriesChart/lib/chartData.ts | 223 ++++++++++++++---- .../lib/components/SeriesChartPanel.tsx | 100 +++++--- src/essence/Tools/SeriesChart/lib/types.ts | 4 + tests/unit/seriesChartData.spec.js | 111 +++++++++ 7 files changed, 384 insertions(+), 88 deletions(-) diff --git a/src/essence/Tools/SeriesChart/MMGISSeriesChartAdapter.tsx b/src/essence/Tools/SeriesChart/MMGISSeriesChartAdapter.tsx index 3b054f523..32f97d299 100644 --- a/src/essence/Tools/SeriesChart/MMGISSeriesChartAdapter.tsx +++ b/src/essence/Tools/SeriesChart/MMGISSeriesChartAdapter.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react' import { SeriesChartPanel } from './lib' -import type { CardState } from './lib/types' +import type { CardState, ChartLayout } from './lib/types' import { mmgisOn, mmgisRequest } from '../_shared/adapters/mmgisAPI' import { useMMGISHandlerReady } from '../_shared/adapters/useMMGISHandlerReady' import { @@ -30,20 +30,23 @@ function chartIdOf(payload: unknown): string | null { */ export function MMGISSeriesChartAdapter() { const [sources, setSources] = useState(DEFAULT_SOURCES) + const [layout, setLayout] = useState('single') const [cards, setCards] = useState>({}) const refresh = useCallback(async () => { try { - const vars = await mmgisRequest<{ sources?: unknown }>( - 'tool:getVars', - PLUGIN_ID, - ) + const vars = await mmgisRequest<{ + sources?: unknown + layout?: unknown + }>('tool:getVars', PLUGIN_ID) const list = Array.isArray(vars?.sources) ? (vars?.sources ?? []).filter( (s): s is string => typeof s === 'string' && s !== '', ) : [] if (list.length > 0) setSources(list) + if (vars?.layout === 'single' || vars?.layout === 'stacked') + setLayout(vars.layout) } catch (err) { console.warn('[SeriesChart] tool:getVars unavailable:', err) } @@ -120,7 +123,7 @@ export function MMGISSeriesChartAdapter() { chartId, state, })) - return + return } function titleOf(state: CardState | undefined): string | undefined { diff --git a/src/essence/Tools/SeriesChart/README.md b/src/essence/Tools/SeriesChart/README.md index b1a0cd999..00e932752 100644 --- a/src/essence/Tools/SeriesChart/README.md +++ b/src/essence/Tools/SeriesChart/README.md @@ -37,6 +37,12 @@ config entry, not a code change: { "sources": ["fetch-timeseries", "fetch-raster-timeseries"] } ``` +`variables.layout` — `"single"` (default) or `"stacked"`. Single renders all +of a card's variables in one chart with a single-select legend as the +variable picker. Stacked renders one chart row per variable — each with its +own unit-named y-axis — with hover and zoom linked across rows, so mixed-unit +variables can be compared over the same x-range. + ## Smoke test (devtools console) ```js diff --git a/src/essence/Tools/SeriesChart/config.json b/src/essence/Tools/SeriesChart/config.json index 66936e03c..2a4120aa1 100644 --- a/src/essence/Tools/SeriesChart/config.json +++ b/src/essence/Tools/SeriesChart/config.json @@ -1,7 +1,8 @@ { "defaults": { "variables": { - "sources": ["fetch-timeseries"] + "sources": ["fetch-timeseries"], + "layout": "single" } }, "defaultIcon": "chart-line", @@ -38,8 +39,16 @@ "name": "Source plugins (JSON array)", "description": "Plugin ids whose series events this chart renders, e.g. [\"fetch-timeseries\"]. Adding a new fetcher plugin here wires it to this chart with no code change.", "type": "json", - "width": 12, + "width": 8, "height": "120px" + }, + { + "field": "variables.layout", + "name": "Chart layout", + "description": "single: all variables share one chart and the legend picks which is visible. stacked: one chart row per variable, with hover and zoom linked across rows.", + "type": "dropdown", + "width": 4, + "options": ["single", "stacked"] } ] } diff --git a/src/essence/Tools/SeriesChart/lib/chartData.ts b/src/essence/Tools/SeriesChart/lib/chartData.ts index bcca9e1d2..de6cf4e85 100644 --- a/src/essence/Tools/SeriesChart/lib/chartData.ts +++ b/src/essence/Tools/SeriesChart/lib/chartData.ts @@ -117,6 +117,16 @@ export function formatTooltipTime(ms: number): string { return TOOLTIP_FMT.format(new Date(ms)) } +/** Vertical space one stacked row occupies (title band + plot). */ +const STACKED_ROW = 150 +/** Below the last row: its x-axis labels plus the shared zoom slider. */ +const STACKED_EXTRA = 48 + +/** Canvas height for a stacked option of `n` variables. */ +export function stackedChartHeight(n: number): number { + return Math.max(n, 1) * STACKED_ROW + STACKED_EXTRA +} + function seriesBase(s: ChartSeries, i: number, theme: ChartTheme) { const color = s.color || theme.palette[i % theme.palette.length] return { @@ -130,6 +140,46 @@ function seriesBase(s: ChartSeries, i: number, theme: ChartTheme) { } } +function sliderZoom(theme: ChartTheme, xAxisIndex: number | number[]) { + return { + type: 'slider' as const, + xAxisIndex, + // Slim range-input look: grey track, solid primary fill, + // round handles, no border, no data preview, no move grip. + height: 8, + bottom: 10, + showDataShadow: false, + brushSelect: false, + borderColor: 'transparent', + backgroundColor: theme.gridColor, + fillerColor: theme.palette[0], + handleIcon: 'circle', + handleSize: 14, + handleStyle: { + color: theme.palette[0], + borderColor: theme.surface, + borderWidth: 1, + }, + moveHandleSize: 0, + } +} + +type TooltipParam = { + marker: string + seriesName: string + value: [number, number | null] +} + +/** Axis tooltip whose title is the hovered UTC datetime, not raw epoch ms. */ +function timeTooltipFormatter(params: TooltipParam[] | TooltipParam): string { + const list = Array.isArray(params) ? params : [params] + if (list.length === 0) return '' + const rows = list.map( + (p) => `${p.marker}${p.seriesName}: ${p.value[1] ?? '—'}`, + ) + return [formatTooltipTime(list[0].value[0]), ...rows].join('
') +} + /** * The complete ECharts option for a payload. Typed loosely on purpose: * echarts' own option generics add nothing here and the object is validated @@ -178,27 +228,7 @@ export function buildChartOption( grid: { left: 56, right: 16, top: 32, bottom: 44 }, dataZoom: [ { type: 'inside' as const, xAxisIndex: 0 }, - { - type: 'slider' as const, - xAxisIndex: 0, - // Slim range-input look: grey track, solid primary fill, - // round handles, no border, no data preview, no move grip. - height: 8, - bottom: 10, - showDataShadow: false, - brushSelect: false, - borderColor: 'transparent', - backgroundColor: theme.gridColor, - fillerColor: theme.palette[0], - handleIcon: 'circle', - handleSize: 14, - handleStyle: { - color: theme.palette[0], - borderColor: theme.surface, - borderWidth: 1, - }, - moveHandleSize: 0, - }, + sliderZoom(theme, 0), ], yAxis, } @@ -245,33 +275,7 @@ export function buildChartOption( tooltip: { trigger: 'axis' as const, axisPointer: { type: 'cross' as const, label: { show: false } }, - ...(isTime - ? { - formatter: ( - params: - | Array<{ - marker: string - seriesName: string - value: [number, number | null] - }> - | { - marker: string - seriesName: string - value: [number, number | null] - }, - ) => { - const list = Array.isArray(params) ? params : [params] - if (list.length === 0) return '' - const rows = list.map( - (p) => - `${p.marker}${p.seriesName}: ${p.value[1] ?? '—'}`, - ) - return [formatTooltipTime(list[0].value[0]), ...rows].join( - '
', - ) - }, - } - : {}), + ...(isTime ? { formatter: timeTooltipFormatter } : {}), }, xAxis: { type: 'value' as const, @@ -289,3 +293,126 @@ export function buildChartOption( series, } } + +/** + * Stacked small-multiples option: one grid row per variable with its own + * unit-named y-axis. The x-axes are linked (`axisPointer.link`) and one + * zoom pair drives them all, so hovering or zooming any row moves every + * row together. No legend — each variable is always visible in its row, + * titled by the series label. The canvas must be `stackedChartHeight(n)` + * tall for the pixel-positioned grids to land inside it. + */ +export function buildStackedChartOption( + payload: ChartSeriesPayload, + theme: ChartTheme, +): Record { + const n = payload.series.length + const allX = payload.series.map((_, i) => i) + const isTime = payload.xType === 'time' + const isCategory = payload.xType === 'category' + + const category = isCategory ? toCategoryData(payload.series) : null + const data: Array | Array> = + category + ? category.rows + : payload.series.map((s) => + (isTime ? toTimePoints(s.points) : toLinearPoints(s.points)).map( + (p) => [p.x, p.y] as [number, number | null], + ), + ) + const xs = category + ? [] + : (data as Array>).flatMap((d) => + d.map((p) => p[0]), + ) + const tickFormat = + isTime && xs.length > 0 + ? makeTimeTickFormat(Math.min(...xs), Math.max(...xs)) + : null + + const title: Array> = [] + const grid: Array> = [] + const xAxis: Array> = [] + const yAxis: Array> = [] + const series: Array> = [] + + payload.series.forEach((s, i) => { + const rowTop = i * STACKED_ROW + // Only the bottom row prints x labels — the rows above share its axis + // via the link, and repeating the labels n times just eats plot height. + const isBottom = i === n - 1 + title.push({ + text: s.label, + top: rowTop + 2, + left: 8, + textStyle: { + fontSize: 12, + fontWeight: 600, + color: theme.textColor, + }, + }) + grid.push({ + left: 56, + right: 16, + top: rowTop + 28, + height: STACKED_ROW - 40, + }) + xAxis.push({ + gridIndex: i, + ...(category + ? { type: 'category' as const, data: category.labels } + : { + type: 'value' as const, + min: 'dataMin' as const, + max: 'dataMax' as const, + splitLine: { show: false }, + }), + axisLabel: { + show: isBottom, + color: theme.textColor, + hideOverlap: true, + ...(tickFormat + ? { formatter: (v: number) => tickFormat(v) } + : {}), + }, + }) + yAxis.push({ + gridIndex: i, + type: 'value' as const, + scale: true, + name: s.unit ?? payload.yLabel ?? undefined, + nameLocation: 'middle' as const, + nameGap: 42, + nameTextStyle: { color: theme.textColor }, + axisLabel: { color: theme.textColor }, + splitLine: { lineStyle: { color: theme.gridColor } }, + }) + series.push({ + ...seriesBase(s, i, theme), + xAxisIndex: i, + yAxisIndex: i, + data: data[i], + }) + }) + + const slider = sliderZoom(theme, allX) + return { + axisPointer: { link: [{ xAxisIndex: 'all' }] }, + tooltip: { + trigger: 'axis' as const, + axisPointer: { type: 'cross' as const, label: { show: false } }, + ...(isTime ? { formatter: timeTooltipFormatter } : {}), + }, + title, + grid, + xAxis, + yAxis, + series, + dataZoom: [ + { type: 'inside' as const, xAxisIndex: allX }, + tickFormat + ? { ...slider, labelFormatter: (v: number) => tickFormat(v) } + : slider, + ], + } +} diff --git a/src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx b/src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx index 1ffac6a06..deadf37ea 100644 --- a/src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx +++ b/src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx @@ -1,15 +1,23 @@ import React, { useEffect, useRef } from 'react' import * as echarts from 'echarts' import type { ChartSeriesPayload } from '../../../_shared/types/chartSeries' -import type { ChartCard, ChartTheme } from '../types' -import { buildChartOption } from '../chartData' +import type { ChartCard, ChartLayout, ChartTheme } from '../types' +import { + buildChartOption, + buildStackedChartOption, + stackedChartHeight, +} from '../chartData' export interface SeriesChartPanelProps { cards: ChartCard[] + layout?: ChartLayout } /** Presentational panel: one card per chartId; placeholder when idle. */ -export function SeriesChartPanel({ cards }: SeriesChartPanelProps) { +export function SeriesChartPanel({ + cards, + layout = 'single', +}: SeriesChartPanelProps) { return (
{cards.length === 0 && ( @@ -36,7 +44,9 @@ export function SeriesChartPanel({ cards }: SeriesChartPanelProps) {

)} - {state.status === 'ready' && } + {state.status === 'ready' && ( + + )} ))}
@@ -80,7 +90,13 @@ function CardHeader({ ) } -function ReadyCard({ payload }: { payload: ChartSeriesPayload }) { +function ReadyCard({ + payload, + layout, +}: { + payload: ChartSeriesPayload + layout: ChartLayout +}) { const chartRef = useRef(null) return ( <> @@ -94,7 +110,7 @@ function ReadyCard({ payload }: { payload: ChartSeriesPayload }) { }) } /> - + ) } @@ -120,12 +136,15 @@ function themeFromCss(el: HTMLElement): ChartTheme { function SeriesCanvas({ payload, + layout, chartRef, }: { payload: ChartSeriesPayload + layout: ChartLayout chartRef?: React.MutableRefObject }) { const hostRef = useRef(null) + const stacked = layout === 'stacked' useEffect(() => { const host = hostRef.current @@ -133,31 +152,36 @@ function SeriesCanvas({ const chart = echarts.init(host) if (chartRef) chartRef.current = chart const theme = themeFromCss(host) - let visible = payload.series[0]?.label - chart.setOption(buildChartOption(payload, theme, visible) as never) - // Single-select legend is the variable picker; rebuild so the y-axis - // renames to the picked variable's unit, keeping the zoom range. - // Re-clicking the active chip would empty the chart — keep it on. - chart.on('legendselectchanged', (e) => { - const event = e as { - name: string - selected: Record - } - visible = event.selected[event.name] ? event.name : visible - const zoom = ( - chart.getOption() as { dataZoom?: Array<{ start?: number; end?: number }> } - ).dataZoom?.[0] - const option = buildChartOption(payload, theme, visible) - if (zoom && Array.isArray(option.dataZoom)) { - option.dataZoom = option.dataZoom.map((z: Record) => ({ - ...z, - start: zoom.start, - end: zoom.end, - })) - } - chart.setOption(option as never, { notMerge: true }) - }) + if (stacked) { + chart.setOption(buildStackedChartOption(payload, theme) as never) + } else { + let visible = payload.series[0]?.label + chart.setOption(buildChartOption(payload, theme, visible) as never) + + // Single-select legend is the variable picker; rebuild so the y-axis + // renames to the picked variable's unit, keeping the zoom range. + // Re-clicking the active chip would empty the chart — keep it on. + chart.on('legendselectchanged', (e) => { + const event = e as { + name: string + selected: Record + } + visible = event.selected[event.name] ? event.name : visible + const zoom = ( + chart.getOption() as { dataZoom?: Array<{ start?: number; end?: number }> } + ).dataZoom?.[0] + const option = buildChartOption(payload, theme, visible) + if (zoom && Array.isArray(option.dataZoom)) { + option.dataZoom = option.dataZoom.map((z: Record) => ({ + ...z, + start: zoom.start, + end: zoom.end, + })) + } + chart.setOption(option as never, { notMerge: true }) + }) + } const observer = new ResizeObserver(() => chart.resize()) observer.observe(host) @@ -166,9 +190,21 @@ function SeriesCanvas({ if (chartRef?.current === chart) chartRef.current = null chart.dispose() } - }, [payload, chartRef]) + }, [payload, stacked, chartRef]) - return
+ return ( +
+ ) } export default SeriesChartPanel diff --git a/src/essence/Tools/SeriesChart/lib/types.ts b/src/essence/Tools/SeriesChart/lib/types.ts index de4c9fc2e..5ed376257 100644 --- a/src/essence/Tools/SeriesChart/lib/types.ts +++ b/src/essence/Tools/SeriesChart/lib/types.ts @@ -3,6 +3,10 @@ import type { ChartSeriesPayload } from '../../_shared/types/chartSeries' +/** How a card renders its variables: one chart with a legend picker, or + * stacked small-multiple rows (one per variable) with linked x-axes. */ +export type ChartLayout = 'single' | 'stacked' + export type CardState = | { status: 'loading'; title?: string } | { status: 'ready'; payload: ChartSeriesPayload } diff --git a/tests/unit/seriesChartData.spec.js b/tests/unit/seriesChartData.spec.js index 40d27d22e..12dfa9c04 100644 --- a/tests/unit/seriesChartData.spec.js +++ b/tests/unit/seriesChartData.spec.js @@ -6,6 +6,8 @@ import { makeTimeTickFormat, formatTooltipTime, buildChartOption, + buildStackedChartOption, + stackedChartHeight, } from '../../src/essence/Tools/SeriesChart/lib/chartData.ts' const THEME = { @@ -268,4 +270,113 @@ describe('seriesChart chartData', () => { }) }) + describe('buildStackedChartOption', () => { + const series = (over = {}) => ({ + id: over.id ?? 's1', + label: over.label ?? 'S1', + points: [ + { x: '2026-01-01T00:00:00Z', y: 1 }, + { x: '2026-01-02T00:00:00Z', y: 2 }, + ], + ...over, + }) + const three = [ + series({ id: 'a', label: 'A', unit: 'ppm' }), + series({ id: 'b', label: 'B', unit: 'K' }), + series({ id: 'c', label: 'C' }), + ] + + test('one grid, axis pair, row title, and series per variable', () => { + const opt = buildStackedChartOption(payloadWith(three), THEME) + expect(opt.grid).toHaveLength(3) + expect(opt.xAxis).toHaveLength(3) + expect(opt.yAxis).toHaveLength(3) + expect(opt.series).toHaveLength(3) + expect(opt.title.map((t) => t.text)).toEqual(['A', 'B', 'C']) + opt.series.forEach((s, i) => { + expect(s.xAxisIndex).toBe(i) + expect(s.yAxisIndex).toBe(i) + }) + expect(opt.xAxis.map((x) => x.gridIndex)).toEqual([0, 1, 2]) + expect(opt.yAxis.map((y) => y.gridIndex)).toEqual([0, 1, 2]) + }) + + test('rows stack downward and the canvas height covers them', () => { + const opt = buildStackedChartOption(payloadWith(three), THEME) + const tops = opt.grid.map((g) => g.top) + expect([...tops].sort((a, b) => a - b)).toEqual(tops) + const lastBottom = tops[2] + opt.grid[2].height + expect(stackedChartHeight(3)).toBeGreaterThan(lastBottom) + expect(stackedChartHeight(3)).toBeGreaterThan(stackedChartHeight(1)) + }) + + test('only the bottom row prints x labels', () => { + const opt = buildStackedChartOption(payloadWith(three), THEME) + expect(opt.xAxis.map((x) => x.axisLabel.show)).toEqual([ + false, + false, + true, + ]) + }) + + test('hover and zoom act on every row: linked pointer, all-axes zoom', () => { + const opt = buildStackedChartOption(payloadWith(three), THEME) + expect(opt.axisPointer.link).toEqual([{ xAxisIndex: 'all' }]) + expect(opt.dataZoom.map((z) => z.type)).toEqual(['inside', 'slider']) + for (const z of opt.dataZoom) { + expect(z.xAxisIndex).toEqual([0, 1, 2]) + } + }) + + test('no legend: every variable is always visible in its own row', () => { + const opt = buildStackedChartOption(payloadWith(three), THEME) + expect(opt.legend).toBeUndefined() + }) + + test('each y-axis is named by its variable unit, yLabel as fallback', () => { + const opt = buildStackedChartOption( + { ...payloadWith(three), yLabel: 'fallback' }, + THEME, + ) + expect(opt.yAxis.map((y) => y.name)).toEqual([ + 'ppm', + 'K', + 'fallback', + ]) + }) + + test('time rows share UTC tick formatting on axis and slider', () => { + const opt = buildStackedChartOption(payloadWith(three), THEME) + const bottom = opt.xAxis[2] + expect(bottom.type).toBe('value') + expect(typeof bottom.axisLabel.formatter).toBe('function') + const slider = opt.dataZoom.find((z) => z.type === 'slider') + expect(typeof slider.labelFormatter).toBe('function') + expect(opt.series[0].data[0]).toEqual([ + Date.parse('2026-01-01T00:00:00Z'), + 1, + ]) + }) + + test('category rows align every row to the shared label union', () => { + const cat = [ + series({ id: 'a', label: 'A', points: [{ x: 'x1', y: 1 }] }), + series({ + id: 'b', + label: 'B', + points: [ + { x: 'x2', y: 2 }, + { x: 'x1', y: 3 }, + ], + }), + ] + const opt = buildStackedChartOption(payloadWith(cat, 'category'), THEME) + expect(opt.xAxis[0].type).toBe('category') + expect(opt.xAxis[0].data).toEqual(['x1', 'x2']) + expect(opt.xAxis[1].data).toEqual(['x1', 'x2']) + expect(opt.series[0].data).toEqual([1, null]) + expect(opt.series[1].data).toEqual([3, 2]) + }) + }) + }) From 75b3e41791c10f2f676cb258245909850b003cd1 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Tue, 11 Aug 2026 11:29:26 -0500 Subject: [PATCH 05/10] SeriesChart: stacked layout as per-variable cards with preview zoom and CSV export --- src/essence/Tools/SeriesChart/README.md | 8 +- src/essence/Tools/SeriesChart/config.json | 2 +- .../Tools/SeriesChart/lib/chartData.ts | 164 +++++++--------- .../lib/components/SeriesChartPanel.tsx | 180 +++++++++++++----- .../lib/styles/components/series-chart.scss | 61 ++++++ tests/unit/seriesChartData.spec.js | 176 +++++++++-------- 6 files changed, 361 insertions(+), 230 deletions(-) diff --git a/src/essence/Tools/SeriesChart/README.md b/src/essence/Tools/SeriesChart/README.md index 00e932752..15de02ba0 100644 --- a/src/essence/Tools/SeriesChart/README.md +++ b/src/essence/Tools/SeriesChart/README.md @@ -39,9 +39,11 @@ config entry, not a code change: `variables.layout` — `"single"` (default) or `"stacked"`. Single renders all of a card's variables in one chart with a single-select legend as the -variable picker. Stacked renders one chart row per variable — each with its -own unit-named y-axis — with hover and zoom linked across rows, so mixed-unit -variables can be compared over the same x-range. +variable picker. Stacked renders one card per variable: a clean single-series +chart over a preview zoom strip (the series ghosted inside the slider), with +a footer chip naming the variable and unit, a hover hint, and a Download CSV +link exporting that variable's points. Each card zooms independently, which +keeps mixed-unit variables comparable without a dual axis. ## Smoke test (devtools console) diff --git a/src/essence/Tools/SeriesChart/config.json b/src/essence/Tools/SeriesChart/config.json index 2a4120aa1..692b44933 100644 --- a/src/essence/Tools/SeriesChart/config.json +++ b/src/essence/Tools/SeriesChart/config.json @@ -45,7 +45,7 @@ { "field": "variables.layout", "name": "Chart layout", - "description": "single: all variables share one chart and the legend picks which is visible. stacked: one chart row per variable, with hover and zoom linked across rows.", + "description": "single: all variables share one chart and the legend picks which is visible. stacked: one card per variable with its own preview zoom strip and a Download CSV link.", "type": "dropdown", "width": 4, "options": ["single", "stacked"] diff --git a/src/essence/Tools/SeriesChart/lib/chartData.ts b/src/essence/Tools/SeriesChart/lib/chartData.ts index de6cf4e85..946a297b6 100644 --- a/src/essence/Tools/SeriesChart/lib/chartData.ts +++ b/src/essence/Tools/SeriesChart/lib/chartData.ts @@ -117,16 +117,6 @@ export function formatTooltipTime(ms: number): string { return TOOLTIP_FMT.format(new Date(ms)) } -/** Vertical space one stacked row occupies (title band + plot). */ -const STACKED_ROW = 150 -/** Below the last row: its x-axis labels plus the shared zoom slider. */ -const STACKED_EXTRA = 48 - -/** Canvas height for a stacked option of `n` variables. */ -export function stackedChartHeight(n: number): number { - return Math.max(n, 1) * STACKED_ROW + STACKED_EXTRA -} - function seriesBase(s: ChartSeries, i: number, theme: ChartTheme) { const color = s.color || theme.palette[i % theme.palette.length] return { @@ -295,70 +285,45 @@ export function buildChartOption( } /** - * Stacked small-multiples option: one grid row per variable with its own - * unit-named y-axis. The x-axes are linked (`axisPointer.link`) and one - * zoom pair drives them all, so hovering or zooming any row moves every - * row together. No legend — each variable is always visible in its row, - * titled by the series label. The canvas must be `stackedChartHeight(n)` - * tall for the pixel-positioned grids to land inside it. + * One variable's card in the stacked layout: a single-series chart over a + * preview zoom strip (the series redrawn inside the slider), per the design + * reference. Sparse unlabeled axes — the card's footer chip, not the chart, + * names the variable and unit. `index` fixes the palette slot so a variable + * keeps its color no matter which subset of variables renders. */ -export function buildStackedChartOption( +export function buildVariableCardOption( + s: ChartSeries, payload: ChartSeriesPayload, theme: ChartTheme, + index: number, ): Record { - const n = payload.series.length - const allX = payload.series.map((_, i) => i) const isTime = payload.xType === 'time' const isCategory = payload.xType === 'category' + const color = s.color || theme.palette[index % theme.palette.length] - const category = isCategory ? toCategoryData(payload.series) : null - const data: Array | Array> = - category - ? category.rows - : payload.series.map((s) => - (isTime ? toTimePoints(s.points) : toLinearPoints(s.points)).map( - (p) => [p.x, p.y] as [number, number | null], - ), - ) + const category = isCategory ? toCategoryData([s]) : null + const data: Array<[number, number | null]> | Array = category + ? category.rows[0] + : (isTime ? toTimePoints(s.points) : toLinearPoints(s.points)).map( + (p) => [p.x, p.y] as [number, number | null], + ) const xs = category ? [] - : (data as Array>).flatMap((d) => - d.map((p) => p[0]), - ) + : (data as Array<[number, number | null]>).map((d) => d[0]) const tickFormat = isTime && xs.length > 0 ? makeTimeTickFormat(Math.min(...xs), Math.max(...xs)) : null - const title: Array> = [] - const grid: Array> = [] - const xAxis: Array> = [] - const yAxis: Array> = [] - const series: Array> = [] - - payload.series.forEach((s, i) => { - const rowTop = i * STACKED_ROW - // Only the bottom row prints x labels — the rows above share its axis - // via the link, and repeating the labels n times just eats plot height. - const isBottom = i === n - 1 - title.push({ - text: s.label, - top: rowTop + 2, - left: 8, - textStyle: { - fontSize: 12, - fontWeight: 600, - color: theme.textColor, - }, - }) - grid.push({ - left: 56, - right: 16, - top: rowTop + 28, - height: STACKED_ROW - 40, - }) - xAxis.push({ - gridIndex: i, + return { + tooltip: { + trigger: 'axis' as const, + axisPointer: { type: 'cross' as const, label: { show: false } }, + ...(isTime ? { formatter: timeTooltipFormatter } : {}), + }, + // Bottom band holds the x labels and the preview strip. + grid: { left: 48, right: 12, top: 12, bottom: 84 }, + xAxis: { ...(category ? { type: 'category' as const, data: category.labels } : { @@ -368,51 +333,62 @@ export function buildStackedChartOption( splitLine: { show: false }, }), axisLabel: { - show: isBottom, color: theme.textColor, hideOverlap: true, ...(tickFormat ? { formatter: (v: number) => tickFormat(v) } : {}), }, - }) - yAxis.push({ - gridIndex: i, + }, + yAxis: { type: 'value' as const, scale: true, - name: s.unit ?? payload.yLabel ?? undefined, - nameLocation: 'middle' as const, - nameGap: 42, - nameTextStyle: { color: theme.textColor }, + // A handful of unnamed ticks — identity and unit live in the + // card footer, so the plot stays clean like the reference. + splitNumber: 2, axisLabel: { color: theme.textColor }, - splitLine: { lineStyle: { color: theme.gridColor } }, - }) - series.push({ - ...seriesBase(s, i, theme), - xAxisIndex: i, - yAxisIndex: i, - data: data[i], - }) - }) - - const slider = sliderZoom(theme, allX) - return { - axisPointer: { link: [{ xAxisIndex: 'all' }] }, - tooltip: { - trigger: 'axis' as const, - axisPointer: { type: 'cross' as const, label: { show: false } }, - ...(isTime ? { formatter: timeTooltipFormatter } : {}), + splitLine: { show: false }, }, - title, - grid, - xAxis, - yAxis, - series, + series: [ + { + ...seriesBase(s, index, theme), + showSymbol: false, + data, + }, + ], dataZoom: [ - { type: 'inside' as const, xAxisIndex: allX }, - tickFormat - ? { ...slider, labelFormatter: (v: number) => tickFormat(v) } - : slider, + { type: 'inside' as const }, + { + type: 'slider' as const, + // Preview strip: the series ghosted inside the slider, light + // default filler over it, dark end handles. + height: 30, + bottom: 10, + showDataShadow: true, + brushSelect: false, + borderColor: theme.gridColor, + handleSize: '80%', + handleStyle: { color: theme.textColor }, + moveHandleSize: 0, + dataBackground: { + lineStyle: { color, opacity: 0.6, width: 1 }, + areaStyle: { color, opacity: 0.08 }, + }, + ...(tickFormat + ? { labelFormatter: (v: number) => tickFormat(v) } + : {}), + }, ], } } + +/** + * A variable's points as a two-column CSV, `x` then the series label. + * `y: null` gaps become empty cells; fields with commas/quotes are quoted. + */ +export function seriesToCsv(s: ChartSeries): string { + const esc = (v: string) => + /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v + const rows = s.points.map((p) => `${esc(String(p.x))},${p.y ?? ''}`) + return [`x,${esc(s.label)}`, ...rows].join('\n') +} diff --git a/src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx b/src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx index deadf37ea..15281bb3a 100644 --- a/src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx +++ b/src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx @@ -1,11 +1,14 @@ import React, { useEffect, useRef } from 'react' import * as echarts from 'echarts' -import type { ChartSeriesPayload } from '../../../_shared/types/chartSeries' +import type { + ChartSeries, + ChartSeriesPayload, +} from '../../../_shared/types/chartSeries' import type { ChartCard, ChartLayout, ChartTheme } from '../types' import { buildChartOption, - buildStackedChartOption, - stackedChartHeight, + buildVariableCardOption, + seriesToCsv, } from '../chartData' export interface SeriesChartPanelProps { @@ -98,6 +101,23 @@ function ReadyCard({ layout: ChartLayout }) { const chartRef = useRef(null) + if (layout === 'stacked') { + // One sub-card per variable, each with its own preview-strip zoom — + // no shared reset button; dragging a card's strip is its reset. + return ( + <> + + {payload.series.map((s, i) => ( + + ))} + + ) + } return ( <> - + ) } @@ -136,15 +156,12 @@ function themeFromCss(el: HTMLElement): ChartTheme { function SeriesCanvas({ payload, - layout, chartRef, }: { payload: ChartSeriesPayload - layout: ChartLayout chartRef?: React.MutableRefObject }) { const hostRef = useRef(null) - const stacked = layout === 'stacked' useEffect(() => { const host = hostRef.current @@ -152,36 +169,31 @@ function SeriesCanvas({ const chart = echarts.init(host) if (chartRef) chartRef.current = chart const theme = themeFromCss(host) + let visible = payload.series[0]?.label + chart.setOption(buildChartOption(payload, theme, visible) as never) - if (stacked) { - chart.setOption(buildStackedChartOption(payload, theme) as never) - } else { - let visible = payload.series[0]?.label - chart.setOption(buildChartOption(payload, theme, visible) as never) - - // Single-select legend is the variable picker; rebuild so the y-axis - // renames to the picked variable's unit, keeping the zoom range. - // Re-clicking the active chip would empty the chart — keep it on. - chart.on('legendselectchanged', (e) => { - const event = e as { - name: string - selected: Record - } - visible = event.selected[event.name] ? event.name : visible - const zoom = ( - chart.getOption() as { dataZoom?: Array<{ start?: number; end?: number }> } - ).dataZoom?.[0] - const option = buildChartOption(payload, theme, visible) - if (zoom && Array.isArray(option.dataZoom)) { - option.dataZoom = option.dataZoom.map((z: Record) => ({ - ...z, - start: zoom.start, - end: zoom.end, - })) - } - chart.setOption(option as never, { notMerge: true }) - }) - } + // Single-select legend is the variable picker; rebuild so the y-axis + // renames to the picked variable's unit, keeping the zoom range. + // Re-clicking the active chip would empty the chart — keep it on. + chart.on('legendselectchanged', (e) => { + const event = e as { + name: string + selected: Record + } + visible = event.selected[event.name] ? event.name : visible + const zoom = ( + chart.getOption() as { dataZoom?: Array<{ start?: number; end?: number }> } + ).dataZoom?.[0] + const option = buildChartOption(payload, theme, visible) + if (zoom && Array.isArray(option.dataZoom)) { + option.dataZoom = option.dataZoom.map((z: Record) => ({ + ...z, + start: zoom.start, + end: zoom.end, + })) + } + chart.setOption(option as never, { notMerge: true }) + }) const observer = new ResizeObserver(() => chart.resize()) observer.observe(host) @@ -190,20 +202,92 @@ function SeriesCanvas({ if (chartRef?.current === chart) chartRef.current = null chart.dispose() } - }, [payload, stacked, chartRef]) + }, [payload, chartRef]) + + return
+} + +/** CSS vars in the order themeFromCss builds its palette, so a variable's + * footer dot and its chart line resolve to the same theme color. */ +const PALETTE_VARS = [ + '--theme-color-primary', + '--theme-color-accent-cool', + '--theme-color-accent-warm', + '--theme-color-secondary', +] + +function downloadCsv(s: ChartSeries) { + const blob = new Blob([seriesToCsv(s)], { type: 'text/csv;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${s.label.replace(/[^\w.-]+/g, '_') || 'series'}.csv` + a.click() + URL.revokeObjectURL(url) +} + +function VariableCard({ + series, + payload, + index, +}: { + series: ChartSeries + payload: ChartSeriesPayload + index: number +}) { + const hostRef = useRef(null) + + useEffect(() => { + const host = hostRef.current + if (!host) return + const chart = echarts.init(host) + const theme = themeFromCss(host) + chart.setOption( + buildVariableCardOption(series, payload, theme, index) as never, + ) + const observer = new ResizeObserver(() => chart.resize()) + observer.observe(host) + return () => { + observer.disconnect() + chart.dispose() + } + }, [series, payload, index]) return ( -
+
+
+
+
+ + +

+ Hover to inspect · drag the strip to zoom +

+
+ +
+
) } diff --git a/src/essence/Tools/SeriesChart/lib/styles/components/series-chart.scss b/src/essence/Tools/SeriesChart/lib/styles/components/series-chart.scss index 6ae5b557e..0e9ad505a 100644 --- a/src/essence/Tools/SeriesChart/lib/styles/components/series-chart.scss +++ b/src/essence/Tools/SeriesChart/lib/styles/components/series-chart.scss @@ -97,3 +97,64 @@ position: relative; height: 280px; } + +.series-chart__variable-card { + margin-top: var(--theme-spacing-1, 0.5rem); + background: var(--theme-color-white, #ffffff); + border: 1px solid var(--theme-color-base-lighter, #dfe1e2); + border-radius: var(--theme-border-radius-md, 4px); + padding: var(--theme-spacing-1, 0.5rem); +} + +.series-chart__variable-canvas { + position: relative; + height: 220px; +} + +.series-chart__variable-footer { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--theme-spacing-1, 0.5rem); + margin-top: var(--theme-spacing-05, 0.25rem); +} + +.series-chart__variable-chip { + display: inline-flex; + align-items: center; + gap: var(--theme-spacing-05, 0.25rem); + font-size: var(--theme-font-size-sm, 0.875rem); + color: var(--theme-color-ink, #1b1b1b); +} + +.series-chart__variable-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.series-chart__variable-unit { + color: var(--theme-color-base-dark, #565c65); +} + +.series-chart__variable-hint { + margin: var(--theme-spacing-05, 0.25rem) 0 0; + font-size: var(--theme-font-size-xs, 0.75rem); + color: var(--theme-color-base-dark, #565c65); +} + +.series-chart__csv-link { + padding: 0; + border: none; + background: none; + font-size: var(--theme-font-size-sm, 0.875rem); + font-weight: var(--theme-font-weight-semibold, 600); + color: var(--theme-color-primary, #005ea2); + cursor: pointer; + white-space: nowrap; + + &:hover { + text-decoration: underline; + } +} diff --git a/tests/unit/seriesChartData.spec.js b/tests/unit/seriesChartData.spec.js index 12dfa9c04..0d65f3d52 100644 --- a/tests/unit/seriesChartData.spec.js +++ b/tests/unit/seriesChartData.spec.js @@ -6,8 +6,8 @@ import { makeTimeTickFormat, formatTooltipTime, buildChartOption, - buildStackedChartOption, - stackedChartHeight, + buildVariableCardOption, + seriesToCsv, } from '../../src/essence/Tools/SeriesChart/lib/chartData.ts' const THEME = { @@ -270,7 +270,7 @@ describe('seriesChart chartData', () => { }) }) - describe('buildStackedChartOption', () => { + describe('buildVariableCardOption', () => { const series = (over = {}) => ({ id: over.id ?? 's1', label: over.label ?? 'S1', @@ -280,102 +280,110 @@ describe('seriesChart chartData', () => { ], ...over, }) - const three = [ - series({ id: 'a', label: 'A', unit: 'ppm' }), - series({ id: 'b', label: 'B', unit: 'K' }), - series({ id: 'c', label: 'C' }), - ] - - test('one grid, axis pair, row title, and series per variable', () => { - const opt = buildStackedChartOption(payloadWith(three), THEME) - expect(opt.grid).toHaveLength(3) - expect(opt.xAxis).toHaveLength(3) - expect(opt.yAxis).toHaveLength(3) - expect(opt.series).toHaveLength(3) - expect(opt.title.map((t) => t.text)).toEqual(['A', 'B', 'C']) - opt.series.forEach((s, i) => { - expect(s.xAxisIndex).toBe(i) - expect(s.yAxisIndex).toBe(i) - }) - expect(opt.xAxis.map((x) => x.gridIndex)).toEqual([0, 1, 2]) - expect(opt.yAxis.map((y) => y.gridIndex)).toEqual([0, 1, 2]) - }) - - test('rows stack downward and the canvas height covers them', () => { - const opt = buildStackedChartOption(payloadWith(three), THEME) - const tops = opt.grid.map((g) => g.top) - expect([...tops].sort((a, b) => a - b)).toEqual(tops) - const lastBottom = tops[2] + opt.grid[2].height - expect(stackedChartHeight(3)).toBeGreaterThan(lastBottom) - expect(stackedChartHeight(3)).toBeGreaterThan(stackedChartHeight(1)) - }) - - test('only the bottom row prints x labels', () => { - const opt = buildStackedChartOption(payloadWith(three), THEME) - expect(opt.xAxis.map((x) => x.axisLabel.show)).toEqual([ - false, - false, - true, - ]) - }) - - test('hover and zoom act on every row: linked pointer, all-axes zoom', () => { - const opt = buildStackedChartOption(payloadWith(three), THEME) - expect(opt.axisPointer.link).toEqual([{ xAxisIndex: 'all' }]) - expect(opt.dataZoom.map((z) => z.type)).toEqual(['inside', 'slider']) - for (const z of opt.dataZoom) { - expect(z.xAxisIndex).toEqual([0, 1, 2]) - } - }) + const card = (s, over = {}) => + buildVariableCardOption( + s, + { ...payloadWith([s]), ...over }, + THEME, + over.index ?? 0, + ) - test('no legend: every variable is always visible in its own row', () => { - const opt = buildStackedChartOption(payloadWith(three), THEME) + test('single clean series: no legend, no symbols, no gridlines', () => { + const opt = card(series()) expect(opt.legend).toBeUndefined() - }) - - test('each y-axis is named by its variable unit, yLabel as fallback', () => { - const opt = buildStackedChartOption( - { ...payloadWith(three), yLabel: 'fallback' }, + expect(opt.series).toHaveLength(1) + expect(opt.series[0].showSymbol).toBe(false) + expect(opt.yAxis.splitLine.show).toBe(false) + }) + + test('identity lives in the footer, not the plot: unnamed sparse y-axis', () => { + const opt = card(series({ unit: 'ppm' })) + expect(opt.yAxis.name).toBeUndefined() + expect(opt.yAxis.splitNumber).toBe(2) + }) + + test('the palette slot follows the variable index, explicit color wins', () => { + const s = series() + const first = buildVariableCardOption(s, payloadWith([s]), THEME, 0) + const second = buildVariableCardOption(s, payloadWith([s]), THEME, 1) + expect(first.series[0].itemStyle.color).toBe(THEME.palette[0]) + expect(second.series[0].itemStyle.color).toBe(THEME.palette[1]) + const explicit = buildVariableCardOption( + series({ color: '#abcdef' }), + payloadWith([s]), THEME, + 1, ) - expect(opt.yAxis.map((y) => y.name)).toEqual([ - 'ppm', - 'K', - 'fallback', - ]) + expect(explicit.series[0].itemStyle.color).toBe('#abcdef') }) - test('time rows share UTC tick formatting on axis and slider', () => { - const opt = buildStackedChartOption(payloadWith(three), THEME) - const bottom = opt.xAxis[2] - expect(bottom.type).toBe('value') - expect(typeof bottom.axisLabel.formatter).toBe('function') + test('zoom strip previews the series in its own color', () => { + const opt = card(series()) + const slider = opt.dataZoom.find((z) => z.type === 'slider') + expect(slider.showDataShadow).toBe(true) + expect(slider.dataBackground.lineStyle.color).toBe(THEME.palette[0]) + expect(opt.dataZoom.map((z) => z.type)).toEqual(['inside', 'slider']) + }) + + test('time cards format axis, slider labels, and tooltip as UTC', () => { + const opt = card(series()) + expect(opt.xAxis.type).toBe('value') + expect(typeof opt.xAxis.axisLabel.formatter).toBe('function') const slider = opt.dataZoom.find((z) => z.type === 'slider') expect(typeof slider.labelFormatter).toBe('function') + expect(typeof opt.tooltip.formatter).toBe('function') expect(opt.series[0].data[0]).toEqual([ Date.parse('2026-01-01T00:00:00Z'), 1, ]) }) - test('category rows align every row to the shared label union', () => { - const cat = [ - series({ id: 'a', label: 'A', points: [{ x: 'x1', y: 1 }] }), - series({ - id: 'b', - label: 'B', - points: [ - { x: 'x2', y: 2 }, - { x: 'x1', y: 3 }, - ], - }), - ] - const opt = buildStackedChartOption(payloadWith(cat, 'category'), THEME) - expect(opt.xAxis[0].type).toBe('category') - expect(opt.xAxis[0].data).toEqual(['x1', 'x2']) - expect(opt.xAxis[1].data).toEqual(['x1', 'x2']) + test('category cards use the variable’s own labels', () => { + const s = series({ + points: [ + { x: 'x1', y: 1 }, + { x: 'x2', y: null }, + ], + }) + const opt = buildVariableCardOption( + s, + payloadWith([s], 'category'), + THEME, + 0, + ) + expect(opt.xAxis.type).toBe('category') + expect(opt.xAxis.data).toEqual(['x1', 'x2']) expect(opt.series[0].data).toEqual([1, null]) - expect(opt.series[1].data).toEqual([3, 2]) + }) + }) + + describe('seriesToCsv', () => { + test('two columns headed x and the series label; gaps are empty cells', () => { + const csv = seriesToCsv({ + id: 'o3', + label: 'O3', + points: [ + { x: '2026-01-01T00:00:00Z', y: 0.04 }, + { x: '2026-01-02T00:00:00Z', y: null }, + ], + }) + expect(csv.split('\n')).toEqual([ + 'x,O3', + '2026-01-01T00:00:00Z,0.04', + '2026-01-02T00:00:00Z,', + ]) + }) + + test('fields with commas or quotes are quoted and escaped', () => { + const csv = seriesToCsv({ + id: 's', + label: 'PM2.5, "fine"', + points: [{ x: 'a,b', y: 1 }], + }) + expect(csv.split('\n')).toEqual([ + 'x,"PM2.5, ""fine"""', + '"a,b",1', + ]) }) }) From 411754bc809900d88dfc22bfdc26cd5c81c4de54 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Tue, 11 Aug 2026 12:44:32 -0500 Subject: [PATCH 06/10] SeriesChart: one design for both layouts; cap stacked list height --- src/essence/Tools/SeriesChart/README.md | 15 +- .../Tools/SeriesChart/lib/chartData.ts | 86 +++++------ .../lib/components/SeriesChartPanel.tsx | 140 +++++++++++------- .../lib/styles/components/series-chart.scss | 7 + tests/unit/seriesChartData.spec.js | 52 ++++--- 5 files changed, 168 insertions(+), 132 deletions(-) diff --git a/src/essence/Tools/SeriesChart/README.md b/src/essence/Tools/SeriesChart/README.md index 15de02ba0..4fd09a971 100644 --- a/src/essence/Tools/SeriesChart/README.md +++ b/src/essence/Tools/SeriesChart/README.md @@ -37,13 +37,14 @@ config entry, not a code change: { "sources": ["fetch-timeseries", "fetch-raster-timeseries"] } ``` -`variables.layout` — `"single"` (default) or `"stacked"`. Single renders all -of a card's variables in one chart with a single-select legend as the -variable picker. Stacked renders one card per variable: a clean single-series -chart over a preview zoom strip (the series ghosted inside the slider), with -a footer chip naming the variable and unit, a hover hint, and a Download CSV -link exporting that variable's points. Each card zooms independently, which -keeps mixed-unit variables comparable without a dual axis. +`variables.layout` — `"single"` (default) or `"stacked"`. Both share one +design: a clean symbol-less line, sparse unnamed y-axis, a preview zoom strip +(the series ghosted inside the slider), and a footer chip naming the variable +and unit with a hover hint and a Download CSV link. Single renders all of a +card's variables in one chart — the single-select legend picks the visible +one, and the strip, footer, and CSV follow the pick. Stacked renders one such +card per variable (each zooming independently, mixed units without a dual +axis), capped at ~1.5 cards tall with the rest scrolling inside the card. ## Smoke test (devtools console) diff --git a/src/essence/Tools/SeriesChart/lib/chartData.ts b/src/essence/Tools/SeriesChart/lib/chartData.ts index 946a297b6..dfb451447 100644 --- a/src/essence/Tools/SeriesChart/lib/chartData.ts +++ b/src/essence/Tools/SeriesChart/lib/chartData.ts @@ -126,31 +126,35 @@ function seriesBase(s: ChartSeries, i: number, theme: ChartTheme) { itemStyle: { color }, lineStyle: { width: 2 }, symbolSize: 5, + showSymbol: false, connectNulls: false, } } -function sliderZoom(theme: ChartTheme, xAxisIndex: number | number[]) { +/** The preview zoom strip both layouts share: the series ghosted inside the + * slider in its own color, light default filler over it, dark end handles. */ +function previewSlider( + theme: ChartTheme, + color: string, + tickFormat: ((ms: number) => string) | null, +) { return { type: 'slider' as const, - xAxisIndex, - // Slim range-input look: grey track, solid primary fill, - // round handles, no border, no data preview, no move grip. - height: 8, + height: 30, bottom: 10, - showDataShadow: false, + showDataShadow: true, brushSelect: false, - borderColor: 'transparent', - backgroundColor: theme.gridColor, - fillerColor: theme.palette[0], - handleIcon: 'circle', - handleSize: 14, - handleStyle: { - color: theme.palette[0], - borderColor: theme.surface, - borderWidth: 1, - }, + borderColor: theme.gridColor, + handleSize: '80%', + handleStyle: { color: theme.textColor }, moveHandleSize: 0, + dataBackground: { + lineStyle: { color, opacity: 0.6, width: 1 }, + areaStyle: { color, opacity: 0.08 }, + }, + ...(tickFormat + ? { labelFormatter: (v: number) => tickFormat(v) } + : {}), } } @@ -176,8 +180,10 @@ function timeTooltipFormatter(params: TooltipParam[] | TooltipParam): string { * by rendering it. * * One variable is visible at a time (`visibleLabel`, default: the first - * series); the single-select legend is the picker and the y-axis is named - * for the visible variable's unit. + * series) and the single-select legend is the picker. Same visual grammar + * as the stacked variable card — clean line, sparse unnamed y-axis, preview + * zoom strip in the visible variable's color; the card footer, not the + * chart, names the variable and unit. */ export function buildChartOption( payload: ChartSeriesPayload, @@ -188,19 +194,22 @@ export function buildChartOption( const selected: Record = {} for (const s of payload.series) selected[s.label] = s.label === visible - const activeSeries = payload.series.find((s) => s.label === visible) - const yTitle = payload.yLabel ?? activeSeries?.unit ?? null + const activeIndex = Math.max( + payload.series.findIndex((s) => s.label === visible), + 0, + ) + const activeSeries = payload.series[activeIndex] + const activeColor = + activeSeries?.color || + theme.palette[activeIndex % theme.palette.length] const yAxis = [ { type: 'value' as const, scale: true, - name: yTitle ?? undefined, - nameLocation: 'middle' as const, - nameGap: 42, - nameTextStyle: { color: theme.textColor }, + splitNumber: 2, axisLabel: { color: theme.textColor }, - splitLine: { lineStyle: { color: theme.gridColor } }, + splitLine: { show: false }, }, ] @@ -215,10 +224,11 @@ export function buildChartOption( textStyle: { color: theme.textColor }, selected, }, - grid: { left: 56, right: 16, top: 32, bottom: 44 }, + // Bottom band holds the x labels and the preview strip. + grid: { left: 48, right: 12, top: 32, bottom: 84 }, dataZoom: [ { type: 'inside' as const, xAxisIndex: 0 }, - sliderZoom(theme, 0), + previewSlider(theme, activeColor, null), ], yAxis, } @@ -352,32 +362,12 @@ export function buildVariableCardOption( series: [ { ...seriesBase(s, index, theme), - showSymbol: false, data, }, ], dataZoom: [ { type: 'inside' as const }, - { - type: 'slider' as const, - // Preview strip: the series ghosted inside the slider, light - // default filler over it, dark end handles. - height: 30, - bottom: 10, - showDataShadow: true, - brushSelect: false, - borderColor: theme.gridColor, - handleSize: '80%', - handleStyle: { color: theme.textColor }, - moveHandleSize: 0, - dataBackground: { - lineStyle: { color, opacity: 0.6, width: 1 }, - areaStyle: { color, opacity: 0.08 }, - }, - ...(tickFormat - ? { labelFormatter: (v: number) => tickFormat(v) } - : {}), - }, + previewSlider(theme, color, tickFormat), ], } } diff --git a/src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx b/src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx index 15281bb3a..1be727dfd 100644 --- a/src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx +++ b/src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx @@ -101,23 +101,39 @@ function ReadyCard({ layout: ChartLayout }) { const chartRef = useRef(null) + const [activeLabel, setActiveLabel] = React.useState( + payload.series[0]?.label, + ) + useEffect(() => { + setActiveLabel(payload.series[0]?.label) + }, [payload]) + if (layout === 'stacked') { // One sub-card per variable, each with its own preview-strip zoom — - // no shared reset button; dragging a card's strip is its reset. + // no shared reset button; dragging a card's strip is its reset. The + // list is capped so a many-variable payload scrolls inside its card + // instead of swallowing the panel. return ( <> - {payload.series.map((s, i) => ( - - ))} +
+ {payload.series.map((s, i) => ( + + ))} +
) } + + const activeIndex = Math.max( + payload.series.findIndex((s) => s.label === activeLabel), + 0, + ) return ( <> - + + {payload.series[activeIndex] && ( + + )} ) } @@ -157,9 +183,13 @@ function themeFromCss(el: HTMLElement): ChartTheme { function SeriesCanvas({ payload, chartRef, + onVisibleChange, }: { payload: ChartSeriesPayload chartRef?: React.MutableRefObject + /** Fires with the picked variable's label so the card footer follows. + * Must be identity-stable (e.g. a setState) — it's an effect dep. */ + onVisibleChange?: (label: string) => void }) { const hostRef = useRef(null) @@ -172,8 +202,8 @@ function SeriesCanvas({ let visible = payload.series[0]?.label chart.setOption(buildChartOption(payload, theme, visible) as never) - // Single-select legend is the variable picker; rebuild so the y-axis - // renames to the picked variable's unit, keeping the zoom range. + // Single-select legend is the variable picker; rebuild so the preview + // strip recolors to the picked variable, keeping the zoom range. // Re-clicking the active chip would empty the chart — keep it on. chart.on('legendselectchanged', (e) => { const event = e as { @@ -181,6 +211,7 @@ function SeriesCanvas({ selected: Record } visible = event.selected[event.name] ? event.name : visible + if (visible) onVisibleChange?.(visible) const zoom = ( chart.getOption() as { dataZoom?: Array<{ start?: number; end?: number }> } ).dataZoom?.[0] @@ -202,18 +233,19 @@ function SeriesCanvas({ if (chartRef?.current === chart) chartRef.current = null chart.dispose() } - }, [payload, chartRef]) + }, [payload, chartRef, onVisibleChange]) return
} -/** CSS vars in the order themeFromCss builds its palette, so a variable's - * footer dot and its chart line resolve to the same theme color. */ +/** CSS vars (with the same fallbacks) in the order themeFromCss builds its + * palette, so a variable's footer dot and its chart line resolve to the + * same theme color even when a theme omits a token. */ const PALETTE_VARS = [ - '--theme-color-primary', - '--theme-color-accent-cool', - '--theme-color-accent-warm', - '--theme-color-secondary', + 'var(--theme-color-primary, #005ea2)', + 'var(--theme-color-accent-cool, #00bde3)', + 'var(--theme-color-accent-warm, #fa9441)', + 'var(--theme-color-secondary, #d83933)', ] function downloadCsv(s: ChartSeries) { @@ -226,6 +258,44 @@ function downloadCsv(s: ChartSeries) { URL.revokeObjectURL(url) } +/** The footer both layouts share: colored dot naming the variable (with + * unit), the interaction hint, and that variable's CSV download. */ +function CardFooter({ series, index }: { series: ChartSeries; index: number }) { + return ( +
+
+ + +

+ Hover to inspect · drag the strip to zoom +

+
+ +
+ ) +} + function VariableCard({ series, payload, @@ -256,37 +326,7 @@ function VariableCard({ return (
-
-
- - -

- Hover to inspect · drag the strip to zoom -

-
- -
+
) } diff --git a/src/essence/Tools/SeriesChart/lib/styles/components/series-chart.scss b/src/essence/Tools/SeriesChart/lib/styles/components/series-chart.scss index 0e9ad505a..0b5b796d8 100644 --- a/src/essence/Tools/SeriesChart/lib/styles/components/series-chart.scss +++ b/src/essence/Tools/SeriesChart/lib/styles/components/series-chart.scss @@ -98,6 +98,13 @@ height: 280px; } +.series-chart__variable-list { + // ~1.5 variable cards tall, then scroll within the station card instead + // of the payload swallowing the whole panel. + max-height: 440px; + overflow-y: auto; +} + .series-chart__variable-card { margin-top: var(--theme-spacing-1, 0.5rem); background: var(--theme-color-white, #ffffff); diff --git a/tests/unit/seriesChartData.spec.js b/tests/unit/seriesChartData.spec.js index 0d65f3d52..105196c7f 100644 --- a/tests/unit/seriesChartData.spec.js +++ b/tests/unit/seriesChartData.spec.js @@ -147,16 +147,25 @@ describe('seriesChart chartData', () => { expect(opt.dataZoom.map((z) => z.type)).toEqual(['inside', 'slider']) }) - test('the zoom scrubber is a slim primary-filled range slider', () => { - const opt = buildChartOption(payloadWith([series()]), THEME) + test('the zoom strip previews the visible variable in its color', () => { + const opt = buildChartOption( + payloadWith([series(), series({ id: 's2', label: 'S2' })]), + THEME, + ) const slider = opt.dataZoom.find((z) => z.type === 'slider') - expect(slider.fillerColor).toBe('#111111') - expect(slider.handleStyle.color).toBe('#111111') - expect(slider.handleStyle.borderColor).toBe('#eeeeee') - expect(slider.backgroundColor).toBe('#dddddd') - expect(slider.handleIcon).toBe('circle') - expect(slider.showDataShadow).toBe(false) + expect(slider.showDataShadow).toBe(true) + expect(slider.dataBackground.lineStyle.color).toBe(THEME.palette[0]) expect(slider.moveHandleSize).toBe(0) + + const picked = buildChartOption( + payloadWith([series(), series({ id: 's2', label: 'S2' })]), + THEME, + 'S2', + ) + const pickedSlider = picked.dataZoom.find((z) => z.type === 'slider') + expect(pickedSlider.dataBackground.lineStyle.color).toBe( + THEME.palette[1], + ) }) test('cycles the theme palette and honors explicit series color', () => { @@ -228,10 +237,9 @@ describe('seriesChart chartData', () => { expect(opt.legend.selectedMode).toBe('single') expect(opt.legend.selected).toEqual({ S1: true, S2: false, S3: false }) expect(opt.yAxis).toHaveLength(1) - expect(opt.yAxis[0].name).toBe('µg/m³') }) - test('the y-axis renames to the picked variable unit', () => { + test('picking a variable moves the selection', () => { const opt = buildChartOption( payloadWith([ series({ unit: 'µg/m³' }), @@ -241,7 +249,6 @@ describe('seriesChart chartData', () => { 'S2', ) expect(opt.legend.selected).toEqual({ S1: false, S2: true }) - expect(opt.yAxis[0].name).toBe('Knots') }) test('no in-canvas toolbox: reset zoom lives in the card header', () => { @@ -249,24 +256,15 @@ describe('seriesChart chartData', () => { expect(opt.toolbox).toBeUndefined() }) - test('a single unit becomes the y-axis name', () => { - const one = buildChartOption( - payloadWith([series({ unit: 'ppm' })]), - THEME, - ) - expect(one.yAxis[0].name).toBe('ppm') - expect(one.yAxis).toHaveLength(1) - expect(one.legend.selected).toEqual({ S1: true }) - }) - - test('yLabel wins as the y-axis name', () => { - const withLabel = buildChartOption( - { ...payloadWith([series()]), yLabel: 'NO₂' }, + test('identity lives in the footer: unnamed sparse clean y-axis', () => { + const opt = buildChartOption( + { ...payloadWith([series({ unit: 'ppm' })]), yLabel: 'NO₂' }, THEME, ) - const without = buildChartOption(payloadWith([series()]), THEME) - expect(withLabel.yAxis[0].name).toBe('NO₂') - expect(without.yAxis[0].name).toBeUndefined() + expect(opt.yAxis[0].name).toBeUndefined() + expect(opt.yAxis[0].splitNumber).toBe(2) + expect(opt.yAxis[0].splitLine.show).toBe(false) + expect(opt.series[0].showSymbol).toBe(false) }) }) From f7a871fc19be859c7c5ef26d9a7434ee4568cc69 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Tue, 11 Aug 2026 13:02:17 -0500 Subject: [PATCH 07/10] Regenerate mission configs for the SeriesChart layout variable --- mission-profiles/generated/full-demo-mission.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mission-profiles/generated/full-demo-mission.json b/mission-profiles/generated/full-demo-mission.json index c52548740..283a604a3 100644 --- a/mission-profiles/generated/full-demo-mission.json +++ b/mission-profiles/generated/full-demo-mission.json @@ -281,7 +281,8 @@ "variables": { "sources": [ "fetch-timeseries" - ] + ], + "layout": "single" }, "metadata": { "icon": "chart-line", From 2c741205d7e31fc17ce3b67619fa1833a265ba93 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Mon, 24 Aug 2026 10:27:57 -0500 Subject: [PATCH 08/10] SeriesChart: loop extents, per-card error boundary, ticks from the visible variable Large payloads no longer overflow the arg-spread extent (crashed past ~130k points); a bad render costs its card, not the panel; time tick granularity follows the picked variable instead of the union of all series. Also: space-separated timezone-less datetimes parse as UTC, CSV quotes lone CRs, one token list feeds both color paths, dead surface/default-export/slider-remap removed. --- .../Tools/SeriesChart/lib/chartData.ts | 85 ++++++++----- .../lib/components/SeriesChartPanel.tsx | 114 ++++++++++++------ src/essence/Tools/SeriesChart/lib/index.ts | 2 +- src/essence/Tools/SeriesChart/lib/types.ts | 5 +- tests/unit/seriesChartData.spec.js | 81 +++++++++++-- tests/unit/seriesChartErrorBoundary.spec.js | 61 ++++++++++ 6 files changed, 265 insertions(+), 83 deletions(-) create mode 100644 tests/unit/seriesChartErrorBoundary.spec.js diff --git a/src/essence/Tools/SeriesChart/lib/chartData.ts b/src/essence/Tools/SeriesChart/lib/chartData.ts index dfb451447..0554410d7 100644 --- a/src/essence/Tools/SeriesChart/lib/chartData.ts +++ b/src/essence/Tools/SeriesChart/lib/chartData.ts @@ -22,13 +22,15 @@ export interface XyPoint { } /** Timezone-less ISO datetimes (common in OGC feature APIs) are read as UTC — - * Date.parse would use the viewer's local zone, shifting points per user. */ -const TZ_LESS_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/ + * Date.parse would use the viewer's local zone, shifting points per user. + * Covers both the T-separated form and the space-separated one common from + * Postgres/pandas exports. */ +const TZ_LESS_ISO = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/ /** * Converts a series' points for a time axis: ISO datetime → epoch ms, * dropping unparseable x values (a bad timestamp shouldn't sink the series). - * `y: null` gaps pass through — Chart.js breaks the line there. + * `y: null` gaps pass through — `connectNulls: false` breaks the line there. */ export function toTimePoints(points: ChartPoint[]): XyPoint[] { const out: XyPoint[] = [] @@ -36,7 +38,11 @@ export function toTimePoints(points: ChartPoint[]): XyPoint[] { const ms = typeof p.x === 'number' ? p.x - : Date.parse(TZ_LESS_ISO.test(p.x) ? `${p.x}Z` : p.x) + : Date.parse( + TZ_LESS_ISO.test(p.x) + ? `${p.x.replace(' ', 'T')}Z` + : p.x, + ) if (Number.isNaN(ms)) continue out.push({ x: ms, y: p.y }) } @@ -158,6 +164,19 @@ function previewSlider( } } +/** Min/max via a loop — `Math.min(...xs)` overflows the engine's argument + * limit past ~100k points, which real hourly multi-year feeds reach. */ +function extentOf(values: ArrayLike): [number, number] | null { + let min = Infinity + let max = -Infinity + for (let i = 0; i < values.length; i++) { + const v = values[i] + if (v < min) min = v + if (v > max) max = v + } + return min <= max ? [min, max] : null +} + type TooltipParam = { marker: string seriesName: string @@ -213,6 +232,33 @@ export function buildChartOption( }, ] + const isCategory = payload.xType === 'category' + const isTime = payload.xType === 'time' + const series = isCategory + ? null + : payload.series.map((s, i) => { + const points = isTime + ? toTimePoints(s.points) + : toLinearPoints(s.points) + return { + ...seriesBase(s, i, theme), + data: points.map((p) => [p.x, p.y]), + } + }) + // The axis only ever shows the visible variable (the single-select + // legend filters the rest), so tick granularity comes from its extent — + // a two-day sensor series paired with a five-year climatology must not + // force month-year labels onto 48 hours of data. Union extent is the + // fallback for a visible series with no plottable points. + const xExtent = series + ? (extentOf( + series[activeIndex]?.data.map((d) => d[0] as number) ?? [], + ) ?? extentOf(series.flatMap((s) => s.data.map((d) => d[0] as number)))) + : null + // The slider's drag labels share this: real dates, not epoch ms. + const tickFormat = + isTime && xExtent ? makeTimeTickFormat(xExtent[0], xExtent[1]) : null + const common = { legend: { show: true, @@ -228,12 +274,12 @@ export function buildChartOption( grid: { left: 48, right: 12, top: 32, bottom: 84 }, dataZoom: [ { type: 'inside' as const, xAxisIndex: 0 }, - previewSlider(theme, activeColor, null), + previewSlider(theme, activeColor, tickFormat), ], yAxis, } - if (payload.xType === 'category') { + if (series === null) { const { labels, rows } = toCategoryData(payload.series) return { ...common, @@ -250,28 +296,8 @@ export function buildChartOption( } } - const isTime = payload.xType === 'time' - const series = payload.series.map((s, i) => { - const points = isTime ? toTimePoints(s.points) : toLinearPoints(s.points) - return { - ...seriesBase(s, i, theme), - data: points.map((p) => [p.x, p.y]), - } - }) - const xs = series.flatMap((s) => s.data.map((d) => d[0] as number)) - const tickFormat = - isTime && xs.length > 0 - ? makeTimeTickFormat(Math.min(...xs), Math.max(...xs)) - : null - return { ...common, - // Drag labels on the slider show real dates, not epoch ms. - dataZoom: common.dataZoom.map((z) => - z.type === 'slider' && tickFormat - ? { ...z, labelFormatter: (v: number) => tickFormat(v) } - : z, - ), tooltip: { trigger: 'axis' as const, axisPointer: { type: 'cross' as const, label: { show: false } }, @@ -320,10 +346,9 @@ export function buildVariableCardOption( const xs = category ? [] : (data as Array<[number, number | null]>).map((d) => d[0]) + const xExtent = extentOf(xs) const tickFormat = - isTime && xs.length > 0 - ? makeTimeTickFormat(Math.min(...xs), Math.max(...xs)) - : null + isTime && xExtent ? makeTimeTickFormat(xExtent[0], xExtent[1]) : null return { tooltip: { @@ -378,7 +403,7 @@ export function buildVariableCardOption( */ export function seriesToCsv(s: ChartSeries): string { const esc = (v: string) => - /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v + /[",\n\r]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v const rows = s.points.map((p) => `${esc(String(p.x))},${p.y ?? ''}`) return [`x,${esc(s.label)}`, ...rows].join('\n') } diff --git a/src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx b/src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx index 1be727dfd..c7971fbfd 100644 --- a/src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx +++ b/src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx @@ -30,32 +30,69 @@ export function SeriesChartPanel({ )} {cards.map(({ chartId, state }) => (
- {state.status === 'loading' && ( - <> - -
-
- - )} - {state.status === 'error' && ( - <> - -

- {state.message} -

- - )} - {state.status === 'ready' && ( - - )} + + {state.status === 'loading' && ( + <> + +
+
+ + )} + {state.status === 'error' && ( + <> + +

+ {state.message} +

+ + )} + {state.status === 'ready' && ( + + )} +
))}
) } +/** One bad payload must cost its own card, not the panel — a render throw + * here would otherwise unmount the whole adapter root. A fresh CardState + * (any new bus event for this chartId) retries the render. */ +export class CardErrorBoundary extends React.Component< + { resetOn: unknown; children: React.ReactNode }, + { error: Error | null } +> { + state: { error: Error | null } = { error: null } + + static getDerivedStateFromError(error: Error) { + return { error } + } + + componentDidUpdate(prevProps: { resetOn: unknown }) { + if (prevProps.resetOn !== this.props.resetOn && this.state.error) + this.setState({ error: null }) + } + + render() { + if (this.state.error) + return ( +

+ Could not render this chart. +

+ ) + return this.props.children + } +} + function CardHeader({ title, onResetZoom, @@ -161,6 +198,21 @@ function ReadyCard({ ) } +/** The palette's [token, fallback] pairs, the single source both color paths + * derive from: themeFromCss resolves them for the chart canvas, PALETTE_VARS + * turns the same list into var() strings for DOM styles — so a variable's + * footer dot and its chart line always land on the same theme color. */ +const PALETTE_TOKENS: Array<[string, string]> = [ + ['--theme-color-primary', '#005ea2'], + ['--theme-color-accent-cool', '#00bde3'], + ['--theme-color-accent-warm', '#fa9441'], + ['--theme-color-secondary', '#d83933'], +] + +const PALETTE_VARS = PALETTE_TOKENS.map( + ([token, fallback]) => `var(${token}, ${fallback})`, +) + /** Theme colors come from the page's --theme-* custom properties so the chart * follows the active USWDS theme bundle; fallbacks are the USWDS defaults. */ function themeFromCss(el: HTMLElement): ChartTheme { @@ -168,15 +220,9 @@ function themeFromCss(el: HTMLElement): ChartTheme { const v = (name: string, fallback: string) => styles.getPropertyValue(name).trim() || fallback return { - palette: [ - v('--theme-color-primary', '#005ea2'), - v('--theme-color-accent-cool', '#00bde3'), - v('--theme-color-accent-warm', '#fa9441'), - v('--theme-color-secondary', '#d83933'), - ], + palette: PALETTE_TOKENS.map(([token, fallback]) => v(token, fallback)), gridColor: v('--theme-color-base-lighter', '#dfe1e2'), textColor: v('--theme-color-base-dark', '#565c65'), - surface: v('--theme-color-white', '#ffffff'), } } @@ -205,6 +251,8 @@ function SeriesCanvas({ // Single-select legend is the variable picker; rebuild so the preview // strip recolors to the picked variable, keeping the zoom range. // Re-clicking the active chip would empty the chart — keep it on. + // The zoom carries over as slider percentages, not an absolute time + // window — identical for same-grid variables, relative otherwise. chart.on('legendselectchanged', (e) => { const event = e as { name: string @@ -238,16 +286,6 @@ function SeriesCanvas({ return
} -/** CSS vars (with the same fallbacks) in the order themeFromCss builds its - * palette, so a variable's footer dot and its chart line resolve to the - * same theme color even when a theme omits a token. */ -const PALETTE_VARS = [ - 'var(--theme-color-primary, #005ea2)', - 'var(--theme-color-accent-cool, #00bde3)', - 'var(--theme-color-accent-warm, #fa9441)', - 'var(--theme-color-secondary, #d83933)', -] - function downloadCsv(s: ChartSeries) { const blob = new Blob([seriesToCsv(s)], { type: 'text/csv;charset=utf-8' }) const url = URL.createObjectURL(blob) @@ -330,5 +368,3 @@ function VariableCard({ ) } - -export default SeriesChartPanel diff --git a/src/essence/Tools/SeriesChart/lib/index.ts b/src/essence/Tools/SeriesChart/lib/index.ts index c11b3362e..5ae681971 100644 --- a/src/essence/Tools/SeriesChart/lib/index.ts +++ b/src/essence/Tools/SeriesChart/lib/index.ts @@ -1,5 +1,5 @@ export { SeriesChartPanel } from './components/SeriesChartPanel' -export type { CardState, ChartCard, ChartTheme } from './types' +export type { CardState, ChartCard, ChartLayout, ChartTheme } from './types' // Side-effect import of compiled styles import './styles/index.scss' diff --git a/src/essence/Tools/SeriesChart/lib/types.ts b/src/essence/Tools/SeriesChart/lib/types.ts index 5ed376257..923a86201 100644 --- a/src/essence/Tools/SeriesChart/lib/types.ts +++ b/src/essence/Tools/SeriesChart/lib/types.ts @@ -4,7 +4,8 @@ import type { ChartSeriesPayload } from '../../_shared/types/chartSeries' /** How a card renders its variables: one chart with a legend picker, or - * stacked small-multiple rows (one per variable) with linked x-axes. */ + * stacked small-multiple rows (one per variable), each zooming + * independently. */ export type ChartLayout = 'single' | 'stacked' export type CardState = @@ -23,6 +24,4 @@ export interface ChartTheme { palette: string[] gridColor: string textColor: string - /** Card/panel surface color (--theme-color-white). */ - surface: string } diff --git a/tests/unit/seriesChartData.spec.js b/tests/unit/seriesChartData.spec.js index 105196c7f..c878c28f6 100644 --- a/tests/unit/seriesChartData.spec.js +++ b/tests/unit/seriesChartData.spec.js @@ -14,7 +14,6 @@ const THEME = { palette: ['#111111', '#222222'], gridColor: '#dddddd', textColor: '#555555', - surface: '#eeeeee', } const DAY = 24 * 60 * 60 * 1000 @@ -54,6 +53,12 @@ describe('seriesChart chartData', () => { { x: Date.parse('2017-12-31T00:00:00Z'), y: 1 }, ]) }) + + test('reads space-separated timezone-less datetimes as UTC too', () => { + expect(toTimePoints([{ x: '2017-12-31 06:30:00', y: 1 }])).toEqual([ + { x: Date.parse('2017-12-31T06:30:00Z'), y: 1 }, + ]) + }) }) describe('toLinearPoints', () => { @@ -251,6 +256,48 @@ describe('seriesChart chartData', () => { expect(opt.legend.selected).toEqual({ S1: false, S2: true }) }) + test('six-figure point counts build without arg-spread overflow', () => { + const points = Array.from({ length: 200000 }, (_, i) => ({ + x: i * 60000, + y: i % 100, + })) + const opt = buildChartOption( + payloadWith([{ id: 'big', label: 'Big', points }]), + THEME, + ) + expect(opt.series[0].data).toHaveLength(200000) + expect(typeof opt.xAxis.axisLabel.formatter).toBe('function') + }) + + test('tick granularity follows the visible variable, not the union', () => { + const hourly = series({ + points: [ + { x: '2026-03-04T00:00:00Z', y: 1 }, + { x: '2026-03-05T00:00:00Z', y: 2 }, + ], + }) + const multiYear = series({ + id: 's2', + label: 'S2', + points: [ + { x: '2020-01-01T00:00:00Z', y: 1 }, + { x: '2026-01-01T00:00:00Z', y: 2 }, + ], + }) + const t = Date.parse('2026-03-04T14:30:00Z') + const shortVisible = buildChartOption( + payloadWith([hourly, multiYear]), + THEME, + ) + expect(shortVisible.xAxis.axisLabel.formatter(t)).toBe('14:30') + const longVisible = buildChartOption( + payloadWith([hourly, multiYear]), + THEME, + 'S2', + ) + expect(longVisible.xAxis.axisLabel.formatter(t)).toBe('Mar 2026') + }) + test('no in-canvas toolbox: reset zoom lives in the card header', () => { const opt = buildChartOption(payloadWith([series()]), THEME) expect(opt.toolbox).toBeUndefined() @@ -270,21 +317,16 @@ describe('seriesChart chartData', () => { describe('buildVariableCardOption', () => { const series = (over = {}) => ({ - id: over.id ?? 's1', - label: over.label ?? 'S1', + id: 's1', + label: 'S1', points: [ { x: '2026-01-01T00:00:00Z', y: 1 }, { x: '2026-01-02T00:00:00Z', y: 2 }, ], ...over, }) - const card = (s, over = {}) => - buildVariableCardOption( - s, - { ...payloadWith([s]), ...over }, - THEME, - over.index ?? 0, - ) + const card = (s, index = 0) => + buildVariableCardOption(s, payloadWith([s]), THEME, index) test('single clean series: no legend, no symbols, no gridlines', () => { const opt = card(series()) @@ -336,6 +378,16 @@ describe('seriesChart chartData', () => { ]) }) + test('six-figure point counts build without arg-spread overflow', () => { + const points = Array.from({ length: 200000 }, (_, i) => ({ + x: i * 60000, + y: i % 100, + })) + const opt = card(series({ points })) + expect(opt.series[0].data).toHaveLength(200000) + expect(typeof opt.xAxis.axisLabel.formatter).toBe('function') + }) + test('category cards use the variable’s own labels', () => { const s = series({ points: [ @@ -383,6 +435,15 @@ describe('seriesChart chartData', () => { '"a,b",1', ]) }) + + test('fields with lone carriage returns are quoted', () => { + const csv = seriesToCsv({ + id: 's', + label: 'a\rb', + points: [{ x: 1, y: 1 }], + }) + expect(csv.split('\n')[0]).toBe('x,"a\rb"') + }) }) }) diff --git a/tests/unit/seriesChartErrorBoundary.spec.js b/tests/unit/seriesChartErrorBoundary.spec.js new file mode 100644 index 000000000..bbe7d0c98 --- /dev/null +++ b/tests/unit/seriesChartErrorBoundary.spec.js @@ -0,0 +1,61 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest' +import React, { act } from 'react' +import { createRoot } from 'react-dom/client' +import { CardErrorBoundary } from '../../src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +function Bomb({ armed }) { + if (armed) throw new Error('boom') + return React.createElement('span', null, 'recovered') +} + +describe('SeriesChart CardErrorBoundary', () => { + let host + let root + let errorSpy + + beforeEach(() => { + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) + // React logs the caught error; keep the test output clean. + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + errorSpy.mockRestore() + }) + + const render = (armed, resetOn) => + act(() => + root.render( + React.createElement( + CardErrorBoundary, + { resetOn }, + React.createElement(Bomb, { armed }), + ), + ), + ) + + test('a throwing card renders the error state instead of unmounting', () => { + render(true, 'state-1') + expect(host.textContent).toContain('Could not render this chart.') + }) + + test('a fresh card state retries the render', () => { + render(true, 'state-1') + expect(host.textContent).toContain('Could not render this chart.') + render(false, 'state-2') + expect(host.textContent).toBe('recovered') + }) + + test('the same card state does not retry', () => { + const state = { status: 'ready' } + render(true, state) + render(false, state) + expect(host.textContent).toContain('Could not render this chart.') + }) +}) From a3c942cbed7fd80eead3989fb26c356c1344254c Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Mon, 24 Aug 2026 10:28:13 -0500 Subject: [PATCH 09/10] SeriesChart: flatten seriesReady and make the contract docs true seriesReady is now flat like its three siblings (SeriesReadyPayload exported; the enveloped form is dropped with a warning, spec-pinned). The guard rejects duplicate series ids/labels that broke the single-select picker. Dual-y-axis promises removed, reserved payload fields marked, layout descriptions corrected, explicitly-empty sources disables all subscriptions. --- .../SeriesChart/MMGISSeriesChartAdapter.tsx | 25 ++-- src/essence/Tools/SeriesChart/README.md | 25 ++-- src/essence/Tools/SeriesChart/config.json | 2 +- .../Tools/_shared/types/chartSeries.ts | 31 +++- tests/unit/chartSeries.spec.js | 20 +++ tests/unit/seriesChartAdapter.spec.js | 136 ++++++++++++++++++ 6 files changed, 212 insertions(+), 27 deletions(-) create mode 100644 tests/unit/seriesChartAdapter.spec.js diff --git a/src/essence/Tools/SeriesChart/MMGISSeriesChartAdapter.tsx b/src/essence/Tools/SeriesChart/MMGISSeriesChartAdapter.tsx index 32f97d299..fdba0b314 100644 --- a/src/essence/Tools/SeriesChart/MMGISSeriesChartAdapter.tsx +++ b/src/essence/Tools/SeriesChart/MMGISSeriesChartAdapter.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react' import { SeriesChartPanel } from './lib' -import type { CardState, ChartLayout } from './lib/types' +import type { CardState, ChartLayout } from './lib' import { mmgisOn, mmgisRequest } from '../_shared/adapters/mmgisAPI' import { useMMGISHandlerReady } from '../_shared/adapters/useMMGISHandlerReady' import { @@ -39,12 +39,16 @@ export function MMGISSeriesChartAdapter() { sources?: unknown layout?: unknown }>('tool:getVars', PLUGIN_ID) - const list = Array.isArray(vars?.sources) - ? (vars?.sources ?? []).filter( - (s): s is string => typeof s === 'string' && s !== '', - ) - : [] - if (list.length > 0) setSources(list) + // A configured array wins even when empty — an explicitly-empty + // list means "listen to nothing"; only an unset config keeps the + // built-in default. + if (Array.isArray(vars?.sources)) { + setSources( + vars.sources.filter( + (s): s is string => typeof s === 'string' && s !== '', + ), + ) + } if (vars?.layout === 'single' || vars?.layout === 'stacked') setLayout(vars.layout) } catch (err) { @@ -75,8 +79,9 @@ export function MMGISSeriesChartAdapter() { })) }), mmgisOn(events.ready, (p) => { - const payload = (p as { payload?: unknown } | null)?.payload - if (!isChartSeriesPayload(payload)) { + // Flat like the other three messages: the event payload + // IS the ChartSeriesPayload, no envelope. + if (!isChartSeriesPayload(p)) { console.warn( `[SeriesChart] dropped malformed seriesReady from '${sourceId}'`, p, @@ -85,7 +90,7 @@ export function MMGISSeriesChartAdapter() { } setCards((prev) => ({ ...prev, - [payload.chartId]: { status: 'ready', payload }, + [p.chartId]: { status: 'ready', payload: p }, })) }), mmgisOn(events.error, (p) => { diff --git a/src/essence/Tools/SeriesChart/README.md b/src/essence/Tools/SeriesChart/README.md index 4fd09a971..4b998fbff 100644 --- a/src/essence/Tools/SeriesChart/README.md +++ b/src/essence/Tools/SeriesChart/README.md @@ -10,22 +10,29 @@ Defined in [`_shared/types/chartSeries.ts`](../_shared/types/chartSeries.ts). A fetcher plugin with id `` emits (names via `seriesEvents('')`): - `plugin::seriesLoading` `{ chartId, title? }` → card shows a spinner -- `plugin::seriesReady` `{ payload: ChartSeriesPayload }` → card renders the chart +- `plugin::seriesReady` `ChartSeriesPayload` → card renders the chart - `plugin::seriesError` `{ chartId, message }` → card shows the message - `plugin::seriesCleared` `{ chartId }` → card is removed +All four messages are flat, with `chartId` at the top level — `seriesReady`'s +payload is the `ChartSeriesPayload` itself, not wrapped in an envelope. + One card per `chartId`; a new payload with the same `chartId` replaces the previous chart. Malformed payloads are dropped with a console warning -(`isChartSeriesPayload` guard) — they never crash the panel. +(`isChartSeriesPayload` guard) — they never crash the panel. Series `id`s +and `label`s must be unique within a payload; duplicates count as malformed +(the label is what the legend picker, footer, and CSV key on). Payload capabilities: multiple series per chart, `time`/`linear`/`category` x-axes, `y: null` gaps (not interpolated), per-series `line`/`area`/`bar` -style and color, and per-series `unit` — exactly two distinct units split -onto left/right y-axes. +style and color, and per-series `unit`, shown in the card footer chip. One +variable renders at a time — mixed-unit payloads work by picking (single +layout) or stacking (stacked layout), never a dual y-axis. The payload's +`subtitle`, `yLabel`, and `meta` fields are reserved: accepted, not yet +rendered. -Time axes render on a linear epoch-ms scale with UTC tick/tooltip formatting -(no Chart.js date-adapter dependency); timezone-less ISO datetimes are read -as UTC. +Time axes render on a linear epoch-ms scale with UTC tick/tooltip +formatting; timezone-less ISO datetimes are read as UTC. ## Configuration @@ -49,14 +56,14 @@ axis), capped at ~1.5 cards tall with the rest scrolling inside the card. ## Smoke test (devtools console) ```js -window.mmgisAPI.emit('plugin:fetch-timeseries:seriesReady', { payload: { +window.mmgisAPI.emit('plugin:fetch-timeseries:seriesReady', { chartId: 'demo', title: 'Station 42', xType: 'time', series: [{ id: 'no2', label: 'NO₂', points: [ { x: '2026-01-01T00:00:00Z', y: 1.2 }, { x: '2026-02-01T00:00:00Z', y: 2.4 }, { x: '2026-03-01T00:00:00Z', y: 1.8 }, ] }], -} }) +}) ``` See [FetchTimeseries](../FetchTimeseries/README.md) for a working diff --git a/src/essence/Tools/SeriesChart/config.json b/src/essence/Tools/SeriesChart/config.json index 692b44933..65ba43221 100644 --- a/src/essence/Tools/SeriesChart/config.json +++ b/src/essence/Tools/SeriesChart/config.json @@ -45,7 +45,7 @@ { "field": "variables.layout", "name": "Chart layout", - "description": "single: all variables share one chart and the legend picks which is visible. stacked: one card per variable with its own preview zoom strip and a Download CSV link.", + "description": "single: all variables share one chart, the legend picks the visible one, and the card header has a reset-zoom button. stacked: one card per variable, each zooming independently. Both layouts have the preview zoom strip, the footer chip, and a Download CSV link.", "type": "dropdown", "width": 4, "options": ["single", "stacked"] diff --git a/src/essence/Tools/_shared/types/chartSeries.ts b/src/essence/Tools/_shared/types/chartSeries.ts index 19b42bf9b..c78784c5d 100644 --- a/src/essence/Tools/_shared/types/chartSeries.ts +++ b/src/essence/Tools/_shared/types/chartSeries.ts @@ -18,12 +18,13 @@ export interface ChartSeries { style?: 'line' | 'area' | 'bar' /** CSS color; omitted → chart theme palette. */ color?: string - /** Measurement unit (e.g. "Parts per million"). When a payload carries - * exactly two distinct units, the chart puts the second on a right-hand - * y-axis so mixed-magnitude series stay readable. */ + /** Measurement unit (e.g. "Parts per million"), shown in the card + * footer next to the variable name. */ unit?: string } +/** Reserved provenance block: accepted and carried on the payload, but not + * yet read by the chart. */ export interface ChartSeriesMeta { /** Plugin id of the emitter, e.g. 'fetch-timeseries'. */ sourcePlugin?: string @@ -36,8 +37,11 @@ export interface ChartSeriesPayload { * chart; distinct chartIds render as separate cards. */ chartId: string title: string + /** Reserved: accepted but not yet rendered. */ subtitle?: string xType: 'time' | 'linear' | 'category' + /** Reserved: accepted but not yet rendered — the card footer chip, not a + * y-axis label, names the visible variable and unit. */ yLabel?: string series: ChartSeries[] meta?: ChartSeriesMeta @@ -59,8 +63,11 @@ export interface SeriesClearedPayload { chartId: string } -/** Event-name suffixes, exported for docs/tests. */ -export const SERIES_EVENT_SUFFIXES = { +/** `seriesReady` is flat like its three siblings: the event payload IS the + * ChartSeriesPayload (chartId at the top level) — there is no envelope. */ +export type SeriesReadyPayload = ChartSeriesPayload + +const SERIES_EVENT_SUFFIXES = { loading: 'seriesLoading', ready: 'seriesReady', error: 'seriesError', @@ -89,7 +96,9 @@ export function seriesEvents(pluginId: string): SeriesEventNames { } } -function isRecord(value: unknown): value is Record { +/** Shared plain-object guard — fetcher-side response mapping reuses it, so + * it lives here rather than being redefined per plugin. */ +export function isRecord(value: unknown): value is Record { return value != null && typeof value === 'object' && !Array.isArray(value) } @@ -118,7 +127,7 @@ function isChartSeries(value: unknown): value is ChartSeries { */ export function isChartSeriesPayload(value: unknown): value is ChartSeriesPayload { if (!isRecord(value)) return false - return ( + const shapeOk = typeof value.chartId === 'string' && value.chartId !== '' && typeof value.title === 'string' && @@ -128,5 +137,13 @@ export function isChartSeriesPayload(value: unknown): value is ChartSeriesPayloa Array.isArray(value.series) && value.series.length > 0 && value.series.every(isChartSeries) + 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, + // so they're rejected at the boundary like any other malformed payload. + const series = value.series as ChartSeries[] + return ( + new Set(series.map((s) => s.id)).size === series.length && + new Set(series.map((s) => s.label)).size === series.length ) } diff --git a/tests/unit/chartSeries.spec.js b/tests/unit/chartSeries.spec.js index 93d409ef9..827765c7e 100644 --- a/tests/unit/chartSeries.spec.js +++ b/tests/unit/chartSeries.spec.js @@ -84,6 +84,26 @@ describe('chartSeries contract', () => { ], }, ], + [ + 'duplicate series ids', + { + ...validPayload, + series: [ + { id: 'a', label: 'A', points: [{ x: 1, y: 1 }] }, + { id: 'a', label: 'B', points: [{ x: 1, y: 2 }] }, + ], + }, + ], + [ + 'duplicate series labels', + { + ...validPayload, + series: [ + { id: 'a', label: 'Same', points: [{ x: 1, y: 1 }] }, + { id: 'b', label: 'Same', points: [{ x: 1, y: 2 }] }, + ], + }, + ], ])('rejects %s', (_name, payload) => { expect(isChartSeriesPayload(payload)).toBe(false) }) diff --git a/tests/unit/seriesChartAdapter.spec.js b/tests/unit/seriesChartAdapter.spec.js new file mode 100644 index 000000000..dedb74583 --- /dev/null +++ b/tests/unit/seriesChartAdapter.spec.js @@ -0,0 +1,136 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest' +import React, { act } from 'react' +import { createRoot } from 'react-dom/client' +import { MMGISSeriesChartAdapter } from '../../src/essence/Tools/SeriesChart/MMGISSeriesChartAdapter' + +// The ready card mounts a real chart; neither echarts' canvas nor +// ResizeObserver exist under jsdom, and neither is what's under test. +vi.mock('echarts', () => ({ + init: () => ({ + setOption() {}, + on() {}, + dispose() {}, + resize() {}, + getOption() { + return {} + }, + dispatchAction() {}, + }), +})) + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +const READY = 'plugin:fetch-timeseries:seriesReady' +const LOADING = 'plugin:fetch-timeseries:seriesLoading' +const ERROR = 'plugin:fetch-timeseries:seriesError' +const CLEARED = 'plugin:fetch-timeseries:seriesCleared' + +const validPayload = () => ({ + chartId: 'c1', + title: 'Station 42', + xType: 'time', + series: [ + { + id: 'no2', + label: 'NO₂', + points: [{ x: '2026-01-01T00:00:00Z', y: 1.5 }], + }, + ], +}) + +function makeBus() { + const handlers = {} + return { + on(event, h) { + ;(handlers[event] ||= []).push(h) + return () => { + handlers[event] = handlers[event].filter((x) => x !== h) + } + }, + emit(event, payload) { + ;(handlers[event] || []).forEach((h) => h(payload)) + }, + request: async () => null, + } +} + +describe('MMGISSeriesChartAdapter', () => { + let host + let root + let bus + + beforeEach(() => { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } + bus = makeBus() + window.mmgisAPI = bus + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) + act(() => root.render(React.createElement(MMGISSeriesChartAdapter))) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + delete window.mmgisAPI + vi.restoreAllMocks() + }) + + test('idle panel shows the placeholder', () => { + expect(host.textContent).toContain('Select something on the map') + }) + + test('seriesLoading renders a spinner card with the title', () => { + act(() => bus.emit(LOADING, { chartId: 'c1', title: 'Station 42' })) + expect(host.textContent).toContain('Station 42') + expect(host.textContent).toContain('Fetching data…') + }) + + test('a flat seriesReady payload renders the chart card', () => { + act(() => bus.emit(READY, validPayload())) + expect(host.textContent).toContain('Station 42') + expect(host.textContent).toContain('NO₂') + expect(host.textContent).not.toContain('Fetching data…') + }) + + test('an enveloped seriesReady is dropped with a warning', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + act(() => bus.emit(LOADING, { chartId: 'c1', title: 'Station 42' })) + act(() => bus.emit(READY, { payload: validPayload() })) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('malformed seriesReady'), + expect.anything(), + ) + expect(host.textContent).toContain('Fetching data…') + }) + + test('seriesError renders the message', () => { + act(() => bus.emit(ERROR, { chartId: 'c1', message: 'Upstream 500' })) + expect(host.textContent).toContain('Upstream 500') + }) + + test('seriesCleared removes the card', () => { + act(() => bus.emit(READY, validPayload())) + act(() => bus.emit(CLEARED, { chartId: 'c1' })) + expect(host.textContent).toContain('Select something on the map') + }) + + test('an explicitly empty sources config disables all subscriptions', async () => { + act(() => root.unmount()) + bus = makeBus() + bus.hasHandler = () => true + bus.request = async (name) => + name === 'tool:getVars' ? { sources: [] } : null + window.mmgisAPI = bus + root = createRoot(host) + await act(async () => + root.render(React.createElement(MMGISSeriesChartAdapter)), + ) + act(() => bus.emit(READY, validPayload())) + expect(host.textContent).toContain('Select something on the map') + }) +}) From 3f87a688fcd2a735aec8911a4b144578a563c206 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Mon, 24 Aug 2026 10:28:25 -0500 Subject: [PATCH 10/10] Enable SeriesChart in the full-demo mission The tool shipped on:false and unassigned to any panel, so the loader never mounted it. Now on and in the float-analysis panel next to Chart. FetchTimeseries' enable lands with its own PR. --- mission-profiles/full-demo.json | 4 +++- mission-profiles/generated/full-demo-mission.json | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/mission-profiles/full-demo.json b/mission-profiles/full-demo.json index 8f02aae94..9efaf4163 100644 --- a/mission-profiles/full-demo.json +++ b/mission-profiles/full-demo.json @@ -25,6 +25,7 @@ "Card", "Chart", "FetchStats", + "SeriesChart", "ShareExport" ], "overrides": { @@ -174,7 +175,8 @@ }, "panelTools": [ "AOI", - "Chart" + "Chart", + "SeriesChart" ], "id": "float-analysis", "dimensions": { diff --git a/mission-profiles/generated/full-demo-mission.json b/mission-profiles/generated/full-demo-mission.json index 283a604a3..39273d022 100644 --- a/mission-profiles/generated/full-demo-mission.json +++ b/mission-profiles/generated/full-demo-mission.json @@ -127,7 +127,8 @@ }, "panelTools": [ "AOI", - "Chart" + "Chart", + "SeriesChart" ], "id": "float-analysis", "dimensions": { @@ -277,7 +278,7 @@ "name": "SeriesChart", "icon": "chart-line", "js": "SeriesChartTool", - "on": false, + "on": true, "variables": { "sources": [ "fetch-timeseries"