diff --git a/src/renderer/diagram/catalogue.ts b/src/renderer/diagram/catalogue.ts new file mode 100644 index 0000000..8a4e4ec --- /dev/null +++ b/src/renderer/diagram/catalogue.ts @@ -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 + /** Property keys → values applied to an element when this item is chosen. */ + seeds?: Record +} + +export interface Catalogue { + id: string + label: string + items: CatalogueItem[] +} + +export const CATALOGUES: Record = { + 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 { + return getCatalogueItem(catalogueId, itemId)?.seeds ?? {} +} diff --git a/src/renderer/diagram/flow.ts b/src/renderer/diagram/flow.ts new file mode 100644 index 0000000..531abc2 --- /dev/null +++ b/src/renderer/diagram/flow.ts @@ -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 + 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 { + 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() + 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() + 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() + 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() + const parentOf = new Map() + const order: string[] = [] + const visited = new Set() + // 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() + 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() + 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 } +} diff --git a/src/renderer/diagram/properties.ts b/src/renderer/diagram/properties.ts new file mode 100644 index 0000000..6983da7 --- /dev/null +++ b/src/renderer/diagram/properties.ts @@ -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 +} + +/** Flatten a schema into a key → def lookup. */ +export function schemaIndex(schema: PropertySchema): Map { + const map = new Map() + 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 { + const out: Record = {} + 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 +): ValidationResult { + const errors: Record = {} + 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 } +} diff --git a/src/renderer/diagram/relations.ts b/src/renderer/diagram/relations.ts new file mode 100644 index 0000000..f0ec1d5 --- /dev/null +++ b/src/renderer/diagram/relations.ts @@ -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 = { + 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 +} + +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' +} diff --git a/src/renderer/diagram/schemas.ts b/src/renderer/diagram/schemas.ts new file mode 100644 index 0000000..c08122b --- /dev/null +++ b/src/renderer/diagram/schemas.ts @@ -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: [] + } + ] + } + ] +} diff --git a/src/renderer/diagram/symbols.ts b/src/renderer/diagram/symbols.ts new file mode 100644 index 0000000..27bd024 --- /dev/null +++ b/src/renderer/diagram/symbols.ts @@ -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: 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 `${body}` +} + +export const SYMBOLS: SymbolDef[] = [ + // ---- Sources ---- + { + id: 'reservoir', + label: 'Reservoir', + category: 'Sources', + width: 76, + height: 76, + svg: icon( + '' + ), + 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(''), + 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(''), + 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(''), + 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(''), + 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(''), + 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(''), + 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(''), + 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(''), + 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(''), + 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(''), + 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(''), + 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(''), + 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(''), + 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(''), + 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(''), + 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 ) for previews/thumbnails. */ +export function symbolSvgMarkup(symbol: SymbolDef, size = symbol.width): string { + return `${symbol.svg}` +} + +/** data: URI encoding of a symbol icon, used as the node's href. */ +export function symbolDataUri(symbol: SymbolDef): string { + const svg = `${symbol.svg}` + return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}` +}