Compare commits

..

10 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
11 changed files with 216 additions and 47 deletions

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

@@ -349,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" }}

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,17 +103,29 @@ 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">
<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>
<!-- 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>

View File

@@ -134,7 +134,9 @@ const chart = computed(() => {
aria-label="Playhead"
@input="sim.seek(Number(($event.target as HTMLInputElement).value))"
/>
<span class="w-14 text-right font-mono text-xs tabular-nums text-base-content/60">
<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>

View File

@@ -43,6 +43,27 @@ 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 "--"
@@ -76,26 +97,6 @@ function options(width: number): uPlot.Options {
height: props.height,
cursor: { y: false },
scales: { x: { time: false } },
// Playhead: a vertical line at the current simulation time, so the chart and
// the animating canvas read off the same instant. Drawn after the series, and
// redrawn whenever `marker` changes (see the watch below).
hooks: {
draw: [
(u: uPlot): void => {
if (props.marker === null) return
const x = Math.round(u.valToPos(props.marker, "x", true))
const { ctx } = u
ctx.save()
ctx.beginPath()
ctx.lineWidth = 1
ctx.strokeStyle = themeColor("--color-primary", "#2563eb")
ctx.moveTo(x, u.bbox.top)
ctx.lineTo(x, u.bbox.top + u.bbox.height)
ctx.stroke()
ctx.restore()
},
],
},
series: [
{},
...props.series.map(
@@ -123,17 +124,25 @@ function build(): void {
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 })
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())
document.fonts?.ready.then(() => {
plot?.redraw()
syncMarker()
})
})
onBeforeUnmount(() => {
@@ -142,15 +151,18 @@ onBeforeUnmount(() => {
})
watch(() => props.data, render)
// The playhead moves without the data changing, so nudge a redraw on its own.
watch(
() => props.marker,
() => plot?.redraw(),
)
// The playhead just slides the overlay — no canvas redraw, so playback stays smooth.
watch(() => props.marker, syncMarker)
</script>
<template>
<div ref="root" class="w-full" />
<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>

View File

@@ -37,17 +37,24 @@ const fill = computed(() => sim.fill(props.id))
class="pointer-events-none absolute inset-[2px] z-0 flex flex-col justify-end"
>
<div
class="rounded-b bg-primary/40 transition-[height]"
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 v-if="value !== null" class="mt-0.5 font-mono text-xs tabular-nums text-base-content/70">
{{ formatValue(value) }}
</div>
</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

@@ -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,

View File

@@ -9,7 +9,16 @@
@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 {