feat(model): add domain model — mediums, properties, catalogues, node/edge types

- Relation registry with medium & direction connection validation (canConnect)
- Color schemes (default/dark/blueprint/high-contrast) keyed by medium
- Typed property schema (string/int/float/enum/catalogueItem(s)/color/list)
  with grouping and pure coercion/validation
- Catalogues (pipes/pumps/materials) with item attributes
- Node type library with SVG icon set + port layouts
- Edge style presets with arrow/circle/diamond/bar marker specs
- 25 unit tests covering validation, coercion and lookups

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilya Ashikhmin 2026-07-02 23:11:42 +02:00
parent 3758164635
commit e28a0ef074
9 changed files with 1074 additions and 0 deletions

62
src/model/catalogues.ts Normal file
View File

@ -0,0 +1,62 @@
/**
* Catalogues are named collections of reusable items (pipe specs, pump models,
* materials ). `catalogueItem` / `catalogueItems` properties reference items
* by id. Item attributes can feed the mock solver (e.g. pipe diameter).
*/
export interface CatalogueItem {
id: string;
label: string;
attrs?: Record<string, string | number>;
}
export interface Catalogue {
id: string;
label: string;
items: CatalogueItem[];
}
export const DEFAULT_CATALOGUES: Catalogue[] = [
{
id: "pipes",
label: "Pipe specifications",
items: [
{ id: "dn50", label: "DN50 steel", attrs: { diameter: 50, material: "steel", roughness: 0.045 } },
{ id: "dn100", label: "DN100 steel", attrs: { diameter: 100, material: "steel", roughness: 0.045 } },
{ id: "dn150", label: "DN150 ductile", attrs: { diameter: 150, material: "ductile", roughness: 0.1 } },
{ id: "dn200", label: "DN200 PVC", attrs: { diameter: 200, material: "pvc", roughness: 0.0015 } },
{ id: "dn300", label: "DN300 concrete", attrs: { diameter: 300, material: "concrete", roughness: 0.3 } },
],
},
{
id: "pumps",
label: "Pump models",
items: [
{ id: "p-small", label: "CR-3 (small)", attrs: { head: 20, maxFlow: 6 } },
{ id: "p-medium", label: "CR-15 (medium)", attrs: { head: 45, maxFlow: 17 } },
{ id: "p-large", label: "CR-64 (large)", attrs: { head: 90, maxFlow: 70 } },
],
},
{
id: "materials",
label: "Materials",
items: [
{ id: "steel", label: "Steel" },
{ id: "pvc", label: "PVC" },
{ id: "ductile", label: "Ductile iron" },
{ id: "concrete", label: "Concrete" },
{ id: "copper", label: "Copper" },
],
},
];
export function cataloguesById(list: Catalogue[]): Record<string, Catalogue> {
return Object.fromEntries(list.map((c) => [c.id, c]));
}
export function findCatalogueItem(
catalogue: Catalogue | undefined,
itemId: string,
): CatalogueItem | undefined {
return catalogue?.items.find((it) => it.id === itemId);
}

94
src/model/colorSchemes.ts Normal file
View File

@ -0,0 +1,94 @@
/**
* Color schemes map each medium to a stroke color and define canvas chrome
* (background, grid, selection). Ports and edges are colored by their medium
* so a diagram reads at a glance. Multiple presets are provided; the active
* scheme is part of the editor configuration.
*/
export interface ColorScheme {
id: string;
label: string;
background: string;
grid: string;
selection: string;
/** Fallback color for mediums not explicitly listed. */
defaultMedium: string;
/** medium id → color. */
mediums: Record<string, string>;
}
export const COLOR_SCHEMES: ColorScheme[] = [
{
id: "default",
label: "Default",
background: "#f7f8fa",
grid: "#e2e6ec",
selection: "#2f6df6",
defaultMedium: "#5b6472",
mediums: {
water: "#2f80ed",
"hot-water": "#eb5757",
gas: "#f2994a",
oil: "#6b4f2a",
power: "#f2c94c",
signal: "#9b51e0",
},
},
{
id: "dark",
label: "Dark",
background: "#1b1f27",
grid: "#2b313c",
selection: "#4c9aff",
defaultMedium: "#aeb6c2",
mediums: {
water: "#56ccf2",
"hot-water": "#ff8a80",
gas: "#f2c078",
oil: "#c9a06b",
power: "#ffd54f",
signal: "#bb86fc",
},
},
{
id: "blueprint",
label: "Blueprint",
background: "#0d3b66",
grid: "#1c4e86",
selection: "#ffd166",
defaultMedium: "#cfe3ff",
mediums: {
water: "#ffffff",
"hot-water": "#ffd166",
gas: "#f4a261",
oil: "#e9c46a",
power: "#ef476f",
signal: "#06d6a0",
},
},
{
id: "high-contrast",
label: "High contrast",
background: "#ffffff",
grid: "#cccccc",
selection: "#000000",
defaultMedium: "#000000",
mediums: {
water: "#0000ff",
"hot-water": "#ff0000",
gas: "#ff8c00",
oil: "#654321",
power: "#008000",
signal: "#800080",
},
},
];
export function getColorScheme(id: string): ColorScheme {
return COLOR_SCHEMES.find((s) => s.id === id) ?? COLOR_SCHEMES[0];
}
/** Resolve a medium's color within a scheme, falling back to the default. */
export function mediumColor(scheme: ColorScheme, medium: string): string {
return scheme.mediums[medium] ?? scheme.defaultMedium;
}

