Compare commits

...

6 Commits

Author SHA1 Message Date
Julien Calixte
ed6f011e69 feat(editor): reset the playhead when a different model loads 2026-06-20 14:39:06 +02:00
Julien Calixte
1d65383bbf fix(editor): make the samples list scrollable so it clears the panel 2026-06-20 14:39:02 +02:00
Julien Calixte
3fc0e23ec7 style(editor): make the stock fill gauge more vivid 2026-06-20 14:37:55 +02:00
Julien Calixte
69435b1315 feat(editor): animate the canvas in step with the simulation
A simulation store owns one run and one playhead, so the chart and the canvas
read off the same instant. While the panel is open, Stocks show a live value and
a fill gauge, Flows and Converters their current value; a play/pause + scrubber
drives the playhead (a wall-clock-paced clock in usePlayback), and a marker
tracks it on the chart.
2026-06-20 14:35:45 +02:00
Julien Calixte
c361b05ec4 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.
2026-06-20 14:35:35 +02:00
Julien Calixte
0c8f89d14e fix(style): load Sono from the api.fonts.coollabs.io host
Switch the body font to Sono and point the @import at
api.fonts.coollabs.io. The bare fonts.coollabs.io host serves the
marketing homepage (HTML), not CSS, so the @font-face rules never
loaded and the font silently fell back to system-ui.
2026-06-20 14:28:58 +02:00
14 changed files with 534 additions and 78 deletions

View File

@@ -22,6 +22,8 @@ Deployed at https://meadows.apoena.dev
- [ADR-0004](./docs/adr/0004-rate-rules-not-formulas.md) — simulation behaviour
comes from a small fixed vocabulary of rules over Information Links, not
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

View 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.

View File

@@ -19,6 +19,7 @@
"daisyui": "^5.5.23",
"idb": "^8.0.3",
"pinia": "^3.0.4",
"uplot": "^1.6.32",
"vue": "^3.5.34"
},
"devDependencies": {

8
pnpm-lock.yaml generated
View File

@@ -26,6 +26,9 @@ importers:
pinia:
specifier: ^3.0.4
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:
specifier: ^3.5.34
version: 3.5.38(typescript@6.0.3)
@@ -939,6 +942,9 @@ packages:
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
uplot@1.6.32:
resolution: {integrity: sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==}
vite@8.0.16:
resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1709,6 +1715,8 @@ snapshots:
undici-types@7.18.2: {}
uplot@1.6.32: {}
vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0):
dependencies:
lightningcss: 1.32.0

View File

