diff --git a/src/model/catalogues.ts b/src/model/catalogues.ts new file mode 100644 index 0000000..4c32115 --- /dev/null +++ b/src/model/catalogues.ts @@ -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; +} + +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 { + 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); +} diff --git a/src/model/colorSchemes.ts b/src/model/colorSchemes.ts new file mode 100644 index 0000000..0d2fd68 --- /dev/null +++ b/src/model/colorSchemes.ts @@ -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; +} + +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; +} diff --git a/src/model/edgeStyles.ts b/src/model/edgeStyles.ts new file mode 100644 index 0000000..2dfb81b --- /dev/null +++ b/src/model/edgeStyles.ts @@ -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; + } +} diff --git a/src/model/index.ts b/src/model/index.ts new file mode 100644 index 0000000..7b0574b --- /dev/null +++ b/src/model/index.ts @@ -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"; diff --git a/src/model/mediums.ts b/src/model/mediums.ts new file mode 100644 index 0000000..a3b2ae5 --- /dev/null +++ b/src/model/mediums.ts @@ -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(); + + 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 }; +} diff --git a/src/model/model.test.ts b/src/model/model.test.ts new file mode 100644 index 0000000..ae84afd --- /dev/null +++ b/src/model/model.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/model/nodeTypes.ts b/src/model/nodeTypes.ts new file mode 100644 index 0000000..a749718 --- /dev/null +++ b/src/model/nodeTypes.ts @@ -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; +} diff --git a/src/model/properties.ts b/src/model/properties.ts new file mode 100644 index 0000000..63c7679 --- /dev/null +++ b/src/model/properties.ts @@ -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, +): 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 { + const out: Record = {}; + for (const def of defs) out[def.key] = defaultValue(def); + return out; +} diff --git a/src/model/svgIcons.ts b/src/model/svgIcons.ts new file mode 100644 index 0000000..b68f452 --- /dev/null +++ b/src/model/svgIcons.ts @@ -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 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 `${inner}`; +} + +export const SVG_ICONS: Record = { + source: svg( + `` + + `` + + ``, + ), + sink: svg( + `` + + ``, + ), + tank: svg( + `` + + `` + + ``, + ), + pump: svg( + `` + + `` + + ``, + ), + valve: svg( + `` + + `` + + ``, + ), + junction: svg( + `` + + ``, + ), + filter: svg( + `` + + ``, + ), + meter: svg( + `` + + `` + + `` + + ``, + ), + heater: svg( + `` + + ``, + ), + elbow: svg( + `` + + ``, + ), + compressor: svg( + `` + + ``, + ), +}; + +/** 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)}`; +}