feat(domain): add relations, properties, catalogue, symbols and flow model

Pure, framework-free core: port media + color schemes with compatibility
rules, typed/grouped property model with coercion and validation,
catalogue store with physics seeds, SVG symbol registry with port layouts,
and a mass-conserving mock flow solver.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilya Ashikhmin 2026-07-02 23:13:45 +02:00
parent 8a1d2d0da6
commit 372c2c25c4
6 changed files with 908 additions and 0 deletions

View File

@ -0,0 +1,103 @@
/**
* Catalogues are reusable lookup tables (pipe materials, pump models ...).
* A `catalogueItem` property stores an item id; selecting an item may seed
* physics defaults on the owning element via `seeds`.
*/
export interface CatalogueItem {
id: string
label: string
/** Arbitrary typed fields describing the item. */
fields: Record<string, string | number>
/** Property keys → values applied to an element when this item is chosen. */
seeds?: Record<string, string | number>
}
export interface Catalogue {
id: string
label: string
items: CatalogueItem[]
}
export const CATALOGUES: Record<string, Catalogue> = {
pipeMaterial: {
id: 'pipeMaterial',
label: 'Pipe material',
items: [
{
id: 'steel',
label: 'Steel (welded)',
fields: { roughness: 0.045, maxPressure: 40 },
seeds: { roughness: 0.045 }
},
{
id: 'pvc',
label: 'PVC',
fields: { roughness: 0.0015, maxPressure: 16 },
seeds: { roughness: 0.0015 }
},
{
id: 'copper',
label: 'Copper',
fields: { roughness: 0.0015, maxPressure: 25 },
seeds: { roughness: 0.0015 }
},
{
id: 'castIron',
label: 'Cast iron',
fields: { roughness: 0.26, maxPressure: 25 },
seeds: { roughness: 0.26 }
}
]
},
pumpModel: {
id: 'pumpModel',
label: 'Pump model',
items: [
{
id: 'grundfos-cr5',
label: 'Grundfos CR 5',
fields: { ratedFlow: 5, ratedHead: 40, power: 1.1 },
seeds: { flowRate: 5 }
},
{
id: 'grundfos-cr10',
label: 'Grundfos CR 10',
fields: { ratedFlow: 10, ratedHead: 60, power: 2.2 },
seeds: { flowRate: 10 }
},
{
id: 'wilo-mhi',
label: 'Wilo MHI 400',
fields: { ratedFlow: 8, ratedHead: 50, power: 1.5 },
seeds: { flowRate: 8 }
}
]
},
valveType: {
id: 'valveType',
label: 'Valve type',
items: [
{ id: 'gate', label: 'Gate valve', fields: { kv: 120 } },
{ id: 'ball', label: 'Ball valve', fields: { kv: 200 } },
{ id: 'butterfly', label: 'Butterfly valve', fields: { kv: 90 } },
{ id: 'check', label: 'Check valve', fields: { kv: 80 } }
]
}
}
export function getCatalogue(id: string): Catalogue | undefined {
return CATALOGUES[id]
}
export function getCatalogueItem(catalogueId: string, itemId: string): CatalogueItem | undefined {
return CATALOGUES[catalogueId]?.items.find((i) => i.id === itemId)
}
/**
* Given a chosen catalogue item, return the property seeds to merge into the
* owning element's values (empty object when the item has none).
*/
export function seedsFromItem(catalogueId: string, itemId: string): Record<string, string | number> {
return getCatalogueItem(catalogueId, itemId)?.seeds ?? {}
}

View File

