Compare commits

..

21 Commits

Author SHA1 Message Date
Julien Calixte
6a4fe59811 feat(samples): add "Overshoot and collapse" gallery sample
The twelfth sample fills the one classic dynamic the gallery was missing —
overshoot and collapse, the dynamic Thinking in Systems is built around. A
Reinforcing engine (Capital reinvesting its extraction revenue) runs on a
non-renewable Resource, the first Stock in the gallery with no inflow: it
overshoots the limit instead of settling at it, the dark twin of "Limits to
growth".

Buildable with the existing rule vocabulary (proportional only); the
non-negative-stock floor tames the bilinear extraction term as the Resource
runs out. Tuned against the simulator: Capital 5 → ~250 (t≈39) → ~5 by t=150,
Resource 1000 → ~6, no divergence. The loop detector classifies the three
loops as expected (R: investment→Capital→extraction; B: depreciation→Capital;
B: extraction→Resource).
2026-06-20 15:15:42 +02:00
Julien Calixte
3a65bf5d59 fix(editor): keep the stock box fixed so simulating doesn't move its arrows
The in-flow value line grew the card downward, dropping its vertical centre —
and the left/right handles with it, so connected pipes and links shifted. Render
the value as an out-of-flow overlay below the box; the card keeps its size and
the handles stay put.
2026-06-20 15:14:13 +02:00
Julien Calixte
8e1e313b20 refactor(editor): inline the header logo so it tracks the theme primary 2026-06-20 15:05:31 +02:00
Julien Calixte
d570425402 fix(editor): align the playhead with the chart's left gutter
valToPos returns CSS pixels relative to the plot area, but the overlay
sits on the canvas, so the playhead was short by the y-axis width and
started before x=0.
2026-06-20 15:01:21 +02:00
Julien Calixte
2c7ec6a6ec style: recolour the favicon to the #ff9ff3 primary 2026-06-20 14:58:34 +02:00
Julien Calixte
ef0cc2878b fix(editor): stop the t-readout wrapping so playback doesn't reflow
The fixed-width time readout wrapped to two lines for fractional times (dt 0.25
→ 't = 70.25'), growing the panel a row each frame and reflowing the whole
panel — the real flicker. Pin its width and forbid wrapping.
2026-06-20 14:56:42 +02:00
Julien Calixte
f38cf49f25 style: force the primary colour to #ff9ff3 across every theme
The default (no data-theme) resolves to DaisyUI's dark theme, whose
:root:not([data-theme]) selector outranks a custom light theme — so the
previous light-only override never reached buttons in dark mode. Force it with
an !important :root override that wins regardless of theme.
2026-06-20 14:53:41 +02:00
Julien Calixte
d5cda95c3e fix(editor): stop the behaviour chart flickering during playback
The playhead was a canvas line redrawn by redrawing the whole uPlot chart on
every frame, which flickers. Draw it as a DOM overlay that slides over the
canvas instead, so the canvas is only redrawn when the data actually changes.
2026-06-20 14:47:08 +02:00
Julien Calixte
dc30e5f890 style: set the primary theme colour to #2e86de 2026-06-20 14:47:08 +02:00
Julien Calixte
382fdddc68 feat(editor): give each Stock a unit
A Stock can carry a display unit (°C, people, $, …): a new optional field, an
inspector text input, validation + round-trip in io, and a readout beside the
live value on the canvas. Equip the demonstrative samples (Water L, Balance $,
Coffee °C, Population/Epidemic people, Yeast cells). Also bumps the fill gauge
opacity for a more vivid level.
2026-06-20 14:47:08 +02:00
Julien Calixte
ed6f011e69 feat(editor): reset the playhead when a different model loads 2026-06-20 14:39:06 +02:00
Julien Calixte
1d65383bbf fix(editor): make the samples list scrollable so it clears the panel 2026-06-20 14:39:02 +02:00
Julien Calixte
3fc0e23ec7 style(editor): make the stock fill gauge more vivid 2026-06-20 14:37:55 +02:00
Julien Calixte
69435b1315 feat(editor): animate the canvas in step with the simulation
A simulation store owns one run and one playhead, so the chart and the canvas
read off the same instant. While the panel is open, Stocks show a live value and
a fill gauge, Flows and Converters their current value; a play/pause + scrubber
drives the playhead (a wall-clock-paced clock in usePlayback), and a marker
tracks it on the chart.
2026-06-20 14:35:45 +02:00
Julien Calixte
c361b05ec4 feat(editor): chart behaviour over time with uPlot
Draw the simulation's Stock trajectories with uPlot (ADR-0005): a real time
axis, hover-to-read values, and a playhead-marker hook the canvas animation
drives. SimChart owns the imperative uPlot lifecycle behind a small prop API.
2026-06-20 14:35:35 +02:00
Julien Calixte
0c8f89d14e fix(style): load Sono from the api.fonts.coollabs.io host
Switch the body font to Sono and point the @import at
api.fonts.coollabs.io. The bare fonts.coollabs.io host serves the
marketing homepage (HTML), not CSS, so the @font-face rules never
loaded and the font silently fell back to system-ui.
2026-06-20 14:28:58 +02:00
Julien Calixte
f0d207c4d5 feat(model): make limits-to-growth a runnable two-flow logistic
Recast the S-curve as a Reinforcing inflow plus a crowding-driven die-off that
grows with Yeast², using only the existing proportional rule. Yeast now climbs
20 → ~1000 as a true sigmoid, and the detector still classifies it R + B — the
balancing loop stays visible, which a single "logistic" rule would have hidden.
Drops the carrying-capacity converter (a faithful one needs a divide rule).
2026-06-20 14:03:43 +02:00
Julien Calixte
34df540e4a feat(editor): add a simulation panel and an element inspector
- ResultsPanel: a Simulate panel that charts every Stock over time (hand-built
  SVG), with start/stop/dt run controls and a divergence warning