83
src/model/edgeStyles.ts Normal file
View File

@ -0,0 +1,83 @@
/**
* Edge (connector) styling: line appearance plus source/target markers
* (arrow, circle, diamond, bar ). These presets are pure descriptions; the
* canvas layer maps them onto JointJS link markup & attrs. Marker geometry is
* produced here as SVG path/attrs so it can be unit-tested independently.
*/
export type MarkerType =
| "none"
| "arrow"
| "open-arrow"
| "circle"
| "diamond"
| "bar";
export type LineStyle = "solid" | "dashed" | "dotted" | "double";
export interface EdgeStyleDef {
id: string;
label: string;
line: LineStyle;
width: number;
sourceMarker: MarkerType;
targetMarker: MarkerType;
}
export const EDGE_STYLES: EdgeStyleDef[] = [
{ id: "pipe", label: "Pipe (arrow)", line: "solid", width: 4, sourceMarker: "none", targetMarker: "arrow" },
{ id: "plain", label: "Plain line", line: "solid", width: 3, sourceMarker: "none", targetMarker: "none" },
{ id: "bidirectional", label: "Bidirectional", line: "solid", width: 4, sourceMarker: "arrow", targetMarker: "arrow" },
{ id: "signal", label: "Signal (dashed)", line: "dashed", width: 2, sourceMarker: "none", targetMarker: "open-arrow" },
{ id: "gauge", label: "Gauge (circles)", line: "solid", width: 3, sourceMarker: "circle", targetMarker: "circle" },
{ id: "valve-link", label: "Diamond ends", line: "solid", width: 3, sourceMarker: "diamond", targetMarker: "diamond" },
{ id: "boundary", label: "Boundary (bar)", line: "dotted", width: 2, sourceMarker: "bar", targetMarker: "bar" },
];
export function edgeStyle(id: string): EdgeStyleDef {
return EDGE_STYLES.find((s) => s.id === id) ?? EDGE_STYLES[0];
}
/** SVG stroke-dasharray for a line style at a given width (empty = solid). */
export function dashArray(line: LineStyle, width: number): string {
switch (line) {
case "dashed":
return `${width * 3} ${width * 2}`;
case "dotted":
return `${width} ${width * 1.5}`;
default:
return "";
}
}
export interface MarkerSpec {
type: "path" | "circle";
/** SVG path `d` for type==="path". */
d?: string;
/** radius for type==="circle". */
r?: number;
fill: string;
}
/**
* Build a JointJS-compatible marker spec pointing along +x (JointJS rotates it
* to the link end). `color` fills solid markers; open markers are stroked.
*/
export function markerSpec(type: MarkerType, color: string): MarkerSpec | null {
switch (type) {
case "none":
return null;
case "arrow":
return { type: "path", d: "M 0 0 L 12 6 L 0 12 L 3 6 z", fill: color };
case "open-arrow":
return { type: "path", d: "M 12 0 L 0 6 L 12 12", fill: "none" };
case "diamond":
return { type: "path", d: "M 0 6 L 7 0 L 14 6 L 7 12 z", fill: color };
case "bar":
return { type: "path", d: "M 0 -6 L 0 12 L 3 12 L 3 -6 z", fill: color };
case "circle":
return { type: "circle", r: 6, fill: color };
default:
return null;
}
}

7
src/model/index.ts Normal file
View File

@ -0,0 +1,7 @@
export * from "./mediums";
export * from "./colorSchemes";
export * from "./properties";
export * from "./catalogues";
export * from "./svgIcons";
export * from "./nodeTypes";
export * from "./edgeStyles";

106
src/model/mediums.ts Normal file
View File

