diff --git a/src/model/catalog.ts b/src/model/catalog.ts new file mode 100644 index 0000000..7c726fd --- /dev/null +++ b/src/model/catalog.ts @@ -0,0 +1,87 @@ +/** + * Catalogues — named collections of reference items (pipe series, materials, + * media). `catalogueItem` / `catalogueItems` properties reference items by id. + */ + +export interface CatalogueItem { + id: string; + label: string; + /** Arbitrary reference attributes (e.g. innerDiameter, density). */ + attrs?: Record; +} + +export interface Catalogue { + id: string; + label: string; + items: CatalogueItem[]; +} + +export const CATALOGUES: Catalogue[] = [ + { + id: 'pipeDiameters', + label: 'Pipe diameter series (DN)', + items: [ + { id: 'dn15', label: 'DN 15', attrs: { innerDiameter: 15 } }, + { id: 'dn25', label: 'DN 25', attrs: { innerDiameter: 25 } }, + { id: 'dn32', label: 'DN 32', attrs: { innerDiameter: 32 } }, + { id: 'dn50', label: 'DN 50', attrs: { innerDiameter: 50 } }, + { id: 'dn80', label: 'DN 80', attrs: { innerDiameter: 80 } }, + { id: 'dn100', label: 'DN 100', attrs: { innerDiameter: 100 } }, + { id: 'dn150', label: 'DN 150', attrs: { innerDiameter: 150 } }, + { id: 'dn200', label: 'DN 200', attrs: { innerDiameter: 200 } }, + ], + }, + { + id: 'materials', + label: 'Pipe materials', + items: [ + { id: 'steel', label: 'Carbon steel', attrs: { roughness: 0.045 } }, + { id: 'stainless', label: 'Stainless steel', attrs: { roughness: 0.015 } }, + { id: 'pe', label: 'Polyethylene (PE)', attrs: { roughness: 0.007 } }, + { id: 'pvc', label: 'PVC', attrs: { roughness: 0.0015 } }, + { id: 'copper', label: 'Copper', attrs: { roughness: 0.0015 } }, + { id: 'castIron', label: 'Cast iron', attrs: { roughness: 0.26 } }, + ], + }, + { + id: 'media', + label: 'Transported media', + items: [ + { id: 'coldWater', label: 'Cold water', attrs: { density: 998 } }, + { id: 'hotWater', label: 'Hot water', attrs: { density: 971 } }, + { id: 'steam', label: 'Steam', attrs: { density: 0.6 } }, + { id: 'naturalGas', label: 'Natural gas', attrs: { density: 0.68 } }, + { id: 'sewage', label: 'Sewage', attrs: { density: 1010 } }, + ], + }, + { + id: 'insulation', + label: 'Insulation types', + items: [ + { id: 'none', label: 'None' }, + { id: 'mineralWool', label: 'Mineral wool 30mm' }, + { id: 'mineralWool50', label: 'Mineral wool 50mm' }, + { id: 'pur', label: 'PUR shell 40mm' }, + ], + }, +]; + +const byId = new Map(CATALOGUES.map((c) => [c.id, c])); + +export function getCatalogue(id: string): Catalogue | undefined { + return byId.get(id); +} + +export function getCatalogueItem(catalogueId: string, itemId: string | null): CatalogueItem | undefined { + if (itemId === null) return undefined; + return byId.get(catalogueId)?.items.find((i) => i.id === itemId); +} + +/** Resolve item labels for display, skipping unknown ids. */ +export function itemLabels(catalogueId: string, itemIds: string[]): string[] { + const cat = byId.get(catalogueId); + if (!cat) return []; + return itemIds + .map((id) => cat.items.find((i) => i.id === id)?.label) + .filter((l): l is string => l !== undefined); +} diff --git a/src/model/graph.ts b/src/model/graph.ts new file mode 100644 index 0000000..5d600ae --- /dev/null +++ b/src/model/graph.ts @@ -0,0 +1,216 @@ +/** + * Pure document model of the pipeline network — independent of JointJS. + * Used by the flow solver, persistence and tests. The editor keeps the + * JointJS graph as runtime state and syncs snapshots through to/fromDocument. + */ + +import type { PropertyDef, PropertyValue } from './properties'; +import { defaultValues } from './properties'; +import { getNodeType } from './nodeTypes'; +import type { PortRelation } from './validation'; +import { checkConnection } from './validation'; + +export interface NodeData { + id: string; + typeId: string; + x: number; + y: number; + angle: number; + props: Record; +} + +export interface EdgeData { + id: string; + sourceNodeId: string; + sourcePortId: string; + targetNodeId: string; + targetPortId: string; + relation: PortRelation; + /** Manual bend points in paper coordinates. */ + vertices: { x: number; y: number }[]; + props: Record; +} + +export interface DiagramDocument { + version: 1; + nodes: NodeData[]; + edges: EdgeData[]; +} + +/** Property schema shared by all edges (pipes/lines). */ +export const EDGE_PROPERTIES: PropertyDef[] = [ + { key: 'title', label: 'Title', type: 'string', group: 'general', default: '' }, + // Presentation + { + key: 'lineStyle', + label: 'Line style', + type: 'enum', + group: 'presentation', + default: 'solid', + options: [ + { value: 'solid', label: 'Solid' }, + { value: 'dashed', label: 'Dashed' }, + { value: 'dotted', label: 'Dotted' }, + { value: 'dashDot', label: 'Dash-dot' }, + ], + }, + { key: 'lineWidth', label: 'Line width', type: 'float', group: 'presentation', unit: 'px', default: 2, min: 0.5, max: 12 }, + { key: 'lineColor', label: 'Line color (override)', type: 'string', group: 'presentation', default: '' }, + { + key: 'sourceMarker', + label: 'Tail marker', + type: 'enum', + group: 'presentation', + default: 'none', + options: [ + { value: 'none', label: 'None' }, + { value: 'arrow', label: 'Arrow' }, + { value: 'circle', label: 'Circle' }, + { value: 'diamond', label: 'Diamond' }, + { value: 'bar', label: 'Bar' }, + ], + }, + { + key: 'targetMarker', + label: 'Head marker', + type: 'enum', + group: 'presentation', + default: 'arrow', + options: [ + { value: 'none', label: 'None' }, + { value: 'arrow', label: 'Arrow' }, + { value: 'circle', label: 'Circle' }, + { value: 'diamond', label: 'Diamond' }, + { value: 'bar', label: 'Bar' }, + ], + }, + // Physics + { key: 'length', label: 'Length', type: 'float', group: 'physics', unit: 'm', default: 10, min: 0 }, + { key: 'diameter', label: 'Diameter', type: 'catalogueItem', group: 'physics', catalogueId: 'pipeDiameters', default: 'dn50' }, + { key: 'material', label: 'Material', type: 'catalogueItem', group: 'physics', catalogueId: 'materials', default: 'steel' }, + { key: 'insulation', label: 'Insulation', type: 'catalogueItems', group: 'physics', catalogueId: 'insulation', default: [] }, + { key: 'designFlowRate', label: 'Design flow rate', type: 'float', group: 'physics', unit: 'm³/h', default: 0, min: 0 }, + { + key: 'flowType', + label: 'Flow type', + type: 'enum', + group: 'physics', + default: 'auto', + options: [ + { value: 'auto', label: 'Auto' }, + { value: 'laminar', label: 'Laminar' }, + { value: 'turbulent', label: 'Turbulent' }, + ], + }, + // Simulation results (read-only, written by the solver) + { key: 'simFlow', label: 'Flow rate', type: 'float', group: 'simulation', unit: 'm³/h', default: 0, readOnly: true }, + { key: 'simDirection', label: 'Direction', type: 'string', group: 'simulation', default: '—', readOnly: true }, +]; + +export function defaultEdgeProps(): Record { + return defaultValues(EDGE_PROPERTIES); +} + +let counter = 0; +export function genId(prefix: string): string { + counter += 1; + return `${prefix}-${counter.toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +export class PipelineGraph { + readonly nodes = new Map(); + readonly edges = new Map(); + + addNode(node: Omit & { props?: Record }): NodeData { + const type = getNodeType(node.typeId); + if (!type) throw new Error(`Unknown node type: ${node.typeId}`); + if (this.nodes.has(node.id)) throw new Error(`Duplicate node id: ${node.id}`); + const data: NodeData = { + ...node, + props: { ...defaultValues(type.properties), ...node.props }, + }; + this.nodes.set(data.id, data); + return data; + } + + addEdge(edge: Omit & { + props?: Record; + vertices?: { x: number; y: number }[]; + }): EdgeData { + const source = this.nodes.get(edge.sourceNodeId); + const target = this.nodes.get(edge.targetNodeId); + if (!source) throw new Error(`Unknown source node: ${edge.sourceNodeId}`); + if (!target) throw new Error(`Unknown target node: ${edge.targetNodeId}`); + const sourcePort = getNodeType(source.typeId)?.ports.find((p) => p.id === edge.sourcePortId); + const targetPort = getNodeType(target.typeId)?.ports.find((p) => p.id === edge.targetPortId); + if (!sourcePort) throw new Error(`Unknown source port: ${edge.sourcePortId}`); + if (!targetPort) throw new Error(`Unknown target port: ${edge.targetPortId}`); + const check = checkConnection(sourcePort, targetPort); + if (!check.ok) throw new Error(check.reason); + if (this.edges.has(edge.id)) throw new Error(`Duplicate edge id: ${edge.id}`); + const data: EdgeData = { + ...edge, + relation: sourcePort.relation, + vertices: edge.vertices ?? [], + props: { ...defaultEdgeProps(), ...edge.props }, + }; + this.edges.set(data.id, data); + return data; + } + + /** Remove a node and all edges connected to it. */ + removeNode(id: string): void { + this.nodes.delete(id); + for (const [edgeId, edge] of this.edges) { + if (edge.sourceNodeId === id || edge.targetNodeId === id) { + this.edges.delete(edgeId); + } + } + } + + removeEdge(id: string): void { + this.edges.delete(id); + } + + edgesOf(nodeId: string): EdgeData[] { + return [...this.edges.values()].filter( + (e) => e.sourceNodeId === nodeId || e.targetNodeId === nodeId, + ); + } + + toDocument(): DiagramDocument { + return { + version: 1, + nodes: [...this.nodes.values()].map((n) => ({ ...n, props: { ...n.props } })), + edges: [...this.edges.values()].map((e) => ({ + ...e, + vertices: e.vertices.map((v) => ({ ...v })), + props: { ...e.props }, + })), + }; + } + + static fromDocument(doc: DiagramDocument): PipelineGraph { + if (doc.version !== 1) throw new Error(`Unsupported document version: ${doc.version}`); + const graph = new PipelineGraph(); + for (const node of doc.nodes) graph.addNode(node); + for (const edge of doc.edges) { + graph.addEdge(edge); + const stored = graph.edges.get(edge.id)!; + stored.vertices = edge.vertices?.map((v) => ({ ...v })) ?? []; + } + return graph; + } +} + +export function serializeDocument(doc: DiagramDocument): string { + return JSON.stringify(doc, null, 2); +} + +export function parseDocument(json: string): DiagramDocument { + const doc = JSON.parse(json) as DiagramDocument; + if (typeof doc !== 'object' || doc === null || !Array.isArray(doc.nodes) || !Array.isArray(doc.edges)) { + throw new Error('Invalid diagram document'); + } + return doc; +} diff --git a/src/model/nodeTypes.ts b/src/model/nodeTypes.ts new file mode 100644 index 0000000..99bcc7d --- /dev/null +++ b/src/model/nodeTypes.ts @@ -0,0 +1,403 @@ +/** + * Node type registry. Each type defines its SVG symbol, port layout and + * property schema. Symbols use a local viewBox coordinate space of the + * declared width × height and currentColor-friendly strokes so color schemes + * can restyle them. + */ + +import type { PropertyDef, PropertyValue } from './properties'; +import { defaultValues } from './properties'; +import type { PortSpec } from './validation'; + +export interface NodeTypeDef { + id: string; + label: string; + category: string; + width: number; + height: number; + /** Inner SVG markup in a `0 0 width height` viewBox. */ + svg: string; + ports: PortSpec[]; + properties: PropertyDef[]; + /** Simulation role used by the mock flow solver. */ + simRole?: 'source' | 'consumer' | 'junction'; +} + +export const NODE_CATEGORIES: { id: string; label: string }[] = [ + { id: 'sources', label: 'Sources & supply' }, + { id: 'machines', label: 'Pumps & machines' }, + { id: 'fittings', label: 'Valves & fittings' }, + { id: 'storage', label: 'Storage' }, + { id: 'consumers', label: 'Consumers' }, + { id: 'instruments', label: 'Instruments' }, +]; + +// --------------------------------------------------------------------------- +// Shared property fragments +// --------------------------------------------------------------------------- + +const titleProp = (dflt: string): PropertyDef => ({ + key: 'title', + label: 'Title', + type: 'string', + group: 'general', + default: dflt, +}); + +const presentationProps: PropertyDef[] = [ + { key: 'x', label: 'X', type: 'int', group: 'presentation', unit: 'px', default: 0 }, + { key: 'y', label: 'Y', type: 'int', group: 'presentation', unit: 'px', default: 0 }, + { + key: 'angle', + label: 'Rotation', + type: 'enum', + group: 'presentation', + default: '0', + options: [ + { value: '0', label: '0°' }, + { value: '90', label: '90°' }, + { value: '180', label: '180°' }, + { value: '270', label: '270°' }, + ], + }, + { key: 'symbolColor', label: 'Symbol color', type: 'color', group: 'presentation', default: '#1e293b' }, + { + key: 'labelVisible', + label: 'Show label', + type: 'enum', + group: 'presentation', + default: 'yes', + options: [ + { value: 'yes', label: 'Yes' }, + { value: 'no', label: 'No' }, + ], + }, +]; + +const pipePhysics: PropertyDef[] = [ + { key: 'medium', label: 'Medium', type: 'catalogueItem', group: 'physics', catalogueId: 'media', default: 'coldWater' }, + { key: 'maxPressure', label: 'Max pressure', type: 'float', group: 'physics', unit: 'bar', default: 10, min: 0 }, +]; + +// --------------------------------------------------------------------------- +// SVG symbol helpers — schematic P&ID-like symbols +// --------------------------------------------------------------------------- + +const S = 'stroke="currentColor" stroke-width="2.5" fill="none"'; +const SF = 'stroke="currentColor" stroke-width="2.5" fill="#fff"'; + +function def( + partial: Omit & { extraProps?: PropertyDef[]; titleDefault?: string }, +): NodeTypeDef { + const { extraProps = [], titleDefault, ...rest } = partial; + return { + ...rest, + properties: [titleProp(titleDefault ?? partial.label), ...presentationProps, ...extraProps], + }; +} + +export const NODE_TYPES: NodeTypeDef[] = [ + // -- Sources --------------------------------------------------------------- + def({ + id: 'waterSource', + label: 'Water source', + category: 'sources', + width: 60, + height: 60, + simRole: 'source', + svg: ``, + ports: [{ id: 'out', relation: 'water', direction: 'out', x: 1, y: 0.5, label: 'Out' }], + extraProps: [ + { key: 'supply', label: 'Supply capacity', type: 'float', group: 'physics', unit: 'm³/h', default: 50, min: 0 }, + { key: 'pressure', label: 'Outlet pressure', type: 'float', group: 'physics', unit: 'bar', default: 4, min: 0 }, + ...pipePhysics, + ], + }), + def({ + id: 'gasSource', + label: 'Gas source', + category: 'sources', + width: 60, + height: 60, + simRole: 'source', + svg: `G`, + ports: [{ id: 'out', relation: 'gas', direction: 'out', x: 1, y: 0.5, label: 'Out' }], + extraProps: [ + { key: 'supply', label: 'Supply capacity', type: 'float', group: 'physics', unit: 'm³/h', default: 30, min: 0 }, + { key: 'medium', label: 'Medium', type: 'catalogueItem', group: 'physics', catalogueId: 'media', default: 'naturalGas' }, + ], + }), + def({ + id: 'boiler', + label: 'Boiler', + category: 'sources', + width: 70, + height: 70, + simRole: 'junction', + svg: ``, + ports: [ + { id: 'gasIn', relation: 'gas', direction: 'in', x: 0, y: 0.75, label: 'Gas' }, + { id: 'heatOut', relation: 'heat', direction: 'out', x: 1, y: 0.25, label: 'Flow' }, + { id: 'heatReturn', relation: 'heat', direction: 'in', x: 1, y: 0.75, label: 'Return' }, + { id: 'powerIn', relation: 'power', direction: 'in', x: 0, y: 0.25, label: 'Power' }, + ], + extraProps: [ + { key: 'ratedPower', label: 'Rated power', type: 'float', group: 'physics', unit: 'kW', default: 24, min: 0 }, + { key: 'efficiency', label: 'Efficiency', type: 'float', group: 'physics', unit: '%', default: 92, min: 0, max: 100 }, + ], + }), + def({ + id: 'powerSupply', + label: 'Power supply', + category: 'sources', + width: 60, + height: 60, + simRole: 'source', + svg: ``, + ports: [{ id: 'out', relation: 'power', direction: 'out', x: 1, y: 0.5, label: 'Out' }], + extraProps: [ + { key: 'voltage', label: 'Voltage', type: 'enum', group: 'physics', default: '400', options: [ + { value: '230', label: '230 V' }, + { value: '400', label: '400 V' }, + { value: '6000', label: '6 kV' }, + ] }, + { key: 'supply', label: 'Capacity', type: 'float', group: 'physics', unit: 'kW', default: 100, min: 0 }, + ], + }), + + // -- Machines --------------------------------------------------------------- + def({ + id: 'pump', + label: 'Pump', + category: 'machines', + width: 60, + height: 60, + simRole: 'junction', + svg: ``, + ports: [ + { id: 'in', relation: 'water', direction: 'in', x: 0, y: 0.5, label: 'Suction' }, + { id: 'out', relation: 'water', direction: 'out', x: 1, y: 0.5, label: 'Discharge' }, + { id: 'powerIn', relation: 'power', direction: 'in', x: 0.5, y: 1, label: 'Power' }, + ], + extraProps: [ + { key: 'head', label: 'Head', type: 'float', group: 'physics', unit: 'm', default: 32, min: 0 }, + { key: 'ratedFlow', label: 'Rated flow', type: 'float', group: 'physics', unit: 'm³/h', default: 25, min: 0 }, + { key: 'speedCurve', label: 'Speed curve', type: 'listOfValues', group: 'physics', itemType: 'float', default: [1450, 2900] }, + ...pipePhysics, + ], + }), + def({ + id: 'heatExchanger', + label: 'Heat exchanger', + category: 'machines', + width: 70, + height: 60, + simRole: 'junction', + svg: ``, + ports: [ + { id: 'primaryIn', relation: 'heat', direction: 'in', x: 0, y: 0.25, label: 'Primary in' }, + { id: 'primaryOut', relation: 'heat', direction: 'out', x: 0, y: 0.75, label: 'Primary out' }, + { id: 'secondaryIn', relation: 'water', direction: 'in', x: 1, y: 0.75, label: 'Secondary in' }, + { id: 'secondaryOut', relation: 'water', direction: 'out', x: 1, y: 0.25, label: 'Secondary out' }, + ], + extraProps: [ + { key: 'power', label: 'Heat power', type: 'float', group: 'physics', unit: 'kW', default: 50, min: 0 }, + { key: 'plates', label: 'Plate count', type: 'int', group: 'physics', default: 40, min: 1 }, + ], + }), + + // -- Fittings ---------------------------------------------------------------- + def({ + id: 'valve', + label: 'Valve', + category: 'fittings', + width: 60, + height: 40, + simRole: 'junction', + svg: ``, + ports: [ + { id: 'a', relation: 'water', direction: 'any', x: 0, y: 0.5, label: 'A' }, + { id: 'b', relation: 'water', direction: 'any', x: 1, y: 0.5, label: 'B' }, + ], + extraProps: [ + { key: 'opening', label: 'Opening', type: 'float', group: 'physics', unit: '%', default: 100, min: 0, max: 100 }, + { key: 'kind', label: 'Valve kind', type: 'enum', group: 'physics', default: 'gate', options: [ + { value: 'gate', label: 'Gate' }, + { value: 'ball', label: 'Ball' }, + { value: 'globe', label: 'Globe' }, + { value: 'butterfly', label: 'Butterfly' }, + ] }, + ...pipePhysics, + ], + }), + def({ + id: 'checkValve', + label: 'Check valve', + category: 'fittings', + width: 60, + height: 40, + simRole: 'junction', + svg: ``, + ports: [ + { id: 'in', relation: 'water', direction: 'in', x: 0, y: 0.5, label: 'In' }, + { id: 'out', relation: 'water', direction: 'out', x: 1, y: 0.5, label: 'Out' }, + ], + extraProps: [...pipePhysics], + }), + def({ + id: 'tee', + label: 'Tee / junction', + category: 'fittings', + width: 60, + height: 60, + simRole: 'junction', + svg: ``, + ports: [ + { id: 'a', relation: 'water', direction: 'any', x: 0, y: 0.5, label: 'A' }, + { id: 'b', relation: 'water', direction: 'any', x: 1, y: 0.5, label: 'B' }, + { id: 'c', relation: 'water', direction: 'any', x: 0.5, y: 1, label: 'C' }, + ], + extraProps: [...pipePhysics], + }), + def({ + id: 'reducer', + label: 'Reducer', + category: 'fittings', + width: 60, + height: 40, + simRole: 'junction', + svg: ``, + ports: [ + { id: 'a', relation: 'water', direction: 'any', x: 0, y: 0.5, label: 'A' }, + { id: 'b', relation: 'water', direction: 'any', x: 1, y: 0.5, label: 'B' }, + ], + extraProps: [ + { key: 'dnFrom', label: 'DN from', type: 'catalogueItem', group: 'physics', catalogueId: 'pipeDiameters', default: 'dn100' }, + { key: 'dnTo', label: 'DN to', type: 'catalogueItem', group: 'physics', catalogueId: 'pipeDiameters', default: 'dn50' }, + ], + }), + + // -- Storage ----------------------------------------------------------------- + def({ + id: 'tank', + label: 'Tank', + category: 'storage', + width: 70, + height: 80, + simRole: 'junction', + svg: ``, + ports: [ + { id: 'in', relation: 'water', direction: 'in', x: 0, y: 0.3, label: 'Inlet' }, + { id: 'out', relation: 'water', direction: 'out', x: 1, y: 0.8, label: 'Outlet' }, + { id: 'drain', relation: 'sewage', direction: 'out', x: 0.5, y: 1, label: 'Drain' }, + ], + extraProps: [ + { key: 'volume', label: 'Volume', type: 'float', group: 'physics', unit: 'm³', default: 10, min: 0 }, + { key: 'level', label: 'Initial level', type: 'float', group: 'physics', unit: '%', default: 60, min: 0, max: 100 }, + ...pipePhysics, + ], + }), + + // -- Consumers --------------------------------------------------------------- + def({ + id: 'consumer', + label: 'Consumer', + category: 'consumers', + width: 60, + height: 60, + simRole: 'consumer', + svg: ``, + ports: [ + { id: 'waterIn', relation: 'water', direction: 'in', x: 0, y: 0.6, label: 'Water' }, + { id: 'heatIn', relation: 'heat', direction: 'in', x: 0, y: 0.3, label: 'Heat' }, + { id: 'powerIn', relation: 'power', direction: 'in', x: 1, y: 0.3, label: 'Power' }, + { id: 'sewageOut', relation: 'sewage', direction: 'out', x: 1, y: 0.8, label: 'Sewage' }, + ], + extraProps: [ + { key: 'demand', label: 'Water demand', type: 'float', group: 'physics', unit: 'm³/h', default: 5, min: 0 }, + { key: 'heatDemand', label: 'Heat demand', type: 'float', group: 'physics', unit: 'kW', default: 10, min: 0 }, + { key: 'priority', label: 'Priority', type: 'int', group: 'physics', default: 1, min: 1, max: 10 }, + ], + }), + def({ + id: 'radiator', + label: 'Radiator', + category: 'consumers', + width: 70, + height: 50, + simRole: 'consumer', + svg: ``, + ports: [ + { id: 'in', relation: 'heat', direction: 'in', x: 0, y: 0.5, label: 'Flow' }, + { id: 'out', relation: 'heat', direction: 'out', x: 1, y: 0.5, label: 'Return' }, + ], + extraProps: [ + { key: 'demand', label: 'Heat output', type: 'float', group: 'physics', unit: 'kW', default: 2, min: 0 }, + ], + }), + def({ + id: 'hydrant', + label: 'Hydrant', + category: 'consumers', + width: 50, + height: 60, + simRole: 'consumer', + svg: ``, + ports: [{ id: 'in', relation: 'water', direction: 'in', x: 0.5, y: 1, label: 'In' }], + extraProps: [ + { key: 'demand', label: 'Design flow', type: 'float', group: 'physics', unit: 'm³/h', default: 15, min: 0 }, + ], + }), + + // -- Instruments --------------------------------------------------------------- + def({ + id: 'sensor', + label: 'Pressure sensor', + category: 'instruments', + width: 50, + height: 50, + simRole: 'junction', + svg: `P`, + ports: [ + { id: 'tap', relation: 'water', direction: 'any', x: 0.5, y: 1, label: 'Tap' }, + { id: 'signalOut', relation: 'signal', direction: 'out', x: 1, y: 0.2, label: 'Signal' }, + ], + extraProps: [ + { key: 'range', label: 'Range', type: 'listOfValues', group: 'physics', itemType: 'float', default: [0, 16] }, + { key: 'alarms', label: 'Alarm levels', type: 'listOfValues', group: 'physics', itemType: 'float', default: [12] }, + ], + }), + def({ + id: 'meter', + label: 'Flow meter', + category: 'instruments', + width: 60, + height: 50, + simRole: 'junction', + svg: `FM`, + ports: [ + { id: 'in', relation: 'water', direction: 'in', x: 0, y: 0.5, label: 'In' }, + { id: 'out', relation: 'water', direction: 'out', x: 1, y: 0.5, label: 'Out' }, + { id: 'signalOut', relation: 'signal', direction: 'out', x: 0.5, y: 0, label: 'Signal' }, + ], + extraProps: [...pipePhysics], + }), +]; + +const typeById = new Map(NODE_TYPES.map((t) => [t.id, t])); + +export function getNodeType(id: string): NodeTypeDef | undefined { + return typeById.get(id); +} + +export function nodeTypesByCategory(): { category: { id: string; label: string }; types: NodeTypeDef[] }[] { + return NODE_CATEGORIES.map((category) => ({ + category, + types: NODE_TYPES.filter((t) => t.category === category.id), + })).filter((g) => g.types.length > 0); +} + +export function defaultNodeProps(typeId: string): Record { + const type = typeById.get(typeId); + return type ? defaultValues(type.properties) : {}; +} diff --git a/src/model/properties.ts b/src/model/properties.ts new file mode 100644 index 0000000..09bcf46 --- /dev/null +++ b/src/model/properties.ts @@ -0,0 +1,190 @@ +/** + * Typed property system for nodes and edges. + * + * Supported types: string, int, float, enum, catalogueItem, catalogueItems, + * color, listOfValues. Properties are organized into groups (General / + * Presentation / Physics / Simulation) rendered as collapsible sections. + */ + +export type PropertyType = + | 'string' + | 'int' + | 'float' + | 'enum' + | 'catalogueItem' + | 'catalogueItems' + | 'color' + | 'listOfValues'; + +export type PropertyValue = string | number | string[] | number[] | null; + +export interface EnumOption { + value: string; + label: string; +} + +export interface PropertyDef { + key: string; + label: string; + type: PropertyType; + /** Group id, see PROPERTY_GROUPS. */ + group: string; + /** Physical unit shown next to the editor (e.g. "mm", "m³/h"). */ + unit?: string; + readOnly?: boolean; + min?: number; + max?: number; + /** Options for `enum` type. */ + options?: EnumOption[]; + /** Catalogue id for `catalogueItem` / `catalogueItems` types. */ + catalogueId?: string; + /** Element type for `listOfValues`. */ + itemType?: 'string' | 'int' | 'float'; + default?: PropertyValue; + description?: string; +} + +export interface PropertyGroupDef { + id: string; + label: string; + order: number; +} + +export const PROPERTY_GROUPS: PropertyGroupDef[] = [ + { id: 'general', label: 'General', order: 0 }, + { id: 'presentation', label: 'Presentation', order: 1 }, + { id: 'physics', label: 'Physics', order: 2 }, + { id: 'simulation', label: 'Simulation results', order: 3 }, +]; + +export interface ValidationResult { + ok: boolean; + /** Value coerced to the canonical representation for the type. */ + value: PropertyValue; + error?: string; +} + +const COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; + +function coerceNumber(raw: PropertyValue, integer: boolean): number | null { + if (raw === null || raw === '') return null; + const n = typeof raw === 'number' ? raw : Number(raw); + if (typeof raw !== 'number' && typeof raw !== 'string') return null; + if (!Number.isFinite(n)) return null; + return integer ? Math.round(n) : n; +} + +/** Validate and coerce a raw value against a property definition. */ +export function validateValue(def: PropertyDef, raw: PropertyValue): ValidationResult { + switch (def.type) { + case 'string': { + const value = raw === null ? '' : String(raw); + return { ok: true, value }; + } + case 'int': + case 'float': { + const n = coerceNumber(raw, def.type === 'int'); + if (n === null) { + return { ok: false, value: raw, error: `"${def.label}" must be a number` }; + } + if (def.min !== undefined && n < def.min) { + return { ok: false, value: n, error: `"${def.label}" must be ≥ ${def.min}` }; + } + if (def.max !== undefined && n > def.max) { + return { ok: false, value: n, error: `"${def.label}" must be ≤ ${def.max}` }; + } + return { ok: true, value: n }; + } + case 'enum': { + const value = String(raw ?? ''); + const options = def.options ?? []; + if (!options.some((o) => o.value === value)) { + return { + ok: false, + value: raw, + error: `"${value}" is not one of: ${options.map((o) => o.value).join(', ')}`, + }; + } + return { ok: true, value }; + } + case 'catalogueItem': { + if (raw === null || raw === '') return { ok: true, value: null }; + return { ok: true, value: String(raw) }; + } + case 'catalogueItems': { + if (raw === null) return { ok: true, value: [] }; + const arr = Array.isArray(raw) ? raw.map(String) : [String(raw)]; + return { ok: true, value: arr }; + } + case 'color': { + const value = String(raw ?? ''); + if (!COLOR_RE.test(value)) { + return { ok: false, value: raw, error: `"${value}" is not a valid hex color` }; + } + return { ok: true, value }; + } + case 'listOfValues': { + if (raw === null) return { ok: true, value: [] }; + const arr = Array.isArray(raw) ? raw : [raw]; + if (def.itemType === 'int' || def.itemType === 'float') { + const nums: number[] = []; + for (const item of arr) { + const n = coerceNumber(item as PropertyValue, def.itemType === 'int'); + if (n === null) { + return { ok: false, value: raw, error: `"${item}" is not a number` }; + } + nums.push(n); + } + return { ok: true, value: nums }; + } + return { ok: true, value: arr.map(String) }; + } + } +} + +/** Build the default property bag for a list of definitions. */ +export function defaultValues(defs: PropertyDef[]): Record { + const out: Record = {}; + for (const def of defs) { + if (def.default !== undefined) { + out[def.key] = def.default; + } else { + switch (def.type) { + case 'string': + case 'color': + out[def.key] = def.type === 'color' ? '#000000' : ''; + break; + case 'int': + case 'float': + out[def.key] = def.min ?? 0; + break; + case 'enum': + out[def.key] = def.options?.[0]?.value ?? ''; + break; + case 'catalogueItem': + out[def.key] = null; + break; + case 'catalogueItems': + case 'listOfValues': + out[def.key] = []; + break; + } + } + } + return out; +} + +/** Group property definitions by group id, ordered by PROPERTY_GROUPS. */ +export function groupDefs(defs: PropertyDef[]): { group: PropertyGroupDef; defs: PropertyDef[] }[] { + const byId = new Map(); + for (const def of defs) { + const list = byId.get(def.group) ?? []; + list.push(def); + byId.set(def.group, list); + } + const known = PROPERTY_GROUPS.filter((g) => byId.has(g.id)).sort((a, b) => a.order - b.order); + const unknown = [...byId.keys()] + .filter((id) => !PROPERTY_GROUPS.some((g) => g.id === id)) + .map((id) => ({ id, label: id, order: 99 })); + return [...known, ...unknown].map((group) => ({ group, defs: byId.get(group.id)! })); +} diff --git a/src/model/validation.ts b/src/model/validation.ts new file mode 100644 index 0000000..533e5d9 --- /dev/null +++ b/src/model/validation.ts @@ -0,0 +1,82 @@ +/** + * Port relations and connection compatibility rules. + * + * A port belongs to exactly one relation (the kind of line it can carry). + * Two ports may be connected when their relations are compatible and their + * directions are not both strict outputs / both strict inputs. + */ + +export type PortRelation = 'water' | 'heat' | 'gas' | 'sewage' | 'power' | 'signal'; + +export type PortDirection = 'in' | 'out' | 'any'; + +export interface PortSpec { + id: string; + relation: PortRelation; + direction: PortDirection; + /** Position in node-relative coordinates, 0..1 of width/height. */ + x: number; + y: number; + label?: string; +} + +export const RELATIONS: { id: PortRelation; label: string }[] = [ + { id: 'water', label: 'Water line' }, + { id: 'heat', label: 'Heating line' }, + { id: 'gas', label: 'Gas line' }, + { id: 'sewage', label: 'Sewage line' }, + { id: 'power', label: 'Power line' }, + { id: 'signal', label: 'Signal line' }, +]; + +/** + * Compatibility matrix. Key relation may connect to any relation in the set. + * Symmetric by construction (see canConnectRelations). + */ +const COMPATIBLE: Record = { + water: ['water', 'heat'], // heating circuits are filled from water lines + heat: ['heat', 'water'], + gas: ['gas'], + sewage: ['sewage', 'water'], // water discharges into sewage + power: ['power'], + signal: ['signal'], +}; + +export function canConnectRelations(a: PortRelation, b: PortRelation): boolean { + return COMPATIBLE[a]?.includes(b) ?? false; +} + +export function canConnectDirections(a: PortDirection, b: PortDirection): boolean { + // Two strict outputs or two strict inputs cannot be tied together. + if (a === 'out' && b === 'out') return false; + if (a === 'in' && b === 'in') return false; + return true; +} + +export interface ConnectionCheck { + ok: boolean; + reason?: string; +} + +export function checkConnection( + source: PortSpec, + target: PortSpec, + opts: { sameNode?: boolean } = {}, +): ConnectionCheck { + if (opts.sameNode && source.id === target.id) { + return { ok: false, reason: 'Cannot connect a port to itself' }; + } + if (!canConnectRelations(source.relation, target.relation)) { + return { + ok: false, + reason: `Incompatible relations: ${source.relation} → ${target.relation}`, + }; + } + if (!canConnectDirections(source.direction, target.direction)) { + return { + ok: false, + reason: `Incompatible directions: ${source.direction} → ${target.direction}`, + }; + } + return { ok: true }; +} diff --git a/src/sim/flow.ts b/src/sim/flow.ts new file mode 100644 index 0000000..b2ee44e --- /dev/null +++ b/src/sim/flow.ts @@ -0,0 +1,135 @@ +/** + * Mock flow-distribution solver. + * + * Not a hydraulic simulation — a plausible mass-balance mock: + * - sources offer `supply`, consumers request `demand` (per relation network); + * - each consumer's demand is split across reachable sources proportionally + * to their supply; + * - each (source, consumer) portion is pushed along the shortest path + * (BFS, edge count), accumulating a signed flow per edge; + * - positive flow runs source→target of the edge as drawn, negative runs + * against it. Demands are scaled down if total supply is insufficient. + */ + +import type { EdgeData, PipelineGraph } from '../model/graph'; +import { getNodeType } from '../model/nodeTypes'; + +export interface EdgeFlow { + edgeId: string; + /** Signed flow, positive along the drawn source→target direction, m³/h. */ + flow: number; +} + +export interface FlowResult { + /** edgeId → signed flow. Every edge of the graph is present. */ + flows: Map; + maxAbsFlow: number; + totalSupplied: number; + totalDemand: number; + /** Consumer node ids that no source can reach. */ + unreachedConsumers: string[]; +} + +function numProp(props: Record, key: string): number { + const v = props[key]; + return typeof v === 'number' && Number.isFinite(v) ? v : 0; +} + +interface Adj { + edge: EdgeData; + other: string; +} + +/** Undirected adjacency restricted to fluid-carrying relations. */ +function buildAdjacency(graph: PipelineGraph): Map { + const adj = new Map(); + for (const edge of graph.edges.values()) { + if (edge.relation === 'signal') continue; // signal lines carry no flow + const a = adj.get(edge.sourceNodeId) ?? []; + a.push({ edge, other: edge.targetNodeId }); + adj.set(edge.sourceNodeId, a); + const b = adj.get(edge.targetNodeId) ?? []; + b.push({ edge, other: edge.sourceNodeId }); + adj.set(edge.targetNodeId, b); + } + return adj; +} + +/** BFS shortest path from `from` to `to`; returns traversed adjacency steps. */ +function shortestPath(adj: Map, from: string, to: string): { edge: EdgeData; forward: boolean }[] | null { + if (from === to) return []; + const prev = new Map(); + const queue = [from]; + const seen = new Set([from]); + while (queue.length > 0) { + const node = queue.shift()!; + for (const { edge, other } of adj.get(node) ?? []) { + if (seen.has(other)) continue; + seen.add(other); + prev.set(other, { node, edge }); + if (other === to) { + const path: { edge: EdgeData; forward: boolean }[] = []; + let cur = to; + while (cur !== from) { + const step = prev.get(cur)!; + path.unshift({ edge: step.edge, forward: step.edge.sourceNodeId === step.node }); + cur = step.node; + } + return path; + } + queue.push(other); + } + } + return null; +} + +export function solveFlow(graph: PipelineGraph): FlowResult { + const adj = buildAdjacency(graph); + const flows = new Map(); + for (const edge of graph.edges.values()) flows.set(edge.id, 0); + + const sources: { id: string; supply: number }[] = []; + const consumers: { id: string; demand: number }[] = []; + for (const node of graph.nodes.values()) { + const role = getNodeType(node.typeId)?.simRole; + if (role === 'source') { + const supply = numProp(node.props, 'supply'); + if (supply > 0) sources.push({ id: node.id, supply }); + } else if (role === 'consumer') { + const demand = numProp(node.props, 'demand'); + if (demand > 0) consumers.push({ id: node.id, demand }); + } + } + + const totalDemand = consumers.reduce((s, c) => s + c.demand, 0); + let totalSupplied = 0; + const unreachedConsumers: string[] = []; + + for (const consumer of consumers) { + // Which sources can reach this consumer, and by what path? + const routes: { path: { edge: EdgeData; forward: boolean }[]; supply: number }[] = []; + for (const source of sources) { + const path = shortestPath(adj, source.id, consumer.id); + if (path) routes.push({ path, supply: source.supply }); + } + if (routes.length === 0) { + unreachedConsumers.push(consumer.id); + continue; + } + const reachableSupply = routes.reduce((s, r) => s + r.supply, 0); + // Scale demand down when reachable supply cannot cover it. + const served = Math.min(consumer.demand, reachableSupply); + totalSupplied += served; + for (const route of routes) { + const portion = served * (route.supply / reachableSupply); + for (const step of route.path) { + flows.set(step.edge.id, (flows.get(step.edge.id) ?? 0) + (step.forward ? portion : -portion)); + } + } + } + + let maxAbsFlow = 0; + for (const f of flows.values()) maxAbsFlow = Math.max(maxAbsFlow, Math.abs(f)); + + return { flows, maxAbsFlow, totalSupplied, totalDemand, unreachedConsumers }; +} diff --git a/src/state/history.ts b/src/state/history.ts new file mode 100644 index 0000000..271f424 --- /dev/null +++ b/src/state/history.ts @@ -0,0 +1,60 @@ +/** + * Snapshot-based undo/redo stack. The editor pushes a serialized snapshot + * after every user transaction; undo/redo walk the stack and the editor + * re-applies the returned snapshot. + */ + +export class History { + private past: T[] = []; + private future: T[] = []; + private current: T; + private limit: number; + + constructor(initial: T, limit = 100) { + this.current = initial; + this.limit = limit; + } + + /** Record a new state after a user action. Clears the redo stack. */ + push(state: T): void { + this.past.push(this.current); + if (this.past.length > this.limit) this.past.shift(); + this.current = state; + this.future = []; + } + + get canUndo(): boolean { + return this.past.length > 0; + } + + get canRedo(): boolean { + return this.future.length > 0; + } + + undo(): T | undefined { + const prev = this.past.pop(); + if (prev === undefined) return undefined; + this.future.push(this.current); + this.current = prev; + return prev; + } + + redo(): T | undefined { + const next = this.future.pop(); + if (next === undefined) return undefined; + this.past.push(this.current); + this.current = next; + return next; + } + + /** Replace the current state without recording history (e.g. on load). */ + reset(state: T): void { + this.past = []; + this.future = []; + this.current = state; + } + + peek(): T { + return this.current; + } +}