diff --git a/mission-profiles/full-demo.json b/mission-profiles/full-demo.json index bee6b2d38..4705708dd 100644 --- a/mission-profiles/full-demo.json +++ b/mission-profiles/full-demo.json @@ -25,6 +25,7 @@ "Card", "Chart", "FetchStats", + "SeriesChart", "ShareExport", "LayerFilterThemes", "LayerFilter" @@ -226,7 +227,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 73838da09..9992b8803 100644 --- a/mission-profiles/generated/full-demo-mission.json +++ b/mission-profiles/generated/full-demo-mission.json @@ -177,7 +177,8 @@ }, "panelTools": [ "AOI", - "Chart" + "Chart", + "SeriesChart" ], "id": "float-analysis", "dimensions": { @@ -323,6 +324,30 @@ "on": true, "variables": {} }, + { + "name": "SeriesChart", + "icon": "chart-line", + "js": "SeriesChartTool", + "on": true, + "variables": { + "sources": [ + "fetch-timeseries" + ], + "layout": "single" + }, + "metadata": { + "icon": "chart-line", + "requiredOrientation": "vertical", + "compatiblePositions": [ + "left", + "right" + ], + "preferredPosition": "right", + "modernLayoutSupport": true, + "width": 400, + "height": 0 + } + }, { "name": "FetchStats", "js": "FetchStatsTool", diff --git a/src/essence/Tools/SeriesChart/MMGISSeriesChartAdapter.tsx b/src/essence/Tools/SeriesChart/MMGISSeriesChartAdapter.tsx new file mode 100644 index 000000000..fdba0b314 --- /dev/null +++ b/src/essence/Tools/SeriesChart/MMGISSeriesChartAdapter.tsx @@ -0,0 +1,138 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { SeriesChartPanel } from './lib' +import type { CardState, ChartLayout } from './lib' +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 [layout, setLayout] = useState('single') + const [cards, setCards] = useState>({}) + + const refresh = useCallback(async () => { + try { + const vars = await mmgisRequest<{ + sources?: unknown + layout?: unknown + }>('tool:getVars', PLUGIN_ID) + // 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) { + 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) => { + // 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, + ) + return + } + setCards((prev) => ({ + ...prev, + [p.chartId]: { status: 'ready', payload: p }, + })) + }), + 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..4b998fbff --- /dev/null +++ b/src/essence/Tools/SeriesChart/README.md @@ -0,0 +1,70 @@ +# 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` `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. 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`, 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; 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"] } +``` + +`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) + +```js +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 +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..42df5bd43 --- /dev/null +++ b/src/essence/Tools/SeriesChart/SeriesChartTool.tsx @@ -0,0 +1,43 @@ +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 + } + if (_root) { + _root.unmount() + _root = null + } + _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..65ba43221 --- /dev/null +++ b/src/essence/Tools/SeriesChart/config.json @@ -0,0 +1,57 @@ +{ + "defaults": { + "variables": { + "sources": ["fetch-timeseries"], + "layout": "single" + } + }, + "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": 8, + "height": "120px" + }, + { + "field": "variables.layout", + "name": "Chart layout", + "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/SeriesChart/lib/chartData.ts b/src/essence/Tools/SeriesChart/lib/chartData.ts new file mode 100644 index 000000000..0554410d7 --- /dev/null +++ b/src/essence/Tools/SeriesChart/lib/chartData.ts @@ -0,0 +1,409 @@ +// 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. + * 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 — `connectNulls: false` 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.replace(' ', 'T')}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, + showSymbol: false, + connectNulls: false, + } +} + +/** 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, + 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) } + : {}), + } +} + +/** 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 + 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 + * by rendering it. + * + * One variable is visible at a time (`visibleLabel`, default: the first + * 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, + 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 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, + splitNumber: 2, + axisLabel: { color: theme.textColor }, + splitLine: { show: false }, + }, + ] + + 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, + type: 'scroll' as const, + selectedMode: 'single' as const, + top: 0, + left: 8, + right: 8, + textStyle: { color: theme.textColor }, + selected, + }, + // 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 }, + previewSlider(theme, activeColor, tickFormat), + ], + yAxis, + } + + if (series === null) { + 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], + })), + } + } + + return { + ...common, + tooltip: { + trigger: 'axis' as const, + axisPointer: { type: 'cross' as const, label: { show: false } }, + ...(isTime ? { formatter: timeTooltipFormatter } : {}), + }, + 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, + } +} + +/** + * 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 buildVariableCardOption( + s: ChartSeries, + payload: ChartSeriesPayload, + theme: ChartTheme, + index: number, +): Record { + const isTime = payload.xType === 'time' + const isCategory = payload.xType === 'category' + const color = s.color || theme.palette[index % theme.palette.length] + + 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<[number, number | null]>).map((d) => d[0]) + const xExtent = extentOf(xs) + const tickFormat = + isTime && xExtent ? makeTimeTickFormat(xExtent[0], xExtent[1]) : null + + 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 } + : { + type: 'value' as const, + min: 'dataMin' as const, + max: 'dataMax' as const, + splitLine: { show: false }, + }), + axisLabel: { + color: theme.textColor, + hideOverlap: true, + ...(tickFormat + ? { formatter: (v: number) => tickFormat(v) } + : {}), + }, + }, + yAxis: { + type: 'value' as const, + scale: true, + // 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: { show: false }, + }, + series: [ + { + ...seriesBase(s, index, theme), + data, + }, + ], + dataZoom: [ + { type: 'inside' as const }, + previewSlider(theme, color, tickFormat), + ], + } +} + +/** + * 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\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 new file mode 100644 index 000000000..c7971fbfd --- /dev/null +++ b/src/essence/Tools/SeriesChart/lib/components/SeriesChartPanel.tsx @@ -0,0 +1,370 @@ +import React, { useEffect, useRef } from 'react' +import * as echarts from 'echarts' +import type { + ChartSeries, + ChartSeriesPayload, +} from '../../../_shared/types/chartSeries' +import type { ChartCard, ChartLayout, ChartTheme } from '../types' +import { + buildChartOption, + buildVariableCardOption, + seriesToCsv, +} from '../chartData' + +export interface SeriesChartPanelProps { + cards: ChartCard[] + layout?: ChartLayout +} + +/** Presentational panel: one card per chartId; placeholder when idle. */ +export function SeriesChartPanel({ + cards, + layout = 'single', +}: 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' && ( + + )} +
+
+ ))} +
+ ) +} + +/** 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, +}: { + title: string + onResetZoom?: () => void +}) { + return ( +
+
+

{title}

+ {onResetZoom && ( + + )} +
+
+ ) +} + +function ReadyCard({ + payload, + layout, +}: { + payload: ChartSeriesPayload + 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. The + // list is capped so a many-variable payload scrolls inside its card + // instead of swallowing the panel. + return ( + <> + +
+ {payload.series.map((s, i) => ( + + ))} +
+ + ) + } + + const activeIndex = Math.max( + payload.series.findIndex((s) => s.label === activeLabel), + 0, + ) + return ( + <> + + chartRef.current?.dispatchAction({ + type: 'dataZoom', + start: 0, + end: 100, + }) + } + /> + + {payload.series[activeIndex] && ( + + )} + + ) +} + +/** 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 { + const styles = getComputedStyle(el) + const v = (name: string, fallback: string) => + styles.getPropertyValue(name).trim() || fallback + return { + palette: PALETTE_TOKENS.map(([token, fallback]) => v(token, fallback)), + gridColor: v('--theme-color-base-lighter', '#dfe1e2'), + textColor: v('--theme-color-base-dark', '#565c65'), + } +} + +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) + + 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 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 + 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] + 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, onVisibleChange]) + + return
+} + +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) +} + +/** 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, + 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 ( +
+
+ +
+ ) +} diff --git a/src/essence/Tools/SeriesChart/lib/index.ts b/src/essence/Tools/SeriesChart/lib/index.ts new file mode 100644 index 000000000..5ae681971 --- /dev/null +++ b/src/essence/Tools/SeriesChart/lib/index.ts @@ -0,0 +1,5 @@ +export { SeriesChartPanel } from './components/SeriesChartPanel' +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/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..0b5b796d8 --- /dev/null +++ b/src/essence/Tools/SeriesChart/lib/styles/components/series-chart.scss @@ -0,0 +1,167 @@ +.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; +} + +.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); + 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/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..923a86201 --- /dev/null +++ b/src/essence/Tools/SeriesChart/lib/types.ts @@ -0,0 +1,27 @@ +// 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' + +/** How a card renders its variables: one chart with a legend picker, or + * stacked small-multiple rows (one per variable), each zooming + * independently. */ +export type ChartLayout = 'single' | 'stacked' + +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 +} diff --git a/src/essence/Tools/_shared/types/chartSeries.ts b/src/essence/Tools/_shared/types/chartSeries.ts new file mode 100644 index 000000000..c78784c5d --- /dev/null +++ b/src/essence/Tools/_shared/types/chartSeries.ts @@ -0,0 +1,149 @@ +// 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"), 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 + 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 + /** 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 +} + +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 +} + +/** `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', + 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, + } +} + +/** 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) +} + +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 + const shapeOk = + 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) + 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 new file mode 100644 index 000000000..827765c7e --- /dev/null +++ b/tests/unit/chartSeries.spec.js @@ -0,0 +1,111 @@ +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' }] }, + ], + }, + ], + [ + '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') + }) +}) diff --git a/tests/unit/seriesChartData.spec.js b/tests/unit/seriesChartData.spec.js new file mode 100644 index 000000000..c878c28f6 --- /dev/null +++ b/tests/unit/seriesChartData.spec.js @@ -0,0 +1,449 @@ +import { describe, test, expect } from 'vitest' +import { + toTimePoints, + toLinearPoints, + toCategoryData, + makeTimeTickFormat, + formatTooltipTime, + buildChartOption, + buildVariableCardOption, + seriesToCsv, +} from '../../src/essence/Tools/SeriesChart/lib/chartData.ts' + +const THEME = { + palette: ['#111111', '#222222'], + gridColor: '#dddddd', + textColor: '#555555', +} + +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 }, + ]) + }) + + 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', () => { + 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 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.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', () => { + 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) + }) + + test('picking a variable moves the selection', () => { + 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 }) + }) + + 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() + }) + + test('identity lives in the footer: unnamed sparse clean y-axis', () => { + const opt = buildChartOption( + { ...payloadWith([series({ unit: 'ppm' })]), yLabel: 'NO₂' }, + THEME, + ) + 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) + }) + }) + + describe('buildVariableCardOption', () => { + 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, + }) + 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()) + expect(opt.legend).toBeUndefined() + 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(explicit.series[0].itemStyle.color).toBe('#abcdef') + }) + + 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('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: [ + { 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]) + }) + }) + + 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', + ]) + }) + + 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.') + }) +})