Compare commits

48 Commits

Author SHA1 Message Date
Julien Calixte
0c8a0c294f feat(editor): browse samples in a categorized modal gallery
Replace the cramped Samples dropdown with a modal that lays the gallery
out as a card grid, sectioned by tier, so the growing set stays scannable
and the learning order is visible at once.
2026-06-22 23:47:16 +02:00
Julien Calixte
37e3a1e89f feat(samples): tag each sample with a pedagogical category 2026-06-22 23:47:10 +02:00
Julien Calixte
914cf01763 refactor(samples): lay the cobra effect out left-to-right
Move Farmed cobras to the left so the story reads in order (farm engine
-> release bridge -> wild -> cull) and the release bridge no longer
doubles back across the canvas. Positions only; dynamics unchanged.
2026-06-22 23:33:14 +02:00
Julien Calixte
fe17bc8fae feat(samples): add "Success to the successful" competitive-exclusion trap
Two equal researchers and one conserved prize (the field's attention):
a single gap-driven Flow tilts it to whoever is a hair ahead, so a 51-49
near-tie locks in to ~100-0. Escalation's zero-sum twin -- the same
two-Stock all-Reinforcing structure, concentrating instead of exploding.
2026-06-22 23:33:06 +02:00
Julien Calixte
34bc8fa70b style: longer description 2026-06-22 23:19:01 +02:00
Julien Calixte
81425f8f3d style(adr-0004): realign the rate-rules table columns 2026-06-22 23:01:33 +02:00
Julien Calixte
d36f58977e feat(samples): add "The cobra effect" perverse-incentive trap
A bounty on dead cobras (a Balancing cull) funds a Reinforcing cobra
farm; once the farm gluts, the overflow rule spills its worthless
surplus into the wild, overshooting to 4x the starting population.

The engine has no time-triggered events, so the historical bounty
cancellation is modelled emergently: breeding outruns the bounty, the
farm gluts, and the one-sided overflow gate dumps the surplus. Sits
after "Bathtub with an overflow" since it reuses that rule.
2026-06-22 23:00:56 +02:00
Julien Calixte
62f6c5cda1 feat(samples): add "World on a warming planet" capstone
A miniature of the Club of Rome's World3 with its pollution sector
reframed as a climate channel: four coupled Stocks where Reinforcing
capital and population overshoot a finite Resource and the carbon they
burn locks in the warming that finishes them. A qualitative tribute to
the overshoot-and-collapse shape on the existing four rules, not a port
of the equations.
2026-06-21 20:06:35 +02:00
Julien Calixte
9ddff65072 docs(adr-0004): record the overflow rule in the vocabulary
The additive growth the ADR anticipated, now realised: the rule table, why
Overflow is a deliberate threshold exception to "the shape emerges", and why it
is a separate kind rather than a clamp on the (bidirectional) Gap rule.
2026-06-21 18:40:51 +02:00
Julien Calixte
faf8500fa5 feat(samples): add "Bathtub with an overflow" companion
The bathtub given a hard ceiling: a flat-out constant tap and an overflow spillway
that carries whatever rises past the brim into a second Stock, the floor. Water
climbs in a straight line then plateaus just above capacity while the floor fills —
an equilibrium from the spill, not from the inflow easing off. The gallery's one
declared ceiling, a counterpoint to the emergent one in "Limits to growth".
2026-06-21 18:31:39 +02:00
Julien Calixte
f691b99ca5 feat(simulation): add the overflow rule, a one-sided gap for hard ceilings
A fourth Rule kind: overflow = max(0, factor × (level − threshold)). It stays
shut until its `+` level passes its `−` threshold, so an outflow on it spills only
the excess — a spillway / hard ceiling the smooth rules can't draw.

Gap can't stand in for it: it's signed and bidirectional (coffee cooling relies on
that), so below the threshold a gap outflow runs backwards. Clamping at zero is the
whole point, and it can't be applied globally without breaking gap. This is the
additive vocabulary growth ADR-0004 anticipates — a new kind in the union plus a
case in the evaluator — alongside touch-ups to the rule validator and the inspector.
2026-06-21 18:31:39 +02:00
Julien Calixte
46d3279a3c feat(samples): add "Tragedy of the commons, fixed" companion
Rework the trap so its Pasture is a renewable commons (logistic
regrowth) with herd mortality: unregulated breeding overshoots the
renewal rate, grazes the grass to bare dirt, and the herds then starve
with it — resource and users ruined together, not just the grass.

Add the fixed companion sharing that exact renewable Pasture. Stocking
is regulated against an agreed reserve, so the commons holds and the
herds settle at a sustainable size — more cattle than the trap's boom
leaves alive. The only difference between the pair is regulation, not
regrowth, so the comparison is honest.
2026-06-21 16:52:23 +02:00
Julien Calixte
15e9149095 feat(chart): split multi-magnitude tracks across two y-axes
Tracks whose peak values span more than an order of magnitude now plot
on a second right-hand axis, so a small track is no longer flattened
against the baseline by a large one. Also labels the legend's time
column "Time" (was uPlot's default "Value") and gives the toggleable
track rows a pointer cursor so the built-in hide/show is discoverable.
2026-06-21 16:02:10 +02:00
Julien Calixte
889c7361ed refactor(editor): drop duplicate description from gloss card
The Inspector already shows the selected element's description as an
editable field, and it co-renders with the gloss card on selection.
Showing the same text read-only in the gloss card was redundant, so the
card returns to its single job: defining the element's kind.
2026-06-21 15:28:42 +02:00
Julien Calixte
26d3c4493a feat(samples): annotate every sample's nodes and links
Fill in the per-element descriptions across all 13 gallery samples, so
selecting any node or link explains why it is there — a self-guided
tour of each model. The link() helper gains an optional description arg.
2026-06-21 15:19:36 +02:00
Julien Calixte
4a0498ee55 feat(editor): give each node and link a description
A free-text "why this element is here" note on Stocks, Flows,
Converters, and Information Links — editable in the Inspector next to
the unit, shown read-only in the gloss card below the generic kind
definition on selection. The Inspector now also opens for Information
Links, which previously had no editing surface.
2026-06-21 15:19:31 +02:00
Julien Calixte
37bbdf5650 fix(samples): make "Drift to low performance" actually drift down
The model had no downward force on Performance, so its only flow
(improvement) could only pull it up. Performance rose 40 -> 60 while
the Standard fell 80 -> 60, contradicting the "slide downhill" story.

Add a constant decay outflow on Performance and seat it just below the
goal. Now both ratchet down in lockstep (Performance 70 -> 10, Standard
80 -> 20), a fixed gap apart — the genuine eroding-goals trap. Rewrite
the doc-comment, layout note, overview line, and blurb to match.
2026-06-21 13:16:28 +02:00
Julien Calixte
67c16a8d44 fix(samples): lower fishery natural-death rate to 0.01
Drops the Allee threshold to ~30 and lifts carrying capacity to ~1170, so
the stock is hardier and only collapses once heavily overfished. The
overshoot-and-collapse-to-extinction arc holds: Fish drift up, the fleet
overshoots (Boats peak ~700), and the catch drives the stock past the
threshold to a permanent zero by t=150.
2026-06-21 13:12:59 +02:00
Julien Calixte
e7a1bfb5af feat(samples): rework "Overshoot and collapse" as a renewable fishery
The non-renewable Resource (a Stock with no inflow) becomes a renewable
fishery with a true point of no return — an Allee threshold. Spawning
scales with density (~Fish^2) while natural deaths are linear (~Fish), so
below a critical density the deaths win and the stock slides to an
extinction it never recovers from; crowding deaths (~Fish^3) cap a healthy
stock at carrying capacity. A reinvesting fleet (Boats, Reinforcing)
overshoots the renewal rate and drags the fish under the threshold, then
starves and scraps itself.

The negative-positive-negative regrowth curve is the one shape the
proportional rule can't draw alone, so two relay Converters build it
(density to lift spawning to ~Fish^2, crowding for the ~Fish^3 ceiling) —
the Limits-to-growth crowding trick, doubled. At 16 nodes this is the
gallery's largest model and the only one with a Converter feeding a
Converter.

Tuned against the engine: Fish hold near 1000, cross the threshold (~200)
at t~40 as the catch overshoots, then go extinct and stay there; Boats
overshoot to ~450 and collapse back near their start by t=150. No
divergence; loops classify as expected (R: fleet reinvestment, birth
engine; B: natural/crowding/catch drains, scrapping).
2026-06-21 13:04:18 +02:00
Julien Calixte
59e3aafa63 feat(editor): dock the sim panel and refit the canvas to the rest
The results panel floated over the canvas and hid the lower part of the
diagram. Dock it beneath the canvas instead, and refit the view when it
opens or closes so the whole diagram stays framed in the height that
remains. The refit fires on Vue Flow's `dimensions` (post-measure), not
on the toggle, so it frames against the settled size.
2026-06-21 12:41:57 +02:00
Julien Calixte
2ae7cf87d7 fix(chart): disable click-drag-to-zoom on the read-only sim chart 2026-06-20 16:47:10 +02:00
Julien Calixte
69fec5fe2e feat(samples): add "AI deskilling spiral" gallery sample
The thirteenth sample points the language at a live debate: a classic trap,
Shifting the burden to the intervenor (Thinking in Systems, ch. 5), with AI as
the intervenor. Technical debt drives AI reliance, AI churns debt and lets
skills atrophy, and a thinner-skilled team refactors less — so the loop
Technical debt -> AI reliance -> atrophy -> Expertise -> refactoring ->
Technical debt is Reinforcing: addiction, not a fix. A learning Balancing brake
(practice toward a skill ceiling) is tuned to lose.

Maps the brief onto valid roles: Expertise and Technical debt are the stocks;
code quality and lead time read off them (inverse of debt / inverse of
expertise); model price lives in the AI-reliance factor. Buildable on the
existing rule vocabulary. Tuned against the simulator: over t=0..50 Expertise
slides 70 -> ~6 and Technical debt spirals 20 -> ~150, stopped (like
"Escalation") before the loop runs off-chart. The loop detector classifies the
four loops as expected (2 R, 2 B).
2026-06-20 15:28:51 +02:00
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
Julien Calixte
528fc97c7f refactor(model): polish the coffee and limits-to-growth samples
Rename the coffee outflow "heat loss" to "cooling" so the balancing loop
reads more naturally, and stack carrying capacity above the source in
limits to growth so its link to crowding is a clean horizontal hop.
2026-06-20 11:51:49 +02:00
Julien Calixte
21a459256a feat(editor): dock flow arrows square at any pipe angle
Swap the filled-triangle arrowhead for an open arrow on flow pipes and
information links, and route flow pipes through a custom cubic edge whose
horizontal flatten arm scales with the vertical drop, not just the
horizontal gap. Vue Flow's default arm collapses toward zero when the
valve sits nearly above the stock, so a steep pipe stayed flat only in
its last pixels and the arrowhead detached from the visible line. The
pipe now keeps a real horizontal run into the stock's left edge at any
approach angle.
2026-06-20 11:51:34 +02:00
Julien Calixte
df03012c8b style(model): align every sample layout to the 20px grid
Snap all sample coordinates to multiples of 20 so they sit on the
editor grid and read cleanly. Computed midpoint() valves had to land
on grid points too, so a few endpoints were nudged (e.g. escalation
arsenals 300->280, limits-to-growth source -300->-280). Population is
re-laid as a grid cascade so its links no longer overlap the pipes.
2026-06-20 11:19:37 +02:00
Julien Calixte
d7d0966adc feat(editor): snap dragged nodes to the 20px grid
Match Vue Flow's snap grid to the background gap so nodes line up
on the dots they already render against.
2026-06-20 11:19:37 +02:00
Julien Calixte
27b7a8da5e feat(model): add fixes-that-fail and drift traps to the gallery
Two more Meadows system traps: Fixes that fail (a balancing
road-building fix whose induced-driving side effect reinforces the
congestion it targets) and Drift to low performance (an eroding goal
that spirals actual performance and the standard downward).

Also re-lays-out the coupled-stock samples for readability: predator
and prey and escalation now use aligned two-row layouts, and fixes
that fail stacks its flow valves in one column so the backfire link
reads as a clean vertical.
2026-06-20 11:09:04 +02:00
25 changed files with 3482 additions and 165 deletions

View File

@@ -19,6 +19,11 @@ Deployed at https://meadows.apoena.dev
substrate; the domain Model is the source of truth.
- [ADR-0003](./docs/adr/0003-flow-as-node-materialised-clouds.md) — a Flow is a
node; Source/Sink clouds are materialised nodes.
- [ADR-0004](./docs/adr/0004-rate-rules-not-formulas.md) — simulation behaviour
comes from a small fixed vocabulary of rules over Information Links, not
free-form formulas.
- [ADR-0005](./docs/adr/0005-uplot-behaviour-chart.md) — uPlot draws the
behaviour-over-time chart; a charting dependency that earns its keep.
## Stack

View File

@@ -0,0 +1,89 @@
# 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 |
| **Overflow** | `max(0, factor × (level threshold))` | the `+` input is _level_, `` is _threshold_ | a spillway / hard ceiling |
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.
**Overflow is the one _declared_ limit.** Constant/Proportional/Gap are smooth and
their ceilings _emerge_ (the S-curve is two of them meeting); Overflow instead is a
threshold — `max(0, …)` that stays shut until a level crosses it, then spills the
excess down an outflow (a bathtub brimming onto the floor). It earns a rule of its
own because Gap _cannot_ stand in: Gap is signed and bidirectional (coffee re-warms
if it drops below the room), so a Gap outflow runs _backwards_ below its target, and
that clamp can't be applied to Gap globally without breaking the goal-seekers. So
Overflow is a deliberate exception to "the shape emerges" — a hard ceiling you
_declare_, for the real limit that is a wall, not a slope (and the rate-side sibling
of the non-negative-Stock floor the integrator already enforces).
**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, and Overflow the same with its `` input as the threshold. 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 — as **Overflow** later bore out (one union member, one
evaluator case, plus matching touch-ups to the rule validator and the
inspector). 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",
"idb": "^8.0.3",
"pinia": "^3.0.4",
"uplot": "^1.6.32",
"vue": "^3.5.34"
},
"devDependencies": {

8
pnpm-lock.yaml generated
View File

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

View File

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

Before

Width:  |  Height:  |  Size: 550 B

After

Width:  |  Height:  |  Size: 550 B

View File

@@ -24,18 +24,23 @@ import {
VueFlow,
type XYPosition,
} from "@vue-flow/core"
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from "vue"
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from "vue"
import { useAutosave } from "@/composables/useAutosave"
import { usePlayback } from "@/composables/usePlayback"
import { parseModel, serializeModel } from "@/model/io"
import { project } from "@/model/projection"
import { type Sample, SAMPLES } from "@/model/samples"
import type { Sample } from "@/model/samples"
import { canConnect } from "@/model/validation"
import { useModelStore } from "@/store/model"
import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd"
import GlossPanel from "./GlossPanel.vue"
import Inspector from "./Inspector.vue"
import LoopOverlay from "./LoopOverlay.vue"
import Palette from "./Palette.vue"
import ResultsPanel from "./ResultsPanel.vue"
import SampleBrowser from "./SampleBrowser.vue"
import InfoLinkEdge from "./edges/InfoLinkEdge.vue"
import PipeEdge from "./edges/PipeEdge.vue"
import CloudNode from "./nodes/CloudNode.vue"
import ConverterNode from "./nodes/ConverterNode.vue"
import FlowNode from "./nodes/FlowNode.vue"
@@ -47,6 +52,17 @@ const graph = computed(() => project(store.model))
const nodes = computed(() => graph.value.nodes)
const edges = computed(() => graph.value.edges)
// The simulation results panel (phase 2). Toggled from the header; the panel runs
// the Model and recomputes reactively while open.
const showResults = ref(false)
// The sample-browser modal (opened from the header "Samples" button).
const browserOpen = ref(false)
// The playback clock that advances the playhead while a run is playing (mounted
// once here; the simulation store holds the state it drives).
usePlayback()
// Explicit shared id: useVueFlow() runs here in the parent setup, before
// <VueFlow> mounts. Pinning both to the same id guarantees they resolve to one
// store instance, so the event hooks below actually fire.
@@ -62,6 +78,7 @@ const {
getSelectedEdges,
viewport,
vueFlowRef,
dimensions,
fitView,
} = useVueFlow("meadows")
@@ -77,6 +94,26 @@ onNodesInitialized(() => {
fitView({ padding: 0.2 })
})
// Docking the results panel (Simulate) shrinks the canvas; closing it gives the
// space back. Re-fit either way so the whole diagram stays framed in whatever
// height remains. Vue Flow re-measures the pane a frame after the panel
// mounts/unmounts and republishes the size via `dimensions`; fitting on *that*
// signal (not straight after the toggle) frames against the settled size, so the
// diagram never lands jammed against the new edge. The flag scopes the fit to
// panel toggles, leaving ordinary window resizes to Vue Flow's own handling.
let fitOnResize = false
watch(showResults, () => {
fitOnResize = true
})
watch(
() => [dimensions.value.width, dimensions.value.height],
() => {
if (!fitOnResize) return
fitOnResize = false
fitView({ padding: 0.2 })
},
)
// Restore the last document on mount and persist every change (F7). Arm the same
// post-measure fit a load uses; fitting synchronously in onRestore would frame
// against unmeasured 0×0 nodes, landing the restored model jammed top-left.
@@ -237,12 +274,12 @@ function onDragOver(event: DragEvent): void {
*/
function loadSample(sample: Sample): void {
if (store.nodeCount > 0 && !window.confirm(`Replace the current model with “${sample.title}”?`)) {
// Cancelled: leave the model untouched and the browser open to pick again.
return
}
fitAfterInit = true
store.setModel(sample.build())
// Close the DaisyUI dropdown (it stays open while the trigger keeps focus).
;(document.activeElement as HTMLElement | null)?.blur()
browserOpen.value = false
}
const fileInput = useTemplateRef<HTMLInputElement>("fileInput")
@@ -337,26 +374,41 @@ onBeforeUnmount(() => {
<template>
<div class="flex h-screen flex-col bg-base-200">
<header class="flex items-center gap-3 border-b border-base-300 bg-base-100 px-4 py-2">
<img src="/favicon.svg" alt="" class="size-6" />
<!-- Inlined favicon so the logo strokes with the theme primary (currentColor
via text-primary); an external <img> can't read the theme var. -->
<svg
class="size-6 text-primary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M14 20a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
<path d="M14 4a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
<path d="M6 12a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
<path d="M22 12a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
<path d="M14 12a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
<path d="M6 12h4" />
<path d="M14 12h4" />
<path d="M12 6v4" />
<path d="M12 14v4" />
</svg>
<h1 class="text-lg font-semibold">meadows</h1>
<span class="text-sm text-base-content/50">
{{ store.nodeCount }} {{ store.nodeCount === 1 ? "element" : "elements" }}
</span>
<div class="ml-auto flex items-center gap-1">
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-sm">Samples</button>
<ul
tabindex="0"
class="dropdown-content menu z-10 mt-1 w-72 gap-1 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
<li v-for="sample in SAMPLES" :key="sample.title">
<button class="flex flex-col items-start gap-0.5" @click="loadSample(sample)">
<span class="font-medium">{{ sample.title }}</span>
<span class="text-xs text-base-content/60">{{ sample.blurb }}</span>
</button>
</li>
</ul>
</div>
<button class="btn btn-ghost btn-sm" @click="browserOpen = true">Samples</button>
<button
class="btn btn-primary btn-sm"
:class="{ 'btn-active': showResults }"
@click="showResults = !showResults"
>
Simulate
</button>
<button class="btn btn-ghost btn-sm" @click="exportModel">Export</button>
<button class="btn btn-ghost btn-sm" @click="fileInput?.click()">Import</button>
<input
@@ -385,6 +437,8 @@ onBeforeUnmount(() => {
:connection-radius="30"
:min-zoom="0.2"
:max-zoom="4"
:snap-to-grid="true"
:snap-grid="[20, 20]"
class="size-full"
>
<Background :gap="20" pattern-color="#d1d5db" />
@@ -403,6 +457,9 @@ onBeforeUnmount(() => {
<CloudNode v-bind="nodeProps" />
</template>
<template #edge-pipe="edgeProps">
<PipeEdge v-bind="edgeProps" />
</template>
<template #edge-info="edgeProps">
<InfoLinkEdge v-bind="edgeProps" />
</template>
@@ -414,6 +471,7 @@ onBeforeUnmount(() => {
<Palette class="absolute top-3 left-3 z-20" @add="addNode" />
<LoopOverlay />
<GlossPanel />
<Inspector />
<!-- 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
@@ -432,5 +490,12 @@ onBeforeUnmount(() => {
</div>
</div>
</div>
<!-- Docked beneath the canvas (not floating over it): opening Simulate shrinks
the canvas to the space above, where the diagram re-fits (see the
`dimensions` watcher in <script>). -->
<ResultsPanel v-if="showResults" @close="showResults = false" />
<SampleBrowser v-model:open="browserOpen" @select="loadSample" />
</div>
</template>

View File

@@ -0,0 +1,218 @@
<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, InformationLink, 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, a Flow via its pipe edge,
* or an Information Link via its info edge (the edge id *is* the link id).
*/
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]
const kind = (edge.data as EdgeData | undefined)?.kind
if (kind === "pipe") return edge.id.split("::")[0]
if (kind === "info") return edge.id
}
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
})
/** The live Information Link behind the selection, when a link (not a node) is selected. */
const link = computed<InformationLink | null>(() => {
const id = selectedId.value
if (!id) return null
return store.model.infoLinks.find((l) => l.id === id) ?? null
})
const KIND_LABEL = { stock: "Stock", flow: "Flow", converter: "Converter" } as const
/** A link's endpoints are always named nodes — it never touches a Cloud (validation.ts). */
function nodeName(id: string): string {
const node = store.model.nodes.find((n) => n.id === id)
return node && "name" in node ? node.name : ""
}
/** 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)
}
/** Write the description for whichever element (node or link) is selected. */
function onDescription(event: Event): void {
const id = element.value?.id ?? link.value?.id
if (!id) return
store.setDescription(id, (event.target as HTMLTextAreaElement).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.",
overflow:
"rate = max(0, factor × (level threshold)): spills only once the “+” level passes the “−” threshold.",
}
</script>
<template>
<div
v-if="element || link"
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"
>
<!-- A named node: Stock (value + unit) or Flow/Converter (rule). -->
<template v-if="element">
<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>
<option value="overflow">Overflow</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>
</template>
<!-- An Information Link: its endpoints and polarity, then its description. -->
<template v-else-if="link">
<div class="flex items-baseline gap-2">
<span class="text-sm font-semibold">Information Link</span>
<span class="text-xs text-base-content/50">{{ link.polarity === "+" ? "+" : "" }}</span>
</div>
<p class="mt-1 text-xs text-base-content/60">
{{ nodeName(link.source) }} {{ nodeName(link.target) }}
</p>
</template>
<!-- Shared across nodes and links: the "why this element is here" note (G4). -->
<label class="mt-2 block">
<span class="text-xs text-base-content/60">Description</span>
<textarea
rows="6"
class="textarea textarea-bordered textarea-sm mt-1 w-full leading-snug"
:value="(element ?? link)?.description ?? ''"
placeholder="Why this element is here…"
@change="onDescription"
/>
</label>
</div>
</template>

View File

@@ -0,0 +1,151 @@
<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="shrink-0 border-t border-base-300 bg-base-100 p-3">
<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>

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
/**
* Sample browser — a modal gallery of the curated example models, laid out as a
* card grid sectioned by pedagogical tier (Primer → … → Mechanics & capstone).
* As the gallery grows this beats the old narrow dropdown: the whole set stays
* scannable in one scroll and the tiers show how the samples relate.
*
* Presentational only — it never touches the Model store. Picking a card emits
* `select`; the Editor owns the confirm/replace/fit logic (one place for it).
* Built on the native <dialog> so Esc, the backdrop click and focus-trapping come
* for free; `open` drives showModal()/close() and the native close event keeps the
* parent's state in sync.
*/
import { computed, useTemplateRef, watch } from "vue"
import { type Sample, SAMPLE_CATEGORIES, SAMPLES } from "@/model/samples"
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{ "update:open": [boolean]; select: [Sample] }>()
const dialog = useTemplateRef<HTMLDialogElement>("dialog")
// Mirror the `open` prop onto the native dialog (showModal gives us the focus trap
// and inert backdrop a plain div can't).
watch(
() => props.open,
(open) => {
const el = dialog.value
if (!el) return
if (open && !el.open) el.showModal()
else if (!open && el.open) el.close()
},
)
// Fired by Esc, the backdrop form, the ✕, or our own close() — fold them all into
// one update:open so the parent's state can't drift from the dialog's.
function onClose(): void {
if (props.open) emit("update:open", false)
}
// The categories in display order, each with its samples (SAMPLES is already in
// pedagogical order, so first-appearance order is preserved). Empty tiers drop out.
const sections = computed(() =>
SAMPLE_CATEGORIES.map((category) => ({
category,
samples: SAMPLES.filter((sample) => sample.category === category),
})).filter((section) => section.samples.length > 0),
)
</script>
<template>
<dialog ref="dialog" class="modal" @close="onClose">
<div class="modal-box w-11/12 max-w-3xl">
<div class="mb-4 flex items-center gap-2">
<h3 class="text-base font-semibold">Browse samples</h3>
<span class="text-xs text-base-content/50">{{ SAMPLES.length }} models</span>
<form method="dialog" class="ml-auto">
<button class="btn btn-circle btn-ghost btn-sm" aria-label="Close"></button>
</form>
</div>
<section v-for="section in sections" :key="section.category" class="mb-5 last:mb-0">
<h4 class="mb-2 text-xs font-semibold tracking-wide text-base-content/50 uppercase">
{{ section.category }}
</h4>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<button
v-for="sample in section.samples"
:key="sample.title"
type="button"
class="flex flex-col gap-1 rounded-box border border-base-300 bg-base-100 p-3 text-left transition hover:border-primary hover:shadow-md"
@click="emit('select', sample)"
>
<span class="font-medium">{{ sample.title }}</span>
<span class="text-xs text-base-content/60">{{ sample.blurb }}</span>
</button>
</div>
</section>
</div>
<!-- Click outside the box to dismiss (submits the dialog fires @close). -->
<form method="dialog" class="modal-backdrop">
<button aria-label="Close">close</button>
</form>
</dialog>
</template>

246
src/components/SimChart.vue Normal file
View File

@@ -0,0 +1,246 @@
<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
}
/**
* Peak |value| of a track as an order of magnitude (log10), or null when the
* track is empty or flat-zero — there is no magnitude to compare.
*/
function magnitude(track: (number | null)[]): number | null {
let peak = 0
for (const v of track) {
if (v !== null && Number.isFinite(v)) {
const a = Math.abs(v)
if (a > peak) peak = a
}
}
return peak > 0 ? Math.log10(peak) : null
}
// Split tracks onto a second y-axis once they span more than this many orders of
// magnitude: below it a single shared axis reads fine; above it the smaller track
// is flattened against the baseline by the larger one.
const SPLIT_DECADES = 1
/**
* Assign each series a y-scale: "y" (left axis) or "y2" (right axis). Tracks are
* split at their widest magnitude gap when that gap exceeds SPLIT_DECADES, with the
* smaller-magnitude tracks moving to "y2"; otherwise every track stays on "y".
* Flat-zero tracks have no magnitude, so they stay on the primary axis.
*/
function scaleKeys(): ("y" | "y2")[] {
const tracks = props.data.slice(1) as (number | null)[][]
const mags = tracks.map(magnitude)
const known = mags.filter((m): m is number => m !== null).sort((a, b) => a - b)
if (known.length < 2) return tracks.map(() => "y")
// The widest gap between adjacent magnitudes is the natural cut between "big" and
// "small" tracks; its midpoint is the threshold each track is measured against.
let gap = 0
let cut = 0
for (let i = 1; i < known.length; i++) {
if (known[i] - known[i - 1] > gap) {
gap = known[i] - known[i - 1]
cut = (known[i] + known[i - 1]) / 2
}
}
if (gap < SPLIT_DECADES) return tracks.map(() => "y")
return mags.map((m) => (m !== null && m < cut ? "y2" : "y"))
}
function trackSig(): string {
const scales = scaleKeys()
return props.series.map((s, i) => `${s.label}|${s.stroke}|${scales[i]}`).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}`,
}
const scales = scaleKeys()
const split = scales.includes("y2")
return {
width,
height: props.height,
// No y crosshair, and no click-drag-to-zoom (uPlot's drag.setScale defaults
// on): the chart is a read-only readout — the playhead drives time, not a
// drag-selected zoom region.
cursor: { y: false, drag: { x: false, y: false } },
scales: { x: { time: false } },
series: [
// x is simulation time, so the live legend's first column reads "Time"
// rather than uPlot's default "Value".
{ label: "Time" },
...props.series.map(
(s, i): uPlot.Series => ({
label: s.label,
scale: scales[i],
stroke: s.stroke,
width: 2,
points: { show: false },
value: (_self, raw) => fmt(raw),
}),
),
],
// When tracks span orders of magnitude the small ones move to a right-hand
// axis ("y2"); its grid is suppressed so the two grids don't clash.
axes: split
? [
axis,
{ ...axis, size: 52 },
{ ...axis, scale: "y2", side: 1, size: 52, grid: { show: false } },
]
: [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;
}
/* Each track row toggles its line on click (⌘/Ctrl-click isolates it); the first
row is the Time readout and has no such handler. */
:deep(.u-legend .u-series) {
cursor: pointer;
}
:deep(.u-legend .u-series:first-child) {
cursor: default;
}
</style>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
/**
* Flow pipe edge (C3) — the source→valve and valve→target segments of a Flow.
*
* The pipe leaves the source on the right and docks square into the target's
* left edge (horizontal tangents both ends), so the arrowhead always enters the
* stock straight-on. Vue Flow's default bezier does the same — but it sizes the
* horizontal "flatten" arm from the *horizontal* gap alone, so when the valve
* sits nearly above the stock that arm collapses to ~zero: the curve stays steep
* and snaps flat only in its final pixels, leaving the arrowhead on a stub of
* horizontal line while the visible stroke arrives steep — the head detaches.
*
* Sizing the arm from the vertical drop too keeps a real horizontal run before
* the stock at any approach angle, so the arrowhead sits on the line and still
* docks square. BaseEdge draws the stroke (same .vue-flow__edge-path class, so
* theme stroke/width are unchanged) and carries the markerEnd through.
*/
import { BaseEdge, type EdgeProps } from "@vue-flow/core"
import { computed } from "vue"
import type { EdgeData } from "@/model/projection"
const props = defineProps<EdgeProps<EdgeData>>()
const path = computed(() => {
const { sourceX: sx, sourceY: sy, targetX: tx, targetY: ty } = props
const arm = Math.min(120, Math.max(30, Math.abs(tx - sx) * 0.5, Math.abs(ty - sy) * 0.45))
// Cubic with horizontal control points: out the source's right, into the
// target's left. `arm` is the run over which each end stays horizontal.
return `M ${sx},${sy} C ${sx + arm},${sy} ${tx - arm},${ty} ${tx},${ty}`
})
</script>
<template>
<BaseEdge :id="props.id" :path="path" :marker-end="props.markerEnd" :style="props.style" />
</template>

View File

@@ -9,6 +9,8 @@ import { computed } from "vue"
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
import type { ConverterNode } from "@/model/types"
import { useSimulationStore } from "@/store/simulation"
import { formatValue } from "./format"
import NodeLabel from "./NodeLabel.vue"
const props = defineProps<NodeProps<NodeData>>()
@@ -16,6 +18,9 @@ const props = defineProps<NodeProps<NodeData>>()
// The projection guarantees a converter-typed node here.
const converter = computed(() => props.data.node as ConverterNode)
const loopRing = useNodeLoopRing(props.id)
const sim = useSimulationStore()
const value = computed(() => sim.valueAt(props.id))
</script>
<template>
@@ -25,6 +30,12 @@ const loopRing = useNodeLoopRing(props.id)
:class="[props.selected ? 'border-primary' : 'border-base-300', loopRing]"
>
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
<span
v-if="value !== null"
class="pointer-events-none absolute inset-0 flex items-center justify-center font-mono text-xs tabular-nums text-base-content/70"
>
{{ formatValue(value) }}
</span>
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
</div>
<NodeLabel :node-id="props.id" :name="converter.name" />

View File

@@ -11,6 +11,8 @@ import { computed } from "vue"
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
import type { FlowNode } from "@/model/types"
import { useSimulationStore } from "@/store/simulation"
import { formatValue } from "./format"
import NodeLabel from "./NodeLabel.vue"
const props = defineProps<NodeProps<NodeData>>()
@@ -18,6 +20,9 @@ const props = defineProps<NodeProps<NodeData>>()
// The projection guarantees a flow-typed node here.
const flow = computed(() => props.data.node as FlowNode)
const loopRing = useNodeLoopRing(props.id)
const sim = useSimulationStore()
const value = computed(() => sim.valueAt(props.id))
</script>
<template>
@@ -40,5 +45,8 @@ const loopRing = useNodeLoopRing(props.id)
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
</div>
<NodeLabel :node-id="props.id" :name="flow.name" />
<div v-if="value !== null" class="font-mono text-xs tabular-nums text-base-content/70">
{{ formatValue(value) }}
</div>
</div>
</template>

View File

@@ -9,6 +9,8 @@ import { computed } from "vue"
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
import type { StockNode } from "@/model/types"
import { useSimulationStore } from "@/store/simulation"
import { formatValue } from "./format"
import NodeLabel from "./NodeLabel.vue"
const props = defineProps<NodeProps<NodeData>>()
@@ -16,15 +18,43 @@ const props = defineProps<NodeProps<NodeData>>()
// The projection guarantees a stock-typed node here.
const stock = computed(() => props.data.node as StockNode)
const loopRing = useNodeLoopRing(props.id)
// Live simulation value at the playhead (null when no run is engaged). A Stock is
// the system's memory, so it also shows a fill gauge — its level over its peak.
const sim = useSimulationStore()
const value = computed(() => sim.valueAt(props.id))
const fill = computed(() => sim.fill(props.id))
</script>
<template>
<div
class="min-w-24 rounded-md border-2 bg-base-100 px-4 py-3 text-center shadow-sm transition-colors"
class="relative min-w-24 rounded-md border-2 bg-base-100 px-4 py-3 text-center shadow-sm transition-colors"
:class="[props.selected ? 'border-primary' : 'border-base-300', loopRing]"
>
<!-- Fill gauge: a bottom-anchored bar, behind the label, inset within the border. -->
<div
v-if="value !== null"
class="pointer-events-none absolute inset-[2px] z-0 flex flex-col justify-end"
>
<div
class="rounded-b bg-primary/60 transition-[height]"
:style="{ height: fill * 100 + '%' }"
/>
</div>
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
<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" />
<!-- Live value sits *below* the box as an out-of-flow overlay, so the box keeps
its exact size and the handles with the pipes and links on them never
shift when a run is engaged. -->
<div
v-if="value !== null"
class="pointer-events-none absolute inset-x-0 top-full mt-1 whitespace-nowrap text-center font-mono text-xs tabular-nums text-base-content/70"
>
{{ formatValue(value)
}}<span v-if="stock.unit" class="ml-0.5 text-base-content/50">{{ stock.unit }}</span>
</div>
</div>
</template>

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 Polarity,
type Position,
type Rule,
type SimSpec,
} from "./types"
/** Pretty JSON so an exported Model is human-readable and diff-friendly (F8). */
@@ -49,6 +51,25 @@ function isPolarity(value: unknown): value is Polarity {
return value === "+" || value === "-"
}
/** A Rule must be one of the fixed kinds with its numeric parameter (ADR-0004). */
function isRule(value: unknown): value is Rule {
if (!isObject(value)) return false
if (value.kind === "constant") return isFiniteNumber(value.value)
if (value.kind === "proportional" || value.kind === "gap" || value.kind === "overflow")
return isFiniteNumber(value.factor)
return false
}
function isSimSpec(value: unknown): value is SimSpec {
return (
isObject(value) &&
isFiniteNumber(value.start) &&
isFiniteNumber(value.stop) &&
isFiniteNumber(value.dt) &&
value.dt > 0
)
}
/** Validate one node by kind, returning an error string or null when valid. */
function nodeError(value: unknown, index: number): string | null {
if (!isObject(value)) return `nodes[${index}] is not an object`
@@ -65,6 +86,28 @@ function nodeError(value: unknown, index: number): string | null {
if (typeof value.source !== "string") return `${at}.source must be a string`
if (typeof value.target !== "string") return `${at}.target must be a string`
}
// Simulation fields (ADR-0004) are optional, but if present must be well-formed.
if (kind === "stock" && value.initialValue !== undefined && !isFiniteNumber(value.initialValue)) {
return `${at}.initialValue must be a finite number`
}
if (kind === "stock" && value.unit !== undefined && typeof value.unit !== "string") {
return `${at}.unit must be a string`
}
if (
(kind === "flow" || kind === "converter") &&
value.rule !== undefined &&
!isRule(value.rule)
) {
return `${at}.rule must be a valid rule (constant/proportional/gap)`
}
// The selection-time rationale (display only); any named node may carry one.
if (
kind !== "cloud" &&
value.description !== undefined &&
typeof value.description !== "string"
) {
return `${at}.description must be a string`
}
return null
}
@@ -76,6 +119,9 @@ function linkError(value: unknown, index: number): string | null {
if (typeof value.source !== "string") return `${at}.source must be a string`
if (typeof value.target !== "string") return `${at}.target must be a string`
if (!isPolarity(value.polarity)) return `${at}.polarity must be "+" or "-"`
if (value.description !== undefined && typeof value.description !== "string") {
return `${at}.description must be a string`
}
return null
}
@@ -104,6 +150,9 @@ export function parseModel(text: string): ParseResult {
if (typeof data.name !== "string") return { ok: false, error: "name must be a string" }
if (!Array.isArray(data.nodes)) return { ok: false, error: "nodes must be an array" }
if (!Array.isArray(data.infoLinks)) return { ok: false, error: "infoLinks must be an array" }
if (data.sim !== undefined && !isSimSpec(data.sim)) {
return { ok: false, error: "sim must be {start, stop, dt} numbers with dt > 0" }
}
for (let i = 0; i < data.nodes.length; i++) {
const error = nodeError(data.nodes[i], i)
@@ -134,6 +183,13 @@ export function parseModel(text: string): ParseResult {
return {
ok: true,
model: { version: MODEL_VERSION, id: data.id, name: data.name, nodes, infoLinks: links },
model: {
version: MODEL_VERSION,
id: data.id,
name: data.name,
nodes,
infoLinks: links,
...(data.sim !== undefined && { sim: data.sim as SimSpec }),
},
}
}

View File

@@ -11,9 +11,18 @@
* Flow's references (ADR-0003); each Information Link contributes one "info"
* edge carrying its polarity.
*/
import type { Edge, Node } from "@vue-flow/core"
import { type Edge, type EdgeMarker, MarkerType, type Node } from "@vue-flow/core"
import type { Model, ModelNode, Polarity } from "./types"
/**
* Open arrowhead drawn at the same stroke weight as the line it ends. Vue Flow
* renders the marker with markerUnits="strokeWidth" (so the head already scales
* with the edge) but downscales its 20-unit viewBox into a 12.5 marker box,
* thinning the chevron to 0.625× the line. strokeWidth 20/12.5 = 1.6 cancels
* that, leaving the head exactly as heavy as its line at any width.
*/
const ARROWHEAD: EdgeMarker = { type: MarkerType.Arrow, strokeWidth: 1.6 }
/** Payload carried on every projected Vue Flow node: the domain node itself. */
export interface NodeData {
node: ModelNode
@@ -54,6 +63,7 @@ export function projectEdges(model: Model): FlowGraphEdge[] {
// Pipe in: source → valve. No arrowhead — the valve is the visual midpoint.
edges.push({
id: `${node.id}::in`,
type: "pipe",
source: node.source,
sourceHandle: HANDLE_OUT,
target: node.id,
@@ -64,13 +74,14 @@ export function projectEdges(model: Model): FlowGraphEdge[] {
// Pipe out: valve → target. Arrowhead carries the flow direction.
edges.push({
id: `${node.id}::out`,
type: "pipe",
source: node.id,
sourceHandle: HANDLE_OUT,
target: node.target,
targetHandle: HANDLE_IN,
data: { kind: "pipe" },
style: { strokeWidth: "2.5px" },
markerEnd: "arrowclosed",
markerEnd: ARROWHEAD,
})
}
@@ -86,7 +97,7 @@ export function projectEdges(model: Model): FlowGraphEdge[] {
targetHandle: HANDLE_IN,
data: { kind: "info", polarity: link.polarity },
style: { strokeDasharray: "5 4" },
markerEnd: "arrowclosed",
markerEnd: ARROWHEAD,
})
}

File diff suppressed because it is too large Load Diff

241
src/model/simulation.ts Normal file
View File

@@ -0,0 +1,241 @@
/**
* 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))
)
}
case "overflow": {
// max(0, factor × (level threshold)): a one-sided gap. The `+` input is the
// level, the `` the threshold; it stays shut until the level passes it, so an
// overflow Flow spills only the excess. Clamping at 0 is what stops it running
// backwards below the threshold — gap can't, by design (it's bidirectional).
const level = links.find((link) => link.polarity === "+")
const threshold = links.find((link) => link.polarity === "-")
return Math.max(
0,
rule.factor *
((level ? valueOf(level.source) : 0) - (threshold ? valueOf(threshold.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,41 @@ export interface Position {
y: number
}
/**
* How a Flow's rate or a Converter's value is computed — a small fixed
* vocabulary, *not* a free-form formula, so a Model stays valid by construction
* and teaches structure → behaviour (ADR-0004). Operands are read from the
* element's inbound Information Links; Polarity selects each operand's role.
*
* - `constant` — a fixed number; reads nothing. (→ linear Stock change)
* - `proportional` — `factor ×` the product of its `+`-polarity inputs.
* (→ exponential growth/decay)
* - `gap` — `factor × (level target)`, where the `+` input is the
* level and the `` input the target. (→ goal-seeking)
* - `overflow` — `max(0, factor × (level threshold))`: a one-sided `gap`
* that only fires once the `+` level exceeds the ``
* threshold. (→ a spillway / hard ceiling)
*/
export type Rule =
| { kind: "constant"; value: number }
| { kind: "proportional"; factor: number }
| { kind: "gap"; factor: number }
| { kind: "overflow"; factor: number }
/**
* The run parameters for a simulation: integrate from `start` to `stop` in steps
* of `dt` (forward Euler). Optional on a Model; absent means the diagram phase or
* a not-yet-simulated Model — the engine falls back to `DEFAULT_SIM_SPEC`.
*/
export interface SimSpec {
start: number
stop: number
dt: number
}
/** Sensible default run when a Model carries no `sim` of its own. */
export const DEFAULT_SIM_SPEC: SimSpec = { start: 0, stop: 100, dt: 1 }
interface BaseNode {
id: string
position: Position
@@ -45,6 +80,10 @@ export interface StockNode extends BaseNode {
name: string
/** Initial accumulated quantity. Optional in the diagram phase; the simulator reads it. */
initialValue?: number
/** Unit of the quantity, e.g. "°C", "people", "$" — shown beside the value (display only). */
unit?: string
/** Why this Stock exists in *this* model — a free-text note surfaced on selection (display only). */
description?: string
}
/**
@@ -60,8 +99,10 @@ export interface FlowNode extends BaseNode {
source: string
/** Node id of the Stock or Cloud the Flow feeds into. */
target: string
/** Rate expression, recomputed each instant. Optional in the diagram phase. */
equation?: string
/** How its rate is computed each instant (ADR-0004). Optional in the diagram phase. */
rule?: Rule
/** Why this Flow exists in *this* model — a free-text note surfaced on selection (display only). */
description?: string
}
/**
@@ -71,8 +112,10 @@ export interface FlowNode extends BaseNode {
export interface ConverterNode extends BaseNode {
kind: "converter"
name: string
/** Expression or constant. Optional in the diagram phase. */
equation?: string
/** How its value is computed each instant (ADR-0004). Optional in the diagram phase. */
rule?: Rule
/** Why this Converter exists in *this* model — a free-text note surfaced on selection (display only). */
description?: string
}
/**
@@ -98,6 +141,8 @@ export interface InformationLink {
/** Target node id — a Flow or Converter, never a Stock (ADR-0001). */
target: string
polarity: Polarity
/** Why this link exists in *this* model — a free-text note surfaced on selection (display only). */
description?: string
}
/** One saved document: the unit of save / export / reopen. */
@@ -107,4 +152,6 @@ export interface Model {
name: string
nodes: ModelNode[]
infoLinks: InformationLink[]
/** Run parameters for simulation (ADR-0004). Optional; absent → not yet simulated. */
sim?: SimSpec
}

View File

@@ -24,7 +24,15 @@ import {
nextName,
} from "@/model/factory"
import { detectLoops } from "@/model/loops"
import type { ConverterNode, Model, ModelNode, Position, StockNode } from "@/model/types"
import type {
ConverterNode,
Model,
ModelNode,
Position,
Rule,
SimSpec,
StockNode,
} from "@/model/types"
import { canConnect, intentFor } from "@/model/validation"
/** Ring-buffer depth for undo (F9 target: ≥50 steps). */
@@ -96,6 +104,80 @@ export const useModelStore = defineStore("model", () => {
node.name = name
}
/**
* Set (or clear) a Stock's initial value — the quantity the simulator starts
* from (ADR-0004). `undefined` un-equips it, sending the Model back to "not yet
* simulatable". No-op when unchanged, so a blur with no edit doesn't burn undo.
*/
function setInitialValue(id: string, value: number | undefined): void {
const node = findNode(id)
if (!node || node.kind !== "stock" || node.initialValue === value) return
record()
if (value === undefined) delete node.initialValue
else node.initialValue = value
}
/** Set (or clear) a Stock's display unit (e.g. "°C"). Empty clears it. No-op when unchanged. */
function setUnit(id: string, unit: string): void {
const node = findNode(id)
if (!node || node.kind !== "stock") return
const next = unit.trim() || undefined
if (node.unit === next) return
record()
if (next === undefined) delete node.unit
else node.unit = next
}
/**
* Set (or clear) the free-text description on a named node or an Information
* Link — the "why this element exists" note surfaced on selection. Empty
* clears it; no-op when unchanged, so a blur with no edit doesn't burn undo.
*/
function setDescription(id: string, text: string): void {
const next = text.trim() || undefined
const node = findNode(id)
if (node && node.kind !== "cloud") {
if (node.description === next) return
record()
if (next === undefined) delete node.description
else node.description = next
return
}
const link = model.value.infoLinks.find((l) => l.id === id)
if (!link || link.description === next) return
record()
if (next === undefined) delete link.description
else link.description = next
}
/**
* Set (or clear) how a Flow's rate or a Converter's value is computed (ADR-0004:
* one of the fixed rules, never a formula). No-op when unchanged.
*/
function setRule(id: string, rule: Rule | undefined): void {
const node = findNode(id)
if (!node || (node.kind !== "flow" && node.kind !== "converter")) return
if (JSON.stringify(node.rule) === JSON.stringify(rule)) return
record()
if (rule === undefined) delete node.rule
else node.rule = rule
}
/** Set the run parameters (start / stop / dt). No-op when unchanged. */
function setSimSpec(spec: SimSpec): void {
const current = model.value.sim
if (
current &&
current.start === spec.start &&
current.stop === spec.stop &&
current.dt === spec.dt
) {
return
}
record()
model.value.sim = spec
}
/**
* Create whatever source→target means under the structure guard: an
* Information Link (default `+` polarity, F6) onto a Flow/Converter, or a Flow
@@ -229,6 +311,11 @@ export const useModelStore = defineStore("model", () => {
beginInteraction,
moveNode,
renameNode,
setInitialValue,
setUnit,
setDescription,
setRule,
setSimSpec,
removeNode,
removeInfoLink,
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
warns ("@import must precede all rules…") and browsers silently DROP the
font import, so the custom font never loads. */
@import url("https://fonts.coollabs.io/css2?family=Inter:wght@400;500;600;700&display=swap");
@import url("https://api.fonts.coollabs.io/css2?family=Sono:wght@400;500;600;700&display=swap");
@import "tailwindcss";
@plugin "daisyui";
@plugin "daisyui/theme" {
name: "light";
default: true;
--color-primary: #16a34a;
--color-primary: #ff9ff3;
}
/* DaisyUI's default (no data-theme) resolves to its *dark* theme via
prefers-color-scheme, with a selector (`:root:not([data-theme])`) that outranks
a custom light theme's `:where(:root)` — so overriding only the light theme
never reached the buttons. Force the brand primary across every theme. */
:root {
--color-primary: #ff9ff3 !important;
--color-primary-content: #3d1f3a !important;
}
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-sans: "Sono", ui-sans-serif, system-ui, sans-serif;
}
/* Larger, easier-to-grab connection handles (Vue Flow defaults to 6px). The