Compare commits
38 Commits
f0d207c4d5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c8a0c294f | ||
|
|
37e3a1e89f | ||
|
|
914cf01763 | ||
|
|
fe17bc8fae | ||
|
|
34bc8fa70b | ||
|
|
81425f8f3d | ||
|
|
d36f58977e | ||
|
|
62f6c5cda1 | ||
|
|
9ddff65072 | ||
|
|
faf8500fa5 | ||
|
|
f691b99ca5 | ||
|
|
46d3279a3c | ||
|
|
15e9149095 | ||
|
|
889c7361ed | ||
|
|
26d3c4493a | ||
|
|
4a0498ee55 | ||
|
|
37bbdf5650 | ||
|
|
67c16a8d44 | ||
|
|
e7a1bfb5af | ||
|
|
59e3aafa63 | ||
|
|
2ae7cf87d7 | ||
|
|
69fec5fe2e | ||
|
|
6a4fe59811 | ||
|
|
3a65bf5d59 | ||
|
|
8e1e313b20 | ||
|
|
d570425402 | ||
|
|
2c7ec6a6ec | ||
|
|
ef0cc2878b | ||
|
|
f38cf49f25 | ||
|
|
d5cda95c3e | ||
|
|
dc30e5f890 | ||
|
|
382fdddc68 | ||
|
|
ed6f011e69 | ||
|
|
1d65383bbf | ||
|
|
3fc0e23ec7 | ||
|
|
69435b1315 | ||
|
|
c361b05ec4 | ||
|
|
0c8f89d14e |
@@ -22,6 +22,8 @@ Deployed at https://meadows.apoena.dev
|
||||
- [ADR-0004](./docs/adr/0004-rate-rules-not-formulas.md) — simulation behaviour
|
||||
comes from a small fixed vocabulary of rules over Information Links, not
|
||||
free-form formulas.
|
||||
- [ADR-0005](./docs/adr/0005-uplot-behaviour-chart.md) — uPlot draws the
|
||||
behaviour-over-time chart; a charting dependency that earns its keep.
|
||||
|
||||
## Stack
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
55
docs/adr/0005-uplot-behaviour-chart.md
Normal file
55
docs/adr/0005-uplot-behaviour-chart.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# uPlot draws the behaviour-over-time chart
|
||||
|
||||
_Part of [meadows](../../README.md) · see [DESIGN.md](../../DESIGN.md)._
|
||||
|
||||
The Results panel (ADR-0004's phase 2) traces each Stock's value over time. It
|
||||
began as a **hand-built SVG** — a deliberate lean-substrate choice, no charting
|
||||
dependency. But a polyline is all that substrate could draw: no time axis, no
|
||||
gridlines, and a legend that could only show a Stock's _final_ value, never the
|
||||
value under the cursor. For a tool whose whole point is reading _behaviour over
|
||||
time_, "what is this Stock at t = 40?" is the question you most want to answer by
|
||||
pointing at the curve.
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt **uPlot** (`uplot`, ~15 KB gzip) — a small, fast canvas time-series
|
||||
library — for the behaviour-over-time chart. It earns the dependency with the
|
||||
three things the SVG could not give: a real **time axis** with ticks and
|
||||
gridlines, and a live legend that **reads each Stock's value at the hovered
|
||||
time**.
|
||||
|
||||
uPlot is imperative and canvas-based, so it is wrapped in **`SimChart.vue`**,
|
||||
which owns its lifecycle (build on mount, `setData` on recompute, rebuild on a
|
||||
track-set change, `ResizeObserver` for width, destroy on unmount). `ResultsPanel`
|
||||
stays declarative: it assembles plain aligned data (Stocks only — the system's
|
||||
memory, per ADR-0004) and hands it down.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- **Keep the hand-built SVG** (zero deps) — rejected: re-implementing axes,
|
||||
tick placement, a hover cursor, and hit-testing _is_ rebuilding a charting
|
||||
library, badly. The lean-substrate note was a code comment, not an ADR; nothing
|
||||
prior is contradicted by spending one dependency where it clearly pays.
|
||||
- **`@gouvfr/dsfr-chart`** (the original prompt) — rejected: it requires the
|
||||
entire DSFR design system (CSS + JS API) and embeds its _own_ Vue and Chart.js,
|
||||
so we would ship Vue twice and a second design language clashing with DaisyUI.
|
||||
It is built for French-government dashboards (region maps, gauges), not generic
|
||||
time series.
|
||||
- **Chart.js / vue-chartjs** — viable and same engine class, but heavier; uPlot
|
||||
is lighter and faster for many series and live-updating data, which is exactly
|
||||
the simulation's shape.
|
||||
- **uPlot (chosen)** — lightest credible option that gives the axis + hover.
|
||||
|
||||
## Consequences
|
||||
|
||||
- This is the project's **first runtime charting dependency**. The price, as with
|
||||
Vue Flow (ADR-0002), is a thin wrapper layer: `SimChart.vue` translates between
|
||||
reactive props and uPlot's imperative API.
|
||||
- The x scale runs with **`time: false`** — x is _simulation_ time (`start…stop`),
|
||||
not wall-clock, which uPlot would otherwise format as calendar dates.
|
||||
- Track colours are read from the **DaisyUI theme variables**. The app ships a
|
||||
single `light` theme, so they are resolved once at build time; a future dark
|
||||
theme would need the plot re-initialised on theme change.
|
||||
- Swapping charting libraries later means rewriting only `SimChart.vue` —
|
||||
`ResultsPanel` passes plain `[times, ...tracks]` aligned data and knows nothing
|
||||
of uPlot.
|
||||
@@ -19,6 +19,7 @@
|
||||
"daisyui": "^5.5.23",
|
||||
"idb": "^8.0.3",
|
||||
"pinia": "^3.0.4",
|
||||
"uplot": "^1.6.32",
|
||||
"vue": "^3.5.34"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -26,6 +26,9 @@ importers:
|
||||
pinia:
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))
|
||||
uplot:
|
||||
specifier: ^1.6.32
|
||||
version: 1.6.32
|
||||
vue:
|
||||
specifier: ^3.5.34
|
||||
version: 3.5.38(typescript@6.0.3)
|
||||
@@ -939,6 +942,9 @@ packages:
|
||||
undici-types@7.18.2:
|
||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||
|
||||
uplot@1.6.32:
|
||||
resolution: {integrity: sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==}
|
||||
|
||||
vite@8.0.16:
|
||||
resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -1709,6 +1715,8 @@ snapshots:
|
||||
|
||||
undici-types@7.18.2: {}
|
||||
|
||||
uplot@1.6.32: {}
|
||||
|
||||
vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
|
||||
@@ -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 |
@@ -24,11 +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"
|
||||
@@ -37,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"
|
||||
@@ -54,6 +56,13 @@ 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()
|
||||
|
||||
// Explicit shared id: useVueFlow() runs here in the parent setup, before
|
||||
// <VueFlow> mounts. Pinning both to the same id guarantees they resolve to one
|
||||
// store instance, so the event hooks below actually fire.
|
||||
@@ -69,6 +78,7 @@ const {
|
||||
getSelectedEdges,
|
||||
viewport,
|
||||
vueFlowRef,
|
||||
dimensions,
|
||||
fitView,
|
||||
} = useVueFlow("meadows")
|
||||
|
||||
@@ -84,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.
|
||||
@@ -244,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")
|
||||
@@ -344,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-10 mt-1 w-72 gap-1 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 }"
|
||||
@@ -434,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
|
||||
@@ -453,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,17 +8,27 @@
|
||||
* It reads the source-of-truth Model straight from the store and recomputes
|
||||
* reactively, so editing a rule and reopening re-runs. When the Model is not yet
|
||||
* sim-ready, it shows what is missing instead of a plot — the gap list doubles as
|
||||
* a checklist for bringing a diagram to life. No charting dependency: the plot is
|
||||
* a hand-built SVG (the same lean-substrate choice as the rest of the editor).
|
||||
* a checklist for bringing a diagram to life. The plot itself is a uPlot canvas
|
||||
* (see SimChart) — a small, fast time-series library that earns its keep with a
|
||||
* real time axis and hover-to-read values the hand-built SVG couldn't give.
|
||||
*/
|
||||
import { computed } from "vue"
|
||||
import { checkSimReady, simulate } from "@/model/simulation"
|
||||
import type uPlot from "uplot"
|
||||
import { computed, onBeforeUnmount, onMounted } from "vue"
|
||||
import SimChart from "./SimChart.vue"
|
||||
import { checkSimReady } from "@/model/simulation"
|
||||
import { DEFAULT_SIM_SPEC, type SimSpec, type StockNode } from "@/model/types"
|
||||
import { useModelStore } from "@/store/model"
|
||||
import { useSimulationStore } from "@/store/simulation"
|
||||
|
||||
defineEmits<{ close: [] }>()
|
||||
|
||||
const store = useModelStore()
|
||||
const sim = useSimulationStore()
|
||||
|
||||
// While the panel is open the canvas shows live values; closing it returns the
|
||||
// canvas to a plain diagram.
|
||||
onMounted(() => sim.enable())
|
||||
onBeforeUnmount(() => sim.disable())
|
||||
|
||||
const problems = computed(() => checkSimReady(store.model))
|
||||
|
||||
@@ -34,54 +44,22 @@ function onSpec(key: keyof SimSpec, event: Event): void {
|
||||
/** Distinct, legible track colours; cycled if a Model has more Stocks than these. */
|
||||
const COLORS = ["#2563eb", "#dc2626", "#16a34a", "#d97706", "#7c3aed", "#0891b2"]
|
||||
|
||||
// SVG canvas in its own coordinate space; it stretches to the panel width, and
|
||||
// non-scaling strokes keep lines crisp under that non-uniform scale.
|
||||
const W = 600
|
||||
const H = 200
|
||||
const PAD = 12
|
||||
|
||||
// Stocks only — the system's memory, and so the trajectories worth watching. The
|
||||
// run comes from the simulation store, so the chart and the canvas share one index.
|
||||
const chart = computed(() => {
|
||||
if (problems.value.length > 0) return null
|
||||
const run = simulate(store.model)
|
||||
const run = sim.run
|
||||
if (!run) return null
|
||||
const stocks = store.model.nodes.filter((n): n is StockNode => n.kind === "stock")
|
||||
const all = stocks.flatMap((s) => run.series.get(s.id) ?? [])
|
||||
if (all.length === 0 || run.times.length < 2) return null
|
||||
if (stocks.length === 0 || run.times.length < 2) return null
|
||||
|
||||
let min = Math.min(...all)
|
||||
let max = Math.max(...all)
|
||||
if (min === max) {
|
||||
// A flat trajectory: pad so the line lands mid-panel instead of on an edge.
|
||||
min -= 1
|
||||
max += 1
|
||||
}
|
||||
const n = run.times.length
|
||||
const x = (i: number) => PAD + (i / (n - 1)) * (W - 2 * PAD)
|
||||
const y = (v: number) => PAD + (1 - (v - min) / (max - min)) * (H - 2 * PAD)
|
||||
|
||||
const lines = stocks.map((s, i) => {
|
||||
const values = run.series.get(s.id) ?? []
|
||||
return {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
color: COLORS[i % COLORS.length],
|
||||
points: values.map((v, j) => `${x(j).toFixed(1)},${y(v).toFixed(1)}`).join(" "),
|
||||
last: values[values.length - 1] ?? 0,
|
||||
}
|
||||
})
|
||||
|
||||
return { lines, min, max, t0: run.times[0], t1: run.times[n - 1], diverged: run.diverged }
|
||||
const data = [run.times, ...stocks.map((s) => run.series.get(s.id) ?? [])] as uPlot.AlignedData
|
||||
const series = stocks.map((s, i) => ({ label: s.name, stroke: COLORS[i % COLORS.length] }))
|
||||
return { data, series, diverged: run.diverged }
|
||||
})
|
||||
|
||||
/** Compact numbers for axis ticks and the legend (e.g. 7039.99 → "7040"). */
|
||||
function fmt(value: number): string {
|
||||
return Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(2)
|
||||
}
|
||||
</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>
|
||||
@@ -132,38 +110,35 @@ function fmt(value: number): string {
|
||||
smaller factor, or a Balancing loop to rein it in.
|
||||
</p>
|
||||
|
||||
<!-- Sim-ready: the plot, with a legend of final values. -->
|
||||
<div v-if="chart" class="mt-2 flex items-stretch gap-3">
|
||||
<svg
|
||||
:viewBox="`0 0 ${W} ${H}`"
|
||||
preserveAspectRatio="none"
|
||||
class="h-40 flex-1 rounded-md bg-base-200/40"
|
||||
role="img"
|
||||
aria-label="Stock values over time"
|
||||
<!-- Sim-ready: the plot, plus a transport that drives the playhead. Hover a
|
||||
point to read values; press play to watch the canvas animate in step. -->
|
||||
<template v-if="chart">
|
||||
<SimChart class="mt-2" :data="chart.data" :series="chart.series" :marker="sim.currentTime" />
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-xs w-8"
|
||||
:aria-label="sim.playing ? 'Pause' : 'Play'"
|
||||
@click="sim.toggle()"
|
||||
>
|
||||
<text :x="PAD" :y="PAD" class="fill-base-content/40 text-[10px]">{{ fmt(chart.max) }}</text>
|
||||
<text :x="PAD" :y="H - PAD / 2" class="fill-base-content/40 text-[10px]">
|
||||
{{ fmt(chart.min) }}
|
||||
</text>
|
||||
<polyline
|
||||
v-for="line in chart.lines"
|
||||
:key="line.id"
|
||||
:points="line.points"
|
||||
fill="none"
|
||||
:stroke="line.color"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round"
|
||||
vector-effect="non-scaling-stroke"
|
||||
{{ sim.playing ? "❚❚" : "▶" }}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
class="range range-xs flex-1"
|
||||
min="0"
|
||||
:max="Math.max(0, sim.frameCount - 1)"
|
||||
:value="sim.playhead"
|
||||
aria-label="Playhead"
|
||||
@input="sim.seek(Number(($event.target as HTMLInputElement).value))"
|
||||
/>
|
||||
</svg>
|
||||
<ul class="flex w-44 flex-col gap-1 self-center text-xs">
|
||||
<li v-for="line in chart.lines" :key="line.id" class="flex items-center gap-2">
|
||||
<span class="size-2.5 shrink-0 rounded-full" :style="{ backgroundColor: line.color }" />
|
||||
<span class="truncate">{{ line.name }}</span>
|
||||
<span class="ml-auto font-mono text-base-content/60">{{ fmt(line.last) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<!-- Not sim-ready: what to fill in to bring it to life. -->
|
||||
<div v-else class="mt-2 text-sm">
|
||||
|
||||
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>
|
||||
246
src/components/SimChart.vue
Normal file
246
src/components/SimChart.vue
Normal file
@@ -0,0 +1,246 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* uPlot wrapper for the behaviour-over-time plot. uPlot is an imperative canvas
|
||||
* library, so this component owns its lifecycle: build on mount, push new samples
|
||||
* with `setData` when a run recomputes, rebuild when the *set* of tracks changes
|
||||
* (a Stock renamed/added/removed), follow the container width with a
|
||||
* ResizeObserver, and destroy on unmount.
|
||||
*
|
||||
* Two deliberate settings:
|
||||
* - `scales.x.time = false` — x is *simulation* time (0…stop), not wall-clock;
|
||||
* uPlot would otherwise format the axis as calendar dates.
|
||||
* - Colours are read from the DaisyUI theme variables so the chart matches the
|
||||
* rest of the editor. The app ships a single light theme (no `data-theme`
|
||||
* switching), so they are resolved once at build time rather than watched.
|
||||
*/
|
||||
import uPlot from "uplot"
|
||||
import "uplot/dist/uPlot.min.css"
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from "vue"
|
||||
|
||||
/** One plotted track: its legend label and line colour. */
|
||||
export interface ChartSeries {
|
||||
label: string
|
||||
stroke: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** uPlot aligned data: `[times, ...one value track per series]`. */
|
||||
data: uPlot.AlignedData
|
||||
/** Track metadata, in the same order as `data[1…]`. */
|
||||
series: ChartSeries[]
|
||||
/** Canvas height in CSS pixels (the legend sits below it). */
|
||||
height?: number
|
||||
/** Simulation time of the playhead, drawn as a vertical line (null = none). */
|
||||
marker?: number | null
|
||||
}>(),
|
||||
{ height: 160, marker: null },
|
||||
)
|
||||
|
||||
const root = ref<HTMLDivElement>()
|
||||
let plot: uPlot | undefined
|
||||
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 "--"
|
||||
return Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(2)
|
||||
}
|
||||
|
||||
/** A DaisyUI theme colour, with a light-theme fallback if the var is unset. */
|
||||
function themeColor(name: string, fallback: string): string {
|
||||
const value = root.value ? getComputedStyle(root.value).getPropertyValue(name).trim() : ""
|
||||
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 {
|
||||
const scales = scaleKeys()
|
||||
return props.series.map((s, i) => `${s.label}|${s.stroke}|${scales[i]}`).join(",")
|
||||
}
|
||||
|
||||
function options(width: number): uPlot.Options {
|
||||
// Follow the app's --font-sans (resolved on the element) rather than naming a
|
||||
// family here, so the chart tracks the theme font without a second source.
|
||||
const family = root.value
|
||||
? getComputedStyle(root.value).fontFamily
|
||||
: "ui-sans-serif, system-ui, sans-serif"
|
||||
const axis: uPlot.Axis = {
|
||||
stroke: themeColor("--color-base-content", "#1f2937"),
|
||||
grid: { stroke: themeColor("--color-base-300", "#e5e7eb"), width: 1 },
|
||||
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,
|
||||
// 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 } },
|
||||
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, i): uPlot.Series => ({
|
||||
label: s.label,
|
||||
scale: scales[i],
|
||||
stroke: s.stroke,
|
||||
width: 2,
|
||||
points: { show: false },
|
||||
value: (_self, raw) => fmt(raw),
|
||||
}),
|
||||
),
|
||||
],
|
||||
// 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 }],
|
||||
}
|
||||
}
|
||||
|
||||
function build(): void {
|
||||
if (!root.value) return
|
||||
plot?.destroy()
|
||||
builtSig = trackSig()
|
||||
plot = new uPlot(options(root.value.clientWidth), props.data, root.value)
|
||||
}
|
||||
|
||||
/** Cheap on every recompute; only a shape change forces a full rebuild. */
|
||||
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 })
|
||||
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()
|
||||
syncMarker()
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect()
|
||||
plot?.destroy()
|
||||
})
|
||||
|
||||
watch(() => props.data, render)
|
||||
// The playhead just slides the overlay — no canvas redraw, so playback stays smooth.
|
||||
watch(() => props.marker, syncMarker)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
:deep(.u-legend) {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
: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>
|
||||
@@ -9,6 +9,8 @@ import { computed } from "vue"
|
||||
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
|
||||
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
|
||||
import type { ConverterNode } from "@/model/types"
|
||||
import { useSimulationStore } from "@/store/simulation"
|
||||
import { formatValue } from "./format"
|
||||
import NodeLabel from "./NodeLabel.vue"
|
||||
|
||||
const props = defineProps<NodeProps<NodeData>>()
|
||||
@@ -16,6 +18,9 @@ const props = defineProps<NodeProps<NodeData>>()
|
||||
// The projection guarantees a converter-typed node here.
|
||||
const converter = computed(() => props.data.node as ConverterNode)
|
||||
const loopRing = useNodeLoopRing(props.id)
|
||||
|
||||
const sim = useSimulationStore()
|
||||
const value = computed(() => sim.valueAt(props.id))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -25,6 +30,12 @@ const loopRing = useNodeLoopRing(props.id)
|
||||
:class="[props.selected ? 'border-primary' : 'border-base-300', loopRing]"
|
||||
>
|
||||
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
|
||||
<span
|
||||
v-if="value !== null"
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center font-mono text-xs tabular-nums text-base-content/70"
|
||||
>
|
||||
{{ formatValue(value) }}
|
||||
</span>
|
||||
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
|
||||
</div>
|
||||
<NodeLabel :node-id="props.id" :name="converter.name" />
|
||||
|
||||
@@ -11,6 +11,8 @@ import { computed } from "vue"
|
||||
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
|
||||
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
|
||||
import type { FlowNode } from "@/model/types"
|
||||
import { useSimulationStore } from "@/store/simulation"
|
||||
import { formatValue } from "./format"
|
||||
import NodeLabel from "./NodeLabel.vue"
|
||||
|
||||
const props = defineProps<NodeProps<NodeData>>()
|
||||
@@ -18,6 +20,9 @@ const props = defineProps<NodeProps<NodeData>>()
|
||||
// The projection guarantees a flow-typed node here.
|
||||
const flow = computed(() => props.data.node as FlowNode)
|
||||
const loopRing = useNodeLoopRing(props.id)
|
||||
|
||||
const sim = useSimulationStore()
|
||||
const value = computed(() => sim.valueAt(props.id))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -40,5 +45,8 @@ const loopRing = useNodeLoopRing(props.id)
|
||||
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
|
||||
</div>
|
||||
<NodeLabel :node-id="props.id" :name="flow.name" />
|
||||
<div v-if="value !== null" class="font-mono text-xs tabular-nums text-base-content/70">
|
||||
{{ formatValue(value) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -9,6 +9,8 @@ import { computed } from "vue"
|
||||
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
|
||||
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
|
||||
import type { StockNode } from "@/model/types"
|
||||
import { useSimulationStore } from "@/store/simulation"
|
||||
import { formatValue } from "./format"
|
||||
import NodeLabel from "./NodeLabel.vue"
|
||||
|
||||
const props = defineProps<NodeProps<NodeData>>()
|
||||
@@ -16,15 +18,43 @@ const props = defineProps<NodeProps<NodeData>>()
|
||||
// The projection guarantees a stock-typed node here.
|
||||
const stock = computed(() => props.data.node as StockNode)
|
||||
const loopRing = useNodeLoopRing(props.id)
|
||||
|
||||
// Live simulation value at the playhead (null when no run is engaged). A Stock is
|
||||
// the system's memory, so it also shows a fill gauge — its level over its peak.
|
||||
const sim = useSimulationStore()
|
||||
const value = computed(() => sim.valueAt(props.id))
|
||||
const fill = computed(() => sim.fill(props.id))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-w-24 rounded-md border-2 bg-base-100 px-4 py-3 text-center shadow-sm transition-colors"
|
||||
class="relative min-w-24 rounded-md border-2 bg-base-100 px-4 py-3 text-center shadow-sm transition-colors"
|
||||
:class="[props.selected ? 'border-primary' : 'border-base-300', loopRing]"
|
||||
>
|
||||
<!-- Fill gauge: a bottom-anchored bar, behind the label, inset within the border. -->
|
||||
<div
|
||||
v-if="value !== null"
|
||||
class="pointer-events-none absolute inset-[2px] z-0 flex flex-col justify-end"
|
||||
>
|
||||
<div
|
||||
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>
|
||||
<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>
|
||||
|
||||
4
src/components/nodes/format.ts
Normal file
4
src/components/nodes/format.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** Compact value for the live readout on a node (7039.99 → "7040", 0.42 → "0.42"). */
|
||||
export function formatValue(value: number): string {
|
||||
return Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(2)
|
||||
}
|
||||
49
src/composables/usePlayback.ts
Normal file
49
src/composables/usePlayback.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Playback clock — the wall-clock that advances the simulation playhead while
|
||||
* `playing`. It lives in a composable (mounted once, in the Editor) because it
|
||||
* needs `requestAnimationFrame` and component teardown; the simulation store
|
||||
* stays pure state + actions.
|
||||
*
|
||||
* A run plays over a fixed wall-clock window regardless of how many samples it
|
||||
* has, so a 40-step model and a 480-step one feel the same speed. Elapsed time is
|
||||
* accumulated and drained in whole frames, so a slow tab catches up rather than
|
||||
* drifting.
|
||||
*/
|
||||
import { onBeforeUnmount, watch } from "vue"
|
||||
import { useSimulationStore } from "@/store/simulation"
|
||||
|
||||
/** Target wall-clock duration for a whole run, in milliseconds. */
|
||||
const RUN_DURATION_MS = 6000
|
||||
|
||||
export function usePlayback(): void {
|
||||
const sim = useSimulationStore()
|
||||
let raf = 0
|
||||
let last = 0
|
||||
let carry = 0
|
||||
|
||||
function frame(now: number): void {
|
||||
if (!sim.playing) return
|
||||
carry += now - last
|
||||
last = now
|
||||
const msPerFrame = Math.max(16, RUN_DURATION_MS / Math.max(1, sim.frameCount))
|
||||
while (carry >= msPerFrame && sim.playing) {
|
||||
carry -= msPerFrame
|
||||
sim.tick()
|
||||
}
|
||||
raf = requestAnimationFrame(frame)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => sim.playing,
|
||||
(playing) => {
|
||||
cancelAnimationFrame(raf)
|
||||
if (playing) {
|
||||
last = performance.now()
|
||||
carry = 0
|
||||
raf = requestAnimationFrame(frame)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => cancelAnimationFrame(raf))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
1649
src/model/samples.ts
1649
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))
|
||||
)
|
||||
}
|
||||
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)
|
||||
* - `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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
151
src/store/simulation.ts
Normal file
151
src/store/simulation.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Simulation store (phase 2) — the single source of *playback* truth, the way
|
||||
* the model store is the single source of structural truth. It owns one run and
|
||||
* one playhead, so the behaviour chart and the canvas animate off the same index:
|
||||
* drag the scrubber and every Stock on the canvas jumps to that instant.
|
||||
*
|
||||
* The run is derived from the Model (recomputed when the Model changes, like the
|
||||
* loop detector), so editing a rule and reopening re-runs. Playback itself — the
|
||||
* clock that advances the playhead — lives in a composable (`usePlayback`), since
|
||||
* timers need component lifecycle; this store only holds state and pure actions.
|
||||
*/
|
||||
import { defineStore } from "pinia"
|
||||
import { computed, ref, watch } from "vue"
|
||||
import { checkSimReady, type Run, simulate } from "@/model/simulation"
|
||||
import { useModelStore } from "./model"
|
||||
|
||||
export const useSimulationStore = defineStore("simulation", () => {
|
||||
const modelStore = useModelStore()
|
||||
|
||||
/** A Model is runnable only once every Stock has a value and every rate a rule. */
|
||||
const ready = computed(() => checkSimReady(modelStore.model).length === 0)
|
||||
|
||||
/** The current run, or null when the Model isn't runnable. Recomputes on edit. */
|
||||
const run = computed<Run | null>(() => {
|
||||
if (!ready.value) return null
|
||||
try {
|
||||
return simulate(modelStore.model)
|
||||
} catch {
|
||||
// checkSimReady already rejects algebraic loops; this is belt-and-braces.
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const frameCount = computed(() => run.value?.times.length ?? 0)
|
||||
|
||||
/** Whether the canvas should show live values: the panel is open and a run exists. */
|
||||
const enabled = ref(false)
|
||||
const playing = ref(false)
|
||||
const playhead = ref(0)
|
||||
|
||||
const active = computed(() => enabled.value && frameCount.value > 0)
|
||||
const atEnd = computed(() => playhead.value >= frameCount.value - 1)
|
||||
const currentTime = computed(() => run.value?.times[playhead.value] ?? null)
|
||||
|
||||
/** Per-element peak magnitude across the run, for the Stock fill gauge. */
|
||||
const peaks = computed(() => {
|
||||
const map = new Map<string, number>()
|
||||
const r = run.value
|
||||
if (r) {
|
||||
for (const [id, series] of r.series) {
|
||||
let max = 0
|
||||
for (const v of series) if (Number.isFinite(v)) max = Math.max(max, Math.abs(v))
|
||||
map.set(id, max)
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
// Keep the playhead in range when the run shrinks (e.g. a shorter stop time).
|
||||
watch(frameCount, (n) => {
|
||||
if (playhead.value > n - 1) playhead.value = Math.max(0, n - 1)
|
||||
})
|
||||
|
||||
// Loading a different document (sample / import / new) restarts playback from the
|
||||
// top. Editing the current one keeps the same id, so the playhead stays put — and
|
||||
// undo/redo restore snapshots in place (same id), so they don't jump it either.
|
||||
watch(
|
||||
() => modelStore.model.id,
|
||||
() => {
|
||||
playing.value = false
|
||||
playhead.value = 0
|
||||
},
|
||||
)
|
||||
|
||||
/** A node's value at the current playhead, or null when there's nothing to show. */
|
||||
function valueAt(id: string): number | null {
|
||||
if (!active.value) return null
|
||||
const value = run.value?.series.get(id)?.[playhead.value]
|
||||
return value === undefined || !Number.isFinite(value) ? null : value
|
||||
}
|
||||
|
||||
/** A Stock's fill level in 0…1 (value over its peak), for the gauge. */
|
||||
function fill(id: string): number {
|
||||
const value = valueAt(id)
|
||||
const peak = peaks.value.get(id) ?? 0
|
||||
if (value === null || peak <= 0) return 0
|
||||
return Math.min(1, Math.max(0, value / peak))
|
||||
}
|
||||
|
||||
function seek(index: number): void {
|
||||
playhead.value = Math.min(Math.max(0, Math.round(index)), Math.max(0, frameCount.value - 1))
|
||||
}
|
||||
|
||||
function play(): void {
|
||||
if (frameCount.value === 0) return
|
||||
if (atEnd.value) playhead.value = 0 // replay from the start
|
||||
playing.value = true
|
||||
}
|
||||
|
||||
function pause(): void {
|
||||
playing.value = false
|
||||
}
|
||||
|
||||
function toggle(): void {
|
||||
if (playing.value) pause()
|
||||
else play()
|
||||
}
|
||||
|
||||
/** Advance one frame; stop at the end. Called by the playback clock. */
|
||||
function tick(): void {
|
||||
if (atEnd.value) {
|
||||
playing.value = false
|
||||
return
|
||||
}
|
||||
playhead.value++
|
||||
}
|
||||
|
||||
/** Engage playback (panel opened): show values, start from the beginning. */
|
||||
function enable(): void {
|
||||
enabled.value = true
|
||||
playhead.value = 0
|
||||
}
|
||||
|
||||
/** Disengage (panel closed): the canvas returns to a plain diagram. */
|
||||
function disable(): void {
|
||||
enabled.value = false
|
||||
playing.value = false
|
||||
playhead.value = 0
|
||||
}
|
||||
|
||||
return {
|
||||
run,
|
||||
ready,
|
||||
frameCount,
|
||||
enabled,
|
||||
playing,
|
||||
playhead,
|
||||
active,
|
||||
atEnd,
|
||||
currentTime,
|
||||
valueAt,
|
||||
fill,
|
||||
seek,
|
||||
play,
|
||||
pause,
|
||||
toggle,
|
||||
tick,
|
||||
enable,
|
||||
disable,
|
||||
}
|
||||
})
|
||||
@@ -3,17 +3,26 @@
|
||||
@import to precede all other rules. If it comes second, the production build
|
||||
warns ("@import must precede all rules…") and browsers silently DROP the
|
||||
font import, so the custom font never loads. */
|
||||
@import url("https://fonts.coollabs.io/css2?family=Inter:wght@400;500;600;700&display=swap");
|
||||
@import url("https://api.fonts.coollabs.io/css2?family=Sono:wght@400;500;600;700&display=swap");
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
@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 {
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-sans: "Sono", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Larger, easier-to-grab connection handles (Vue Flow defaults to 6px). The
|
||||
|
||||
Reference in New Issue
Block a user