feat(model): add domain types and model factory
This commit is contained in:
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 }
|
||||||
|
}
|
||||||
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[]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user