feat(store): add model store with snapshot undo/redo and loops

This commit is contained in:
Julien Calixte
2026-06-20 09:37:32 +02:00
parent 6d03d706a0
commit 0aaa4114d9

239
src/store/model.ts Normal file
View 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,
}
})