diff --git a/src/model/factory.ts b/src/model/factory.ts new file mode 100644 index 0000000..f66bf3c --- /dev/null +++ b/src/model/factory.ts @@ -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, 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): 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 } +} diff --git a/src/model/types.ts b/src/model/types.ts new file mode 100644 index 0000000..89db7ff --- /dev/null +++ b/src/model/types.ts @@ -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[] +}