Compare commits

32 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
14 changed files with 2066 additions and 221 deletions

View File

@@ -22,20 +22,33 @@ 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. One gesture, two payoffs — no new per-link data.
target, and Overflow the same with its `` input as the threshold. One gesture,
two payoffs — no new per-link data.
## Considered Options
@@ -70,5 +83,7 @@ target. One gesture, two payoffs — no new per-link data.
- The vocabulary starts deliberately small (Constant / Proportional / Gap —
enough for linear, exponential, and goal-seeking, and for the coffee and
savings samples). Growing it is additive: a new `kind` in the union plus a case
in the evaluator. Multi-input products (e.g. `Population × fertility`) are a
later increment, not a phase-2 blocker.
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

@@ -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,12 +24,12 @@ 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"
@@ -38,6 +38,7 @@ 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"
@@ -55,6 +56,9 @@ const edges = computed(() => graph.value.edges)
// 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()
@@ -74,6 +78,7 @@ const {
getSelectedEdges,
viewport,
vueFlowRef,
dimensions,
fitView,
} = useVueFlow("meadows")
@@ -89,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.
@@ -249,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")
@@ -349,26 +374,34 @@ 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-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)">
<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 }"
@@ -439,7 +472,6 @@ onBeforeUnmount(() => {
<LoopOverlay />
<GlossPanel />
<Inspector />
<ResultsPanel v-if="showResults" @close="showResults = false" />
<!-- Self-dismissing teaching hint, e.g. when a Flow is dragged back onto its
own Stock to "close the loop". Click to dismiss early; z-30 keeps it above
@@ -458,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

@@ -14,20 +14,25 @@
import { useVueFlow } from "@vue-flow/core"
import { computed } from "vue"
import type { EdgeData } from "@/model/projection"
import type { ConverterNode, FlowNode, Rule, StockNode } from "@/model/types"
import 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, or a Flow via its pipe edge. */
/**
* 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]
if ((edge.data as EdgeData | undefined)?.kind === "pipe") return edge.id.split("::")[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
})
@@ -41,8 +46,21 @@ const element = computed<StockNode | FlowNode | ConverterNode | null>(() => {
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
@@ -62,6 +80,19 @@ 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)
}
/** 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
@@ -84,21 +115,26 @@ 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"
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. -->
<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 +144,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>
@@ -122,6 +169,7 @@ const RULE_HINT: Record<Rule["kind"], string> = {
<option value="constant">Constant</option>
<option value="proportional">Proportional</option>
<option value="gap">Gap</option>
<option value="overflow">Overflow</option>
</select>
</label>
@@ -142,5 +190,29 @@ const RULE_HINT: Record<Rule["kind"], string> = {
{{ 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

@@ -59,9 +59,7 @@ const chart = computed(() => {
</script>
<template>
<div
class="absolute inset-x-3 bottom-3 z-30 rounded-box border border-base-300 bg-base-100/95 p-3 shadow-lg backdrop-blur"
>
<div class="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>
@@ -134,7 +132,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

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

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 "--"
@@ -55,8 +76,54 @@ function themeColor(name: string, fallback: string): string {
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 {
return props.series.map((s) => `${s.label}|${s.stroke}`).join(",")
const scales = scaleKeys()
return props.series.map((s, i) => `${s.label}|${s.stroke}|${scales[i]}`).join(",")
}
function options(width: number): uPlot.Options {
@@ -71,36 +138,24 @@ function options(width: number): uPlot.Options {
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,
cursor: { y: false },
// 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 } },
// 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: [
{},
// 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): uPlot.Series => ({
(s, i): uPlot.Series => ({
label: s.label,
scale: scales[i],
stroke: s.stroke,
width: 2,
points: { show: false },
@@ -108,7 +163,15 @@ function options(width: number): uPlot.Options {
}),
),
],
axes: [axis, { ...axis, size: 52 }],
// 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 }],
}
}
@@ -123,17 +186,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 +213,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>
@@ -161,4 +235,12 @@ watch(
: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

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

@@ -55,7 +55,8 @@ function isPolarity(value: unknown): value is Polarity {
function isRule(value: unknown): value is Rule {
if (!isObject(value)) return false
if (value.kind === "constant") return isFiniteNumber(value.value)
if (value.kind === "proportional" || value.kind === "gap") return isFiniteNumber(value.factor)
if (value.kind === "proportional" || value.kind === "gap" || value.kind === "overflow")
return isFiniteNumber(value.factor)
return false
}
@@ -89,6 +90,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 &&
@@ -96,6 +100,14 @@ function nodeError(value: unknown, index: number): string | null {
) {
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
}
@@ -107,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
}

File diff suppressed because it is too large Load Diff

View File

@@ -107,6 +107,19 @@ function evalRule(rule: Rule, links: InformationLink[], valueOf: (id: string) =>
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)),
)
}
}
}

View File

@@ -42,11 +42,15 @@ export interface Position {
* (→ 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
@@ -76,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
}
/**
@@ -93,6 +101,8 @@ export interface FlowNode extends BaseNode {
target: 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
}
/**
@@ -104,6 +114,8 @@ export interface ConverterNode extends BaseNode {
name: 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
}
/**
@@ -129,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. */

View File

@@ -117,6 +117,39 @@ 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) 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.
@@ -279,6 +312,8 @@ export const useModelStore = defineStore("model", () => {
moveNode,
renameNode,
setInitialValue,
setUnit,
setDescription,
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 {