Compare commits
11 Commits
aadc157144
...
37017c1175
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37017c1175 | ||
|
|
d3119e8a20 | ||
|
|
03848ae893 | ||
|
|
0416581799 | ||
|
|
0aaa4114d9 | ||
|
|
6d03d706a0 | ||
|
|
5c3f467a24 | ||
|
|
fd94a8b7ed | ||
|
|
8fac6f4694 | ||
|
|
95990b0d1f | ||
|
|
2c0d4b842f |
@@ -2,7 +2,7 @@
|
||||
|
||||
The ubiquitous language for **meadows** — a web app for building systems-thinking
|
||||
models (stock-and-flow diagrams with feedback loops) in the spirit of Donella
|
||||
Meadows' *Thinking in Systems*. The diagram is the product now; numeric simulation
|
||||
Meadows' _Thinking in Systems_. The diagram is the product now; numeric simulation
|
||||
is a planned later phase, so the language is chosen to be simulation-ready.
|
||||
|
||||
Part of [meadows](./README.md); see [DESIGN.md](./DESIGN.md) for how these terms
|
||||
@@ -12,7 +12,7 @@ map to goals, functions, and components.
|
||||
|
||||
**System**:
|
||||
The real-world thing being studied — an interconnected set of elements producing
|
||||
a behaviour over time. It is the *referent*, never the artifact in the app.
|
||||
a behaviour over time. It is the _referent_, never the artifact in the app.
|
||||
_Avoid_: model, diagram, map (those are the artifact, not the real thing).
|
||||
|
||||
**Model**:
|
||||
|
||||
191
DESIGN.md
191
DESIGN.md
@@ -256,10 +256,10 @@ symbols: ● strong (9) · ◐ medium (3) · △ weak (1). Roof: `++`/`+` reinfo
|
||||
}
|
||||
|
||||
\begin{document}
|
||||
% --- meadows: 3 goals (WHATs) x 9 functions (HOWs); no competitor zone;
|
||||
% --- meadows: 4 goals (WHATs) x 10 functions (HOWs); no competitor zone;
|
||||
% basement = Target / Importance Sigma / Relative %. ---
|
||||
\def\qfdNW{3}
|
||||
\def\qfdNH{9}
|
||||
\def\qfdNW{4}
|
||||
\def\qfdNH{10}
|
||||
\def\qfdWhatW{4.2}
|
||||
\def\qfdBasementN{3}
|
||||
\def\qfdWhatsTitle{Goals (WHATs)}
|
||||
@@ -270,23 +270,26 @@ symbols: ● strong (9) · ◐ medium (3) · △ weak (1). Roof: `++`/`+` reinfo
|
||||
\pgfmathsetmacro{\qfdWhatTextW}{\qfdWhatW - 0.2}
|
||||
\foreach \r/\t in {1/{G1 Build with near-zero friction},
|
||||
2/{G2 Feedback surfaces as live insight},
|
||||
3/{G3 Models safe \& portable}}
|
||||
3/{G3 Models safe \& portable},
|
||||
4/{G4 Learn the language by example}}
|
||||
\node[anchor=west, font=\scriptsize, text width=\qfdWhatTextW cm, align=left]
|
||||
at ({\qfdLeftEdge + 0.1}, {-\r + 0.5}) {\t};
|
||||
\foreach \r/\imp in {1/9, 2/8, 3/7}
|
||||
\foreach \r/\imp in {1/9, 2/8, 3/7, 4/4}
|
||||
\node[font=\scriptsize] at ({-\qfdImpW/2}, {-\r + 0.5}) {\imp};
|
||||
|
||||
% --- HOWs (functions, rotated) ---
|
||||
\foreach \c/\t in {1/{F1 place node}, 2/{F2 connect},
|
||||
3/{F3 smooth canvas}, 4/{F4 valid inline},
|
||||
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}}
|
||||
\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 ---
|
||||
\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,
|
||||
\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,
|
||||
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}
|
||||
\node[qfdrel/\s] at ({\c - 0.5}, {-\r + 0.5}) {};
|
||||
|
||||
% --- Roof correlations (C-i-j, i<j); from DESIGN.md §5 ---
|
||||
@@ -296,15 +299,16 @@ symbols: ● strong (9) · ◐ medium (3) · △ weak (1). Roof: `++`/`+` reinfo
|
||||
\node[font=\scriptsize] at (C-3-9) {$-$}; % F3 x F9 mild conflict
|
||||
\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-7-10) {$-$}; % F7 x F10 conflict (load clears history)
|
||||
|
||||
% --- Basement: Target / Importance Sigma / Relative %, + row labels ---
|
||||
\foreach \rr/\lbl in {0/{Target}, 1/{Imp.\ $\Sigma$}, 2/{Rel.\ \%}}
|
||||
\node[anchor=east, font=\scriptsize\itshape]
|
||||
at (-0.1, {-\qfdNW - 0.5 - \rr}) {\lbl};
|
||||
\foreach \c/\tgt/\sig/\rel in
|
||||
{1/{1 gest.}/81/11, 2/{1 drag}/105/14, 3/{60 fps}/89/12,
|
||||
4/{0 inv.}/58/8, 5/{$\le$16ms}/81/11, 6/{dflt $\pm$}/99/13,
|
||||
7/{$\le$500ms}/72/10, 8/{id.}/63/8, 9/{$\ge$50}/98/13} {
|
||||
{1/{1 gest.}/81/10, 2/{1 drag}/105/13, 3/{60 fps}/89/11,
|
||||
4/{0 inv.}/58/7, 5/{$\le$16ms}/93/11, 6/{dflt $\pm$}/103/13,
|
||||
7/{$\le$500ms}/72/9, 8/{id.}/63/8, 9/{$\ge$50}/98/12, 10/{1 load}/45/6} {
|
||||
\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\bfseries] at ({\c - 0.5}, {-\qfdNW - 2.5}) {\rel};
|
||||
@@ -317,98 +321,113 @@ symbols: ● strong (9) · ◐ medium (3) · △ weak (1). Roof: `++`/`+` reinfo
|
||||
|
||||
## 1. Goals — the WHATs
|
||||
|
||||
| ID | Goal | Weight | Source |
|
||||
|-----|-----------------------------------------------|:------:|---------------------------------|
|
||||
| G1 | Build a model with near-zero friction | 9 | original request; CONTEXT.md |
|
||||
| G2 | Feedback structure surfaces as insight (live) | 8 | original request; ADR-0001 |
|
||||
| G3 | Models are safe and portable | 7 | original request |
|
||||
| ID | Goal | Weight | Source |
|
||||
| --- | --------------------------------------------- | :----: | ---------------------------- |
|
||||
| G1 | Build a model with near-zero friction | 9 | original request; CONTEXT.md |
|
||||
| G2 | Feedback structure surfaces as insight (live) | 8 | original request; ADR-0001 |
|
||||
| G3 | Models are safe and portable | 7 | original request |
|
||||
| G4 | Learn the language by example | 4 | README (Meadows spirit) |
|
||||
|
||||
## 2. Functions — the HOWs
|
||||
|
||||
| ID | Function | Dir | Target (now) | Target (future) |
|
||||
|-----|----------------------------------------------------------------------|:---:|--------------------------------------|------------------------|
|
||||
| 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 | — |
|
||||
| F3 | Render & manipulate the canvas smoothly | ↑ | 60 fps pan/zoom/drag @ ≤150 elements | ≥300 elements |
|
||||
| F4 | Enforce valid structure inline | → | 0 invalid models persistable | — |
|
||||
| F5 | Detect & classify loops live (R/B) on every structural change | ↓ | ≤16 ms recompute @ ≤150 elements | ≤16 ms @ ≥300 |
|
||||
| F6 | Capture link polarity frictionlessly | ↓ | default on create, 1-click toggle | — |
|
||||
| F7 | Autosave locally, continuously | → | debounced ≤500 ms; survive reload | — |
|
||||
| F8 | Round-trip Models as versioned JSON | → | export→import identity; `version` | schema migrations |
|
||||
| F9 | Undo/redo every model change | ↑ | ≥50 steps, all structural ops | — |
|
||||
| ID | Function | Dir | Target (now) | Target (future) |
|
||||
| --- | ------------------------------------------------------------------- | :-: | ------------------------------------ | ----------------------- |
|
||||
| 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 | — |
|
||||
| F3 | Render & manipulate the canvas smoothly | ↑ | 60 fps pan/zoom/drag @ ≤150 elements | ≥300 elements |
|
||||
| F4 | Enforce valid structure inline | → | 0 invalid models persistable | — |
|
||||
| F5 | Detect & classify loops live (R/B) on every structural change | ↓ | ≤16 ms recompute @ ≤150 elements | ≤16 ms @ ≥300 |
|
||||
| F6 | Capture link polarity frictionlessly | ↓ | default on create, 1-click toggle | — |
|
||||
| F7 | Autosave locally, continuously | → | debounced ≤500 ms; survive reload | — |
|
||||
| F8 | Round-trip Models as versioned JSON | → | export→import identity; `version` | schema migrations |
|
||||
| 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 |
|
||||
|
||||
## 3. Cascade — Goals → Functions → How → Components
|
||||
|
||||
- **G1** Build a model with near-zero friction _W:9_
|
||||
- **F1** Place a node in one gesture _↓ 1 click/drag_
|
||||
- **G1** Build a model with near-zero friction _W:9_
|
||||
- **F1** Place a node in one gesture _↓ 1 click/drag_
|
||||
- **How**: Vue Flow drag-from-palette → store action creates the node
|
||||
- **Component**: C1 Vue Flow canvas · C2 custom node components · C6 projection
|
||||
- **F2** Connect by direct manipulation (auto cloud on open end) _↓ 1 drag_
|
||||
- **F2** Connect by direct manipulation (auto cloud on open end) _↓ 1 drag_
|
||||
- **How**: drag from a node handle; open end auto-spawns a Cloud
|
||||
- **Component**: C2 node handles · C3 edge components · C5 store actions · C7 structure guard
|
||||
- **F3** Render & manipulate smoothly _↑ 60 fps @ ≤150_
|
||||
- **F3** Render & manipulate smoothly _↑ 60 fps @ ≤150_
|
||||
- **How**: Vue Flow viewport (GPU transforms); Model→VueFlow projection memoised
|
||||
- **Component**: C1 Vue Flow canvas · C6 projection
|
||||
- **F9** Undo/redo _↑ ≥50 steps_
|
||||
- **F9** Undo/redo _↑ ≥50 steps_
|
||||
- **How**: ring buffer of deep-cloned Model snapshots in the store
|
||||
- **Component**: C5 model store (history)
|
||||
- **G2** Feedback structure surfaces as insight (live) _W:8_
|
||||
- **F5** Detect & classify loops live _↓ ≤16 ms @ ≤150_
|
||||
- **G2** Feedback structure surfaces as insight (live) _W:8_
|
||||
- **F5** Detect & classify loops live _↓ ≤16 ms @ ≤150_
|
||||
- **How**: derive directed graph → Tarjan SCC → capped Johnson → polarity product (R/B)
|
||||
- **How**: SCC-only — _rejected (loses per-loop insight), see T3_
|
||||
- **Component**: C8 loop engine · C11 loop overlay
|
||||
- **F6** Capture link polarity frictionlessly _↓ default + toggle_
|
||||
- **F6** Capture link polarity frictionlessly _↓ default + toggle_
|
||||
- **How**: InfoLink defaults to `+` on create; 1-click `+/−` toggle on the edge badge
|
||||
- **Component**: C3 edge components · C5 store actions
|
||||
- **F4** Enforce valid structure inline _→ 0 invalid persistable_
|
||||
- **How**: guard *guides* (disallow invalid handle targets) rather than erroring after
|
||||
- **F4** Enforce valid structure inline _→ 0 invalid persistable_
|
||||
- **How**: guard _guides_ (disallow invalid handle targets) rather than erroring after
|
||||
- **Component**: C7 structure guard · C4 domain types
|
||||
- **G3** Models are safe and portable _W:7_
|
||||
- **F7** Autosave locally, continuously _→ ≤500 ms debounce_
|
||||
- **G3** Models are safe and portable _W:7_
|
||||
- **F7** Autosave locally, continuously _→ ≤500 ms debounce_
|
||||
- **How**: store subscribes to Model changes → debounced `repository.save()`
|
||||
- **Component**: C5 store · C9 ModelRepository (IndexedDB/idb)
|
||||
- **F8** Round-trip Models as versioned JSON _→ lossless_
|
||||
- **F8** Round-trip Models as versioned JSON _→ lossless_
|
||||
- **How**: serialise Model (`version` field) → download; import → validate → load
|
||||
- **How**: PouchDB/cloud sync — _deferred (needs a backend), see Tensions_
|
||||
- **Component**: C10 import/export · C4 domain types
|
||||
- **F9** (also serves G3 as a safety net) → C5 model store
|
||||
- **G4** Learn the language by example _W:4_
|
||||
- **F10** Load a curated sample on demand _→ 1 click, set spans the language_
|
||||
- **How**: ship example Models as pure data (the same factory constructors the
|
||||
store uses); a header menu loads one via `store.setModel` + `fitView`
|
||||
- **How**: interactive tutorial / guided tour — _rejected (a tutorial
|
||||
framework to maintain; a readable, editable gallery teaches by example and
|
||||
doubles as instant content), see T6_
|
||||
- **Component**: C13 sample gallery · C5 store (`setModel`) · C1 canvas (`fitView`)
|
||||
- **F5** (seeing loops classified R/B _is_ the lesson) → C8 loop engine · C11 overlay
|
||||
- **F6** (visible polarity reinforces the lesson) → C3 edge components
|
||||
|
||||
## 4. House — Goals × Functions
|
||||
|
||||
Cells: strength 9/3/1. Importance Σ = Σ(weight × strength).
|
||||
|
||||
| | F1 | F2 | F3 | F4 | F5 | F6 | F7 | F8 | F9 |
|
||||
|----------|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|
|
||||
| G1 (9) | 9 | 9 | 9 | 3 | 1 | 3 | 1 | | 3 |
|
||||
| G2 (8) | | 3 | 1 | 3 | 9 | 9 | | | 1 |
|
||||
| G3 (7) | | | | 1 | | | 9 | 9 | 9 |
|
||||
| **Σ** | 81 | 105| 89 | 58 | 81 | 99 | 72 | 63 | 98 |
|
||||
| | F1 | F2 | F3 | F4 | F5 | F6 | F7 | F8 | F9 | F10 |
|
||||
| ------ | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||
| G1 (9) | 9 | 9 | 9 | 3 | 1 | 3 | 1 | | 3 | 1 |
|
||||
| G2 (8) | | 3 | 1 | 3 | 9 | 9 | | | 1 | |
|
||||
| G3 (7) | | | | 1 | | | 9 | 9 | 9 | |
|
||||
| G4 (4) | | | | | 3 | 1 | | | | 9 |
|
||||
| **Σ** | 81 | 105 | 89 | 58 | 93 | 103 | 72 | 63 | 98 | 45 |
|
||||
|
||||
**Top engineering priorities (from importance):** the matrix puts **F2 (connect, 105)**, **F6 (polarity, 99)** and **F9 (undo, 98)** above the headline detector **F5 (81)**. The lesson: loop insight is only as good as its *inputs* and *ergonomics* — getting wiring (F2) and polarity capture (F6) effortless matters as much as the detection itself, and a reliable safety net (F9) underwrites fearless building. F4 (58) is real but lowest-leverage — 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, 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).
|
||||
|
||||
## 5. Roof — Function × Function tradeoffs
|
||||
|
||||
`◎` strong reinforce · `○` mild reinforce · `×` mild conflict · `⊗` strong conflict.
|
||||
|
||||
| | F2 | F3 | F4 | F5 | F6 | F9 |
|
||||
|--------|:--:|:--:|:--:|:--:|:--:|:--:|
|
||||
| **F2** | — | | × | | ◎ | |
|
||||
| **F3** | | — | | ⊗ | | × |
|
||||
| **F4** | | | — | ○ | | |
|
||||
| **F5** | | | | — | ◎ | |
|
||||
| **F6** | | | | | — | |
|
||||
| **F9** | | | | | | — |
|
||||
| | F2 | F3 | F4 | F5 | F6 | F9 |
|
||||
| ------ | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||
| **F2** | — | | × | | ◎ | |
|
||||
| **F3** | | — | | ⊗ | | × |
|
||||
| **F4** | | | — | ○ | | |
|
||||
| **F5** | | | | — | ◎ | |
|
||||
| **F6** | | | | | — | |
|
||||
| **F9** | | | | | | — |
|
||||
|
||||
**Conflicts that actually shape the design:**
|
||||
|
||||
- **F5 ⊗ F3** — live loop enumeration on every change vs the 60 fps frame budget. The design-shaping conflict. Mitigated by the **cap** (≤200 cycles) + running detection **off the render frame** (debounce / idle callback / worker). Owned by ADR-0001 + the §7 budget.
|
||||
- **F2 × F4** — guardrails can add friction to connecting. Mitigated by *guiding* the gesture (invalid handles refuse the drop) instead of erroring afterwards.
|
||||
- **F2 × F4** — guardrails can add friction to connecting. Mitigated by _guiding_ the gesture (invalid handles refuse the drop) instead of erroring afterwards.
|
||||
- **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.)
|
||||
|
||||
## 6. Components & Function → Component map
|
||||
|
||||
| ID | Component | ADR |
|
||||
|-----|-----------------------------------------------------|----------|
|
||||
| ID | Component | ADR |
|
||||
| --- | --------------------------------------------------- | -------- |
|
||||
| C1 | Vue Flow canvas (`@vue-flow/core` + addons) | ADR-0002 |
|
||||
| C2 | Custom node components (Stock/Flow/Converter/Cloud) | ADR-0002 |
|
||||
| C3 | Custom edge components (InfoLink badge, Flow pipe) | ADR-0002 |
|
||||
@@ -421,38 +440,41 @@ Cells: strength 9/3/1. Importance Σ = Σ(weight × strength).
|
||||
| C10 | Import/Export (versioned JSON `io.ts`) | — |
|
||||
| C11 | Loop overlay UI (R/B badges, hover highlight) | ADR-0001 |
|
||||
| C12 | Palette + chrome (DaisyUI toolbar, model list) | — |
|
||||
| C13 | Sample gallery (`model/samples.ts` + header menu) | — |
|
||||
|
||||
| | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | C11 | C12 |
|
||||
|-----|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:---:|:---:|:---:|
|
||||
| F1 | 9 | 9 | | | 3 | 9 | | | | | | 9 |
|
||||
| F2 | 9 | 9 | 9 | | 9 | 3 | 9 | | | | | |
|
||||
| F3 | 9 | 3 | 3 | | | 9 | | | | | | |
|
||||
| F4 | | 1 | 1 | 3 | 3 | | 9 | | | | | |
|
||||
| F5 | | | | 3 | 1 | | | 9 | | | 9 | |
|
||||
| F6 | | | 9 | 3 | 3 | | | 1 | | | | |
|
||||
| F7 | | | | | 9 | | | | 9 | | | |
|
||||
| F8 | | | | 3 | | | | | 1 | 9 | | |
|
||||
| F9 | | | | | 9 | | | | | | | |
|
||||
| | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | C11 | C12 | C13 |
|
||||
| --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||
| F1 | 9 | 9 | | | 3 | 9 | | | | | | 9 | |
|
||||
| F2 | 9 | 9 | 9 | | 9 | 3 | 9 | | | | | | |
|
||||
| F3 | 9 | 3 | 3 | | | 9 | | | | | | | |
|
||||
| F4 | | 1 | 1 | 3 | 3 | | 9 | | | | | | |
|
||||
| F5 | | | | 3 | 1 | | | 9 | | | 9 | | |
|
||||
| F6 | | | 9 | 3 | 3 | | | 1 | | | | | |
|
||||
| F7 | | | | | 9 | | | | 9 | | | | |
|
||||
| F8 | | | | 3 | | | | | 1 | 9 | | | |
|
||||
| F9 | | | | | 9 | | | | | | | | |
|
||||
| F10 | 1 | | | | 3 | | | | | | | | 9 |
|
||||
|
||||
## 7. Critical performance budget
|
||||
|
||||
| Rank | Function | Target | Watched on | If we miss it |
|
||||
|------|----------|----------------------------|-------------------------------------|-----------------------------------------------------------|
|
||||
| 1 | F5 | ≤16 ms recompute @ ≤150 | bench on synthetic dense graphs | enforce cycle cap; move detection off-frame (idle/worker); debounce |
|
||||
| 2 | F3 | 60 fps pan/zoom/drag @ ≤150| frame profiling in DevTools | memoise projection; simplify node DOM; virtualise off-screen nodes |
|
||||
| 3 | F2 | 1 drag, 0 menus | manual UX walkthrough | simplify handle model; reduce connect steps |
|
||||
| 4 | F7 | ≤500 ms debounce, 0 loss | reload-after-edit integration test | flush on `visibilitychange`/`beforeunload` |
|
||||
| 5 | F8 | export→import identity | property test (round-trip) | pin schema; add migration on `version` bump |
|
||||
| Rank | Function | Target | Watched on | If we miss it |
|
||||
| ---- | -------- | --------------------------- | ---------------------------------- | ------------------------------------------------------------------- |
|
||||
| 1 | F5 | ≤16 ms recompute @ ≤150 | bench on synthetic dense graphs | enforce cycle cap; move detection off-frame (idle/worker); debounce |
|
||||
| 2 | F3 | 60 fps pan/zoom/drag @ ≤150 | frame profiling in DevTools | memoise projection; simplify node DOM; virtualise off-screen nodes |
|
||||
| 3 | F2 | 1 drag, 0 menus | manual UX walkthrough | simplify handle model; reduce connect steps |
|
||||
| 4 | F7 | ≤500 ms debounce, 0 loss | reload-after-edit integration test | flush on `visibilitychange`/`beforeunload` |
|
||||
| 5 | F8 | export→import identity | property test (round-trip) | pin schema; add migration on `version` bump |
|
||||
|
||||
## 8. Tradeoffs — 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 |
|
||||
| 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 |
|
||||
| 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) | — |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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) | — |
|
||||
| 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 | — |
|
||||
|
||||
### Tensions being watched (unresolved by design)
|
||||
|
||||
@@ -464,6 +486,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.
|
||||
- **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.
|
||||
- **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**).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# meadows
|
||||
|
||||
A canvas for thinking in systems — stocks, flows, converters, and feedback loops.
|
||||
Inspired by Donella Meadows' *Thinking in Systems*.
|
||||
Inspired by Donella Meadows' _Thinking in Systems_.
|
||||
|
||||
Deployed at https://meadows.apoena.dev
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ A **Feedback Loop** in a **Model** is a cycle in the wiring (Stock → Informati
|
||||
Link(s)/Flow → same Stock). We chose to **derive** loops by detecting cycles and
|
||||
classifying each as **Reinforcing** (even number of `−` polarities) or
|
||||
**Balancing** (odd), rather than storing them as first-class objects the user
|
||||
hand-labels. This makes loops *discovered insight* that can never disagree with
|
||||
hand-labels. This makes loops _discovered insight_ that can never disagree with
|
||||
the actual structure — the point of a systems-thinking tool.
|
||||
|
||||
## Considered Options
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
_Part of [meadows](../../README.md) · see [DESIGN.md](../../DESIGN.md)._
|
||||
|
||||
In the domain `Model`, a **Flow** is a *node* carrying `source` and `target`
|
||||
references (each to a Stock or Cloud), not an edge. The pipe you see is *rendered*
|
||||
In the domain `Model`, a **Flow** is a _node_ carrying `source` and `target`
|
||||
references (each to a Stock or Cloud), not an edge. The pipe you see is _rendered_
|
||||
from those references. The **Source/Sink** boundary is a **materialised Cloud
|
||||
node**, so every Flow connects node→node uniformly — no `null` ends.
|
||||
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
"fmt:check": "oxfmt --check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/controls": "^1.1.3",
|
||||
"@vue-flow/core": "^1.48.2",
|
||||
"daisyui": "^5.5.23",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.34"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
268
pnpm-lock.yaml
generated
268
pnpm-lock.yaml
generated
@@ -8,9 +8,21 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@vue-flow/background':
|
||||
specifier: ^1.3.2
|
||||
version: 1.3.2(@vue-flow/core@1.48.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3))
|
||||
'@vue-flow/controls':
|
||||
specifier: ^1.1.3
|
||||
version: 1.1.3(@vue-flow/core@1.48.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3))
|
||||
'@vue-flow/core':
|
||||
specifier: ^1.48.2
|
||||
version: 1.48.2(vue@3.5.38(typescript@6.0.3))
|
||||
daisyui:
|
||||
specifier: ^5.5.23
|
||||
version: 5.5.23
|
||||
pinia:
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))
|
||||
vue:
|
||||
specifier: ^3.5.34
|
||||
version: 3.5.38(typescript@6.0.3)
|
||||
@@ -541,6 +553,9 @@ packages:
|
||||
'@types/node@24.13.2':
|
||||
resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==}
|
||||
|
||||
'@types/web-bluetooth@0.0.20':
|
||||
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
||||
|
||||
'@vitejs/plugin-vue@6.0.7':
|
||||
resolution: {integrity: sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -557,6 +572,23 @@ packages:
|
||||
'@volar/typescript@2.4.28':
|
||||
resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==}
|
||||
|
||||
'@vue-flow/background@1.3.2':
|
||||
resolution: {integrity: sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==}
|
||||
peerDependencies:
|
||||
'@vue-flow/core': ^1.23.0
|
||||
vue: ^3.3.0
|
||||
|
||||
'@vue-flow/controls@1.1.3':
|
||||
resolution: {integrity: sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==}
|
||||
peerDependencies:
|
||||
'@vue-flow/core': ^1.23.0
|
||||
vue: ^3.3.0
|
||||
|
||||
'@vue-flow/core@1.48.2':
|
||||
resolution: {integrity: sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==}
|
||||
peerDependencies:
|
||||
vue: ^3.3.0
|
||||
|
||||
'@vue/compiler-core@3.5.38':
|
||||
resolution: {integrity: sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==}
|
||||
|
||||
@@ -569,6 +601,15 @@ packages:
|
||||
'@vue/compiler-ssr@3.5.38':
|
||||
resolution: {integrity: sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==}
|
||||
|
||||
'@vue/devtools-api@7.7.9':
|
||||
resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==}
|
||||
|
||||
'@vue/devtools-kit@7.7.9':
|
||||
resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==}
|
||||
|
||||
'@vue/devtools-shared@7.7.9':
|
||||
resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==}
|
||||
|
||||
'@vue/language-core@3.3.5':
|
||||
resolution: {integrity: sha512-UkKu5nhX89fg4VhlG/FOeI10G3cj/7radKT/cy9BT4Q9qJmJlSTAc/dP63Xqs29aypN4f39xUV6PsLNk/dcD6g==}
|
||||
|
||||
@@ -600,12 +641,66 @@ packages:
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
'@vueuse/core@10.11.1':
|
||||
resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
|
||||
|
||||
'@vueuse/metadata@10.11.1':
|
||||
resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
|
||||
|
||||
'@vueuse/shared@10.11.1':
|
||||
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
|
||||
|
||||
alien-signals@3.2.1:
|
||||
resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==}
|
||||
|
||||
birpc@2.9.0:
|
||||
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
|
||||
|
||||
copy-anything@4.0.5:
|
||||
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
d3-color@3.1.0:
|
||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-dispatch@3.0.1:
|
||||
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-drag@3.0.0:
|
||||
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-ease@3.0.1:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-selection@3.0.0:
|
||||
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-timer@3.0.1:
|
||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-transition@3.0.1:
|
||||
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
d3-selection: 2 - 3
|
||||
|
||||
d3-zoom@3.0.0:
|
||||
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
daisyui@5.5.23:
|
||||
resolution: {integrity: sha512-xuheNUSL4T6ZVtWXoioqcNkjoyGX85QTDz4HTw2aBPfqk4fuMjax5HDo8qCmpV6M1YN8bGvfx5BpYCoDeRlt+A==}
|
||||
|
||||
@@ -641,6 +736,13 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
hookable@5.5.3:
|
||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||
|
||||
is-what@5.5.0:
|
||||
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
jiti@2.7.0:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
@@ -722,6 +824,9 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
muggle-string@0.4.1:
|
||||
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
||||
|
||||
@@ -759,6 +864,9 @@ packages:
|
||||
path-browserify@1.0.1:
|
||||
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
||||
|
||||
perfect-debounce@1.0.0:
|
||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -766,10 +874,22 @@ packages:
|
||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pinia@3.0.4:
|
||||
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
|
||||
peerDependencies:
|
||||
typescript: '>=4.5.0'
|
||||
vue: ^3.5.11
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
postcss@8.5.15:
|
||||
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
rfdc@1.4.1:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
rolldown@1.0.3:
|
||||
resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -779,6 +899,14 @@ packages:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
speakingurl@14.0.1:
|
||||
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
superjson@2.2.6:
|
||||
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
tailwindcss@4.3.1:
|
||||
resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==}
|
||||
|
||||
@@ -851,6 +979,17 @@ packages:
|
||||
vscode-uri@3.1.0:
|
||||
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@vue/composition-api': ^1.0.0-rc.1
|
||||
vue: ^3.0.0-0 || ^2.6.0
|
||||
peerDependenciesMeta:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
vue-tsc@3.3.5:
|
||||
resolution: {integrity: sha512-Rzh/G2MmNlMSAMTiQEjDrsb4dgB/jbtEM47rVN2NtidF1dfb/q4w4QvpQBtW5+y3y5H27Hjh7deVwk+YB02fNg==}
|
||||
hasBin: true
|
||||
@@ -1166,6 +1305,8 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 7.18.2
|
||||
|
||||
'@types/web-bluetooth@0.0.20': {}
|
||||
|
||||
'@vitejs/plugin-vue@6.0.7(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0))(vue@3.5.38(typescript@6.0.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
@@ -1184,6 +1325,27 @@ snapshots:
|
||||
path-browserify: 1.0.1
|
||||
vscode-uri: 3.1.0
|
||||
|
||||
'@vue-flow/background@1.3.2(@vue-flow/core@1.48.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3))':
|
||||
dependencies:
|
||||
'@vue-flow/core': 1.48.2(vue@3.5.38(typescript@6.0.3))
|
||||
vue: 3.5.38(typescript@6.0.3)
|
||||
|
||||
'@vue-flow/controls@1.1.3(@vue-flow/core@1.48.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3))':
|
||||
dependencies:
|
||||
'@vue-flow/core': 1.48.2(vue@3.5.38(typescript@6.0.3))
|
||||
vue: 3.5.38(typescript@6.0.3)
|
||||
|
||||
'@vue-flow/core@1.48.2(vue@3.5.38(typescript@6.0.3))':
|
||||
dependencies:
|
||||
'@vueuse/core': 10.11.1(vue@3.5.38(typescript@6.0.3))
|
||||
d3-drag: 3.0.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-zoom: 3.0.0
|
||||
vue: 3.5.38(typescript@6.0.3)
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
|
||||
'@vue/compiler-core@3.5.38':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.7
|
||||
@@ -1214,6 +1376,24 @@ snapshots:
|
||||
'@vue/compiler-dom': 3.5.38
|
||||
'@vue/shared': 3.5.38
|
||||
|
||||
'@vue/devtools-api@7.7.9':
|
||||
dependencies:
|
||||
'@vue/devtools-kit': 7.7.9
|
||||
|
||||
'@vue/devtools-kit@7.7.9':
|
||||
dependencies:
|
||||
'@vue/devtools-shared': 7.7.9
|
||||
birpc: 2.9.0
|
||||
hookable: 5.5.3
|
||||
mitt: 3.0.1
|
||||
perfect-debounce: 1.0.0
|
||||
speakingurl: 14.0.1
|
||||
superjson: 2.2.6
|
||||
|
||||
'@vue/devtools-shared@7.7.9':
|
||||
dependencies:
|
||||
rfdc: 1.4.1
|
||||
|
||||
'@vue/language-core@3.3.5':
|
||||
dependencies:
|
||||
'@volar/language-core': 2.4.28
|
||||
@@ -1253,10 +1433,71 @@ snapshots:
|
||||
typescript: 6.0.3
|
||||
vue: 3.5.38(typescript@6.0.3)
|
||||
|
||||
'@vueuse/core@10.11.1(vue@3.5.38(typescript@6.0.3))':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.20
|
||||
'@vueuse/metadata': 10.11.1
|
||||
'@vueuse/shared': 10.11.1(vue@3.5.38(typescript@6.0.3))
|
||||
vue-demi: 0.14.10(vue@3.5.38(typescript@6.0.3))
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@vueuse/metadata@10.11.1': {}
|
||||
|
||||
'@vueuse/shared@10.11.1(vue@3.5.38(typescript@6.0.3))':
|
||||
dependencies:
|
||||
vue-demi: 0.14.10(vue@3.5.38(typescript@6.0.3))
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
alien-signals@3.2.1: {}
|
||||
|
||||
birpc@2.9.0: {}
|
||||
|
||||
copy-anything@4.0.5:
|
||||
dependencies:
|
||||
is-what: 5.5.0
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
d3-color@3.1.0: {}
|
||||
|
||||
d3-dispatch@3.0.1: {}
|
||||
|
||||
d3-drag@3.0.0:
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
|
||||
d3-ease@3.0.1: {}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
|
||||
d3-selection@3.0.0: {}
|
||||
|
||||
d3-timer@3.0.1: {}
|
||||
|
||||
d3-transition@3.0.1(d3-selection@3.0.0):
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
d3-dispatch: 3.0.1
|
||||
d3-ease: 3.0.1
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
d3-zoom@3.0.0:
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-drag: 3.0.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-transition: 3.0.1(d3-selection@3.0.0)
|
||||
|
||||
daisyui@5.5.23: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
@@ -1279,6 +1520,10 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
hookable@5.5.3: {}
|
||||
|
||||
is-what@5.5.0: {}
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
@@ -1334,6 +1579,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
muggle-string@0.4.1: {}
|
||||
|
||||
nanoid@3.3.13: {}
|
||||
@@ -1386,16 +1633,27 @@ snapshots:
|
||||
|
||||
path-browserify@1.0.1: {}
|
||||
|
||||
perfect-debounce@1.0.0: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
|
||||
pinia@3.0.4(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 7.7.9
|
||||
vue: 3.5.38(typescript@6.0.3)
|
||||
optionalDependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
postcss@8.5.15:
|
||||
dependencies:
|
||||
nanoid: 3.3.13
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
|
||||
rolldown@1.0.3:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.133.0
|
||||
@@ -1419,6 +1677,12 @@ snapshots:
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
speakingurl@14.0.1: {}
|
||||
|
||||
superjson@2.2.6:
|
||||
dependencies:
|
||||
copy-anything: 4.0.5
|
||||
|
||||
tailwindcss@4.3.1: {}
|
||||
|
||||
tapable@2.3.3: {}
|
||||
@@ -1451,6 +1715,10 @@ snapshots:
|
||||
|
||||
vscode-uri@3.1.0: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.38(typescript@6.0.3)):
|
||||
dependencies:
|
||||
vue: 3.5.38(typescript@6.0.3)
|
||||
|
||||
vue-tsc@3.3.5(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@volar/typescript': 2.4.28
|
||||
|
||||
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
vue-demi: false
|
||||
18
src/App.vue
18
src/App.vue
@@ -1,21 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
const appName = "meadows"
|
||||
import Editor from "@/components/Editor.vue"
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="min-h-screen bg-base-200 flex items-center justify-center p-6">
|
||||
<div class="card w-full max-w-md bg-base-100 shadow-xl">
|
||||
<div class="card-body items-center text-center">
|
||||
<img src="/favicon.svg" alt="" class="size-12" />
|
||||
<h1 class="card-title text-3xl font-semibold">{{ appName }}</h1>
|
||||
<p class="text-base-content/70">
|
||||
A canvas for thinking in systems — stocks, flows, faucets, and feedback loops.
|
||||
</p>
|
||||
<div class="card-actions mt-4">
|
||||
<button class="btn btn-primary" disabled>Start a system</button>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/40 mt-2">Scaffold ready. Editor coming next.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Editor />
|
||||
</template>
|
||||
|
||||
294
src/components/Editor.vue
Normal file
294
src/components/Editor.vue
Normal file
@@ -0,0 +1,294 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Editor (C1) — the Vue Flow canvas and chrome.
|
||||
*
|
||||
* The Model store is the source of truth; `nodes`/`edges` here are a derived
|
||||
* projection bound one-way into Vue Flow (ADR-0002). Vue Flow owns interaction
|
||||
* (pan/zoom/drag/select) on its internal copy; we translate the gestures we care
|
||||
* about back into store actions:
|
||||
* - drag start → take an undo restore point
|
||||
* - drag stop → commit final positions
|
||||
* - Delete/Backspace → remove selected nodes (Vue Flow's own delete is off, so
|
||||
* it can't delete from its copy behind the Model's back)
|
||||
* After each store mutation the projection recomputes and Vue Flow reconciles by
|
||||
* id, so positions never fight.
|
||||
*/
|
||||
import { Background } from "@vue-flow/background"
|
||||
import { Controls } from "@vue-flow/controls"
|
||||
import {
|
||||
type Connection,
|
||||
ErrorCode,
|
||||
isErrorOfType,
|
||||
type OnConnectStartParams,
|
||||
Panel,
|
||||
useVueFlow,
|
||||
VueFlow,
|
||||
type XYPosition,
|
||||
} from "@vue-flow/core"
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted } from "vue"
|
||||
import { project } from "@/model/projection"
|
||||
import { type Sample, SAMPLES } from "@/model/samples"
|
||||
import { canConnect } from "@/model/validation"
|
||||
import { useModelStore } from "@/store/model"
|
||||
import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd"
|
||||
import LoopOverlay from "./LoopOverlay.vue"
|
||||
import Palette from "./Palette.vue"
|
||||
import InfoLinkEdge from "./edges/InfoLinkEdge.vue"
|
||||
import CloudNode from "./nodes/CloudNode.vue"
|
||||
import ConverterNode from "./nodes/ConverterNode.vue"
|
||||
import FlowNode from "./nodes/FlowNode.vue"
|
||||
import StockNode from "./nodes/StockNode.vue"
|
||||
|
||||
const store = useModelStore()
|
||||
|
||||
const graph = computed(() => project(store.model))
|
||||
const nodes = computed(() => graph.value.nodes)
|
||||
const edges = computed(() => graph.value.edges)
|
||||
|
||||
// Explicit shared id: useVueFlow() runs here in the parent setup, before
|
||||
// <VueFlow> mounts. Pinning both to the same id guarantees they resolve to one
|
||||
// store instance, so the event hooks below actually fire.
|
||||
const {
|
||||
onNodeDragStart,
|
||||
onNodeDragStop,
|
||||
onConnect,
|
||||
onConnectStart,
|
||||
onConnectEnd,
|
||||
onError,
|
||||
getSelectedNodes,
|
||||
getSelectedEdges,
|
||||
viewport,
|
||||
vueFlowRef,
|
||||
fitView,
|
||||
} = useVueFlow("meadows")
|
||||
|
||||
onNodeDragStart(() => store.beginInteraction())
|
||||
onNodeDragStop(({ nodes: dragged }) => {
|
||||
for (const node of dragged) store.moveNode(node.id, node.position)
|
||||
})
|
||||
|
||||
// Vue Flow measures the pane synchronously in its own onMounted, before the
|
||||
// browser has flushed first layout, so the initial read is 0×0 and it emits a
|
||||
// benign MISSING_VIEWPORT_DIMENSIONS error (it falls back to 500×500, then its
|
||||
// ResizeObserver corrects to the real size next frame). Swallow only that one;
|
||||
// registering any handler suppresses Vue Flow's default console.warn, so log
|
||||
// every other error to keep genuine problems visible.
|
||||
onError((err) => {
|
||||
if (isErrorOfType(err, ErrorCode.MISSING_VIEWPORT_DIMENSIONS)) return
|
||||
console.warn(err.message)
|
||||
})
|
||||
|
||||
// --- Connecting (F2) ---
|
||||
// Refuse invalid drops mid-gesture so the structure stays valid (F4, "guide,
|
||||
// don't nag"). The same guard backs store.connect as defence in depth.
|
||||
//
|
||||
// Vue Flow runs this guard for BOTH live gestures and the edges we project. The
|
||||
// projected pipe/link edges arrive as full edges (with an `id`) and are valid by
|
||||
// construction — the store guards every mutation — whereas a live gesture is a
|
||||
// handle-less Connection. We must only guard the gesture: the derived pipe-out
|
||||
// edge is valve→stock, which is invalid *as a gesture* but a real rendered edge,
|
||||
// so validating projected edges would drop every pipe-out (Vue Flow EDGE_INVALID).
|
||||
function isValidConnection(connection: Connection): boolean {
|
||||
if ("id" in connection) return true
|
||||
return canConnect(store.model, connection.source, connection.target)
|
||||
}
|
||||
|
||||
// Remember the drag's source so releasing on empty canvas can open onto a Cloud.
|
||||
let connectSource: string | null = null
|
||||
let connectionMade = false
|
||||
|
||||
onConnectStart((params: OnConnectStartParams) => {
|
||||
connectSource = params.handleType === "source" ? (params.nodeId ?? null) : null
|
||||
connectionMade = false
|
||||
})
|
||||
|
||||
onConnect((connection) => {
|
||||
connectionMade = true
|
||||
store.connect(connection.source, connection.target)
|
||||
})
|
||||
|
||||
onConnectEnd((event) => {
|
||||
if (!connectionMade && connectSource && event) {
|
||||
const point = pointerPosition(event)
|
||||
if (point) store.connectToNewCloud(connectSource, screenToFlow(point.x, point.y))
|
||||
}
|
||||
connectSource = null
|
||||
})
|
||||
|
||||
/** clientX/clientY from a mouse or touch end event. */
|
||||
function pointerPosition(event: MouseEvent | TouchEvent): { x: number; y: number } | null {
|
||||
if ("clientX" in event) return { x: event.clientX, y: event.clientY }
|
||||
const touch = event.changedTouches[0]
|
||||
return touch ? { x: touch.clientX, y: touch.clientY } : null
|
||||
}
|
||||
|
||||
/** Convert a screen point (clientX/clientY) to graph coordinates. */
|
||||
function screenToFlow(clientX: number, clientY: number): XYPosition {
|
||||
const rect = vueFlowRef.value?.getBoundingClientRect()
|
||||
const { x, y, zoom } = viewport.value
|
||||
return {
|
||||
x: (clientX - (rect?.left ?? 0) - x) / zoom,
|
||||
y: (clientY - (rect?.top ?? 0) - y) / zoom,
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop a node so its top-left lands roughly centred on the given graph point. */
|
||||
function placeNode(kind: PlaceableKind, at: XYPosition): void {
|
||||
const position = { x: at.x - 48, y: at.y - 24 }
|
||||
if (kind === "stock") store.addStock(position)
|
||||
else store.addConverter(position)
|
||||
}
|
||||
|
||||
/** Click-to-add: drop at the visible pane centre, cascaded so clicks don't stack. */
|
||||
function addNode(kind: PlaceableKind): void {
|
||||
const rect = vueFlowRef.value?.getBoundingClientRect()
|
||||
const centre = screenToFlow(
|
||||
(rect?.left ?? 0) + (rect?.width ?? 800) / 2,
|
||||
(rect?.top ?? 0) + (rect?.height ?? 600) / 2,
|
||||
)
|
||||
const step = (store.model.nodes.length % 8) * 22
|
||||
placeNode(kind, { x: centre.x + step, y: centre.y + step })
|
||||
}
|
||||
|
||||
/** Drag-to-add: drop where the chip is released. */
|
||||
function onDrop(event: DragEvent): void {
|
||||
event.preventDefault()
|
||||
const kind = event.dataTransfer?.getData(NODE_DND_MIME)
|
||||
if (kind !== "stock" && kind !== "converter") return
|
||||
placeNode(kind, screenToFlow(event.clientX, event.clientY))
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent): void {
|
||||
event.preventDefault()
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a sample Model (G4, the instructive goal). `setModel` replaces the whole
|
||||
* document and clears undo history, so guard real work behind a confirm — but
|
||||
* stay frictionless on the empty starting canvas. Fit the view once the
|
||||
* projection has re-derived so the loaded model lands framed.
|
||||
*/
|
||||
async function loadSample(sample: Sample): Promise<void> {
|
||||
if (store.nodeCount > 0 && !window.confirm(`Replace the current model with “${sample.title}”?`)) {
|
||||
return
|
||||
}
|
||||
store.setModel(sample.build())
|
||||
// Close the DaisyUI dropdown (it stays open while the trigger keeps focus).
|
||||
;(document.activeElement as HTMLElement | null)?.blur()
|
||||
await nextTick()
|
||||
fitView({ padding: 0.2 })
|
||||
}
|
||||
|
||||
function isTextEntry(target: EventTarget | null): boolean {
|
||||
const el = target as HTMLElement | null
|
||||
return el?.tagName === "INPUT" || el?.tagName === "TEXTAREA" || el?.isContentEditable === true
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (isTextEntry(event.target)) return
|
||||
|
||||
const mod = event.metaKey || event.ctrlKey
|
||||
if (mod && event.key.toLowerCase() === "z") {
|
||||
event.preventDefault()
|
||||
if (event.shiftKey) store.redo()
|
||||
else store.undo()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "Delete" || event.key === "Backspace") {
|
||||
const nodeIds = getSelectedNodes.value.map((node) => node.id)
|
||||
// Edges are projected, not stored: a "pipe" edge stands in for its Flow node
|
||||
// (id is `${flowId}::in|out`), an "info" edge is the Information Link itself.
|
||||
const flowIds = new Set<string>()
|
||||
const linkIds = new Set<string>()
|
||||
for (const edge of getSelectedEdges.value) {
|
||||
if (edge.data?.kind === "pipe") flowIds.add(edge.id.split("::")[0])
|
||||
else if (edge.data?.kind === "info") linkIds.add(edge.id)
|
||||
}
|
||||
|
||||
if (nodeIds.length === 0 && flowIds.size === 0 && linkIds.size === 0) return
|
||||
event.preventDefault()
|
||||
for (const id of nodeIds) store.removeNode(id)
|
||||
for (const id of flowIds) store.removeNode(id)
|
||||
for (const id of linkIds) store.removeInfoLink(id)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener("keydown", onKeydown))
|
||||
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen flex-col bg-base-200">
|
||||
<header class="flex items-center gap-3 border-b border-base-300 bg-base-100 px-4 py-2">
|
||||
<img src="/favicon.svg" alt="" class="size-6" />
|
||||
<h1 class="text-lg font-semibold">meadows</h1>
|
||||
<span class="text-sm text-base-content/50">
|
||||
{{ store.nodeCount }} {{ store.nodeCount === 1 ? "element" : "elements" }}
|
||||
</span>
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<div class="dropdown dropdown-end">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm">Samples</button>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="dropdown-content menu z-10 mt-1 w-72 gap-1 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
|
||||
>
|
||||
<li v-for="sample in SAMPLES" :key="sample.title">
|
||||
<button class="flex flex-col items-start gap-0.5" @click="loadSample(sample)">
|
||||
<span class="font-medium">{{ sample.title }}</span>
|
||||
<span class="text-xs text-base-content/60">{{ sample.blurb }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm" :disabled="!store.canUndo" @click="store.undo()">
|
||||
Undo
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" :disabled="!store.canRedo" @click="store.redo()">
|
||||
Redo
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="relative min-h-0 flex-1" @drop="onDrop" @dragover="onDragOver">
|
||||
<VueFlow
|
||||
id="meadows"
|
||||
:nodes="nodes"
|
||||
:edges="edges"
|
||||
:delete-key-code="null"
|
||||
:is-valid-connection="isValidConnection"
|
||||
:connection-radius="30"
|
||||
:min-zoom="0.2"
|
||||
:max-zoom="4"
|
||||
class="size-full"
|
||||
>
|
||||
<Background :gap="20" pattern-color="#d1d5db" />
|
||||
<Controls position="bottom-left" :show-interactive="false" />
|
||||
|
||||
<Panel position="top-left">
|
||||
<Palette @add="addNode" />
|
||||
</Panel>
|
||||
|
||||
<template #node-stock="nodeProps">
|
||||
<StockNode v-bind="nodeProps" />
|
||||
</template>
|
||||
<template #node-converter="nodeProps">
|
||||
<ConverterNode v-bind="nodeProps" />
|
||||
</template>
|
||||
<template #node-flow="nodeProps">
|
||||
<FlowNode v-bind="nodeProps" />
|
||||
</template>
|
||||
<template #node-cloud="nodeProps">
|
||||
<CloudNode v-bind="nodeProps" />
|
||||
</template>
|
||||
|
||||
<template #edge-info="edgeProps">
|
||||
<InfoLinkEdge v-bind="edgeProps" />
|
||||
</template>
|
||||
</VueFlow>
|
||||
|
||||
<LoopOverlay />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
89
src/components/LoopOverlay.vue
Normal file
89
src/components/LoopOverlay.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Loop overlay (C11, ADR-0001) — an R/B badge floating at each detected loop's
|
||||
* centre, Reinforcing in green and Balancing in amber. Loops are derived live
|
||||
* from the wiring (store getter), so badges appear, vanish and reclassify as you
|
||||
* wire, toggle polarity, or delete. Hovering a badge lights up that loop's nodes
|
||||
* and links via the shared highlight.
|
||||
*
|
||||
* Badges live in screen space: each loop's centroid (the average of its node
|
||||
* positions, in flow coords) is mapped through the live viewport transform so it
|
||||
* tracks pan/zoom, and colliding badges are stacked so overlapping loops stay
|
||||
* legible (the accepted cost of on-canvas badges).
|
||||
*/
|
||||
import { useVueFlow } from "@vue-flow/core"
|
||||
import { computed } from "vue"
|
||||
import { useLoopHighlight } from "@/composables/useLoopHighlight"
|
||||
import type { Loop } from "@/model/loops"
|
||||
import { useModelStore } from "@/store/model"
|
||||
|
||||
const store = useModelStore()
|
||||
const { viewport } = useVueFlow("meadows")
|
||||
const { highlight, clear } = useLoopHighlight()
|
||||
|
||||
const nameOf = computed(() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const node of store.model.nodes) if (node.kind !== "cloud") map.set(node.id, node.name)
|
||||
return map
|
||||
})
|
||||
|
||||
interface Badge {
|
||||
loop: Loop
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const badges = computed<Badge[]>(() => {
|
||||
const positions = new Map(store.model.nodes.map((n) => [n.id, n.position]))
|
||||
const { x: vx, y: vy, zoom } = viewport.value
|
||||
const placed: Badge[] = []
|
||||
|
||||
for (const loop of store.loops) {
|
||||
let sx = 0
|
||||
let sy = 0
|
||||
for (const id of loop.nodeIds) {
|
||||
const p = positions.get(id)
|
||||
if (p) {
|
||||
sx += p.x
|
||||
sy += p.y
|
||||
}
|
||||
}
|
||||
const n = loop.nodeIds.length || 1
|
||||
// node positions are top-left; nudge toward node centre, then to screen.
|
||||
const x = (sx / n + 24) * zoom + vx
|
||||
let y = (sy / n + 18) * zoom + vy
|
||||
while (placed.some((b) => Math.abs(b.x - x) < 26 && Math.abs(b.y - y) < 26)) y += 26
|
||||
placed.push({ loop, x, y })
|
||||
}
|
||||
return placed
|
||||
})
|
||||
|
||||
function title(loop: Loop): string {
|
||||
const kind = loop.type === "R" ? "Reinforcing" : "Balancing"
|
||||
const names = loop.nodeIds.map((id) => nameOf.value.get(id) ?? "·").join(" → ")
|
||||
return `${kind} loop · ${names}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<button
|
||||
v-for="badge in badges"
|
||||
:key="badge.loop.id"
|
||||
class="pointer-events-auto absolute flex size-6 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border text-xs font-bold shadow transition-transform hover:scale-125"
|
||||
:class="
|
||||
badge.loop.type === 'R'
|
||||
? 'border-success/60 bg-success text-success-content'
|
||||
: 'border-warning/60 bg-warning text-warning-content'
|
||||
"
|
||||
:style="{ left: `${badge.x}px`, top: `${badge.y}px` }"
|
||||
:title="title(badge.loop)"
|
||||
@mouseenter="highlight(badge.loop)"
|
||||
@mouseleave="clear()"
|
||||
@focus="highlight(badge.loop)"
|
||||
@blur="clear()"
|
||||
>
|
||||
{{ badge.loop.type }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
48
src/components/Palette.vue
Normal file
48
src/components/Palette.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Palette (C12) — place a node either way (F1, "1 click/drag"): click to drop at
|
||||
* the viewport centre, or drag a chip onto the canvas to drop where you release.
|
||||
* The Editor owns placement; this just signals the kind (via event on click, via
|
||||
* dataTransfer on drag). Flows aren't placed here — they're drawn by connecting
|
||||
* two nodes (F2, next).
|
||||
*/
|
||||
import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd"
|
||||
|
||||
const emit = defineEmits<{ add: [kind: PlaceableKind] }>()
|
||||
|
||||
function onDragStart(event: DragEvent, kind: PlaceableKind): void {
|
||||
if (!event.dataTransfer) return
|
||||
event.dataTransfer.setData(NODE_DND_MIME, kind)
|
||||
event.dataTransfer.effectAllowed = "copy"
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-1 rounded-box border border-base-300 bg-base-100/90 p-2 shadow-md backdrop-blur"
|
||||
>
|
||||
<span
|
||||
class="px-2 pt-1 pb-0.5 text-xs font-semibold tracking-wide text-base-content/50 uppercase"
|
||||
>
|
||||
Add
|
||||
</span>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm cursor-grab justify-start gap-2 active:cursor-grabbing"
|
||||
draggable="true"
|
||||
@click="emit('add', 'stock')"
|
||||
@dragstart="onDragStart($event, 'stock')"
|
||||
>
|
||||
<span class="inline-block size-3.5 rounded-sm border-2 border-current" />
|
||||
Stock
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm cursor-grab justify-start gap-2 active:cursor-grabbing"
|
||||
draggable="true"
|
||||
@click="emit('add', 'converter')"
|
||||
@dragstart="onDragStart($event, 'converter')"
|
||||
>
|
||||
<span class="inline-block size-3.5 rounded-full border-2 border-current" />
|
||||
Converter
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
71
src/components/edges/InfoLinkEdge.vue
Normal file
71
src/components/edges/InfoLinkEdge.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Information Link edge (C3) — the dashed influence arrow plus a clickable
|
||||
* polarity badge at its midpoint. Polarity is captured on create (default `+`,
|
||||
* F6) and flipped in one click here; it is the loop detector's only structural
|
||||
* input (ADR-0001), so it lives right on the link. The bezier pipe is drawn by
|
||||
* BaseEdge; the badge is portalled over the viewport by EdgeLabelRenderer so it
|
||||
* stays pinned to the path under pan/zoom.
|
||||
*/
|
||||
import { BaseEdge, type EdgeProps, EdgeLabelRenderer, getBezierPath } from "@vue-flow/core"
|
||||
import { computed } from "vue"
|
||||
import { useLoopHighlight } from "@/composables/useLoopHighlight"
|
||||
import type { EdgeData } from "@/model/projection"
|
||||
import { useModelStore } from "@/store/model"
|
||||
|
||||
const props = defineProps<EdgeProps<EdgeData>>()
|
||||
const store = useModelStore()
|
||||
const { highlighted } = useLoopHighlight()
|
||||
|
||||
// [path, labelX, labelY, ...] — labelX/Y is the path centre, in flow coords.
|
||||
const path = computed(() =>
|
||||
getBezierPath({
|
||||
sourceX: props.sourceX,
|
||||
sourceY: props.sourceY,
|
||||
sourcePosition: props.sourcePosition,
|
||||
targetX: props.targetX,
|
||||
targetY: props.targetY,
|
||||
targetPosition: props.targetPosition,
|
||||
}),
|
||||
)
|
||||
|
||||
const polarity = computed(() => props.data.polarity ?? "+")
|
||||
// Display the typographic minus (the model stores ASCII "-").
|
||||
const glyph = computed(() => (polarity.value === "+" ? "+" : "−"))
|
||||
|
||||
// Light up when this link is an edge of the hovered loop (C11).
|
||||
const inLoop = computed(
|
||||
() => highlighted.value?.edges.has(`${props.source}>${props.target}`) ?? false,
|
||||
)
|
||||
const edgeStyle = computed(() =>
|
||||
inLoop.value
|
||||
? {
|
||||
...props.style,
|
||||
stroke: highlighted.value?.type === "R" ? "var(--color-success)" : "var(--color-warning)",
|
||||
strokeWidth: "2.5px",
|
||||
}
|
||||
: props.style,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseEdge :id="props.id" :path="path[0]" :marker-end="props.markerEnd" :style="edgeStyle" />
|
||||
<EdgeLabelRenderer>
|
||||
<button
|
||||
class="nodrag nopan absolute flex size-5 items-center justify-center rounded-full border text-xs font-bold leading-none shadow-sm transition-colors"
|
||||
:class="
|
||||
polarity === '+'
|
||||
? 'border-primary/40 bg-base-100 text-primary hover:bg-primary/10'
|
||||
: 'border-error/50 bg-base-100 text-error hover:bg-error/10'
|
||||
"
|
||||
:style="{
|
||||
transform: `translate(-50%, -50%) translate(${path[1]}px, ${path[2]}px)`,
|
||||
pointerEvents: 'all',
|
||||
}"
|
||||
:title="`Polarity ${glyph} — click to toggle`"
|
||||
@click="store.toggleLinkPolarity(props.id)"
|
||||
>
|
||||
{{ glyph }}
|
||||
</button>
|
||||
</EdgeLabelRenderer>
|
||||
</template>
|
||||
29
src/components/nodes/CloudNode.vue
Normal file
29
src/components/nodes/CloudNode.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Cloud — the model boundary (Source/Sink) on an open Flow end. Auto-materialised
|
||||
* (ADR-0003), never placed by hand, carries no name. Drawn as the conventional
|
||||
* cloud symbol, with handles so a Flow can terminate on it either way.
|
||||
*/
|
||||
import { Handle, type NodeProps, Position } from "@vue-flow/core"
|
||||
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
|
||||
|
||||
defineProps<NodeProps<NodeData>>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="size-9 text-base-content/40"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M6.5 19a4.5 4.5 0 0 1 0-9 5.5 5.5 0 0 1 10.5-1.5 4 4 0 0 1 1 7.9" />
|
||||
</svg>
|
||||
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
|
||||
</div>
|
||||
</template>
|
||||
32
src/components/nodes/ConverterNode.vue
Normal file
32
src/components/nodes/ConverterNode.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Converter — a stateless helper value. Drawn as a circle, the classic
|
||||
* auxiliary/converter symbol, with its name beneath. Handles let Information
|
||||
* Links arrive (target) and leave (source).
|
||||
*/
|
||||
import { Handle, type NodeProps, Position } from "@vue-flow/core"
|
||||
import { computed } from "vue"
|
||||
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
|
||||
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
|
||||
import type { ConverterNode } from "@/model/types"
|
||||
import NodeLabel from "./NodeLabel.vue"
|
||||
|
||||
const props = defineProps<NodeProps<NodeData>>()
|
||||
|
||||
// The projection guarantees a converter-typed node here.
|
||||
const converter = computed(() => props.data.node as ConverterNode)
|
||||
const loopRing = useNodeLoopRing(props.id)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<div
|
||||
class="relative size-12 rounded-full border-2 bg-base-100 shadow-sm transition-colors"
|
||||
:class="[props.selected ? 'border-primary' : 'border-base-300', loopRing]"
|
||||
>
|
||||
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
|
||||
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
|
||||
</div>
|
||||
<NodeLabel :node-id="props.id" :name="converter.name" />
|
||||
</div>
|
||||
</template>
|
||||
44
src/components/nodes/FlowNode.vue
Normal file
44
src/components/nodes/FlowNode.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Flow — a rate, drawn as the valve (an outlined bowtie/butterfly) sitting on
|
||||
* the pipe between source and target. The pipe segments are projected edges
|
||||
* (ADR-0003); this node is the valve and its name. Outlined (not filled) so it
|
||||
* reads as a tap rather than two arrows. Handles sit on the valve so the pipe
|
||||
* connects through it and Information Links can target/leave the rate.
|
||||
*/
|
||||
import { Handle, type NodeProps, Position } from "@vue-flow/core"
|
||||
import { computed } from "vue"
|
||||
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
|
||||
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
|
||||
import type { FlowNode } from "@/model/types"
|
||||
import NodeLabel from "./NodeLabel.vue"
|
||||
|
||||
const props = defineProps<NodeProps<NodeData>>()
|
||||
|
||||
// The projection guarantees a flow-typed node here.
|
||||
const flow = computed(() => props.data.node as FlowNode)
|
||||
const loopRing = useNodeLoopRing(props.id)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<div class="relative rounded-full" :class="loopRing">
|
||||
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="size-7 transition-colors"
|
||||
:class="props.selected ? 'text-primary' : 'text-base-content/60'"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- Outlined bowtie valve: two hollow triangles meeting at the centre. -->
|
||||
<path d="M3 5 L12 12 L3 19 Z" />
|
||||
<path d="M21 5 L12 12 L21 19 Z" />
|
||||
</svg>
|
||||
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
|
||||
</div>
|
||||
<NodeLabel :node-id="props.id" :name="flow.name" />
|
||||
</div>
|
||||
</template>
|
||||
62
src/components/nodes/NodeLabel.vue
Normal file
62
src/components/nodes/NodeLabel.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* A node's name with frictionless inline rename: double-click to edit, Enter or
|
||||
* blur to commit through the store, Escape to cancel. The `nodrag` class and
|
||||
* stopped pointer events keep Vue Flow from starting a drag while typing.
|
||||
*
|
||||
* While editing, an invisible sizer span (mirroring the draft text) holds the
|
||||
* box width and the input overlays it absolutely — so switching between display
|
||||
* and edit never reflows the node (no width jump on a Stock, no shifted circle
|
||||
* on a Converter).
|
||||
*/
|
||||
import { nextTick, ref } from "vue"
|
||||
import { useModelStore } from "@/store/model"
|
||||
|
||||
const props = defineProps<{ nodeId: string; name: string }>()
|
||||
|
||||
const store = useModelStore()
|
||||
const editing = ref(false)
|
||||
const draft = ref("")
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
|
||||
async function begin(): Promise<void> {
|
||||
draft.value = props.name
|
||||
editing.value = true
|
||||
await nextTick()
|
||||
input.value?.focus()
|
||||
input.value?.select()
|
||||
}
|
||||
|
||||
function commit(): void {
|
||||
if (!editing.value) return
|
||||
editing.value = false
|
||||
const trimmed = draft.value.trim()
|
||||
if (trimmed && trimmed !== props.name) store.renameNode(props.nodeId, trimmed)
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
editing.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="relative inline-block min-w-[1.5ch] text-center text-sm font-medium">
|
||||
<template v-if="editing">
|
||||
<!-- Invisible sizer: defines the box width from the draft, so the input
|
||||
(absolutely positioned over it) matches the text it replaced. -->
|
||||
<span class="invisible whitespace-pre" aria-hidden="true">{{ draft || " " }}</span>
|
||||
<input
|
||||
ref="input"
|
||||
v-model="draft"
|
||||
class="nodrag absolute inset-0 w-full rounded bg-base-100 px-0 text-center text-sm font-medium outline-none ring-1 ring-primary"
|
||||
@blur="commit"
|
||||
@keydown.enter.prevent="commit"
|
||||
@keydown.esc.prevent="cancel"
|
||||
@pointerdown.stop
|
||||
/>
|
||||
</template>
|
||||
<span v-else class="cursor-text whitespace-pre select-none" @dblclick.stop="begin">
|
||||
{{ name }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
30
src/components/nodes/StockNode.vue
Normal file
30
src/components/nodes/StockNode.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Stock — the accumulation. Drawn as a rectangle, the classic stock-and-flow
|
||||
* symbol for the one stateful element. A target handle (left) and source handle
|
||||
* (right) let Flows attach and Information Links leave.
|
||||
*/
|
||||
import { Handle, type NodeProps, Position } from "@vue-flow/core"
|
||||
import { computed } from "vue"
|
||||
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
|
||||
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
|
||||
import type { StockNode } from "@/model/types"
|
||||
import NodeLabel from "./NodeLabel.vue"
|
||||
|
||||
const props = defineProps<NodeProps<NodeData>>()
|
||||
|
||||
// The projection guarantees a stock-typed node here.
|
||||
const stock = computed(() => props.data.node as StockNode)
|
||||
const loopRing = useNodeLoopRing(props.id)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-w-24 rounded-md border-2 bg-base-100 px-4 py-3 text-center shadow-sm transition-colors"
|
||||
:class="[props.selected ? 'border-primary' : 'border-base-300', loopRing]"
|
||||
>
|
||||
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
|
||||
<NodeLabel :node-id="props.id" :name="stock.name" />
|
||||
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
|
||||
</div>
|
||||
</template>
|
||||
7
src/components/palette-dnd.ts
Normal file
7
src/components/palette-dnd.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/** Shared contract between the Palette (drag source) and Editor (drop target). */
|
||||
|
||||
/** Node kinds the palette can place. Flows are drawn by connecting (F2). */
|
||||
export type PlaceableKind = "stock" | "converter"
|
||||
|
||||
/** Custom MIME type carrying the kind across an HTML5 drag-and-drop. */
|
||||
export const NODE_DND_MIME = "application/x-meadows-node"
|
||||
48
src/composables/useLoopHighlight.ts
Normal file
48
src/composables/useLoopHighlight.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Shared loop-hover highlight (C11). Deliberately *not* in the Model store: this
|
||||
* is transient view state that must never be snapshotted for undo or picked up
|
||||
* by autosave (F7). The badge layer sets it on hover; nodes and Information Link
|
||||
* edges read it to light up the loop they belong to.
|
||||
*
|
||||
* A module-level ref makes it a singleton shared by every component that calls
|
||||
* the composable, without prop-drilling through Vue Flow's render tree.
|
||||
*/
|
||||
import { computed, type Ref, ref } from "vue"
|
||||
import type { Loop, LoopType } from "@/model/loops"
|
||||
|
||||
interface LoopHighlight {
|
||||
nodes: Set<string>
|
||||
/** Directed `source>target` pairs of the loop's edges, for link highlighting. */
|
||||
edges: Set<string>
|
||||
type: LoopType
|
||||
}
|
||||
|
||||
const highlighted = ref<LoopHighlight | null>(null)
|
||||
|
||||
export function useLoopHighlight(): {
|
||||
highlighted: Ref<LoopHighlight | null>
|
||||
highlight: (loop: Loop) => void
|
||||
clear: () => void
|
||||
} {
|
||||
function highlight(loop: Loop): void {
|
||||
const ids = loop.nodeIds
|
||||
const edges = new Set<string>()
|
||||
for (let i = 0; i < ids.length; i++) edges.add(`${ids[i]}>${ids[(i + 1) % ids.length]}`)
|
||||
highlighted.value = { nodes: new Set(ids), edges, type: loop.type }
|
||||
}
|
||||
function clear(): void {
|
||||
highlighted.value = null
|
||||
}
|
||||
return { highlighted, highlight, clear }
|
||||
}
|
||||
|
||||
/** Ring class for a node when it belongs to the hovered loop (empty otherwise). */
|
||||
export function useNodeLoopRing(nodeId: string): Ref<string> {
|
||||
return computed(() => {
|
||||
const hl = highlighted.value
|
||||
if (!hl || !hl.nodes.has(nodeId)) return ""
|
||||
return hl.type === "R"
|
||||
? "ring-2 ring-success ring-offset-2"
|
||||
: "ring-2 ring-warning ring-offset-2"
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { createPinia } from "pinia"
|
||||
import { createApp } from "vue"
|
||||
// Vue Flow styles first so our Tailwind utilities can override them.
|
||||
import "@vue-flow/core/dist/style.css"
|
||||
import "@vue-flow/core/dist/theme-default.css"
|
||||
import "@vue-flow/controls/dist/style.css"
|
||||
import "./style.css"
|
||||
import App from "./App.vue"
|
||||
|
||||
createApp(App).mount("#app")
|
||||
createApp(App).use(createPinia()).mount("#app")
|
||||
|
||||
74
src/model/factory.ts
Normal file
74
src/model/factory.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Pure constructors for Model pieces: id generation, a blank Model, and the
|
||||
* auto-naming that lets F1 place a node with zero dialogs. No store, no Vue —
|
||||
* just data in, data out, so it stays unit-testable.
|
||||
*/
|
||||
import {
|
||||
type CloudNode,
|
||||
type ConverterNode,
|
||||
type FlowNode,
|
||||
type Model,
|
||||
MODEL_VERSION,
|
||||
type ModelNode,
|
||||
type NodeKind,
|
||||
type Position,
|
||||
type StockNode,
|
||||
} from "./types"
|
||||
|
||||
/** Short, readable, collision-resistant id. `crypto.randomUUID` is built in. */
|
||||
export function newId(prefix: string): string {
|
||||
return `${prefix}-${crypto.randomUUID().slice(0, 8)}`
|
||||
}
|
||||
|
||||
/** Human label per kind, used for auto-naming. Clouds are unnamed boundaries. */
|
||||
const LABEL: Record<Exclude<NodeKind, "cloud">, string> = {
|
||||
stock: "Stock",
|
||||
flow: "Flow",
|
||||
converter: "Converter",
|
||||
}
|
||||
|
||||
export function emptyModel(name = "Untitled model"): Model {
|
||||
return { version: MODEL_VERSION, id: newId("model"), name, nodes: [], infoLinks: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Next free auto-name for `kind`, e.g. "Stock 3". Picks one past the highest
|
||||
* trailing number already in use so it stays stable across deletes/renames.
|
||||
*/
|
||||
export function nextName(nodes: ModelNode[], kind: Exclude<NodeKind, "cloud">): string {
|
||||
const label = LABEL[kind]
|
||||
const pattern = new RegExp(`^${label} (\\d+)$`)
|
||||
let max = 0
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== kind || !("name" in node)) continue
|
||||
const match = pattern.exec(node.name)
|
||||
if (match) max = Math.max(max, Number(match[1]))
|
||||
}
|
||||
return `${label} ${max + 1}`
|
||||
}
|
||||
|
||||
export function makeStock(position: Position, name: string): StockNode {
|
||||
return { id: newId("stock"), kind: "stock", name, position }
|
||||
}
|
||||
|
||||
export function makeConverter(position: Position, name: string): ConverterNode {
|
||||
return { id: newId("conv"), kind: "converter", name, position }
|
||||
}
|
||||
|
||||
export function makeFlow(
|
||||
position: Position,
|
||||
name: string,
|
||||
source: string,
|
||||
target: string,
|
||||
): FlowNode {
|
||||
return { id: newId("flow"), kind: "flow", name, source, target, position }
|
||||
}
|
||||
|
||||
export function makeCloud(position: Position): CloudNode {
|
||||
return { id: newId("cloud"), kind: "cloud", position }
|
||||
}
|
||||
|
||||
/** Midpoint between two positions — where a Flow's valve sits on its pipe. */
|
||||
export function midpoint(a: Position, b: Position): Position {
|
||||
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }
|
||||
}
|
||||
173
src/model/loops.ts
Normal file
173
src/model/loops.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Loop engine (C8, ADR-0001) — feedback loops are *derived* from the wiring, not
|
||||
* stored, so they can never disagree with the structure.
|
||||
*
|
||||
* We build a signed directed graph and enumerate its simple cycles, classifying
|
||||
* each by the parity of its negative links: an even number of `−` is a
|
||||
* Reinforcing loop (R), an odd number is Balancing (B) — the sign of the product
|
||||
* of the loop's link polarities.
|
||||
*
|
||||
* Signed edges come from two places (ADR-0001 "every link carries polarity;
|
||||
* inflows/outflows carry inherent polarity"):
|
||||
* - Information Links contribute their stored `+`/`−` (source → target).
|
||||
* - A Flow contributes its inherent material polarity: `+` into the stock it
|
||||
* fills (flow → target) and `−` out of the stock it drains (flow → source).
|
||||
* Clouds have no outgoing edges, so no cycle passes through the model boundary.
|
||||
*
|
||||
* Enumeration is Tarjan SCC (prune acyclic structure) → DFS of simple cycles
|
||||
* within each component, started from each member in id order with descendants
|
||||
* restricted to that order so every cycle is found exactly once (from its
|
||||
* minimum node). Both the result count and the search are capped (T3): pathology
|
||||
* yields "the first N loops", never a blown frame budget (F5 ⊗ F3).
|
||||
*/
|
||||
import type { Model, Polarity } from "./types"
|
||||
|
||||
export type LoopType = "R" | "B"
|
||||
|
||||
export interface Loop {
|
||||
/** Canonical id: the cycle's node ids in traversal order, min-id first. */
|
||||
id: string
|
||||
/** Nodes on the cycle, in order (no repeated closing node). */
|
||||
nodeIds: string[]
|
||||
/** Count of `−` links around the loop; its parity decides R vs B. */
|
||||
negatives: number
|
||||
type: LoopType
|
||||
}
|
||||
|
||||
export interface LoopResult {
|
||||
loops: Loop[]
|
||||
/** True if the result or search cap was hit — there may be more loops. */
|
||||
capped: boolean
|
||||
}
|
||||
|
||||
/** Max simple cycles reported before we stop (ADR-0001/T3: "+N more"). */
|
||||
export const LOOP_CAP = 200
|
||||
/** Hard ceiling on edge expansions, so a dense graph can't blow the frame. */
|
||||
const STEP_BUDGET = 50_000
|
||||
|
||||
interface SignedEdge {
|
||||
to: string
|
||||
polarity: Polarity
|
||||
}
|
||||
|
||||
function buildGraph(model: Model): Map<string, SignedEdge[]> {
|
||||
const adj = new Map<string, SignedEdge[]>()
|
||||
for (const node of model.nodes) adj.set(node.id, [])
|
||||
|
||||
const add = (from: string, edge: SignedEdge): void => {
|
||||
const list = adj.get(from)
|
||||
if (list) list.push(edge)
|
||||
}
|
||||
|
||||
for (const link of model.infoLinks) add(link.source, { to: link.target, polarity: link.polarity })
|
||||
for (const node of model.nodes) {
|
||||
if (node.kind !== "flow") continue
|
||||
add(node.id, { to: node.target, polarity: "+" }) // fills its target
|
||||
add(node.id, { to: node.source, polarity: "-" }) // drains its source
|
||||
}
|
||||
return adj
|
||||
}
|
||||
|
||||
/** Tarjan's SCC — components are returned in reverse topological order. */
|
||||
function stronglyConnectedComponents(adj: Map<string, SignedEdge[]>): string[][] {
|
||||
const index = new Map<string, number>()
|
||||
const low = new Map<string, number>()
|
||||
const onStack = new Set<string>()
|
||||
const stack: string[] = []
|
||||
const sccs: string[][] = []
|
||||
let counter = 0
|
||||
|
||||
const connect = (v: string): void => {
|
||||
index.set(v, counter)
|
||||
low.set(v, counter)
|
||||
counter++
|
||||
stack.push(v)
|
||||
onStack.add(v)
|
||||
|
||||
for (const { to } of adj.get(v) ?? []) {
|
||||
if (!index.has(to)) {
|
||||
connect(to)
|
||||
low.set(v, Math.min(low.get(v)!, low.get(to)!))
|
||||
} else if (onStack.has(to)) {
|
||||
low.set(v, Math.min(low.get(v)!, index.get(to)!))
|
||||
}
|
||||
}
|
||||
|
||||
if (low.get(v) === index.get(v)) {
|
||||
const component: string[] = []
|
||||
let w: string
|
||||
do {
|
||||
w = stack.pop()!
|
||||
onStack.delete(w)
|
||||
component.push(w)
|
||||
} while (w !== v)
|
||||
sccs.push(component)
|
||||
}
|
||||
}
|
||||
|
||||
for (const v of adj.keys()) if (!index.has(v)) connect(v)
|
||||
return sccs
|
||||
}
|
||||
|
||||
export function detectLoops(model: Model): LoopResult {
|
||||
const adj = buildGraph(model)
|
||||
const components = stronglyConnectedComponents(adj).filter((c) => c.length > 1)
|
||||
|
||||
const loops: Loop[] = []
|
||||
const seen = new Set<string>()
|
||||
let capped = false
|
||||
let steps = 0
|
||||
|
||||
const record = (nodeIds: string[], negatives: number): void => {
|
||||
if (loops.length >= LOOP_CAP) {
|
||||
capped = true
|
||||
return
|
||||
}
|
||||
const id = nodeIds.join(">")
|
||||
if (seen.has(id)) return
|
||||
seen.add(id)
|
||||
loops.push({ id, nodeIds, negatives, type: negatives % 2 === 0 ? "R" : "B" })
|
||||
}
|
||||
|
||||
for (const component of components) {
|
||||
if (capped) break
|
||||
const members = new Set(component)
|
||||
const order = [...component].sort()
|
||||
const rank = new Map(order.map((id, i) => [id, i]))
|
||||
|
||||
for (let start = 0; start < order.length && !capped; start++) {
|
||||
const startId = order[start]
|
||||
const path: string[] = []
|
||||
const onPath = new Set<string>()
|
||||
const polarities: Polarity[] = []
|
||||
|
||||
const walk = (v: string): void => {
|
||||
path.push(v)
|
||||
onPath.add(v)
|
||||
for (const edge of adj.get(v) ?? []) {
|
||||
if (capped) break
|
||||
if (++steps > STEP_BUDGET) {
|
||||
capped = true
|
||||
break
|
||||
}
|
||||
if (!members.has(edge.to) || rank.get(edge.to)! < start) continue
|
||||
if (edge.to === startId) {
|
||||
const negatives =
|
||||
polarities.filter((p) => p === "-").length + (edge.polarity === "-" ? 1 : 0)
|
||||
record([...path], negatives)
|
||||
} else if (!onPath.has(edge.to)) {
|
||||
polarities.push(edge.polarity)
|
||||
walk(edge.to)
|
||||
polarities.pop()
|
||||
}
|
||||
}
|
||||
path.pop()
|
||||
onPath.delete(v)
|
||||
}
|
||||
|
||||
walk(startId)
|
||||
}
|
||||
}
|
||||
|
||||
return { loops, capped }
|
||||
}
|
||||
98
src/model/projection.ts
Normal file
98
src/model/projection.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Model → Vue Flow projection (C6, ADR-0002).
|
||||
*
|
||||
* One-directional and pure: the domain Model in, the `{ nodes, edges }` Vue Flow
|
||||
* renders out. Writes never flow this way — Vue Flow events call store actions
|
||||
* that mutate the Model, and this re-derives. Keeping it pure means it is cheap
|
||||
* to memoise (the F3 escape hatch) and trivial to reason about.
|
||||
*
|
||||
* Edges are derived, never stored. A Flow node contributes two "pipe" edges
|
||||
* (source→valve, valve→target) so the classic pipe-with-valve renders from the
|
||||
* Flow's references (ADR-0003); each Information Link contributes one "info"
|
||||
* edge carrying its polarity.
|
||||
*/
|
||||
import type { Edge, Node } from "@vue-flow/core"
|
||||
import type { Model, ModelNode, Polarity } from "./types"
|
||||
|
||||
/** Payload carried on every projected Vue Flow node: the domain node itself. */
|
||||
export interface NodeData {
|
||||
node: ModelNode
|
||||
}
|
||||
|
||||
/** Payload on a projected edge — which kind, and (for info links) its polarity. */
|
||||
export interface EdgeData {
|
||||
kind: "pipe" | "info"
|
||||
polarity?: Polarity
|
||||
}
|
||||
|
||||
export type FlowGraphNode = Node<NodeData>
|
||||
export type FlowGraphEdge = Edge<EdgeData>
|
||||
|
||||
/**
|
||||
* Stable handle ids every node renders (target on the left, source on the
|
||||
* right). Edges name them explicitly so Vue Flow always resolves the right
|
||||
* anchor — without ids, an edge whose source is the small two-handle valve fails
|
||||
* to render (the "pipe-out" bug).
|
||||
*/
|
||||
export const HANDLE_IN = "in"
|
||||
export const HANDLE_OUT = "out"
|
||||
|
||||
export function projectNodes(model: Model): FlowGraphNode[] {
|
||||
return model.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: node.kind,
|
||||
position: node.position,
|
||||
data: { node },
|
||||
}))
|
||||
}
|
||||
|
||||
export function projectEdges(model: Model): FlowGraphEdge[] {
|
||||
const edges: FlowGraphEdge[] = []
|
||||
|
||||
for (const node of model.nodes) {
|
||||
if (node.kind !== "flow") continue
|
||||
// Pipe in: source → valve. No arrowhead — the valve is the visual midpoint.
|
||||
edges.push({
|
||||
id: `${node.id}::in`,
|
||||
source: node.source,
|
||||
sourceHandle: HANDLE_OUT,
|
||||
target: node.id,
|
||||
targetHandle: HANDLE_IN,
|
||||
data: { kind: "pipe" },
|
||||
style: { strokeWidth: "2.5px" },
|
||||
})
|
||||
// Pipe out: valve → target. Arrowhead carries the flow direction.
|
||||
edges.push({
|
||||
id: `${node.id}::out`,
|
||||
source: node.id,
|
||||
sourceHandle: HANDLE_OUT,
|
||||
target: node.target,
|
||||
targetHandle: HANDLE_IN,
|
||||
data: { kind: "pipe" },
|
||||
style: { strokeWidth: "2.5px" },
|
||||
markerEnd: "arrowclosed",
|
||||
})
|
||||
}
|
||||
|
||||
// Information Links: thin, dashed, arrow at the influenced end. The custom
|
||||
// "info" edge type renders the dashed pipe plus the clickable polarity badge.
|
||||
for (const link of model.infoLinks) {
|
||||
edges.push({
|
||||
id: link.id,
|
||||
type: "info",
|
||||
source: link.source,
|
||||
sourceHandle: HANDLE_OUT,
|
||||
target: link.target,
|
||||
targetHandle: HANDLE_IN,
|
||||
data: { kind: "info", polarity: link.polarity },
|
||||
style: { strokeDasharray: "5 4" },
|
||||
markerEnd: "arrowclosed",
|
||||
})
|
||||
}
|
||||
|
||||
return edges
|
||||
}
|
||||
|
||||
export function project(model: Model): { nodes: FlowGraphNode[]; edges: FlowGraphEdge[] } {
|
||||
return { nodes: projectNodes(model), edges: projectEdges(model) }
|
||||
}
|
||||
155
src/model/samples.ts
Normal file
155
src/model/samples.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Sample Models — a small, curated gallery the user can load to learn the
|
||||
* language by example (CONTEXT.md) and to have something on the canvas in one
|
||||
* click.
|
||||
*
|
||||
* The set is deliberately "simple yet exhaustive": each Model is the smallest
|
||||
* thing that makes its point, but read top to bottom they introduce the whole
|
||||
* vocabulary and both loop kinds, one new idea at a time:
|
||||
*
|
||||
* 1. Bathtub — Stock, Flow (in/out), Source, Sink. No feedback.
|
||||
* 2. Savings account — + Information Link, `+` polarity → a Reinforcing loop.
|
||||
* 3. Coffee cooling — + Converter, `−` polarity → a Balancing loop.
|
||||
* 4. Population — all of the above at once: Reinforcing *and* Balancing.
|
||||
*
|
||||
* These are plain data built from the same tested constructors the store uses
|
||||
* (factory.ts), so every sample is a valid Model by construction. `build()`
|
||||
* mints fresh ids on each call, so loading a sample twice never collides.
|
||||
*/
|
||||
import { makeCloud, makeConverter, makeFlow, makeStock, midpoint, newId } from "./factory"
|
||||
import {
|
||||
type InformationLink,
|
||||
type Model,
|
||||
MODEL_VERSION,
|
||||
type ModelNode,
|
||||
type Polarity,
|
||||
} from "./types"
|
||||
|
||||
/** A loadable example: a title and one-line blurb for the menu, plus a builder. */
|
||||
export interface Sample {
|
||||
title: string
|
||||
blurb: string
|
||||
build: () => Model
|
||||
}
|
||||
|
||||
/** Information Link between two already-built nodes, with an explicit polarity. */
|
||||
function link(source: ModelNode, target: ModelNode, polarity: Polarity): InformationLink {
|
||||
return { id: newId("link"), source: source.id, target: target.id, polarity }
|
||||
}
|
||||
|
||||
function model(name: string, nodes: ModelNode[], infoLinks: InformationLink[]): Model {
|
||||
return { version: MODEL_VERSION, id: newId("model"), name, nodes, infoLinks }
|
||||
}
|
||||
|
||||
/**
|
||||
* Bathtub — the canonical first model. A Stock filled by one Flow and drained by
|
||||
* another, each open end resting on a Cloud. No Information Links, so no
|
||||
* feedback: it just shows the substrate (Stock, Flow, Source, Sink).
|
||||
*/
|
||||
function bathtub(): Model {
|
||||
const source = makeCloud({ x: -278, y: -18 })
|
||||
const water = makeStock({ x: -48, y: -20 }, "Water")
|
||||
const sink = makeCloud({ x: 242, y: -18 })
|
||||
const filling = makeFlow(
|
||||
midpoint(source.position, water.position),
|
||||
"filling",
|
||||
source.id,
|
||||
water.id,
|
||||
)
|
||||
const emptying = makeFlow(midpoint(water.position, sink.position), "emptying", water.id, sink.id)
|
||||
return model("Bathtub", [source, water, sink, filling, emptying], [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Savings account — the simplest Reinforcing loop. Interest flows in from outside
|
||||
* (a Source), and the bigger the Balance the larger the interest: Balance → [+]
|
||||
* → interest → (inflow) → Balance. Even number of `−` (zero) → Reinforcing.
|
||||
*/
|
||||
function savings(): Model {
|
||||
// Source up-left, Balance down-right: the interest valve lands at their
|
||||
// midpoint (above Balance), so the `Balance → interest` link arcs back up as a
|
||||
// visible Reinforcing loop instead of overlapping the inflow pipe.
|
||||
const source = makeCloud({ x: -250, y: -70 })
|
||||
const balance = makeStock({ x: 110, y: 30 }, "Balance")
|
||||
const interest = makeFlow(
|
||||
midpoint(source.position, balance.position),
|
||||
"interest",
|
||||
source.id,
|
||||
balance.id,
|
||||
)
|
||||
return model("Savings account", [source, balance, interest], [link(balance, interest, "+")])
|
||||
}
|
||||
|
||||
/**
|
||||
* Coffee cooling — the simplest Balancing loop, plus a Converter. Heat leaves the
|
||||
* cup faster the hotter it is (Coffee → [+] → heat loss) but slower the warmer
|
||||
* the room (room temperature → [−] → heat loss). The loop Coffee → heat loss →
|
||||
* (outflow) → Coffee has one `−` → Balancing: it settles toward room temperature.
|
||||
*/
|
||||
function coffee(): Model {
|
||||
const coffee = makeStock({ x: -200, y: -10 }, "Coffee")
|
||||
const sink = makeCloud({ x: 190, y: -8 })
|
||||
const heatLoss = makeFlow(
|
||||
midpoint(coffee.position, sink.position),
|
||||
"heat loss",
|
||||
coffee.id,
|
||||
sink.id,
|
||||
)
|
||||
const room = makeConverter({ x: -29, y: -150 }, "room temperature")
|
||||
return model(
|
||||
"Coffee cooling",
|
||||
[coffee, sink, heatLoss, room],
|
||||
[link(coffee, heatLoss, "+"), link(room, heatLoss, "-")],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Population — the whole language in one model. Births add to Population and more
|
||||
* people means more births (Reinforcing); deaths remove from it and more people
|
||||
* means more deaths (Balancing). Converters feed each rate: fertility raises
|
||||
* births (`+`), life expectancy lowers deaths (`−`).
|
||||
*/
|
||||
function population(): Model {
|
||||
const source = makeCloud({ x: -360, y: -8 })
|
||||
const people = makeStock({ x: -48, y: -10 }, "Population")
|
||||
const sink = makeCloud({ x: 250, y: -8 })
|
||||
const births = makeFlow(
|
||||
midpoint(source.position, people.position),
|
||||
"births",
|
||||
source.id,
|
||||
people.id,
|
||||
)
|
||||
const deaths = makeFlow(midpoint(people.position, sink.position), "deaths", people.id, sink.id)
|
||||
const fertility = makeConverter({ x: -204, y: -150 }, "fertility")
|
||||
const lifeExpectancy = makeConverter({ x: 101, y: -150 }, "life expectancy")
|
||||
return model(
|
||||
"Population",
|
||||
[source, people, sink, births, deaths, fertility, lifeExpectancy],
|
||||
[
|
||||
link(people, births, "+"),
|
||||
link(fertility, births, "+"),
|
||||
link(people, deaths, "+"),
|
||||
link(lifeExpectancy, deaths, "-"),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/** The gallery, ordered simplest first. */
|
||||
export const SAMPLES: Sample[] = [
|
||||
{ title: "Bathtub", blurb: "A stock filled and drained — no feedback yet.", build: bathtub },
|
||||
{
|
||||
title: "Savings account",
|
||||
blurb: "Interest on a balance: a Reinforcing loop.",
|
||||
build: savings,
|
||||
},
|
||||
{
|
||||
title: "Coffee cooling",
|
||||
blurb: "Settling toward room temperature: a Balancing loop.",
|
||||
build: coffee,
|
||||
},
|
||||
{
|
||||
title: "Population",
|
||||
blurb: "Births and deaths: Reinforcing and Balancing together.",
|
||||
build: population,
|
||||
},
|
||||
]
|
||||
110
src/model/types.ts
Normal file
110
src/model/types.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* The domain Model — the single source of truth for a meadows document.
|
||||
*
|
||||
* The Vue Flow graph is a derived projection of this (ADR-0002); the future
|
||||
* simulator reads this and knows nothing of the view layer. Everything here is
|
||||
* plain, serialisable data (no class instances, no functions) so it round-trips
|
||||
* as JSON (F8) and deep-clones cheaply for undo snapshots (F9).
|
||||
*
|
||||
* Vocabulary follows CONTEXT.md. Structural rules follow the ADRs:
|
||||
* - A Flow is a *node* with `source`/`target` references, not an edge (ADR-0003).
|
||||
* - Source/Sink boundaries are materialised Cloud nodes, so no end is ever null.
|
||||
* - The only stored edge type is the Information Link; loops are detected, never
|
||||
* stored (ADR-0001).
|
||||
*/
|
||||
|
||||
/** Schema version stamped on every Model for round-trip migrations (F8). */
|
||||
export const MODEL_VERSION = 1
|
||||
|
||||
/** The four node kinds. A `kind` field discriminates the `ModelNode` union. */
|
||||
export type NodeKind = "stock" | "flow" | "converter" | "cloud"
|
||||
|
||||
/**
|
||||
* The sign on an Information Link (and inherent to a Flow): `+` when more cause
|
||||
* means more effect, `−` when more cause means less effect. The input that lets
|
||||
* loops be classified Reinforcing/Balancing.
|
||||
*/
|
||||
export type Polarity = "+" | "-"
|
||||
|
||||
export interface Position {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface BaseNode {
|
||||
id: string
|
||||
position: Position
|
||||
}
|
||||
|
||||
/**
|
||||
* An accumulation that holds a quantity over time — the system's memory and the
|
||||
* only stateful element. Changes only through Flows.
|
||||
*/
|
||||
export interface StockNode extends BaseNode {
|
||||
kind: "stock"
|
||||
name: string
|
||||
/** Initial accumulated quantity. Optional in the diagram phase; the simulator reads it. */
|
||||
initialValue?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A rate that moves quantity between two ends. A Flow is a node (ADR-0003): the
|
||||
* pipe-with-valve you see is rendered from `source`/`target`. Inflow/outflow is
|
||||
* relative to a given Stock, not a separate type — a stock→stock Flow is an
|
||||
* outflow for `source` and an inflow for `target`.
|
||||
*/
|
||||
export interface FlowNode extends BaseNode {
|
||||
kind: "flow"
|
||||
name: string
|
||||
/** Node id of the Stock or Cloud the Flow draws from. */
|
||||
source: string
|
||||
/** Node id of the Stock or Cloud the Flow feeds into. */
|
||||
target: string
|
||||
/** Rate expression, recomputed each instant. Optional in the diagram phase. */
|
||||
equation?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A stateless helper value, recomputed each instant from its inputs (or a fixed
|
||||
* constant). Feeds Flows or other Converters via Information Links.
|
||||
*/
|
||||
export interface ConverterNode extends BaseNode {
|
||||
kind: "converter"
|
||||
name: string
|
||||
/** Expression or constant. Optional in the diagram phase. */
|
||||
equation?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A model boundary (Source/Sink) on an open Flow end — drawn as a cloud.
|
||||
* Auto-managed: spawned when a Flow is left open-ended, removed when the Flow
|
||||
* attaches to a Stock (ADR-0003). Carries no name and no value.
|
||||
*/
|
||||
export interface CloudNode extends BaseNode {
|
||||
kind: "cloud"
|
||||
}
|
||||
|
||||
export type ModelNode = StockNode | FlowNode | ConverterNode | CloudNode
|
||||
|
||||
/**
|
||||
* Shows that one element's value influences a Flow's or Converter's value.
|
||||
* Carries a Polarity; carries no quantity and never targets a Stock (a Stock
|
||||
* changes only via Flows).
|
||||
*/
|
||||
export interface InformationLink {
|
||||
id: string
|
||||
/** Source node id — a Stock, Flow, or Converter (the value being read). */
|
||||
source: string
|
||||
/** Target node id — a Flow or Converter, never a Stock (ADR-0001). */
|
||||
target: string
|
||||
polarity: Polarity
|
||||
}
|
||||
|
||||
/** One saved document: the unit of save / export / reopen. */
|
||||
export interface Model {
|
||||
version: number
|
||||
id: string
|
||||
name: string
|
||||
nodes: ModelNode[]
|
||||
infoLinks: InformationLink[]
|
||||
}
|
||||
51
src/model/validation.ts
Normal file
51
src/model/validation.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Structure guard (C7, F4) — decides what dragging source→target *means* and
|
||||
* whether it's allowed, from the domain rules in CONTEXT.md / ADR-0001:
|
||||
*
|
||||
* - A Flow terminates on a Stock or Cloud; its other end is a Stock or Cloud
|
||||
* too (never cloud→cloud, which would bound nothing).
|
||||
* - An Information Link terminates on a Flow or Converter, never a Stock
|
||||
* (Stocks change only via Flows). Its source is a Stock, Flow, or Converter,
|
||||
* never a Cloud (a boundary carries no value).
|
||||
*
|
||||
* The *target* kind disambiguates the gesture: dropping on a Stock/Cloud can only
|
||||
* be a Flow; dropping on a Flow/Converter can only be an Information Link. This
|
||||
* is "guide, don't nag" (F4): the editor refuses an invalid drop mid-gesture
|
||||
* rather than erroring after the fact.
|
||||
*/
|
||||
import type { Model, ModelNode } from "./types"
|
||||
|
||||
/** What a source→target connection would create, or null if the rules forbid it. */
|
||||
export type ConnectionIntent = "flow" | "info" | null
|
||||
|
||||
export function intentFor(source: ModelNode, target: ModelNode): ConnectionIntent {
|
||||
if (source.id === target.id) return null
|
||||
|
||||
if (target.kind === "stock" || target.kind === "cloud") {
|
||||
// A Flow. Both ends must be Stocks or Clouds, and not cloud→cloud.
|
||||
if (source.kind !== "stock" && source.kind !== "cloud") return null
|
||||
if (source.kind === "cloud" && target.kind === "cloud") return null
|
||||
return "flow"
|
||||
}
|
||||
|
||||
// target is a Flow or Converter → an Information Link.
|
||||
if (source.kind === "cloud") return null
|
||||
return "info"
|
||||
}
|
||||
|
||||
/** Whether source→target is allowed *and* not a duplicate of an existing edge. */
|
||||
export function canConnect(model: Model, sourceId: string, targetId: string): boolean {
|
||||
const source = model.nodes.find((node) => node.id === sourceId)
|
||||
const target = model.nodes.find((node) => node.id === targetId)
|
||||
if (!source || !target) return false
|
||||
|
||||
const intent = intentFor(source, target)
|
||||
if (intent === null) return false
|
||||
|
||||
if (intent === "info") {
|
||||
return !model.infoLinks.some((link) => link.source === sourceId && link.target === targetId)
|
||||
}
|
||||
return !model.nodes.some(
|
||||
(node) => node.kind === "flow" && node.source === sourceId && node.target === targetId,
|
||||
)
|
||||
}
|
||||
239
src/store/model.ts
Normal file
239
src/store/model.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Model store (C5) — the live home of the source-of-truth Model plus undo/redo.
|
||||
*
|
||||
* Every structural write goes through an action here; the Vue Flow view is a
|
||||
* projection downstream (ADR-0002). Undo is snapshot-based (T5): we deep-clone
|
||||
* the whole Model onto a bounded history stack before each change. Models are
|
||||
* small and the data is plain, so cloning is cheap and always correct — no
|
||||
* command objects to keep in sync.
|
||||
*
|
||||
* Drags are a special case: positions change many times per gesture, so the
|
||||
* restore point is taken once on drag start (`beginInteraction`) and the
|
||||
* position writes during/after the drag don't record history.
|
||||
*/
|
||||
import { defineStore } from "pinia"
|
||||
import { computed, ref } from "vue"
|
||||
import {
|
||||
emptyModel,
|
||||
makeCloud,
|
||||
makeConverter,
|
||||
makeFlow,
|
||||
makeStock,
|
||||
midpoint,
|
||||
newId,
|
||||
nextName,
|
||||
} from "@/model/factory"
|
||||
import { detectLoops } from "@/model/loops"
|
||||
import type { ConverterNode, Model, ModelNode, Position, StockNode } from "@/model/types"
|
||||
import { canConnect, intentFor } from "@/model/validation"
|
||||
|
||||
/** Ring-buffer depth for undo (F9 target: ≥50 steps). */
|
||||
const HISTORY_LIMIT = 50
|
||||
|
||||
export const useModelStore = defineStore("model", () => {
|
||||
const model = ref<Model>(emptyModel())
|
||||
const past = ref<Model[]>([])
|
||||
const future = ref<Model[]>([])
|
||||
|
||||
const canUndo = computed(() => past.value.length > 0)
|
||||
const canRedo = computed(() => future.value.length > 0)
|
||||
const nodeCount = computed(() => model.value.nodes.length)
|
||||
|
||||
// Feedback loops are derived from the wiring, never stored (ADR-0001). This
|
||||
// re-runs only on *structural* change: detectLoops reads ids/kinds/links, not
|
||||
// positions, so Vue's per-property reactivity skips it during node drags (F5).
|
||||
const detected = computed(() => detectLoops(model.value))
|
||||
const loops = computed(() => detected.value.loops)
|
||||
const loopsCapped = computed(() => detected.value.capped)
|
||||
|
||||
function snapshot(): Model {
|
||||
// Plain, deep, proxy-immune clone. JSON is safe here precisely because the
|
||||
// Model is contractually JSON-serialisable (F8); structuredClone would choke
|
||||
// on Vue reactive proxies that array reassignments leave nested in the state.
|
||||
return JSON.parse(JSON.stringify(model.value)) as Model
|
||||
}
|
||||
|
||||
/** Push a restore point before a mutation and drop the redo branch. */
|
||||
function record(): void {
|
||||
past.value.push(snapshot())
|
||||
if (past.value.length > HISTORY_LIMIT) past.value.shift()
|
||||
future.value = []
|
||||
}
|
||||
|
||||
function findNode(id: string): ModelNode | undefined {
|
||||
return model.value.nodes.find((node) => node.id === id)
|
||||
}
|
||||
|
||||
function addStock(position: Position): StockNode {
|
||||
record()
|
||||
const node = makeStock(position, nextName(model.value.nodes, "stock"))
|
||||
model.value.nodes.push(node)
|
||||
return node
|
||||
}
|
||||
|
||||
function addConverter(position: Position): ConverterNode {
|
||||
record()
|
||||
const node = makeConverter(position, nextName(model.value.nodes, "converter"))
|
||||
model.value.nodes.push(node)
|
||||
return node
|
||||
}
|
||||
|
||||
/** Take the undo restore point at the start of a drag gesture. */
|
||||
function beginInteraction(): void {
|
||||
record()
|
||||
}
|
||||
|
||||
/** Commit a node's new position. No history — the drag already recorded one. */
|
||||
function moveNode(id: string, position: Position): void {
|
||||
const node = findNode(id)
|
||||
if (node) node.position = { ...position }
|
||||
}
|
||||
|
||||
function renameNode(id: string, name: string): void {
|
||||
const node = findNode(id)
|
||||
if (!node || node.kind === "cloud") return
|
||||
record()
|
||||
node.name = name
|
||||
}
|
||||
|
||||
/**
|
||||
* Create whatever source→target means under the structure guard: an
|
||||
* Information Link (default `+` polarity, F6) onto a Flow/Converter, or a Flow
|
||||
* node (valve at the midpoint) between two Stocks/Clouds. Invalid or duplicate
|
||||
* connections are silently ignored — the gesture is also guarded upstream.
|
||||
*/
|
||||
function connect(sourceId: string, targetId: string): void {
|
||||
const source = findNode(sourceId)
|
||||
const target = findNode(targetId)
|
||||
if (!source || !target || !canConnect(model.value, sourceId, targetId)) return
|
||||
|
||||
const intent = intentFor(source, target)
|
||||
record()
|
||||
if (intent === "info") {
|
||||
model.value.infoLinks.push({
|
||||
id: newId("link"),
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
polarity: "+",
|
||||
})
|
||||
} else if (intent === "flow") {
|
||||
const name = nextName(model.value.nodes, "flow")
|
||||
const position = midpoint(source.position, target.position)
|
||||
model.value.nodes.push(makeFlow(position, name, sourceId, targetId))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open-ended Flow: drop a Stock's connection on empty canvas to spawn a Sink
|
||||
* Cloud there and a Flow into it (ADR-0003). Only Stocks originate boundary
|
||||
* flows for now; other sources are ignored.
|
||||
*/
|
||||
function connectToNewCloud(sourceId: string, dropPosition: Position): void {
|
||||
const source = findNode(sourceId)
|
||||
if (!source || source.kind !== "stock") return
|
||||
record()
|
||||
const cloud = makeCloud({ x: dropPosition.x - 18, y: dropPosition.y - 18 })
|
||||
const name = nextName(model.value.nodes, "flow")
|
||||
model.value.nodes.push(cloud)
|
||||
model.value.nodes.push(
|
||||
makeFlow(midpoint(source.position, cloud.position), name, sourceId, cloud.id),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a node and everything that would otherwise dangle: Flow nodes that
|
||||
* reference it as an end, Information Links touching any removed node, and then
|
||||
* any Cloud left unreferenced (a Cloud exists only to bound a Flow, ADR-0003).
|
||||
*/
|
||||
function removeNode(id: string): void {
|
||||
record()
|
||||
const removed = new Set<string>([id])
|
||||
for (const node of model.value.nodes) {
|
||||
if (node.kind === "flow" && (node.source === id || node.target === id)) removed.add(node.id)
|
||||
}
|
||||
model.value.nodes = model.value.nodes.filter((node) => !removed.has(node.id))
|
||||
model.value.infoLinks = model.value.infoLinks.filter(
|
||||
(link) => !removed.has(link.source) && !removed.has(link.target),
|
||||
)
|
||||
pruneClouds()
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip an Information Link's polarity (F6). Polarity is captured on create
|
||||
* (default `+`) and toggled in one click on the edge badge; it is the loop
|
||||
* detector's only structural input (ADR-0001), so each flip is a real,
|
||||
* undoable model change.
|
||||
*/
|
||||
function toggleLinkPolarity(id: string): void {
|
||||
const link = model.value.infoLinks.find((l) => l.id === id)
|
||||
if (!link) return
|
||||
record()
|
||||
link.polarity = link.polarity === "+" ? "-" : "+"
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a single Information Link by id. Links are edges in the projection,
|
||||
* not nodes, so they need their own delete path (selecting a link and pressing
|
||||
* Delete). No cascade: a link touches nothing that depends on it.
|
||||
*/
|
||||
function removeInfoLink(id: string): void {
|
||||
if (!model.value.infoLinks.some((link) => link.id === id)) return
|
||||
record()
|
||||
model.value.infoLinks = model.value.infoLinks.filter((link) => link.id !== id)
|
||||
}
|
||||
|
||||
/** Drop Clouds no longer referenced by any Flow. */
|
||||
function pruneClouds(): void {
|
||||
const referenced = new Set<string>()
|
||||
for (const node of model.value.nodes) {
|
||||
if (node.kind === "flow") {
|
||||
referenced.add(node.source)
|
||||
referenced.add(node.target)
|
||||
}
|
||||
}
|
||||
model.value.nodes = model.value.nodes.filter(
|
||||
(node) => node.kind !== "cloud" || referenced.has(node.id),
|
||||
)
|
||||
}
|
||||
|
||||
/** Replace the whole document (load / import / new). Clears history. */
|
||||
function setModel(next: Model): void {
|
||||
model.value = next
|
||||
past.value = []
|
||||
future.value = []
|
||||
}
|
||||
|
||||
function undo(): void {
|
||||
if (!canUndo.value) return
|
||||
future.value.push(snapshot())
|
||||
model.value = past.value.pop() as Model
|
||||
}
|
||||
|
||||
function redo(): void {
|
||||
if (!canRedo.value) return
|
||||
past.value.push(snapshot())
|
||||
model.value = future.value.pop() as Model
|
||||
}
|
||||
|
||||
return {
|
||||
model,
|
||||
canUndo,
|
||||
canRedo,
|
||||
nodeCount,
|
||||
loops,
|
||||
loopsCapped,
|
||||
addStock,
|
||||
addConverter,
|
||||
connect,
|
||||
connectToNewCloud,
|
||||
beginInteraction,
|
||||
moveNode,
|
||||
renameNode,
|
||||
removeNode,
|
||||
removeInfoLink,
|
||||
toggleLinkPolarity,
|
||||
setModel,
|
||||
undo,
|
||||
redo,
|
||||
}
|
||||
})
|
||||
@@ -15,3 +15,15 @@
|
||||
@theme {
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Larger, easier-to-grab connection handles (Vue Flow defaults to 6px). The
|
||||
Flow valve is small, so its handles get extra size. Loaded after Vue Flow's
|
||||
own CSS (see main.ts) so these win. */
|
||||
.vue-flow__handle {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
.vue-flow__node-flow .vue-flow__handle {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user