@ -0,0 +1,106 @@
/**
* Mediums (a.k.a. relation types) describe what flows through a pipeline:
* water, power, gas, steam Ports carry a medium and a direction; two ports
* may be connected only when their mediums are compatible AND their directions
* are complementary. This module is pure and fully unit-tested.
*/
export type PortDirection = "in" | "out" | "inout";
export interface MediumDef {
/** Stable id, e.g. "water". */
id: string;
label: string;
/**
* Ids of mediums this medium may connect to. A medium is implicitly
* compatible with itself; list others here to allow cross-medium links
* (e.g. "hot-water" "water").
*/
compatibleWith?: string[];
}
/** A concrete port on a placed node, used for connection checks. */
export interface PortRef {
nodeId: string;
portId: string;
medium: string;
direction: PortDirection;
}
export const DEFAULT_MEDIUMS: MediumDef[] = [
{ id: "water", label: "Water", compatibleWith: ["hot-water"] },
{ id: "hot-water", label: "Hot water", compatibleWith: ["water"] },
{ id: "gas", label: "Gas" },
{ id: "oil", label: "Oil" },
{ id: "power", label: "Power" },
{ id: "signal", label: "Signal / Data" },
];
/** Registry allowing O(1) medium lookup and compatibility checks. */
export class RelationRegistry {
private byId = new Map<string, MediumDef>();
constructor(mediums: MediumDef[] = DEFAULT_MEDIUMS) {
for (const m of mediums) this.byId.set(m.id, m);
}
get(id: string): MediumDef | undefined {
return this.byId.get(id);
}
list(): MediumDef[] {
return [...this.byId.values()];
}
/** True when two mediums are allowed to be joined (symmetric). */
mediumsCompatible(a: string, b: string): boolean {
if (a === b) return true;
const ma = this.byId.get(a);
const mb = this.byId.get(b);
const aOk = !!ma?.compatibleWith?.includes(b);
const bOk = !!mb?.compatibleWith?.includes(a);
return aOk || bOk;
}
}
/** Directions form a valid flow pair (out → in, or inout is a wildcard). */
export function directionsCompatible(a: PortDirection, b: PortDirection): boolean {
if (a === "inout" || b === "inout") return true;
return a !== b; // one "out" and one "in"
}
export interface ConnectResult {
ok: boolean;
reason?: string;
}
/**
* Central connection-validation rule used by the canvas' validateConnection
* hook and by the model tests. Rejects self-connections, incompatible
* mediums, and incompatible directions.
*/
export function canConnect(
source: PortRef,
target: PortRef,
relations: RelationRegistry,
): ConnectResult {
if (source.nodeId === target.nodeId && source.portId === target.portId) {
return { ok: false, reason: "A port cannot connect to itself." };
}
if (source.nodeId === target.nodeId) {
return { ok: false, reason: "Cannot connect a node to itself." };
}
if (!relations.mediumsCompatible(source.medium, target.medium)) {
return {
ok: false,
reason: `Incompatible mediums: ${source.medium}${target.medium}.`,
};
}
if (!directionsCompatible(source.direction, target.direction)) {
return {
ok: false,
reason: `Incompatible directions: ${source.direction}${target.direction}.`,
};
}
return { ok: true };
}

190
src/model/model.test.ts Normal file
View File

