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) -}
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))