Compare commits
14 Commits
26d3c4493a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c8a0c294f | ||
|
|
37e3a1e89f | ||
|
|
914cf01763 | ||
|
|
fe17bc8fae | ||
|
|
34bc8fa70b | ||
|
|
81425f8f3d | ||
|
|
d36f58977e | ||
|
|
62f6c5cda1 | ||
|
|
9ddff65072 | ||
|
|
faf8500fa5 | ||
|
|
f691b99ca5 | ||
|
|
46d3279a3c | ||
|
|
15e9149095 | ||
|
|
889c7361ed |
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ import { useVueFlow } from "@vue-flow/core"
|
|||||||
import { computed } from "vue"
|
import { computed } from "vue"
|
||||||
import { type GlossEntry, INFO_LINK_GLOSS, NODE_GLOSSARY } from "@/model/glossary"
|
import { type GlossEntry, INFO_LINK_GLOSS, NODE_GLOSSARY } from "@/model/glossary"
|
||||||
import type { EdgeData, NodeData } from "@/model/projection"
|
import type { EdgeData, NodeData } from "@/model/projection"
|
||||||
import { useModelStore } from "@/store/model"
|
|
||||||
|
|
||||||
const store = useModelStore()
|
|
||||||
const { getSelectedNodes, getSelectedEdges } = useVueFlow("meadows")
|
const { getSelectedNodes, getSelectedEdges } = useVueFlow("meadows")
|
||||||
|
|
||||||
const gloss = computed<GlossEntry | null>(() => {
|
const gloss = computed<GlossEntry | null>(() => {
|
||||||
@@ -34,33 +32,6 @@ const gloss = computed<GlossEntry | null>(() => {
|
|||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* The selected element's own "why it's here" note (G4) — distinct from the
|
|
||||||
* generic `gloss` above, which defines the *kind*. Read live from the store so
|
|
||||||
* it tracks edits, and resolves the same selection a pipe/info edge stands in for.
|
|
||||||
*/
|
|
||||||
const description = computed<string | null>(() => {
|
|
||||||
const nodes = getSelectedNodes.value
|
|
||||||
const edges = getSelectedEdges.value
|
|
||||||
|
|
||||||
if (nodes.length === 1 && edges.length === 0) {
|
|
||||||
const node = store.model.nodes.find((n) => n.id === nodes[0].id)
|
|
||||||
return node && node.kind !== "cloud" ? (node.description ?? null) : null
|
|
||||||
}
|
|
||||||
if (edges.length === 1 && nodes.length === 0) {
|
|
||||||
const edge = edges[0]
|
|
||||||
const kind = (edge.data as EdgeData | undefined)?.kind
|
|
||||||
if (kind === "pipe") {
|
|
||||||
const flow = store.model.nodes.find((n) => n.id === edge.id.split("::")[0])
|
|
||||||
return flow && flow.kind !== "cloud" ? (flow.description ?? null) : null
|
|
||||||
}
|
|
||||||
if (kind === "info") {
|
|
||||||
return store.model.infoLinks.find((l) => l.id === edge.id)?.description ?? null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -70,13 +41,5 @@ const description = computed<string | null>(() => {
|
|||||||
>
|
>
|
||||||
<div class="text-sm font-semibold">{{ gloss.term }}</div>
|
<div class="text-sm font-semibold">{{ gloss.term }}</div>
|
||||||
<p class="mt-0.5 text-xs leading-snug text-base-content/70">{{ gloss.short }}</p>
|
<p class="mt-0.5 text-xs leading-snug text-base-content/70">{{ gloss.short }}</p>
|
||||||
<!-- This element's own rationale, below a divider so it reads apart from the
|
|
||||||
generic definition above (the "tour" note for the loaded sample). -->
|
|
||||||
<p
|
|
||||||
v-if="description"
|
|
||||||
class="mt-2 border-t border-base-300 pt-2 text-xs leading-snug text-base-content/80"
|
|
||||||
>
|
|
||||||
{{ description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ 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>
|
||||||
|
|
||||||
@@ -167,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>
|
||||||
|
|
||||||
@@ -204,7 +207,7 @@ const RULE_HINT: Record<Rule["kind"], string> = {
|
|||||||
<label class="mt-2 block">
|
<label class="mt-2 block">
|
||||||
<span class="text-xs text-base-content/60">Description</span>
|
<span class="text-xs text-base-content/60">Description</span>
|
||||||
<textarea
|
<textarea
|
||||||
rows="3"
|
rows="6"
|
||||||
class="textarea textarea-bordered textarea-sm mt-1 w-full leading-snug"
|
class="textarea textarea-bordered textarea-sm mt-1 w-full leading-snug"
|
||||||
:value="(element ?? link)?.description ?? ''"
|
:value="(element ?? link)?.description ?? ''"
|
||||||
placeholder="Why this element is here…"
|
placeholder="Why this element is here…"
|
||||||
|
|||||||
85
src/components/SampleBrowser.vue
Normal file
85
src/components/SampleBrowser.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1033
src/model/samples.ts
1033
src/model/samples.ts
File diff suppressed because it is too large
Load Diff
@@ -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)),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user