From 34df540e4a078b474b909ee9a00071b1c461762e Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sat, 20 Jun 2026 13:56:19 +0200 Subject: [PATCH] feat(editor): add a simulation panel and an element inspector - ResultsPanel: a Simulate panel that charts every Stock over time (hand-built SVG), with start/stop/dt run controls and a divergence warning - Inspector: edit a selected Stock's initial value or a Flow/Converter's rule - store: undoable setInitialValue / setRule / setSimSpec actions --- src/components/Editor.vue | 15 +++ src/components/Inspector.vue | 146 ++++++++++++++++++++++++++ src/components/ResultsPanel.vue | 176 ++++++++++++++++++++++++++++++++ src/store/model.ts | 54 +++++++++- 4 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 src/components/Inspector.vue create mode 100644 src/components/ResultsPanel.vue diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 4943fa9..cbd9f84 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -33,8 +33,10 @@ import { canConnect } from "@/model/validation" import { useModelStore } from "@/store/model" import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd" import GlossPanel from "./GlossPanel.vue" +import Inspector from "./Inspector.vue" import LoopOverlay from "./LoopOverlay.vue" import Palette from "./Palette.vue" +import ResultsPanel from "./ResultsPanel.vue" import InfoLinkEdge from "./edges/InfoLinkEdge.vue" import PipeEdge from "./edges/PipeEdge.vue" import CloudNode from "./nodes/CloudNode.vue" @@ -48,6 +50,10 @@ const graph = computed(() => project(store.model)) const nodes = computed(() => graph.value.nodes) const edges = computed(() => graph.value.edges) +// The simulation results panel (phase 2). Toggled from the header; the panel runs +// the Model and recomputes reactively while open. +const showResults = ref(false) + // 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. @@ -358,6 +364,13 @@ onBeforeUnmount(() => { + { + + + + + + + + diff --git a/src/components/ResultsPanel.vue b/src/components/ResultsPanel.vue new file mode 100644 index 0000000..5558619 --- /dev/null +++ b/src/components/ResultsPanel.vue @@ -0,0 +1,176 @@ + + + diff --git a/src/store/model.ts b/src/store/model.ts index d1d3621..07b81a7 100644 --- a/src/store/model.ts +++ b/src/store/model.ts @@ -24,7 +24,15 @@ import { nextName, } from "@/model/factory" import { detectLoops } from "@/model/loops" -import type { ConverterNode, Model, ModelNode, Position, StockNode } from "@/model/types" +import type { + ConverterNode, + Model, + ModelNode, + Position, + Rule, + SimSpec, + StockNode, +} from "@/model/types" import { canConnect, intentFor } from "@/model/validation" /** Ring-buffer depth for undo (F9 target: ≥50 steps). */ @@ -96,6 +104,47 @@ export const useModelStore = defineStore("model", () => { node.name = name } + /** + * Set (or clear) a Stock's initial value — the quantity the simulator starts + * from (ADR-0004). `undefined` un-equips it, sending the Model back to "not yet + * simulatable". No-op when unchanged, so a blur with no edit doesn't burn undo. + */ + function setInitialValue(id: string, value: number | undefined): void { + const node = findNode(id) + if (!node || node.kind !== "stock" || node.initialValue === value) return + record() + if (value === undefined) delete node.initialValue + else node.initialValue = value + } + + /** + * Set (or clear) how a Flow's rate or a Converter's value is computed (ADR-0004: + * one of the fixed rules, never a formula). No-op when unchanged. + */ + function setRule(id: string, rule: Rule | undefined): void { + const node = findNode(id) + if (!node || (node.kind !== "flow" && node.kind !== "converter")) return + if (JSON.stringify(node.rule) === JSON.stringify(rule)) return + record() + if (rule === undefined) delete node.rule + else node.rule = rule + } + + /** Set the run parameters (start / stop / dt). No-op when unchanged. */ + function setSimSpec(spec: SimSpec): void { + const current = model.value.sim + if ( + current && + current.start === spec.start && + current.stop === spec.stop && + current.dt === spec.dt + ) { + return + } + record() + model.value.sim = spec + } + /** * Create whatever source→target means under the structure guard: an * Information Link (default `+` polarity, F6) onto a Flow/Converter, or a Flow @@ -229,6 +278,9 @@ export const useModelStore = defineStore("model", () => { beginInteraction, moveNode, renameNode, + setInitialValue, + setRule, + setSimSpec, removeNode, removeInfoLink, toggleLinkPolarity,