- Inspector: edit a selected Stock's initial value or a Flow/Converter's rule
- store: undoable setInitialValue / setRule / setSimSpec actions
2026-06-20 13:56:19 +02:00
Julien Calixte
40dc9fba43 feat(model): equip the sample gallery with runnable values
Give 10 of 11 samples initial values, rules, and a run window so they simulate
on load: Bathtub (linear), Savings/Escalation/Fixes-that-fail (Reinforcing),
Coffee (Balancing), Population, Epidemic (SIR), Tragedy (overshoot/collapse),
Drift (eroding goal), Predator-prey (damped). Limits to growth is left
diagram-only — its S-curve needs a saturating rule the vocabulary lacks.
2026-06-20 13:56:19 +02:00
Julien Calixte
5b6e830778 feat(model): simulate models from rules over information links
Phase 2 brings Models to life. ADR-0004: behaviour comes from a small fixed
vocabulary of rules (Constant / Proportional / Gap) read over the inbound
Information Links, not free-form formulas — valid by construction.

- types: Rule union, SimSpec, initialValue; replaces the unused equation field
- simulation: forward-Euler engine, dependency-ordered evaluation, algebraic-loop
  detection, non-negative stocks, and a divergence guard
- io: validate and round-trip the new fields (F8)
2026-06-20 13:56:06 +02:00
Julien Calixte
964e621f0e fix(editor): match the flow arrowhead weight to its line
Vue Flow's open arrow renders at 0.625x the edge stroke (its 20-unit
viewBox is squeezed into a 12.5 marker box), so the chevron looked thin
against the 2.5px pipe. A marker strokeWidth of 20/12.5 cancels the
downscale, drawing the head at the line's weight at any width.
2026-06-20 12:43:27 +02:00
23 changed files with 1554 additions and 37 deletions

View File

@@ -19,6 +19,11 @@ Deployed at https://meadows.apoena.dev
substrate; the domain Model is the source of truth. substrate; the domain Model is the source of truth.
- [ADR-0003](./docs/adr/0003-flow-as-node-materialised-clouds.md) — a Flow is a - [ADR-0003](./docs/adr/0003-flow-as-node-materialised-clouds.md) — a Flow is a
node; Source/Sink clouds are materialised nodes. 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 ## Stack

View 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.

View 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.

View File

@@ -19,6 +19,7 @@
"daisyui": "^5.5.23", "daisyui": "^5.5.23",
"idb": "^8.0.3", "idb": "^8.0.3",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"uplot": "^1.6.32",
"vue": "^3.5.34" "vue": "^3.5.34"
}, },
"devDependencies": { "devDependencies": {

8
pnpm-lock.yaml generated
View File

@@ -26,6 +26,9 @@ importers:
pinia: pinia:
specifier: ^3.0.4 specifier: ^3.0.4
version: 3.0.4(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)) 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: vue:
specifier: ^3.5.34 specifier: ^3.5.34
version: 3.5.38(typescript@6.0.3) version: 3.5.38(typescript@6.0.3)
@@ -939,6 +942,9 @@ packages:
undici-types@7.18.2: undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
uplot@1.6.32:
resolution: {integrity: sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==}
vite@8.0.16: vite@8.0.16:
resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -1709,6 +1715,8 @@ snapshots:
undici-types@7.18.2: {} undici-types@7.18.2: {}
uplot@1.6.32: {}
vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0): vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0):
dependencies: dependencies:
lightningcss: 1.32.0 lightningcss: 1.32.0

View File

@@ -4,7 +4,7 @@
height="24" height="24"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="#16A34A" stroke="#ff9ff3"
stroke-width="2" stroke-width="2"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"

Before

Width:  |  Height:  |  Size: 550 B

After

Width:  |  Height:  |  Size: 550 B

View File

