Compare commits
21 Commits
528fc97c7f
...
feat/overs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a4fe59811 | ||
|
|
3a65bf5d59 | ||
|
|
8e1e313b20 | ||
|
|
d570425402 | ||
|
|
2c7ec6a6ec | ||
|
|
ef0cc2878b | ||
|
|
f38cf49f25 | ||
|
|
d5cda95c3e | ||
|
|
dc30e5f890 | ||
|
|
382fdddc68 | ||
|
|
ed6f011e69 | ||
|
|
1d65383bbf | ||
|
|
3fc0e23ec7 | ||
|
|
69435b1315 | ||
|
|
c361b05ec4 | ||
|
|
0c8f89d14e | ||
|
|
f0d207c4d5 | ||
|
|
34df540e4a | ||
|
|
40dc9fba43 | ||
|
|
5b6e830778 | ||
|
|
964e621f0e |
@@ -19,6 +19,11 @@ Deployed at https://meadows.apoena.dev
|
||||
substrate; the domain Model is the source of truth.
|
||||
- [ADR-0003](./docs/adr/0003-flow-as-node-materialised-clouds.md) — a Flow is a
|
||||
node; Source/Sink clouds are materialised nodes.
|
||||
- [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
|
||||
|
||||
|
||||
74
docs/adr/0004-rate-rules-not-formulas.md
Normal file
74
docs/adr/0004-rate-rules-not-formulas.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Behaviour comes from rules over Information Links, not free-form formulas
|
||||
|
||||
_Part of [meadows](../../README.md) · see [DESIGN.md](../../DESIGN.md)._
|
||||
|
||||
Numeric simulation (phase 2) makes a **Model** _alive_: Stocks accumulate over
|
||||
time, Flows and Converters recompute each instant. Two coupled decisions shape
|
||||
how a Model carries the numbers, and both trade expressive power for **valid by
|
||||
construction** — the right trade for a tool that exists to _popularise_ systems
|
||||
thinking, not to compete with Vensim.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. The Information Link _is_ the declared dependency.** A Flow's or Converter's
|
||||
inputs are exactly the elements that link into it. There is no separate "equation
|
||||
references" namespace to keep in sync — the wiring you draw _is_ the wiring the
|
||||
simulator reads. The same signed graph the loop detector walks (ADR-0001) is the
|
||||
graph the simulator integrates, so the loops you _see_ classified R/B are the
|
||||
loops you _run_. They can never disagree.
|
||||
|
||||
**2. A Flow/Converter computes from a small fixed vocabulary of `Rule`s, not a
|
||||
typed-in formula.** Each instantaneous element picks one rule and a plain number
|
||||
or two — never an expression:
|
||||
|
||||
| Rule | Value | Reads (via Information Links) | Emergent behaviour |
|
||||
| ---------------- | --------------------------- | ----------------------------------------- | -------------------------- |
|
||||
| **Constant** | a fixed number | nothing | linear Stock change |
|
||||
| **Proportional** | `factor × (its `+` inputs)` | the `+`-polarity inputs | exponential growth / decay |
|
||||
| **Gap** | `factor × (level − target)` | the `+` input is _level_, `−` is _target_ | goal-seeking / asymptotic |
|
||||
|
||||
The famous curves are _compositions_ of these over the structure — a logistic
|
||||
S-curve is Proportional growth meeting a Gap-driven ceiling (limits-to-growth);
|
||||
goal-seeking decay is a lone Gap (coffee cooling). The user sets up a local rule;
|
||||
the global shape **emerges**. That emergence _is_ the lesson.
|
||||
|
||||
**Polarity does double duty.** The `+`/`−` already captured for loop
|
||||
classification (ADR-0001) also selects each operand's role: Proportional reads
|
||||
its `+` inputs; Gap reads its `+` input as the level and its `−` input as the
|
||||
target. One gesture, two payoffs — no new per-link data.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- **Free-form expression strings** (`birth rate = Population × fertility`) —
|
||||
maximally expressive, and what `equation?: string` originally anticipated.
|
||||
Rejected: needs a parser + a sandbox (never `eval`/`new Function`), invites
|
||||
broken-formula and name-resolution errors (auto-names contain spaces), and lets
|
||||
a learner _paint_ a curve instead of discovering it from structure.
|
||||
- **Pick the output curve** (label a Stock "exponential" / "logarithmic") —
|
||||
rejected: it is the answer, not the cause, and it breaks the moment feedback
|
||||
decides the shape. "Logarithmic" in particular has no honest local rule; what
|
||||
people mean by it is asymptotic approach — which _is_ the Gap rule.
|
||||
- **Rules over Information Links (chosen)** — no parser, valid by construction,
|
||||
and it teaches structure → behaviour.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The domain types gain a `Rule` union on Flow/Converter (replacing the unused
|
||||
`equation?: string`), an optional `initialValue` on Stock, and an optional
|
||||
`SimSpec` (`start` / `stop` / `dt`) on the Model. All optional and additive, so
|
||||
existing saved Models still load (F8); they are simply not _simulatable_ until
|
||||
equipped.
|
||||
- **Algebraic loops are an error.** A cycle in the wiring is legitimate feedback
|
||||
**iff it passes through a Stock** — the Stock supplies last-step state and so
|
||||
breaks the within-step dependency. A cycle among only Flows/Converters has no
|
||||
Stock to break it: the simulator cannot order it and rejects it. The simulator
|
||||
reuses the cycle machinery to detect this; it is a new _sim-readiness_ check,
|
||||
distinct from structural validity (validation.ts).
|
||||
- A reader seeing `{ kind: "gap", factor }` on a Flow and wondering where its
|
||||
operands come from should look here: they are the Flow's inbound Information
|
||||
Links, picked by Polarity.
|
||||
- The vocabulary starts deliberately small (Constant / Proportional / Gap —
|
||||
enough for linear, exponential, and goal-seeking, and for the coffee and
|
||||
savings samples). Growing it is additive: a new `kind` in the union plus a case
|
||||
in the evaluator. Multi-input products (e.g. `Population × fertility`) are a
|
||||
later increment, not a phase-2 blocker.
|
||||
55
docs/adr/0005-uplot-behaviour-chart.md
Normal file
55
docs/adr/0005-uplot-behaviour-chart.md
Normal 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.
|
||||
@@ -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
8
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#16A34A"
|
||||
stroke="#ff9ff3"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
|
||||
|
Before Width: | Height: | Size: 550 B After Width: | Height: | Size: 550 B |
@@ -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"
|
||||
@@ -33,8 +34,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 +51,14 @@ 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)
|
||||
|
||||
// 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.
|
||||
@@ -338,7 +349,28 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<div class="flex h-screen flex-col bg-base-200">
|
||||
<header class="flex items-center gap-3 border-b border-base-300 bg-base-100 px-4 py-2">
|
||||
<img src="/favicon.svg" alt="" class="size-6" />
|
||||
<!-- Inlined favicon so the logo strokes with the theme primary (currentColor
|
||||
via text-primary); an external <img> can't read the theme var. -->
|
||||
<svg
|
||||
class="size-6 text-primary"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M14 20a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
|
||||
<path d="M14 4a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
|
||||
<path d="M6 12a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
|
||||
<path d="M22 12a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
|
||||
<path d="M14 12a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
|
||||
<path d="M6 12h4" />
|
||||
<path d="M14 12h4" />
|
||||
<path d="M12 6v4" />
|
||||
<path d="M12 14v4" />
|
||||
</svg>
|
||||
<h1 class="text-lg font-semibold">meadows</h1>
|
||||
<span class="text-sm text-base-content/50">
|
||||
{{ store.nodeCount }} {{ store.nodeCount === 1 ? "element" : "elements" }}
|
||||
@@ -348,7 +380,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)">
|
||||
@@ -358,6 +390,13 @@ onBeforeUnmount(() => {
|
||||
</li>
|
||||
</ul>
|
||||
</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="fileInput?.click()">Import</button>
|
||||
<input
|
||||
@@ -420,6 +459,8 @@ onBeforeUnmount(() => {
|
||||
<Palette class="absolute top-3 left-3 z-20" @add="addNode" />
|
||||
<LoopOverlay />
|
||||
<GlossPanel />
|
||||
<Inspector />
|
||||
<ResultsPanel v-if="showResults" @close="showResults = false" />
|
||||
|
||||
<!-- 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
|
||||
|
||||
164
src/components/Inspector.vue
Normal file
164
src/components/Inspector.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<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 onUnit(event: Event): void {
|
||||
const el = element.value
|
||||
if (el?.kind !== "stock") return
|
||||
store.setUnit(el.id, (event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
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, and its unit. -->
|
||||
<template v-if="element.kind === 'stock'">
|
||||
<label 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>
|
||||
<label class="mt-2 block">
|
||||
<span class="text-xs text-base-content/60">Unit</span>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-sm input-bordered mt-1 w-full"
|
||||
:value="element.unit ?? ''"
|
||||
placeholder="e.g. °C, people, $"
|
||||
@change="onUnit"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<!-- 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>
|
||||
153
src/components/ResultsPanel.vue
Normal file
153
src/components/ResultsPanel.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<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. 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 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))
|
||||
|
||||
/** 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"]
|
||||
|
||||
// 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(() => {
|
||||
const run = sim.run
|
||||
if (!run) return null
|
||||
const stocks = store.model.nodes.filter((n): n is StockNode => n.kind === "stock")
|
||||
if (stocks.length === 0 || run.times.length < 2) return null
|
||||
|
||||
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 }
|
||||
})
|
||||
</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, 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()"
|
||||
>
|
||||
{{ 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))"
|
||||
/>
|
||||
<span
|
||||
class="w-24 shrink-0 whitespace-nowrap 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">
|
||||
<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>
|
||||
176
src/components/SimChart.vue
Normal file
176
src/components/SimChart.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<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 = ""
|
||||
|
||||
// Playhead as a DOM overlay (CSS px), not a canvas line: a moving line drawn by
|
||||
// redrawing the whole uPlot canvas every frame flickers, so the line slides over
|
||||
// the canvas instead and the canvas is only redrawn when the data changes.
|
||||
const markerX = ref<number | null>(null)
|
||||
const markerTop = ref(0)
|
||||
const markerHeight = ref(0)
|
||||
|
||||
function syncMarker(): void {
|
||||
if (!plot || props.marker === null) {
|
||||
markerX.value = null
|
||||
return
|
||||
}
|
||||
// bbox is in device pixels; the overlay is laid out in CSS pixels.
|
||||
const ratio = window.devicePixelRatio || 1
|
||||
// valToPos (CSS px) is relative to the plot area's left edge, but the overlay
|
||||
// is positioned against the canvas, so add the left gutter (the y-axis width).
|
||||
markerX.value = plot.bbox.left / ratio + plot.valToPos(props.marker, "x")
|
||||
markerTop.value = plot.bbox.top / ratio
|
||||
markerHeight.value = plot.bbox.height / ratio
|
||||
}
|
||||
|
||||
/** 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 } },
|
||||
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)
|
||||
syncMarker() // the plot area may have shifted; keep the overlay aligned
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
build()
|
||||
observer = new ResizeObserver(() => {
|
||||
if (plot && root.value) {
|
||||
plot.setSize({ width: root.value.clientWidth, height: props.height })
|
||||
syncMarker()
|
||||
}
|
||||
})
|
||||
if (root.value) observer.observe(root.value)
|
||||
syncMarker()
|
||||
// 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()
|
||||
syncMarker()
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect()
|
||||
plot?.destroy()
|
||||
})
|
||||
|
||||
watch(() => props.data, render)
|
||||
// The playhead just slides the overlay — no canvas redraw, so playback stays smooth.
|
||||
watch(() => props.marker, syncMarker)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="relative w-full">
|
||||
<div
|
||||
v-if="markerX !== null"
|
||||
class="pointer-events-none absolute w-px bg-primary"
|
||||
:style="{ left: `${markerX}px`, top: `${markerTop}px`, height: `${markerHeight}px` }"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,43 @@ 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/60 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>
|
||||
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
|
||||
<!-- Live value sits *below* the box as an out-of-flow overlay, so the box keeps
|
||||
its exact size and the handles — with the pipes and links on them — never
|
||||
shift when a run is engaged. -->
|
||||
<div
|
||||
v-if="value !== null"
|
||||
class="pointer-events-none absolute inset-x-0 top-full mt-1 whitespace-nowrap text-center font-mono text-xs tabular-nums text-base-content/70"
|
||||
>
|
||||
{{ formatValue(value)
|
||||
}}<span v-if="stock.unit" class="ml-0.5 text-base-content/50">{{ stock.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
4
src/components/nodes/format.ts
Normal file
4
src/components/nodes/format.ts
Normal 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)
|
||||
}
|
||||
49
src/composables/usePlayback.ts
Normal file
49
src/composables/usePlayback.ts
Normal 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))
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
type NodeKind,
|
||||
type Polarity,
|
||||
type Position,
|
||||
type Rule,
|
||||
type SimSpec,
|
||||
} from "./types"
|
||||
|
||||
/** Pretty JSON so an exported Model is human-readable and diff-friendly (F8). */
|
||||
@@ -49,6 +51,24 @@ function isPolarity(value: unknown): value is Polarity {
|
||||
return value === "+" || value === "-"
|
||||
}
|
||||
|
||||
/** A Rule must be one of the fixed kinds with its numeric parameter (ADR-0004). */
|
||||
function isRule(value: unknown): value is Rule {
|
||||
if (!isObject(value)) return false
|
||||
if (value.kind === "constant") return isFiniteNumber(value.value)
|
||||
if (value.kind === "proportional" || value.kind === "gap") return isFiniteNumber(value.factor)
|
||||
return false
|
||||
}
|
||||
|
||||
function isSimSpec(value: unknown): value is SimSpec {
|
||||
return (
|
||||
isObject(value) &&
|
||||
isFiniteNumber(value.start) &&
|
||||
isFiniteNumber(value.stop) &&
|
||||
isFiniteNumber(value.dt) &&
|
||||
value.dt > 0
|
||||
)
|
||||
}
|
||||
|
||||
/** Validate one node by kind, returning an error string or null when valid. */
|
||||
function nodeError(value: unknown, index: number): string | null {
|
||||
if (!isObject(value)) return `nodes[${index}] is not an object`
|
||||
@@ -65,6 +85,20 @@ function nodeError(value: unknown, index: number): string | null {
|
||||
if (typeof value.source !== "string") return `${at}.source must be a string`
|
||||
if (typeof value.target !== "string") return `${at}.target must be a string`
|
||||
}
|
||||
// Simulation fields (ADR-0004) are optional, but if present must be well-formed.
|
||||
if (kind === "stock" && value.initialValue !== undefined && !isFiniteNumber(value.initialValue)) {
|
||||
return `${at}.initialValue must be a finite number`
|
||||
}
|
||||
if (kind === "stock" && value.unit !== undefined && typeof value.unit !== "string") {
|
||||
return `${at}.unit must be a string`
|
||||
}
|
||||
if (
|
||||
(kind === "flow" || kind === "converter") &&
|
||||
value.rule !== undefined &&
|
||||
!isRule(value.rule)
|
||||
) {
|
||||
return `${at}.rule must be a valid rule (constant/proportional/gap)`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -104,6 +138,9 @@ export function parseModel(text: string): ParseResult {
|
||||
if (typeof data.name !== "string") return { ok: false, error: "name must be a string" }
|
||||
if (!Array.isArray(data.nodes)) return { ok: false, error: "nodes must be an array" }
|
||||
if (!Array.isArray(data.infoLinks)) return { ok: false, error: "infoLinks must be an array" }
|
||||
if (data.sim !== undefined && !isSimSpec(data.sim)) {
|
||||
return { ok: false, error: "sim must be {start, stop, dt} numbers with dt > 0" }
|
||||
}
|
||||
|
||||
for (let i = 0; i < data.nodes.length; i++) {
|
||||
const error = nodeError(data.nodes[i], i)
|
||||
@@ -134,6 +171,13 @@ export function parseModel(text: string): ParseResult {
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
model: { version: MODEL_VERSION, id: data.id, name: data.name, nodes, infoLinks: links },
|
||||
model: {
|
||||
version: MODEL_VERSION,
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
nodes,
|
||||
infoLinks: links,
|
||||
...(data.sim !== undefined && { sim: data.sim as SimSpec }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,18 @@
|
||||
* Flow's references (ADR-0003); each Information Link contributes one "info"
|
||||
* edge carrying its polarity.
|
||||
*/
|
||||
import type { Edge, Node } from "@vue-flow/core"
|
||||
import { type Edge, type EdgeMarker, MarkerType, type Node } from "@vue-flow/core"
|
||||
import type { Model, ModelNode, Polarity } from "./types"
|
||||
|
||||
/**
|
||||
* Open arrowhead drawn at the same stroke weight as the line it ends. Vue Flow
|
||||
* renders the marker with markerUnits="strokeWidth" (so the head already scales
|
||||
* with the edge) but downscales its 20-unit viewBox into a 12.5 marker box,
|
||||
* thinning the chevron to 0.625× the line. strokeWidth 20/12.5 = 1.6 cancels
|
||||
* that, leaving the head exactly as heavy as its line at any width.
|
||||
*/
|
||||
const ARROWHEAD: EdgeMarker = { type: MarkerType.Arrow, strokeWidth: 1.6 }
|
||||
|
||||
/** Payload carried on every projected Vue Flow node: the domain node itself. */
|
||||
export interface NodeData {
|
||||
node: ModelNode
|
||||
@@ -72,7 +81,7 @@ export function projectEdges(model: Model): FlowGraphEdge[] {
|
||||
targetHandle: HANDLE_IN,
|
||||
data: { kind: "pipe" },
|
||||
style: { strokeWidth: "2.5px" },
|
||||
markerEnd: "arrow",
|
||||
markerEnd: ARROWHEAD,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -88,7 +97,7 @@ export function projectEdges(model: Model): FlowGraphEdge[] {
|
||||
targetHandle: HANDLE_IN,
|
||||
data: { kind: "info", polarity: link.polarity },
|
||||
style: { strokeDasharray: "5 4" },
|
||||
markerEnd: "arrow",
|
||||
markerEnd: ARROWHEAD,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
* Beyond that primer, three classic models go a step further — each adds one
|
||||
* structure the first four never show, so they read as a second tier:
|
||||
*
|
||||
* 5. Limits to growth — two loops (R and B) fighting over a single Flow, plus a
|
||||
* constant Converter (carrying capacity) that feeds a loop
|
||||
* without being part of it.
|
||||
* 5. Limits to growth — a Reinforcing inflow and a Balancing outflow on one
|
||||
* Stock, with a Converter (crowding) relaying the density
|
||||
* that brakes growth: the S-curve.
|
||||
* 6. Predator and prey — two coupled Stocks whose interlocking loops oscillate.
|
||||
* 7. Epidemic — a chain of Stocks joined by Stock→Stock Flows: no clouds.
|
||||
*
|
||||
@@ -33,6 +33,14 @@
|
||||
* 11. Drift to low performance — a goal that erodes toward actual performance, so a
|
||||
* Reinforcing loop ratchets both downward.
|
||||
*
|
||||
* Last, the dynamic the book is named for, and the one the gallery has saved until a
|
||||
* reader knows every piece it needs:
|
||||
*
|
||||
* 12. Overshoot and collapse — a Reinforcing engine running on a *non-renewable*
|
||||
* Stock (the first with no inflow): it overshoots the
|
||||
* limit instead of settling at it, the dark twin of
|
||||
* "Limits to growth" — the ceiling erodes, so it crashes.
|
||||
*
|
||||
* These are plain data built from the same tested constructors the store uses
|
||||
* (factory.ts), so every sample is a valid Model by construction. `build()`
|
||||
* mints fresh ids on each call, so loading a sample twice never collides.
|
||||
@@ -44,6 +52,7 @@ import {
|
||||
MODEL_VERSION,
|
||||
type ModelNode,
|
||||
type Polarity,
|
||||
type SimSpec,
|
||||
} from "./types"
|
||||
|
||||
/** A loadable example: a title and one-line blurb for the menu, plus a builder. */
|
||||
@@ -58,8 +67,13 @@ function link(source: ModelNode, target: ModelNode, polarity: Polarity): Informa
|
||||
return { id: newId("link"), source: source.id, target: target.id, polarity }
|
||||
}
|
||||
|
||||
function model(name: string, nodes: ModelNode[], infoLinks: InformationLink[]): Model {
|
||||
return { version: MODEL_VERSION, id: newId("model"), name, nodes, infoLinks }
|
||||
function model(
|
||||
name: string,
|
||||
nodes: ModelNode[],
|
||||
infoLinks: InformationLink[],
|
||||
sim?: SimSpec,
|
||||
): Model {
|
||||
return { version: MODEL_VERSION, id: newId("model"), name, nodes, infoLinks, sim }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,6 +84,8 @@ function model(name: string, nodes: ModelNode[], infoLinks: InformationLink[]):
|
||||
function bathtub(): Model {
|
||||
const source = makeCloud({ x: -280, y: 0 })
|
||||
const water = makeStock({ x: 0, y: 0 }, "Water")
|
||||
water.initialValue = 20
|
||||
water.unit = "L"
|
||||
const sink = makeCloud({ x: 280, y: 0 })
|
||||
const filling = makeFlow(
|
||||
midpoint(source.position, water.position),
|
||||
@@ -78,7 +94,15 @@ function bathtub(): Model {
|
||||
water.id,
|
||||
)
|
||||
const emptying = makeFlow(midpoint(water.position, sink.position), "emptying", water.id, sink.id)
|
||||
return model("Bathtub", [source, water, sink, filling, emptying], [])
|
||||
// No Information Links, so each rate is a plain Constant. A faster inflow than
|
||||
// outflow means Water rises in a straight line — accumulation with no feedback.
|
||||
filling.rule = { kind: "constant", value: 5 }
|
||||
emptying.rule = { kind: "constant", value: 3 }
|
||||
return model("Bathtub", [source, water, sink, filling, emptying], [], {
|
||||
start: 0,
|
||||
stop: 40,
|
||||
dt: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,13 +116,22 @@ function savings(): Model {
|
||||
// visible Reinforcing loop instead of overlapping the inflow pipe.
|
||||
const source = makeCloud({ x: -240, y: -80 })
|
||||
const balance = makeStock({ x: 120, y: 40 }, "Balance")
|
||||
balance.initialValue = 1000
|
||||
balance.unit = "$"
|
||||
const interest = makeFlow(
|
||||
midpoint(source.position, balance.position),
|
||||
"interest",
|
||||
source.id,
|
||||
balance.id,
|
||||
)
|
||||
return model("Savings account", [source, balance, interest], [link(balance, interest, "+")])
|
||||
// interest = 5% × Balance (the `+` link). A Stock feeding its own inflow → the
|
||||
// Reinforcing loop runs as exponential growth.
|
||||
interest.rule = { kind: "proportional", factor: 0.05 }
|
||||
return model("Savings account", [source, balance, interest], [link(balance, interest, "+")], {
|
||||
start: 0,
|
||||
stop: 40,
|
||||
dt: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,13 +142,20 @@ function savings(): Model {
|
||||
*/
|
||||
function coffee(): Model {
|
||||
const coffee = makeStock({ x: -200, y: 0 }, "Coffee")
|
||||
coffee.initialValue = 90
|
||||
coffee.unit = "°C"
|
||||
const sink = makeCloud({ x: 200, y: 0 })
|
||||
const cooling = makeFlow(midpoint(coffee.position, sink.position), "cooling", coffee.id, sink.id)
|
||||
// cooling = 0.1 × (Coffee − room): the `+` input is the level, the `−` the target.
|
||||
// An outflow closing the gap to room temperature → the Balancing loop settles there.
|
||||
cooling.rule = { kind: "gap", factor: 0.1 }
|
||||
const room = makeConverter({ x: 0, y: -160 }, "room temperature")
|
||||
room.rule = { kind: "constant", value: 20 }
|
||||
return model(
|
||||
"Coffee cooling",
|
||||
[coffee, sink, cooling, room],
|
||||
[link(coffee, cooling, "+"), link(room, cooling, "-")],
|
||||
{ start: 0, stop: 60, dt: 1 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -132,11 +172,24 @@ function population(): Model {
|
||||
// Valves are placed by hand, not at the midpoint, to hold the steps.
|
||||
const source = makeCloud({ x: -360, y: -240 })
|
||||
const fertility = makeConverter({ x: -360, y: -40 }, "fertility")
|
||||
fertility.rule = { kind: "constant", value: 0.03 }
|
||||
const lifeExpectancy = makeConverter({ x: -360, y: 240 }, "life expectancy")
|
||||
// Wired into deaths for the Balancing loop's structure, but not yet read by the
|
||||
// rate: a faithful "deaths = Population ÷ life expectancy" needs a divide rule we
|
||||
// don't have, so deaths uses a flat mortality rate below. (See the gallery notes.)
|
||||
lifeExpectancy.rule = { kind: "constant", value: 70 }
|
||||
const people = makeStock({ x: 0, y: 0 }, "Population")
|
||||
people.initialValue = 100
|
||||
people.unit = "people"
|
||||
const births = makeFlow({ x: -160, y: -160 }, "births", source.id, people.id)
|
||||
// births = fertility × Population (both `+` inputs): more people and higher
|
||||
// fertility, more births — the Reinforcing engine.
|
||||
births.rule = { kind: "proportional", factor: 1 }
|
||||
const sink = makeCloud({ x: 360, y: 240 })
|
||||
const deaths = makeFlow({ x: 160, y: 160 }, "deaths", people.id, sink.id)
|
||||
// deaths = 2% of Population each step (its `+` input) — the Balancing drain. With
|
||||
// births at 3%, the Reinforcing loop wins and the population grows exponentially.
|
||||
deaths.rule = { kind: "proportional", factor: 0.02 }
|
||||
return model(
|
||||
"Population",
|
||||
[source, people, sink, births, deaths, fertility, lifeExpectancy],
|
||||
@@ -146,34 +199,47 @@ function population(): Model {
|
||||
link(people, deaths, "+"),
|
||||
link(lifeExpectancy, deaths, "-"),
|
||||
],
|
||||
{ start: 0, stop: 100, dt: 1 },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Limits to growth — the S-curve, and the first model where two loops fight over
|
||||
* one Flow. Yeast multiplies the more there is of it (Yeast → [+] → growth: a
|
||||
* Reinforcing loop), but the fuller the vat the more crowding holds growth back
|
||||
* (Yeast → [+] → crowding → [−] → growth: a Balancing loop). Carrying capacity is
|
||||
* a *constant* Converter — no inputs — that sets how soon crowding bites; it feeds
|
||||
* the balancing loop without sitting on any cycle.
|
||||
* Limits to growth — the S-curve, where a Reinforcing engine meets a Balancing
|
||||
* brake. Yeast multiplies the more there is of it (Yeast → [+] → growth: a
|
||||
* Reinforcing inflow), but crowding rises with the population (Yeast → [+] →
|
||||
* crowding) and drives a die-off that grows with the *square* of the Yeast
|
||||
* (Yeast, crowding → [+] → die-off → drains Yeast: a Balancing outflow). Growth
|
||||
* wins early, the die-off wins late, so Yeast settles where they balance (≈1000)
|
||||
* — the classic sigmoid, with *both* loops visible to the detector. (A named
|
||||
* "carrying capacity" would want a divide rule we don't have yet; here the ceiling
|
||||
* falls out of the growth and die-off rates.)
|
||||
*/
|
||||
function limitsToGrowth(): Model {
|
||||
const source = makeCloud({ x: -280, y: 0 })
|
||||
const yeast = makeStock({ x: 40, y: 0 }, "Yeast")
|
||||
yeast.initialValue = 20
|
||||
yeast.unit = "cells"
|
||||
const growth = makeFlow(midpoint(source.position, yeast.position), "growth", source.id, yeast.id)
|
||||
// crowding rides above the pipe; carrying capacity stacks above the Source on the
|
||||
// left, so the `capacity → crowding` link is a clean horizontal hop along the top.
|
||||
const capacity = makeConverter({ x: -280, y: -160 }, "carrying capacity")
|
||||
const crowding = makeConverter({ x: -40, y: -160 }, "crowding")
|
||||
// growth = 30% of Yeast (its `+` input): the Reinforcing engine.
|
||||
growth.rule = { kind: "proportional", factor: 0.3 }
|
||||
const sink = makeCloud({ x: 360, y: 0 })
|
||||
const dieOff = makeFlow(midpoint(yeast.position, sink.position), "die-off", yeast.id, sink.id)
|
||||
// die-off = factor × Yeast × crowding. With crowding ∝ Yeast it scales as Yeast²,
|
||||
// so the Balancing drain overtakes the linear growth and Yeast plateaus.
|
||||
dieOff.rule = { kind: "proportional", factor: 0.0003 }
|
||||
// crowding ≈ the population density (proportional to Yeast), what drives the die-off.
|
||||
const crowding = makeConverter({ x: 200, y: -160 }, "crowding")
|
||||
crowding.rule = { kind: "proportional", factor: 1 }
|
||||
return model(
|
||||
"Limits to growth",
|
||||
[source, yeast, growth, crowding, capacity],
|
||||
[source, yeast, growth, sink, dieOff, crowding],
|
||||
[
|
||||
link(yeast, growth, "+"),
|
||||
link(yeast, crowding, "+"),
|
||||
link(crowding, growth, "-"),
|
||||
link(capacity, crowding, "-"),
|
||||
link(yeast, dieOff, "+"),
|
||||
link(crowding, dieOff, "+"),
|
||||
],
|
||||
{ start: 0, stop: 40, dt: 1 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -192,6 +258,7 @@ function predatorPrey(): Model {
|
||||
// cross-stock loop traces a circuit through the open centre.
|
||||
const preySource = makeCloud({ x: -480, y: -140 })
|
||||
const rabbits = makeStock({ x: -80, y: -140 }, "Rabbits")
|
||||
rabbits.initialValue = 100
|
||||
const preySink = makeCloud({ x: 320, y: -140 })
|
||||
const rabbitBirths = makeFlow(
|
||||
midpoint(preySource.position, rabbits.position),
|
||||
@@ -199,14 +266,19 @@ function predatorPrey(): Model {
|
||||
preySource.id,
|
||||
rabbits.id,
|
||||
)
|
||||
// rabbits breed in proportion to themselves (Reinforcing) …
|
||||
rabbitBirths.rule = { kind: "proportional", factor: 0.08 }
|
||||
const predation = makeFlow(
|
||||
midpoint(rabbits.position, preySink.position),
|
||||
"predation",
|
||||
rabbits.id,
|
||||
preySink.id,
|
||||
)
|
||||
// … and are thinned by predation = rabbits × foxes (both `+`): the coupling term.
|
||||
predation.rule = { kind: "proportional", factor: 0.004 }
|
||||
const foxSource = makeCloud({ x: -480, y: 140 })
|
||||
const foxes = makeStock({ x: -80, y: 140 }, "Foxes")
|
||||
foxes.initialValue = 20
|
||||
const foxSink = makeCloud({ x: 320, y: 140 })
|
||||
const foxBirths = makeFlow(
|
||||
midpoint(foxSource.position, foxes.position),
|
||||
@@ -214,12 +286,17 @@ function predatorPrey(): Model {
|
||||
foxSource.id,
|
||||
foxes.id,
|
||||
)
|
||||
// foxes are born in proportion to the rabbits available to eat …
|
||||
foxBirths.rule = { kind: "proportional", factor: 0.02 }
|
||||
const foxDeaths = makeFlow(
|
||||
midpoint(foxes.position, foxSink.position),
|
||||
"fox deaths",
|
||||
foxes.id,
|
||||
foxSink.id,
|
||||
)
|
||||
// … and die off on their own. The lag around the loop makes the two populations
|
||||
// chase each other. (Forward Euler damps the orbit — see the gallery notes.)
|
||||
foxDeaths.rule = { kind: "proportional", factor: 0.2 }
|
||||
return model(
|
||||
"Predator and prey",
|
||||
[
|
||||
@@ -241,6 +318,7 @@ function predatorPrey(): Model {
|
||||
link(rabbits, foxBirths, "+"),
|
||||
link(foxes, foxDeaths, "+"),
|
||||
],
|
||||
{ start: 0, stop: 120, dt: 0.25 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -255,21 +333,35 @@ function predatorPrey(): Model {
|
||||
*/
|
||||
function epidemic(): Model {
|
||||
const susceptible = makeStock({ x: -280, y: 0 }, "Susceptible")
|
||||
susceptible.initialValue = 990
|
||||
susceptible.unit = "people"
|
||||
const infected = makeStock({ x: 0, y: 0 }, "Infected")
|
||||
infected.initialValue = 10
|
||||
infected.unit = "people"
|
||||
const recovered = makeStock({ x: 280, y: 0 }, "Recovered")
|
||||
recovered.initialValue = 0
|
||||
recovered.unit = "people"
|
||||
const infection = makeFlow(
|
||||
midpoint(susceptible.position, infected.position),
|
||||
"infection",
|
||||
susceptible.id,
|
||||
infected.id,
|
||||
)
|
||||
// infection = infectivity × Susceptible × Infected (proportional reads all three
|
||||
// `+` inputs): the more carriers and the more susceptibles, the faster it spreads.
|
||||
// The non-negative-stock floor keeps Susceptible from being over-drained.
|
||||
infection.rule = { kind: "proportional", factor: 1 }
|
||||
const recovery = makeFlow(
|
||||
midpoint(infected.position, recovered.position),
|
||||
"recovery",
|
||||
infected.id,
|
||||
recovered.id,
|
||||
)
|
||||
// recovery = 15% of the Infected each step (its one `+` input).
|
||||
recovery.rule = { kind: "proportional", factor: 0.15 }
|
||||
const infectivity = makeConverter({ x: -140, y: -160 }, "infectivity")
|
||||
// Small, so infectivity × S × I stays a sane rate (R0 = infectivity·S₀/γ ≈ 2.6).
|
||||
infectivity.rule = { kind: "constant", value: 0.0004 }
|
||||
return model(
|
||||
"Epidemic",
|
||||
[susceptible, infected, recovered, infection, recovery, infectivity],
|
||||
@@ -279,6 +371,7 @@ function epidemic(): Model {
|
||||
link(infected, recovery, "+"),
|
||||
link(infectivity, infection, "+"),
|
||||
],
|
||||
{ start: 0, stop: 60, dt: 1 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -295,17 +388,22 @@ function epidemic(): Model {
|
||||
*/
|
||||
function tragedyOfTheCommons(): Model {
|
||||
const pasture = makeStock({ x: 0, y: 0 }, "Pasture")
|
||||
pasture.initialValue = 1000
|
||||
// Two symmetric herds: cattle enter from a Source on the outside, grass leaves
|
||||
// the Pasture downward to a Sink. The two `Pasture → growth` links are the weak
|
||||
// brake the trap overruns.
|
||||
const sourceA = makeCloud({ x: -640, y: 0 })
|
||||
const herdA = makeStock({ x: -360, y: 0 }, "Herd A")
|
||||
herdA.initialValue = 10
|
||||
const growthA = makeFlow(
|
||||
midpoint(sourceA.position, herdA.position),
|
||||
"growth A",
|
||||
sourceA.id,
|
||||
herdA.id,
|
||||
)
|
||||
// growth = herd × Pasture (both `+`): each herd grows the more cattle it has and
|
||||
// the more grass is left — a Reinforcing loop, braked only by the shared Pasture.
|
||||
growthA.rule = { kind: "proportional", factor: 0.0003 }
|
||||
const sinkA = makeCloud({ x: -200, y: 240 })
|
||||
const grazingA = makeFlow(
|
||||
midpoint(pasture.position, sinkA.position),
|
||||
@@ -313,14 +411,19 @@ function tragedyOfTheCommons(): Model {
|
||||
pasture.id,
|
||||
sinkA.id,
|
||||
)
|
||||
// grazing = 6% of the herd, drained from the *shared* Pasture (which never
|
||||
// regrows here): two appetites racing one stock down to bare dirt.
|
||||
grazingA.rule = { kind: "proportional", factor: 0.06 }
|
||||
const sourceB = makeCloud({ x: 640, y: 0 })
|
||||
const herdB = makeStock({ x: 360, y: 0 }, "Herd B")
|
||||
herdB.initialValue = 10
|
||||
const growthB = makeFlow(
|
||||
midpoint(sourceB.position, herdB.position),
|
||||
"growth B",
|
||||
sourceB.id,
|
||||
herdB.id,
|
||||
)
|
||||
growthB.rule = { kind: "proportional", factor: 0.0003 }
|
||||
const sinkB = makeCloud({ x: 200, y: 240 })
|
||||
const grazingB = makeFlow(
|
||||
midpoint(pasture.position, sinkB.position),
|
||||
@@ -328,6 +431,7 @@ function tragedyOfTheCommons(): Model {
|
||||
pasture.id,
|
||||
sinkB.id,
|
||||
)
|
||||
grazingB.rule = { kind: "proportional", factor: 0.06 }
|
||||
return model(
|
||||
"Tragedy of the commons",
|
||||
[pasture, sourceA, herdA, growthA, sinkA, grazingA, sourceB, herdB, growthB, sinkB, grazingB],
|
||||
@@ -339,6 +443,7 @@ function tragedyOfTheCommons(): Model {
|
||||
link(herdB, grazingB, "+"),
|
||||
link(pasture, growthB, "+"),
|
||||
],
|
||||
{ start: 0, stop: 60, dt: 1 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -357,24 +462,31 @@ function escalation(): Model {
|
||||
// cross there, where the R badge lands, so the whole loop reads at a glance.
|
||||
const blueSource = makeCloud({ x: -560, y: -120 })
|
||||
const blueArsenal = makeStock({ x: 280, y: -120 }, "Blue arsenal")
|
||||
blueArsenal.initialValue = 10
|
||||
const blueBuildup = makeFlow(
|
||||
midpoint(blueSource.position, blueArsenal.position),
|
||||
"Blue buildup",
|
||||
blueSource.id,
|
||||
blueArsenal.id,
|
||||
)
|
||||
// Each side builds in proportion to the other's arsenal (its one `+` input), so
|
||||
// the two feed each other: a Reinforcing loop with no brake → unbounded growth.
|
||||
blueBuildup.rule = { kind: "proportional", factor: 0.1 }
|
||||
const redSource = makeCloud({ x: -560, y: 120 })
|
||||
const redArsenal = makeStock({ x: 280, y: 120 }, "Red arsenal")
|
||||
redArsenal.initialValue = 12
|
||||
const redBuildup = makeFlow(
|
||||
midpoint(redSource.position, redArsenal.position),
|
||||
"Red buildup",
|
||||
redSource.id,
|
||||
redArsenal.id,
|
||||
)
|
||||
redBuildup.rule = { kind: "proportional", factor: 0.1 }
|
||||
return model(
|
||||
"Escalation",
|
||||
[blueSource, blueArsenal, blueBuildup, redSource, redArsenal, redBuildup],
|
||||
[link(blueArsenal, redBuildup, "+"), link(redArsenal, blueBuildup, "+")],
|
||||
{ start: 0, stop: 40, dt: 1 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -396,13 +508,22 @@ function fixesThatFail(): Model {
|
||||
// the diagonal back to Congestion. Placed by hand, not midpoint, to hold the column.
|
||||
const source = makeCloud({ x: -420, y: 120 })
|
||||
const congestion = makeStock({ x: 300, y: 120 }, "Congestion")
|
||||
congestion.initialValue = 50
|
||||
const driving = makeFlow({ x: -120, y: 120 }, "driving", source.id, congestion.id)
|
||||
// driving = 1.5 × road building (its `+` input): every new road induces *more*
|
||||
// traffic than it cleared — the side effect that refills the symptom.
|
||||
driving.rule = { kind: "proportional", factor: 1.5 }
|
||||
const sink = makeCloud({ x: 300, y: -160 })
|
||||
const roadBuilding = makeFlow({ x: -120, y: -160 }, "road building", congestion.id, sink.id)
|
||||
// road building = 40% of Congestion (its `+` input), draining it: the Balancing
|
||||
// fix. But induced driving outweighs it, so the Reinforcing loop wins and
|
||||
// Congestion climbs anyway — you can't build your way out of traffic.
|
||||
roadBuilding.rule = { kind: "proportional", factor: 0.4 }
|
||||
return model(
|
||||
"Fixes that fail",
|
||||
[source, congestion, driving, sink, roadBuilding],
|
||||
[link(congestion, roadBuilding, "+"), link(roadBuilding, driving, "+")],
|
||||
{ start: 0, stop: 30, dt: 1 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -422,13 +543,18 @@ function driftToLowPerformance(): Model {
|
||||
// Reinforcing spiral cross in the open centre, where the R badge lands.
|
||||
const source = makeCloud({ x: -560, y: 120 })
|
||||
const performance = makeStock({ x: -160, y: 120 }, "Performance")
|
||||
performance.initialValue = 40
|
||||
const improvement = makeFlow(
|
||||
midpoint(source.position, performance.position),
|
||||
"improvement",
|
||||
source.id,
|
||||
performance.id,
|
||||
)
|
||||
// improvement closes the gap upward: 10% of (Standard − Performance), pulling
|
||||
// Performance toward the Standard.
|
||||
improvement.rule = { kind: "gap", factor: 0.1 }
|
||||
const standard = makeStock({ x: 160, y: -120 }, "Standard")
|
||||
standard.initialValue = 80
|
||||
const sink = makeCloud({ x: 560, y: -120 })
|
||||
const slippage = makeFlow(
|
||||
midpoint(standard.position, sink.position),
|
||||
@@ -436,6 +562,10 @@ function driftToLowPerformance(): Model {
|
||||
standard.id,
|
||||
sink.id,
|
||||
)
|
||||
// slippage erodes the *same* gap from the other side: the Standard drifts down
|
||||
// toward actual Performance. Both gaps close, so they meet — the Standard has
|
||||
// sagged from 80 to the middle, the eroding-goal trap.
|
||||
slippage.rule = { kind: "gap", factor: 0.1 }
|
||||
return model(
|
||||
"Drift to low performance",
|
||||
[source, performance, improvement, standard, sink, slippage],
|
||||
@@ -445,6 +575,69 @@ function driftToLowPerformance(): Model {
|
||||
link(standard, slippage, "+"),
|
||||
link(performance, slippage, "-"),
|
||||
],
|
||||
{ start: 0, stop: 60, dt: 1 },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Overshoot and collapse — the dark twin of "Limits to growth". The same
|
||||
* Reinforcing engine runs, but the limit here is a *non-renewable* Resource that
|
||||
* only depletes: a Stock with no inflow, the first in the gallery. An economy
|
||||
* (Capital) lives off it — extraction grows with both the Resource left and the
|
||||
* Capital deployed (Resource, Capital → [+] → extraction), and the revenue is
|
||||
* reinvested as new Capital (extraction → [+] → investment → Capital), so the loop
|
||||
* Capital → extraction → investment → Capital carries no `−` → Reinforcing. Capital
|
||||
* climbs and extraction accelerates, but every unit burned is gone for good, so the
|
||||
* Resource crosses the break-even level, the engine starves, and depreciation
|
||||
* (Capital → [+] → depreciation, a Balancing drain) takes Capital down: it peaks,
|
||||
* then collapses. Contrast "Predator and prey", whose prey regrows and so settles
|
||||
* into oscillation — a finite Resource cannot, so it overshoots and crashes instead.
|
||||
*/
|
||||
function overshootAndCollapse(): Model {
|
||||
// Resource on the left drains only downward (no inflow). Capital on the right runs
|
||||
// a full Source → investment → Capital → depreciation → Sink column. The two
|
||||
// coupling links — Capital → extraction and extraction → investment — cross in the
|
||||
// open centre, where the R badge lands.
|
||||
const resource = makeStock({ x: -240, y: 0 }, "Resource")
|
||||
resource.initialValue = 1000
|
||||
const extractionSink = makeCloud({ x: -240, y: 360 })
|
||||
const extraction = makeFlow({ x: -240, y: 160 }, "extraction", resource.id, extractionSink.id)
|
||||
// extraction = factor × Resource × Capital (both `+`): more capital extracts
|
||||
// faster, scarcer resource slower. The bilinear term the non-negative floor tames.
|
||||
extraction.rule = { kind: "proportional", factor: 0.0004 }
|
||||
const capital = makeStock({ x: 240, y: 0 }, "Capital")
|
||||
capital.initialValue = 5
|
||||
const investmentSource = makeCloud({ x: 240, y: -360 })
|
||||
const investment = makeFlow({ x: 240, y: -160 }, "investment", investmentSource.id, capital.id)
|
||||
// investment = factor × extraction (its one `+` input): the revenue reinvested —
|
||||
// a Flow feeding a Flow, the edge that closes the Reinforcing loop through Capital.
|
||||
investment.rule = { kind: "proportional", factor: 0.5 }
|
||||
const depreciationSink = makeCloud({ x: 240, y: 360 })
|
||||
const depreciation = makeFlow({ x: 240, y: 160 }, "depreciation", capital.id, depreciationSink.id)
|
||||
// depreciation = factor × Capital (its `+` input): the Balancing drain that wins
|
||||
// once the Resource can no longer feed investment.
|
||||
depreciation.rule = { kind: "proportional", factor: 0.04 }
|
||||
return model(
|
||||
"Overshoot and collapse",
|
||||
[
|
||||
resource,
|
||||
extractionSink,
|
||||
extraction,
|
||||
capital,
|
||||
investmentSource,
|
||||
investment,
|
||||
depreciationSink,
|
||||
depreciation,
|
||||
],
|
||||
[
|
||||
link(resource, extraction, "+"),
|
||||
link(capital, extraction, "+"),
|
||||
link(extraction, investment, "+"),
|
||||
link(capital, depreciation, "+"),
|
||||
],
|
||||
// Capital starts at 5, overshoots to ~250 by t≈39, and collapses back near its
|
||||
// starting level by t=150 — the full boom-and-bust arc, no dead tail.
|
||||
{ start: 0, stop: 150, dt: 1 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -501,4 +694,9 @@ export const SAMPLES: Sample[] = [
|
||||
blurb: "Goals erode toward actual: a Reinforcing slide downhill.",
|
||||
build: driftToLowPerformance,
|
||||
},
|
||||
{
|
||||
title: "Overshoot and collapse",
|
||||
blurb: "A growth engine burns a finite Resource: it peaks, then crashes.",
|
||||
build: overshootAndCollapse,
|
||||
},
|
||||
]
|
||||
|
||||
228
src/model/simulation.ts
Normal file
228
src/model/simulation.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Simulation engine (ADR-0004) — what makes a Model *alive*. Pure data in, time
|
||||
* series out; it knows nothing of Vue or the canvas, so it is trivially testable
|
||||
* and runs off any reactive frame.
|
||||
*
|
||||
* The system-dynamics loop, one step of `dt`:
|
||||
* 1. Evaluate the instantaneous network — every Converter and Flow — in
|
||||
* dependency order, reading the *current* Stock values. A Converter/Flow
|
||||
* depends on the elements that link into it (ADR-0004: the Information Link
|
||||
* *is* the dependency); Stocks are state, available without ordering.
|
||||
* 2. Integrate every Stock *simultaneously* (forward Euler):
|
||||
* `stock += dt × (Σ inflow rates − Σ outflow rates)`. All net rates are read
|
||||
* from the same pre-update state, so updating one Stock never feeds another
|
||||
* within the same step.
|
||||
*
|
||||
* A cycle in the wiring is legitimate feedback only if it passes through a Stock
|
||||
* (the Stock supplies last-step state and breaks the within-step dependency). A
|
||||
* cycle among only Converters/Flows is an **algebraic loop** — unorderable, and
|
||||
* rejected by `evaluationOrder` / surfaced by `checkSimReady`.
|
||||
*/
|
||||
import {
|
||||
type ConverterNode,
|
||||
DEFAULT_SIM_SPEC,
|
||||
type FlowNode,
|
||||
type InformationLink,
|
||||
type Model,
|
||||
type Rule,
|
||||
type SimSpec,
|
||||
} from "./types"
|
||||
|
||||
/** Thrown when a Model cannot be simulated as wired (e.g. an algebraic loop). */
|
||||
export class SimulationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = "SimulationError"
|
||||
}
|
||||
}
|
||||
|
||||
/** A completed run: aligned `times` and per-element value tracks. */
|
||||
export interface Run {
|
||||
/** The time at each recorded sample, `start … stop` in steps of `dt`. */
|
||||
times: number[]
|
||||
/** nodeId → its value at each time index (Stocks, Flows, Converters; not Clouds). */
|
||||
series: Map<string, number[]>
|
||||
/** True if the run ran past representable numbers and stopped early (see below). */
|
||||
diverged: boolean
|
||||
}
|
||||
|
||||
/** A Converter or Flow — the stateless, instantaneous elements an order applies to. */
|
||||
type Instant = ConverterNode | FlowNode
|
||||
|
||||
/**
|
||||
* Order the instantaneous network so each element is computed after everything
|
||||
* it reads. Throws `SimulationError` on an algebraic loop (a Converter/Flow cycle
|
||||
* with no Stock to break it). Stocks are excluded — they are state, not computed.
|
||||
*/
|
||||
export function evaluationOrder(model: Model): Instant[] {
|
||||
const instant = model.nodes.filter(
|
||||
(node): node is Instant => node.kind === "flow" || node.kind === "converter",
|
||||
)
|
||||
const ids = new Set(instant.map((node) => node.id))
|
||||
|
||||
// deps[x] = the instantaneous elements x reads (links from another Flow/Converter).
|
||||
const deps = new Map<string, Set<string>>(instant.map((node) => [node.id, new Set<string>()]))
|
||||
for (const link of model.infoLinks) {
|
||||
if (ids.has(link.source) && ids.has(link.target)) deps.get(link.target)?.add(link.source)
|
||||
}
|
||||
|
||||
const order: Instant[] = []
|
||||
const resolved = new Set<string>()
|
||||
while (order.length < instant.length) {
|
||||
const next = instant.find(
|
||||
(node) =>
|
||||
!resolved.has(node.id) && [...(deps.get(node.id) ?? [])].every((d) => resolved.has(d)),
|
||||
)
|
||||
if (!next) {
|
||||
const names = instant
|
||||
.filter((node) => !resolved.has(node.id))
|
||||
.map((node) => node.name)
|
||||
.join(", ")
|
||||
throw new SimulationError(
|
||||
`Algebraic loop: ${names} depend on each other with no Stock to break the cycle.`,
|
||||
)
|
||||
}
|
||||
resolved.add(next.id)
|
||||
order.push(next)
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
/** Evaluate one Rule given a way to read the values feeding in via `links`. */
|
||||
function evalRule(rule: Rule, links: InformationLink[], valueOf: (id: string) => number): number {
|
||||
switch (rule.kind) {
|
||||
case "constant":
|
||||
return rule.value
|
||||
case "proportional": {
|
||||
// factor × the product of every `+`-polarity input (one input → factor × it).
|
||||
let value = rule.factor
|
||||
for (const link of links) if (link.polarity === "+") value *= valueOf(link.source)
|
||||
return value
|
||||
}
|
||||
case "gap": {
|
||||
// factor × (level − target): the `+` input is the level, the `−` the target.
|
||||
const level = links.find((link) => link.polarity === "+")
|
||||
const target = links.find((link) => link.polarity === "-")
|
||||
return (
|
||||
rule.factor * ((level ? valueOf(level.source) : 0) - (target ? valueOf(target.source) : 0))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the Model and return aligned time series. Assumes a sim-ready Model
|
||||
* (see `checkSimReady`); a missing rule evaluates to 0 and a missing initial
|
||||
* value to 0 rather than throwing, so a half-built Model still produces a plot.
|
||||
* Throws `SimulationError` only on an algebraic loop, which has no defined order.
|
||||
*/
|
||||
export function simulate(model: Model, spec: SimSpec = model.sim ?? DEFAULT_SIM_SPEC): Run {
|
||||
const order = evaluationOrder(model)
|
||||
const nodeById = new Map(model.nodes.map((node) => [node.id, node]))
|
||||
|
||||
const inbound = new Map<string, InformationLink[]>()
|
||||
for (const link of model.infoLinks) {
|
||||
const list = inbound.get(link.target)
|
||||
if (list) list.push(link)
|
||||
else inbound.set(link.target, [link])
|
||||
}
|
||||
|
||||
const stocks = model.nodes.filter((node) => node.kind === "stock")
|
||||
const flows = model.nodes.filter((node): node is FlowNode => node.kind === "flow")
|
||||
|
||||
const stockValues = new Map<string, number>(stocks.map((s) => [s.id, s.initialValue ?? 0]))
|
||||
|
||||
const times: number[] = []
|
||||
const series = new Map<string, number[]>()
|
||||
for (const node of model.nodes) if (node.kind !== "cloud") series.set(node.id, [])
|
||||
let diverged = false
|
||||
|
||||
// dt ≤ 0 would never advance (and 0 diverges); a non-positive step means no run.
|
||||
const steps = spec.dt > 0 ? Math.max(0, Math.floor((spec.stop - spec.start) / spec.dt)) : 0
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
// A run-away Reinforcing loop over a long horizon can exceed what a float
|
||||
// holds. Stop at the last valid sample and flag it rather than plotting NaN.
|
||||
if (!stocks.every((s) => Number.isFinite(stockValues.get(s.id) ?? 0))) {
|
||||
diverged = true
|
||||
break
|
||||
}
|
||||
|
||||
// 1. Evaluate the instantaneous network from the current Stock values.
|
||||
const computed = new Map<string, number>()
|
||||
const valueOf = (id: string): number => {
|
||||
const node = nodeById.get(id)
|
||||
if (!node) return 0
|
||||
if (node.kind === "stock") return stockValues.get(id) ?? 0
|
||||
if (node.kind === "cloud") return 0
|
||||
return computed.get(id) ?? 0
|
||||
}
|
||||
for (const node of order) {
|
||||
computed.set(
|
||||
node.id,
|
||||
node.rule ? evalRule(node.rule, inbound.get(node.id) ?? [], valueOf) : 0,
|
||||
)
|
||||
}
|
||||
|
||||
// Record this sample (Stocks at their current value, Flows/Converters as just computed).
|
||||
times.push(spec.start + i * spec.dt)
|
||||
for (const s of stocks) series.get(s.id)?.push(stockValues.get(s.id) ?? 0)
|
||||
for (const node of order) series.get(node.id)?.push(computed.get(node.id) ?? 0)
|
||||
|
||||
if (i >= steps) break
|
||||
|
||||
// 2. Integrate every Stock simultaneously (forward Euler) — but with
|
||||
// non-negative stocks: an outflow can't drain more than its source holds this
|
||||
// step. Scale a stock's competing outflows together if they would overdraw,
|
||||
// and apply the scaled rate to both ends so quantity is conserved. You can't
|
||||
// infect more people than are susceptible — and that floor is exactly what
|
||||
// stops a bilinear model (S × I) from flipping sign and diverging.
|
||||
const rate = new Map<string, number>(flows.map((f) => [f.id, computed.get(f.id) ?? 0]))
|
||||
for (const s of stocks) {
|
||||
const available = stockValues.get(s.id) ?? 0
|
||||
const drains = flows.filter((f) => f.source === s.id && (rate.get(f.id) ?? 0) > 0)
|
||||
const totalOut = drains.reduce((sum, f) => sum + (rate.get(f.id) ?? 0), 0)
|
||||
if (totalOut * spec.dt > available) {
|
||||
const scale = available / (totalOut * spec.dt)
|
||||
for (const f of drains) rate.set(f.id, (rate.get(f.id) ?? 0) * scale)
|
||||
}
|
||||
}
|
||||
|
||||
const next = new Map(stockValues)
|
||||
for (const s of stocks) {
|
||||
let net = 0
|
||||
for (const flow of flows) {
|
||||
const r = rate.get(flow.id) ?? 0
|
||||
if (flow.target === s.id) net += r // an inflow fills it
|
||||
if (flow.source === s.id) net -= r // an outflow drains it
|
||||
}
|
||||
next.set(s.id, (stockValues.get(s.id) ?? 0) + spec.dt * net)
|
||||
}
|
||||
for (const [id, value] of next) stockValues.set(id, value)
|
||||
}
|
||||
|
||||
return { times, series, diverged }
|
||||
}
|
||||
|
||||
/**
|
||||
* What stands between this Model and a run, as human-readable lines (empty array
|
||||
* = ready). Distinct from structural validity (validation.ts): a Model can be a
|
||||
* perfectly valid diagram yet not carry the numbers a simulation needs.
|
||||
*/
|
||||
export function checkSimReady(model: Model): string[] {
|
||||
const problems: string[] = []
|
||||
for (const node of model.nodes) {
|
||||
if (node.kind === "stock" && node.initialValue === undefined) {
|
||||
problems.push(`${node.name} has no initial value.`)
|
||||
}
|
||||
if ((node.kind === "flow" || node.kind === "converter") && !node.rule) {
|
||||
problems.push(`${node.name} has no rule yet.`)
|
||||
}
|
||||
}
|
||||
try {
|
||||
evaluationOrder(model)
|
||||
} catch (error) {
|
||||
if (error instanceof SimulationError) problems.push(error.message)
|
||||
else throw error
|
||||
}
|
||||
return problems
|
||||
}
|
||||
@@ -31,6 +31,37 @@ export interface Position {
|
||||
y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* How a Flow's rate or a Converter's value is computed — a small fixed
|
||||
* vocabulary, *not* a free-form formula, so a Model stays valid by construction
|
||||
* and teaches structure → behaviour (ADR-0004). Operands are read from the
|
||||
* element's inbound Information Links; Polarity selects each operand's role.
|
||||
*
|
||||
* - `constant` — a fixed number; reads nothing. (→ linear Stock change)
|
||||
* - `proportional` — `factor ×` the product of its `+`-polarity inputs.
|
||||
* (→ exponential growth/decay)
|
||||
* - `gap` — `factor × (level − target)`, where the `+` input is the
|
||||
* level and the `−` input the target. (→ goal-seeking)
|
||||
*/
|
||||
export type Rule =
|
||||
| { kind: "constant"; value: number }
|
||||
| { kind: "proportional"; factor: number }
|
||||
| { kind: "gap"; factor: number }
|
||||
|
||||
/**
|
||||
* The run parameters for a simulation: integrate from `start` to `stop` in steps
|
||||
* of `dt` (forward Euler). Optional on a Model; absent means the diagram phase or
|
||||
* a not-yet-simulated Model — the engine falls back to `DEFAULT_SIM_SPEC`.
|
||||
*/
|
||||
export interface SimSpec {
|
||||
start: number
|
||||
stop: number
|
||||
dt: number
|
||||
}
|
||||
|
||||
/** Sensible default run when a Model carries no `sim` of its own. */
|
||||
export const DEFAULT_SIM_SPEC: SimSpec = { start: 0, stop: 100, dt: 1 }
|
||||
|
||||
interface BaseNode {
|
||||
id: string
|
||||
position: Position
|
||||
@@ -45,6 +76,8 @@ export interface StockNode extends BaseNode {
|
||||
name: string
|
||||
/** Initial accumulated quantity. Optional in the diagram phase; the simulator reads it. */
|
||||
initialValue?: number
|
||||
/** Unit of the quantity, e.g. "°C", "people", "$" — shown beside the value (display only). */
|
||||
unit?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,8 +93,8 @@ export interface FlowNode extends BaseNode {
|
||||
source: string
|
||||
/** Node id of the Stock or Cloud the Flow feeds into. */
|
||||
target: string
|
||||
/** Rate expression, recomputed each instant. Optional in the diagram phase. */
|
||||
equation?: string
|
||||
/** How its rate is computed each instant (ADR-0004). Optional in the diagram phase. */
|
||||
rule?: Rule
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,8 +104,8 @@ export interface FlowNode extends BaseNode {
|
||||
export interface ConverterNode extends BaseNode {
|
||||
kind: "converter"
|
||||
name: string
|
||||
/** Expression or constant. Optional in the diagram phase. */
|
||||
equation?: string
|
||||
/** How its value is computed each instant (ADR-0004). Optional in the diagram phase. */
|
||||
rule?: Rule
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,4 +140,6 @@ export interface Model {
|
||||
name: string
|
||||
nodes: ModelNode[]
|
||||
infoLinks: InformationLink[]
|
||||
/** Run parameters for simulation (ADR-0004). Optional; absent → not yet simulated. */
|
||||
sim?: SimSpec
|
||||
}
|
||||
|
||||
@@ -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,58 @@ 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) a Stock's display unit (e.g. "°C"). Empty clears it. No-op when unchanged. */
|
||||
function setUnit(id: string, unit: string): void {
|
||||
const node = findNode(id)
|
||||
if (!node || node.kind !== "stock") return
|
||||
const next = unit.trim() || undefined
|
||||
if (node.unit === next) return
|
||||
record()
|
||||
if (next === undefined) delete node.unit
|
||||
else node.unit = next
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +289,10 @@ export const useModelStore = defineStore("model", () => {
|
||||
beginInteraction,
|
||||
moveNode,
|
||||
renameNode,
|
||||
setInitialValue,
|
||||
setUnit,
|
||||
setRule,
|
||||
setSimSpec,
|
||||
removeNode,
|
||||
removeInfoLink,
|
||||
toggleLinkPolarity,
|
||||
|
||||
151
src/store/simulation.ts
Normal file
151
src/store/simulation.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -3,17 +3,26 @@
|
||||
@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" {
|
||||
name: "light";
|
||||
default: true;
|
||||
--color-primary: #16a34a;
|
||||
--color-primary: #ff9ff3;
|
||||
}
|
||||
|
||||
/* DaisyUI's default (no data-theme) resolves to its *dark* theme via
|
||||
prefers-color-scheme, with a selector (`:root:not([data-theme])`) that outranks
|
||||
a custom light theme's `:where(:root)` — so overriding only the light theme
|
||||
never reached the buttons. Force the brand primary across every theme. */
|
||||
:root {
|
||||
--color-primary: #ff9ff3 !important;
|
||||
--color-primary-content: #3d1f3a !important;
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
Reference in New Issue
Block a user