@ -0,0 +1,121 @@
/**
* Mock hydraulic solver. Real pipe-network solving (Hardy-Cross etc.) is out
* of scope; instead we distribute a conserved flow from sources to sinks over
* a spanning tree of the network. On a tree the result is exact (Kirchhoff /
* mass conservation holds at every node); extra loop edges are assigned zero
* flow. This is enough to drive a believable animated visualization.
*/
export interface FlowNodeInput {
id: string
/** Net supply: > 0 source, < 0 demand, 0 transit. */
supply: number
}
export interface FlowEdgeInput {
id: string
source: string
target: string
}
export interface EdgeFlow {
id: string
/** Non-negative magnitude (m³/h). */
magnitude: number
/** true → flows source→target, false → target→source. */
forward: boolean
}
export interface FlowSolution {
edges: Map<string, EdgeFlow>
maxMagnitude: number
/** Sum of |imbalance| across nodes — 0 means perfect conservation. */
residual: number
}
/**
* Balance supplies so total supply === total demand (scales demands to match
* available supply). Returns a new map; the input is not mutated.
*/
export function balanceSupplies(nodes: FlowNodeInput[]): Map<string, number> {
const supply = nodes.filter((n) => n.supply > 0).reduce((a, n) => a + n.supply, 0)
const demand = nodes.filter((n) => n.supply < 0).reduce((a, n) => a - n.supply, 0)
const out = new Map<string, number>()
const scale = demand > 0 && supply > 0 ? supply / demand : 1
for (const n of nodes) out.set(n.id, n.supply < 0 ? n.supply * scale : n.supply)
return out
}
export function solveFlow(nodes: FlowNodeInput[], edges: FlowEdgeInput[]): FlowSolution {
const net = balanceSupplies(nodes)
const result = new Map<string, EdgeFlow>()
for (const e of edges) result.set(e.id, { id: e.id, magnitude: 0, forward: true })
// Adjacency over undirected edges (skip dangling endpoints).
const adj = new Map<string, { edge: FlowEdgeInput; other: string }[]>()
for (const n of nodes) adj.set(n.id, [])
for (const e of edges) {
if (!net.has(e.source) || !net.has(e.target)) continue
adj.get(e.source)!.push({ edge: e, other: e.target })
adj.get(e.target)!.push({ edge: e, other: e.source })
}
// Build a spanning forest with BFS; record the tree edge used to reach a node.
const parentEdge = new Map<string, FlowEdgeInput>()
const parentOf = new Map<string, string>()
const order: string[] = []
const visited = new Set<string>()
// Visit sources first so trees are rooted at supply where possible.
const roots = [...nodes].sort((a, b) => (net.get(b.id)! - net.get(a.id)!)).map((n) => n.id)
for (const root of roots) {
if (visited.has(root)) continue
visited.add(root)
const queue = [root]
while (queue.length) {
const u = queue.shift()!
order.push(u)
for (const { edge, other } of adj.get(u) ?? []) {
if (visited.has(other)) continue
visited.add(other)
parentEdge.set(other, edge)
parentOf.set(other, u)
queue.push(other)
}
}
}
// Post-order accumulation: subtree net supply flows through the parent edge.
const subtreeNet = new Map<string, number>()
for (const id of order) subtreeNet.set(id, net.get(id) ?? 0)
for (let i = order.length - 1; i >= 0; i--) {
const node = order[i]
const parent = parentOf.get(node)
if (parent === undefined) continue
const s = subtreeNet.get(node)!
subtreeNet.set(parent, subtreeNet.get(parent)! + s)
const edge = parentEdge.get(node)!
// Positive subtree surplus flows node → parent.
const forwardIsSourceToTarget = edge.source === node ? s < 0 : s > 0
result.set(edge.id, { id: edge.id, magnitude: Math.abs(s), forward: forwardIsSourceToTarget })
}
// Metrics.
let maxMagnitude = 0
for (const f of result.values()) maxMagnitude = Math.max(maxMagnitude, f.magnitude)
const inflow = new Map<string, number>()
for (const id of net.keys()) inflow.set(id, net.get(id) ?? 0)
for (const e of edges) {
const f = result.get(e.id)!
if (f.magnitude === 0) continue
const from = f.forward ? e.source : e.target
const to = f.forward ? e.target : e.source
if (inflow.has(from)) inflow.set(from, inflow.get(from)! - f.magnitude)
if (inflow.has(to)) inflow.set(to, inflow.get(to)! + f.magnitude)
}
let residual = 0
for (const v of inflow.values()) residual += Math.abs(v)
return { edges: result, maxMagnitude, residual }
}

View File