@ -0,0 +1,190 @@
import { describe, it, expect } from "vitest";
import {
RelationRegistry,
canConnect,
directionsCompatible,
type PortRef,
} from "./mediums";
import {
coerceValue,
defaultValue,
buildDefaults,
sortByGroup,
type PropertyDef,
} from "./properties";
import { getColorScheme, mediumColor } from "./colorSchemes";
import { dashArray, markerSpec, edgeStyle } from "./edgeStyles";
import { cataloguesById, DEFAULT_CATALOGUES } from "./catalogues";
import { NODE_TYPES, nodeType, nodeTypesByGroup } from "./nodeTypes";
import { iconDataUri } from "./svgIcons";
const rel = new RelationRegistry();
function port(p: Partial<PortRef>): PortRef {
return { nodeId: "n1", portId: "out", medium: "water", direction: "out", ...p };
}
describe("mediums / relations", () => {
it("connects out → in of same medium", () => {
const r = canConnect(port({}), port({ nodeId: "n2", portId: "in", direction: "in" }), rel);
expect(r.ok).toBe(true);
});
it("rejects out → out (bad direction)", () => {
const r = canConnect(port({}), port({ nodeId: "n2", portId: "o2", direction: "out" }), rel);
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/direction/i);
});
it("rejects incompatible mediums", () => {
const r = canConnect(
port({ medium: "water" }),
port({ nodeId: "n2", portId: "in", direction: "in", medium: "power" }),
rel,
);
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/medium/i);
});
it("allows declared cross-medium compatibility (water ↔ hot-water)", () => {
const r = canConnect(
port({ medium: "hot-water" }),
port({ nodeId: "n2", portId: "in", direction: "in", medium: "water" }),
rel,
);
expect(r.ok).toBe(true);
});
it("rejects self connection on the same node", () => {
const r = canConnect(port({}), port({ portId: "in", direction: "in" }), rel);
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/node to itself/i);
});
it("inout acts as a wildcard direction", () => {
expect(directionsCompatible("inout", "out")).toBe(true);
expect(directionsCompatible("in", "out")).toBe(true);
expect(directionsCompatible("in", "in")).toBe(false);
});
});
describe("property coercion", () => {
const cats = cataloguesById(DEFAULT_CATALOGUES);
it("coerces int and clamps to range", () => {
const def: PropertyDef = { key: "x", label: "x", type: "int", group: "physics", min: 0, max: 10 };
expect(coerceValue(def, "7.9").value).toBe(7);
expect(coerceValue(def, "-4").value).toBe(0);
expect(coerceValue(def, "42").value).toBe(10);
expect(coerceValue(def, "abc").ok).toBe(false);
});
it("coerces float without truncation", () => {
const def: PropertyDef = { key: "x", label: "x", type: "float", group: "physics" };
expect(coerceValue(def, "3.14").value).toBeCloseTo(3.14);
});
it("validates color hex", () => {
const def: PropertyDef = { key: "c", label: "c", type: "color", group: "presentation" };
expect(coerceValue(def, "#33aaff").ok).toBe(true);
expect(coerceValue(def, "#3af").ok).toBe(true);
expect(coerceValue(def, "red").ok).toBe(false);
});
it("validates enum membership", () => {
const def: PropertyDef = {
key: "s", label: "s", type: "enum", group: "physics",
options: [{ value: "a", label: "A" }, { value: "b", label: "B" }],
};
expect(coerceValue(def, "a").ok).toBe(true);
expect(coerceValue(def, "z").ok).toBe(false);
});
it("validates catalogueItem against its catalogue", () => {
const def: PropertyDef = { key: "m", label: "m", type: "catalogueItem", group: "physics", catalogue: "pumps" };
expect(coerceValue(def, "p-small", cats).ok).toBe(true);
expect(coerceValue(def, "nope", cats).ok).toBe(false);
expect(coerceValue(def, "", cats).ok).toBe(true); // empty allowed
});
it("dedupes catalogueItems and rejects unknown", () => {
const def: PropertyDef = { key: "t", label: "t", type: "catalogueItems", group: "physics", catalogue: "materials" };
expect(coerceValue(def, ["steel", "steel", "pvc"], cats).value).toEqual(["steel", "pvc"]);
expect(coerceValue(def, ["steel", "unobtanium"], cats).ok).toBe(false);
});
it("coerces numeric list", () => {
const def: PropertyDef = { key: "l", label: "l", type: "list", group: "physics", itemType: "float" };
expect(coerceValue(def, ["1.5", "2", 3]).value).toEqual([1.5, 2, 3]);
expect(coerceValue(def, ["1", "x"]).ok).toBe(false);
});
it("supplies sensible defaults per type", () => {
expect(defaultValue({ key: "a", label: "a", type: "int", group: "g" })).toBe(0);
expect(defaultValue({ key: "a", label: "a", type: "list", group: "g" })).toEqual([]);
expect(defaultValue({ key: "a", label: "a", type: "string", group: "g", default: "hi" })).toBe("hi");
});
it("sorts property defs by group order", () => {
const defs: PropertyDef[] = [
{ key: "p", label: "p", type: "float", group: "physics" },
{ key: "t", label: "t", type: "string", group: "identity" },
{ key: "c", label: "c", type: "color", group: "presentation" },
];
expect(sortByGroup(defs).map((d) => d.group)).toEqual(["identity", "presentation", "physics"]);
});
});
describe("color schemes", () => {
it("resolves medium color and falls back to default", () => {
const s = getColorScheme("default");
expect(mediumColor(s, "water")).toBe("#2f80ed");
expect(mediumColor(s, "unknown")).toBe(s.defaultMedium);
});
it("falls back to first scheme for unknown id", () => {
expect(getColorScheme("does-not-exist").id).toBe("default");
});
});
describe("edge styles", () => {
it("produces dash arrays only for non-solid lines", () => {
expect(dashArray("solid", 4)).toBe("");
expect(dashArray("dashed", 4)).not.toBe("");
});
it("returns null marker for none and a spec otherwise", () => {
expect(markerSpec("none", "#000")).toBeNull();
expect(markerSpec("arrow", "#000")?.type).toBe("path");
expect(markerSpec("circle", "#000")?.type).toBe("circle");
});
it("falls back to first style for unknown id", () => {
expect(edgeStyle("nope").id).toBe("pipe");
});
});
describe("node types", () => {
it("every node type has an icon and at least one port", () => {
for (const nt of NODE_TYPES) {
expect(nt.ports.length).toBeGreaterThan(0);
expect(iconDataUri(nt.icon)).toMatch(/^data:image\/svg\+xml/);
}
});
it("every node type includes identity title property", () => {
for (const nt of NODE_TYPES) {
expect(nt.properties.some((p) => p.key === "title")).toBe(true);
}
});
it("groups node types preserving order", () => {
const groups = nodeTypesByGroup();
expect(groups.length).toBeGreaterThan(1);
expect(groups.flatMap((g) => g.types)).toHaveLength(NODE_TYPES.length);
});
it("looks up by id", () => {
expect(nodeType("pump")?.label).toBe("Pump");
expect(nodeType("missing")).toBeUndefined();
});
it("builds a full default property bag", () => {
const bag = buildDefaults(nodeType("pump")!.properties);
expect(bag.title).toBe("");
expect(bag.power).toBe(7.5);
});
});

