feat(model): domain model — properties, catalogues, port relations, node types, graph, flow solver, history
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d6903bd3cb
commit
3040dc9d20
87
src/model/catalog.ts
Normal file
87
src/model/catalog.ts
Normal file
@ -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<string, string | number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
216
src/model/graph.ts
Normal file
216
src/model/graph.ts
Normal file
@ -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<string, PropertyValue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, PropertyValue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, PropertyValue> {
|
||||||
|
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<string, NodeData>();
|
||||||
|
readonly edges = new Map<string, EdgeData>();
|
||||||
|
|
||||||
|
addNode(node: Omit<NodeData, 'props'> & { props?: Record<string, PropertyValue> }): 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<EdgeData, 'props' | 'vertices' | 'relation'> & {
|
||||||
|
props?: Record<string, PropertyValue>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
403
src/model/nodeTypes.ts
Normal file
403
src/model/nodeTypes.ts
Normal file
@ -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<NodeTypeDef, 'properties'> & { 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: `<circle cx="30" cy="30" r="24" ${SF}/><path d="M30 16 C22 28 20 33 20 38 a10 10 0 0 0 20 0 c0-5-2-10-10-22Z" stroke="currentColor" stroke-width="2" fill="none"/>`,
|
||||||
|
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: `<circle cx="30" cy="30" r="24" ${SF}/><text x="30" y="37" text-anchor="middle" font-size="18" font-family="sans-serif" fill="currentColor" stroke="none">G</text>`,
|
||||||
|
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: `<rect x="10" y="6" width="50" height="58" rx="8" ${SF}/><path d="M35 20 c-6 9-8 13-8 17a8 8 0 0 0 16 0c0-4-2-8-8-17Z" ${S} stroke-width="2"/>`,
|
||||||
|
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: `<rect x="8" y="8" width="44" height="44" rx="4" ${SF}/><path d="M33 14 22 32h8l-4 14 12-19h-8l3-13Z" fill="currentColor" stroke="none"/>`,
|
||||||
|
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: `<circle cx="30" cy="30" r="22" ${SF}/><path d="M30 8 L48 22 M30 8 L12 22" ${S}/>`,
|
||||||
|
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: `<rect x="6" y="10" width="58" height="40" rx="6" ${SF}/><path d="M6 30 h14 l8 -12 l12 24 l12 -24 l8 12 h4" ${S} stroke-width="2"/>`,
|
||||||
|
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: `<path d="M6 8 L30 20 L6 32 Z" ${SF}/><path d="M54 8 L30 20 L54 32 Z" ${SF}/>`,
|
||||||
|
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: `<path d="M8 8 v24 M8 20 L50 8 v24 Z" ${SF}/>`,
|
||||||
|
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: `<path d="M6 30 h48 M30 30 v24" ${S} stroke-width="5"/><circle cx="30" cy="30" r="5" fill="currentColor" stroke="none"/>`,
|
||||||
|
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: `<path d="M8 8 L52 14 V26 L8 32 Z" ${SF}/>`,
|
||||||
|
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: `<path d="M10 14 a25 10 0 0 1 50 0 v50 a25 10 0 0 1 -50 0 Z" ${SF}/><ellipse cx="35" cy="14" rx="25" ry="10" ${S} stroke-width="2"/><path d="M14 46 h42" ${S} stroke-width="1.5" stroke-dasharray="4 3"/>`,
|
||||||
|
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: `<path d="M10 30 L30 12 L50 30 V52 H10 Z" ${SF}/><rect x="25" y="36" width="10" height="16" fill="currentColor" stroke="none"/>`,
|
||||||
|
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: `<rect x="6" y="8" width="58" height="34" rx="4" ${SF}/><path d="M16 8 v34 M26 8 v34 M36 8 v34 M46 8 v34 M56 8 v34" ${S} stroke-width="1.5"/>`,
|
||||||
|
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: `<rect x="18" y="16" width="14" height="38" rx="4" ${SF}/><circle cx="25" cy="12" r="7" ${SF}/><path d="M10 30 h8 M32 30 h8" ${S}/>`,
|
||||||
|
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: `<circle cx="25" cy="25" r="18" ${SF}/><text x="25" y="31" text-anchor="middle" font-size="14" font-family="sans-serif" fill="currentColor" stroke="none">P</text>`,
|
||||||
|
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: `<circle cx="30" cy="25" r="18" ${SF}/><text x="30" y="31" text-anchor="middle" font-size="14" font-family="sans-serif" fill="currentColor" stroke="none">FM</text>`,
|
||||||
|
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<string, PropertyValue> {
|
||||||
|
const type = typeById.get(typeId);
|
||||||
|
return type ? defaultValues(type.properties) : {};
|
||||||
|
}
|
||||||
190
src/model/properties.ts
Normal file
190
src/model/properties.ts
Normal file
@ -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<string, PropertyValue> {
|
||||||
|
const out: Record<string, PropertyValue> = {};
|
||||||
|
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<string, PropertyDef[]>();
|
||||||
|
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)! }));
|
||||||
|
}
|
||||||
82
src/model/validation.ts
Normal file
82
src/model/validation.ts
Normal file
@ -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<PortRelation, PortRelation[]> = {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
135
src/sim/flow.ts
Normal file
135
src/sim/flow.ts
Normal file
@ -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<string, number>;
|
||||||
|
maxAbsFlow: number;
|
||||||
|
totalSupplied: number;
|
||||||
|
totalDemand: number;
|
||||||
|
/** Consumer node ids that no source can reach. */
|
||||||
|
unreachedConsumers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function numProp(props: Record<string, unknown>, 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<string, Adj[]> {
|
||||||
|
const adj = new Map<string, Adj[]>();
|
||||||
|
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<string, Adj[]>, from: string, to: string): { edge: EdgeData; forward: boolean }[] | null {
|
||||||
|
if (from === to) return [];
|
||||||
|
const prev = new Map<string, { node: string; edge: EdgeData }>();
|
||||||
|
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<string, number>();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
60
src/state/history.ts
Normal file
60
src/state/history.ts
Normal file
@ -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<T> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user