@@ -26,6 +26,7 @@ import {
} from "@vue-flow/core"
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from "vue"
import { useAutosave } from "@/composables/useAutosave"
import { usePlayback } from "@/composables/usePlayback"
import { parseModel, serializeModel } from "@/model/io"
import { project } from "@/model/projection"
import { type Sample, SAMPLES } from "@/model/samples"
@@ -54,6 +55,10 @@ const edges = computed(() => graph.value.edges)
// the Model and recomputes reactively while open.
const showResults = ref(false)
// The playback clock that advances the playhead while a run is playing (mounted
// once here; the simulation store holds the state it drives).
usePlayback()
// Explicit shared id: useVueFlow() runs here in the parent setup, before
// <VueFlow> mounts. Pinning both to the same id guarantees they resolve to one
// store instance, so the event hooks below actually fire.
@@ -354,7 +359,7 @@ onBeforeUnmount(() => {
<button tabindex="0" class="btn btn-ghost btn-sm">Samples</button>
<ul
tabindex="0"
class="dropdown-content menu z-10 mt-1 w-72 gap-1 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
class="dropdown-content menu z-40 mt-1 max-h-80 w-72 flex-nowrap gap-1 overflow-y-auto rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
<li v-for="sample in SAMPLES" :key="sample.title">
<button class="flex flex-col items-start gap-0.5" @click="loadSample(sample)">

View File

@@ -8,17 +8,27 @@
* It reads the source-of-truth Model straight from the store and recomputes
* reactively, so editing a rule and reopening re-runs. When the Model is not yet
* sim-ready, it shows what is missing instead of a plot — the gap list doubles as
* a checklist for bringing a diagram to life. No charting dependency: the plot is
* a hand-built SVG (the same lean-substrate choice as the rest of the editor).
* a checklist for bringing a diagram to life. The plot itself is a uPlot canvas
* (see SimChart) — a small, fast time-series library that earns its keep with a
* real time axis and hover-to-read values the hand-built SVG couldn't give.
*/
import { computed } from "vue"
import { checkSimReady, simulate } from "@/model/simulation"
import type uPlot from "uplot"
import { computed, onBeforeUnmount, onMounted } from "vue"
import SimChart from "./SimChart.vue"
import { checkSimReady } from "@/model/simulation"
import { DEFAULT_SIM_SPEC, type SimSpec, type StockNode } from "@/model/types"
import { useModelStore } from "@/store/model"
import { useSimulationStore } from "@/store/simulation"
defineEmits<{ close: [] }>()
const store = useModelStore()
const sim = useSimulationStore()
// While the panel is open the canvas shows live values; closing it returns the
// canvas to a plain diagram.
onMounted(() => sim.enable())
onBeforeUnmount(() => sim.disable())
const problems = computed(() => checkSimReady(store.model))
@@ -34,48 +44,18 @@ function onSpec(key: keyof SimSpec, event: Event): void {
/** Distinct, legible track colours; cycled if a Model has more Stocks than these. */
const COLORS = ["#2563eb", "#dc2626", "#16a34a", "#d97706", "#7c3aed", "#0891b2"]
// SVG canvas in its own coordinate space; it stretches to the panel width, and
// non-scaling strokes keep lines crisp under that non-uniform scale.
const W = 600
const H = 200
const PAD = 12
// Stocks only — the system's memory, and so the trajectories worth watching. The
// run comes from the simulation store, so the chart and the canvas share one index.
const chart = computed(() => {
if (problems.value.length > 0) return null
const run = simulate(store.model)
const run = sim.run
if (!run) return null
const stocks = store.model.nodes.filter((n): n is StockNode => n.kind === "stock")
const all = stocks.flatMap((s) => run.series.get(s.id) ?? [])
if (all.length === 0 || run.times.length < 2) return null
if (stocks.length === 0 || run.times.length < 2) return null
let min = Math.min(...all)
let max = Math.max(...all)
if (min === max) {
// A flat trajectory: pad so the line lands mid-panel instead of on an edge.
min -= 1
max += 1
}
const n = run.times.length
const x = (i: number) => PAD + (i / (n - 1)) * (W - 2 * PAD)
const y = (v: number) => PAD + (1 - (v - min) / (max - min)) * (H - 2 * PAD)
const lines = stocks.map((s, i) => {
const values = run.series.get(s.id) ?? []
return {
id: s.id,
name: s.name,
color: COLORS[i % COLORS.length],
points: values.map((v, j) => `${x(j).toFixed(1)},${y(v).toFixed(1)}`).join(" "),
last: values[values.length - 1] ?? 0,
}
})
return { lines, min, max, t0: run.times[0], t1: run.times[n - 1], diverged: run.diverged }
const data = [run.times, ...stocks.map((s) => run.series.get(s.id) ?? [])] as uPlot.AlignedData
const series = stocks.map((s, i) => ({ label: s.name, stroke: COLORS[i % COLORS.length] }))
return { data, series, diverged: run.diverged }
})
/** Compact numbers for axis ticks and the legend (e.g. 7039.99 → "7040"). */
function fmt(value: number): string {
return Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(2)
}
</script>
<template>
@@ -132,38 +112,33 @@ function fmt(value: number): string {
smaller factor, or a Balancing loop to rein it in.
</p>
<!-- Sim-ready: the plot, with a legend of final values. -->
<div v-if="chart" class="mt-2 flex items-stretch gap-3">
<svg
:viewBox="`0 0 ${W} ${H}`"
preserveAspectRatio="none"
class="h-40 flex-1 rounded-md bg-base-200/40"
role="img"
aria-label="Stock values over time"
<!-- Sim-ready: the plot, plus a transport that drives the playhead. Hover a
point to read values; press play to watch the canvas animate in step. -->
<template v-if="chart">
<SimChart class="mt-2" :data="chart.data" :series="chart.series" :marker="sim.currentTime" />
<div class="mt-2 flex items-center gap-2">
<button
type="button"
class="btn btn-primary btn-xs w-8"
:aria-label="sim.playing ? 'Pause' : 'Play'"
@click="sim.toggle()"
>
<text :x="PAD" :y="PAD" class="fill-base-content/40 text-[10px]">{{ fmt(chart.max) }}</text>
<text :x="PAD" :y="H - PAD / 2" class="fill-base-content/40 text-[10px]">
{{ fmt(chart.min) }}
</text>
<polyline
v-for="line in chart.lines"
:key="line.id"
:points="line.points"
fill="none"
:stroke="line.color"
stroke-width="2"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
{{ sim.playing ? "❚❚" : "▶" }}
</button>
<input
type="range"
class="range range-xs flex-1"
min="0"
:max="Math.max(0, sim.frameCount - 1)"
:value="sim.playhead"
aria-label="Playhead"
@input="sim.seek(Number(($event.target as HTMLInputElement).value))"
/>
</svg>
<ul class="flex w-44 flex-col gap-1 self-center text-xs">
<li v-for="line in chart.lines" :key="line.id" class="flex items-center gap-2">
<span class="size-2.5 shrink-0 rounded-full" :style="{ backgroundColor: line.color }" />
<span class="truncate">{{ line.name }}</span>
<span class="ml-auto font-mono text-base-content/60">{{ fmt(line.last) }}</span>
</li>
</ul>
<span class="w-14 text-right font-mono text-xs tabular-nums text-base-content/60">
t = {{ sim.currentTime ?? 0 }}
</span>
</div>
</template>
<!-- Not sim-ready: what to fill in to bring it to life. -->
<div v-else class="mt-2 text-sm">

164
src/components/SimChart.vue Normal file
View 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>

View File

@@ -9,6 +9,8 @@ import { computed } from "vue"
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
import type { ConverterNode } from "@/model/types"
import { useSimulationStore } from "@/store/simulation"
import { formatValue } from "./format"
import NodeLabel from "./NodeLabel.vue"
const props = defineProps<NodeProps<NodeData>>()
@@ -16,6 +18,9 @@ const props = defineProps<NodeProps<NodeData>>()
// The projection guarantees a converter-typed node here.
const converter = computed(() => props.data.node as ConverterNode)
const loopRing = useNodeLoopRing(props.id)
const sim = useSimulationStore()
const value = computed(() => sim.valueAt(props.id))
</script>
<template>
@@ -25,6 +30,12 @@ const loopRing = useNodeLoopRing(props.id)
:class="[props.selected ? 'border-primary' : 'border-base-300', loopRing]"
>
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
<span
v-if="value !== null"
class="pointer-events-none absolute inset-0 flex items-center justify-center font-mono text-xs tabular-nums text-base-content/70"
>
{{ formatValue(value) }}
</span>
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
</div>
<NodeLabel :node-id="props.id" :name="converter.name" />

View File

@@ -11,6 +11,8 @@ import { computed } from "vue"
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
import type { FlowNode } from "@/model/types"
import { useSimulationStore } from "@/store/simulation"
import { formatValue } from "./format"
import NodeLabel from "./NodeLabel.vue"
const props = defineProps<NodeProps<NodeData>>()
@@ -18,6 +20,9 @@ const props = defineProps<NodeProps<NodeData>>()
// The projection guarantees a flow-typed node here.
const flow = computed(() => props.data.node as FlowNode)
const loopRing = useNodeLoopRing(props.id)
const sim = useSimulationStore()
const value = computed(() => sim.valueAt(props.id))
</script>
<template>
@@ -40,5 +45,8 @@ const loopRing = useNodeLoopRing(props.id)
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
</div>
<NodeLabel :node-id="props.id" :name="flow.name" />
<div v-if="value !== null" class="font-mono text-xs tabular-nums text-base-content/70">
{{ formatValue(value) }}
</div>
</div>
</template>

View File

@@ -9,6 +9,8 @@ import { computed } from "vue"
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
import type { StockNode } from "@/model/types"
import { useSimulationStore } from "@/store/simulation"
import { formatValue } from "./format"
import NodeLabel from "./NodeLabel.vue"
const props = defineProps<NodeProps<NodeData>>()
@@ -16,15 +18,36 @@ const props = defineProps<NodeProps<NodeData>>()
// The projection guarantees a stock-typed node here.
const stock = computed(() => props.data.node as StockNode)
const loopRing = useNodeLoopRing(props.id)
// Live simulation value at the playhead (null when no run is engaged). A Stock is
// the system's memory, so it also shows a fill gauge — its level over its peak.
const sim = useSimulationStore()
const value = computed(() => sim.valueAt(props.id))
const fill = computed(() => sim.fill(props.id))
</script>
<template>
<div
class="min-w-24 rounded-md border-2 bg-base-100 px-4 py-3 text-center shadow-sm transition-colors"
class="relative min-w-24 rounded-md border-2 bg-base-100 px-4 py-3 text-center shadow-sm transition-colors"
:class="[props.selected ? 'border-primary' : 'border-base-300', loopRing]"
>
<!-- Fill gauge: a bottom-anchored bar, behind the label, inset within the border. -->
<div
v-if="value !== null"
class="pointer-events-none absolute inset-[2px] z-0 flex flex-col justify-end"
>
<div
class="rounded-b bg-primary/40 transition-[height]"
:style="{ height: fill * 100 + '%' }"
/>
</div>
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
<div class="relative z-10">
<NodeLabel :node-id="props.id" :name="stock.name" />
<div v-if="value !== null" class="mt-0.5 font-mono text-xs tabular-nums text-base-content/70">
{{ formatValue(value) }}
</div>
</div>
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
</div>
</template>

View File

@@ -0,0 +1,4 @@
/** Compact value for the live readout on a node (7039.99 → "7040", 0.42 → "0.42"). */
export function formatValue(value: number): string {
return Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(2)
}

View File

@@ -0,0 +1,49 @@
/**
* Playback clock — the wall-clock that advances the simulation playhead while
* `playing`. It lives in a composable (mounted once, in the Editor) because it
* needs `requestAnimationFrame` and component teardown; the simulation store
* stays pure state + actions.
*
* A run plays over a fixed wall-clock window regardless of how many samples it
* has, so a 40-step model and a 480-step one feel the same speed. Elapsed time is
* accumulated and drained in whole frames, so a slow tab catches up rather than
* drifting.
*/
import { onBeforeUnmount, watch } from "vue"
import { useSimulationStore } from "@/store/simulation"
/** Target wall-clock duration for a whole run, in milliseconds. */
const RUN_DURATION_MS = 6000
export function usePlayback(): void {
const sim = useSimulationStore()
let raf = 0
let last = 0
let carry = 0
function frame(now: number): void {
if (!sim.playing) return
carry += now - last
last = now
const msPerFrame = Math.max(16, RUN_DURATION_MS / Math.max(1, sim.frameCount))
while (carry >= msPerFrame && sim.playing) {
carry -= msPerFrame
sim.tick()
}
raf = requestAnimationFrame(frame)
}
watch(
() => sim.playing,
(playing) => {
cancelAnimationFrame(raf)
if (playing) {
last = performance.now()
carry = 0
raf = requestAnimationFrame(frame)
}
},
)
onBeforeUnmount(() => cancelAnimationFrame(raf))
}

151
src/store/simulation.ts Normal file
View File

@@ -0,0 +1,151 @@
/**
* Simulation store (phase 2) — the single source of *playback* truth, the way
* the model store is the single source of structural truth. It owns one run and
* one playhead, so the behaviour chart and the canvas animate off the same index:
* drag the scrubber and every Stock on the canvas jumps to that instant.
*
* The run is derived from the Model (recomputed when the Model changes, like the
* loop detector), so editing a rule and reopening re-runs. Playback itself — the
* clock that advances the playhead — lives in a composable (`usePlayback`), since
* timers need component lifecycle; this store only holds state and pure actions.
*/
import { defineStore } from "pinia"
import { computed, ref, watch } from "vue"
import { checkSimReady, type Run, simulate } from "@/model/simulation"
import { useModelStore } from "./model"
export const useSimulationStore = defineStore("simulation", () => {
const modelStore = useModelStore()
/** A Model is runnable only once every Stock has a value and every rate a rule. */
const ready = computed(() => checkSimReady(modelStore.model).length === 0)
/** The current run, or null when the Model isn't runnable. Recomputes on edit. */
const run = computed<Run | null>(() => {
if (!ready.value) return null
try {
return simulate(modelStore.model)
} catch {
// checkSimReady already rejects algebraic loops; this is belt-and-braces.
return null
}
})
const frameCount = computed(() => run.value?.times.length ?? 0)
/** Whether the canvas should show live values: the panel is open and a run exists. */
const enabled = ref(false)
const playing = ref(false)
const playhead = ref(0)
const active = computed(() => enabled.value && frameCount.value > 0)
const atEnd = computed(() => playhead.value >= frameCount.value - 1)
const currentTime = computed(() => run.value?.times[playhead.value] ?? null)
/** Per-element peak magnitude across the run, for the Stock fill gauge. */
const peaks = computed(() => {
const map = new Map<string, number>()
const r = run.value
if (r) {
for (const [id, series] of r.series) {
let max = 0
for (const v of series) if (Number.isFinite(v)) max = Math.max(max, Math.abs(v))
map.set(id, max)
}
}
return map
})
// Keep the playhead in range when the run shrinks (e.g. a shorter stop time).
watch(frameCount, (n) => {
if (playhead.value > n - 1) playhead.value = Math.max(0, n - 1)
})
// Loading a different document (sample / import / new) restarts playback from the
// top. Editing the current one keeps the same id, so the playhead stays put — and
// undo/redo restore snapshots in place (same id), so they don't jump it either.
watch(
() => modelStore.model.id,
() => {
playing.value = false
playhead.value = 0
},
)
/** A node's value at the current playhead, or null when there's nothing to show. */
function valueAt(id: string): number | null {
if (!active.value) return null
const value = run.value?.series.get(id)?.[playhead.value]
return value === undefined || !Number.isFinite(value) ? null : value
}
/** A Stock's fill level in 0…1 (value over its peak), for the gauge. */
function fill(id: string): number {
const value = valueAt(id)
const peak = peaks.value.get(id) ?? 0
if (value === null || peak <= 0) return 0
return Math.min(1, Math.max(0, value / peak))
}
function seek(index: number): void {
playhead.value = Math.min(Math.max(0, Math.round(index)), Math.max(0, frameCount.value - 1))
}
function play(): void {
if (frameCount.value === 0) return
if (atEnd.value) playhead.value = 0 // replay from the start
playing.value = true
}
function pause(): void {
playing.value = false
}
function toggle(): void {
if (playing.value) pause()
else play()
}
/** Advance one frame; stop at the end. Called by the playback clock. */
function tick(): void {
if (atEnd.value) {
playing.value = false
return
}
playhead.value++
}
/** Engage playback (panel opened): show values, start from the beginning. */
function enable(): void {
enabled.value = true
playhead.value = 0
}
/** Disengage (panel closed): the canvas returns to a plain diagram. */
function disable(): void {
enabled.value = false
playing.value = false
playhead.value = 0
}
return {
run,
ready,
frameCount,
enabled,
playing,
playhead,
active,
atEnd,
currentTime,
valueAt,
fill,
seek,
play,
pause,
toggle,
tick,
enable,
disable,
}
})

View File

@@ -3,7 +3,7 @@
@import to precede all other rules. If it comes second, the production build
warns ("@import must precede all rules…") and browsers silently DROP the
font import, so the custom font never loads. */
@import url("https://fonts.coollabs.io/css2?family=Inter:wght@400;500;600;700&display=swap");
@import url("https://api.fonts.coollabs.io/css2?family=Sono:wght@400;500;600;700&display=swap");
@import "tailwindcss";
@plugin "daisyui";
@plugin "daisyui/theme" {
@@ -13,7 +13,7 @@
}
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-sans: "Sono", ui-sans-serif, system-ui, sans-serif;
}
/* Larger, easier-to-grab connection handles (Vue Flow defaults to 6px). The