246
src/model/nodeTypes.ts Normal file
View File

@ -0,0 +1,246 @@
/**
* Node type library. Each {@link NodeTypeDef} bundles the vector icon, port
* layout (id, medium, direction, side) and a typed property schema grouped
* into identity / geometry / presentation / physics. The palette lists these
* grouped by `group`; dropping one instantiates a canvas node.
*/
import type { PortDirection } from "./mediums";
import type { PropertyDef } from "./properties";
export type PortSide = "left" | "right" | "top" | "bottom";
export interface PortDef {
id: string;
medium: string;
direction: PortDirection;
side: PortSide;
label?: string;
}
export interface NodeTypeDef {
id: string;
label: string;
/** Palette group. */
group: string;
icon: string; // key into SVG_ICONS
size: { width: number; height: number };
ports: PortDef[];
properties: PropertyDef[];
}
/** Identity + presentation props shared by every node. */
function commonProps(): PropertyDef[] {
return [
{ key: "title", label: "Title", type: "string", group: "identity", default: "" },
{ key: "tag", label: "Tag / ID", type: "string", group: "identity", default: "" },
{ key: "notes", label: "Notes", type: "string", group: "identity", default: "" },
{ key: "color", label: "Accent color", type: "color", group: "presentation", default: "#2f80ed" },
{
key: "showLabel",
label: "Show label",
type: "enum",
group: "presentation",
default: "yes",
options: [
{ value: "yes", label: "Yes" },
{ value: "no", label: "No" },
],
},
];
}
const SIZE = { width: 72, height: 72 };
export const NODE_TYPES: NodeTypeDef[] = [
{
id: "water-source",
label: "Water source",
group: "Sources & sinks",
icon: "source",
size: SIZE,
ports: [{ id: "out", medium: "water", direction: "out", side: "right", label: "out" }],
properties: [
...commonProps(),
{ key: "supply", label: "Supply", type: "float", group: "physics", unit: "L/s", default: 10, min: 0 },
{ key: "head", label: "Head", type: "float", group: "physics", unit: "m", default: 30, min: 0 },
],
},
{
id: "water-sink",
label: "Demand / outlet",
group: "Sources & sinks",
icon: "sink",
size: SIZE,
ports: [{ id: "in", medium: "water", direction: "in", side: "left", label: "in" }],
properties: [
...commonProps(),
{ key: "demand", label: "Demand", type: "float", group: "physics", unit: "L/s", default: 5, min: 0 },
],
},
{
id: "tank",
label: "Storage tank",
group: "Storage",
icon: "tank",
size: { width: 80, height: 88 },
ports: [
{ id: "in", medium: "water", direction: "in", side: "top", label: "in" },
{ id: "out", medium: "water", direction: "out", side: "bottom", label: "out" },
],
properties: [
...commonProps(),
{ key: "capacity", label: "Capacity", type: "float", group: "physics", unit: "m³", default: 100, min: 0 },
{ key: "level", label: "Level", type: "float", group: "physics", unit: "%", default: 60, min: 0, max: 100 },
],
},
{
id: "pump",
label: "Pump",
group: "Active equipment",
icon: "pump",
size: SIZE,
ports: [
{ id: "in", medium: "water", direction: "in", side: "left", label: "suction" },
{ id: "out", medium: "water", direction: "out", side: "right", label: "discharge" },
],
properties: [
...commonProps(),
{ key: "model", label: "Pump model", type: "catalogueItem", group: "physics", catalogue: "pumps" },
{ key: "power", label: "Power", type: "float", group: "physics", unit: "kW", default: 7.5, min: 0 },
{
key: "state",
label: "State",
type: "enum",
group: "physics",
default: "on",
options: [
{ value: "on", label: "Running" },
{ value: "off", label: "Stopped" },
],
},
],
},
{
id: "valve",
label: "Valve",
group: "Active equipment",
icon: "valve",
size: SIZE,
ports: [
{ id: "in", medium: "water", direction: "in", side: "left", label: "in" },
{ id: "out", medium: "water", direction: "out", side: "right", label: "out" },
],
properties: [
...commonProps(),
{
key: "state",
label: "State",
type: "enum",
group: "physics",
default: "open",
options: [
{ value: "open", label: "Open" },
{ value: "throttled", label: "Throttled" },
{ value: "closed", label: "Closed" },
],
},
{ key: "openness", label: "Openness", type: "float", group: "physics", unit: "%", default: 100, min: 0, max: 100 },
],
},
{
id: "junction",
label: "Junction",
group: "Fittings",
icon: "junction",
size: { width: 56, height: 56 },
ports: [
{ id: "p-left", medium: "water", direction: "inout", side: "left" },
{ id: "p-right", medium: "water", direction: "inout", side: "right" },
{ id: "p-top", medium: "water", direction: "inout", side: "top" },
{ id: "p-bottom", medium: "water", direction: "inout", side: "bottom" },
],
properties: [...commonProps()],
},
{
id: "filter",
label: "Filter / strainer",
group: "Treatment",
icon: "filter",
size: SIZE,
ports: [
{ id: "in", medium: "water", direction: "in", side: "left", label: "in" },
{ id: "out", medium: "water", direction: "out", side: "right", label: "out" },
],
properties: [
...commonProps(),
{ key: "spec", label: "Pipe spec", type: "catalogueItem", group: "physics", catalogue: "pipes" },
{ key: "mesh", label: "Mesh", type: "int", group: "physics", unit: "µm", default: 100, min: 1 },
],
},
{
id: "meter",
label: "Flow meter",
group: "Instrumentation",
icon: "meter",
size: { width: 60, height: 60 },
ports: [
{ id: "in", medium: "water", direction: "in", side: "left", label: "in" },
{ id: "out", medium: "water", direction: "out", side: "right", label: "out" },
],
properties: [
...commonProps(),
{ key: "reading", label: "Reading", type: "float", group: "physics", unit: "L/s", default: 0, readonly: true },
{ key: "tags", label: "Alarms", type: "catalogueItems", group: "physics", catalogue: "materials" },
],
},
{
id: "heater",
label: "Heat exchanger",
group: "Treatment",
icon: "heater",
size: SIZE,
ports: [
{ id: "in", medium: "water", direction: "in", side: "left", label: "in" },
{ id: "out", medium: "hot-water", direction: "out", side: "right", label: "hot out" },
],
properties: [
...commonProps(),
{ key: "powerKw", label: "Rated power", type: "float", group: "physics", unit: "kW", default: 50, min: 0 },
{ key: "setpoints", label: "Setpoints", type: "list", group: "physics", itemType: "float" },
],
},
{
id: "compressor",
label: "Gas compressor",
group: "Active equipment",
icon: "compressor",
size: SIZE,
ports: [
{ id: "in", medium: "gas", direction: "in", side: "left", label: "in" },
{ id: "out", medium: "gas", direction: "out", side: "right", label: "out" },
],
properties: [
...commonProps(),
{ key: "ratio", label: "Pressure ratio", type: "float", group: "physics", default: 2.5, min: 1 },
],
},
];
export function nodeType(id: string): NodeTypeDef | undefined {
return NODE_TYPES.find((n) => n.id === id);
}
/** Palette groups in stable order with their node types. */
export function nodeTypesByGroup(): { group: string; types: NodeTypeDef[] }[] {
const groups: { group: string; types: NodeTypeDef[] }[] = [];
for (const nt of NODE_TYPES) {
let g = groups.find((x) => x.group === nt.group);
if (!g) {
g = { group: nt.group, types: [] };
groups.push(g);
}
g.types.push(nt);
}
return groups;
}