@ -0,0 +1,149 @@
/**
* Typed, grouped property model shared by nodes and edges. The property panel
* renders an editor per `PropertyType`; the model stores plain JSON values.
*/
export type PropertyType =
| 'string'
| 'int'
| 'float'
| 'enum'
| 'color'
| 'catalogueItem'
| 'catalogueItems'
| 'listOfValues'
export interface PropertyDef {
key: string
label: string
type: PropertyType
group: string
/** Options for `enum` and item labels for `listOfValues`. */
options?: { value: string; label: string }[]
/** Catalogue id for `catalogueItem` / `catalogueItems`. */
catalogue?: string
unit?: string
min?: number
max?: number
step?: number
required?: boolean
default?: PropertyValue
/** Element type of a `listOfValues`. */
itemType?: 'string' | 'int' | 'float'
description?: string
}
export type PropertyValue =
| string
| number
| boolean
| string[]
| number[]
| null
export interface PropertyGroup {
id: string
label: string
defs: PropertyDef[]
}
/** A full schema is an ordered list of groups. */
export type PropertySchema = PropertyGroup[]
export interface ValidationResult {
ok: boolean
errors: Record<string, string>
}
/** Flatten a schema into a key → def lookup. */
export function schemaIndex(schema: PropertySchema): Map<string, PropertyDef> {
const map = new Map<string, PropertyDef>()
for (const g of schema) for (const d of g.defs) map.set(d.key, d)
return map
}
/** Build a values object from the schema defaults. */
export function defaultsFor(schema: PropertySchema): Record<string, PropertyValue> {
const out: Record<string, PropertyValue> = {}
for (const g of schema) {
for (const d of g.defs) {
out[d.key] = d.default !== undefined ? d.default : emptyValue(d)
}
}
return out
}
function emptyValue(d: PropertyDef): PropertyValue {
switch (d.type) {
case 'int':
case 'float':
return d.min ?? 0
case 'catalogueItems':
case 'listOfValues':
return []
case 'enum':
return d.options?.[0]?.value ?? ''
case 'color':
return '#888888'
default:
return ''
}
}
/** Coerce a raw input value into the type dictated by the definition. */
export function coerce(def: PropertyDef, raw: unknown): PropertyValue {
switch (def.type) {
case 'int': {
const n = Math.round(Number(raw))
return Number.isFinite(n) ? clampNumber(def, n) : (def.min ?? 0)
}
case 'float': {
const n = Number(raw)
return Number.isFinite(n) ? clampNumber(def, n) : (def.min ?? 0)
}
case 'catalogueItems':
return Array.isArray(raw) ? (raw as string[]).map(String) : []
case 'listOfValues':
if (!Array.isArray(raw)) return []
return def.itemType && def.itemType !== 'string'
? (raw as unknown[]).map((v) => Number(v)).filter((v) => Number.isFinite(v))
: (raw as unknown[]).map(String)
case 'color':
case 'enum':
case 'catalogueItem':
case 'string':
default:
return raw == null ? '' : String(raw)
}
}
function clampNumber(def: PropertyDef, n: number): number {
if (def.min != null && n < def.min) return def.min
if (def.max != null && n > def.max) return def.max
return n
}
/** Validate a values object against the schema. */
export function validate(
schema: PropertySchema,
values: Record<string, PropertyValue>
): ValidationResult {
const errors: Record<string, string> = {}
for (const g of schema) {
for (const d of g.defs) {
const v = values[d.key]
if (d.required && (v === '' || v == null || (Array.isArray(v) && v.length === 0))) {
errors[d.key] = `${d.label} is required`
continue
}
if ((d.type === 'int' || d.type === 'float') && typeof v === 'number') {
if (d.min != null && v < d.min) errors[d.key] = `${d.label} must be ≥ ${d.min}`
else if (d.max != null && v > d.max) errors[d.key] = `${d.label} must be ≤ ${d.max}`
}
if (d.type === 'enum' && d.options && v !== '' && !d.options.some((o) => o.value === v)) {
errors[d.key] = `${d.label} has an invalid option`
}
}
}
return { ok: Object.keys(errors).length === 0, errors }
}

View File

