Compare commits

16 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
10 changed files with 1674 additions and 193 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: or two — never an expression:
| Rule | Value | Reads (via Information Links) | Emergent behaviour | | Rule | Value | Reads (via Information Links) | Emergent behaviour |
| ---------------- | --------------------------- | ----------------------------------------- | -------------------------- | | ---------------- | -------------------------------------- | -------------------------------------------- | -------------------------- |
| **Constant** | a fixed number | nothing | linear Stock change | | **Constant** | a fixed number | nothing | linear Stock change |
| **Proportional** | `factor × (its `+` inputs)` | the `+`-polarity inputs | exponential growth / decay | | **Proportional** | `factor × (its `+` inputs)` | the `+`-polarity inputs | exponential growth / decay |
| **Gap** | `factor × (level target)` | the `+` input is _level_, `` is _target_ | goal-seeking / asymptotic | | **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 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); 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; 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. 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 **Polarity does double duty.** The `+`/`` already captured for loop
classification (ADR-0001) also selects each operand's role: Proportional reads 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 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 ## Considered Options
@@ -70,5 +83,7 @@ target. One gesture, two payoffs — no new per-link data.
- The vocabulary starts deliberately small (Constant / Proportional / Gap — - The vocabulary starts deliberately small (Constant / Proportional / Gap —
enough for linear, exponential, and goal-seeking, and for the coffee and 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 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 in the evaluator — as **Overflow** later bore out (one union member, one
later increment, not a phase-2 blocker. 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

@@ -29,7 +29,7 @@ import { useAutosave } from "@/composables/useAutosave"
import { usePlayback } from "@/composables/usePlayback" import { usePlayback } from "@/composables/usePlayback"
import { parseModel, serializeModel } from "@/model/io" import { parseModel, serializeModel } from "@/model/io"
import { project } from "@/model/projection" import { project } from "@/model/projection"
import { type Sample, SAMPLES } from "@/model/samples" import type { Sample } from "@/model/samples"
import { canConnect } from "@/model/validation" import { canConnect } from "@/model/validation"
import { useModelStore } from "@/store/model" import { useModelStore } from "@/store/model"
import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd" import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd"
@@ -38,6 +38,7 @@ import Inspector from "./Inspector.vue"
import LoopOverlay from "./LoopOverlay.vue" import LoopOverlay from "./LoopOverlay.vue"
import Palette from "./Palette.vue" import Palette from "./Palette.vue"
import ResultsPanel from "./ResultsPanel.vue" import ResultsPanel from "./ResultsPanel.vue"
import SampleBrowser from "./SampleBrowser.vue"
import InfoLinkEdge from "./edges/InfoLinkEdge.vue" import InfoLinkEdge from "./edges/InfoLinkEdge.vue"
import PipeEdge from "./edges/PipeEdge.vue" import PipeEdge from "./edges/PipeEdge.vue"
import CloudNode from "./nodes/CloudNode.vue" import CloudNode from "./nodes/CloudNode.vue"
@@ -55,6 +56,9 @@ const edges = computed(() => graph.value.edges)
// the Model and recomputes reactively while open. // the Model and recomputes reactively while open.
const showResults = ref(false) 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 // The playback clock that advances the playhead while a run is playing (mounted
// once here; the simulation store holds the state it drives). // once here; the simulation store holds the state it drives).
usePlayback() usePlayback()
@@ -270,12 +274,12 @@ function onDragOver(event: DragEvent): void {
*/ */
function loadSample(sample: Sample): void { function loadSample(sample: Sample): void {
if (store.nodeCount > 0 && !window.confirm(`Replace the current model with “${sample.title}”?`)) { 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 return
} }
fitAfterInit = true fitAfterInit = true
store.setModel(sample.build()) store.setModel(sample.build())
// Close the DaisyUI dropdown (it stays open while the trigger keeps focus). browserOpen.value = false
;(document.activeElement as HTMLElement | null)?.blur()
} }
const fileInput = useTemplateRef<HTMLInputElement>("fileInput") const fileInput = useTemplateRef<HTMLInputElement>("fileInput")
@@ -397,20 +401,7 @@ onBeforeUnmount(() => {
{{ store.nodeCount }} {{ store.nodeCount === 1 ? "element" : "elements" }} {{ store.nodeCount }} {{ store.nodeCount === 1 ? "element" : "elements" }}
</span> </span>
<div class="ml-auto flex items-center gap-1"> <div class="ml-auto flex items-center gap-1">
<div class="dropdown dropdown-end"> <button class="btn btn-ghost btn-sm" @click="browserOpen = true">Samples</button>
<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 <button
class="btn btn-primary btn-sm" class="btn btn-primary btn-sm"
:class="{ 'btn-active': showResults }" :class="{ 'btn-active': showResults }"
@@ -504,5 +495,7 @@ onBeforeUnmount(() => {
the canvas to the space above, where the diagram re-fits (see the the canvas to the space above, where the diagram re-fits (see the
`dimensions` watcher in <script>). --> `dimensions` watcher in <script>). -->
<ResultsPanel v-if="showResults" @close="showResults = false" /> <ResultsPanel v-if="showResults" @close="showResults = false" />
<SampleBrowser v-model:open="browserOpen" @select="loadSample" />
</div> </div>
</template> </template>

View File

@@ -14,20 +14,25 @@
import { useVueFlow } from "@vue-flow/core" import { useVueFlow } from "@vue-flow/core"
import { computed } from "vue" import { computed } from "vue"
import type { EdgeData } from "@/model/projection" 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" import { useModelStore } from "@/store/model"
const store = useModelStore() const store = useModelStore()
const { getSelectedNodes, getSelectedEdges } = useVueFlow("meadows") 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 selectedId = computed<string | null>(() => {
const nodes = getSelectedNodes.value const nodes = getSelectedNodes.value
const edges = getSelectedEdges.value const edges = getSelectedEdges.value
if (nodes.length === 1 && edges.length === 0) return nodes[0].id if (nodes.length === 1 && edges.length === 0) return nodes[0].id
if (edges.length === 1 && nodes.length === 0) { if (edges.length === 1 && nodes.length === 0) {
const edge = edges[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 return null
}) })
@@ -41,8 +46,21 @@ const element = computed<StockNode | FlowNode | ConverterNode | null>(() => {
return 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 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. */ /** Every rule carries exactly one number (a value or a factor); read it uniformly. */
function ruleNumber(rule?: Rule): number { function ruleNumber(rule?: Rule): number {
if (!rule) return 0 if (!rule) return 0
@@ -68,6 +86,13 @@ function onUnit(event: Event): void {
store.setUnit(el.id, (event.target as HTMLInputElement).value) 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 { function onKind(event: Event): void {
const el = element.value const el = element.value
if (el?.kind !== "flow" && el?.kind !== "converter") return if (el?.kind !== "flow" && el?.kind !== "converter") return
@@ -90,14 +115,18 @@ const RULE_HINT: Record<Rule["kind"], string> = {
constant: "A fixed number — no inputs.", constant: "A fixed number — no inputs.",
proportional: "rate = factor × its “+” inputs.", proportional: "rate = factor × its “+” inputs.",
gap: "rate = factor × (level target): the “+” input is the level, the “−” the target.", 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> </script>
<template> <template>
<div <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" 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"> <div class="flex items-baseline gap-2">
<span class="text-sm font-semibold">{{ element.name }}</span> <span class="text-sm font-semibold">{{ element.name }}</span>
<span class="text-xs text-base-content/50">{{ KIND_LABEL[element.kind] }}</span> <span class="text-xs text-base-content/50">{{ KIND_LABEL[element.kind] }}</span>
@@ -140,6 +169,7 @@ const RULE_HINT: Record<Rule["kind"], string> = {
<option value="constant">Constant</option> <option value="constant">Constant</option>
<option value="proportional">Proportional</option> <option value="proportional">Proportional</option>
<option value="gap">Gap</option> <option value="gap">Gap</option>
<option value="overflow">Overflow</option>
</select> </select>
</label> </label>
@@ -160,5 +190,29 @@ const RULE_HINT: Record<Rule["kind"], string> = {
{{ RULE_HINT[element.rule.kind] }} {{ RULE_HINT[element.rule.kind] }}
</p> </p>
</template> </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> </div>
</template> </template>

View File

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

View File

@@ -76,8 +76,54 @@ function themeColor(name: string, fallback: string): string {
return value || fallback 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 { 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 { function options(width: number): uPlot.Options {
@@ -92,6 +138,8 @@ function options(width: number): uPlot.Options {
ticks: { stroke: themeColor("--color-base-300", "#e5e7eb"), width: 1 }, ticks: { stroke: themeColor("--color-base-300", "#e5e7eb"), width: 1 },
font: `11px ${family}`, font: `11px ${family}`,
} }
const scales = scaleKeys()
const split = scales.includes("y2")
return { return {
width, width,
height: props.height, height: props.height,
@@ -101,10 +149,13 @@ function options(width: number): uPlot.Options {
cursor: { y: false, drag: { x: false, y: false } }, cursor: { y: false, drag: { x: false, y: false } },
scales: { x: { time: false } }, scales: { x: { time: false } },
series: [ 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( ...props.series.map(
(s): uPlot.Series => ({ (s, i): uPlot.Series => ({
label: s.label, label: s.label,
scale: scales[i],
stroke: s.stroke, stroke: s.stroke,
width: 2, width: 2,
points: { show: false }, points: { show: false },
@@ -112,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 }],
} }
} }
@@ -176,4 +235,12 @@ watch(() => props.marker, syncMarker)
:deep(.u-legend .u-value) { :deep(.u-legend .u-value) {
font-variant-numeric: tabular-nums; 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> </style>

View File

@@ -55,7 +55,8 @@ function isPolarity(value: unknown): value is Polarity {
function isRule(value: unknown): value is Rule { function isRule(value: unknown): value is Rule {
if (!isObject(value)) return false if (!isObject(value)) return false
if (value.kind === "constant") return isFiniteNumber(value.value) 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 return false
} }
@@ -99,6 +100,14 @@ function nodeError(value: unknown, index: number): string | null {
) { ) {
return `${at}.rule must be a valid rule (constant/proportional/gap)` 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 return null
} }
@@ -110,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.source !== "string") return `${at}.source must be a string`
if (typeof value.target !== "string") return `${at}.target must be a string` if (typeof value.target !== "string") return `${at}.target must be a string`
if (!isPolarity(value.polarity)) return `${at}.polarity must be "+" or "-"` 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 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)) 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) * (→ exponential growth/decay)
* - `gap` — `factor × (level target)`, where the `+` input is the * - `gap` — `factor × (level target)`, where the `+` input is the
* level and the `` input the target. (→ goal-seeking) * 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 = export type Rule =
| { kind: "constant"; value: number } | { kind: "constant"; value: number }
| { kind: "proportional"; factor: number } | { kind: "proportional"; factor: number }
| { kind: "gap"; factor: number } | { kind: "gap"; factor: number }
| { kind: "overflow"; factor: number }
/** /**
* The run parameters for a simulation: integrate from `start` to `stop` in steps * The run parameters for a simulation: integrate from `start` to `stop` in steps
@@ -78,6 +82,8 @@ export interface StockNode extends BaseNode {
initialValue?: number initialValue?: number
/** Unit of the quantity, e.g. "°C", "people", "$" — shown beside the value (display only). */ /** Unit of the quantity, e.g. "°C", "people", "$" — shown beside the value (display only). */
unit?: string unit?: string
/** Why this Stock exists in *this* model — a free-text note surfaced on selection (display only). */
description?: string
} }
/** /**
@@ -95,6 +101,8 @@ export interface FlowNode extends BaseNode {
target: string target: string
/** How its rate is computed each instant (ADR-0004). Optional in the diagram phase. */ /** How its rate is computed each instant (ADR-0004). Optional in the diagram phase. */
rule?: Rule rule?: Rule
/** Why this Flow exists in *this* model — a free-text note surfaced on selection (display only). */
description?: string
} }
/** /**
@@ -106,6 +114,8 @@ export interface ConverterNode extends BaseNode {
name: string name: string
/** How its value is computed each instant (ADR-0004). Optional in the diagram phase. */ /** How its value is computed each instant (ADR-0004). Optional in the diagram phase. */
rule?: Rule rule?: Rule
/** Why this Converter exists in *this* model — a free-text note surfaced on selection (display only). */
description?: string
} }
/** /**
@@ -131,6 +141,8 @@ export interface InformationLink {
/** Target node id — a Flow or Converter, never a Stock (ADR-0001). */ /** Target node id — a Flow or Converter, never a Stock (ADR-0001). */
target: string target: string
polarity: Polarity 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. */ /** One saved document: the unit of save / export / reopen. */

View File

@@ -128,6 +128,28 @@ export const useModelStore = defineStore("model", () => {
else node.unit = next 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: * 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. * one of the fixed rules, never a formula). No-op when unchanged.
@@ -291,6 +313,7 @@ export const useModelStore = defineStore("model", () => {
renameNode, renameNode,
setInitialValue, setInitialValue,
setUnit, setUnit,
setDescription,
setRule, setRule,
setSimSpec, setSimSpec,
removeNode, removeNode,