From 69435b1315b0c303fba459ded1731eaf1c9a15cd Mon Sep 17 00:00:00 2001
From: Julien Calixte
Date: Sat, 20 Jun 2026 14:35:45 +0200
Subject: [PATCH] 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.
---
src/components/Editor.vue | 5 +
src/components/ResultsPanel.vue | 121 +++++++++------------
src/components/nodes/ConverterNode.vue | 11 ++
src/components/nodes/FlowNode.vue | 8 ++
src/components/nodes/StockNode.vue | 27 ++++-
src/components/nodes/format.ts | 4 +
src/composables/usePlayback.ts | 49 +++++++++
src/store/simulation.ts | 140 +++++++++++++++++++++++++
8 files changed, 290 insertions(+), 75 deletions(-)
create mode 100644 src/components/nodes/format.ts
create mode 100644 src/composables/usePlayback.ts
create mode 100644 src/store/simulation.ts
diff --git a/src/components/Editor.vue b/src/components/Editor.vue
index cbd9f84..29f3c69 100644
--- a/src/components/Editor.vue
+++ b/src/components/Editor.vue
@@ -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
// mounts. Pinning both to the same id guarantees they resolve to one
// store instance, so the event hooks below actually fire.
diff --git a/src/components/ResultsPanel.vue b/src/components/ResultsPanel.vue
index 5558619..474dae1 100644
--- a/src/components/ResultsPanel.vue
+++ b/src/components/ResultsPanel.vue
@@ -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)
-}
@@ -132,38 +112,33 @@ function fmt(value: number): string {
smaller factor, or a Balancing loop to rein it in.
-
-
-
+
diff --git a/src/components/nodes/ConverterNode.vue b/src/components/nodes/ConverterNode.vue
index 5b4aa14..abe830d 100644
--- a/src/components/nodes/ConverterNode.vue
+++ b/src/components/nodes/ConverterNode.vue
@@ -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>()
@@ -16,6 +18,9 @@ const props = defineProps>()
// 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))
@@ -25,6 +30,12 @@ const loopRing = useNodeLoopRing(props.id)
:class="[props.selected ? 'border-primary' : 'border-base-300', loopRing]"
>
+
+ {{ formatValue(value) }}
+
diff --git a/src/components/nodes/FlowNode.vue b/src/components/nodes/FlowNode.vue
index e36ec43..d0994c2 100644
--- a/src/components/nodes/FlowNode.vue
+++ b/src/components/nodes/FlowNode.vue
@@ -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>()
@@ -18,6 +20,9 @@ const props = defineProps>()
// 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))
@@ -40,5 +45,8 @@ const loopRing = useNodeLoopRing(props.id)
+
+ {{ formatValue(value) }}
+
diff --git a/src/components/nodes/StockNode.vue b/src/components/nodes/StockNode.vue
index 24576f1..a667bac 100644
--- a/src/components/nodes/StockNode.vue
+++ b/src/components/nodes/StockNode.vue
@@ -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>()
@@ -16,15 +18,36 @@ const props = defineProps>()
// 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))
+
+
+
+
-
+
+
+
+ {{ formatValue(value) }}
+
+
diff --git a/src/components/nodes/format.ts b/src/components/nodes/format.ts
new file mode 100644
index 0000000..6cdb843
--- /dev/null
+++ b/src/components/nodes/format.ts
@@ -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)
+}
diff --git a/src/composables/usePlayback.ts b/src/composables/usePlayback.ts
new file mode 100644
index 0000000..ce9dfc5
--- /dev/null
+++ b/src/composables/usePlayback.ts
@@ -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))
+}
diff --git a/src/store/simulation.ts b/src/store/simulation.ts
new file mode 100644
index 0000000..620c446
--- /dev/null
+++ b/src/store/simulation.ts
@@ -0,0 +1,140 @@
+/**
+ * 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(() => {
+ 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()
+ 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)
+ })
+
+ /** 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,
+ }
+})