feat(editor): chart behaviour over time with uPlot
Draw the simulation's Stock trajectories with uPlot (ADR-0005): a real time axis, hover-to-read values, and a playhead-marker hook the canvas animation drives. SimChart owns the imperative uPlot lifecycle behind a small prop API.
This commit is contained in:
@@ -22,6 +22,8 @@ Deployed at https://meadows.apoena.dev
|
|||||||
- [ADR-0004](./docs/adr/0004-rate-rules-not-formulas.md) — simulation behaviour
|
- [ADR-0004](./docs/adr/0004-rate-rules-not-formulas.md) — simulation behaviour
|
||||||
comes from a small fixed vocabulary of rules over Information Links, not
|
comes from a small fixed vocabulary of rules over Information Links, not
|
||||||
free-form formulas.
|
free-form formulas.
|
||||||
|
- [ADR-0005](./docs/adr/0005-uplot-behaviour-chart.md) — uPlot draws the
|
||||||
|
behaviour-over-time chart; a charting dependency that earns its keep.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
|
|||||||
55
docs/adr/0005-uplot-behaviour-chart.md
Normal file
55
docs/adr/0005-uplot-behaviour-chart.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# uPlot draws the behaviour-over-time chart
|
||||||
|
|
||||||
|
_Part of [meadows](../../README.md) · see [DESIGN.md](../../DESIGN.md)._
|
||||||
|
|
||||||
|
The Results panel (ADR-0004's phase 2) traces each Stock's value over time. It
|
||||||
|
began as a **hand-built SVG** — a deliberate lean-substrate choice, no charting
|
||||||
|
dependency. But a polyline is all that substrate could draw: no time axis, no
|
||||||
|
gridlines, and a legend that could only show a Stock's _final_ value, never the
|
||||||
|
value under the cursor. For a tool whose whole point is reading _behaviour over
|
||||||
|
time_, "what is this Stock at t = 40?" is the question you most want to answer by
|
||||||
|
pointing at the curve.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Adopt **uPlot** (`uplot`, ~15 KB gzip) — a small, fast canvas time-series
|
||||||
|
library — for the behaviour-over-time chart. It earns the dependency with the
|
||||||
|
three things the SVG could not give: a real **time axis** with ticks and
|
||||||
|
gridlines, and a live legend that **reads each Stock's value at the hovered
|
||||||
|
time**.
|
||||||
|
|
||||||
|
uPlot is imperative and canvas-based, so it is wrapped in **`SimChart.vue`**,
|
||||||
|
which owns its lifecycle (build on mount, `setData` on recompute, rebuild on a
|
||||||
|
track-set change, `ResizeObserver` for width, destroy on unmount). `ResultsPanel`
|
||||||
|
stays declarative: it assembles plain aligned data (Stocks only — the system's
|
||||||
|
memory, per ADR-0004) and hands it down.
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
- **Keep the hand-built SVG** (zero deps) — rejected: re-implementing axes,
|
||||||
|
tick placement, a hover cursor, and hit-testing _is_ rebuilding a charting
|
||||||
|
library, badly. The lean-substrate note was a code comment, not an ADR; nothing
|
||||||
|
prior is contradicted by spending one dependency where it clearly pays.
|
||||||
|
- **`@gouvfr/dsfr-chart`** (the original prompt) — rejected: it requires the
|
||||||
|
entire DSFR design system (CSS + JS API) and embeds its _own_ Vue and Chart.js,
|
||||||
|
so we would ship Vue twice and a second design language clashing with DaisyUI.
|
||||||
|
It is built for French-government dashboards (region maps, gauges), not generic
|
||||||
|
time series.
|
||||||
|
- **Chart.js / vue-chartjs** — viable and same engine class, but heavier; uPlot
|
||||||
|
is lighter and faster for many series and live-updating data, which is exactly
|
||||||
|
the simulation's shape.
|
||||||
|
- **uPlot (chosen)** — lightest credible option that gives the axis + hover.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- This is the project's **first runtime charting dependency**. The price, as with
|
||||||
|
Vue Flow (ADR-0002), is a thin wrapper layer: `SimChart.vue` translates between
|
||||||
|
reactive props and uPlot's imperative API.
|
||||||
|
- The x scale runs with **`time: false`** — x is _simulation_ time (`start…stop`),
|
||||||
|
not wall-clock, which uPlot would otherwise format as calendar dates.
|
||||||
|
- Track colours are read from the **DaisyUI theme variables**. The app ships a
|
||||||
|
single `light` theme, so they are resolved once at build time; a future dark
|
||||||
|
theme would need the plot re-initialised on theme change.
|
||||||
|
- Swapping charting libraries later means rewriting only `SimChart.vue` —
|
||||||
|
`ResultsPanel` passes plain `[times, ...tracks]` aligned data and knows nothing
|
||||||
|
of uPlot.
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"daisyui": "^5.5.23",
|
"daisyui": "^5.5.23",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
"uplot": "^1.6.32",
|
||||||
"vue": "^3.5.34"
|
"vue": "^3.5.34"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -26,6 +26,9 @@ importers:
|
|||||||
pinia:
|
pinia:
|
||||||
specifier: ^3.0.4
|
specifier: ^3.0.4
|
||||||
version: 3.0.4(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))
|
version: 3.0.4(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))
|
||||||
|
uplot:
|
||||||
|
specifier: ^1.6.32
|
||||||
|
version: 1.6.32
|
||||||
vue:
|
vue:
|
||||||
specifier: ^3.5.34
|
specifier: ^3.5.34
|
||||||
version: 3.5.38(typescript@6.0.3)
|
version: 3.5.38(typescript@6.0.3)
|
||||||
@@ -939,6 +942,9 @@ packages:
|
|||||||
undici-types@7.18.2:
|
undici-types@7.18.2:
|
||||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||||
|
|
||||||
|
uplot@1.6.32:
|
||||||
|
resolution: {integrity: sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==}
|
||||||
|
|
||||||
vite@8.0.16:
|
vite@8.0.16:
|
||||||
resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==}
|
resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1709,6 +1715,8 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@7.18.2: {}
|
undici-types@7.18.2: {}
|
||||||
|
|
||||||
|
uplot@1.6.32: {}
|
||||||
|
|
||||||
vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0):
|
vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
|
|||||||
164
src/components/SimChart.vue
Normal file
164
src/components/SimChart.vue
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* uPlot wrapper for the behaviour-over-time plot. uPlot is an imperative canvas
|
||||||
|
* library, so this component owns its lifecycle: build on mount, push new samples
|
||||||
|
* with `setData` when a run recomputes, rebuild when the *set* of tracks changes
|
||||||
|
* (a Stock renamed/added/removed), follow the container width with a
|
||||||
|
* ResizeObserver, and destroy on unmount.
|
||||||
|
*
|
||||||
|
* Two deliberate settings:
|
||||||
|
* - `scales.x.time = false` — x is *simulation* time (0…stop), not wall-clock;
|
||||||
|
* uPlot would otherwise format the axis as calendar dates.
|
||||||
|
* - Colours are read from the DaisyUI theme variables so the chart matches the
|
||||||
|
* rest of the editor. The app ships a single light theme (no `data-theme`
|
||||||
|
* switching), so they are resolved once at build time rather than watched.
|
||||||
|
*/
|
||||||
|
import uPlot from "uplot"
|
||||||
|
import "uplot/dist/uPlot.min.css"
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from "vue"
|
||||||
|
|
||||||
|
/** One plotted track: its legend label and line colour. */
|
||||||
|
export interface ChartSeries {
|
||||||
|
label: string
|
||||||
|
stroke: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** uPlot aligned data: `[times, ...one value track per series]`. */
|
||||||
|
data: uPlot.AlignedData
|
||||||
|
/** Track metadata, in the same order as `data[1…]`. */
|
||||||
|
series: ChartSeries[]
|
||||||
|
/** Canvas height in CSS pixels (the legend sits below it). */
|
||||||
|
height?: number
|
||||||
|
/** Simulation time of the playhead, drawn as a vertical line (null = none). */
|
||||||
|
marker?: number | null
|
||||||
|
}>(),
|
||||||
|
{ height: 160, marker: null },
|
||||||
|
)
|
||||||
|
|
||||||
|
const root = ref<HTMLDivElement>()
|
||||||
|
let plot: uPlot | undefined
|
||||||
|
let observer: ResizeObserver | undefined
|
||||||
|
/** Signature of the current plot's tracks; a change means the shape changed. */
|
||||||
|
let builtSig = ""
|
||||||
|
|
||||||
|
/** Compact axis/legend numbers (7039.99 → "7040", 0.42 → "0.42", idle → "--"). */
|
||||||
|
function fmt(value: number | null): string {
|
||||||
|
if (value === null) return "--"
|
||||||
|
return Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A DaisyUI theme colour, with a light-theme fallback if the var is unset. */
|
||||||
|
function themeColor(name: string, fallback: string): string {
|
||||||
|
const value = root.value ? getComputedStyle(root.value).getPropertyValue(name).trim() : ""
|
||||||
|
return value || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackSig(): string {
|
||||||
|
return props.series.map((s) => `${s.label}|${s.stroke}`).join(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
function options(width: number): uPlot.Options {
|
||||||
|
// Follow the app's --font-sans (resolved on the element) rather than naming a
|
||||||
|
// family here, so the chart tracks the theme font without a second source.
|
||||||
|
const family = root.value
|
||||||
|
? getComputedStyle(root.value).fontFamily
|
||||||
|
: "ui-sans-serif, system-ui, sans-serif"
|
||||||
|
const axis: uPlot.Axis = {
|
||||||
|
stroke: themeColor("--color-base-content", "#1f2937"),
|
||||||
|
grid: { stroke: themeColor("--color-base-300", "#e5e7eb"), width: 1 },
|
||||||
|
ticks: { stroke: themeColor("--color-base-300", "#e5e7eb"), width: 1 },
|
||||||
|
font: `11px ${family}`,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height: props.height,
|
||||||
|
cursor: { y: false },
|
||||||
|
scales: { x: { time: false } },
|
||||||
|
// Playhead: a vertical line at the current simulation time, so the chart and
|
||||||
|
// the animating canvas read off the same instant. Drawn after the series, and
|
||||||
|
// redrawn whenever `marker` changes (see the watch below).
|
||||||
|
hooks: {
|
||||||
|
draw: [
|
||||||
|
(u: uPlot): void => {
|
||||||
|
if (props.marker === null) return
|
||||||
|
const x = Math.round(u.valToPos(props.marker, "x", true))
|
||||||
|
const { ctx } = u
|
||||||
|
ctx.save()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.strokeStyle = themeColor("--color-primary", "#2563eb")
|
||||||
|
ctx.moveTo(x, u.bbox.top)
|
||||||
|
ctx.lineTo(x, u.bbox.top + u.bbox.height)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.restore()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{},
|
||||||
|
...props.series.map(
|
||||||
|
(s): uPlot.Series => ({
|
||||||
|
label: s.label,
|
||||||
|
stroke: s.stroke,
|
||||||
|
width: 2,
|
||||||
|
points: { show: false },
|
||||||
|
value: (_self, raw) => fmt(raw),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
axes: [axis, { ...axis, size: 52 }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(): void {
|
||||||
|
if (!root.value) return
|
||||||
|
plot?.destroy()
|
||||||
|
builtSig = trackSig()
|
||||||
|
plot = new uPlot(options(root.value.clientWidth), props.data, root.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cheap on every recompute; only a shape change forces a full rebuild. */
|
||||||
|
function render(): void {
|
||||||
|
if (!plot || trackSig() !== builtSig) build()
|
||||||
|
else plot.setData(props.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
build()
|
||||||
|
observer = new ResizeObserver(() => {
|
||||||
|
if (plot && root.value) plot.setSize({ width: root.value.clientWidth, height: props.height })
|
||||||
|
})
|
||||||
|
if (root.value) observer.observe(root.value)
|
||||||
|
// The canvas paints before the web font loads and won't repaint on its own, so
|
||||||
|
// axis labels would stick to the fallback. Redraw once the font is ready.
|
||||||
|
document.fonts?.ready.then(() => plot?.redraw())
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
observer?.disconnect()
|
||||||
|
plot?.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.data, render)
|
||||||
|
// The playhead moves without the data changing, so nudge a redraw on its own.
|
||||||
|
watch(
|
||||||
|
() => props.marker,
|
||||||
|
() => plot?.redraw(),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="root" class="w-full" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.u-legend) {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-base-content);
|
||||||
|
}
|
||||||
|
:deep(.u-legend .u-value) {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user