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