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:
parent
3758164635
commit
e28a0ef074
62
src/model/catalogues.ts
Normal file
62
src/model/catalogues.ts
Normal 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
94
src/model/colorSchemes.ts
Normal 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
83
src/model/edgeStyles.ts
Normal 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
7
src/model/index.ts
Normal 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
106
src/model/mediums.ts
Normal 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
190
src/model/model.test.ts
Normal 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
246
src/model/nodeTypes.ts
Normal 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
205
src/model/properties.ts
Normal 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
81
src/model/svgIcons.ts
Normal 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)}`;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user