Compare commits
10 Commits
ed6f011e69
...
feat/overs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a4fe59811 | ||
|
|
3a65bf5d59 | ||
|
|
8e1e313b20 | ||
|
|
d570425402 | ||
|
|
2c7ec6a6ec | ||
|
|
ef0cc2878b | ||
|
|
f38cf49f25 | ||
|
|
d5cda95c3e | ||
|
|
dc30e5f890 | ||
|
|
382fdddc68 |
@@ -4,7 +4,7 @@
|
|||||||
height="24"
|
height="24"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="#16A34A"
|
stroke="#ff9ff3"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 550 B After Width: | Height: | Size: 550 B |
@@ -349,7 +349,28 @@ onBeforeUnmount(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-screen flex-col bg-base-200">
|
<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">
|
<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>
|
<h1 class="text-lg font-semibold">meadows</h1>
|
||||||
<span class="text-sm text-base-content/50">
|
<span class="text-sm text-base-content/50">
|
||||||
{{ store.nodeCount }} {{ store.nodeCount === 1 ? "element" : "elements" }}
|
{{ store.nodeCount }} {{ store.nodeCount === 1 ? "element" : "elements" }}
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ function onInitial(event: Event): void {
|
|||||||
if (Number.isFinite(n)) store.setInitialValue(el.id, n)
|
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 {
|
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
|
||||||
@@ -97,17 +103,29 @@ const RULE_HINT: Record<Rule["kind"], string> = {
|
|||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stock: the quantity it starts from. -->
|
<!-- Stock: the quantity it starts from, and its unit. -->
|
||||||
<label v-if="element.kind === 'stock'" class="mt-2 block">
|
<template v-if="element.kind === 'stock'">
|
||||||
<span class="text-xs text-base-content/60">Initial value</span>
|
<label class="mt-2 block">
|
||||||
<input
|
<span class="text-xs text-base-content/60">Initial value</span>
|
||||||
type="number"
|
<input
|
||||||
class="input input-sm input-bordered mt-1 w-full"
|
type="number"
|
||||||
:value="element.initialValue ?? ''"
|
class="input input-sm input-bordered mt-1 w-full"
|
||||||
placeholder="—"
|
:value="element.initialValue ?? ''"
|
||||||
@change="onInitial"
|
placeholder="—"
|
||||||
/>
|
@change="onInitial"
|
||||||
</label>
|
/>
|
||||||
|
</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. -->
|
<!-- Flow / Converter: pick a rule, then its number. -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|||||||
@@ -134,7 +134,9 @@ const chart = computed(() => {
|
|||||||
aria-label="Playhead"
|
aria-label="Playhead"
|
||||||
@input="sim.seek(Number(($event.target as HTMLInputElement).value))"
|
@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 }}
|
t = {{ sim.currentTime ?? 0 }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,6 +43,27 @@ let observer: ResizeObserver | undefined
|
|||||||
/** Signature of the current plot's tracks; a change means the shape changed. */
|
/** Signature of the current plot's tracks; a change means the shape changed. */
|
||||||
let builtSig = ""
|
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 → "--"). */
|
/** Compact axis/legend numbers (7039.99 → "7040", 0.42 → "0.42", idle → "--"). */
|
||||||
function fmt(value: number | null): string {
|
function fmt(value: number | null): string {
|
||||||
if (value === null) return "--"
|
if (value === null) return "--"
|
||||||
@@ -76,26 +97,6 @@ function options(width: number): uPlot.Options {
|
|||||||
height: props.height,
|
height: props.height,
|
||||||
cursor: { y: false },
|
cursor: { y: false },
|
||||||
scales: { x: { time: 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: [
|
series: [
|
||||||
{},
|
{},
|
||||||
...props.series.map(
|
...props.series.map(
|
||||||
@@ -123,17 +124,25 @@ function build(): void {
|
|||||||
function render(): void {
|
function render(): void {
|
||||||
if (!plot || trackSig() !== builtSig) build()
|
if (!plot || trackSig() !== builtSig) build()
|
||||||
else plot.setData(props.data)
|
else plot.setData(props.data)
|
||||||
|
syncMarker() // the plot area may have shifted; keep the overlay aligned
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
build()
|
build()
|
||||||
observer = new ResizeObserver(() => {
|
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)
|
if (root.value) observer.observe(root.value)
|
||||||
|
syncMarker()
|
||||||
// The canvas paints before the web font loads and won't repaint on its own, so
|
// 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.
|
// 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(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -142,15 +151,18 @@ onBeforeUnmount(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
watch(() => props.data, render)
|
watch(() => props.data, render)
|
||||||
// The playhead moves without the data changing, so nudge a redraw on its own.
|
// The playhead just slides the overlay — no canvas redraw, so playback stays smooth.
|
||||||
watch(
|
watch(() => props.marker, syncMarker)
|
||||||
() => props.marker,
|
|
||||||
() => plot?.redraw(),
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -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"
|
class="pointer-events-none absolute inset-[2px] z-0 flex flex-col justify-end"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="rounded-b bg-primary/40 transition-[height]"
|
class="rounded-b bg-primary/60 transition-[height]"
|
||||||
:style="{ height: fill * 100 + '%' }"
|
:style="{ height: fill * 100 + '%' }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
|
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
|
||||||
<div class="relative z-10">
|
<div class="relative z-10">
|
||||||
<NodeLabel :node-id="props.id" :name="stock.name" />
|
<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>
|
</div>
|
||||||
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ function nodeError(value: unknown, index: number): string | null {
|
|||||||
if (kind === "stock" && value.initialValue !== undefined && !isFiniteNumber(value.initialValue)) {
|
if (kind === "stock" && value.initialValue !== undefined && !isFiniteNumber(value.initialValue)) {
|
||||||
return `${at}.initialValue must be a finite number`
|
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 (
|
if (
|
||||||
(kind === "flow" || kind === "converter") &&
|
(kind === "flow" || kind === "converter") &&
|
||||||
value.rule !== undefined &&
|
value.rule !== undefined &&
|
||||||
|
|||||||
@@ -33,6 +33,14 @@
|
|||||||
* 11. Drift to low performance — a goal that erodes toward actual performance, so a
|
* 11. Drift to low performance — a goal that erodes toward actual performance, so a
|
||||||
* Reinforcing loop ratchets both downward.
|
* 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
|
* 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()`
|
* (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.
|
* 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 source = makeCloud({ x: -280, y: 0 })
|
||||||
const water = makeStock({ x: 0, y: 0 }, "Water")
|
const water = makeStock({ x: 0, y: 0 }, "Water")
|
||||||
water.initialValue = 20
|
water.initialValue = 20
|
||||||
|
water.unit = "L"
|
||||||
const sink = makeCloud({ x: 280, y: 0 })
|
const sink = makeCloud({ x: 280, y: 0 })
|
||||||
const filling = makeFlow(
|
const filling = makeFlow(
|
||||||
midpoint(source.position, water.position),
|
midpoint(source.position, water.position),
|
||||||
@@ -108,6 +117,7 @@ function savings(): Model {
|
|||||||
const source = makeCloud({ x: -240, y: -80 })
|
const source = makeCloud({ x: -240, y: -80 })
|
||||||
const balance = makeStock({ x: 120, y: 40 }, "Balance")
|
const balance = makeStock({ x: 120, y: 40 }, "Balance")
|
||||||
balance.initialValue = 1000
|
balance.initialValue = 1000
|
||||||
|
balance.unit = "$"
|
||||||
const interest = makeFlow(
|
const interest = makeFlow(
|
||||||
midpoint(source.position, balance.position),
|
midpoint(source.position, balance.position),
|
||||||
"interest",
|
"interest",
|
||||||
@@ -133,6 +143,7 @@ function savings(): Model {
|
|||||||
function coffee(): Model {
|
function coffee(): Model {
|
||||||
const coffee = makeStock({ x: -200, y: 0 }, "Coffee")
|
const coffee = makeStock({ x: -200, y: 0 }, "Coffee")
|
||||||
coffee.initialValue = 90
|
coffee.initialValue = 90
|
||||||
|
coffee.unit = "°C"
|
||||||
const sink = makeCloud({ x: 200, y: 0 })
|
const sink = makeCloud({ x: 200, y: 0 })
|
||||||
const cooling = makeFlow(midpoint(coffee.position, sink.position), "cooling", coffee.id, sink.id)
|
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.
|
// 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 }
|
lifeExpectancy.rule = { kind: "constant", value: 70 }
|
||||||
const people = makeStock({ x: 0, y: 0 }, "Population")
|
const people = makeStock({ x: 0, y: 0 }, "Population")
|
||||||
people.initialValue = 100
|
people.initialValue = 100
|
||||||
|
people.unit = "people"
|
||||||
const births = makeFlow({ x: -160, y: -160 }, "births", source.id, people.id)
|
const births = makeFlow({ x: -160, y: -160 }, "births", source.id, people.id)
|
||||||
// births = fertility × Population (both `+` inputs): more people and higher
|
// births = fertility × Population (both `+` inputs): more people and higher
|
||||||
// fertility, more births — the Reinforcing engine.
|
// fertility, more births — the Reinforcing engine.
|
||||||
@@ -206,6 +218,7 @@ function limitsToGrowth(): Model {
|
|||||||
const source = makeCloud({ x: -280, y: 0 })
|
const source = makeCloud({ x: -280, y: 0 })
|
||||||
const yeast = makeStock({ x: 40, y: 0 }, "Yeast")
|
const yeast = makeStock({ x: 40, y: 0 }, "Yeast")
|
||||||
yeast.initialValue = 20
|
yeast.initialValue = 20
|
||||||
|
yeast.unit = "cells"
|
||||||
const growth = makeFlow(midpoint(source.position, yeast.position), "growth", source.id, yeast.id)
|
const growth = makeFlow(midpoint(source.position, yeast.position), "growth", source.id, yeast.id)
|
||||||
// growth = 30% of Yeast (its `+` input): the Reinforcing engine.
|
// growth = 30% of Yeast (its `+` input): the Reinforcing engine.
|
||||||
growth.rule = { kind: "proportional", factor: 0.3 }
|
growth.rule = { kind: "proportional", factor: 0.3 }
|
||||||
@@ -321,10 +334,13 @@ function predatorPrey(): Model {
|
|||||||
function epidemic(): Model {
|
function epidemic(): Model {
|
||||||
const susceptible = makeStock({ x: -280, y: 0 }, "Susceptible")
|
const susceptible = makeStock({ x: -280, y: 0 }, "Susceptible")
|
||||||
susceptible.initialValue = 990
|
susceptible.initialValue = 990
|
||||||
|
susceptible.unit = "people"
|
||||||
const infected = makeStock({ x: 0, y: 0 }, "Infected")
|
const infected = makeStock({ x: 0, y: 0 }, "Infected")
|
||||||
infected.initialValue = 10
|
infected.initialValue = 10
|
||||||
|
infected.unit = "people"
|
||||||
const recovered = makeStock({ x: 280, y: 0 }, "Recovered")
|
const recovered = makeStock({ x: 280, y: 0 }, "Recovered")
|
||||||
recovered.initialValue = 0
|
recovered.initialValue = 0
|
||||||
|
recovered.unit = "people"
|
||||||
const infection = makeFlow(
|
const infection = makeFlow(
|
||||||
midpoint(susceptible.position, infected.position),
|
midpoint(susceptible.position, infected.position),
|
||||||
"infection",
|
"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. */
|
/** The gallery, ordered simplest first. */
|
||||||
export const SAMPLES: Sample[] = [
|
export const SAMPLES: Sample[] = [
|
||||||
{ title: "Bathtub", blurb: "A stock filled and drained — no feedback yet.", build: bathtub },
|
{ 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.",
|
blurb: "Goals erode toward actual: a Reinforcing slide downhill.",
|
||||||
build: driftToLowPerformance,
|
build: driftToLowPerformance,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Overshoot and collapse",
|
||||||
|
blurb: "A growth engine burns a finite Resource: it peaks, then crashes.",
|
||||||
|
build: overshootAndCollapse,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ export interface StockNode extends BaseNode {
|
|||||||
name: string
|
name: string
|
||||||
/** Initial accumulated quantity. Optional in the diagram phase; the simulator reads it. */
|
/** Initial accumulated quantity. Optional in the diagram phase; the simulator reads it. */
|
||||||
initialValue?: number
|
initialValue?: number
|
||||||
|
/** Unit of the quantity, e.g. "°C", "people", "$" — shown beside the value (display only). */
|
||||||
|
unit?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -117,6 +117,17 @@ export const useModelStore = defineStore("model", () => {
|
|||||||
else node.initialValue = value
|
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:
|
* 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.
|
||||||
@@ -279,6 +290,7 @@ export const useModelStore = defineStore("model", () => {
|
|||||||
moveNode,
|
moveNode,
|
||||||
renameNode,
|
renameNode,
|
||||||
setInitialValue,
|
setInitialValue,
|
||||||
|
setUnit,
|
||||||
setRule,
|
setRule,
|
||||||
setSimSpec,
|
setSimSpec,
|
||||||
removeNode,
|
removeNode,
|
||||||
|
|||||||
@@ -9,7 +9,16 @@
|
|||||||
@plugin "daisyui/theme" {
|
@plugin "daisyui/theme" {
|
||||||
name: "light";
|
name: "light";
|
||||||
default: true;
|
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 {
|
@theme {
|
||||||
|
|||||||
Reference in New Issue
Block a user