@@ -26,6 +26,7 @@ import {
} from "@vue-flow/core" } from "@vue-flow/core"
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from "vue" import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from "vue"
import { useAutosave } from "@/composables/useAutosave" import { useAutosave } from "@/composables/useAutosave"
import { usePlayback } from "@/composables/usePlayback"
import { parseModel, serializeModel } from "@/model/io" import { parseModel, serializeModel } from "@/model/io"
import { project } from "@/model/projection" import { project } from "@/model/projection"
import { type Sample, SAMPLES } from "@/model/samples" import { type Sample, SAMPLES } from "@/model/samples"
@@ -33,8 +34,10 @@ import { canConnect } from "@/model/validation"
import { useModelStore } from "@/store/model" import { useModelStore } from "@/store/model"
import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd" import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd"
import GlossPanel from "./GlossPanel.vue" import GlossPanel from "./GlossPanel.vue"
import Inspector from "./Inspector.vue"
import LoopOverlay from "./LoopOverlay.vue" import LoopOverlay from "./LoopOverlay.vue"
import Palette from "./Palette.vue" import Palette from "./Palette.vue"
import ResultsPanel from "./ResultsPanel.vue"
import InfoLinkEdge from "./edges/InfoLinkEdge.vue" import InfoLinkEdge from "./edges/InfoLinkEdge.vue"
import PipeEdge from "./edges/PipeEdge.vue" import PipeEdge from "./edges/PipeEdge.vue"
import CloudNode from "./nodes/CloudNode.vue" import CloudNode from "./nodes/CloudNode.vue"
@@ -48,6 +51,14 @@ const graph = computed(() => project(store.model))
const nodes = computed(() => graph.value.nodes) const nodes = computed(() => graph.value.nodes)
const edges = computed(() => graph.value.edges) const edges = computed(() => graph.value.edges)
// The simulation results panel (phase 2). Toggled from the header; the panel runs
// the Model and recomputes reactively while open.
const showResults = ref(false)
// 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 // Explicit shared id: useVueFlow() runs here in the parent setup, before
// <VueFlow> mounts. Pinning both to the same id guarantees they resolve to one // <VueFlow> mounts. Pinning both to the same id guarantees they resolve to one
// store instance, so the event hooks below actually fire. // store instance, so the event hooks below actually fire.
@@ -338,7 +349,28 @@ onBeforeUnmount(() => {
<template> <template>
<div class="flex h-screen flex-col bg-base-200"> <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"> <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> <h1 class="text-lg font-semibold">meadows</h1>
<span class="text-sm text-base-content/50"> <span class="text-sm text-base-content/50">
{{ store.nodeCount }} {{ store.nodeCount === 1 ? "element" : "elements" }} {{ store.nodeCount }} {{ store.nodeCount === 1 ? "element" : "elements" }}
@@ -348,7 +380,7 @@ onBeforeUnmount(() => {
<button tabindex="0" class="btn btn-ghost btn-sm">Samples</button> <button tabindex="0" class="btn btn-ghost btn-sm">Samples</button>
<ul <ul
tabindex="0" 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"> <li v-for="sample in SAMPLES" :key="sample.title">
<button class="flex flex-col items-start gap-0.5" @click="loadSample(sample)"> <button class="flex flex-col items-start gap-0.5" @click="loadSample(sample)">
@@ -358,6 +390,13 @@ onBeforeUnmount(() => {
</li> </li>
</ul> </ul>
</div> </div>
<button
class="btn btn-primary btn-sm"
:class="{ 'btn-active': showResults }"
@click="showResults = !showResults"
>
Simulate
</button>
<button class="btn btn-ghost btn-sm" @click="exportModel">Export</button> <button class="btn btn-ghost btn-sm" @click="exportModel">Export</button>
<button class="btn btn-ghost btn-sm" @click="fileInput?.click()">Import</button> <button class="btn btn-ghost btn-sm" @click="fileInput?.click()">Import</button>
<input <input
@@ -420,6 +459,8 @@ onBeforeUnmount(() => {
<Palette class="absolute top-3 left-3 z-20" @add="addNode" /> <Palette class="absolute top-3 left-3 z-20" @add="addNode" />
<LoopOverlay /> <LoopOverlay />
<GlossPanel /> <GlossPanel />
<Inspector />
<ResultsPanel v-if="showResults" @close="showResults = false" />
<!-- Self-dismissing teaching hint, e.g. when a Flow is dragged back onto its <!-- Self-dismissing teaching hint, e.g. when a Flow is dragged back onto its
own Stock to "close the loop". Click to dismiss early; z-30 keeps it above own Stock to "close the loop". Click to dismiss early; z-30 keeps it above

View 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>

View 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
View 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>

View File

@@ -9,6 +9,8 @@ import { computed } from "vue"
import { useNodeLoopRing } from "@/composables/useLoopHighlight" import { useNodeLoopRing } from "@/composables/useLoopHighlight"
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection" import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
import type { ConverterNode } from "@/model/types" import type { ConverterNode } from "@/model/types"
import { useSimulationStore } from "@/store/simulation"
import { formatValue } from "./format"
import NodeLabel from "./NodeLabel.vue" import NodeLabel from "./NodeLabel.vue"
const props = defineProps<NodeProps<NodeData>>() const props = defineProps<NodeProps<NodeData>>()
@@ -16,6 +18,9 @@ const props = defineProps<NodeProps<NodeData>>()
// The projection guarantees a converter-typed node here. // The projection guarantees a converter-typed node here.
const converter = computed(() => props.data.node as ConverterNode) const converter = computed(() => props.data.node as ConverterNode)
const loopRing = useNodeLoopRing(props.id) const loopRing = useNodeLoopRing(props.id)
const sim = useSimulationStore()
const value = computed(() => sim.valueAt(props.id))
</script> </script>
<template> <template>
@@ -25,6 +30,12 @@ const loopRing = useNodeLoopRing(props.id)
:class="[props.selected ? 'border-primary' : 'border-base-300', loopRing]" :class="[props.selected ? 'border-primary' : 'border-base-300', loopRing]"
> >
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" /> <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" /> <Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
</div> </div>
<NodeLabel :node-id="props.id" :name="converter.name" /> <NodeLabel :node-id="props.id" :name="converter.name" />

View File

@@ -11,6 +11,8 @@ import { computed } from "vue"
import { useNodeLoopRing } from "@/composables/useLoopHighlight" import { useNodeLoopRing } from "@/composables/useLoopHighlight"
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection" import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
import type { FlowNode } from "@/model/types" import type { FlowNode } from "@/model/types"
import { useSimulationStore } from "@/store/simulation"
import { formatValue } from "./format"
import NodeLabel from "./NodeLabel.vue" import NodeLabel from "./NodeLabel.vue"
const props = defineProps<NodeProps<NodeData>>() const props = defineProps<NodeProps<NodeData>>()
@@ -18,6 +20,9 @@ const props = defineProps<NodeProps<NodeData>>()
// The projection guarantees a flow-typed node here. // The projection guarantees a flow-typed node here.
const flow = computed(() => props.data.node as FlowNode) const flow = computed(() => props.data.node as FlowNode)
const loopRing = useNodeLoopRing(props.id) const loopRing = useNodeLoopRing(props.id)
const sim = useSimulationStore()
const value = computed(() => sim.valueAt(props.id))
</script> </script>
<template> <template>
@@ -40,5 +45,8 @@ const loopRing = useNodeLoopRing(props.id)
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" /> <Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
</div> </div>
<NodeLabel :node-id="props.id" :name="flow.name" /> <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> </div>
</template> </template>

View File

@@ -9,6 +9,8 @@ import { computed } from "vue"
import { useNodeLoopRing } from "@/composables/useLoopHighlight" import { useNodeLoopRing } from "@/composables/useLoopHighlight"
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection" import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
import type { StockNode } from "@/model/types" import type { StockNode } from "@/model/types"
import { useSimulationStore } from "@/store/simulation"
import { formatValue } from "./format"
import NodeLabel from "./NodeLabel.vue" import NodeLabel from "./NodeLabel.vue"
const props = defineProps<NodeProps<NodeData>>() const props = defineProps<NodeProps<NodeData>>()
@@ -16,15 +18,43 @@ const props = defineProps<NodeProps<NodeData>>()
// The projection guarantees a stock-typed node here. // The projection guarantees a stock-typed node here.
const stock = computed(() => props.data.node as StockNode) const stock = computed(() => props.data.node as StockNode)
const loopRing = useNodeLoopRing(props.id) 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> </script>
<template> <template>
<div <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]" :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" /> <Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
<NodeLabel :node-id="props.id" :name="stock.name" /> <div class="relative z-10">
<NodeLabel :node-id="props.id" :name="stock.name" />
</div>
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" /> <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> </div>
</template> </template>

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

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

View File

@@ -21,6 +21,8 @@ import {
type NodeKind, type NodeKind,
type Polarity, type Polarity,
type Position, type Position,
type Rule,
type SimSpec,
} from "./types" } from "./types"
/** Pretty JSON so an exported Model is human-readable and diff-friendly (F8). */ /** 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 === "-" 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. */ /** Validate one node by kind, returning an error string or null when valid. */
function nodeError(value: unknown, index: number): string | null { function nodeError(value: unknown, index: number): string | null {
if (!isObject(value)) return `nodes[${index}] is not an object` 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.source !== "string") return `${at}.source must be a string`
if (typeof value.target !== "string") return `${at}.target 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 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 (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.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 (!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++) { for (let i = 0; i < data.nodes.length; i++) {
const error = nodeError(data.nodes[i], i) const error = nodeError(data.nodes[i], i)
@@ -134,6 +171,13 @@ export function parseModel(text: string): ParseResult {
return { return {
ok: true, 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 }),
},
} }
} }

View File

@@ -11,9 +11,18 @@
* Flow's references (ADR-0003); each Information Link contributes one "info" * Flow's references (ADR-0003); each Information Link contributes one "info"
* edge carrying its polarity. * 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" 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. */ /** Payload carried on every projected Vue Flow node: the domain node itself. */
export interface NodeData { export interface NodeData {
node: ModelNode node: ModelNode
@@ -72,7 +81,7 @@ export function projectEdges(model: Model): FlowGraphEdge[] {
targetHandle: HANDLE_IN, targetHandle: HANDLE_IN,
data: { kind: "pipe" }, data: { kind: "pipe" },
style: { strokeWidth: "2.5px" }, style: { strokeWidth: "2.5px" },
markerEnd: "arrow", markerEnd: ARROWHEAD,
}) })
} }
@@ -88,7 +97,7 @@ export function projectEdges(model: Model): FlowGraphEdge[] {
targetHandle: HANDLE_IN, targetHandle: HANDLE_IN,
data: { kind: "info", polarity: link.polarity }, data: { kind: "info", polarity: link.polarity },
style: { strokeDasharray: "5 4" }, style: { strokeDasharray: "5 4" },
markerEnd: "arrow", markerEnd: ARROWHEAD,
}) })
} }

View File

@@ -15,9 +15,9 @@
* Beyond that primer, three classic models go a step further — each adds one * 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: * 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 * 5. Limits to growth — a Reinforcing inflow and a Balancing outflow on one
* constant Converter (carrying capacity) that feeds a loop * Stock, with a Converter (crowding) relaying the density
* without being part of it. * that brakes growth: the S-curve.
* 6. Predator and prey — two coupled Stocks whose interlocking loops oscillate. * 6. Predator and prey — two coupled Stocks whose interlocking loops oscillate.
* 7. Epidemic — a chain of Stocks joined by Stock→Stock Flows: no clouds. * 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 * 11. Drift to low performance — a goal that erodes toward actual performance, so a
* Reinforcing loop ratchets both downward. * 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 * 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()` * (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. * mints fresh ids on each call, so loading a sample twice never collides.
@@ -44,6 +52,7 @@ import {
MODEL_VERSION, MODEL_VERSION,
type ModelNode, type ModelNode,
type Polarity, type Polarity,
type SimSpec,
} from "./types" } from "./types"
/** A loadable example: a title and one-line blurb for the menu, plus a builder. */ /** 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 } return { id: newId("link"), source: source.id, target: target.id, polarity }
} }
function model(name: string, nodes: ModelNode[], infoLinks: InformationLink[]): Model { function model(
return { version: MODEL_VERSION, id: newId("model"), name, nodes, infoLinks } 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 { function bathtub(): Model {
const source = makeCloud({ x: -280, y: 0 }) const source = makeCloud({ x: -280, y: 0 })
const water = makeStock({ x: 0, y: 0 }, "Water") const water = makeStock({ x: 0, y: 0 }, "Water")
water.initialValue = 20
water.unit = "L"
const sink = makeCloud({ x: 280, y: 0 }) const sink = makeCloud({ x: 280, y: 0 })
const filling = makeFlow( const filling = makeFlow(
midpoint(source.position, water.position), midpoint(source.position, water.position),
@@ -78,7 +94,15 @@ function bathtub(): Model {
water.id, water.id,
) )
const emptying = makeFlow(midpoint(water.position, sink.position), "emptying", water.id, sink.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. // visible Reinforcing loop instead of overlapping the inflow pipe.
const source = makeCloud({ x: -240, y: -80 }) const source = makeCloud({ x: -240, y: -80 })
const balance = makeStock({ x: 120, y: 40 }, "Balance") const balance = makeStock({ x: 120, y: 40 }, "Balance")
balance.initialValue = 1000
balance.unit = "$"
const interest = makeFlow( const interest = makeFlow(
midpoint(source.position, balance.position), midpoint(source.position, balance.position),
"interest", "interest",
source.id, source.id,
balance.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 { function coffee(): Model {
const coffee = makeStock({ x: -200, y: 0 }, "Coffee") const coffee = makeStock({ x: -200, y: 0 }, "Coffee")
coffee.initialValue = 90
coffee.unit = "°C"
const sink = makeCloud({ x: 200, y: 0 }) const sink = makeCloud({ x: 200, y: 0 })
const cooling = makeFlow(midpoint(coffee.position, sink.position), "cooling", coffee.id, sink.id) 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") const room = makeConverter({ x: 0, y: -160 }, "room temperature")
room.rule = { kind: "constant", value: 20 }
return model( return model(
"Coffee cooling", "Coffee cooling",
[coffee, sink, cooling, room], [coffee, sink, cooling, room],
[link(coffee, cooling, "+"), link(room, cooling, "-")], [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. // Valves are placed by hand, not at the midpoint, to hold the steps.
const source = makeCloud({ x: -360, y: -240 }) const source = makeCloud({ x: -360, y: -240 })
const fertility = makeConverter({ x: -360, y: -40 }, "fertility") const fertility = makeConverter({ x: -360, y: -40 }, "fertility")
fertility.rule = { kind: "constant", value: 0.03 }
const lifeExpectancy = makeConverter({ x: -360, y: 240 }, "life expectancy") 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") 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) 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 sink = makeCloud({ x: 360, y: 240 })
const deaths = makeFlow({ x: 160, y: 160 }, "deaths", people.id, sink.id) 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( return model(
"Population", "Population",
[source, people, sink, births, deaths, fertility, lifeExpectancy], [source, people, sink, births, deaths, fertility, lifeExpectancy],
@@ -146,34 +199,47 @@ function population(): Model {
link(people, deaths, "+"), link(people, deaths, "+"),
link(lifeExpectancy, 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 * Limits to growth — the S-curve, where a Reinforcing engine meets a Balancing
* one Flow. Yeast multiplies the more there is of it (Yeast → [+] → growth: a * brake. Yeast multiplies the more there is of it (Yeast → [+] → growth: a
* Reinforcing loop), but the fuller the vat the more crowding holds growth back * Reinforcing inflow), but crowding rises with the population (Yeast → [+] →
* (Yeast → [+] → crowding → [] → growth: a Balancing loop). Carrying capacity is * crowding) and drives a die-off that grows with the *square* of the Yeast
* a *constant* Converter — no inputs — that sets how soon crowding bites; it feeds * (Yeast, crowding → [+] → die-off → drains Yeast: a Balancing outflow). Growth
* the balancing loop without sitting on any cycle. * 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 { function limitsToGrowth(): Model {
const source = makeCloud({ x: -280, y: 0 }) const source = makeCloud({ x: -280, y: 0 })
const yeast = makeStock({ x: 40, y: 0 }, "Yeast") 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) 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 // growth = 30% of Yeast (its `+` input): the Reinforcing engine.
// left, so the `capacity → crowding` link is a clean horizontal hop along the top. growth.rule = { kind: "proportional", factor: 0.3 }
const capacity = makeConverter({ x: -280, y: -160 }, "carrying capacity") const sink = makeCloud({ x: 360, y: 0 })
const crowding = makeConverter({ x: -40, y: -160 }, "crowding") 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( return model(
"Limits to growth", "Limits to growth",
[source, yeast, growth, crowding, capacity], [source, yeast, growth, sink, dieOff, crowding],
[ [
link(yeast, growth, "+"), link(yeast, growth, "+"),
link(yeast, crowding, "+"), link(yeast, crowding, "+"),
link(crowding, growth, "-"), link(yeast, dieOff, "+"),
link(capacity, crowding, "-"), 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. // cross-stock loop traces a circuit through the open centre.
const preySource = makeCloud({ x: -480, y: -140 }) const preySource = makeCloud({ x: -480, y: -140 })
const rabbits = makeStock({ x: -80, y: -140 }, "Rabbits") const rabbits = makeStock({ x: -80, y: -140 }, "Rabbits")
rabbits.initialValue = 100
const preySink = makeCloud({ x: 320, y: -140 }) const preySink = makeCloud({ x: 320, y: -140 })
const rabbitBirths = makeFlow( const rabbitBirths = makeFlow(
midpoint(preySource.position, rabbits.position), midpoint(preySource.position, rabbits.position),
@@ -199,14 +266,19 @@ function predatorPrey(): Model {
preySource.id, preySource.id,
rabbits.id, rabbits.id,
) )
// rabbits breed in proportion to themselves (Reinforcing) …
rabbitBirths.rule = { kind: "proportional", factor: 0.08 }
const predation = makeFlow( const predation = makeFlow(
midpoint(rabbits.position, preySink.position), midpoint(rabbits.position, preySink.position),
"predation", "predation",
rabbits.id, rabbits.id,
preySink.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 foxSource = makeCloud({ x: -480, y: 140 })
const foxes = makeStock({ x: -80, y: 140 }, "Foxes") const foxes = makeStock({ x: -80, y: 140 }, "Foxes")
foxes.initialValue = 20
const foxSink = makeCloud({ x: 320, y: 140 }) const foxSink = makeCloud({ x: 320, y: 140 })
const foxBirths = makeFlow( const foxBirths = makeFlow(
midpoint(foxSource.position, foxes.position), midpoint(foxSource.position, foxes.position),
@@ -214,12 +286,17 @@ function predatorPrey(): Model {
foxSource.id, foxSource.id,
foxes.id, foxes.id,
) )
// foxes are born in proportion to the rabbits available to eat …
foxBirths.rule = { kind: "proportional", factor: 0.02 }
const foxDeaths = makeFlow( const foxDeaths = makeFlow(
midpoint(foxes.position, foxSink.position), midpoint(foxes.position, foxSink.position),
"fox deaths", "fox deaths",
foxes.id, foxes.id,
foxSink.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( return model(
"Predator and prey", "Predator and prey",
[ [
@@ -241,6 +318,7 @@ function predatorPrey(): Model {
link(rabbits, foxBirths, "+"), link(rabbits, foxBirths, "+"),
link(foxes, foxDeaths, "+"), link(foxes, foxDeaths, "+"),
], ],
{ start: 0, stop: 120, dt: 0.25 },
) )
} }
@@ -255,21 +333,35 @@ function predatorPrey(): Model {
*/ */
function epidemic(): Model { function epidemic(): Model {
const susceptible = makeStock({ x: -280, y: 0 }, "Susceptible") const susceptible = makeStock({ x: -280, y: 0 }, "Susceptible")
susceptible.initialValue = 990
susceptible.unit = "people"
const infected = makeStock({ x: 0, y: 0 }, "Infected") const infected = makeStock({ x: 0, y: 0 }, "Infected")
infected.initialValue = 10
infected.unit = "people"
const recovered = makeStock({ x: 280, y: 0 }, "Recovered") const recovered = makeStock({ x: 280, y: 0 }, "Recovered")
recovered.initialValue = 0
recovered.unit = "people"
const infection = makeFlow( const infection = makeFlow(
midpoint(susceptible.position, infected.position), midpoint(susceptible.position, infected.position),
"infection", "infection",
susceptible.id, susceptible.id,
infected.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( const recovery = makeFlow(
midpoint(infected.position, recovered.position), midpoint(infected.position, recovered.position),
"recovery", "recovery",
infected.id, infected.id,
recovered.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") 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( return model(
"Epidemic", "Epidemic",
[susceptible, infected, recovered, infection, recovery, infectivity], [susceptible, infected, recovered, infection, recovery, infectivity],
@@ -279,6 +371,7 @@ function epidemic(): Model {
link(infected, recovery, "+"), link(infected, recovery, "+"),
link(infectivity, infection, "+"), link(infectivity, infection, "+"),
], ],
{ start: 0, stop: 60, dt: 1 },
) )
} }
@@ -295,17 +388,22 @@ function epidemic(): Model {
*/ */
function tragedyOfTheCommons(): Model { function tragedyOfTheCommons(): Model {
const pasture = makeStock({ x: 0, y: 0 }, "Pasture") const pasture = makeStock({ x: 0, y: 0 }, "Pasture")
pasture.initialValue = 1000
// Two symmetric herds: cattle enter from a Source on the outside, grass leaves // 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 // the Pasture downward to a Sink. The two `Pasture → growth` links are the weak
// brake the trap overruns. // brake the trap overruns.
const sourceA = makeCloud({ x: -640, y: 0 }) const sourceA = makeCloud({ x: -640, y: 0 })
const herdA = makeStock({ x: -360, y: 0 }, "Herd A") const herdA = makeStock({ x: -360, y: 0 }, "Herd A")
herdA.initialValue = 10
const growthA = makeFlow( const growthA = makeFlow(
midpoint(sourceA.position, herdA.position), midpoint(sourceA.position, herdA.position),
"growth A", "growth A",
sourceA.id, sourceA.id,
herdA.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 sinkA = makeCloud({ x: -200, y: 240 })
const grazingA = makeFlow( const grazingA = makeFlow(
midpoint(pasture.position, sinkA.position), midpoint(pasture.position, sinkA.position),
@@ -313,14 +411,19 @@ function tragedyOfTheCommons(): Model {
pasture.id, pasture.id,
sinkA.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 sourceB = makeCloud({ x: 640, y: 0 })
const herdB = makeStock({ x: 360, y: 0 }, "Herd B") const herdB = makeStock({ x: 360, y: 0 }, "Herd B")
herdB.initialValue = 10
const growthB = makeFlow( const growthB = makeFlow(
midpoint(sourceB.position, herdB.position), midpoint(sourceB.position, herdB.position),
"growth B", "growth B",
sourceB.id, sourceB.id,
herdB.id, herdB.id,
) )
growthB.rule = { kind: "proportional", factor: 0.0003 }
const sinkB = makeCloud({ x: 200, y: 240 }) const sinkB = makeCloud({ x: 200, y: 240 })
const grazingB = makeFlow( const grazingB = makeFlow(
midpoint(pasture.position, sinkB.position), midpoint(pasture.position, sinkB.position),
@@ -328,6 +431,7 @@ function tragedyOfTheCommons(): Model {
pasture.id, pasture.id,
sinkB.id, sinkB.id,
) )
grazingB.rule = { kind: "proportional", factor: 0.06 }
return model( return model(
"Tragedy of the commons", "Tragedy of the commons",
[pasture, sourceA, herdA, growthA, sinkA, grazingA, sourceB, herdB, growthB, sinkB, grazingB], [pasture, sourceA, herdA, growthA, sinkA, grazingA, sourceB, herdB, growthB, sinkB, grazingB],
@@ -339,6 +443,7 @@ function tragedyOfTheCommons(): Model {
link(herdB, grazingB, "+"), link(herdB, grazingB, "+"),
link(pasture, growthB, "+"), 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. // cross there, where the R badge lands, so the whole loop reads at a glance.
const blueSource = makeCloud({ x: -560, y: -120 }) const blueSource = makeCloud({ x: -560, y: -120 })
const blueArsenal = makeStock({ x: 280, y: -120 }, "Blue arsenal") const blueArsenal = makeStock({ x: 280, y: -120 }, "Blue arsenal")
blueArsenal.initialValue = 10
const blueBuildup = makeFlow( const blueBuildup = makeFlow(
midpoint(blueSource.position, blueArsenal.position), midpoint(blueSource.position, blueArsenal.position),
"Blue buildup", "Blue buildup",
blueSource.id, blueSource.id,
blueArsenal.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 redSource = makeCloud({ x: -560, y: 120 })
const redArsenal = makeStock({ x: 280, y: 120 }, "Red arsenal") const redArsenal = makeStock({ x: 280, y: 120 }, "Red arsenal")
redArsenal.initialValue = 12
const redBuildup = makeFlow( const redBuildup = makeFlow(
midpoint(redSource.position, redArsenal.position), midpoint(redSource.position, redArsenal.position),
"Red buildup", "Red buildup",
redSource.id, redSource.id,
redArsenal.id, redArsenal.id,
) )
redBuildup.rule = { kind: "proportional", factor: 0.1 }
return model( return model(
"Escalation", "Escalation",
[blueSource, blueArsenal, blueBuildup, redSource, redArsenal, redBuildup], [blueSource, blueArsenal, blueBuildup, redSource, redArsenal, redBuildup],
[link(blueArsenal, redBuildup, "+"), link(redArsenal, blueBuildup, "+")], [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. // the diagonal back to Congestion. Placed by hand, not midpoint, to hold the column.
const source = makeCloud({ x: -420, y: 120 }) const source = makeCloud({ x: -420, y: 120 })
const congestion = makeStock({ x: 300, y: 120 }, "Congestion") const congestion = makeStock({ x: 300, y: 120 }, "Congestion")
congestion.initialValue = 50
const driving = makeFlow({ x: -120, y: 120 }, "driving", source.id, congestion.id) 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 sink = makeCloud({ x: 300, y: -160 })
const roadBuilding = makeFlow({ x: -120, y: -160 }, "road building", congestion.id, sink.id) 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( return model(
"Fixes that fail", "Fixes that fail",
[source, congestion, driving, sink, roadBuilding], [source, congestion, driving, sink, roadBuilding],
[link(congestion, roadBuilding, "+"), link(roadBuilding, driving, "+")], [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. // Reinforcing spiral cross in the open centre, where the R badge lands.
const source = makeCloud({ x: -560, y: 120 }) const source = makeCloud({ x: -560, y: 120 })
const performance = makeStock({ x: -160, y: 120 }, "Performance") const performance = makeStock({ x: -160, y: 120 }, "Performance")
performance.initialValue = 40
const improvement = makeFlow( const improvement = makeFlow(
midpoint(source.position, performance.position), midpoint(source.position, performance.position),
"improvement", "improvement",
source.id, source.id,
performance.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") const standard = makeStock({ x: 160, y: -120 }, "Standard")
standard.initialValue = 80
const sink = makeCloud({ x: 560, y: -120 }) const sink = makeCloud({ x: 560, y: -120 })
const slippage = makeFlow( const slippage = makeFlow(
midpoint(standard.position, sink.position), midpoint(standard.position, sink.position),
@@ -436,6 +562,10 @@ function driftToLowPerformance(): Model {
standard.id, standard.id,
sink.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( return model(
"Drift to low performance", "Drift to low performance",
[source, performance, improvement, standard, sink, slippage], [source, performance, improvement, standard, sink, slippage],
@@ -445,6 +575,69 @@ function driftToLowPerformance(): Model {
link(standard, slippage, "+"), link(standard, slippage, "+"),
link(performance, 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.", blurb: "Goals erode toward actual: a Reinforcing slide downhill.",
build: driftToLowPerformance, 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
View 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
}

View File

@@ -31,6 +31,37 @@ export interface Position {
y: number 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 { interface BaseNode {
id: string id: string
position: Position position: Position
@@ -45,6 +76,8 @@ export interface StockNode extends BaseNode {
name: string name: string
/** Initial accumulated quantity. Optional in the diagram phase; the simulator reads it. */ /** Initial accumulated quantity. Optional in the diagram phase; the simulator reads it. */
initialValue?: number 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 source: string
/** Node id of the Stock or Cloud the Flow feeds into. */ /** Node id of the Stock or Cloud the Flow feeds into. */
target: string target: string
/** Rate expression, recomputed each instant. Optional in the diagram phase. */ /** How its rate is computed each instant (ADR-0004). Optional in the diagram phase. */
equation?: string rule?: Rule
} }
/** /**
@@ -71,8 +104,8 @@ export interface FlowNode extends BaseNode {
export interface ConverterNode extends BaseNode { export interface ConverterNode extends BaseNode {
kind: "converter" kind: "converter"
name: string name: string
/** Expression or constant. Optional in the diagram phase. */ /** How its value is computed each instant (ADR-0004). Optional in the diagram phase. */
equation?: string rule?: Rule
} }
/** /**
@@ -107,4 +140,6 @@ export interface Model {
name: string name: string
nodes: ModelNode[] nodes: ModelNode[]
infoLinks: InformationLink[] infoLinks: InformationLink[]
/** Run parameters for simulation (ADR-0004). Optional; absent → not yet simulated. */
sim?: SimSpec
} }

View File

@@ -24,7 +24,15 @@ import {
nextName, nextName,
} from "@/model/factory" } from "@/model/factory"
import { detectLoops } from "@/model/loops" import { detectLoops } from "@/model/loops"
import type { ConverterNode, Model, ModelNode, Position, StockNode } from "@/model/types" import type {
ConverterNode,
Model,
ModelNode,
Position,
Rule,
SimSpec,
StockNode,
} from "@/model/types"
import { canConnect, intentFor } from "@/model/validation" import { canConnect, intentFor } from "@/model/validation"
/** Ring-buffer depth for undo (F9 target: ≥50 steps). */ /** Ring-buffer depth for undo (F9 target: ≥50 steps). */
@@ -96,6 +104,58 @@ export const useModelStore = defineStore("model", () => {
node.name = name node.name = name
} }
/**
* Set (or clear) a Stock's initial value — the quantity the simulator starts
* from (ADR-0004). `undefined` un-equips it, sending the Model back to "not yet
* simulatable". No-op when unchanged, so a blur with no edit doesn't burn undo.
*/
function setInitialValue(id: string, value: number | undefined): void {
const node = findNode(id)
if (!node || node.kind !== "stock" || node.initialValue === value) return
record()
if (value === undefined) delete node.initialValue
else node.initialValue = value
}
/** Set (or clear) 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 * Create whatever source→target means under the structure guard: an
* Information Link (default `+` polarity, F6) onto a Flow/Converter, or a Flow * Information Link (default `+` polarity, F6) onto a Flow/Converter, or a Flow
@@ -229,6 +289,10 @@ export const useModelStore = defineStore("model", () => {
beginInteraction, beginInteraction,
moveNode, moveNode,
renameNode, renameNode,
setInitialValue,
setUnit,
setRule,
setSimSpec,
removeNode, removeNode,
removeInfoLink, removeInfoLink,
toggleLinkPolarity, toggleLinkPolarity,

151
src/store/simulation.ts Normal file
View 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,
}
})

View File

@@ -3,17 +3,26 @@
@import to precede all other rules. If it comes second, the production build @import to precede all other rules. If it comes second, the production build
warns ("@import must precede all rules…") and browsers silently DROP the warns ("@import must precede all rules…") and browsers silently DROP the
font import, so the custom font never loads. */ 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"; @import "tailwindcss";
@plugin "daisyui"; @plugin "daisyui";
@plugin "daisyui/theme" { @plugin "daisyui/theme" {
name: "light"; name: "light";
default: true; 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 { @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 /* Larger, easier-to-grab connection handles (Vue Flow defaults to 6px). The