feat(store): add model store with snapshot undo/redo and loops
This commit is contained in:
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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user