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
This commit is contained in:
@@ -33,8 +33,10 @@ import { canConnect } from "@/model/validation"
|
|||||||
import { useModelStore } from "@/store/model"
|
import { useModelStore } from "@/store/model"
|
||||||
import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd"
|
import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd"
|
||||||
import GlossPanel from "./GlossPanel.vue"
|
import GlossPanel from "./GlossPanel.vue"
|
||||||
|
import Inspector from "./Inspector.vue"
|
||||||
import LoopOverlay from "./LoopOverlay.vue"
|
import LoopOverlay from "./LoopOverlay.vue"
|
||||||
import Palette from "./Palette.vue"
|
import Palette from "./Palette.vue"
|
||||||
|
import ResultsPanel from "./ResultsPanel.vue"
|
||||||
import InfoLinkEdge from "./edges/InfoLinkEdge.vue"
|
import InfoLinkEdge from "./edges/InfoLinkEdge.vue"
|
||||||
import PipeEdge from "./edges/PipeEdge.vue"
|
import PipeEdge from "./edges/PipeEdge.vue"
|
||||||
import CloudNode from "./nodes/CloudNode.vue"
|
import CloudNode from "./nodes/CloudNode.vue"
|
||||||
@@ -48,6 +50,10 @@ const graph = computed(() => project(store.model))
|
|||||||
const nodes = computed(() => graph.value.nodes)
|
const nodes = computed(() => graph.value.nodes)
|
||||||
const edges = computed(() => graph.value.edges)
|
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
|
// Explicit shared id: useVueFlow() runs here in the parent setup, before
|
||||||
// <VueFlow> mounts. Pinning both to the same id guarantees they resolve to one
|
// <VueFlow> mounts. Pinning both to the same id guarantees they resolve to one
|
||||||
// store instance, so the event hooks below actually fire.
|
// store instance, so the event hooks below actually fire.
|
||||||
@@ -358,6 +364,13 @@ onBeforeUnmount(() => {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
:class="{ 'btn-active': showResults }"
|
||||||
|
@click="showResults = !showResults"
|
||||||
|
>
|
||||||
|
Simulate
|
||||||
|
</button>
|
||||||
<button class="btn btn-ghost btn-sm" @click="exportModel">Export</button>
|
<button class="btn btn-ghost btn-sm" @click="exportModel">Export</button>
|
||||||
<button class="btn btn-ghost btn-sm" @click="fileInput?.click()">Import</button>
|
<button class="btn btn-ghost btn-sm" @click="fileInput?.click()">Import</button>
|
||||||
<input
|
<input
|
||||||
@@ -420,6 +433,8 @@ onBeforeUnmount(() => {
|
|||||||
<Palette class="absolute top-3 left-3 z-20" @add="addNode" />
|
<Palette class="absolute top-3 left-3 z-20" @add="addNode" />
|
||||||
<LoopOverlay />
|
<LoopOverlay />
|
||||||
<GlossPanel />
|
<GlossPanel />
|
||||||
|
<Inspector />
|
||||||
|
<ResultsPanel v-if="showResults" @close="showResults = false" />
|
||||||
|
|
||||||
<!-- Self-dismissing teaching hint, e.g. when a Flow is dragged back onto its
|
<!-- Self-dismissing teaching hint, e.g. when a Flow is dragged back onto its
|
||||||
own Stock to "close the loop". Click to dismiss early; z-30 keeps it above
|
own Stock to "close the loop". Click to dismiss early; z-30 keeps it above
|
||||||
|
|||||||
146
src/components/Inspector.vue
Normal file
146
src/components/Inspector.vue
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Inspector (phase 2, ADR-0004) — equips the *selected* element with the numbers
|
||||||
|
* a simulation needs, so a Model becomes more than samples: a Stock gets its
|
||||||
|
* initial value, a Flow/Converter its rule. Editing is deliberately small —
|
||||||
|
* choose a rule from the fixed vocabulary and type one or two numbers; there is
|
||||||
|
* no formula box, so the Model stays valid by construction.
|
||||||
|
*
|
||||||
|
* It reads selection from the shared Vue Flow instance (as GlossPanel does), then
|
||||||
|
* resolves the *live* domain node from the store so edits round-trip through
|
||||||
|
* undoable store actions. Operands for a rule are the element's inbound
|
||||||
|
* Information Links picked by Polarity — set those by wiring, not here.
|
||||||
|
*/
|
||||||
|
import { useVueFlow } from "@vue-flow/core"
|
||||||
|
import { computed } from "vue"
|
||||||
|
import type { EdgeData } from "@/model/projection"
|
||||||
|
import type { ConverterNode, FlowNode, Rule, StockNode } from "@/model/types"
|
||||||
|
import { useModelStore } from "@/store/model"
|
||||||
|
|
||||||
|
const store = useModelStore()
|
||||||
|
const { getSelectedNodes, getSelectedEdges } = useVueFlow("meadows")
|
||||||
|
|
||||||
|
/** The single selected element's id — a node directly, or a Flow via its pipe edge. */
|
||||||
|
const selectedId = computed<string | null>(() => {
|
||||||
|
const nodes = getSelectedNodes.value
|
||||||
|
const edges = getSelectedEdges.value
|
||||||
|
if (nodes.length === 1 && edges.length === 0) return nodes[0].id
|
||||||
|
if (edges.length === 1 && nodes.length === 0) {
|
||||||
|
const edge = edges[0]
|
||||||
|
if ((edge.data as EdgeData | undefined)?.kind === "pipe") return edge.id.split("::")[0]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
/** The live, editable domain node behind the selection (Clouds are not editable). */
|
||||||
|
const element = computed<StockNode | FlowNode | ConverterNode | null>(() => {
|
||||||
|
const id = selectedId.value
|
||||||
|
if (!id) return null
|
||||||
|
const node = store.model.nodes.find((n) => n.id === id)
|
||||||
|
if (node?.kind === "stock" || node?.kind === "flow" || node?.kind === "converter") return node
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
const KIND_LABEL = { stock: "Stock", flow: "Flow", converter: "Converter" } as const
|
||||||
|
|
||||||
|
/** Every rule carries exactly one number (a value or a factor); read it uniformly. */
|
||||||
|
function ruleNumber(rule?: Rule): number {
|
||||||
|
if (!rule) return 0
|
||||||
|
return rule.kind === "constant" ? rule.value : rule.factor
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRule(kind: Rule["kind"], n: number): Rule {
|
||||||
|
return kind === "constant" ? { kind, value: n } : { kind, factor: n }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInitial(event: Event): void {
|
||||||
|
const el = element.value
|
||||||
|
if (el?.kind !== "stock") return
|
||||||
|
const raw = (event.target as HTMLInputElement).value.trim()
|
||||||
|
if (raw === "") return store.setInitialValue(el.id, undefined)
|
||||||
|
const n = Number(raw)
|
||||||
|
if (Number.isFinite(n)) store.setInitialValue(el.id, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKind(event: Event): void {
|
||||||
|
const el = element.value
|
||||||
|
if (el?.kind !== "flow" && el?.kind !== "converter") return
|
||||||
|
const kind = (event.target as HTMLSelectElement).value as Rule["kind"] | ""
|
||||||
|
if (!kind) return
|
||||||
|
// Carry the existing number across a kind change; default a fresh rule sensibly.
|
||||||
|
const n = el.rule ? ruleNumber(el.rule) : kind === "constant" ? 0 : 1
|
||||||
|
store.setRule(el.id, buildRule(kind, n))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onParam(event: Event): void {
|
||||||
|
const el = element.value
|
||||||
|
if ((el?.kind !== "flow" && el?.kind !== "converter") || !el.rule) return
|
||||||
|
const n = Number((event.target as HTMLInputElement).value)
|
||||||
|
store.setRule(el.id, buildRule(el.rule.kind, Number.isFinite(n) ? n : 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-line reminder of where a rule reads its operands (they come from links). */
|
||||||
|
const RULE_HINT: Record<Rule["kind"], string> = {
|
||||||
|
constant: "A fixed number — no inputs.",
|
||||||
|
proportional: "rate = factor × its “+” inputs.",
|
||||||
|
gap: "rate = factor × (level − target): the “+” input is the level, the “−” the target.",
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="element"
|
||||||
|
class="absolute top-3 right-3 z-20 w-60 rounded-box border border-base-300 bg-base-100/95 p-3 shadow-md backdrop-blur"
|
||||||
|
>
|
||||||
|
<div class="flex items-baseline gap-2">
|
||||||
|
<span class="text-sm font-semibold">{{ element.name }}</span>
|
||||||
|
<span class="text-xs text-base-content/50">{{ KIND_LABEL[element.kind] }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stock: the quantity it starts from. -->
|
||||||
|
<label v-if="element.kind === 'stock'" class="mt-2 block">
|
||||||
|
<span class="text-xs text-base-content/60">Initial value</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="input input-sm input-bordered mt-1 w-full"
|
||||||
|
:value="element.initialValue ?? ''"
|
||||||
|
placeholder="—"
|
||||||
|
@change="onInitial"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Flow / Converter: pick a rule, then its number. -->
|
||||||
|
<template v-else>
|
||||||
|
<label class="mt-2 block">
|
||||||
|
<span class="text-xs text-base-content/60">Rule</span>
|
||||||
|
<select
|
||||||
|
class="select select-sm select-bordered mt-1 w-full"
|
||||||
|
:value="element.rule?.kind ?? ''"
|
||||||
|
@change="onKind"
|
||||||
|
>
|
||||||
|
<option value="" disabled>Choose a rule…</option>
|
||||||
|
<option value="constant">Constant</option>
|
||||||
|
<option value="proportional">Proportional</option>
|
||||||
|
<option value="gap">Gap</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label v-if="element.rule" class="mt-2 block">
|
||||||
|
<span class="text-xs text-base-content/60">
|
||||||
|
{{ element.rule.kind === "constant" ? "Value" : "Factor" }}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
class="input input-sm input-bordered mt-1 w-full"
|
||||||
|
:value="ruleNumber(element.rule)"
|
||||||
|
@change="onParam"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p v-if="element.rule" class="mt-2 text-xs leading-snug text-base-content/50">
|
||||||
|
{{ RULE_HINT[element.rule.kind] }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
176
src/components/ResultsPanel.vue
Normal file
176
src/components/ResultsPanel.vue
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Results panel (phase 2, ADR-0004) — what makes the Model *alive*: it runs the
|
||||||
|
* simulation and traces each Stock's value over time. Stocks are the system's
|
||||||
|
* memory, so their trajectories are the behaviour worth watching (the savings
|
||||||
|
* balance snowballs; the coffee settles toward room temperature).
|
||||||
|
*
|
||||||
|
* 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).
|
||||||
|
*/
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { checkSimReady, simulate } from "@/model/simulation"
|
||||||
|
import { DEFAULT_SIM_SPEC, type SimSpec, type StockNode } from "@/model/types"
|
||||||
|
import { useModelStore } from "@/store/model"
|
||||||
|
|
||||||
|
defineEmits<{ close: [] }>()
|
||||||
|
|
||||||
|
const store = useModelStore()
|
||||||
|
|
||||||
|
const problems = computed(() => checkSimReady(store.model))
|
||||||
|
|
||||||
|
/** The current run window; falls back to the default until the Model carries one. */
|
||||||
|
const spec = computed<SimSpec>(() => store.model.sim ?? DEFAULT_SIM_SPEC)
|
||||||
|
|
||||||
|
function onSpec(key: keyof SimSpec, event: Event): void {
|
||||||
|
const n = Number((event.target as HTMLInputElement).value)
|
||||||
|
if (!Number.isFinite(n) || (key === "dt" && n <= 0)) return
|
||||||
|
store.setSimSpec({ ...spec.value, [key]: n })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
|
||||||
|
const chart = computed(() => {
|
||||||
|
if (problems.value.length > 0) return null
|
||||||
|
const run = simulate(store.model)
|
||||||
|
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
|
||||||
|
|
||||||
|
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 }
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 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>
|
||||||
|
<div
|
||||||
|
class="absolute inset-x-3 bottom-3 z-30 rounded-box border border-base-300 bg-base-100/95 p-3 shadow-lg backdrop-blur"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-semibold">Behaviour over time</span>
|
||||||
|
<span class="truncate text-xs text-base-content/50">{{ store.model.name }}</span>
|
||||||
|
<div class="ml-auto flex items-center gap-2 text-xs text-base-content/60">
|
||||||
|
<label class="flex items-center gap-1">
|
||||||
|
from
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="input input-xs input-bordered w-16"
|
||||||
|
:value="spec.start"
|
||||||
|
@change="onSpec('start', $event)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1">
|
||||||
|
to
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="input input-xs input-bordered w-16"
|
||||||
|
:value="spec.stop"
|
||||||
|
@change="onSpec('stop', $event)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1">
|
||||||
|
step
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
min="0"
|
||||||
|
class="input input-xs input-bordered w-16"
|
||||||
|
:value="spec.dt"
|
||||||
|
@change="onSpec('dt', $event)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-circle btn-ghost btn-xs"
|
||||||
|
aria-label="Close"
|
||||||
|
@click="$emit('close')"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stopped early because values ran past what a float can hold. -->
|
||||||
|
<p v-if="chart?.diverged" class="mt-2 text-xs text-warning">
|
||||||
|
⚠ Values grew beyond what can be plotted and the run stopped early — try a smaller step, a
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Not sim-ready: what to fill in to bring it to life. -->
|
||||||
|
<div v-else class="mt-2 text-sm">
|
||||||
|
<p class="text-base-content/70">To bring this model to life, set:</p>
|
||||||
|
<ul class="mt-1 list-inside list-disc text-base-content/60">
|
||||||
|
<li v-for="(problem, i) in problems" :key="i">{{ problem }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -24,7 +24,15 @@ import {
|
|||||||
nextName,
|
nextName,
|
||||||
} from "@/model/factory"
|
} from "@/model/factory"
|
||||||
import { detectLoops } from "@/model/loops"
|
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"
|
import { canConnect, intentFor } from "@/model/validation"
|
||||||
|
|
||||||
/** Ring-buffer depth for undo (F9 target: ≥50 steps). */
|
/** Ring-buffer depth for undo (F9 target: ≥50 steps). */
|
||||||
@@ -96,6 +104,47 @@ export const useModelStore = defineStore("model", () => {
|
|||||||
node.name = name
|
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
|
* Create whatever source→target means under the structure guard: an
|
||||||
* Information Link (default `+` polarity, F6) onto a Flow/Converter, or a Flow
|
* Information Link (default `+` polarity, F6) onto a Flow/Converter, or a Flow
|
||||||
@@ -229,6 +278,9 @@ export const useModelStore = defineStore("model", () => {
|
|||||||
beginInteraction,
|
beginInteraction,
|
||||||
moveNode,
|
moveNode,
|
||||||
renameNode,
|
renameNode,
|
||||||
|
setInitialValue,
|
||||||
|
setRule,
|
||||||
|
setSimSpec,
|
||||||
removeNode,
|
removeNode,
|
||||||
removeInfoLink,
|
removeInfoLink,
|
||||||
toggleLinkPolarity,
|
toggleLinkPolarity,
|
||||||
|
|||||||
Reference in New Issue
Block a user