@ -0,0 +1,106 @@
/**
* Port "relations" describe the medium a port carries (water, power, gas ...).
* Two ports may be connected only when their media are compatible. Colors are
* provided by swappable color schemes so the whole diagram can be re-themed.
*/
export type MediumId =
| 'water'
| 'hotWater'
| 'sewage'
| 'gas'
| 'steam'
| 'air'
| 'power'
| 'signal'
export interface Medium {
id: MediumId
label: string
/** Media (besides itself) this medium may connect to. */
compatibleWith: MediumId[]
}
export const MEDIA: Record<MediumId, Medium> = {
water: { id: 'water', label: 'Cold water', compatibleWith: ['hotWater'] },
hotWater: { id: 'hotWater', label: 'Hot water', compatibleWith: ['water', 'steam'] },
sewage: { id: 'sewage', label: 'Sewage', compatibleWith: [] },
gas: { id: 'gas', label: 'Gas', compatibleWith: [] },
steam: { id: 'steam', label: 'Steam', compatibleWith: ['hotWater'] },
air: { id: 'air', label: 'Compressed air', compatibleWith: [] },
power: { id: 'power', label: 'Power line', compatibleWith: [] },
signal: { id: 'signal', label: 'Signal / control', compatibleWith: [] }
}
export const MEDIUM_IDS = Object.keys(MEDIA) as MediumId[]
/** True when a port of medium `a` may be connected to a port of medium `b`. */
export function canConnectMedia(a: MediumId, b: MediumId): boolean {
if (a === b) return true
return (
MEDIA[a]?.compatibleWith.includes(b) === true ||
MEDIA[b]?.compatibleWith.includes(a) === true
)
}
// ---- Color schemes --------------------------------------------------------
export interface ColorScheme {
id: string
label: string
colors: Record<MediumId, string>
}
export const COLOR_SCHEMES: ColorScheme[] = [
{
id: 'default',
label: 'Default',
colors: {
water: '#2f7fe0',
hotWater: '#e0562f',
sewage: '#7a5b34',
gas: '#e0b62f',
steam: '#d14fd1',
air: '#3fb6c0',
power: '#e0d030',
signal: '#9aa0a6'
}
},
{
id: 'colorblind',
label: 'Color-blind safe',
colors: {
water: '#0072B2',
hotWater: '#D55E00',
sewage: '#8c6d31',
gas: '#E69F00',
steam: '#CC79A7',
air: '#56B4E9',
power: '#F0E442',
signal: '#999999'
}
},
{
id: 'print',
label: 'Print (mono-friendly)',
colors: {
water: '#1f3a93',
hotWater: '#7b241c',
sewage: '#4d3b1f',
gas: '#7d6608',
steam: '#4a235a',
air: '#0e6251',
power: '#7d6608',
signal: '#424949'
}
}
]
export function getColorScheme(id: string): ColorScheme {
return COLOR_SCHEMES.find((s) => s.id === id) ?? COLOR_SCHEMES[0]
}
export function mediumColor(mediumId: MediumId | string, schemeId = 'default'): string {
const scheme = getColorScheme(schemeId)
return scheme.colors[mediumId as MediumId] ?? '#9aa0a6'
}

View File