205
src/model/properties.ts Normal file
View File

@ -0,0 +1,205 @@
/**
* Property model. Every node/edge carries a bag of typed properties described
* by {@link PropertyDef}s and organised into {@link PropertyGroup}s (identity,
* geometry, physics, presentation). The properties panel renders an editor per
* type; this module owns the *pure* logic: default values plus coercion &
* validation of raw user input into typed values.
*/
import type { Catalogue } from "./catalogues";
export type PropertyType =
| "string"
| "int"
| "float"
| "enum"
| "catalogueItem"
| "catalogueItems"
| "color"
| "list";
export interface PropertyOption {
value: string;
label: string;
}
export interface PropertyDef {
key: string;
label: string;
type: PropertyType;
group: string;
default?: PropertyValue;
/** enum options. */
options?: PropertyOption[];
/** catalogue id for catalogueItem / catalogueItems. */
catalogue?: string;
/** element type for `list`. */
itemType?: "string" | "int" | "float";
min?: number;
max?: number;
step?: number;
unit?: string;
readonly?: boolean;
description?: string;
}
export type PropertyValue =
| string
| number
| boolean
| string[]
| number[]
| null;
export interface PropertyGroup {
id: string;
label: string;
order: number;
}
export const PROPERTY_GROUPS: PropertyGroup[] = [
{ id: "identity", label: "Identity", order: 0 },
{ id: "geometry", label: "Geometry", order: 1 },
{ id: "presentation", label: "Presentation", order: 2 },
{ id: "physics", label: "Physics", order: 3 },
];
export function groupLabel(id: string): string {
return PROPERTY_GROUPS.find((g) => g.id === id)?.label ?? id;
}
/** Sort property defs by their group order, keeping in-group order stable. */
export function sortByGroup(defs: PropertyDef[]): PropertyDef[] {
const order = new Map(PROPERTY_GROUPS.map((g, i) => [g.id, g.order ?? i]));
return defs
.map((d, i) => ({ d, i }))
.sort((a, b) => {
const ga = order.get(a.d.group) ?? 99;
const gb = order.get(b.d.group) ?? 99;
return ga - gb || a.i - b.i;
})
.map((x) => x.d);
}
export function defaultValue(def: PropertyDef): PropertyValue {
if (def.default !== undefined) return def.default;
switch (def.type) {
case "string":
case "enum":
case "catalogueItem":
return "";
case "color":
return "#888888";
case "int":
case "float":
return 0;
case "catalogueItems":
case "list":
return [];
default:
return null;
}
}
export interface CoerceResult {
ok: boolean;
value?: PropertyValue;
error?: string;
}
function clamp(n: number, def: PropertyDef): number {
if (def.min !== undefined && n < def.min) n = def.min;
if (def.max !== undefined && n > def.max) n = def.max;
return n;
}
const HEX_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
/**
* Coerce and validate a raw value (usually a string from an input) into the
* property's typed representation. Returns `{ ok:false, error }` on failure so
* callers can surface inline validation without throwing.
*/
export function coerceValue(
def: PropertyDef,
raw: unknown,
catalogues?: Record<string, Catalogue>,
): CoerceResult {
switch (def.type) {
case "string":
return { ok: true, value: raw == null ? "" : String(raw) };
case "color": {
const s = String(raw).trim();
if (!HEX_RE.test(s)) return { ok: false, error: "Expected a hex color like #33aaff." };
return { ok: true, value: s };
}
case "int": {
const n = typeof raw === "number" ? raw : parseInt(String(raw).trim(), 10);
if (!Number.isFinite(n)) return { ok: false, error: "Expected an integer." };
return { ok: true, value: clamp(Math.trunc(n), def) };
}
case "float": {
const n = typeof raw === "number" ? raw : parseFloat(String(raw).trim());
if (!Number.isFinite(n)) return { ok: false, error: "Expected a number." };
return { ok: true, value: clamp(n, def) };
}
case "enum": {
const s = String(raw);
const ok = def.options?.some((o) => o.value === s) ?? false;
if (!ok) return { ok: false, error: `"${s}" is not one of the allowed options.` };
return { ok: true, value: s };
}
case "catalogueItem": {
const s = String(raw);
if (s === "") return { ok: true, value: "" };
if (def.catalogue && catalogues) {
const cat = catalogues[def.catalogue];
if (cat && !cat.items.some((it) => it.id === s)) {
return { ok: false, error: `Unknown item "${s}" in catalogue ${def.catalogue}.` };
}
}
return { ok: true, value: s };
}
case "catalogueItems": {
const arr = Array.isArray(raw) ? raw.map(String) : [];
if (def.catalogue && catalogues) {
const cat = catalogues[def.catalogue];
if (cat) {
const bad = arr.find((id) => !cat.items.some((it) => it.id === id));
if (bad) return { ok: false, error: `Unknown item "${bad}".` };
}
}
return { ok: true, value: [...new Set(arr)] };
}
case "list": {
const arr = Array.isArray(raw) ? raw : [];
if (def.itemType === "int" || def.itemType === "float") {
const nums: number[] = [];
for (const el of arr) {
const n = typeof el === "number" ? el : parseFloat(String(el));
if (!Number.isFinite(n)) return { ok: false, error: `"${el}" is not a number.` };
nums.push(def.itemType === "int" ? Math.trunc(n) : n);
}
return { ok: true, value: nums };
}
return { ok: true, value: arr.map(String) };
}
default:
return { ok: false, error: `Unsupported property type: ${(def as PropertyDef).type}.` };
}
}
/** Build a full property bag from defs, applying defaults. */
export function buildDefaults(defs: PropertyDef[]): Record<string, PropertyValue> {
const out: Record<string, PropertyValue> = {};
for (const def of defs) out[def.key] = defaultValue(def);
return out;
}

