Compare commits

..

16 Commits

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

Buildable with the existing rule vocabulary (proportional only); the
non-negative-stock floor tames the bilinear extraction term as the Resource
runs out. Tuned against the simulator: Capital 5 → ~250 (t≈39) → ~5 by t=150,
Resource 1000 → ~6, no divergence. The loop detector classifies the three
loops as expected (R: investment→Capital→extraction; B: depreciation→Capital;
B: extraction→Resource).
2026-06-20 15:15:42 +02:00
Julien Calixte
3a65bf5d59 fix(editor): keep the stock box fixed so simulating doesn't move its arrows
The in-flow value line grew the card downward, dropping its vertical centre —
and the left/right handles with it, so connected pipes and links shifted. Render
the value as an out-of-flow overlay below the box; the card keeps its size and
the handles stay put.
2026-06-20 15:14:13 +02:00
Julien Calixte
8e1e313b20 refactor(editor): inline the header logo so it tracks the theme primary 2026-06-20 15:05:31 +02:00
Julien Calixte
d570425402 fix(editor): align the playhead with the chart's left gutter
valToPos returns CSS pixels relative to the plot area, but the overlay
sits on the canvas, so the playhead was short by the y-axis width and
started before x=0.
2026-06-20 15:01:21 +02:00
Julien Calixte
2c7ec6a6ec style: recolour the favicon to the #ff9ff3 primary 2026-06-20 14:58:34 +02:00
Julien Calixte
ef0cc2878b fix(editor): stop the t-readout wrapping so playback doesn't reflow
The fixed-width time readout wrapped to two lines for fractional times (dt 0.25
→ 't = 70.25'), growing the panel a row each frame and reflowing the whole
panel — the real flicker. Pin its width and forbid wrapping.
2026-06-20 14:56:42 +02:00
Julien Calixte
f38cf49f25 style: force the primary colour to #ff9ff3 across every theme
The default (no data-theme) resolves to DaisyUI's dark theme, whose
:root:not([data-theme]) selector outranks a custom light theme — so the
previous light-only override never reached buttons in dark mode. Force it with
an !important :root override that wins regardless of theme.
2026-06-20 14:53:41 +02:00
Julien Calixte
d5cda95c3e fix(editor): stop the behaviour chart flickering during playback
The playhead was a canvas line redrawn by redrawing the whole uPlot chart on
every frame, which flickers. Draw it as a DOM overlay that slides over the
canvas instead, so the canvas is only redrawn when the data actually changes.
2026-06-20 14:47:08 +02:00
Julien Calixte
dc30e5f890 style: set the primary theme colour to #2e86de 2026-06-20 14:47:08 +02:00
Julien Calixte
382fdddc68 feat(editor): give each Stock a unit
A Stock can carry a display unit (°C, people, $, …): a new optional field, an
inspector text input, validation + round-trip in io, and a readout beside the
live value on the canvas. Equip the demonstrative samples (Water L, Balance $,
Coffee °C, Population/Epidemic people, Yeast cells). Also bumps the fill gauge
opacity for a more vivid level.
2026-06-20 14:47:08 +02:00
Julien Calixte
ed6f011e69 feat(editor): reset the playhead when a different model loads 2026-06-20 14:39:06 +02:00
Julien Calixte
1d65383bbf fix(editor): make the samples list scrollable so it clears the panel 2026-06-20 14:39:02 +02:00
Julien Calixte
3fc0e23ec7 style(editor): make the stock fill gauge more vivid 2026-06-20 14:37:55 +02:00
Julien Calixte
69435b1315 feat(editor): animate the canvas in step with the simulation
A simulation store owns one run and one playhead, so the chart and the canvas
read off the same instant. While the panel is open, Stocks show a live value and
a fill gauge, Flows and Converters their current value; a play/pause + scrubber
drives the playhead (a wall-clock-paced clock in usePlayback), and a marker
tracks it on the chart.
2026-06-20 14:35:45 +02:00
Julien Calixte
c361b05ec4 feat(editor): chart behaviour over time with uPlot
Draw the simulation's Stock trajectories with uPlot (ADR-0005): a real time
axis, hover-to-read values, and a playhead-marker hook the canvas animation
drives. SimChart owns the imperative uPlot lifecycle behind a small prop API.
2026-06-20 14:35:35 +02:00
Julien Calixte
0c8f89d14e fix(style): load Sono from the api.fonts.coollabs.io host
Switch the body font to Sono and point the @import at
api.fonts.coollabs.io. The bare fonts.coollabs.io host serves the
marketing homepage (HTML), not CSS, so the @font-face rules never
loaded and the font silently fell back to system-ui.
2026-06-20 14:28:58 +02:00
20 changed files with 717 additions and 92 deletions

