Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion mission-profiles/full-demo.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"Card",
"Chart",
"FetchStats",
"SeriesChart",
"ShareExport",
"LayerFilterThemes",
"LayerFilter"
Expand Down Expand Up @@ -226,7 +227,8 @@
},
"panelTools": [
"AOI",
"Chart"
"Chart",
"SeriesChart"
],
"id": "float-analysis",
"dimensions": {
Expand Down
27 changes: 26 additions & 1 deletion mission-profiles/generated/full-demo-mission.json
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,8 @@
},
"panelTools": [
"AOI",
"Chart"
"Chart",
"SeriesChart"
],
"id": "float-analysis",
"dimensions": {
Expand Down Expand Up @@ -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",
Expand Down
138 changes: 138 additions & 0 deletions src/essence/Tools/SeriesChart/MMGISSeriesChartAdapter.tsx
Original file line number Diff line number Diff line change
@@ -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<string[]>(DEFAULT_SOURCES)
const [layout, setLayout] = useState<ChartLayout>('single')
const [cards, setCards] = useState<Record<string, CardState>>({})

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 <SeriesChartPanel cards={cardList} layout={layout} />
}

function titleOf(state: CardState | undefined): string | undefined {
if (!state) return undefined
if (state.status === 'ready') return state.payload.title
return state.title
}
70 changes: 70 additions & 0 deletions src/essence/Tools/SeriesChart/README.md
Original file line number Diff line number Diff line change
@@ -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 `<id>` emits (names via `seriesEvents('<id>')`):

- `plugin:<id>:seriesLoading` `{ chartId, title? }` → card shows a spinner
- `plugin:<id>:seriesReady` `ChartSeriesPayload` → card renders the chart
- `plugin:<id>:seriesError` `{ chartId, message }` → card shows the message
- `plugin:<id>: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.
43 changes: 43 additions & 0 deletions src/essence/Tools/SeriesChart/SeriesChartTool.tsx
Original file line number Diff line number Diff line change
@@ -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(<MMGISSeriesChartAdapter />)
this.made = true
},

destroy: function () {
if (_root) {
_root.unmount()
_root = null
}
this.targetId = null
this.made = false
},

getUrlString: function () {
return ''
},
}

export default SeriesChartTool
57 changes: 57 additions & 0 deletions src/essence/Tools/SeriesChart/config.json
Original file line number Diff line number Diff line change
@@ -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:<source>: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"]
}
]
}
]
}
}
Loading
Loading