81
src/model/svgIcons.ts Normal file
View File

@ -0,0 +1,81 @@
/**
* The SVG "set" that defines node visuals. Each entry is standalone SVG markup
* (64×64 viewBox, stroke-based so it reads on any background). Nodes reference
* an icon by key; the canvas renders it as an <image> via a data URI, keeping
* node geometry and the vector art decoupled exactly the "nodes are defined
* by an SVG set with port configuration" model.
*/
const S = 'stroke="#2b2f36" stroke-width="2.5" fill="none" stroke-linejoin="round" stroke-linecap="round"';
const FILL = 'fill="#ffffff"';
function svg(inner: string): string {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">${inner}</svg>`;
}
export const SVG_ICONS: Record<string, string> = {
source: svg(
`<path d="M10 20 h44 v24 a22 8 0 0 1 -44 0 z" ${FILL} ${S}/>` +
`<ellipse cx="32" cy="20" rx="22" ry="8" ${FILL} ${S}/>` +
`<path d="M24 30 l8 8 l8 -8" ${S}/>`,
),
sink: svg(
`<path d="M12 16 h40 l-6 30 a14 6 0 0 1 -28 0 z" ${FILL} ${S}/>` +
`<path d="M26 40 l6 8 l6 -8" ${S}/>`,
),
tank: svg(
`<rect x="14" y="12" width="36" height="40" rx="6" ${FILL} ${S}/>` +
`<path d="M14 40 h36" ${S}/>` +
`<path d="M20 40 a6 5 0 0 0 12 0 a6 5 0 0 0 12 0" ${S}/>`,
),
pump: svg(
`<circle cx="32" cy="32" r="18" ${FILL} ${S}/>` +
`<path d="M32 32 l14 -10 M32 32 l14 10 M32 14 v10" ${S}/>` +
`<circle cx="32" cy="32" r="4" fill="#2b2f36"/>`,
),
valve: svg(
`<path d="M14 18 l18 14 l-18 14 z" ${FILL} ${S}/>` +
`<path d="M50 18 l-18 14 l18 14 z" ${FILL} ${S}/>` +
`<path d="M32 32 v-14 M24 14 h16" ${S}/>`,
),
junction: svg(
`<path d="M8 32 h48 M32 32 v20" ${S}/>` +
`<circle cx="32" cy="32" r="5" fill="#2b2f36"/>`,
),
filter: svg(
`<rect x="14" y="14" width="36" height="36" rx="4" ${FILL} ${S}/>` +
`<path d="M20 24 h24 M20 32 h24 M20 40 h24" ${S}/>`,
),
meter: svg(
`<circle cx="32" cy="32" r="18" ${FILL} ${S}/>` +
`<path d="M32 32 l10 -8" ${S}/>` +
`<circle cx="32" cy="32" r="3" fill="#2b2f36"/>` +
`<path d="M32 14 v4 M50 32 h-4 M32 50 v-4 M14 32 h4" ${S}/>`,
),
heater: svg(
`<circle cx="32" cy="32" r="18" ${FILL} ${S}/>` +
`<path d="M24 40 q4 -8 0 -16 M32 40 q4 -8 0 -16 M40 40 q4 -8 0 -16" ${S}/>`,
),
elbow: svg(
`<path d="M16 48 v-16 a16 16 0 0 1 16 -16 h16" ${FILL} ${S}/>` +
`<path d="M16 48 v-16 a16 16 0 0 1 16 -16 h16" ${S}/>`,
),
compressor: svg(
`<path d="M14 20 h36 v24 h-36 z" ${FILL} ${S}/>` +
`<path d="M14 44 l36 -24 M22 44 v-24 M32 44 v-24 M42 44 v-24" ${S}/>`,
),
};
/** UTF-8 → base64 that works in both jsdom (tests) and the browser. */
function toBase64(str: string): string {
if (typeof btoa === "function") {
return btoa(unescape(encodeURIComponent(str)));
}
// Node/Vitest fallback.
return Buffer.from(str, "utf-8").toString("base64");
}
export function iconDataUri(key: string): string {
const markup = SVG_ICONS[key] ?? SVG_ICONS.junction;
return `data:image/svg+xml;base64,${toBase64(markup)}`;
}