View File

@@ -22,6 +22,8 @@ Deployed at https://meadows.apoena.dev
- [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,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

@@ -26,6 +26,7 @@ import {
} from "@vue-flow/core"
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from "vue"
import { useAutosave } from "@/composables/useAutosave"
import { usePlayback } from "@/composables/usePlayback"
import { parseModel, serializeModel } from "@/model/io"
import { project } from "@/model/projection"
import { type Sample, SAMPLES } from "@/model/samples"
@@ -54,6 +55,10 @@ const edges = computed(() => graph.value.edges)
// the Model and recomputes reactively while open.
const showResults = ref(false)
// The playback clock that advances the playhead while a run is playing (mounted
// once here; the simulation store holds the state it drives).
usePlayback()
// Explicit shared id: useVueFlow() runs here in the parent setup, before
// <VueFlow> mounts. Pinning both to the same id guarantees they resolve to one
// store instance, so the event hooks below actually fire.
@@ -344,7 +349,28 @@ onBeforeUnmount(() => {
<template>
<div class="flex h-screen flex-col bg-base-200">
<header class="flex items-center gap-3 border-b border-base-300 bg-base-100 px-4 py-2">
<img src="/favicon.svg" alt="" class="size-6" />
<!-- Inlined favicon so the logo strokes with the theme primary (currentColor
via text-primary); an external <img> can't read the theme var. -->
<svg
class="size-6 text-primary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M14 20a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
<path d="M14 4a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
<path d="M6 12a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
<path d="M22 12a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
<path d="M14 12a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
<path d="M6 12h4" />
<path d="M14 12h4" />
<path d="M12 6v4" />
<path d="M12 14v4" />
</svg>
<h1 class="text-lg font-semibold">meadows</h1>
<span class="text-sm text-base-content/50">
{{ store.nodeCount }} {{ store.nodeCount === 1 ? "element" : "elements" }}
@@ -354,7 +380,7 @@ onBeforeUnmount(() => {
<button tabindex="0" class="btn btn-ghost btn-sm">Samples</button>
<ul
tabindex="0"
class="dropdown-content menu z-10 mt-1 w-72 gap-1 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
class="dropdown-content menu z-40 mt-1 max-h-80 w-72 flex-nowrap gap-1 overflow-y-auto rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
<li v-for="sample in SAMPLES" :key="sample.title">
<button class="flex flex-col items-start gap-0.5" @click="loadSample(sample)">

View File

@@ -62,6 +62,12 @@ function onInitial(event: Event): void {
if (Number.isFinite(n)) store.setInitialValue(el.id, n)
}
function onUnit(event: Event): void {
const el = element.value
if (el?.kind !== "stock") return
store.setUnit(el.id, (event.target as HTMLInputElement).value)
}
function onKind(event: Event): void {
const el = element.value
if (el?.kind !== "flow" && el?.kind !== "converter") return
@@ -97,8 +103,9 @@ const RULE_HINT: Record<Rule["kind"], string> = {
<span class="text-xs text-base-content/50">{{ KIND_LABEL[element.kind] }}</span>
</div>
<!-- Stock: the quantity it starts from. -->
<label v-if="element.kind === 'stock'" class="mt-2 block">
<!-- 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"
@@ -108,6 +115,17 @@ const RULE_HINT: Record<Rule["kind"], string> = {
@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>

View File

@@ -8,17 +8,27 @@
* It reads the source-of-truth Model straight from the store and recomputes
* reactively, so editing a rule and reopening re-runs. When the Model is not yet
* sim-ready, it shows what is missing instead of a plot — the gap list doubles as
* a checklist for bringing a diagram to life. No charting dependency: the plot is
* a hand-built SVG (the same lean-substrate choice as the rest of the editor).
* 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 { computed } from "vue"
import { checkSimReady, simulate } from "@/model/simulation"
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))
@@ -34,48 +44,18 @@ function onSpec(key: keyof SimSpec, event: Event): void {
/** Distinct, legible track colours; cycled if a Model has more Stocks than these. */
const COLORS = ["#2563eb", "#dc2626", "#16a34a", "#d97706", "#7c3aed", "#0891b2"]
// SVG canvas in its own coordinate space; it stretches to the panel width, and
// non-scaling strokes keep lines crisp under that non-uniform scale.
const W = 600
const H = 200
const PAD = 12
// 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(() => {
if (problems.value.length > 0) return null
const run = simulate(store.model)
const run = sim.run
if (!run) return null
const stocks = store.model.nodes.filter((n): n is StockNode => n.kind === "stock")
const all = stocks.flatMap((s) => run.series.get(s.id) ?? [])
if (all.length === 0 || run.times.length < 2) return null
if (stocks.length === 0 || run.times.length < 2) return null
let min = Math.min(...all)
let max = Math.max(...all)
if (min === max) {
// A flat trajectory: pad so the line lands mid-panel instead of on an edge.
min -= 1
max += 1
}
const n = run.times.length
const x = (i: number) => PAD + (i / (n - 1)) * (W - 2 * PAD)
const y = (v: number) => PAD + (1 - (v - min) / (max - min)) * (H - 2 * PAD)
const lines = stocks.map((s, i) => {
const values = run.series.get(s.id) ?? []
return {
id: s.id,
name: s.name,
color: COLORS[i % COLORS.length],
points: values.map((v, j) => `${x(j).toFixed(1)},${y(v).toFixed(1)}`).join(" "),
last: values[values.length - 1] ?? 0,
}
})
return { lines, min, max, t0: run.times[0], t1: run.times[n - 1], diverged: run.diverged }
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 }
})
/** Compact numbers for axis ticks and the legend (e.g. 7039.99 → "7040"). */
function fmt(value: number): string {
return Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(2)
}
</script>
<template>
@@ -132,38 +112,35 @@ function fmt(value: number): string {
smaller factor, or a Balancing loop to rein it in.
</p>
<!-- Sim-ready: the plot, with a legend of final values. -->
<div v-if="chart" class="mt-2 flex items-stretch gap-3">
<svg
:viewBox="`0 0 ${W} ${H}`"
preserveAspectRatio="none"
class="h-40 flex-1 rounded-md bg-base-200/40"
role="img"
aria-label="Stock values over time"
<!-- 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()"
>
<text :x="PAD" :y="PAD" class="fill-base-content/40 text-[10px]">{{ fmt(chart.max) }}</text>
<text :x="PAD" :y="H - PAD / 2" class="fill-base-content/40 text-[10px]">
{{ fmt(chart.min) }}
</text>
<polyline
v-for="line in chart.lines"
:key="line.id"
:points="line.points"
fill="none"
:stroke="line.color"
stroke-width="2"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
{{ 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))"
/>
</svg>
<ul class="flex w-44 flex-col gap-1 self-center text-xs">
<li v-for="line in chart.lines" :key="line.id" class="flex items-center gap-2">
<span class="size-2.5 shrink-0 rounded-full" :style="{ backgroundColor: line.color }" />
<span class="truncate">{{ line.name }}</span>
<span class="ml-auto font-mono text-base-content/60">{{ fmt(line.last) }}</span>
</li>
</ul>
<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">

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

@@ -0,0 +1,176 @@
<script setup lang="ts">
/**
* uPlot wrapper for the behaviour-over-time plot. uPlot is an imperative canvas
* library, so this component owns its lifecycle: build on mount, push new samples
* with `setData` when a run recomputes, rebuild when the *set* of tracks changes
* (a Stock renamed/added/removed), follow the container width with a
* ResizeObserver, and destroy on unmount.
*
* Two deliberate settings:
* - `scales.x.time = false` — x is *simulation* time (0…stop), not wall-clock;
* uPlot would otherwise format the axis as calendar dates.
* - Colours are read from the DaisyUI theme variables so the chart matches the
* rest of the editor. The app ships a single light theme (no `data-theme`
* switching), so they are resolved once at build time rather than watched.
*/
import uPlot from "uplot"
import "uplot/dist/uPlot.min.css"
import { onBeforeUnmount, onMounted, ref, watch } from "vue"
/** One plotted track: its legend label and line colour. */
export interface ChartSeries {
label: string
stroke: string
}
const props = withDefaults(
defineProps<{
/** uPlot aligned data: `[times, ...one value track per series]`. */
data: uPlot.AlignedData
/** Track metadata, in the same order as `data[1…]`. */
series: ChartSeries[]
/** Canvas height in CSS pixels (the legend sits below it). */
height?: number
/** Simulation time of the playhead, drawn as a vertical line (null = none). */
marker?: number | null
}>(),
{ height: 160, marker: null },
)
const root = ref<HTMLDivElement>()
let plot: uPlot | undefined
let observer: ResizeObserver | undefined
/** Signature of the current plot's tracks; a change means the shape changed. */
let builtSig = ""
// Playhead as a DOM overlay (CSS px), not a canvas line: a moving line drawn by
// redrawing the whole uPlot canvas every frame flickers, so the line slides over
// the canvas instead and the canvas is only redrawn when the data changes.
const markerX = ref<number | null>(null)
const markerTop = ref(0)
const markerHeight = ref(0)
function syncMarker(): void {
if (!plot || props.marker === null) {
markerX.value = null
return
}
// bbox is in device pixels; the overlay is laid out in CSS pixels.
const ratio = window.devicePixelRatio || 1
// valToPos (CSS px) is relative to the plot area's left edge, but the overlay
// is positioned against the canvas, so add the left gutter (the y-axis width).
markerX.value = plot.bbox.left / ratio + plot.valToPos(props.marker, "x")
markerTop.value = plot.bbox.top / ratio
markerHeight.value = plot.bbox.height / ratio
}
/** Compact axis/legend numbers (7039.99 → "7040", 0.42 → "0.42", idle → "--"). */
function fmt(value: number | null): string {
if (value === null) return "--"
return Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(2)
}
/** A DaisyUI theme colour, with a light-theme fallback if the var is unset. */
function themeColor(name: string, fallback: string): string {
const value = root.value ? getComputedStyle(root.value).getPropertyValue(name).trim() : ""
return value || fallback
}
function trackSig(): string {
return props.series.map((s) => `${s.label}|${s.stroke}`).join(",")
}
function options(width: number): uPlot.Options {
// Follow the app's --font-sans (resolved on the element) rather than naming a
// family here, so the chart tracks the theme font without a second source.
const family = root.value
? getComputedStyle(root.value).fontFamily
: "ui-sans-serif, system-ui, sans-serif"
const axis: uPlot.Axis = {
stroke: themeColor("--color-base-content", "#1f2937"),
grid: { stroke: themeColor("--color-base-300", "#e5e7eb"), width: 1 },
ticks: { stroke: themeColor("--color-base-300", "#e5e7eb"), width: 1 },
font: `11px ${family}`,
}
return {
width,
height: props.height,
cursor: { y: false },
scales: { x: { time: false } },
series: [
{},
...props.series.map(
(s): uPlot.Series => ({
label: s.label,
stroke: s.stroke,
width: 2,
points: { show: false },
value: (_self, raw) => fmt(raw),
}),
),
],
axes: [axis, { ...axis, size: 52 }],
}
}
function build(): void {
if (!root.value) return
plot?.destroy()
builtSig = trackSig()
plot = new uPlot(options(root.value.clientWidth), props.data, root.value)
}
/** Cheap on every recompute; only a shape change forces a full rebuild. */
function render(): void {
if (!plot || trackSig() !== builtSig) build()
else plot.setData(props.data)
syncMarker() // the plot area may have shifted; keep the overlay aligned
}
onMounted(() => {
build()
observer = new ResizeObserver(() => {
if (plot && root.value) {
plot.setSize({ width: root.value.clientWidth, height: props.height })
syncMarker()
}
})
if (root.value) observer.observe(root.value)
syncMarker()
// The canvas paints before the web font loads and won't repaint on its own, so
// axis labels would stick to the fallback. Redraw once the font is ready.
document.fonts?.ready.then(() => {
plot?.redraw()
syncMarker()
})
})
onBeforeUnmount(() => {
observer?.disconnect()
plot?.destroy()
})
watch(() => props.data, render)
// The playhead just slides the overlay — no canvas redraw, so playback stays smooth.
watch(() => props.marker, syncMarker)
</script>
<template>
<div ref="root" class="relative w-full">
<div
v-if="markerX !== null"
class="pointer-events-none absolute w-px bg-primary"
:style="{ left: `${markerX}px`, top: `${markerTop}px`, height: `${markerHeight}px` }"
/>
</div>
</template>
<style scoped>
:deep(.u-legend) {
font-size: 0.75rem;
color: var(--color-base-content);
}
:deep(.u-legend .u-value) {
font-variant-numeric: tabular-nums;
}
</style>

View File

@@ -9,6 +9,8 @@ import { computed } from "vue"
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
import { 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" />
<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

@@ -89,6 +89,9 @@ function nodeError(value: unknown, index: number): string | null {
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 &&

View File

@@ -33,6 +33,14 @@
* 11. Drift to low performance — a goal that erodes toward actual performance, so a
* Reinforcing loop ratchets both downward.
*
* Last, the dynamic the book is named for, and the one the gallery has saved until a
* reader knows every piece it needs:
*
* 12. Overshoot and collapse — a Reinforcing engine running on a *non-renewable*
* Stock (the first with no inflow): it overshoots the
* limit instead of settling at it, the dark twin of
* "Limits to growth" — the ceiling erodes, so it crashes.
*
* These are plain data built from the same tested constructors the store uses
* (factory.ts), so every sample is a valid Model by construction. `build()`
* mints fresh ids on each call, so loading a sample twice never collides.
@@ -77,6 +85,7 @@ function bathtub(): Model {
const source = makeCloud({ x: -280, y: 0 })
const water = makeStock({ x: 0, y: 0 }, "Water")
water.initialValue = 20
water.unit = "L"
const sink = makeCloud({ x: 280, y: 0 })
const filling = makeFlow(
midpoint(source.position, water.position),
@@ -108,6 +117,7 @@ function savings(): Model {
const source = makeCloud({ x: -240, y: -80 })
const balance = makeStock({ x: 120, y: 40 }, "Balance")
balance.initialValue = 1000
balance.unit = "$"
const interest = makeFlow(
midpoint(source.position, balance.position),
"interest",
@@ -133,6 +143,7 @@ function savings(): Model {
function coffee(): Model {
const coffee = makeStock({ x: -200, y: 0 }, "Coffee")
coffee.initialValue = 90
coffee.unit = "°C"
const sink = makeCloud({ x: 200, y: 0 })
const cooling = makeFlow(midpoint(coffee.position, sink.position), "cooling", coffee.id, sink.id)
// cooling = 0.1 × (Coffee room): the `+` input is the level, the `` the target.
@@ -169,6 +180,7 @@ function population(): Model {
lifeExpectancy.rule = { kind: "constant", value: 70 }
const people = makeStock({ x: 0, y: 0 }, "Population")
people.initialValue = 100
people.unit = "people"
const births = makeFlow({ x: -160, y: -160 }, "births", source.id, people.id)
// births = fertility × Population (both `+` inputs): more people and higher
// fertility, more births — the Reinforcing engine.
@@ -206,6 +218,7 @@ function limitsToGrowth(): Model {
const source = makeCloud({ x: -280, y: 0 })
const yeast = makeStock({ x: 40, y: 0 }, "Yeast")
yeast.initialValue = 20
yeast.unit = "cells"
const growth = makeFlow(midpoint(source.position, yeast.position), "growth", source.id, yeast.id)
// growth = 30% of Yeast (its `+` input): the Reinforcing engine.
growth.rule = { kind: "proportional", factor: 0.3 }
@@ -321,10 +334,13 @@ function predatorPrey(): Model {
function epidemic(): Model {
const susceptible = makeStock({ x: -280, y: 0 }, "Susceptible")
susceptible.initialValue = 990
susceptible.unit = "people"
const infected = makeStock({ x: 0, y: 0 }, "Infected")
infected.initialValue = 10
infected.unit = "people"
const recovered = makeStock({ x: 280, y: 0 }, "Recovered")
recovered.initialValue = 0
recovered.unit = "people"
const infection = makeFlow(
midpoint(susceptible.position, infected.position),
"infection",
@@ -563,6 +579,68 @@ function driftToLowPerformance(): Model {
)
}
/**
* Overshoot and collapse — the dark twin of "Limits to growth". The same
* Reinforcing engine runs, but the limit here is a *non-renewable* Resource that
* only depletes: a Stock with no inflow, the first in the gallery. An economy
* (Capital) lives off it — extraction grows with both the Resource left and the
* Capital deployed (Resource, Capital → [+] → extraction), and the revenue is
* reinvested as new Capital (extraction → [+] → investment → Capital), so the loop
* Capital → extraction → investment → Capital carries no `` → Reinforcing. Capital
* climbs and extraction accelerates, but every unit burned is gone for good, so the
* Resource crosses the break-even level, the engine starves, and depreciation
* (Capital → [+] → depreciation, a Balancing drain) takes Capital down: it peaks,
* then collapses. Contrast "Predator and prey", whose prey regrows and so settles
* into oscillation — a finite Resource cannot, so it overshoots and crashes instead.
*/
function overshootAndCollapse(): Model {
// Resource on the left drains only downward (no inflow). Capital on the right runs
// a full Source → investment → Capital → depreciation → Sink column. The two
// coupling links — Capital → extraction and extraction → investment — cross in the
// open centre, where the R badge lands.
const resource = makeStock({ x: -240, y: 0 }, "Resource")
resource.initialValue = 1000
const extractionSink = makeCloud({ x: -240, y: 360 })
const extraction = makeFlow({ x: -240, y: 160 }, "extraction", resource.id, extractionSink.id)
// extraction = factor × Resource × Capital (both `+`): more capital extracts
// faster, scarcer resource slower. The bilinear term the non-negative floor tames.
extraction.rule = { kind: "proportional", factor: 0.0004 }
const capital = makeStock({ x: 240, y: 0 }, "Capital")
capital.initialValue = 5
const investmentSource = makeCloud({ x: 240, y: -360 })
const investment = makeFlow({ x: 240, y: -160 }, "investment", investmentSource.id, capital.id)
// investment = factor × extraction (its one `+` input): the revenue reinvested —
// a Flow feeding a Flow, the edge that closes the Reinforcing loop through Capital.
investment.rule = { kind: "proportional", factor: 0.5 }
const depreciationSink = makeCloud({ x: 240, y: 360 })
const depreciation = makeFlow({ x: 240, y: 160 }, "depreciation", capital.id, depreciationSink.id)
// depreciation = factor × Capital (its `+` input): the Balancing drain that wins
// once the Resource can no longer feed investment.
depreciation.rule = { kind: "proportional", factor: 0.04 }
return model(
"Overshoot and collapse",
[
resource,
extractionSink,
extraction,
capital,
investmentSource,
investment,
depreciationSink,
depreciation,
],
[
link(resource, extraction, "+"),
link(capital, extraction, "+"),
link(extraction, investment, "+"),
link(capital, depreciation, "+"),
],
// Capital starts at 5, overshoots to ~250 by t≈39, and collapses back near its
// starting level by t=150 — the full boom-and-bust arc, no dead tail.
{ start: 0, stop: 150, dt: 1 },
)
}
/** The gallery, ordered simplest first. */
export const SAMPLES: Sample[] = [
{ title: "Bathtub", blurb: "A stock filled and drained — no feedback yet.", build: bathtub },
@@ -616,4 +694,9 @@ export const SAMPLES: Sample[] = [
blurb: "Goals erode toward actual: a Reinforcing slide downhill.",
build: driftToLowPerformance,
},
{
title: "Overshoot and collapse",
blurb: "A growth engine burns a finite Resource: it peaks, then crashes.",
build: overshootAndCollapse,
},
]

View File

@@ -76,6 +76,8 @@ export interface StockNode extends BaseNode {
name: string
/** Initial accumulated quantity. Optional in the diagram phase; the simulator reads it. */
initialValue?: number
/** Unit of the quantity, e.g. "°C", "people", "$" — shown beside the value (display only). */
unit?: string
}
/**

View File

@@ -117,6 +117,17 @@ export const useModelStore = defineStore("model", () => {
else node.initialValue = value
}
/** Set (or clear) a Stock's display unit (e.g. "°C"). Empty clears it. No-op when unchanged. */
function setUnit(id: string, unit: string): void {
const node = findNode(id)
if (!node || node.kind !== "stock") return
const next = unit.trim() || undefined
if (node.unit === next) return
record()
if (next === undefined) delete node.unit
else node.unit = next
}
/**
* Set (or clear) how a Flow's rate or a Converter's value is computed (ADR-0004:
* one of the fixed rules, never a formula). No-op when unchanged.
@@ -279,6 +290,7 @@ export const useModelStore = defineStore("model", () => {
moveNode,
renameNode,
setInitialValue,
setUnit,
setRule,
setSimSpec,
removeNode,

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