@ -0,0 +1,137 @@
/**
* Reusable property-schema fragments for nodes and edges. Each symbol composes
* a base (Identity + Presentation) with its own Physics group.
*/
import type { PropertyGroup, PropertySchema, PropertyDef } from './properties'
const yesNo: PropertyDef['options'] = [
{ value: 'yes', label: 'Show' },
{ value: 'no', label: 'Hide' }
]
export function identityGroup(defaultTitle: string): PropertyGroup {
return {
id: 'identity',
label: 'Identity',
defs: [
{ key: 'title', label: 'Title', type: 'string', group: 'identity', default: defaultTitle },
{ key: 'tag', label: 'Tag / ID', type: 'string', group: 'identity', default: '' },
{
key: 'notes',
label: 'Notes',
type: 'string',
group: 'identity',
default: ''
}
]
}
}
export function nodePresentationGroup(color: string): PropertyGroup {
return {
id: 'presentation',
label: 'Presentation',
defs: [
{ key: 'color', label: 'Accent color', type: 'color', group: 'presentation', default: color },
{
key: 'rotation',
label: 'Rotation',
type: 'int',
group: 'presentation',
unit: '°',
min: 0,
max: 270,
step: 90,
default: 0
},
{ key: 'width', label: 'Width', type: 'int', group: 'presentation', unit: 'px', min: 24, max: 240, default: 72 },
{ key: 'height', label: 'Height', type: 'int', group: 'presentation', unit: 'px', min: 24, max: 240, default: 72 },
{ key: 'showLabel', label: 'Label', type: 'enum', group: 'presentation', options: yesNo, default: 'yes' }
]
}
}
export function physicsGroup(defs: PropertyDef[]): PropertyGroup {
return { id: 'physics', label: 'Physics', defs: defs.map((d) => ({ ...d, group: 'physics' })) }
}
/** Build a node schema from a physics-group definition list. */
export function nodeSchema(
defaultTitle: string,
color: string,
physics: PropertyDef[]
): PropertySchema {
return [identityGroup(defaultTitle), nodePresentationGroup(color), physicsGroup(physics)]
}
// ---- Edge schema ----------------------------------------------------------
export const MARKER_OPTIONS: PropertyDef['options'] = [
{ value: 'none', label: 'None' },
{ value: 'arrow', label: 'Arrow' },
{ value: 'openArrow', label: 'Open arrow' },
{ value: 'circle', label: 'Circle' },
{ value: 'diamond', label: 'Diamond' },
{ value: 'bar', label: 'Bar' }
]
export const LINE_STYLE_OPTIONS: PropertyDef['options'] = [
{ value: 'solid', label: 'Solid' },
{ value: 'dashed', label: 'Dashed' },
{ value: 'dotted', label: 'Dotted' }
]
export function edgeSchema(): PropertySchema {
return [
{
id: 'identity',
label: 'Identity',
defs: [
{ key: 'title', label: 'Title', type: 'string', group: 'identity', default: '' },
{ key: 'tag', label: 'Tag / ID', type: 'string', group: 'identity', default: '' }
]
},
{
id: 'presentation',
label: 'Presentation',
defs: [
{ key: 'color', label: 'Line color', type: 'color', group: 'presentation', default: '#2f7fe0' },
{ key: 'lineStyle', label: 'Line style', type: 'enum', group: 'presentation', options: LINE_STYLE_OPTIONS, default: 'solid' },
{ key: 'strokeWidth', label: 'Width', type: 'int', group: 'presentation', unit: 'px', min: 1, max: 12, default: 3 },
{ key: 'tailMarker', label: 'Tail marker', type: 'enum', group: 'presentation', options: MARKER_OPTIONS, default: 'none' },
{ key: 'headMarker', label: 'Head marker', type: 'enum', group: 'presentation', options: MARKER_OPTIONS, default: 'arrow' }
]
},
{
id: 'physics',
label: 'Physics',
defs: [
{ key: 'length', label: 'Length', type: 'float', group: 'physics', unit: 'm', min: 0, step: 0.1, default: 10 },
{ key: 'diameter', label: 'Diameter', type: 'float', group: 'physics', unit: 'mm', min: 1, step: 1, default: 100 },
{ key: 'material', label: 'Material', type: 'catalogueItem', group: 'physics', catalogue: 'pipeMaterial', default: 'steel' },
{ key: 'roughness', label: 'Roughness', type: 'float', group: 'physics', unit: 'mm', min: 0, step: 0.001, default: 0.045 },
{
key: 'flowType',
label: 'Flow type',
type: 'enum',
group: 'physics',
options: [
{ value: 'auto', label: 'Auto' },
{ value: 'laminar', label: 'Laminar' },
{ value: 'turbulent', label: 'Turbulent' }
],
default: 'auto'
},
{ key: 'designFlow', label: 'Design flow', type: 'float', group: 'physics', unit: 'm³/h', min: 0, step: 0.1, default: 0 },
{
key: 'tags',
label: 'Zone tags',
type: 'listOfValues',
group: 'physics',
itemType: 'string',
default: []
}
]
}
]
}

View File

