Compare commits
63 Commits
bc90a9bca9
...
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 | ||
|
|
f0d207c4d5 | ||
|
|
34df540e4a | ||
|
|
40dc9fba43 | ||
|
|
5b6e830778 | ||
|
|
964e621f0e | ||
|
|
528fc97c7f | ||
|
|
21a459256a | ||
|
|
df03012c8b | ||
|
|
d7d0966adc | ||
|
|
27b7a8da5e | ||
|
|
26dd245a94 | ||
|
|
169dd043c3 | ||
|
|
9f11d5230d | ||
|
|
a44f1e7bb1 | ||
|
|
32498d51d1 | ||
|
|
cd276d9d2a | ||
|
|
e1efa3c823 | ||
|
|
6815b81701 | ||
|
|
5e50b2dfd4 | ||
|
|
acce53ce47 | ||
|
|
72ef29413a | ||
|
|
75ebb39848 | ||
|
|
4c55ff9ffa | ||
|
|
9f71febca0 | ||
|
|
1c571059df |
77
DESIGN.md
77
DESIGN.md
@@ -256,10 +256,10 @@ symbols: ● strong (9) · ◐ medium (3) · △ weak (1). Roof: `++`/`+` reinfo
|
|||||||
}
|
}
|
||||||
|
|
||||||
\begin{document}
|
\begin{document}
|
||||||
% --- meadows: 4 goals (WHATs) x 10 functions (HOWs); no competitor zone;
|
% --- meadows: 4 goals (WHATs) x 11 functions (HOWs); no competitor zone;
|
||||||
% basement = Target / Importance Sigma / Relative %. ---
|
% basement = Target / Importance Sigma / Relative %. ---
|
||||||
\def\qfdNW{4}
|
\def\qfdNW{4}
|
||||||
\def\qfdNH{10}
|
\def\qfdNH{11}
|
||||||
\def\qfdWhatW{4.2}
|
\def\qfdWhatW{4.2}
|
||||||
\def\qfdBasementN{3}
|
\def\qfdBasementN{3}
|
||||||
\def\qfdWhatsTitle{Goals (WHATs)}
|
\def\qfdWhatsTitle{Goals (WHATs)}
|
||||||
@@ -282,14 +282,14 @@ symbols: ● strong (9) · ◐ medium (3) · △ weak (1). Roof: `++`/`+` reinfo
|
|||||||
3/{F3 smooth canvas}, 4/{F4 valid inline},
|
3/{F3 smooth canvas}, 4/{F4 valid inline},
|
||||||
5/{F5 loops live}, 6/{F6 polarity},
|
5/{F5 loops live}, 6/{F6 polarity},
|
||||||
7/{F7 autosave}, 8/{F8 round-trip}, 9/{F9 undo/redo},
|
7/{F7 autosave}, 8/{F8 round-trip}, 9/{F9 undo/redo},
|
||||||
10/{F10 samples}}
|
10/{F10 samples}, 11/{F11 glossary}}
|
||||||
\node[rotate=90, anchor=west, font=\scriptsize] at ({\c - 0.5}, 0.15) {\t};
|
\node[rotate=90, anchor=west, font=\scriptsize] at ({\c - 0.5}, 0.15) {\t};
|
||||||
|
|
||||||
% --- Relation matrix (col c = function, row r = goal); strength S/M/W ---
|
% --- Relation matrix (col c = function, row r = goal); strength S/M/W ---
|
||||||
\foreach \c/\r/\s in {1/1/S, 2/1/S, 3/1/S, 4/1/M, 5/1/W, 6/1/M, 7/1/W, 9/1/M, 10/1/W,
|
\foreach \c/\r/\s in {1/1/S, 2/1/S, 3/1/S, 4/1/M, 5/1/W, 6/1/M, 7/1/W, 9/1/M, 10/1/W, 11/1/W,
|
||||||
2/2/M, 3/2/W, 4/2/M, 5/2/S, 6/2/S, 9/2/W,
|
2/2/M, 3/2/W, 4/2/M, 5/2/S, 6/2/S, 9/2/W,
|
||||||
4/3/W, 7/3/S, 8/3/S, 9/3/S,
|
4/3/W, 7/3/S, 8/3/S, 9/3/S,
|
||||||
5/4/M, 6/4/W, 10/4/S}
|
5/4/M, 6/4/W, 10/4/S, 11/4/S}
|
||||||
\node[qfdrel/\s] at ({\c - 0.5}, {-\r + 0.5}) {};
|
\node[qfdrel/\s] at ({\c - 0.5}, {-\r + 0.5}) {};
|
||||||
|
|
||||||
% --- Roof correlations (C-i-j, i<j); from DESIGN.md §5 ---
|
% --- Roof correlations (C-i-j, i<j); from DESIGN.md §5 ---
|
||||||
@@ -300,15 +300,17 @@ symbols: ● strong (9) · ◐ medium (3) · △ weak (1). Roof: `++`/`+` reinfo
|
|||||||
\node[font=\scriptsize] at (C-4-5) {$+$}; % F4 o F5 mild reinforce
|
\node[font=\scriptsize] at (C-4-5) {$+$}; % F4 o F5 mild reinforce
|
||||||
\node[font=\scriptsize] at (C-5-6) {$+\!+$}; % F5 o F6 strong reinforce
|
\node[font=\scriptsize] at (C-5-6) {$+\!+$}; % F5 o F6 strong reinforce
|
||||||
\node[font=\scriptsize] at (C-7-10) {$-$}; % F7 x F10 conflict (load clears history)
|
\node[font=\scriptsize] at (C-7-10) {$-$}; % F7 x F10 conflict (load clears history)
|
||||||
|
\node[font=\scriptsize] at (C-10-11) {$+\!+$}; % F10 o F11 reinforce (the two G4 aids)
|
||||||
|
|
||||||
% --- Basement: Target / Importance Sigma / Relative %, + row labels ---
|
% --- Basement: Target / Importance Sigma / Relative %, + row labels ---
|
||||||
\foreach \rr/\lbl in {0/{Target}, 1/{Imp.\ $\Sigma$}, 2/{Rel.\ \%}}
|
\foreach \rr/\lbl in {0/{Target}, 1/{Imp.\ $\Sigma$}, 2/{Rel.\ \%}}
|
||||||
\node[anchor=east, font=\scriptsize\itshape]
|
\node[anchor=east, font=\scriptsize\itshape]
|
||||||
at (-0.1, {-\qfdNW - 0.5 - \rr}) {\lbl};
|
at (-0.1, {-\qfdNW - 0.5 - \rr}) {\lbl};
|
||||||
\foreach \c/\tgt/\sig/\rel in
|
\foreach \c/\tgt/\sig/\rel in
|
||||||
{1/{1 gest.}/81/10, 2/{1 drag}/105/13, 3/{60 fps}/89/11,
|
{1/{1 gest.}/81/10, 2/{1 drag}/105/12, 3/{60 fps}/89/10,
|
||||||
4/{0 inv.}/58/7, 5/{$\le$16ms}/93/11, 6/{dflt $\pm$}/103/13,
|
4/{0 inv.}/58/7, 5/{$\le$16ms}/93/11, 6/{dflt $\pm$}/103/12,
|
||||||
7/{$\le$500ms}/72/9, 8/{id.}/63/8, 9/{$\ge$50}/98/12, 10/{1 load}/45/6} {
|
7/{$\le$500ms}/72/9, 8/{id.}/63/7, 9/{$\ge$50}/98/12, 10/{1 load}/45/5,
|
||||||
|
11/{gloss}/45/5} {
|
||||||
\node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 0.5}) {\tgt};
|
\node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 0.5}) {\tgt};
|
||||||
\node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 1.5}) {\sig};
|
\node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 1.5}) {\sig};
|
||||||
\node[font=\scriptsize\bfseries] at ({\c - 0.5}, {-\qfdNW - 2.5}) {\rel};
|
\node[font=\scriptsize\bfseries] at ({\c - 0.5}, {-\qfdNW - 2.5}) {\rel};
|
||||||
@@ -331,7 +333,7 @@ symbols: ● strong (9) · ◐ medium (3) · △ weak (1). Roof: `++`/`+` reinfo
|
|||||||
## 2. Functions — the HOWs
|
## 2. Functions — the HOWs
|
||||||
|
|
||||||
| ID | Function | Dir | Target (now) | Target (future) |
|
| ID | Function | Dir | Target (now) | Target (future) |
|
||||||
| --- | ------------------------------------------------------------------- | :-: | ------------------------------------ | ----------------------- |
|
| --- | ---------------------------------------------------------------------------- | :-: | --------------------------------------------------- | ----------------------- |
|
||||||
| F1 | Place a node in one gesture (palette → canvas, auto-named) | ↓ | 1 click/drag, 0 dialogs | — |
|
| F1 | Place a node in one gesture (palette → canvas, auto-named) | ↓ | 1 click/drag, 0 dialogs | — |
|
||||||
| F2 | Connect by direct manipulation (auto source/sink cloud on open end) | ↓ | 1 drag, 0 menus | — |
|
| F2 | Connect by direct manipulation (auto source/sink cloud on open end) | ↓ | 1 drag, 0 menus | — |
|
||||||
| F3 | Render & manipulate the canvas smoothly | ↑ | 60 fps pan/zoom/drag @ ≤150 elements | ≥300 elements |
|
| F3 | Render & manipulate the canvas smoothly | ↑ | 60 fps pan/zoom/drag @ ≤150 elements | ≥300 elements |
|
||||||
@@ -342,6 +344,7 @@ symbols: ● strong (9) · ◐ medium (3) · △ weak (1). Roof: `++`/`+` reinfo
|
|||||||
| F8 | Round-trip Models as versioned JSON | → | export→import identity; `version` | schema migrations |
|
| F8 | Round-trip Models as versioned JSON | → | export→import identity; `version` | schema migrations |
|
||||||
| F9 | Undo/redo every model change | ↑ | ≥50 steps, all structural ops | — |
|
| F9 | Undo/redo every model change | ↑ | ≥50 steps, all structural ops | — |
|
||||||
| F10 | Load a curated sample on demand (covers the whole language) | → | 1 click; set spans whole language | more samples; shareable |
|
| F10 | Load a curated sample on demand (covers the whole language) | → | 1 click; set spans whole language | more samples; shareable |
|
||||||
|
| F11 | Surface the language at point-of-use (palette tooltips + gloss-on-selection) | → | gloss on hover/selection; 0 persistent canvas marks | polarity / R/B glosses |
|
||||||
|
|
||||||
## 3. Cascade — Goals → Functions → How → Components
|
## 3. Cascade — Goals → Functions → How → Components
|
||||||
|
|
||||||
@@ -386,6 +389,12 @@ symbols: ● strong (9) · ◐ medium (3) · △ weak (1). Roof: `++`/`+` reinfo
|
|||||||
framework to maintain; a readable, editable gallery teaches by example and
|
framework to maintain; a readable, editable gallery teaches by example and
|
||||||
doubles as instant content), see T6_
|
doubles as instant content), see T6_
|
||||||
- **Component**: C13 sample gallery · C5 store (`setModel`) · C1 canvas (`fitView`)
|
- **Component**: C13 sample gallery · C5 store (`setModel`) · C1 canvas (`fitView`)
|
||||||
|
- **F11** Surface the language at point-of-use _→ gloss on hover/selection_
|
||||||
|
- **How**: palette chips carry a tooltip; a corner panel glosses the single
|
||||||
|
selected element — off-canvas, pointer-through, gone on deselect
|
||||||
|
- **How**: passive per-node hover tooltips — _rejected (hover-noise on dense
|
||||||
|
models, and the bubble scales with the canvas zoom), see T7_
|
||||||
|
- **Component**: C14 glossary + gloss panel · C12 palette
|
||||||
- **F5** (seeing loops classified R/B _is_ the lesson) → C8 loop engine · C11 overlay
|
- **F5** (seeing loops classified R/B _is_ the lesson) → C8 loop engine · C11 overlay
|
||||||
- **F6** (visible polarity reinforces the lesson) → C3 edge components
|
- **F6** (visible polarity reinforces the lesson) → C3 edge components
|
||||||
|
|
||||||
@@ -393,15 +402,15 @@ symbols: ● strong (9) · ◐ medium (3) · △ weak (1). Roof: `++`/`+` reinfo
|
|||||||
|
|
||||||
Cells: strength 9/3/1. Importance Σ = Σ(weight × strength).
|
Cells: strength 9/3/1. Importance Σ = Σ(weight × strength).
|
||||||
|
|
||||||
| | F1 | F2 | F3 | F4 | F5 | F6 | F7 | F8 | F9 | F10 |
|
| | F1 | F2 | F3 | F4 | F5 | F6 | F7 | F8 | F9 | F10 | F11 |
|
||||||
| ------ | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: |
|
| ------ | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| G1 (9) | 9 | 9 | 9 | 3 | 1 | 3 | 1 | | 3 | 1 |
|
| G1 (9) | 9 | 9 | 9 | 3 | 1 | 3 | 1 | | 3 | 1 | 1 |
|
||||||
| G2 (8) | | 3 | 1 | 3 | 9 | 9 | | | 1 | |
|
| G2 (8) | | 3 | 1 | 3 | 9 | 9 | | | 1 | | |
|
||||||
| G3 (7) | | | | 1 | | | 9 | 9 | 9 | |
|
| G3 (7) | | | | 1 | | | 9 | 9 | 9 | | |
|
||||||
| G4 (4) | | | | | 3 | 1 | | | | 9 |
|
| G4 (4) | | | | | 3 | 1 | | | | 9 | 9 |
|
||||||
| **Σ** | 81 | 105 | 89 | 58 | 93 | 103 | 72 | 63 | 98 | 45 |
|
| **Σ** | 81 | 105 | 89 | 58 | 93 | 103 | 72 | 63 | 98 | 45 | 45 |
|
||||||
|
|
||||||
**Top engineering priorities (from importance):** the matrix still puts **F2 (connect, 105)**, **F6 (polarity, 103)** and **F9 (undo, 98)** at the top, with the headline detector **F5** climbing to **93** — G4 reinforces it, because _seeing_ a loop classified R/B is itself the lesson. The lesson holds: loop insight is only as good as its _inputs_ and _ergonomics_ — effortless wiring (F2) and polarity capture (F6) matter as much as detection, and a reliable safety net (F9) underwrites fearless building. **F10 (samples, 45)** is the lowest-leverage function, exactly right for a supporting aid: it earns its place by being nearly free — static Models reusing the whole pipeline, no new model code — not by priority. F4 (58) stays low: keep it lightweight (guide, don't nag).
|
**Top engineering priorities (from importance):** the matrix still puts **F2 (connect, 105)**, **F6 (polarity, 103)** and **F9 (undo, 98)** at the top, with the headline detector **F5** climbing to **93** — G4 reinforces it, because _seeing_ a loop classified R/B is itself the lesson. The lesson holds: loop insight is only as good as its _inputs_ and _ergonomics_ — effortless wiring (F2) and polarity capture (F6) matter as much as detection, and a reliable safety net (F9) underwrites fearless building. **F10 (samples) and F11 (glossary), 45 each**, are the lowest-leverage functions — exactly right for the two G4 supporting aids: each earns its place by being nearly free (static Models reusing the pipeline; one-line glosses surfaced on hover/selection), not by priority. F4 (58) stays low: keep it lightweight (guide, don't nag).
|
||||||
|
|
||||||
## 5. Roof — Function × Function tradeoffs
|
## 5. Roof — Function × Function tradeoffs
|
||||||
|
|
||||||
@@ -423,11 +432,12 @@ Cells: strength 9/3/1. Importance Σ = Σ(weight × strength).
|
|||||||
- **F3 × F9** — snapshot cloning on every change costs memory/time. Bounded by small models + a fixed-size ring buffer; revisit with structural sharing only if profiling demands.
|
- **F3 × F9** — snapshot cloning on every change costs memory/time. Bounded by small models + a fixed-size ring buffer; revisit with structural sharing only if profiling demands.
|
||||||
- **F2 ◎ F6, F5 ◎ F6** — polarity is captured _in_ the connect gesture and _is_ the detector's input; these reinforce, so build them together.
|
- **F2 ◎ F6, F5 ◎ F6** — polarity is captured _in_ the connect gesture and _is_ the detector's input; these reinforce, so build them together.
|
||||||
- **F10 × F7** — loading a sample calls `setModel`, which _replaces the whole document and clears undo history_. Against autosave (F7), one click could wipe real work. Mitigated by confirming the replace only when the canvas is non-empty (frictionless on the empty start). Owned by T6. (F7 and F10 sit outside the core roof table above; this is the new tension this release.)
|
- **F10 × F7** — loading a sample calls `setModel`, which _replaces the whole document and clears undo history_. Against autosave (F7), one click could wipe real work. Mitigated by confirming the replace only when the canvas is non-empty (frictionless on the empty start). Owned by T6. (F7 and F10 sit outside the core roof table above; this is the new tension this release.)
|
||||||
|
- **F10 ◎ F11** — the two G4 aids reinforce: samples give worked examples, the glossary gives the words for the pieces they're made of. Build either and the other teaches better. (Both sit outside the core roof table.)
|
||||||
|
|
||||||
## 6. Components & Function → Component map
|
## 6. Components & Function → Component map
|
||||||
|
|
||||||
| ID | Component | ADR |
|
| ID | Component | ADR |
|
||||||
| --- | --------------------------------------------------- | -------- |
|
| --- | -------------------------------------------------------------------------------- | -------- |
|
||||||
| C1 | Vue Flow canvas (`@vue-flow/core` + addons) | ADR-0002 |
|
| C1 | Vue Flow canvas (`@vue-flow/core` + addons) | ADR-0002 |
|
||||||
| C2 | Custom node components (Stock/Flow/Converter/Cloud) | ADR-0002 |
|
| C2 | Custom node components (Stock/Flow/Converter/Cloud) | ADR-0002 |
|
||||||
| C3 | Custom edge components (InfoLink badge, Flow pipe) | ADR-0002 |
|
| C3 | Custom edge components (InfoLink badge, Flow pipe) | ADR-0002 |
|
||||||
@@ -441,19 +451,21 @@ Cells: strength 9/3/1. Importance Σ = Σ(weight × strength).
|
|||||||
| C11 | Loop overlay UI (R/B badges, hover highlight) | ADR-0001 |
|
| C11 | Loop overlay UI (R/B badges, hover highlight) | ADR-0001 |
|
||||||
| C12 | Palette + chrome (DaisyUI toolbar, model list) | — |
|
| C12 | Palette + chrome (DaisyUI toolbar, model list) | — |
|
||||||
| C13 | Sample gallery (`model/samples.ts` + header menu) | — |
|
| C13 | Sample gallery (`model/samples.ts` + header menu) | — |
|
||||||
|
| C14 | Glossary + gloss affordances (`glossary.ts`, `GlossPanel.vue`, palette tooltips) | — |
|
||||||
|
|
||||||
| | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | C11 | C12 | C13 |
|
| | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | C11 | C12 | C13 | C14 |
|
||||||
| --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| F1 | 9 | 9 | | | 3 | 9 | | | | | | 9 | |
|
| F1 | 9 | 9 | | | 3 | 9 | | | | | | 9 | | |
|
||||||
| F2 | 9 | 9 | 9 | | 9 | 3 | 9 | | | | | | |
|
| F2 | 9 | 9 | 9 | | 9 | 3 | 9 | | | | | | | |
|
||||||
| F3 | 9 | 3 | 3 | | | 9 | | | | | | | |
|
| F3 | 9 | 3 | 3 | | | 9 | | | | | | | | |
|
||||||
| F4 | | 1 | 1 | 3 | 3 | | 9 | | | | | | |
|
| F4 | | 1 | 1 | 3 | 3 | | 9 | | | | | | | |
|
||||||
| F5 | | | | 3 | 1 | | | 9 | | | 9 | | |
|
| F5 | | | | 3 | 1 | | | 9 | | | 9 | | | |
|
||||||
| F6 | | | 9 | 3 | 3 | | | 1 | | | | | |
|
| F6 | | | 9 | 3 | 3 | | | 1 | | | | | | |
|
||||||
| F7 | | | | | 9 | | | | 9 | | | | |
|
| F7 | | | | | 9 | | | | 9 | | | | | |
|
||||||
| F8 | | | | 3 | | | | | 1 | 9 | | | |
|
| F8 | | | | 3 | | | | | 1 | 9 | | | | |
|
||||||
| F9 | | | | | 9 | | | | | | | | |
|
| F9 | | | | | 9 | | | | | | | | | |
|
||||||
| F10 | 1 | | | | 3 | | | | | | | | 9 |
|
| F10 | 1 | | | | 3 | | | | | | | | 9 | |
|
||||||
|
| F11 | | | | | | | | | | | | 3 | | 9 |
|
||||||
|
|
||||||
## 7. Critical performance budget
|
## 7. Critical performance budget
|
||||||
|
|
||||||
@@ -468,13 +480,14 @@ Cells: strength 9/3/1. Importance Σ = Σ(weight × strength).
|
|||||||
## 8. Tradeoffs — Got / Paid / ADR
|
## 8. Tradeoffs — Got / Paid / ADR
|
||||||
|
|
||||||
| ID | Tradeoff | Got | Paid | ADR |
|
| ID | Tradeoff | Got | Paid | ADR |
|
||||||
| --- | ----------------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------- |
|
| --- | ------------------------------------------ | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------- |
|
||||||
| T1 | Vue Flow over hand-rolled SVG | fast F1–F3, batteries-included editor mechanics | a dependency owning the view layer + a Model↔VueFlow projection to maintain | ADR-0002 |
|
| T1 | Vue Flow over hand-rolled SVG | fast F1–F3, batteries-included editor mechanics | a dependency owning the view layer + a Model↔VueFlow projection to maintain | ADR-0002 |
|
||||||
| T2 | Flow-as-node + materialised Clouds | uniform graph for loops/render; info-links can target flows; sim-clean | Cloud lifecycle management; a node that renders as a pipe | ADR-0003 |
|
| T2 | Flow-as-node + materialised Clouds | uniform graph for loops/render; info-links can target flows; sim-clean | Cloud lifecycle management; a node that renders as a pipe | ADR-0003 |
|
||||||
| T3 | Enumerate loops with a cap | per-loop R/B insight (G2) within the frame budget | capped completeness on pathological graphs ("+N more") | ADR-0001 |
|
| T3 | Enumerate loops with a cap | per-loop R/B insight (G2) within the frame budget | capped completeness on pathological graphs ("+N more") | ADR-0001 |
|
||||||
| T4 | IndexedDB(`idb`) behind `ModelRepository` | size headroom + swappable engine (sync later) | async wrapper + a small dependency; no sync today | — |
|
| T4 | IndexedDB(`idb`) behind `ModelRepository` | size headroom + swappable engine (sync later) | async wrapper + a small dependency; no sync today | — |
|
||||||
| T5 | Snapshot undo over command pattern | simplicity and correctness | memory per snapshot (bounded by ring buffer + small models) | — |
|
| T5 | Snapshot undo over command pattern | simplicity and correctness | memory per snapshot (bounded by ring buffer + small models) | — |
|
||||||
| T6 | Samples load by replacing the whole Model | a sample is _just a Model_ — no special load path; reuses the whole pipeline; one click | the load is destructive (`setModel` clears undo history); guarded by a confirm on a non-empty canvas | — |
|
| T6 | Samples load by replacing the whole Model | a sample is _just a Model_ — no special load path; reuses the whole pipeline; one click | the load is destructive (`setModel` clears undo history); guarded by a confirm on a non-empty canvas | — |
|
||||||
|
| T7 | Gloss on selection, not passive node hover | a clean, quiet canvas on dense models — no per-node hover popups, no zoom-scaled bubbles | you must select an element to read its definition (one click) | — |
|
||||||
|
|
||||||
### Tensions being watched (unresolved by design)
|
### Tensions being watched (unresolved by design)
|
||||||
|
|
||||||
@@ -486,7 +499,7 @@ Cells: strength 9/3/1. Importance Σ = Σ(weight × strength).
|
|||||||
- **Brief vocabulary vs glossary.** The original request said "functions," "arrows," "faucets"; DESIGN uses the resolved CONTEXT.md terms verbatim — **Converter**, **Flow**/**Information Link**, faucet dropped.
|
- **Brief vocabulary vs glossary.** The original request said "functions," "arrows," "faucets"; DESIGN uses the resolved CONTEXT.md terms verbatim — **Converter**, **Flow**/**Information Link**, faucet dropped.
|
||||||
- **G3 ambiguity.** "Safe and portable" initially read as possible cloud sync; clarified — G3 is satisfied by **local persistence + JSON export**, not sync (PouchDB deferred, see Tensions).
|
- **G3 ambiguity.** "Safe and portable" initially read as possible cloud sync; clarified — G3 is satisfied by **local persistence + JSON export**, not sync (PouchDB deferred, see Tensions).
|
||||||
- **Storage default drift.** Initial recommendation was localStorage; resolved to **IndexedDB via `idb`**, behind a `ModelRepository` interface so the engine choice is no longer hard-to-reverse.
|
- **Storage default drift.** Initial recommendation was localStorage; resolved to **IndexedDB via `idb`**, behind a `ModelRepository` interface so the engine choice is no longer hard-to-reverse.
|
||||||
- **Teaching intent unrecorded.** The project is framed as Meadows-inspired (README) and the whole vocabulary is pedagogical (CONTEXT.md), yet no Goal captured "teach the language" until now — the intent lived only in the vision. Surfaced and recorded as **G4** (weight 4), answered by the sample gallery (**F10** / **C13**).
|
- **Teaching intent unrecorded.** The project is framed as Meadows-inspired (README) and the whole vocabulary is pedagogical (CONTEXT.md), yet no Goal captured "teach the language" until now — the intent lived only in the vision. Surfaced and recorded as **G4** (weight 4), answered by the sample gallery (**F10** / **C13**) and inline glossary affordances (**F11** / **C14**).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
FROM node:22-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json pnpm-lock.yaml* ./
|
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
|
||||||
RUN corepack enable && pnpm install --frozen-lockfile
|
RUN corepack enable && pnpm install --frozen-lockfile
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ Deployed at https://meadows.apoena.dev
|
|||||||
substrate; the domain Model is the source of truth.
|
substrate; the domain Model is the source of truth.
|
||||||
- [ADR-0003](./docs/adr/0003-flow-as-node-materialised-clouds.md) — a Flow is a
|
- [ADR-0003](./docs/adr/0003-flow-as-node-materialised-clouds.md) — a Flow is a
|
||||||
node; Source/Sink clouds are materialised nodes.
|
node; Source/Sink clouds are materialised nodes.
|
||||||
|
- [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
|
## Stack
|
||||||
|
|
||||||
|
|||||||
89
docs/adr/0004-rate-rules-not-formulas.md
Normal file
89
docs/adr/0004-rate-rules-not-formulas.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Behaviour comes from rules over Information Links, not free-form formulas
|
||||||
|
|
||||||
|
_Part of [meadows](../../README.md) · see [DESIGN.md](../../DESIGN.md)._
|
||||||
|
|
||||||
|
Numeric simulation (phase 2) makes a **Model** _alive_: Stocks accumulate over
|
||||||
|
time, Flows and Converters recompute each instant. Two coupled decisions shape
|
||||||
|
how a Model carries the numbers, and both trade expressive power for **valid by
|
||||||
|
construction** — the right trade for a tool that exists to _popularise_ systems
|
||||||
|
thinking, not to compete with Vensim.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**1. The Information Link _is_ the declared dependency.** A Flow's or Converter's
|
||||||
|
inputs are exactly the elements that link into it. There is no separate "equation
|
||||||
|
references" namespace to keep in sync — the wiring you draw _is_ the wiring the
|
||||||
|
simulator reads. The same signed graph the loop detector walks (ADR-0001) is the
|
||||||
|
graph the simulator integrates, so the loops you _see_ classified R/B are the
|
||||||
|
loops you _run_. They can never disagree.
|
||||||
|
|
||||||
|
**2. A Flow/Converter computes from a small fixed vocabulary of `Rule`s, not a
|
||||||
|
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, and Overflow the same with its `−` input as the threshold. One gesture,
|
||||||
|
two payoffs — no new per-link data.
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
- **Free-form expression strings** (`birth rate = Population × fertility`) —
|
||||||
|
maximally expressive, and what `equation?: string` originally anticipated.
|
||||||
|
Rejected: needs a parser + a sandbox (never `eval`/`new Function`), invites
|
||||||
|
broken-formula and name-resolution errors (auto-names contain spaces), and lets
|
||||||
|
a learner _paint_ a curve instead of discovering it from structure.
|
||||||
|
- **Pick the output curve** (label a Stock "exponential" / "logarithmic") —
|
||||||
|
rejected: it is the answer, not the cause, and it breaks the moment feedback
|
||||||
|
decides the shape. "Logarithmic" in particular has no honest local rule; what
|
||||||
|
people mean by it is asymptotic approach — which _is_ the Gap rule.
|
||||||
|
- **Rules over Information Links (chosen)** — no parser, valid by construction,
|
||||||
|
and it teaches structure → behaviour.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The domain types gain a `Rule` union on Flow/Converter (replacing the unused
|
||||||
|
`equation?: string`), an optional `initialValue` on Stock, and an optional
|
||||||
|
`SimSpec` (`start` / `stop` / `dt`) on the Model. All optional and additive, so
|
||||||
|
existing saved Models still load (F8); they are simply not _simulatable_ until
|
||||||
|
equipped.
|
||||||
|
- **Algebraic loops are an error.** A cycle in the wiring is legitimate feedback
|
||||||
|
**iff it passes through a Stock** — the Stock supplies last-step state and so
|
||||||
|
breaks the within-step dependency. A cycle among only Flows/Converters has no
|
||||||
|
Stock to break it: the simulator cannot order it and rejects it. The simulator
|
||||||
|
reuses the cycle machinery to detect this; it is a new _sim-readiness_ check,
|
||||||
|
distinct from structural validity (validation.ts).
|
||||||
|
- A reader seeing `{ kind: "gap", factor }` on a Flow and wondering where its
|
||||||
|
operands come from should look here: they are the Flow's inbound Information
|
||||||
|
Links, picked by Polarity.
|
||||||
|
- 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 — 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.
|
||||||
@@ -17,7 +17,9 @@
|
|||||||
"@vue-flow/controls": "^1.1.3",
|
"@vue-flow/controls": "^1.1.3",
|
||||||
"@vue-flow/core": "^1.48.2",
|
"@vue-flow/core": "^1.48.2",
|
||||||
"daisyui": "^5.5.23",
|
"daisyui": "^5.5.23",
|
||||||
|
"idb": "^8.0.3",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
"uplot": "^1.6.32",
|
||||||
"vue": "^3.5.34"
|
"vue": "^3.5.34"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -20,9 +20,15 @@ importers:
|
|||||||
daisyui:
|
daisyui:
|
||||||
specifier: ^5.5.23
|
specifier: ^5.5.23
|
||||||
version: 5.5.23
|
version: 5.5.23
|
||||||
|
idb:
|
||||||
|
specifier: ^8.0.3
|
||||||
|
version: 8.0.3
|
||||||
pinia:
|
pinia:
|
||||||
specifier: ^3.0.4
|
specifier: ^3.0.4
|
||||||
version: 3.0.4(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))
|
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:
|
vue:
|
||||||
specifier: ^3.5.34
|
specifier: ^3.5.34
|
||||||
version: 3.5.38(typescript@6.0.3)
|
version: 3.5.38(typescript@6.0.3)
|
||||||
@@ -739,6 +745,9 @@ packages:
|
|||||||
hookable@5.5.3:
|
hookable@5.5.3:
|
||||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||||
|
|
||||||
|
idb@8.0.3:
|
||||||
|
resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==}
|
||||||
|
|
||||||
is-what@5.5.0:
|
is-what@5.5.0:
|
||||||
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
|
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -933,6 +942,9 @@ packages:
|
|||||||
undici-types@7.18.2:
|
undici-types@7.18.2:
|
||||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||||
|
|
||||||
|
uplot@1.6.32:
|
||||||
|
resolution: {integrity: sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==}
|
||||||
|
|
||||||
vite@8.0.16:
|
vite@8.0.16:
|
||||||
resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==}
|
resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1522,6 +1534,8 @@ snapshots:
|
|||||||
|
|
||||||
hookable@5.5.3: {}
|
hookable@5.5.3: {}
|
||||||
|
|
||||||
|
idb@8.0.3: {}
|
||||||
|
|
||||||
is-what@5.5.0: {}
|
is-what@5.5.0: {}
|
||||||
|
|
||||||
jiti@2.7.0: {}
|
jiti@2.7.0: {}
|
||||||
@@ -1701,6 +1715,8 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@7.18.2: {}
|
undici-types@7.18.2: {}
|
||||||
|
|
||||||
|
uplot@1.6.32: {}
|
||||||
|
|
||||||
vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0):
|
vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
height="24"
|
height="24"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="#16A34A"
|
stroke="#ff9ff3"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 550 B After Width: | Height: | Size: 550 B |
@@ -20,20 +20,27 @@ import {
|
|||||||
ErrorCode,
|
ErrorCode,
|
||||||
isErrorOfType,
|
isErrorOfType,
|
||||||
type OnConnectStartParams,
|
type OnConnectStartParams,
|
||||||
Panel,
|
|
||||||
useVueFlow,
|
useVueFlow,
|
||||||
VueFlow,
|
VueFlow,
|
||||||
type XYPosition,
|
type XYPosition,
|
||||||
} from "@vue-flow/core"
|
} from "@vue-flow/core"
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted } 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 { 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"
|
||||||
|
import GlossPanel from "./GlossPanel.vue"
|
||||||
|
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 SampleBrowser from "./SampleBrowser.vue"
|
||||||
import InfoLinkEdge from "./edges/InfoLinkEdge.vue"
|
import InfoLinkEdge from "./edges/InfoLinkEdge.vue"
|
||||||
|
import PipeEdge from "./edges/PipeEdge.vue"
|
||||||
import CloudNode from "./nodes/CloudNode.vue"
|
import CloudNode from "./nodes/CloudNode.vue"
|
||||||
import ConverterNode from "./nodes/ConverterNode.vue"
|
import ConverterNode from "./nodes/ConverterNode.vue"
|
||||||
import FlowNode from "./nodes/FlowNode.vue"
|
import FlowNode from "./nodes/FlowNode.vue"
|
||||||
@@ -45,6 +52,17 @@ const graph = computed(() => project(store.model))
|
|||||||
const nodes = computed(() => graph.value.nodes)
|
const nodes = computed(() => graph.value.nodes)
|
||||||
const edges = computed(() => graph.value.edges)
|
const edges = computed(() => graph.value.edges)
|
||||||
|
|
||||||
|
// The simulation results panel (phase 2). Toggled from the header; the panel runs
|
||||||
|
// 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
|
// Explicit shared id: useVueFlow() runs here in the parent setup, before
|
||||||
// <VueFlow> mounts. Pinning both to the same id guarantees they resolve to one
|
// <VueFlow> mounts. Pinning both to the same id guarantees they resolve to one
|
||||||
// store instance, so the event hooks below actually fire.
|
// store instance, so the event hooks below actually fire.
|
||||||
@@ -55,13 +73,56 @@ const {
|
|||||||
onConnectStart,
|
onConnectStart,
|
||||||
onConnectEnd,
|
onConnectEnd,
|
||||||
onError,
|
onError,
|
||||||
|
onNodesInitialized,
|
||||||
getSelectedNodes,
|
getSelectedNodes,
|
||||||
getSelectedEdges,
|
getSelectedEdges,
|
||||||
viewport,
|
viewport,
|
||||||
vueFlowRef,
|
vueFlowRef,
|
||||||
|
dimensions,
|
||||||
fitView,
|
fitView,
|
||||||
} = useVueFlow("meadows")
|
} = useVueFlow("meadows")
|
||||||
|
|
||||||
|
// Fit the view after a *load* (sample/import/restore), once the freshly projected
|
||||||
|
// nodes have been measured. Vue Flow measures node dimensions a frame after they
|
||||||
|
// mount, so calling fitView straight after setModel fits to 0×0 boxes and the model
|
||||||
|
// lands jammed in the top-left; onNodesInitialized fires post-measure, so framing
|
||||||
|
// is correct. A one-shot flag keeps it scoped to loads (not every re-init).
|
||||||
|
let fitAfterInit = false
|
||||||
|
onNodesInitialized(() => {
|
||||||
|
if (!fitAfterInit) return
|
||||||
|
fitAfterInit = false
|
||||||
|
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.
|
||||||
|
useAutosave({
|
||||||
|
onRestore: () => {
|
||||||
|
fitAfterInit = true
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
onNodeDragStart(() => store.beginInteraction())
|
onNodeDragStart(() => store.beginInteraction())
|
||||||
onNodeDragStop(({ nodes: dragged }) => {
|
onNodeDragStop(({ nodes: dragged }) => {
|
||||||
for (const node of dragged) store.moveNode(node.id, node.position)
|
for (const node of dragged) store.moveNode(node.id, node.position)
|
||||||
@@ -90,16 +151,51 @@ onError((err) => {
|
|||||||
// so validating projected edges would drop every pipe-out (Vue Flow EDGE_INVALID).
|
// so validating projected edges would drop every pipe-out (Vue Flow EDGE_INVALID).
|
||||||
function isValidConnection(connection: Connection): boolean {
|
function isValidConnection(connection: Connection): boolean {
|
||||||
if ("id" in connection) return true
|
if ("id" in connection) return true
|
||||||
return canConnect(store.model, connection.source, connection.target)
|
const ok = canConnect(store.model, connection.source, connection.target)
|
||||||
|
// Remember the latest *refused* close-the-loop attempt so the release handler
|
||||||
|
// can explain it. Overwrite on every candidate (clearing when valid) so this
|
||||||
|
// reflects the handle we actually let go on, not one we merely passed over.
|
||||||
|
pendingLoopHint = ok ? null : loopClosureHint(connection.source, connection.target)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message for dragging a Flow back onto a Stock it already touches — the
|
||||||
|
* "close the loop by hand" gesture. The drop is refused (an Information Link
|
||||||
|
* never targets a Stock; Stocks change only via Flows, ADR-0001), but the loop
|
||||||
|
* is already there: the Flow's own pipe *is* the link back. Naming that turns a
|
||||||
|
* dead-end gesture into the lesson (F4). Returns null for any other refusal,
|
||||||
|
* which stays silent.
|
||||||
|
*/
|
||||||
|
function loopClosureHint(sourceId: string, targetId: string): string | null {
|
||||||
|
const flow = store.model.nodes.find((node) => node.id === sourceId)
|
||||||
|
if (flow?.kind !== "flow") return null
|
||||||
|
const stock = store.model.nodes.find((node) => node.id === targetId)
|
||||||
|
if (stock?.kind !== "stock") return null
|
||||||
|
if (flow.source !== stock.id && flow.target !== stock.id) return null
|
||||||
|
const direction = flow.source === stock.id ? "out of" : "into"
|
||||||
|
return `${flow.name} already flows ${direction} ${stock.name} — that pipe is the link back, so the loop is already there.`
|
||||||
|
}
|
||||||
|
|
||||||
|
// A transient, self-dismissing teaching hint shown over the canvas (F4).
|
||||||
|
const hint = ref<string | null>(null)
|
||||||
|
let hintTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
function showHint(message: string): void {
|
||||||
|
hint.value = message
|
||||||
|
clearTimeout(hintTimer)
|
||||||
|
hintTimer = setTimeout(() => (hint.value = null), 6000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remember the drag's source so releasing on empty canvas can open onto a Cloud.
|
// Remember the drag's source so releasing on empty canvas can open onto a Cloud.
|
||||||
let connectSource: string | null = null
|
let connectSource: string | null = null
|
||||||
let connectionMade = false
|
let connectionMade = false
|
||||||
|
// Set by isValidConnection when the live gesture is a refused loop-closure.
|
||||||
|
let pendingLoopHint: string | null = null
|
||||||
|
|
||||||
onConnectStart((params: OnConnectStartParams) => {
|
onConnectStart((params: OnConnectStartParams) => {
|
||||||
connectSource = params.handleType === "source" ? (params.nodeId ?? null) : null
|
connectSource = params.handleType === "source" ? (params.nodeId ?? null) : null
|
||||||
connectionMade = false
|
connectionMade = false
|
||||||
|
pendingLoopHint = null
|
||||||
})
|
})
|
||||||
|
|
||||||
onConnect((connection) => {
|
onConnect((connection) => {
|
||||||
@@ -108,11 +204,18 @@ onConnect((connection) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onConnectEnd((event) => {
|
onConnectEnd((event) => {
|
||||||
if (!connectionMade && connectSource && event) {
|
if (!connectionMade) {
|
||||||
|
// A refused loop-closure and an empty-canvas open are mutually exclusive
|
||||||
|
// (the former needs a Flow source, the latter a Stock source), so prefer the
|
||||||
|
// explanation when we have one.
|
||||||
|
if (pendingLoopHint) showHint(pendingLoopHint)
|
||||||
|
else if (connectSource && event) {
|
||||||
const point = pointerPosition(event)
|
const point = pointerPosition(event)
|
||||||
if (point) store.connectToNewCloud(connectSource, screenToFlow(point.x, point.y))
|
if (point) store.connectToNewCloud(connectSource, screenToFlow(point.x, point.y))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
connectSource = null
|
connectSource = null
|
||||||
|
pendingLoopHint = null
|
||||||
})
|
})
|
||||||
|
|
||||||
/** clientX/clientY from a mouse or touch end event. */
|
/** clientX/clientY from a mouse or touch end event. */
|
||||||
@@ -169,15 +272,61 @@ function onDragOver(event: DragEvent): void {
|
|||||||
* stay frictionless on the empty starting canvas. Fit the view once the
|
* stay frictionless on the empty starting canvas. Fit the view once the
|
||||||
* projection has re-derived so the loaded model lands framed.
|
* projection has re-derived so the loaded model lands framed.
|
||||||
*/
|
*/
|
||||||
async function loadSample(sample: Sample): Promise<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
|
||||||
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()
|
}
|
||||||
await nextTick()
|
|
||||||
fitView({ padding: 0.2 })
|
const fileInput = useTemplateRef<HTMLInputElement>("fileInput")
|
||||||
|
|
||||||
|
/** A filesystem-safe stem from the model name, e.g. "Coffee cooling" → "coffee-cooling". */
|
||||||
|
function fileStem(name: string): string {
|
||||||
|
const slug = name
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
return slug || "model"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Export the current Model as versioned JSON download (F8). */
|
||||||
|
function exportModel(): void {
|
||||||
|
const blob = new Blob([serializeModel(store.model)], { type: "application/json" })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const anchor = document.createElement("a")
|
||||||
|
anchor.href = url
|
||||||
|
anchor.download = `${fileStem(store.model.name)}.json`
|
||||||
|
anchor.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import a Model from a JSON file: validate, confirm a destructive replace, load (F8). */
|
||||||
|
async function onImportFile(event: Event): Promise<void> {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
// Reset first so picking the same file again still fires `change`.
|
||||||
|
input.value = ""
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
const result = parseModel(await file.text())
|
||||||
|
if (!result.ok) {
|
||||||
|
window.alert(`Couldn't import “${file.name}”: ${result.error}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Importing replaces the document and clears undo (T6 / F10×F7), so guard real work.
|
||||||
|
if (
|
||||||
|
store.nodeCount > 0 &&
|
||||||
|
!window.confirm(`Replace the current model with “${result.model.name}”?`)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fitAfterInit = true
|
||||||
|
store.setModel(result.model)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTextEntry(target: EventTarget | null): boolean {
|
function isTextEntry(target: EventTarget | null): boolean {
|
||||||
@@ -216,32 +365,59 @@ function onKeydown(event: KeyboardEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => window.addEventListener("keydown", onKeydown))
|
onMounted(() => window.addEventListener("keydown", onKeydown))
|
||||||
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown))
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener("keydown", onKeydown)
|
||||||
|
clearTimeout(hintTimer)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-screen flex-col bg-base-200">
|
<div class="flex h-screen flex-col bg-base-200">
|
||||||
<header class="flex items-center gap-3 border-b border-base-300 bg-base-100 px-4 py-2">
|
<header class="flex items-center gap-3 border-b border-base-300 bg-base-100 px-4 py-2">
|
||||||
<img src="/favicon.svg" alt="" class="size-6" />
|
<!-- Inlined favicon so the logo strokes with the theme primary (currentColor
|
||||||
|
via text-primary); an external <img> can't read the theme var. -->
|
||||||
|
<svg
|
||||||
|
class="size-6 text-primary"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M14 20a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
|
||||||
|
<path d="M14 4a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
|
||||||
|
<path d="M6 12a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
|
||||||
|
<path d="M22 12a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
|
||||||
|
<path d="M14 12a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
|
||||||
|
<path d="M6 12h4" />
|
||||||
|
<path d="M14 12h4" />
|
||||||
|
<path d="M12 6v4" />
|
||||||
|
<path d="M12 14v4" />
|
||||||
|
</svg>
|
||||||
<h1 class="text-lg font-semibold">meadows</h1>
|
<h1 class="text-lg font-semibold">meadows</h1>
|
||||||
<span class="text-sm text-base-content/50">
|
<span class="text-sm text-base-content/50">
|
||||||
{{ store.nodeCount }} {{ store.nodeCount === 1 ? "element" : "elements" }}
|
{{ store.nodeCount }} {{ store.nodeCount === 1 ? "element" : "elements" }}
|
||||||
</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>
|
<button
|
||||||
<ul
|
class="btn btn-primary btn-sm"
|
||||||
tabindex="0"
|
:class="{ 'btn-active': showResults }"
|
||||||
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"
|
@click="showResults = !showResults"
|
||||||
>
|
>
|
||||||
<li v-for="sample in SAMPLES" :key="sample.title">
|
Simulate
|
||||||
<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>
|
</button>
|
||||||
</li>
|
<button class="btn btn-ghost btn-sm" @click="exportModel">Export</button>
|
||||||
</ul>
|
<button class="btn btn-ghost btn-sm" @click="fileInput?.click()">Import</button>
|
||||||
</div>
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
class="hidden"
|
||||||
|
@change="onImportFile"
|
||||||
|
/>
|
||||||
<button class="btn btn-ghost btn-sm" :disabled="!store.canUndo" @click="store.undo()">
|
<button class="btn btn-ghost btn-sm" :disabled="!store.canUndo" @click="store.undo()">
|
||||||
Undo
|
Undo
|
||||||
</button>
|
</button>
|
||||||
@@ -261,15 +437,13 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown))
|
|||||||
:connection-radius="30"
|
:connection-radius="30"
|
||||||
:min-zoom="0.2"
|
:min-zoom="0.2"
|
||||||
:max-zoom="4"
|
:max-zoom="4"
|
||||||
|
:snap-to-grid="true"
|
||||||
|
:snap-grid="[20, 20]"
|
||||||
class="size-full"
|
class="size-full"
|
||||||
>
|
>
|
||||||
<Background :gap="20" pattern-color="#d1d5db" />
|
<Background :gap="20" pattern-color="#d1d5db" />
|
||||||
<Controls position="bottom-left" :show-interactive="false" />
|
<Controls position="bottom-left" :show-interactive="false" />
|
||||||
|
|
||||||
<Panel position="top-left">
|
|
||||||
<Palette @add="addNode" />
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<template #node-stock="nodeProps">
|
<template #node-stock="nodeProps">
|
||||||
<StockNode v-bind="nodeProps" />
|
<StockNode v-bind="nodeProps" />
|
||||||
</template>
|
</template>
|
||||||
@@ -283,12 +457,45 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown))
|
|||||||
<CloudNode v-bind="nodeProps" />
|
<CloudNode v-bind="nodeProps" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #edge-pipe="edgeProps">
|
||||||
|
<PipeEdge v-bind="edgeProps" />
|
||||||
|
</template>
|
||||||
<template #edge-info="edgeProps">
|
<template #edge-info="edgeProps">
|
||||||
<InfoLinkEdge v-bind="edgeProps" />
|
<InfoLinkEdge v-bind="edgeProps" />
|
||||||
</template>
|
</template>
|
||||||
</VueFlow>
|
</VueFlow>
|
||||||
|
|
||||||
|
<!-- Chrome lives at this level, above the badge overlay: Vue Flow's pane is a
|
||||||
|
z-index:0 stacking context, so a Panel inside it would be painted under
|
||||||
|
the screen-space R/B badges (which sit on the canvas, over loop centres). -->
|
||||||
|
<Palette class="absolute top-3 left-3 z-20" @add="addNode" />
|
||||||
<LoopOverlay />
|
<LoopOverlay />
|
||||||
|
<GlossPanel />
|
||||||
|
<Inspector />
|
||||||
|
|
||||||
|
<!-- 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
|
||||||
|
the palette and badge overlay. role=status announces it politely. -->
|
||||||
|
<div v-if="hint" class="toast toast-center toast-bottom z-30">
|
||||||
|
<div role="status" class="alert alert-info max-w-sm text-left shadow-lg">
|
||||||
|
<span>{{ hint }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-circle btn-ghost btn-xs"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
@click="hint = null"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</template>
|
||||||
|
|||||||
45
src/components/GlossPanel.vue
Normal file
45
src/components/GlossPanel.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Gloss-on-selection (F11, G4) — when exactly one element is selected, name it
|
||||||
|
* and define it in a small corner card. Selection is deliberate and singular, so
|
||||||
|
* the canvas stays pixel-clean and silent until you ask — no passive hover noise
|
||||||
|
* on a dense model. Self-contained: reads selection straight from the shared Vue
|
||||||
|
* Flow instance, so it drops into the Editor as one tag.
|
||||||
|
*
|
||||||
|
* A pipe edge stands in for its Flow node, so selecting one glosses the Flow; an
|
||||||
|
* info edge glosses the Information Link. Anything else (nothing, or a multi-
|
||||||
|
* selection) hides the card.
|
||||||
|
*/
|
||||||
|
import { useVueFlow } from "@vue-flow/core"
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { type GlossEntry, INFO_LINK_GLOSS, NODE_GLOSSARY } from "@/model/glossary"
|
||||||
|
import type { EdgeData, NodeData } from "@/model/projection"
|
||||||
|
|
||||||
|
const { getSelectedNodes, getSelectedEdges } = useVueFlow("meadows")
|
||||||
|
|
||||||
|
const gloss = computed<GlossEntry | null>(() => {
|
||||||
|
const nodes = getSelectedNodes.value
|
||||||
|
const edges = getSelectedEdges.value
|
||||||
|
|
||||||
|
if (nodes.length === 1 && edges.length === 0) {
|
||||||
|
const node = (nodes[0].data as NodeData | undefined)?.node
|
||||||
|
return node ? NODE_GLOSSARY[node.kind] : null
|
||||||
|
}
|
||||||
|
if (edges.length === 1 && nodes.length === 0) {
|
||||||
|
const kind = (edges[0].data as EdgeData | undefined)?.kind
|
||||||
|
if (kind === "info") return INFO_LINK_GLOSS
|
||||||
|
if (kind === "pipe") return NODE_GLOSSARY.flow
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="gloss"
|
||||||
|
class="pointer-events-none absolute right-3 bottom-3 z-10 max-w-xs rounded-box border border-base-300 bg-base-100/90 p-3 shadow-md backdrop-blur"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
218
src/components/Inspector.vue
Normal file
218
src/components/Inspector.vue
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Inspector (phase 2, ADR-0004) — equips the *selected* element with the numbers
|
||||||
|
* a simulation needs, so a Model becomes more than samples: a Stock gets its
|
||||||
|
* initial value, a Flow/Converter its rule. Editing is deliberately small —
|
||||||
|
* choose a rule from the fixed vocabulary and type one or two numbers; there is
|
||||||
|
* no formula box, so the Model stays valid by construction.
|
||||||
|
*
|
||||||
|
* It reads selection from the shared Vue Flow instance (as GlossPanel does), then
|
||||||
|
* resolves the *live* domain node from the store so edits round-trip through
|
||||||
|
* undoable store actions. Operands for a rule are the element's inbound
|
||||||
|
* Information Links picked by Polarity — set those by wiring, not here.
|
||||||
|
*/
|
||||||
|
import { useVueFlow } from "@vue-flow/core"
|
||||||
|
import { computed } from "vue"
|
||||||
|
import type { EdgeData } from "@/model/projection"
|
||||||
|
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, 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]
|
||||||
|
const kind = (edge.data as EdgeData | undefined)?.kind
|
||||||
|
if (kind === "pipe") return edge.id.split("::")[0]
|
||||||
|
if (kind === "info") return edge.id
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
/** The live, editable domain node behind the selection (Clouds are not editable). */
|
||||||
|
const element = computed<StockNode | FlowNode | ConverterNode | null>(() => {
|
||||||
|
const id = selectedId.value
|
||||||
|
if (!id) return null
|
||||||
|
const node = store.model.nodes.find((n) => n.id === id)
|
||||||
|
if (node?.kind === "stock" || node?.kind === "flow" || node?.kind === "converter") return node
|
||||||
|
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
|
||||||
|
return rule.kind === "constant" ? rule.value : rule.factor
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRule(kind: Rule["kind"], n: number): Rule {
|
||||||
|
return kind === "constant" ? { kind, value: n } : { kind, factor: n }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInitial(event: Event): void {
|
||||||
|
const el = element.value
|
||||||
|
if (el?.kind !== "stock") return
|
||||||
|
const raw = (event.target as HTMLInputElement).value.trim()
|
||||||
|
if (raw === "") return store.setInitialValue(el.id, undefined)
|
||||||
|
const n = Number(raw)
|
||||||
|
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
|
||||||
|
const kind = (event.target as HTMLSelectElement).value as Rule["kind"] | ""
|
||||||
|
if (!kind) return
|
||||||
|
// Carry the existing number across a kind change; default a fresh rule sensibly.
|
||||||
|
const n = el.rule ? ruleNumber(el.rule) : kind === "constant" ? 0 : 1
|
||||||
|
store.setRule(el.id, buildRule(kind, n))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onParam(event: Event): void {
|
||||||
|
const el = element.value
|
||||||
|
if ((el?.kind !== "flow" && el?.kind !== "converter") || !el.rule) return
|
||||||
|
const n = Number((event.target as HTMLInputElement).value)
|
||||||
|
store.setRule(el.id, buildRule(el.rule.kind, Number.isFinite(n) ? n : 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-line reminder of where a rule reads its operands (they come from links). */
|
||||||
|
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 || 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, 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"
|
||||||
|
class="input input-sm input-bordered mt-1 w-full"
|
||||||
|
:value="element.initialValue ?? ''"
|
||||||
|
placeholder="—"
|
||||||
|
@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>
|
||||||
|
<label class="mt-2 block">
|
||||||
|
<span class="text-xs text-base-content/60">Rule</span>
|
||||||
|
<select
|
||||||
|
class="select select-sm select-bordered mt-1 w-full"
|
||||||
|
:value="element.rule?.kind ?? ''"
|
||||||
|
@change="onKind"
|
||||||
|
>
|
||||||
|
<option value="" disabled>Choose a rule…</option>
|
||||||
|
<option value="constant">Constant</option>
|
||||||
|
<option value="proportional">Proportional</option>
|
||||||
|
<option value="gap">Gap</option>
|
||||||
|
<option value="overflow">Overflow</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label v-if="element.rule" class="mt-2 block">
|
||||||
|
<span class="text-xs text-base-content/60">
|
||||||
|
{{ element.rule.kind === "constant" ? "Value" : "Factor" }}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
class="input input-sm input-bordered mt-1 w-full"
|
||||||
|
:value="ruleNumber(element.rule)"
|
||||||
|
@change="onParam"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p v-if="element.rule" class="mt-2 text-xs leading-snug text-base-content/50">
|
||||||
|
{{ 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>
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
* dataTransfer on drag). Flows aren't placed here — they're drawn by connecting
|
* dataTransfer on drag). Flows aren't placed here — they're drawn by connecting
|
||||||
* two nodes (F2, next).
|
* two nodes (F2, next).
|
||||||
*/
|
*/
|
||||||
|
import { NODE_GLOSSARY } from "@/model/glossary"
|
||||||
import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd"
|
import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd"
|
||||||
|
|
||||||
const emit = defineEmits<{ add: [kind: PlaceableKind] }>()
|
const emit = defineEmits<{ add: [kind: PlaceableKind] }>()
|
||||||
@@ -27,8 +28,10 @@ function onDragStart(event: DragEvent, kind: PlaceableKind): void {
|
|||||||
Add
|
Add
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost btn-sm cursor-grab justify-start gap-2 active:cursor-grabbing"
|
class="btn btn-ghost btn-sm tooltip tooltip-right cursor-grab justify-start gap-2 active:cursor-grabbing"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
|
:data-tip="NODE_GLOSSARY.stock.short"
|
||||||
|
:aria-label="`Stock — ${NODE_GLOSSARY.stock.short}`"
|
||||||
@click="emit('add', 'stock')"
|
@click="emit('add', 'stock')"
|
||||||
@dragstart="onDragStart($event, 'stock')"
|
@dragstart="onDragStart($event, 'stock')"
|
||||||
>
|
>
|
||||||
@@ -36,8 +39,10 @@ function onDragStart(event: DragEvent, kind: PlaceableKind): void {
|
|||||||
Stock
|
Stock
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost btn-sm cursor-grab justify-start gap-2 active:cursor-grabbing"
|
class="btn btn-ghost btn-sm tooltip tooltip-right cursor-grab justify-start gap-2 active:cursor-grabbing"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
|
:data-tip="NODE_GLOSSARY.converter.short"
|
||||||
|
:aria-label="`Converter — ${NODE_GLOSSARY.converter.short}`"
|
||||||
@click="emit('add', 'converter')"
|
@click="emit('add', 'converter')"
|
||||||
@dragstart="onDragStart($event, 'converter')"
|
@dragstart="onDragStart($event, 'converter')"
|
||||||
>
|
>
|
||||||
|
|||||||
151
src/components/ResultsPanel.vue
Normal file
151
src/components/ResultsPanel.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Results panel (phase 2, ADR-0004) — what makes the Model *alive*: it runs the
|
||||||
|
* simulation and traces each Stock's value over time. Stocks are the system's
|
||||||
|
* memory, so their trajectories are the behaviour worth watching (the savings
|
||||||
|
* balance snowballs; the coffee settles toward room temperature).
|
||||||
|
*
|
||||||
|
* 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. 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 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))
|
||||||
|
|
||||||
|
/** The current run window; falls back to the default until the Model carries one. */
|
||||||
|
const spec = computed<SimSpec>(() => store.model.sim ?? DEFAULT_SIM_SPEC)
|
||||||
|
|
||||||
|
function onSpec(key: keyof SimSpec, event: Event): void {
|
||||||
|
const n = Number((event.target as HTMLInputElement).value)
|
||||||
|
if (!Number.isFinite(n) || (key === "dt" && n <= 0)) return
|
||||||
|
store.setSimSpec({ ...spec.value, [key]: n })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distinct, legible track colours; cycled if a Model has more Stocks than these. */
|
||||||
|
const COLORS = ["#2563eb", "#dc2626", "#16a34a", "#d97706", "#7c3aed", "#0891b2"]
|
||||||
|
|
||||||
|
// 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(() => {
|
||||||
|
const run = sim.run
|
||||||
|
if (!run) return null
|
||||||
|
const stocks = store.model.nodes.filter((n): n is StockNode => n.kind === "stock")
|
||||||
|
if (stocks.length === 0 || run.times.length < 2) return null
|
||||||
|
|
||||||
|
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 }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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>
|
||||||
|
<div class="ml-auto flex items-center gap-2 text-xs text-base-content/60">
|
||||||
|
<label class="flex items-center gap-1">
|
||||||
|
from
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="input input-xs input-bordered w-16"
|
||||||
|
:value="spec.start"
|
||||||
|
@change="onSpec('start', $event)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1">
|
||||||
|
to
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="input input-xs input-bordered w-16"
|
||||||
|
:value="spec.stop"
|
||||||
|
@change="onSpec('stop', $event)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1">
|
||||||
|
step
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
min="0"
|
||||||
|
class="input input-xs input-bordered w-16"
|
||||||
|
:value="spec.dt"
|
||||||
|
@change="onSpec('dt', $event)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-circle btn-ghost btn-xs"
|
||||||
|
aria-label="Close"
|
||||||
|
@click="$emit('close')"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stopped early because values ran past what a float can hold. -->
|
||||||
|
<p v-if="chart?.diverged" class="mt-2 text-xs text-warning">
|
||||||
|
⚠ Values grew beyond what can be plotted and the run stopped early — try a smaller step, a
|
||||||
|
smaller factor, or a Balancing loop to rein it in.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- 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()"
|
||||||
|
>
|
||||||
|
{{ 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))"
|
||||||
|
/>
|
||||||
|
<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">
|
||||||
|
<p class="text-base-content/70">To bring this model to life, set:</p>
|
||||||
|
<ul class="mt-1 list-inside list-disc text-base-content/60">
|
||||||
|
<li v-for="(problem, i) in problems" :key="i">{{ problem }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
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>
|
||||||
35
src/components/edges/PipeEdge.vue
Normal file
35
src/components/edges/PipeEdge.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Flow pipe edge (C3) — the source→valve and valve→target segments of a Flow.
|
||||||
|
*
|
||||||
|
* The pipe leaves the source on the right and docks square into the target's
|
||||||
|
* left edge (horizontal tangents both ends), so the arrowhead always enters the
|
||||||
|
* stock straight-on. Vue Flow's default bezier does the same — but it sizes the
|
||||||
|
* horizontal "flatten" arm from the *horizontal* gap alone, so when the valve
|
||||||
|
* sits nearly above the stock that arm collapses to ~zero: the curve stays steep
|
||||||
|
* and snaps flat only in its final pixels, leaving the arrowhead on a stub of
|
||||||
|
* horizontal line while the visible stroke arrives steep — the head detaches.
|
||||||
|
*
|
||||||
|
* Sizing the arm from the vertical drop too keeps a real horizontal run before
|
||||||
|
* the stock at any approach angle, so the arrowhead sits on the line and still
|
||||||
|
* docks square. BaseEdge draws the stroke (same .vue-flow__edge-path class, so
|
||||||
|
* theme stroke/width are unchanged) and carries the markerEnd through.
|
||||||
|
*/
|
||||||
|
import { BaseEdge, type EdgeProps } from "@vue-flow/core"
|
||||||
|
import { computed } from "vue"
|
||||||
|
import type { EdgeData } from "@/model/projection"
|
||||||
|
|
||||||
|
const props = defineProps<EdgeProps<EdgeData>>()
|
||||||
|
|
||||||
|
const path = computed(() => {
|
||||||
|
const { sourceX: sx, sourceY: sy, targetX: tx, targetY: ty } = props
|
||||||
|
const arm = Math.min(120, Math.max(30, Math.abs(tx - sx) * 0.5, Math.abs(ty - sy) * 0.45))
|
||||||
|
// Cubic with horizontal control points: out the source's right, into the
|
||||||
|
// target's left. `arm` is the run over which each end stays horizontal.
|
||||||
|
return `M ${sx},${sy} C ${sx + arm},${sy} ${tx - arm},${ty} ${tx},${ty}`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BaseEdge :id="props.id" :path="path" :marker-end="props.markerEnd" :style="props.style" />
|
||||||
|
</template>
|
||||||
@@ -9,6 +9,8 @@ import { computed } from "vue"
|
|||||||
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
|
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
|
||||||
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
|
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
|
||||||
import type { ConverterNode } from "@/model/types"
|
import type { ConverterNode } from "@/model/types"
|
||||||
|
import { useSimulationStore } from "@/store/simulation"
|
||||||
|
import { formatValue } from "./format"
|
||||||
import NodeLabel from "./NodeLabel.vue"
|
import NodeLabel from "./NodeLabel.vue"
|
||||||
|
|
||||||
const props = defineProps<NodeProps<NodeData>>()
|
const props = defineProps<NodeProps<NodeData>>()
|
||||||
@@ -16,6 +18,9 @@ const props = defineProps<NodeProps<NodeData>>()
|
|||||||
// The projection guarantees a converter-typed node here.
|
// The projection guarantees a converter-typed node here.
|
||||||
const converter = computed(() => props.data.node as ConverterNode)
|
const converter = computed(() => props.data.node as ConverterNode)
|
||||||
const loopRing = useNodeLoopRing(props.id)
|
const loopRing = useNodeLoopRing(props.id)
|
||||||
|
|
||||||
|
const sim = useSimulationStore()
|
||||||
|
const value = computed(() => sim.valueAt(props.id))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -25,6 +30,12 @@ const loopRing = useNodeLoopRing(props.id)
|
|||||||
:class="[props.selected ? 'border-primary' : 'border-base-300', loopRing]"
|
:class="[props.selected ? 'border-primary' : 'border-base-300', loopRing]"
|
||||||
>
|
>
|
||||||
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
|
<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" />
|
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
|
||||||
</div>
|
</div>
|
||||||
<NodeLabel :node-id="props.id" :name="converter.name" />
|
<NodeLabel :node-id="props.id" :name="converter.name" />
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { computed } from "vue"
|
|||||||
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
|
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
|
||||||
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
|
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
|
||||||
import type { FlowNode } from "@/model/types"
|
import type { FlowNode } from "@/model/types"
|
||||||
|
import { useSimulationStore } from "@/store/simulation"
|
||||||
|
import { formatValue } from "./format"
|
||||||
import NodeLabel from "./NodeLabel.vue"
|
import NodeLabel from "./NodeLabel.vue"
|
||||||
|
|
||||||
const props = defineProps<NodeProps<NodeData>>()
|
const props = defineProps<NodeProps<NodeData>>()
|
||||||
@@ -18,6 +20,9 @@ const props = defineProps<NodeProps<NodeData>>()
|
|||||||
// The projection guarantees a flow-typed node here.
|
// The projection guarantees a flow-typed node here.
|
||||||
const flow = computed(() => props.data.node as FlowNode)
|
const flow = computed(() => props.data.node as FlowNode)
|
||||||
const loopRing = useNodeLoopRing(props.id)
|
const loopRing = useNodeLoopRing(props.id)
|
||||||
|
|
||||||
|
const sim = useSimulationStore()
|
||||||
|
const value = computed(() => sim.valueAt(props.id))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -40,5 +45,8 @@ const loopRing = useNodeLoopRing(props.id)
|
|||||||
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
|
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
|
||||||
</div>
|
</div>
|
||||||
<NodeLabel :node-id="props.id" :name="flow.name" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { computed } from "vue"
|
|||||||
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
|
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
|
||||||
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
|
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
|
||||||
import type { StockNode } from "@/model/types"
|
import type { StockNode } from "@/model/types"
|
||||||
|
import { useSimulationStore } from "@/store/simulation"
|
||||||
|
import { formatValue } from "./format"
|
||||||
import NodeLabel from "./NodeLabel.vue"
|
import NodeLabel from "./NodeLabel.vue"
|
||||||
|
|
||||||
const props = defineProps<NodeProps<NodeData>>()
|
const props = defineProps<NodeProps<NodeData>>()
|
||||||
@@ -16,15 +18,43 @@ const props = defineProps<NodeProps<NodeData>>()
|
|||||||
// The projection guarantees a stock-typed node here.
|
// The projection guarantees a stock-typed node here.
|
||||||
const stock = computed(() => props.data.node as StockNode)
|
const stock = computed(() => props.data.node as StockNode)
|
||||||
const loopRing = useNodeLoopRing(props.id)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<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]"
|
: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" />
|
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
|
||||||
|
<div class="relative z-10">
|
||||||
<NodeLabel :node-id="props.id" :name="stock.name" />
|
<NodeLabel :node-id="props.id" :name="stock.name" />
|
||||||
|
</div>
|
||||||
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
|
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
|
||||||
|
<!-- Live value sits *below* the box as an out-of-flow overlay, so the box keeps
|
||||||
|
its exact size and the handles — with the pipes and links on them — never
|
||||||
|
shift when a run is engaged. -->
|
||||||
|
<div
|
||||||
|
v-if="value !== null"
|
||||||
|
class="pointer-events-none absolute inset-x-0 top-full mt-1 whitespace-nowrap text-center font-mono text-xs tabular-nums text-base-content/70"
|
||||||
|
>
|
||||||
|
{{ formatValue(value)
|
||||||
|
}}<span v-if="stock.unit" class="ml-0.5 text-base-content/50">{{ stock.unit }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
83
src/composables/useAutosave.ts
Normal file
83
src/composables/useAutosave.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Autosave (F7) — the binding between the Model store (C5) and the repository
|
||||||
|
* (C9). It restores the last document on mount, then persists every change with
|
||||||
|
* a short debounce so a burst of edits costs one write, well inside the ≤500 ms
|
||||||
|
* budget. The debounce window is the only data-loss risk, so pending edits are
|
||||||
|
* also flushed when the tab is hidden or closed.
|
||||||
|
*
|
||||||
|
* It lives in a composable, not the store: persistence needs component lifecycle
|
||||||
|
* (mount/unmount) and browser globals, which keep the store pure and testable.
|
||||||
|
* The repository is injected (defaulting to IndexedDB) so it stays swappable.
|
||||||
|
*/
|
||||||
|
import { nextTick, onBeforeUnmount, onMounted, watch } from "vue"
|
||||||
|
import { createRepository, type ModelRepository } from "@/model/repository"
|
||||||
|
import { useModelStore } from "@/store/model"
|
||||||
|
|
||||||
|
/** Coalesce a burst of edits into one write; comfortably under the 500 ms budget. */
|
||||||
|
const DEBOUNCE_MS = 400
|
||||||
|
|
||||||
|
interface AutosaveOptions {
|
||||||
|
repository?: ModelRepository
|
||||||
|
/** Called once after a saved Model is restored (e.g. to fit the view). */
|
||||||
|
onRestore?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutosave(options: AutosaveOptions = {}): { flush: () => void } {
|
||||||
|
const repository = options.repository ?? createRepository()
|
||||||
|
const store = useModelStore()
|
||||||
|
// Armed only after the initial load, so restoring a document doesn't echo back
|
||||||
|
// a redundant save of what we just read.
|
||||||
|
let ready = false
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
async function save(): Promise<void> {
|
||||||
|
if (timer !== null) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
if (!ready) return
|
||||||
|
// Plain, proxy-free clone: idb structured-clones on put and would choke on
|
||||||
|
// the Vue reactive proxy (the same reason undo snapshots use JSON).
|
||||||
|
await repository.save(JSON.parse(JSON.stringify(store.model)) as typeof store.model)
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule(): void {
|
||||||
|
if (timer !== null) clearTimeout(timer)
|
||||||
|
timer = setTimeout(() => void save(), DEBOUNCE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function flush(): void {
|
||||||
|
if (timer !== null) void save()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVisibilityChange(): void {
|
||||||
|
if (document.visibilityState === "hidden") flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => store.model,
|
||||||
|
() => {
|
||||||
|
if (ready) schedule()
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const saved = await repository.load()
|
||||||
|
if (saved) store.setModel(saved)
|
||||||
|
// Let the watch tick from setModel pass before arming, so it isn't re-saved.
|
||||||
|
await nextTick()
|
||||||
|
if (saved) options.onRestore?.()
|
||||||
|
ready = true
|
||||||
|
document.addEventListener("visibilitychange", onVisibilityChange)
|
||||||
|
window.addEventListener("beforeunload", flush)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener("visibilitychange", onVisibilityChange)
|
||||||
|
window.removeEventListener("beforeunload", flush)
|
||||||
|
flush()
|
||||||
|
})
|
||||||
|
|
||||||
|
return { flush }
|
||||||
|
}
|
||||||
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))
|
||||||
|
}
|
||||||
48
src/model/glossary.ts
Normal file
48
src/model/glossary.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* UI glossary (F11, G4) — one-line definitions of the language, surfaced where a
|
||||||
|
* newcomer meets each term: as palette tooltips before placing, and in the
|
||||||
|
* gloss-on-selection panel after.
|
||||||
|
*
|
||||||
|
* CONTEXT.md is the authority for the vocabulary; these are deliberately terser
|
||||||
|
* (a tooltip can't hold the full entry with its `Avoid:` clauses). Keep them in
|
||||||
|
* sync with CONTEXT.md by hand — there's no test runner yet to assert it; when
|
||||||
|
* one lands, a check that every `NodeKind` has an entry and each `term` matches a
|
||||||
|
* CONTEXT.md heading would close the drift gap.
|
||||||
|
*/
|
||||||
|
import type { NodeKind } from "./types"
|
||||||
|
|
||||||
|
export interface GlossEntry {
|
||||||
|
/** The canonical term, verbatim from CONTEXT.md. */
|
||||||
|
term: string
|
||||||
|
/** A one-sentence gloss, distilled from the CONTEXT.md definition. */
|
||||||
|
short: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NODE_GLOSSARY: Record<NodeKind, GlossEntry> = {
|
||||||
|
stock: {
|
||||||
|
term: "Stock",
|
||||||
|
short:
|
||||||
|
"An accumulation that holds a quantity over time — the system's memory. Only Flows can change it.",
|
||||||
|
},
|
||||||
|
flow: {
|
||||||
|
term: "Flow",
|
||||||
|
short:
|
||||||
|
"A rate that moves quantity into or out of a Stock — the only element that can change one.",
|
||||||
|
},
|
||||||
|
converter: {
|
||||||
|
term: "Converter",
|
||||||
|
short:
|
||||||
|
"A stateless helper value, recomputed each instant from its inputs (or a fixed constant).",
|
||||||
|
},
|
||||||
|
cloud: {
|
||||||
|
term: "Source / Sink",
|
||||||
|
short:
|
||||||
|
"The model boundary on an open Flow end — where quantity comes from (Source) or goes (Sink).",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INFO_LINK_GLOSS: GlossEntry = {
|
||||||
|
term: "Information Link",
|
||||||
|
short:
|
||||||
|
"Carries one element's value to a Flow or Converter — influence only, never quantity; its sign is its Polarity.",
|
||||||
|
}
|
||||||
195
src/model/io.ts
Normal file
195
src/model/io.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* Import/Export (C10, F8) — round-trip a Model as versioned JSON.
|
||||||
|
*
|
||||||
|
* Export is just pretty JSON: the Model is contractually plain, serialisable
|
||||||
|
* data (see types.ts), so `serializeModel` is lossless and human-readable.
|
||||||
|
* Import is the trust boundary — a file may be hand-edited, truncated, or from a
|
||||||
|
* future version — so `parseModel` validates shape, types, the schema version,
|
||||||
|
* and referential integrity before handing back a typed Model. It never throws;
|
||||||
|
* it returns a discriminated result so callers can surface a clear message.
|
||||||
|
*
|
||||||
|
* The validator deliberately does not re-implement the full structural guard
|
||||||
|
* (validation.ts): our own exports are valid by construction, and checking that
|
||||||
|
* every reference resolves is enough to keep the projection and loop detector
|
||||||
|
* from tripping over a dangling id.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
type InformationLink,
|
||||||
|
type Model,
|
||||||
|
MODEL_VERSION,
|
||||||
|
type ModelNode,
|
||||||
|
type NodeKind,
|
||||||
|
type Polarity,
|
||||||
|
type Position,
|
||||||
|
type Rule,
|
||||||
|
type SimSpec,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
/** Pretty JSON so an exported Model is human-readable and diff-friendly (F8). */
|
||||||
|
export function serializeModel(model: Model): string {
|
||||||
|
return JSON.stringify(model, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The outcome of an import: a validated Model, or a reason it was rejected. */
|
||||||
|
export type ParseResult = { ok: true; model: Model } | { ok: false; error: string }
|
||||||
|
|
||||||
|
const NODE_KINDS: readonly NodeKind[] = ["stock", "flow", "converter", "cloud"]
|
||||||
|
|
||||||
|
function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFiniteNumber(value: unknown): value is number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPosition(value: unknown): value is Position {
|
||||||
|
return isObject(value) && isFiniteNumber(value.x) && isFiniteNumber(value.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPolarity(value: unknown): value is Polarity {
|
||||||
|
return value === "+" || value === "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Rule must be one of the fixed kinds with its numeric parameter (ADR-0004). */
|
||||||
|
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" || value.kind === "overflow")
|
||||||
|
return isFiniteNumber(value.factor)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSimSpec(value: unknown): value is SimSpec {
|
||||||
|
return (
|
||||||
|
isObject(value) &&
|
||||||
|
isFiniteNumber(value.start) &&
|
||||||
|
isFiniteNumber(value.stop) &&
|
||||||
|
isFiniteNumber(value.dt) &&
|
||||||
|
value.dt > 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate one node by kind, returning an error string or null when valid. */
|
||||||
|
function nodeError(value: unknown, index: number): string | null {
|
||||||
|
if (!isObject(value)) return `nodes[${index}] is not an object`
|
||||||
|
const at = `nodes[${index}]`
|
||||||
|
if (typeof value.id !== "string" || value.id === "") return `${at}.id must be a non-empty string`
|
||||||
|
if (typeof value.kind !== "string" || !NODE_KINDS.includes(value.kind as NodeKind)) {
|
||||||
|
return `${at}.kind must be one of ${NODE_KINDS.join(", ")}`
|
||||||
|
}
|
||||||
|
if (!isPosition(value.position)) return `${at}.position must be {x, y} numbers`
|
||||||
|
|
||||||
|
const kind = value.kind as NodeKind
|
||||||
|
if (kind !== "cloud" && typeof value.name !== "string") return `${at}.name must be a string`
|
||||||
|
if (kind === "flow") {
|
||||||
|
if (typeof value.source !== "string") return `${at}.source must be a string`
|
||||||
|
if (typeof value.target !== "string") return `${at}.target must be a string`
|
||||||
|
}
|
||||||
|
// Simulation fields (ADR-0004) are optional, but if present must be well-formed.
|
||||||
|
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 &&
|
||||||
|
!isRule(value.rule)
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate one Information Link, returning an error string or null when valid. */
|
||||||
|
function linkError(value: unknown, index: number): string | null {
|
||||||
|
if (!isObject(value)) return `infoLinks[${index}] is not an object`
|
||||||
|
const at = `infoLinks[${index}]`
|
||||||
|
if (typeof value.id !== "string" || value.id === "") return `${at}.id must be a non-empty string`
|
||||||
|
if (typeof value.source !== "string") return `${at}.source must be a string`
|
||||||
|
if (typeof value.target !== "string") return `${at}.target must be a string`
|
||||||
|
if (!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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and validate JSON text into a Model. Returns `{ok:false, error}` for
|
||||||
|
* anything that isn't a well-formed, current-version Model with resolvable
|
||||||
|
* references — never throws.
|
||||||
|
*/
|
||||||
|
export function parseModel(text: string): ParseResult {
|
||||||
|
let data: unknown
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Not valid JSON" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isObject(data)) return { ok: false, error: "Top level must be an object" }
|
||||||
|
if (data.version !== MODEL_VERSION) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Unsupported model version ${String(data.version)} (expected ${MODEL_VERSION})`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof data.id !== "string" || data.id === "")
|
||||||
|
return { ok: false, error: "id must be a non-empty string" }
|
||||||
|
if (typeof data.name !== "string") return { ok: false, error: "name must be a string" }
|
||||||
|
if (!Array.isArray(data.nodes)) return { ok: false, error: "nodes must be an array" }
|
||||||
|
if (!Array.isArray(data.infoLinks)) return { ok: false, error: "infoLinks must be an array" }
|
||||||
|
if (data.sim !== undefined && !isSimSpec(data.sim)) {
|
||||||
|
return { ok: false, error: "sim must be {start, stop, dt} numbers with dt > 0" }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < data.nodes.length; i++) {
|
||||||
|
const error = nodeError(data.nodes[i], i)
|
||||||
|
if (error) return { ok: false, error }
|
||||||
|
}
|
||||||
|
for (let i = 0; i < data.infoLinks.length; i++) {
|
||||||
|
const error = linkError(data.infoLinks[i], i)
|
||||||
|
if (error) return { ok: false, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
// References must resolve, or the projection and loop detector would dangle.
|
||||||
|
const nodes = data.nodes as ModelNode[]
|
||||||
|
const ids = new Set(nodes.map((node) => node.id))
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.kind !== "flow") continue
|
||||||
|
if (!ids.has(node.source))
|
||||||
|
return { ok: false, error: `flow ${node.id} references missing source ${node.source}` }
|
||||||
|
if (!ids.has(node.target))
|
||||||
|
return { ok: false, error: `flow ${node.id} references missing target ${node.target}` }
|
||||||
|
}
|
||||||
|
const links = data.infoLinks as InformationLink[]
|
||||||
|
for (const link of links) {
|
||||||
|
if (!ids.has(link.source))
|
||||||
|
return { ok: false, error: `link ${link.id} references missing source ${link.source}` }
|
||||||
|
if (!ids.has(link.target))
|
||||||
|
return { ok: false, error: `link ${link.id} references missing target ${link.target}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
model: {
|
||||||
|
version: MODEL_VERSION,
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
nodes,
|
||||||
|
infoLinks: links,
|
||||||
|
...(data.sim !== undefined && { sim: data.sim as SimSpec }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,9 +11,18 @@
|
|||||||
* Flow's references (ADR-0003); each Information Link contributes one "info"
|
* Flow's references (ADR-0003); each Information Link contributes one "info"
|
||||||
* edge carrying its polarity.
|
* edge carrying its polarity.
|
||||||
*/
|
*/
|
||||||
import type { Edge, Node } from "@vue-flow/core"
|
import { type Edge, type EdgeMarker, MarkerType, type Node } from "@vue-flow/core"
|
||||||
import type { Model, ModelNode, Polarity } from "./types"
|
import type { Model, ModelNode, Polarity } from "./types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open arrowhead drawn at the same stroke weight as the line it ends. Vue Flow
|
||||||
|
* renders the marker with markerUnits="strokeWidth" (so the head already scales
|
||||||
|
* with the edge) but downscales its 20-unit viewBox into a 12.5 marker box,
|
||||||
|
* thinning the chevron to 0.625× the line. strokeWidth 20/12.5 = 1.6 cancels
|
||||||
|
* that, leaving the head exactly as heavy as its line at any width.
|
||||||
|
*/
|
||||||
|
const ARROWHEAD: EdgeMarker = { type: MarkerType.Arrow, strokeWidth: 1.6 }
|
||||||
|
|
||||||
/** Payload carried on every projected Vue Flow node: the domain node itself. */
|
/** Payload carried on every projected Vue Flow node: the domain node itself. */
|
||||||
export interface NodeData {
|
export interface NodeData {
|
||||||
node: ModelNode
|
node: ModelNode
|
||||||
@@ -54,6 +63,7 @@ export function projectEdges(model: Model): FlowGraphEdge[] {
|
|||||||
// Pipe in: source → valve. No arrowhead — the valve is the visual midpoint.
|
// Pipe in: source → valve. No arrowhead — the valve is the visual midpoint.
|
||||||
edges.push({
|
edges.push({
|
||||||
id: `${node.id}::in`,
|
id: `${node.id}::in`,
|
||||||
|
type: "pipe",
|
||||||
source: node.source,
|
source: node.source,
|
||||||
sourceHandle: HANDLE_OUT,
|
sourceHandle: HANDLE_OUT,
|
||||||
target: node.id,
|
target: node.id,
|
||||||
@@ -64,13 +74,14 @@ export function projectEdges(model: Model): FlowGraphEdge[] {
|
|||||||
// Pipe out: valve → target. Arrowhead carries the flow direction.
|
// Pipe out: valve → target. Arrowhead carries the flow direction.
|
||||||
edges.push({
|
edges.push({
|
||||||
id: `${node.id}::out`,
|
id: `${node.id}::out`,
|
||||||
|
type: "pipe",
|
||||||
source: node.id,
|
source: node.id,
|
||||||
sourceHandle: HANDLE_OUT,
|
sourceHandle: HANDLE_OUT,
|
||||||
target: node.target,
|
target: node.target,
|
||||||
targetHandle: HANDLE_IN,
|
targetHandle: HANDLE_IN,
|
||||||
data: { kind: "pipe" },
|
data: { kind: "pipe" },
|
||||||
style: { strokeWidth: "2.5px" },
|
style: { strokeWidth: "2.5px" },
|
||||||
markerEnd: "arrowclosed",
|
markerEnd: ARROWHEAD,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +97,7 @@ export function projectEdges(model: Model): FlowGraphEdge[] {
|
|||||||
targetHandle: HANDLE_IN,
|
targetHandle: HANDLE_IN,
|
||||||
data: { kind: "info", polarity: link.polarity },
|
data: { kind: "info", polarity: link.polarity },
|
||||||
style: { strokeDasharray: "5 4" },
|
style: { strokeDasharray: "5 4" },
|
||||||
markerEnd: "arrowclosed",
|
markerEnd: ARROWHEAD,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
48
src/model/repository.ts
Normal file
48
src/model/repository.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Model repository (C9, F7) — where the working document is persisted locally.
|
||||||
|
*
|
||||||
|
* IndexedDB (via `idb`) sits behind a tiny `ModelRepository` interface so the
|
||||||
|
* storage engine is no longer a hard-to-reverse choice (T4): a future sync
|
||||||
|
* backend (PouchDB, an API) is a new implementation behind the same two methods,
|
||||||
|
* with no caller change. Phase 1 is single-document, so the current Model is
|
||||||
|
* stored under one fixed key; a model list (C12) would key by `model.id` instead.
|
||||||
|
*/
|
||||||
|
import { type IDBPDatabase, openDB } from "idb"
|
||||||
|
import type { Model } from "./types"
|
||||||
|
|
||||||
|
export interface ModelRepository {
|
||||||
|
/** Persist the current Model, overwriting the previous autosave. */
|
||||||
|
save(model: Model): Promise<void>
|
||||||
|
/** Load the autosaved Model, or null if nothing has been saved yet. */
|
||||||
|
load(): Promise<Model | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
const DB_NAME = "meadows"
|
||||||
|
const DB_VERSION = 1
|
||||||
|
const STORE = "models"
|
||||||
|
const CURRENT_KEY = "current"
|
||||||
|
|
||||||
|
let dbPromise: Promise<IDBPDatabase> | null = null
|
||||||
|
|
||||||
|
function db(): Promise<IDBPDatabase> {
|
||||||
|
// Memoise the open so we pay the handshake once, not per save.
|
||||||
|
dbPromise ??= openDB(DB_NAME, DB_VERSION, {
|
||||||
|
upgrade(database) {
|
||||||
|
if (!database.objectStoreNames.contains(STORE)) database.createObjectStore(STORE)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return dbPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The default IndexedDB-backed repository used by the app. */
|
||||||
|
export function createRepository(): ModelRepository {
|
||||||
|
return {
|
||||||
|
async save(model: Model): Promise<void> {
|
||||||
|
await (await db()).put(STORE, model, CURRENT_KEY)
|
||||||
|
},
|
||||||
|
async load(): Promise<Model | null> {
|
||||||
|
const model = await (await db()).get(STORE, CURRENT_KEY)
|
||||||
|
return (model as Model | undefined) ?? null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
2013
src/model/samples.ts
2013
src/model/samples.ts
File diff suppressed because it is too large
Load Diff
241
src/model/simulation.ts
Normal file
241
src/model/simulation.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* Simulation engine (ADR-0004) — what makes a Model *alive*. Pure data in, time
|
||||||
|
* series out; it knows nothing of Vue or the canvas, so it is trivially testable
|
||||||
|
* and runs off any reactive frame.
|
||||||
|
*
|
||||||
|
* The system-dynamics loop, one step of `dt`:
|
||||||
|
* 1. Evaluate the instantaneous network — every Converter and Flow — in
|
||||||
|
* dependency order, reading the *current* Stock values. A Converter/Flow
|
||||||
|
* depends on the elements that link into it (ADR-0004: the Information Link
|
||||||
|
* *is* the dependency); Stocks are state, available without ordering.
|
||||||
|
* 2. Integrate every Stock *simultaneously* (forward Euler):
|
||||||
|
* `stock += dt × (Σ inflow rates − Σ outflow rates)`. All net rates are read
|
||||||
|
* from the same pre-update state, so updating one Stock never feeds another
|
||||||
|
* within the same step.
|
||||||
|
*
|
||||||
|
* A cycle in the wiring is legitimate feedback only if it passes through a Stock
|
||||||
|
* (the Stock supplies last-step state and breaks the within-step dependency). A
|
||||||
|
* cycle among only Converters/Flows is an **algebraic loop** — unorderable, and
|
||||||
|
* rejected by `evaluationOrder` / surfaced by `checkSimReady`.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
type ConverterNode,
|
||||||
|
DEFAULT_SIM_SPEC,
|
||||||
|
type FlowNode,
|
||||||
|
type InformationLink,
|
||||||
|
type Model,
|
||||||
|
type Rule,
|
||||||
|
type SimSpec,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
/** Thrown when a Model cannot be simulated as wired (e.g. an algebraic loop). */
|
||||||
|
export class SimulationError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = "SimulationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A completed run: aligned `times` and per-element value tracks. */
|
||||||
|
export interface Run {
|
||||||
|
/** The time at each recorded sample, `start … stop` in steps of `dt`. */
|
||||||
|
times: number[]
|
||||||
|
/** nodeId → its value at each time index (Stocks, Flows, Converters; not Clouds). */
|
||||||
|
series: Map<string, number[]>
|
||||||
|
/** True if the run ran past representable numbers and stopped early (see below). */
|
||||||
|
diverged: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Converter or Flow — the stateless, instantaneous elements an order applies to. */
|
||||||
|
type Instant = ConverterNode | FlowNode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order the instantaneous network so each element is computed after everything
|
||||||
|
* it reads. Throws `SimulationError` on an algebraic loop (a Converter/Flow cycle
|
||||||
|
* with no Stock to break it). Stocks are excluded — they are state, not computed.
|
||||||
|
*/
|
||||||
|
export function evaluationOrder(model: Model): Instant[] {
|
||||||
|
const instant = model.nodes.filter(
|
||||||
|
(node): node is Instant => node.kind === "flow" || node.kind === "converter",
|
||||||
|
)
|
||||||
|
const ids = new Set(instant.map((node) => node.id))
|
||||||
|
|
||||||
|
// deps[x] = the instantaneous elements x reads (links from another Flow/Converter).
|
||||||
|
const deps = new Map<string, Set<string>>(instant.map((node) => [node.id, new Set<string>()]))
|
||||||
|
for (const link of model.infoLinks) {
|
||||||
|
if (ids.has(link.source) && ids.has(link.target)) deps.get(link.target)?.add(link.source)
|
||||||
|
}
|
||||||
|
|
||||||
|
const order: Instant[] = []
|
||||||
|
const resolved = new Set<string>()
|
||||||
|
while (order.length < instant.length) {
|
||||||
|
const next = instant.find(
|
||||||
|
(node) =>
|
||||||
|
!resolved.has(node.id) && [...(deps.get(node.id) ?? [])].every((d) => resolved.has(d)),
|
||||||
|
)
|
||||||
|
if (!next) {
|
||||||
|
const names = instant
|
||||||
|
.filter((node) => !resolved.has(node.id))
|
||||||
|
.map((node) => node.name)
|
||||||
|
.join(", ")
|
||||||
|
throw new SimulationError(
|
||||||
|
`Algebraic loop: ${names} depend on each other with no Stock to break the cycle.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
resolved.add(next.id)
|
||||||
|
order.push(next)
|
||||||
|
}
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Evaluate one Rule given a way to read the values feeding in via `links`. */
|
||||||
|
function evalRule(rule: Rule, links: InformationLink[], valueOf: (id: string) => number): number {
|
||||||
|
switch (rule.kind) {
|
||||||
|
case "constant":
|
||||||
|
return rule.value
|
||||||
|
case "proportional": {
|
||||||
|
// factor × the product of every `+`-polarity input (one input → factor × it).
|
||||||
|
let value = rule.factor
|
||||||
|
for (const link of links) if (link.polarity === "+") value *= valueOf(link.source)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
case "gap": {
|
||||||
|
// factor × (level − target): the `+` input is the level, the `−` the target.
|
||||||
|
const level = links.find((link) => link.polarity === "+")
|
||||||
|
const target = links.find((link) => link.polarity === "-")
|
||||||
|
return (
|
||||||
|
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)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the Model and return aligned time series. Assumes a sim-ready Model
|
||||||
|
* (see `checkSimReady`); a missing rule evaluates to 0 and a missing initial
|
||||||
|
* value to 0 rather than throwing, so a half-built Model still produces a plot.
|
||||||
|
* Throws `SimulationError` only on an algebraic loop, which has no defined order.
|
||||||
|
*/
|
||||||
|
export function simulate(model: Model, spec: SimSpec = model.sim ?? DEFAULT_SIM_SPEC): Run {
|
||||||
|
const order = evaluationOrder(model)
|
||||||
|
const nodeById = new Map(model.nodes.map((node) => [node.id, node]))
|
||||||
|
|
||||||
|
const inbound = new Map<string, InformationLink[]>()
|
||||||
|
for (const link of model.infoLinks) {
|
||||||
|
const list = inbound.get(link.target)
|
||||||
|
if (list) list.push(link)
|
||||||
|
else inbound.set(link.target, [link])
|
||||||
|
}
|
||||||
|
|
||||||
|
const stocks = model.nodes.filter((node) => node.kind === "stock")
|
||||||
|
const flows = model.nodes.filter((node): node is FlowNode => node.kind === "flow")
|
||||||
|
|
||||||
|
const stockValues = new Map<string, number>(stocks.map((s) => [s.id, s.initialValue ?? 0]))
|
||||||
|
|
||||||
|
const times: number[] = []
|
||||||
|
const series = new Map<string, number[]>()
|
||||||
|
for (const node of model.nodes) if (node.kind !== "cloud") series.set(node.id, [])
|
||||||
|
let diverged = false
|
||||||
|
|
||||||
|
// dt ≤ 0 would never advance (and 0 diverges); a non-positive step means no run.
|
||||||
|
const steps = spec.dt > 0 ? Math.max(0, Math.floor((spec.stop - spec.start) / spec.dt)) : 0
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
// A run-away Reinforcing loop over a long horizon can exceed what a float
|
||||||
|
// holds. Stop at the last valid sample and flag it rather than plotting NaN.
|
||||||
|
if (!stocks.every((s) => Number.isFinite(stockValues.get(s.id) ?? 0))) {
|
||||||
|
diverged = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Evaluate the instantaneous network from the current Stock values.
|
||||||
|
const computed = new Map<string, number>()
|
||||||
|
const valueOf = (id: string): number => {
|
||||||
|
const node = nodeById.get(id)
|
||||||
|
if (!node) return 0
|
||||||
|
if (node.kind === "stock") return stockValues.get(id) ?? 0
|
||||||
|
if (node.kind === "cloud") return 0
|
||||||
|
return computed.get(id) ?? 0
|
||||||
|
}
|
||||||
|
for (const node of order) {
|
||||||
|
computed.set(
|
||||||
|
node.id,
|
||||||
|
node.rule ? evalRule(node.rule, inbound.get(node.id) ?? [], valueOf) : 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record this sample (Stocks at their current value, Flows/Converters as just computed).
|
||||||
|
times.push(spec.start + i * spec.dt)
|
||||||
|
for (const s of stocks) series.get(s.id)?.push(stockValues.get(s.id) ?? 0)
|
||||||
|
for (const node of order) series.get(node.id)?.push(computed.get(node.id) ?? 0)
|
||||||
|
|
||||||
|
if (i >= steps) break
|
||||||
|
|
||||||
|
// 2. Integrate every Stock simultaneously (forward Euler) — but with
|
||||||
|
// non-negative stocks: an outflow can't drain more than its source holds this
|
||||||
|
// step. Scale a stock's competing outflows together if they would overdraw,
|
||||||
|
// and apply the scaled rate to both ends so quantity is conserved. You can't
|
||||||
|
// infect more people than are susceptible — and that floor is exactly what
|
||||||
|
// stops a bilinear model (S × I) from flipping sign and diverging.
|
||||||
|
const rate = new Map<string, number>(flows.map((f) => [f.id, computed.get(f.id) ?? 0]))
|
||||||
|
for (const s of stocks) {
|
||||||
|
const available = stockValues.get(s.id) ?? 0
|
||||||
|
const drains = flows.filter((f) => f.source === s.id && (rate.get(f.id) ?? 0) > 0)
|
||||||
|
const totalOut = drains.reduce((sum, f) => sum + (rate.get(f.id) ?? 0), 0)
|
||||||
|
if (totalOut * spec.dt > available) {
|
||||||
|
const scale = available / (totalOut * spec.dt)
|
||||||
|
for (const f of drains) rate.set(f.id, (rate.get(f.id) ?? 0) * scale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = new Map(stockValues)
|
||||||
|
for (const s of stocks) {
|
||||||
|
let net = 0
|
||||||
|
for (const flow of flows) {
|
||||||
|
const r = rate.get(flow.id) ?? 0
|
||||||
|
if (flow.target === s.id) net += r // an inflow fills it
|
||||||
|
if (flow.source === s.id) net -= r // an outflow drains it
|
||||||
|
}
|
||||||
|
next.set(s.id, (stockValues.get(s.id) ?? 0) + spec.dt * net)
|
||||||
|
}
|
||||||
|
for (const [id, value] of next) stockValues.set(id, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { times, series, diverged }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What stands between this Model and a run, as human-readable lines (empty array
|
||||||
|
* = ready). Distinct from structural validity (validation.ts): a Model can be a
|
||||||
|
* perfectly valid diagram yet not carry the numbers a simulation needs.
|
||||||
|
*/
|
||||||
|
export function checkSimReady(model: Model): string[] {
|
||||||
|
const problems: string[] = []
|
||||||
|
for (const node of model.nodes) {
|
||||||
|
if (node.kind === "stock" && node.initialValue === undefined) {
|
||||||
|
problems.push(`${node.name} has no initial value.`)
|
||||||
|
}
|
||||||
|
if ((node.kind === "flow" || node.kind === "converter") && !node.rule) {
|
||||||
|
problems.push(`${node.name} has no rule yet.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
evaluationOrder(model)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SimulationError) problems.push(error.message)
|
||||||
|
else throw error
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
@@ -31,6 +31,41 @@ export interface Position {
|
|||||||
y: number
|
y: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a Flow's rate or a Converter's value is computed — a small fixed
|
||||||
|
* vocabulary, *not* a free-form formula, so a Model stays valid by construction
|
||||||
|
* and teaches structure → behaviour (ADR-0004). Operands are read from the
|
||||||
|
* element's inbound Information Links; Polarity selects each operand's role.
|
||||||
|
*
|
||||||
|
* - `constant` — a fixed number; reads nothing. (→ linear Stock change)
|
||||||
|
* - `proportional` — `factor ×` the product of its `+`-polarity inputs.
|
||||||
|
* (→ 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
|
||||||
|
* of `dt` (forward Euler). Optional on a Model; absent means the diagram phase or
|
||||||
|
* a not-yet-simulated Model — the engine falls back to `DEFAULT_SIM_SPEC`.
|
||||||
|
*/
|
||||||
|
export interface SimSpec {
|
||||||
|
start: number
|
||||||
|
stop: number
|
||||||
|
dt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sensible default run when a Model carries no `sim` of its own. */
|
||||||
|
export const DEFAULT_SIM_SPEC: SimSpec = { start: 0, stop: 100, dt: 1 }
|
||||||
|
|
||||||
interface BaseNode {
|
interface BaseNode {
|
||||||
id: string
|
id: string
|
||||||
position: Position
|
position: Position
|
||||||
@@ -45,6 +80,10 @@ export interface StockNode extends BaseNode {
|
|||||||
name: string
|
name: string
|
||||||
/** Initial accumulated quantity. Optional in the diagram phase; the simulator reads it. */
|
/** Initial accumulated quantity. Optional in the diagram phase; the simulator reads it. */
|
||||||
initialValue?: number
|
initialValue?: number
|
||||||
|
/** Unit of the quantity, e.g. "°C", "people", "$" — shown beside the value (display only). */
|
||||||
|
unit?: string
|
||||||
|
/** Why this Stock exists in *this* model — a free-text note surfaced on selection (display only). */
|
||||||
|
description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,8 +99,10 @@ export interface FlowNode extends BaseNode {
|
|||||||
source: string
|
source: string
|
||||||
/** Node id of the Stock or Cloud the Flow feeds into. */
|
/** Node id of the Stock or Cloud the Flow feeds into. */
|
||||||
target: string
|
target: string
|
||||||
/** Rate expression, recomputed each instant. Optional in the diagram phase. */
|
/** How its rate is computed each instant (ADR-0004). Optional in the diagram phase. */
|
||||||
equation?: string
|
rule?: Rule
|
||||||
|
/** Why this Flow exists in *this* model — a free-text note surfaced on selection (display only). */
|
||||||
|
description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,8 +112,10 @@ export interface FlowNode extends BaseNode {
|
|||||||
export interface ConverterNode extends BaseNode {
|
export interface ConverterNode extends BaseNode {
|
||||||
kind: "converter"
|
kind: "converter"
|
||||||
name: string
|
name: string
|
||||||
/** Expression or constant. Optional in the diagram phase. */
|
/** How its value is computed each instant (ADR-0004). Optional in the diagram phase. */
|
||||||
equation?: string
|
rule?: Rule
|
||||||
|
/** Why this Converter exists in *this* model — a free-text note surfaced on selection (display only). */
|
||||||
|
description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,6 +141,8 @@ export interface InformationLink {
|
|||||||
/** Target node id — a Flow or Converter, never a Stock (ADR-0001). */
|
/** Target node id — a Flow or Converter, never a Stock (ADR-0001). */
|
||||||
target: string
|
target: string
|
||||||
polarity: Polarity
|
polarity: Polarity
|
||||||
|
/** Why this link exists in *this* model — a free-text note surfaced on selection (display only). */
|
||||||
|
description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One saved document: the unit of save / export / reopen. */
|
/** One saved document: the unit of save / export / reopen. */
|
||||||
@@ -107,4 +152,6 @@ export interface Model {
|
|||||||
name: string
|
name: string
|
||||||
nodes: ModelNode[]
|
nodes: ModelNode[]
|
||||||
infoLinks: InformationLink[]
|
infoLinks: InformationLink[]
|
||||||
|
/** Run parameters for simulation (ADR-0004). Optional; absent → not yet simulated. */
|
||||||
|
sim?: SimSpec
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,15 @@ import {
|
|||||||
nextName,
|
nextName,
|
||||||
} from "@/model/factory"
|
} from "@/model/factory"
|
||||||
import { detectLoops } from "@/model/loops"
|
import { detectLoops } from "@/model/loops"
|
||||||
import type { ConverterNode, Model, ModelNode, Position, StockNode } from "@/model/types"
|
import type {
|
||||||
|
ConverterNode,
|
||||||
|
Model,
|
||||||
|
ModelNode,
|
||||||
|
Position,
|
||||||
|
Rule,
|
||||||
|
SimSpec,
|
||||||
|
StockNode,
|
||||||
|
} from "@/model/types"
|
||||||
import { canConnect, intentFor } from "@/model/validation"
|
import { canConnect, intentFor } from "@/model/validation"
|
||||||
|
|
||||||
/** Ring-buffer depth for undo (F9 target: ≥50 steps). */
|
/** Ring-buffer depth for undo (F9 target: ≥50 steps). */
|
||||||
@@ -96,6 +104,80 @@ export const useModelStore = defineStore("model", () => {
|
|||||||
node.name = name
|
node.name = name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set (or clear) a Stock's initial value — the quantity the simulator starts
|
||||||
|
* from (ADR-0004). `undefined` un-equips it, sending the Model back to "not yet
|
||||||
|
* simulatable". No-op when unchanged, so a blur with no edit doesn't burn undo.
|
||||||
|
*/
|
||||||
|
function setInitialValue(id: string, value: number | undefined): void {
|
||||||
|
const node = findNode(id)
|
||||||
|
if (!node || node.kind !== "stock" || node.initialValue === value) return
|
||||||
|
record()
|
||||||
|
if (value === undefined) delete node.initialValue
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
function setRule(id: string, rule: Rule | undefined): void {
|
||||||
|
const node = findNode(id)
|
||||||
|
if (!node || (node.kind !== "flow" && node.kind !== "converter")) return
|
||||||
|
if (JSON.stringify(node.rule) === JSON.stringify(rule)) return
|
||||||
|
record()
|
||||||
|
if (rule === undefined) delete node.rule
|
||||||
|
else node.rule = rule
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set the run parameters (start / stop / dt). No-op when unchanged. */
|
||||||
|
function setSimSpec(spec: SimSpec): void {
|
||||||
|
const current = model.value.sim
|
||||||
|
if (
|
||||||
|
current &&
|
||||||
|
current.start === spec.start &&
|
||||||
|
current.stop === spec.stop &&
|
||||||
|
current.dt === spec.dt
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
record()
|
||||||
|
model.value.sim = spec
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create whatever source→target means under the structure guard: an
|
* Create whatever source→target means under the structure guard: an
|
||||||
* Information Link (default `+` polarity, F6) onto a Flow/Converter, or a Flow
|
* Information Link (default `+` polarity, F6) onto a Flow/Converter, or a Flow
|
||||||
@@ -229,6 +311,11 @@ export const useModelStore = defineStore("model", () => {
|
|||||||
beginInteraction,
|
beginInteraction,
|
||||||
moveNode,
|
moveNode,
|
||||||
renameNode,
|
renameNode,
|
||||||
|
setInitialValue,
|
||||||
|
setUnit,
|
||||||
|
setDescription,
|
||||||
|
setRule,
|
||||||
|
setSimSpec,
|
||||||
removeNode,
|
removeNode,
|
||||||
removeInfoLink,
|
removeInfoLink,
|
||||||
toggleLinkPolarity,
|
toggleLinkPolarity,
|
||||||
|
|||||||
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
|
@import to precede all other rules. If it comes second, the production build
|
||||||
warns ("@import must precede all rules…") and browsers silently DROP the
|
warns ("@import must precede all rules…") and browsers silently DROP the
|
||||||
font import, so the custom font never loads. */
|
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";
|
@import "tailwindcss";
|
||||||
@plugin "daisyui";
|
@plugin "daisyui";
|
||||||
@plugin "daisyui/theme" {
|
@plugin "daisyui/theme" {
|
||||||
name: "light";
|
name: "light";
|
||||||
default: true;
|
default: true;
|
||||||
--color-primary: #16a34a;
|
--color-primary: #ff9ff3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DaisyUI's default (no data-theme) resolves to its *dark* theme via
|
||||||
|
prefers-color-scheme, with a selector (`:root:not([data-theme])`) that outranks
|
||||||
|
a custom light theme's `:where(:root)` — so overriding only the light theme
|
||||||
|
never reached the buttons. Force the brand primary across every theme. */
|
||||||
|
:root {
|
||||||
|
--color-primary: #ff9ff3 !important;
|
||||||
|
--color-primary-content: #3d1f3a !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--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
|
/* Larger, easier-to-grab connection handles (Vue Flow defaults to 6px). The
|
||||||
|
|||||||
Reference in New Issue
Block a user