Files
meadows/src/store/model.ts
Julien Calixte 4a0498ee55 feat(editor): give each node and link a description
A free-text "why this element is here" note on Stocks, Flows,
Converters, and Information Links — editable in the Inspector next to
the unit, shown read-only in the gloss card below the generic kind
definition on selection. The Inspector now also opens for Information
Links, which previously had no editing surface.
2026-06-21 15:19:31 +02:00

327 lines
11 KiB
TypeScript

/**
* 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,
Rule,
SimSpec,
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
}
/**
* Set (or clear) a Stock's initial value — the quantity the simulator starts
* from (ADR-0004). `undefined` un-equips it, sending the Model back to "not yet
* simulatable". No-op when unchanged, so a blur with no edit doesn't burn undo.
*/
function setInitialValue(id: string, value: number | undefined): void {
const node = findNode(id)
if (!node || node.kind !== "stock" || node.initialValue === value) return
record()
if (value === undefined) delete node.initialValue
else node.initialValue = value
}
/** Set (or clear) a Stock's display unit (e.g. "°C"). Empty clears it. No-op when unchanged. */
function setUnit(id: string, unit: string): void {
const node = findNode(id)
if (!node || node.kind !== "stock") return
const next = unit.trim() || undefined
if (node.unit === next) return
record()
if (next === undefined) delete node.unit
else node.unit = next
}
/**
* Set (or clear) the free-text description on a named node or an Information
* Link — the "why this element exists" note surfaced on selection. Empty
* clears it; no-op when unchanged, so a blur with no edit doesn't burn undo.
*/
function setDescription(id: string, text: string): void {
const next = text.trim() || undefined
const node = findNode(id)
if (node && node.kind !== "cloud") {
if (node.description === next) return
record()
if (next === undefined) delete node.description
else node.description = next
return
}
const link = model.value.infoLinks.find((l) => l.id === id)
if (!link || link.description === next) return
record()
if (next === undefined) delete link.description
else link.description = next
}
/**
* Set (or clear) how a Flow's rate or a Converter's value is computed (ADR-0004:
* one of the fixed rules, never a formula). No-op when unchanged.
*/
function setRule(id: string, rule: Rule | undefined): void {
const node = findNode(id)
if (!node || (node.kind !== "flow" && node.kind !== "converter")) return
if (JSON.stringify(node.rule) === JSON.stringify(rule)) return
record()
if (rule === undefined) delete node.rule
else node.rule = rule
}
/** Set the run parameters (start / stop / dt). No-op when unchanged. */
function setSimSpec(spec: SimSpec): void {
const current = model.value.sim
if (
current &&
current.start === spec.start &&
current.stop === spec.stop &&
current.dt === spec.dt
) {
return
}
record()
model.value.sim = spec
}
/**
* Create whatever source→target means under the structure guard: an
* 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,
setInitialValue,
setUnit,
setDescription,
setRule,
setSimSpec,
removeNode,
removeInfoLink,
toggleLinkPolarity,
setModel,
undo,
redo,
}
})