@ -0,0 +1,292 @@
/**
* Symbol registry. Each symbol pairs an inline SVG icon (drawn in a 0..100
* viewBox) with a port layout and a property schema. Ports carry a medium
* (see relations.ts) that governs which connections are legal.
*/
import type { MediumId } from './relations'
import type { PropertySchema, PropertyDef } from './properties'
import { nodeSchema } from './schemas'
export interface PortSpec {
id: string
/** Position as a fraction of the symbol box (0..1). */
x: number
y: number
medium: MediumId
label?: string
}
export interface SymbolDef {
id: string
label: string
category: string
width: number
height: number
/** SVG body drawn inside a `0 0 100 100` viewBox (no outer <svg>). */
svg: string
ports: PortSpec[]
schema: PropertySchema
}
const stroke = '#d7dae0'
// Common physics fragments -------------------------------------------------
const elevation: PropertyDef = { key: 'elevation', label: 'Elevation', type: 'float', group: 'physics', unit: 'm', default: 0, step: 0.1 }
const capacity: PropertyDef = { key: 'capacity', label: 'Capacity', type: 'float', group: 'physics', unit: 'm³', min: 0, default: 10 }
function icon(body: string): string {
return `<g fill="none" stroke="${stroke}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">${body}</g>`
}
export const SYMBOLS: SymbolDef[] = [
// ---- Sources ----
{
id: 'reservoir',
label: 'Reservoir',
category: 'Sources',
width: 76,
height: 76,
svg: icon(
'<path d="M20 30 h60 v50 h-60 z"/><path d="M20 44 q15 -10 30 0 t30 0" fill="none"/><path d="M50 30 v-14"/>'
),
ports: [{ id: 'out', x: 1, y: 0.6, medium: 'water', label: 'Outlet' }],
schema: nodeSchema('Reservoir', '#2f7fe0', [
elevation,
{ ...capacity, default: 500 },
{ key: 'head', label: 'Static head', type: 'float', unit: 'm', default: 20, group: 'physics' }
])
},
{
id: 'well',
label: 'Well / Source',
category: 'Sources',
width: 64,
height: 64,
svg: icon('<circle cx="50" cy="50" r="30"/><path d="M50 30 v40 M30 50 h40"/>'),
ports: [{ id: 'out', x: 1, y: 0.5, medium: 'water', label: 'Outlet' }],
schema: nodeSchema('Source', '#2f7fe0', [
{ key: 'supplyFlow', label: 'Supply flow', type: 'float', unit: 'm³/h', min: 0, default: 20, group: 'physics' },
{ key: 'pressure', label: 'Pressure', type: 'float', unit: 'bar', min: 0, default: 4, group: 'physics' }
])
},
{
id: 'grid-supply',
label: 'Grid supply',
category: 'Sources',
width: 64,
height: 64,
svg: icon('<path d="M55 20 L35 55 h20 L45 80"/><circle cx="50" cy="50" r="30"/>'),
ports: [{ id: 'out', x: 1, y: 0.5, medium: 'power', label: 'Line' }],
schema: nodeSchema('Grid supply', '#e0d030', [
{ key: 'voltage', label: 'Voltage', type: 'float', unit: 'kV', min: 0, default: 0.4, group: 'physics' }
])
},
// ---- Consumers ----
{
id: 'outlet',
label: 'Outlet / Demand',
category: 'Consumers',
width: 60,
height: 60,
svg: icon('<circle cx="50" cy="50" r="28"/><path d="M40 50 h20 M52 42 l8 8 -8 8"/>'),
ports: [{ id: 'in', x: 0, y: 0.5, medium: 'water', label: 'Inlet' }],
schema: nodeSchema('Outlet', '#2f7fe0', [
{ key: 'demand', label: 'Demand', type: 'float', unit: 'm³/h', min: 0, default: 5, group: 'physics' },
{ key: 'minPressure', label: 'Min pressure', type: 'float', unit: 'bar', min: 0, default: 1.5, group: 'physics' }
])
},
{
id: 'load',
label: 'Electrical load',
category: 'Consumers',
width: 60,
height: 60,
svg: icon('<rect x="25" y="30" width="50" height="40" rx="4"/><path d="M50 20 v10 M40 45 h20 M40 55 h20"/>'),
ports: [{ id: 'in', x: 0, y: 0.5, medium: 'power', label: 'Line' }],
schema: nodeSchema('Load', '#e0d030', [
{ key: 'power', label: 'Power', type: 'float', unit: 'kW', min: 0, default: 5, group: 'physics' }
])
},
// ---- Fittings ----
{
id: 'tee',
label: 'Tee junction',
category: 'Fittings',
width: 56,
height: 56,
svg: icon('<path d="M10 50 h80 M50 50 v40"/>'),
ports: [
{ id: 'a', x: 0, y: 0.5, medium: 'water' },
{ id: 'b', x: 1, y: 0.5, medium: 'water' },
{ id: 'c', x: 0.5, y: 1, medium: 'water' }
],
schema: nodeSchema('Tee', '#7f8894', [])
},
{
id: 'cross',
label: 'Cross junction',
category: 'Fittings',
width: 56,
height: 56,
svg: icon('<path d="M10 50 h80 M50 10 v80"/>'),
ports: [
{ id: 'a', x: 0, y: 0.5, medium: 'water' },
{ id: 'b', x: 1, y: 0.5, medium: 'water' },
{ id: 'c', x: 0.5, y: 0, medium: 'water' },
{ id: 'd', x: 0.5, y: 1, medium: 'water' }
],
schema: nodeSchema('Cross', '#7f8894', [])
},
{
id: 'elbow',
label: 'Elbow',
category: 'Fittings',
width: 52,
height: 52,
svg: icon('<path d="M50 90 V50 H90"/>'),
ports: [
{ id: 'a', x: 0.5, y: 1, medium: 'water' },
{ id: 'b', x: 1, y: 0.5, medium: 'water' }
],
schema: nodeSchema('Elbow', '#7f8894', [])
},
{
id: 'reducer',
label: 'Reducer',
category: 'Fittings',
width: 60,
height: 44,
svg: icon('<path d="M15 30 L45 30 L85 45 L85 70 L45 70 L15 70 Z"/>'),
ports: [
{ id: 'a', x: 0, y: 0.5, medium: 'water' },
{ id: 'b', x: 1, y: 0.5, medium: 'water' }
],
schema: nodeSchema('Reducer', '#7f8894', [
{ key: 'dIn', label: 'Inlet Ø', type: 'float', unit: 'mm', min: 1, default: 150, group: 'physics' },
{ key: 'dOut', label: 'Outlet Ø', type: 'float', unit: 'mm', min: 1, default: 100, group: 'physics' }
])
},
// ---- Equipment ----
{
id: 'pump',
label: 'Pump',
category: 'Equipment',
width: 68,
height: 68,
svg: icon('<circle cx="50" cy="50" r="30"/><path d="M50 50 L74 38 M50 50 L50 20"/>'),
ports: [
{ id: 'in', x: 0, y: 0.5, medium: 'water', label: 'Suction' },
{ id: 'out', x: 1, y: 0.5, medium: 'water', label: 'Discharge' }
],
schema: nodeSchema('Pump', '#3fb6c0', [
{ key: 'model', label: 'Model', type: 'catalogueItem', catalogue: 'pumpModel', default: 'grundfos-cr5', group: 'physics' },
{ key: 'flowRate', label: 'Flow rate', type: 'float', unit: 'm³/h', min: 0, default: 5, group: 'physics' },
{ key: 'head', label: 'Head', type: 'float', unit: 'm', min: 0, default: 40, group: 'physics' }
])
},
{
id: 'valve',
label: 'Valve',
category: 'Equipment',
width: 60,
height: 48,
svg: icon('<path d="M20 30 L20 70 L50 50 Z M80 30 L80 70 L50 50 Z"/>'),
ports: [
{ id: 'a', x: 0, y: 0.5, medium: 'water' },
{ id: 'b', x: 1, y: 0.5, medium: 'water' }
],
schema: nodeSchema('Valve', '#e0562f', [
{ key: 'valveType', label: 'Type', type: 'catalogueItem', catalogue: 'valveType', default: 'gate', group: 'physics' },
{ key: 'opening', label: 'Opening', type: 'int', unit: '%', min: 0, max: 100, default: 100, group: 'physics' }
])
},
{
id: 'tank',
label: 'Storage tank',
category: 'Equipment',
width: 72,
height: 84,
svg: icon('<rect x="25" y="20" width="50" height="60" rx="6"/><path d="M25 62 h50"/>'),
ports: [
{ id: 'in', x: 0.5, y: 0, medium: 'water', label: 'Fill' },
{ id: 'out', x: 0.5, y: 1, medium: 'water', label: 'Drain' }
],
schema: nodeSchema('Tank', '#2f7fe0', [elevation, capacity, { key: 'level', label: 'Level', type: 'int', unit: '%', min: 0, max: 100, default: 60, group: 'physics' }])
},
{
id: 'heat-exchanger',
label: 'Heat exchanger',
category: 'Equipment',
width: 84,
height: 60,
svg: icon('<rect x="15" y="30" width="70" height="40" rx="6"/><path d="M25 50 q10 -12 20 0 t20 0 t20 0"/>'),
ports: [
{ id: 'in', x: 0, y: 0.5, medium: 'water', label: 'Cold in' },
{ id: 'out', x: 1, y: 0.5, medium: 'hotWater', label: 'Hot out' }
],
schema: nodeSchema('Heat exchanger', '#e0562f', [
{ key: 'duty', label: 'Duty', type: 'float', unit: 'kW', min: 0, default: 50, group: 'physics' }
])
},
// ---- Instruments ----
{
id: 'flow-meter',
label: 'Flow meter',
category: 'Instruments',
width: 56,
height: 56,
svg: icon('<circle cx="50" cy="50" r="28"/><path d="M35 55 h30 M40 45 l10 -8 10 8"/>'),
ports: [
{ id: 'a', x: 0, y: 0.5, medium: 'water' },
{ id: 'b', x: 1, y: 0.5, medium: 'water' }
],
schema: nodeSchema('Flow meter', '#9aa0a6', [
{ key: 'reading', label: 'Reading', type: 'float', unit: 'm³/h', default: 0, group: 'physics' }
])
},
{
id: 'gauge',
label: 'Pressure gauge',
category: 'Instruments',
width: 52,
height: 52,
svg: icon('<circle cx="50" cy="50" r="28"/><path d="M50 50 L64 38 M50 78 v6"/>'),
ports: [{ id: 'a', x: 0.5, y: 1, medium: 'water' }],
schema: nodeSchema('Gauge', '#9aa0a6', [
{ key: 'reading', label: 'Reading', type: 'float', unit: 'bar', default: 0, group: 'physics' }
])
},
{
id: 'sensor',
label: 'Signal sensor',
category: 'Instruments',
width: 48,
height: 48,
svg: icon('<rect x="28" y="28" width="44" height="44" rx="6"/><path d="M40 50 h20 M50 40 v20"/>'),
ports: [{ id: 'sig', x: 1, y: 0.5, medium: 'signal', label: 'Signal' }],
schema: nodeSchema('Sensor', '#9aa0a6', [])
}
]
export const SYMBOL_CATEGORIES = Array.from(new Set(SYMBOLS.map((s) => s.category)))
const byId = new Map(SYMBOLS.map((s) => [s.id, s]))
export function getSymbol(id: string): SymbolDef | undefined {
return byId.get(id)
}
export function symbolsByCategory(category: string): SymbolDef[] {
return SYMBOLS.filter((s) => s.category === category)
}
/** Full inline SVG string (with outer <svg>) for previews/thumbnails. */
export function symbolSvgMarkup(symbol: SymbolDef, size = symbol.width): string {
return `<svg viewBox="0 0 100 100" width="${size}" height="${(size * symbol.height) / symbol.width}" xmlns="http://www.w3.org/2000/svg">${symbol.svg}</svg>`
}
/** data: URI encoding of a symbol icon, used as the node's <image> href. */
export function symbolDataUri(symbol: SymbolDef): string {
const svg = `<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">${symbol.svg}</svg>`
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
}