@procaaso/alphinity-ui-components 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/IMPLEMENTATION_GUIDE.md +1351 -0
- package/README.md +9 -22
- package/dist/index.cjs +162 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +26 -1
- package/dist/index.d.ts +26 -1
- package/dist/index.js +162 -11
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
|
@@ -0,0 +1,1351 @@
|
|
|
1
|
+
# P&ID Canvas Implementation Guide
|
|
2
|
+
|
|
3
|
+
## Architecture Overview
|
|
4
|
+
|
|
5
|
+
This library provides **UI primitives** for building P&ID diagram editors and runtime viewers. Your application handles **state management** and **data binding**.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌─────────────────────────────────────────────┐
|
|
9
|
+
│ Your Application (Engineering Mode) │
|
|
10
|
+
│ - Diagram state management │
|
|
11
|
+
│ - Save/load from database │
|
|
12
|
+
│ - Data stream configuration │
|
|
13
|
+
│ - User permissions │
|
|
14
|
+
└─────────────┬───────────────────────────────┘
|
|
15
|
+
│ Props & Callbacks
|
|
16
|
+
▼
|
|
17
|
+
┌─────────────────────────────────────────────┐
|
|
18
|
+
│ @procaaso/alphinity-ui-components │
|
|
19
|
+
│ - PIDCanvas (rendering & interaction) │
|
|
20
|
+
│ - Hooks (useTelemetry, useViewBox, etc.) │
|
|
21
|
+
│ - Visual components │
|
|
22
|
+
└─────────────────────────────────────────────┘
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Quick Start: Minimal Implementation
|
|
28
|
+
|
|
29
|
+
### 1. Basic Viewer (Read-Only)
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { PIDCanvas } from '@procaaso/alphinity-ui-components';
|
|
33
|
+
import type { PIDDiagram } from '@procaaso/alphinity-ui-components';
|
|
34
|
+
|
|
35
|
+
function DiagramViewer({ diagram }: { diagram: PIDDiagram }) {
|
|
36
|
+
return (
|
|
37
|
+
<div style={{ width: '100%', height: '100vh' }}>
|
|
38
|
+
<PIDCanvas
|
|
39
|
+
diagram={diagram}
|
|
40
|
+
features={{
|
|
41
|
+
pan: true,
|
|
42
|
+
zoom: true,
|
|
43
|
+
selection: false,
|
|
44
|
+
telemetry: false,
|
|
45
|
+
}}
|
|
46
|
+
/>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### 2. Interactive Editor
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
import { PIDCanvas, SymbolLibrary } from '@procaaso/alphinity-ui-components';
|
|
56
|
+
import { useState } from 'react';
|
|
57
|
+
import type { PIDDiagram, NodeInstance, PipeEdge } from '@procaaso/alphinity-ui-components';
|
|
58
|
+
|
|
59
|
+
function DiagramEditor() {
|
|
60
|
+
const [diagram, setDiagram] = useState<PIDDiagram>({
|
|
61
|
+
nodes: [],
|
|
62
|
+
pipes: [],
|
|
63
|
+
viewBox: { x: 0, y: 0, width: 2000, height: 1500 },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const handleSymbolDrop = (symbolId: string, position: { x: number; y: number }) => {
|
|
67
|
+
const newNode: NodeInstance = {
|
|
68
|
+
id: `node-${Date.now()}`,
|
|
69
|
+
symbolId,
|
|
70
|
+
transform: { x: position.x, y: position.y, rotation: 0 },
|
|
71
|
+
label: symbolId,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
setDiagram((prev) => ({
|
|
75
|
+
...prev,
|
|
76
|
+
nodes: [...prev.nodes, newNode],
|
|
77
|
+
}));
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const handlePipeCreated = (
|
|
81
|
+
sourceId: string,
|
|
82
|
+
targetId: string,
|
|
83
|
+
sourcePort?: string,
|
|
84
|
+
targetPort?: string
|
|
85
|
+
) => {
|
|
86
|
+
const newPipe: PipeEdge = {
|
|
87
|
+
id: `pipe-${Date.now()}`,
|
|
88
|
+
fromNodeId: sourceId,
|
|
89
|
+
toNodeId: targetId,
|
|
90
|
+
fromPortId: sourcePort,
|
|
91
|
+
toPortId: targetPort,
|
|
92
|
+
routePoints: [], // Will be calculated by library
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
setDiagram((prev) => ({
|
|
96
|
+
...prev,
|
|
97
|
+
pipes: [...prev.pipes, newPipe],
|
|
98
|
+
}));
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const handleDelete = (nodeIds: string[], pipeIds: string[]) => {
|
|
102
|
+
setDiagram((prev) => ({
|
|
103
|
+
...prev,
|
|
104
|
+
nodes: prev.nodes.filter((n) => !nodeIds.includes(n.id)),
|
|
105
|
+
pipes: prev.pipes.filter((p) => !pipeIds.includes(p.id)),
|
|
106
|
+
}));
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div style={{ display: 'flex', height: '100vh' }}>
|
|
111
|
+
{/* Symbol Library */}
|
|
112
|
+
<SymbolLibrary onSymbolDragEnd={handleSymbolDrop} />
|
|
113
|
+
|
|
114
|
+
{/* Canvas */}
|
|
115
|
+
<div style={{ flex: 1 }}>
|
|
116
|
+
<PIDCanvas
|
|
117
|
+
diagram={diagram}
|
|
118
|
+
features={{
|
|
119
|
+
pan: true,
|
|
120
|
+
zoom: true,
|
|
121
|
+
selection: true,
|
|
122
|
+
dragNodes: true,
|
|
123
|
+
keyboardShortcuts: true,
|
|
124
|
+
connectionMode: true,
|
|
125
|
+
allowSymbolDrop: true,
|
|
126
|
+
}}
|
|
127
|
+
onSymbolDrop={handleSymbolDrop}
|
|
128
|
+
onPipeCreated={handlePipeCreated}
|
|
129
|
+
onDiagramChange={setDiagram}
|
|
130
|
+
onDelete={handleDelete}
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Adding Custom SVG Symbols
|
|
141
|
+
|
|
142
|
+
The library comes with a basic set of P&ID symbols, but you can easily add your own. Symbols are static SVG definitions that can be reused across diagrams.
|
|
143
|
+
|
|
144
|
+
### Symbol Structure
|
|
145
|
+
|
|
146
|
+
Each symbol is defined by the `SymbolDefinition` interface:
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
import type { SymbolDefinition } from '@procaaso/alphinity-ui-components';
|
|
150
|
+
|
|
151
|
+
const mySymbol: SymbolDefinition = {
|
|
152
|
+
id: 'tank-vertical', // Unique identifier
|
|
153
|
+
name: 'Vertical Tank', // Display name
|
|
154
|
+
category: 'vessel', // Category for grouping
|
|
155
|
+
viewBox: { x: 0, y: 0, width: 60, height: 100 }, // SVG coordinate space
|
|
156
|
+
ports: [
|
|
157
|
+
// Connection points for pipes
|
|
158
|
+
{ id: 'inlet', x: 30, y: 0, type: 'inlet', direction: 'in' },
|
|
159
|
+
{ id: 'outlet', x: 30, y: 100, type: 'outlet', direction: 'out' },
|
|
160
|
+
],
|
|
161
|
+
svgContent: ` // SVG markup (inline as string)
|
|
162
|
+
<g>
|
|
163
|
+
<rect x="10" y="10" width="40" height="80"
|
|
164
|
+
fill="#b0bec5" stroke="#546e7a" stroke-width="2"/>
|
|
165
|
+
<line x1="30" y1="0" x2="30" y2="10"
|
|
166
|
+
stroke="#546e7a" stroke-width="3"/>
|
|
167
|
+
<line x1="30" y1="90" x2="30" y2="100"
|
|
168
|
+
stroke="#546e7a" stroke-width="3"/>
|
|
169
|
+
</g>
|
|
170
|
+
`,
|
|
171
|
+
metadata: {
|
|
172
|
+
// Optional metadata
|
|
173
|
+
description: 'Vertical storage tank',
|
|
174
|
+
tags: ['tank', 'vessel', 'storage'],
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Method 1: Extend the Built-in Library
|
|
180
|
+
|
|
181
|
+
Create a custom symbol library file in your app:
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
// src/symbols/customSymbols.ts
|
|
185
|
+
import type { SymbolDefinition } from '@procaaso/alphinity-ui-components';
|
|
186
|
+
import { mockSymbolLibrary } from '@procaaso/alphinity-ui-components';
|
|
187
|
+
|
|
188
|
+
export const customSymbols: Record<string, SymbolDefinition> = {
|
|
189
|
+
...mockSymbolLibrary, // Include built-in symbols
|
|
190
|
+
|
|
191
|
+
'tank-vertical': {
|
|
192
|
+
id: 'tank-vertical',
|
|
193
|
+
name: 'Vertical Tank',
|
|
194
|
+
category: 'vessel',
|
|
195
|
+
viewBox: { x: 0, y: 0, width: 60, height: 100 },
|
|
196
|
+
ports: [
|
|
197
|
+
{ id: 'inlet', x: 30, y: 0, type: 'inlet', direction: 'in' },
|
|
198
|
+
{ id: 'outlet', x: 30, y: 100, type: 'outlet', direction: 'out' },
|
|
199
|
+
{ id: 'drain', x: 50, y: 100, type: 'outlet', direction: 'out' },
|
|
200
|
+
],
|
|
201
|
+
svgContent: `
|
|
202
|
+
<g>
|
|
203
|
+
<!-- Tank body -->
|
|
204
|
+
<rect x="10" y="10" width="40" height="80"
|
|
205
|
+
fill="#b0bec5" stroke="#546e7a" stroke-width="2" rx="2"/>
|
|
206
|
+
<!-- Inlet pipe -->
|
|
207
|
+
<line x1="30" y1="0" x2="30" y2="10"
|
|
208
|
+
stroke="#546e7a" stroke-width="3" stroke-linecap="round"/>
|
|
209
|
+
<!-- Outlet pipe -->
|
|
210
|
+
<line x1="30" y1="90" x2="30" y2="100"
|
|
211
|
+
stroke="#546e7a" stroke-width="3" stroke-linecap="round"/>
|
|
212
|
+
<!-- Drain pipe -->
|
|
213
|
+
<line x1="50" y1="90" x2="50" y2="100"
|
|
214
|
+
stroke="#546e7a" stroke-width="2" stroke-linecap="round"/>
|
|
215
|
+
<!-- Level indicator -->
|
|
216
|
+
<line x1="12" y1="50" x2="48" y2="50"
|
|
217
|
+
stroke="#0277bd" stroke-width="1" stroke-dasharray="2,2"/>
|
|
218
|
+
</g>
|
|
219
|
+
`,
|
|
220
|
+
metadata: {
|
|
221
|
+
description: 'Vertical storage tank with drain',
|
|
222
|
+
tags: ['tank', 'vessel', 'storage'],
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
'motor-electric': {
|
|
227
|
+
id: 'motor-electric',
|
|
228
|
+
name: 'Electric Motor',
|
|
229
|
+
category: 'actuator',
|
|
230
|
+
viewBox: { x: 0, y: 0, width: 50, height: 50 },
|
|
231
|
+
ports: [{ id: 'shaft', x: 25, y: 0, type: 'outlet', direction: 'out' }],
|
|
232
|
+
svgContent: `
|
|
233
|
+
<g>
|
|
234
|
+
<!-- Motor housing -->
|
|
235
|
+
<circle cx="25" cy="25" r="20"
|
|
236
|
+
fill="#546e7a" stroke="#263238" stroke-width="2"/>
|
|
237
|
+
<!-- M label -->
|
|
238
|
+
<text x="25" y="32" text-anchor="middle"
|
|
239
|
+
font-size="20" font-weight="bold" fill="#ffffff">M</text>
|
|
240
|
+
<!-- Shaft -->
|
|
241
|
+
<line x1="25" y1="0" x2="25" y2="5"
|
|
242
|
+
stroke="#263238" stroke-width="3" stroke-linecap="round"/>
|
|
243
|
+
</g>
|
|
244
|
+
`,
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// Helper to get symbols
|
|
249
|
+
export function getCustomSymbol(id: string): SymbolDefinition | undefined {
|
|
250
|
+
return customSymbols[id];
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Then use your custom library in components:
|
|
255
|
+
|
|
256
|
+
```tsx
|
|
257
|
+
import { SymbolLibrary } from '@procaaso/alphinity-ui-components';
|
|
258
|
+
import { customSymbols } from './symbols/customSymbols';
|
|
259
|
+
|
|
260
|
+
function App() {
|
|
261
|
+
return (
|
|
262
|
+
<SymbolLibrary symbols={Object.values(customSymbols)} onSymbolDragEnd={handleSymbolDrop} />
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Method 2: Import from SVG Files
|
|
268
|
+
|
|
269
|
+
If you have existing SVG files, extract the content:
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
// symbols/fromSvgFile.ts
|
|
273
|
+
export const valveFromFile: SymbolDefinition = {
|
|
274
|
+
id: 'valve-butterfly',
|
|
275
|
+
name: 'Butterfly Valve',
|
|
276
|
+
category: 'valve',
|
|
277
|
+
viewBox: { x: 0, y: 0, width: 60, height: 40 },
|
|
278
|
+
ports: [
|
|
279
|
+
{ id: 'inlet', x: 0, y: 20, type: 'inlet', direction: 'in' },
|
|
280
|
+
{ id: 'outlet', x: 60, y: 20, type: 'outlet', direction: 'out' },
|
|
281
|
+
],
|
|
282
|
+
// Copy/paste the SVG content from your .svg file
|
|
283
|
+
// Remove the <svg> wrapper and any <?xml> declarations
|
|
284
|
+
svgContent: `
|
|
285
|
+
<g>
|
|
286
|
+
<!-- Paste SVG content here -->
|
|
287
|
+
<path d="M 10 10 L 50 30 L 10 50 Z" fill="#ccc" stroke="#333" stroke-width="2"/>
|
|
288
|
+
</g>
|
|
289
|
+
`,
|
|
290
|
+
};
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
**From diagrams.net/draw.io exports:**
|
|
294
|
+
|
|
295
|
+
1. Export as SVG
|
|
296
|
+
2. Open in text editor
|
|
297
|
+
3. Copy everything between `<svg>...</svg>`
|
|
298
|
+
4. Remove `<svg>` tags, keep inner `<g>` content
|
|
299
|
+
5. Paste into `svgContent`
|
|
300
|
+
|
|
301
|
+
### Method 3: Database-Backed Symbols
|
|
302
|
+
|
|
303
|
+
For enterprise apps, store symbols in a database:
|
|
304
|
+
|
|
305
|
+
```typescript
|
|
306
|
+
// api/symbols.ts
|
|
307
|
+
export async function loadSymbolLibrary(): Promise<Record<string, SymbolDefinition>> {
|
|
308
|
+
const response = await fetch('/api/symbols');
|
|
309
|
+
const symbols = await response.json();
|
|
310
|
+
|
|
311
|
+
return symbols.reduce((acc, symbol) => {
|
|
312
|
+
acc[symbol.id] = symbol;
|
|
313
|
+
return acc;
|
|
314
|
+
}, {});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Usage in app
|
|
318
|
+
function App() {
|
|
319
|
+
const [symbols, setSymbols] = useState<Record<string, SymbolDefinition>>({});
|
|
320
|
+
|
|
321
|
+
useEffect(() => {
|
|
322
|
+
loadSymbolLibrary().then(setSymbols);
|
|
323
|
+
}, []);
|
|
324
|
+
|
|
325
|
+
return <SymbolLibrary symbols={Object.values(symbols)} />;
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### Port Positioning Rules
|
|
330
|
+
|
|
331
|
+
Ports define where pipes connect to symbols:
|
|
332
|
+
|
|
333
|
+
```typescript
|
|
334
|
+
ports: [
|
|
335
|
+
{
|
|
336
|
+
id: 'inlet', // Unique within this symbol
|
|
337
|
+
x: 0, // X coordinate in viewBox space
|
|
338
|
+
y: 25, // Y coordinate in viewBox space
|
|
339
|
+
type: 'inlet', // Type (inlet, outlet, or custom)
|
|
340
|
+
direction: 'in', // Optional: flow direction hint
|
|
341
|
+
},
|
|
342
|
+
];
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
**Key points:**
|
|
346
|
+
|
|
347
|
+
- Coordinates are relative to `viewBox` origin (top-left)
|
|
348
|
+
- Ports at edges should be at `x: 0`, `x: width`, `y: 0`, or `y: height`
|
|
349
|
+
- Multiple ports are supported (manifolds, 3-way valves, etc.)
|
|
350
|
+
- Port types are informational only - library doesn't enforce flow direction
|
|
351
|
+
|
|
352
|
+
**Example multi-port symbol:**
|
|
353
|
+
|
|
354
|
+
```typescript
|
|
355
|
+
{
|
|
356
|
+
id: 'valve-3way',
|
|
357
|
+
viewBox: { x: 0, y: 0, width: 60, height: 60 },
|
|
358
|
+
ports: [
|
|
359
|
+
{ id: 'inlet', x: 0, y: 30, type: 'inlet' },
|
|
360
|
+
{ id: 'outlet-1', x: 60, y: 20, type: 'outlet' },
|
|
361
|
+
{ id: 'outlet-2', x: 60, y: 40, type: 'outlet' },
|
|
362
|
+
],
|
|
363
|
+
// SVG content...
|
|
364
|
+
}
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
### SVG Styling Best Practices
|
|
368
|
+
|
|
369
|
+
**Use inline styles for portability:**
|
|
370
|
+
|
|
371
|
+
```svg
|
|
372
|
+
<!-- ✅ GOOD - Inline attributes -->
|
|
373
|
+
<rect fill="#546e7a" stroke="#263238" stroke-width="2"/>
|
|
374
|
+
|
|
375
|
+
<!-- ❌ BAD - CSS classes (won't work) -->
|
|
376
|
+
<rect class="tank-body"/>
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
**Keep colors consistent:**
|
|
380
|
+
|
|
381
|
+
```typescript
|
|
382
|
+
// Standard industrial colors
|
|
383
|
+
const COLORS = {
|
|
384
|
+
steel: '#546e7a', // Equipment bodies
|
|
385
|
+
pipe: '#263238', // Pipes and connections
|
|
386
|
+
fluid: '#0277bd', // Liquid indicators
|
|
387
|
+
actuator: '#f57c00', // Actuators/motors
|
|
388
|
+
alarm: '#d32f2f', // Alarms/warnings
|
|
389
|
+
};
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
**Optimize for zoom:**
|
|
393
|
+
|
|
394
|
+
- Use `stroke-width: 2` or higher (visible when zoomed out)
|
|
395
|
+
- Avoid text unless it's large (16px+)
|
|
396
|
+
- Use simple shapes (better performance)
|
|
397
|
+
|
|
398
|
+
### Testing New Symbols
|
|
399
|
+
|
|
400
|
+
Add to the demo to preview:
|
|
401
|
+
|
|
402
|
+
```tsx
|
|
403
|
+
// demo/RuntimeDemo.tsx
|
|
404
|
+
import { customSymbols } from '../src/symbols/customSymbols';
|
|
405
|
+
|
|
406
|
+
// Test your symbol
|
|
407
|
+
const testDiagram = {
|
|
408
|
+
nodes: [
|
|
409
|
+
{
|
|
410
|
+
id: 'test-1',
|
|
411
|
+
symbolId: 'tank-vertical', // Your custom symbol
|
|
412
|
+
transform: { x: 100, y: 100, rotation: 0 },
|
|
413
|
+
},
|
|
414
|
+
],
|
|
415
|
+
pipes: [],
|
|
416
|
+
viewBox: { x: 0, y: 0, width: 2000, height: 1500 },
|
|
417
|
+
};
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
---
|
|
421
|
+
|
|
422
|
+
## Production Implementation Pattern
|
|
423
|
+
|
|
424
|
+
### Application Structure
|
|
425
|
+
|
|
426
|
+
```
|
|
427
|
+
your-app/
|
|
428
|
+
├── src/
|
|
429
|
+
│ ├── features/
|
|
430
|
+
│ │ ├── engineering-mode/ # Edit mode
|
|
431
|
+
│ │ │ ├── DiagramEditor.tsx
|
|
432
|
+
│ │ │ ├── DataStreamConfig.tsx
|
|
433
|
+
│ │ │ └── useEngineeringMode.ts
|
|
434
|
+
│ │ ├── runtime-mode/ # HMI viewer
|
|
435
|
+
│ │ │ ├── RuntimeViewer.tsx
|
|
436
|
+
│ │ │ ├── TelemetryBinding.tsx
|
|
437
|
+
│ │ │ └── useDataStreams.ts
|
|
438
|
+
│ │ └── persistence/
|
|
439
|
+
│ │ ├── useDiagramStorage.ts
|
|
440
|
+
│ │ └── api.ts
|
|
441
|
+
│ └── components/
|
|
442
|
+
│ └── DiagramWorkspace.tsx # Mode switcher
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
### State Management Hook
|
|
446
|
+
|
|
447
|
+
```tsx
|
|
448
|
+
// features/persistence/useDiagramStorage.ts
|
|
449
|
+
import { useState, useEffect } from 'react';
|
|
450
|
+
import type { PIDDiagram } from '@procaaso/alphinity-ui-components';
|
|
451
|
+
|
|
452
|
+
export function useDiagramStorage(diagramId: string) {
|
|
453
|
+
const [diagram, setDiagram] = useState<PIDDiagram | null>(null);
|
|
454
|
+
const [loading, setLoading] = useState(true);
|
|
455
|
+
const [saving, setSaving] = useState(false);
|
|
456
|
+
|
|
457
|
+
// Load from database
|
|
458
|
+
useEffect(() => {
|
|
459
|
+
async function load() {
|
|
460
|
+
const response = await fetch(`/api/diagrams/${diagramId}`);
|
|
461
|
+
const data = await response.json();
|
|
462
|
+
setDiagram(data);
|
|
463
|
+
setLoading(false);
|
|
464
|
+
}
|
|
465
|
+
load();
|
|
466
|
+
}, [diagramId]);
|
|
467
|
+
|
|
468
|
+
// Save to database (debounced)
|
|
469
|
+
const saveDiagram = async (updated: PIDDiagram) => {
|
|
470
|
+
setDiagram(updated);
|
|
471
|
+
setSaving(true);
|
|
472
|
+
|
|
473
|
+
try {
|
|
474
|
+
await fetch(`/api/diagrams/${diagramId}`, {
|
|
475
|
+
method: 'PUT',
|
|
476
|
+
headers: { 'Content-Type': 'application/json' },
|
|
477
|
+
body: JSON.stringify(updated),
|
|
478
|
+
});
|
|
479
|
+
} finally {
|
|
480
|
+
setSaving(false);
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
return { diagram, loading, saving, saveDiagram };
|
|
485
|
+
}
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
### Engineering Mode (Edit)
|
|
489
|
+
|
|
490
|
+
```tsx
|
|
491
|
+
// features/engineering-mode/DiagramEditor.tsx
|
|
492
|
+
import { PIDCanvas, SymbolLibrary } from '@procaaso/alphinity-ui-components';
|
|
493
|
+
import { useDiagramStorage } from '../persistence/useDiagramStorage';
|
|
494
|
+
import { DataStreamConfig } from './DataStreamConfig';
|
|
495
|
+
|
|
496
|
+
export function DiagramEditor({ diagramId }: { diagramId: string }) {
|
|
497
|
+
const { diagram, loading, saveDiagram } = useDiagramStorage(diagramId);
|
|
498
|
+
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
|
499
|
+
|
|
500
|
+
if (loading || !diagram) return <div>Loading...</div>;
|
|
501
|
+
|
|
502
|
+
return (
|
|
503
|
+
<div style={{ display: 'flex', height: '100vh' }}>
|
|
504
|
+
{/* Left: Symbol Library */}
|
|
505
|
+
<div style={{ width: 250, borderRight: '1px solid #ccc' }}>
|
|
506
|
+
<SymbolLibrary
|
|
507
|
+
onSymbolDragEnd={(symbolId, x, y) => {
|
|
508
|
+
// Add node to diagram
|
|
509
|
+
const newNode = {
|
|
510
|
+
id: `node-${Date.now()}`,
|
|
511
|
+
symbolId,
|
|
512
|
+
transform: { x, y, rotation: 0 },
|
|
513
|
+
};
|
|
514
|
+
saveDiagram({
|
|
515
|
+
...diagram,
|
|
516
|
+
nodes: [...diagram.nodes, newNode],
|
|
517
|
+
});
|
|
518
|
+
}}
|
|
519
|
+
/>
|
|
520
|
+
</div>
|
|
521
|
+
|
|
522
|
+
{/* Center: Canvas */}
|
|
523
|
+
<div style={{ flex: 1 }}>
|
|
524
|
+
<PIDCanvas
|
|
525
|
+
diagram={diagram}
|
|
526
|
+
features={{
|
|
527
|
+
selection: true,
|
|
528
|
+
dragNodes: true,
|
|
529
|
+
connectionMode: true,
|
|
530
|
+
keyboardShortcuts: true,
|
|
531
|
+
allowSymbolDrop: true,
|
|
532
|
+
}}
|
|
533
|
+
onNodeClick={setSelectedNodeId}
|
|
534
|
+
onDiagramChange={saveDiagram}
|
|
535
|
+
onDelete={(nodeIds, pipeIds) => {
|
|
536
|
+
saveDiagram({
|
|
537
|
+
...diagram,
|
|
538
|
+
nodes: diagram.nodes.filter((n) => !nodeIds.includes(n.id)),
|
|
539
|
+
pipes: diagram.pipes.filter((p) => !pipeIds.includes(p.id)),
|
|
540
|
+
});
|
|
541
|
+
}}
|
|
542
|
+
/>
|
|
543
|
+
</div>
|
|
544
|
+
|
|
545
|
+
{/* Right: Configuration Panel */}
|
|
546
|
+
<div style={{ width: 300, borderLeft: '1px solid #ccc' }}>
|
|
547
|
+
{selectedNodeId && (
|
|
548
|
+
<DataStreamConfig nodeId={selectedNodeId} diagram={diagram} onUpdate={saveDiagram} />
|
|
549
|
+
)}
|
|
550
|
+
</div>
|
|
551
|
+
</div>
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
### Data Stream Configuration
|
|
557
|
+
|
|
558
|
+
```tsx
|
|
559
|
+
// features/engineering-mode/DataStreamConfig.tsx
|
|
560
|
+
import { useState } from 'react';
|
|
561
|
+
import type { PIDDiagram } from '@procaaso/alphinity-ui-components';
|
|
562
|
+
|
|
563
|
+
export function DataStreamConfig({
|
|
564
|
+
nodeId,
|
|
565
|
+
diagram,
|
|
566
|
+
onUpdate,
|
|
567
|
+
}: {
|
|
568
|
+
nodeId: string;
|
|
569
|
+
diagram: PIDDiagram;
|
|
570
|
+
onUpdate: (diagram: PIDDiagram) => void;
|
|
571
|
+
}) {
|
|
572
|
+
const node = diagram.nodes.find((n) => n.id === nodeId);
|
|
573
|
+
const [dataSource, setDataSource] = useState('');
|
|
574
|
+
|
|
575
|
+
const handleBindData = () => {
|
|
576
|
+
// Store data binding metadata on the node
|
|
577
|
+
const updatedNodes = diagram.nodes.map((n) =>
|
|
578
|
+
n.id === nodeId
|
|
579
|
+
? {
|
|
580
|
+
...n,
|
|
581
|
+
metadata: {
|
|
582
|
+
...n.metadata,
|
|
583
|
+
dataSource,
|
|
584
|
+
dataType: 'temperature',
|
|
585
|
+
unit: '°F',
|
|
586
|
+
},
|
|
587
|
+
}
|
|
588
|
+
: n
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
onUpdate({ ...diagram, nodes: updatedNodes });
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
return (
|
|
595
|
+
<div style={{ padding: 16 }}>
|
|
596
|
+
<h3>Data Stream Configuration</h3>
|
|
597
|
+
<p>Node: {node?.label || nodeId}</p>
|
|
598
|
+
|
|
599
|
+
<label>
|
|
600
|
+
Data Source (Tag ID):
|
|
601
|
+
<input
|
|
602
|
+
type="text"
|
|
603
|
+
value={dataSource}
|
|
604
|
+
onChange={(e) => setDataSource(e.target.value)}
|
|
605
|
+
placeholder="e.g., PLC1.Tank01.Temp"
|
|
606
|
+
/>
|
|
607
|
+
</label>
|
|
608
|
+
|
|
609
|
+
<button onClick={handleBindData}>Bind Data Stream</button>
|
|
610
|
+
|
|
611
|
+
<div style={{ marginTop: 20 }}>
|
|
612
|
+
<h4>Current Bindings:</h4>
|
|
613
|
+
<pre>{JSON.stringify(node?.metadata, null, 2)}</pre>
|
|
614
|
+
</div>
|
|
615
|
+
</div>
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
### Runtime Mode (HMI Viewer)
|
|
621
|
+
|
|
622
|
+
```tsx
|
|
623
|
+
// features/runtime-mode/RuntimeViewer.tsx
|
|
624
|
+
import { PIDCanvas } from '@procaaso/alphinity-ui-components';
|
|
625
|
+
import { useDiagramStorage } from '../persistence/useDiagramStorage';
|
|
626
|
+
import { useDataStreams } from './useDataStreams';
|
|
627
|
+
|
|
628
|
+
export function RuntimeViewer({ diagramId }: { diagramId: string }) {
|
|
629
|
+
const { diagram, loading } = useDiagramStorage(diagramId);
|
|
630
|
+
|
|
631
|
+
// Connect to real-time data (WebSocket, MQTT, OPC UA, etc.)
|
|
632
|
+
const telemetry = useDataStreams(diagram?.nodes || []);
|
|
633
|
+
|
|
634
|
+
if (loading || !diagram) return <div>Loading...</div>;
|
|
635
|
+
|
|
636
|
+
return (
|
|
637
|
+
<div style={{ width: '100%', height: '100vh' }}>
|
|
638
|
+
<PIDCanvas
|
|
639
|
+
diagram={diagram}
|
|
640
|
+
features={{
|
|
641
|
+
pan: true,
|
|
642
|
+
zoom: true,
|
|
643
|
+
telemetry: true,
|
|
644
|
+
}}
|
|
645
|
+
telemetry={telemetry}
|
|
646
|
+
/>
|
|
647
|
+
</div>
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
### Real-Time Data Hook
|
|
653
|
+
|
|
654
|
+
```tsx
|
|
655
|
+
// features/runtime-mode/useDataStreams.ts
|
|
656
|
+
import { useState, useEffect } from 'react';
|
|
657
|
+
import type {
|
|
658
|
+
NodeInstance,
|
|
659
|
+
TelemetryMap,
|
|
660
|
+
TelemetryBinding,
|
|
661
|
+
} from '@procaaso/alphinity-ui-components';
|
|
662
|
+
|
|
663
|
+
export function useDataStreams(nodes: NodeInstance[]): TelemetryMap {
|
|
664
|
+
const [telemetry, setTelemetry] = useState<TelemetryMap>(new Map());
|
|
665
|
+
|
|
666
|
+
useEffect(() => {
|
|
667
|
+
// Example: Connect to WebSocket
|
|
668
|
+
const ws = new WebSocket('wss://your-scada-server/data');
|
|
669
|
+
|
|
670
|
+
ws.onmessage = (event) => {
|
|
671
|
+
const update = JSON.parse(event.data);
|
|
672
|
+
|
|
673
|
+
// Match incoming data to nodes
|
|
674
|
+
const updatedTelemetry = new Map(telemetry);
|
|
675
|
+
|
|
676
|
+
nodes.forEach((node) => {
|
|
677
|
+
const dataSource = node.metadata?.dataSource;
|
|
678
|
+
if (dataSource && update[dataSource]) {
|
|
679
|
+
const binding: TelemetryBinding = {
|
|
680
|
+
nodeId: node.id,
|
|
681
|
+
tagId: dataSource,
|
|
682
|
+
value: update[dataSource].value,
|
|
683
|
+
timestamp: new Date(update[dataSource].timestamp),
|
|
684
|
+
quality: update[dataSource].quality || 'good',
|
|
685
|
+
unit: node.metadata?.unit || '',
|
|
686
|
+
display: {
|
|
687
|
+
label: node.label || dataSource,
|
|
688
|
+
valueColor: '#ffffff',
|
|
689
|
+
},
|
|
690
|
+
};
|
|
691
|
+
updatedTelemetry.set(node.id, binding);
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
setTelemetry(updatedTelemetry);
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
return () => ws.close();
|
|
699
|
+
}, [nodes]);
|
|
700
|
+
|
|
701
|
+
return telemetry;
|
|
702
|
+
}
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
---
|
|
706
|
+
|
|
707
|
+
## Layout & Sizing
|
|
708
|
+
|
|
709
|
+
### Container Requirements
|
|
710
|
+
|
|
711
|
+
**PIDCanvas requires a sized container**. It uses `width: 100%` and `height: 100%` internally, so the parent must have explicit dimensions:
|
|
712
|
+
|
|
713
|
+
```tsx
|
|
714
|
+
// ✅ GOOD - Parent has explicit height
|
|
715
|
+
<div style={{ width: '100%', height: '100vh' }}>
|
|
716
|
+
<PIDCanvas diagram={diagram} />
|
|
717
|
+
</div>
|
|
718
|
+
|
|
719
|
+
// ✅ GOOD - Flexbox with flex: 1
|
|
720
|
+
<div style={{ display: 'flex', height: '100vh' }}>
|
|
721
|
+
<Sidebar />
|
|
722
|
+
<div style={{ flex: 1 }}>
|
|
723
|
+
<PIDCanvas diagram={diagram} />
|
|
724
|
+
</div>
|
|
725
|
+
</div>
|
|
726
|
+
|
|
727
|
+
// ❌ BAD - Parent has no height
|
|
728
|
+
<div>
|
|
729
|
+
<PIDCanvas diagram={diagram} /> {/* Will collapse to 0 height */}
|
|
730
|
+
</div>
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
### ViewBox Coordinates
|
|
734
|
+
|
|
735
|
+
The `viewBox` defines the world coordinate system (in SVG units), independent of pixel dimensions:
|
|
736
|
+
|
|
737
|
+
```tsx
|
|
738
|
+
diagram.viewBox = { x: 0, y: 0, width: 2000, height: 1500 };
|
|
739
|
+
// Node at { x: 1000, y: 750 } is at the center
|
|
740
|
+
// Works on any screen size (mobile, desktop, ultrawide)
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
**Key Points:**
|
|
744
|
+
|
|
745
|
+
- Node positions (transform.x/y) are in viewBox units, not pixels
|
|
746
|
+
- Pan/zoom modifies the viewBox, not node positions
|
|
747
|
+
- Grid spacing (25 units) scales with zoom level
|
|
748
|
+
- Use consistent viewBox dimensions across diagrams for predictable spacing
|
|
749
|
+
|
|
750
|
+
### Aspect Ratio Preservation
|
|
751
|
+
|
|
752
|
+
PIDCanvas uses `preserveAspectRatio="xMidYMid meet"`:
|
|
753
|
+
|
|
754
|
+
- Content is centered and scaled to fit
|
|
755
|
+
- Maintains aspect ratio (no distortion)
|
|
756
|
+
- May add letterboxing (top/bottom) or pillarboxing (sides) on ultra-wide/tall displays
|
|
757
|
+
|
|
758
|
+
This is handled automatically - coordinate transformation accounts for it.
|
|
759
|
+
|
|
760
|
+
### Performance Considerations
|
|
761
|
+
|
|
762
|
+
**Large Diagrams (100+ nodes):**
|
|
763
|
+
|
|
764
|
+
- Each node/pipe is a separate SVG element (good for interactivity)
|
|
765
|
+
- Port indicators render for all nodes in connection mode
|
|
766
|
+
- Consider implementing viewport culling if needed (future enhancement)
|
|
767
|
+
|
|
768
|
+
**High-Frequency Telemetry Updates:**
|
|
769
|
+
|
|
770
|
+
- Use `useTelemetry` hook with batched updates
|
|
771
|
+
- Debounce incoming data streams (max 1-2 updates/sec per node)
|
|
772
|
+
- DataOverlay components re-render only when their telemetry changes
|
|
773
|
+
|
|
774
|
+
---
|
|
775
|
+
|
|
776
|
+
## Recommended Patterns (From Demo)
|
|
777
|
+
|
|
778
|
+
These patterns from `RuntimeDemo.tsx` represent best practices for production implementations:
|
|
779
|
+
|
|
780
|
+
### 1. Edit/Runtime Mode Switching
|
|
781
|
+
|
|
782
|
+
```tsx
|
|
783
|
+
function DiagramWorkspace({ diagramId }: { diagramId: string }) {
|
|
784
|
+
const [mode, setMode] = useState<'edit' | 'runtime'>('edit');
|
|
785
|
+
const { diagram, saveDiagram } = useDiagramStorage(diagramId);
|
|
786
|
+
|
|
787
|
+
return (
|
|
788
|
+
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
|
|
789
|
+
{/* Mode Toggle */}
|
|
790
|
+
<header style={{ padding: 10, borderBottom: '1px solid #ccc' }}>
|
|
791
|
+
<button onClick={() => setMode('edit')}>Edit Mode</button>
|
|
792
|
+
<button onClick={() => setMode('runtime')}>Runtime Mode</button>
|
|
793
|
+
</header>
|
|
794
|
+
|
|
795
|
+
{/* Canvas with mode-specific features */}
|
|
796
|
+
<div style={{ flex: 1, display: 'flex' }}>
|
|
797
|
+
{mode === 'edit' && <SymbolLibrary />}
|
|
798
|
+
|
|
799
|
+
<div style={{ flex: 1 }}>
|
|
800
|
+
<PIDCanvas
|
|
801
|
+
diagram={diagram}
|
|
802
|
+
features={{
|
|
803
|
+
// Edit features
|
|
804
|
+
selection: mode === 'edit',
|
|
805
|
+
dragNodes: mode === 'edit',
|
|
806
|
+
connectionMode: mode === 'edit',
|
|
807
|
+
keyboardShortcuts: mode === 'edit',
|
|
808
|
+
allowSymbolDrop: mode === 'edit',
|
|
809
|
+
editPipeRoutes: mode === 'edit',
|
|
810
|
+
|
|
811
|
+
// Runtime features
|
|
812
|
+
telemetry: mode === 'runtime',
|
|
813
|
+
|
|
814
|
+
// Always enabled
|
|
815
|
+
pan: true,
|
|
816
|
+
zoom: true,
|
|
817
|
+
}}
|
|
818
|
+
onDiagramChange={mode === 'edit' ? saveDiagram : undefined}
|
|
819
|
+
telemetry={mode === 'runtime' ? telemetry : undefined}
|
|
820
|
+
/>
|
|
821
|
+
</div>
|
|
822
|
+
</div>
|
|
823
|
+
</div>
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
**Why this pattern:**
|
|
829
|
+
|
|
830
|
+
- Clear separation of concerns (editing vs monitoring)
|
|
831
|
+
- Prevents accidental edits in production HMI
|
|
832
|
+
- Features are explicitly tied to user role/mode
|
|
833
|
+
|
|
834
|
+
### 2. Connection Mode with Visual Port Selection
|
|
835
|
+
|
|
836
|
+
```tsx
|
|
837
|
+
const [connectionMode, setConnectionMode] = useState(false);
|
|
838
|
+
|
|
839
|
+
<PIDCanvas
|
|
840
|
+
features={{
|
|
841
|
+
connectionMode: connectionMode && mode === 'edit',
|
|
842
|
+
// Disable other interactions during connection
|
|
843
|
+
selection: !connectionMode && mode === 'edit',
|
|
844
|
+
dragNodes: !connectionMode && mode === 'edit',
|
|
845
|
+
}}
|
|
846
|
+
onPipeCreated={(sourceId, targetId, sourcePort, targetPort) => {
|
|
847
|
+
const newPipe = {
|
|
848
|
+
id: `pipe-${Date.now()}`,
|
|
849
|
+
fromNodeId: sourceId,
|
|
850
|
+
toNodeId: targetId,
|
|
851
|
+
fromPortId: sourcePort, // User-selected port
|
|
852
|
+
toPortId: targetPort, // User-selected port
|
|
853
|
+
routePoints: [],
|
|
854
|
+
};
|
|
855
|
+
saveDiagram({ ...diagram, pipes: [...diagram.pipes, newPipe] });
|
|
856
|
+
setConnectionMode(false);
|
|
857
|
+
}}
|
|
858
|
+
/>;
|
|
859
|
+
|
|
860
|
+
{
|
|
861
|
+
/* Connection Mode Toggle */
|
|
862
|
+
}
|
|
863
|
+
<button onClick={() => setConnectionMode(!connectionMode)}>
|
|
864
|
+
{connectionMode ? 'Cancel Connection' : 'Connect Nodes'}
|
|
865
|
+
</button>;
|
|
866
|
+
```
|
|
867
|
+
|
|
868
|
+
**Why this pattern:**
|
|
869
|
+
|
|
870
|
+
- User has full control over which ports connect
|
|
871
|
+
- Visual feedback (port circles, rubber-band line)
|
|
872
|
+
- No directional enforcement (any port → any port)
|
|
873
|
+
- Works with node rotation (ports move with transforms)
|
|
874
|
+
|
|
875
|
+
### 3. Auto-Save with Diagram Changes
|
|
876
|
+
|
|
877
|
+
```tsx
|
|
878
|
+
const debouncedSave = useDebouncedCallback(
|
|
879
|
+
(diagram: PIDDiagram) => {
|
|
880
|
+
fetch(`/api/diagrams/${diagramId}`, {
|
|
881
|
+
method: 'PUT',
|
|
882
|
+
body: JSON.stringify(diagram),
|
|
883
|
+
});
|
|
884
|
+
},
|
|
885
|
+
1000 // Save 1 second after last change
|
|
886
|
+
);
|
|
887
|
+
|
|
888
|
+
<PIDCanvas
|
|
889
|
+
onDiagramChange={debouncedSave}
|
|
890
|
+
onNodeMove={debouncedSave}
|
|
891
|
+
// Other callbacks also trigger auto-save
|
|
892
|
+
/>;
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
**Why this pattern:**
|
|
896
|
+
|
|
897
|
+
- No manual "Save" button needed
|
|
898
|
+
- Prevents data loss from browser crashes
|
|
899
|
+
- Debouncing prevents excessive API calls during drag operations
|
|
900
|
+
|
|
901
|
+
### 4. Three-Panel Layout (Symbol Library + Canvas + Config)
|
|
902
|
+
|
|
903
|
+
```tsx
|
|
904
|
+
<div style={{ display: 'flex', height: '100vh' }}>
|
|
905
|
+
{/* Left: Symbol Library */}
|
|
906
|
+
<div style={{ width: 250, borderRight: '1px solid #ccc', overflowY: 'auto' }}>
|
|
907
|
+
<SymbolLibrary onSymbolDragEnd={handleSymbolDrop} />
|
|
908
|
+
</div>
|
|
909
|
+
|
|
910
|
+
{/* Center: Canvas */}
|
|
911
|
+
<div style={{ flex: 1, position: 'relative' }}>
|
|
912
|
+
<PIDCanvas
|
|
913
|
+
diagram={diagram}
|
|
914
|
+
onNodeClick={setSelectedNodeId}
|
|
915
|
+
// ... other props
|
|
916
|
+
/>
|
|
917
|
+
|
|
918
|
+
{/* Connection mode toggle overlay */}
|
|
919
|
+
{mode === 'edit' && (
|
|
920
|
+
<button
|
|
921
|
+
style={{ position: 'absolute', top: 20, left: 20 }}
|
|
922
|
+
onClick={() => setConnectionMode(!connectionMode)}
|
|
923
|
+
>
|
|
924
|
+
{connectionMode ? '✖ Cancel' : '🔗 Connect'}
|
|
925
|
+
</button>
|
|
926
|
+
)}
|
|
927
|
+
</div>
|
|
928
|
+
|
|
929
|
+
{/* Right: Configuration Panel */}
|
|
930
|
+
{selectedNodeId && (
|
|
931
|
+
<div style={{ width: 300, borderLeft: '1px solid #ccc', padding: 16 }}>
|
|
932
|
+
<NodeConfigPanel nodeId={selectedNodeId} diagram={diagram} onUpdate={saveDiagram} />
|
|
933
|
+
</div>
|
|
934
|
+
)}
|
|
935
|
+
</div>
|
|
936
|
+
```
|
|
937
|
+
|
|
938
|
+
**Why this pattern:**
|
|
939
|
+
|
|
940
|
+
- Familiar CAD/drawing tool UX
|
|
941
|
+
- Symbol library always accessible
|
|
942
|
+
- Configuration panel appears when node selected
|
|
943
|
+
- Canvas takes remaining space (responsive)
|
|
944
|
+
|
|
945
|
+
### 5. Keyboard Shortcuts
|
|
946
|
+
|
|
947
|
+
```tsx
|
|
948
|
+
<PIDCanvas
|
|
949
|
+
features={{
|
|
950
|
+
keyboardShortcuts: mode === 'edit',
|
|
951
|
+
}}
|
|
952
|
+
onDelete={(nodeIds, pipeIds) => {
|
|
953
|
+
const updatedDiagram = {
|
|
954
|
+
...diagram,
|
|
955
|
+
nodes: diagram.nodes.filter((n) => !nodeIds.includes(n.id)),
|
|
956
|
+
pipes: diagram.pipes.filter((p) => !pipeIds.includes(p.id)),
|
|
957
|
+
};
|
|
958
|
+
saveDiagram(updatedDiagram);
|
|
959
|
+
}}
|
|
960
|
+
/>
|
|
961
|
+
```
|
|
962
|
+
|
|
963
|
+
**Built-in shortcuts when enabled:**
|
|
964
|
+
|
|
965
|
+
- `Delete` - Remove selected nodes/pipes
|
|
966
|
+
- `Ctrl+C` - Copy selection (calls `onItemsCopied`)
|
|
967
|
+
- `Ctrl+V` - Paste (calls `onPaste`)
|
|
968
|
+
|
|
969
|
+
**Why this pattern:**
|
|
970
|
+
|
|
971
|
+
- Industry-standard shortcuts
|
|
972
|
+
- Opt-in (disabled in runtime mode)
|
|
973
|
+
- You implement the actual copy/paste logic
|
|
974
|
+
|
|
975
|
+
### 6. Port-Based Routing (No Direction Enforcement)
|
|
976
|
+
|
|
977
|
+
```tsx
|
|
978
|
+
// Library auto-calculates route between any two ports
|
|
979
|
+
onPipeCreated={(sourceId, targetId, sourcePort, targetPort) => {
|
|
980
|
+
// sourcePort and targetPort can be any port (inlet, outlet, or custom)
|
|
981
|
+
// Library doesn't enforce "outlet → inlet" direction
|
|
982
|
+
const pipe = {
|
|
983
|
+
fromNodeId: sourceId,
|
|
984
|
+
toNodeId: targetId,
|
|
985
|
+
fromPortId: sourcePort || 'outlet', // Fallback to default if not specified
|
|
986
|
+
toPortId: targetPort || 'inlet',
|
|
987
|
+
routePoints: [], // Library calculates orthogonal routing
|
|
988
|
+
};
|
|
989
|
+
// ...
|
|
990
|
+
}
|
|
991
|
+
```
|
|
992
|
+
|
|
993
|
+
**Why this pattern:**
|
|
994
|
+
|
|
995
|
+
- Supports non-standard connections (recirculation loops, bypass lines)
|
|
996
|
+
- Works with rotated nodes (ports maintain correct positions)
|
|
997
|
+
- Users can rotate nodes after connecting without breaking pipes
|
|
998
|
+
- Future-proof for multi-port symbols (3-way valves, manifolds)
|
|
999
|
+
|
|
1000
|
+
---
|
|
1001
|
+
|
|
1002
|
+
## Best Practices
|
|
1003
|
+
|
|
1004
|
+
### 1. Separate Edit and Runtime Modes
|
|
1005
|
+
|
|
1006
|
+
**Don't** enable interactive features in runtime mode:
|
|
1007
|
+
|
|
1008
|
+
```tsx
|
|
1009
|
+
// ❌ BAD - allows editing in production HMI
|
|
1010
|
+
<PIDCanvas features={{ selection: true, dragNodes: true }} />
|
|
1011
|
+
```
|
|
1012
|
+
|
|
1013
|
+
**Do** use role-based feature flags:
|
|
1014
|
+
|
|
1015
|
+
```tsx
|
|
1016
|
+
// ✅ GOOD - features based on user role
|
|
1017
|
+
<PIDCanvas
|
|
1018
|
+
features={{
|
|
1019
|
+
selection: user.role === 'engineer',
|
|
1020
|
+
dragNodes: user.role === 'engineer',
|
|
1021
|
+
telemetry: mode === 'runtime',
|
|
1022
|
+
}}
|
|
1023
|
+
/>
|
|
1024
|
+
```
|
|
1025
|
+
|
|
1026
|
+
### 2. Store Data Bindings as Metadata
|
|
1027
|
+
|
|
1028
|
+
```tsx
|
|
1029
|
+
// Store on node.metadata (not in telemetry)
|
|
1030
|
+
const node: NodeInstance = {
|
|
1031
|
+
id: 'tank-01',
|
|
1032
|
+
symbolId: 'tank-vertical',
|
|
1033
|
+
transform: { x: 100, y: 100, rotation: 0 },
|
|
1034
|
+
metadata: {
|
|
1035
|
+
// Your application data
|
|
1036
|
+
dataSource: 'PLC1.TK001.Level',
|
|
1037
|
+
unit: '%',
|
|
1038
|
+
alarmHigh: 95,
|
|
1039
|
+
alarmLow: 10,
|
|
1040
|
+
},
|
|
1041
|
+
};
|
|
1042
|
+
```
|
|
1043
|
+
|
|
1044
|
+
### 3. Lazy Load Diagrams
|
|
1045
|
+
|
|
1046
|
+
```tsx
|
|
1047
|
+
// Don't load all diagrams at once
|
|
1048
|
+
const diagrams = await fetch('/api/diagrams?summary=true');
|
|
1049
|
+
|
|
1050
|
+
// Load full diagram when needed
|
|
1051
|
+
const fullDiagram = await fetch(`/api/diagrams/${id}`);
|
|
1052
|
+
```
|
|
1053
|
+
|
|
1054
|
+
### 4. Debounce Saves
|
|
1055
|
+
|
|
1056
|
+
````tsx
|
|
1057
|
+
import { useDebouncedCallback } from 'use-debounce';
|
|
1058
|
+
|
|
1059
|
+
const debouncedSave = useDebouncedCallback((diagram) => {
|
|
1060
|
+
saveDiagram(diagram);
|
|
1061
|
+
### 4. Debounce Saves
|
|
1062
|
+
|
|
1063
|
+
```tsx
|
|
1064
|
+
import { useDebouncedCallback } from 'use-debounce';
|
|
1065
|
+
|
|
1066
|
+
const debouncedSave = useDebouncedCallback((diagram) => {
|
|
1067
|
+
saveDiagram(diagram);
|
|
1068
|
+
}, 1000);
|
|
1069
|
+
````
|
|
1070
|
+
|
|
1071
|
+
### 5. Handle Connection Mode State
|
|
1072
|
+
|
|
1073
|
+
When connection mode is active, disable conflicting features:
|
|
1074
|
+
|
|
1075
|
+
```tsx
|
|
1076
|
+
const features = {
|
|
1077
|
+
selection: mode === 'edit' && !connectionMode,
|
|
1078
|
+
dragNodes: mode === 'edit' && !connectionMode,
|
|
1079
|
+
connectionMode: mode === 'edit' && connectionMode,
|
|
1080
|
+
editPipeRoutes: mode === 'edit' && !connectionMode,
|
|
1081
|
+
};
|
|
1082
|
+
```
|
|
1083
|
+
|
|
1084
|
+
**Why:** Prevents confusing UX where user tries to drag nodes while connecting pipes.
|
|
1085
|
+
|
|
1086
|
+
### 6. Use Absolute Positioning for Overlays
|
|
1087
|
+
|
|
1088
|
+
```tsx
|
|
1089
|
+
{
|
|
1090
|
+
/* Connection mode button */
|
|
1091
|
+
}
|
|
1092
|
+
<button
|
|
1093
|
+
style={{
|
|
1094
|
+
position: 'absolute',
|
|
1095
|
+
top: 20,
|
|
1096
|
+
left: 20,
|
|
1097
|
+
zIndex: 10,
|
|
1098
|
+
}}
|
|
1099
|
+
onClick={() => setConnectionMode(!connectionMode)}
|
|
1100
|
+
>
|
|
1101
|
+
{connectionMode ? '✖ Cancel Connection' : '🔗 Connect Nodes'}
|
|
1102
|
+
</button>;
|
|
1103
|
+
```
|
|
1104
|
+
|
|
1105
|
+
**Why:** Keeps controls accessible without interfering with canvas pan/zoom.
|
|
1106
|
+
|
|
1107
|
+
---
|
|
1108
|
+
|
|
1109
|
+
## Common Gotchas
|
|
1110
|
+
|
|
1111
|
+
### 1. Container Height Collapsing
|
|
1112
|
+
|
|
1113
|
+
**Problem:** Canvas doesn't appear or has 0 height.
|
|
1114
|
+
|
|
1115
|
+
**Solution:** Ensure parent has explicit height:
|
|
1116
|
+
|
|
1117
|
+
```tsx
|
|
1118
|
+
// Add height: '100vh' to root container
|
|
1119
|
+
<div style={{ height: '100vh' }}>
|
|
1120
|
+
<PIDCanvas ... />
|
|
1121
|
+
</div>
|
|
1122
|
+
```
|
|
1123
|
+
|
|
1124
|
+
### 2. Node Positions After Zoom
|
|
1125
|
+
|
|
1126
|
+
**Problem:** Dropped symbols appear at wrong location when zoomed.
|
|
1127
|
+
|
|
1128
|
+
**Solution:** Already handled! `screenToWorld()` accounts for viewBox and aspect ratio. Just use:
|
|
1129
|
+
|
|
1130
|
+
```tsx
|
|
1131
|
+
onSymbolDrop={(symbolId, position) => {
|
|
1132
|
+
// position.x/y are already in world coordinates
|
|
1133
|
+
const node = { transform: { x: position.x, y: position.y } };
|
|
1134
|
+
}}
|
|
1135
|
+
```
|
|
1136
|
+
|
|
1137
|
+
### 3. Port Indicators Not Visible at Low Zoom
|
|
1138
|
+
|
|
1139
|
+
**Problem:** Port circles (5px radius) become tiny when zoomed out.
|
|
1140
|
+
|
|
1141
|
+
**Solution:** Indicators are SVG elements that scale with zoom. If needed, adjust radius:
|
|
1142
|
+
|
|
1143
|
+
```tsx
|
|
1144
|
+
// Port indicators are sized in world units, not pixels
|
|
1145
|
+
// At viewBox scale 1:1, radius 5 = 5 screen pixels
|
|
1146
|
+
// At 50% zoom, radius 5 = 2.5 screen pixels
|
|
1147
|
+
```
|
|
1148
|
+
|
|
1149
|
+
For zoom-independent sizing, consider adding a feature request for screen-space indicators.
|
|
1150
|
+
|
|
1151
|
+
### 4. Pipe Routes Not Updating After Node Rotation
|
|
1152
|
+
|
|
1153
|
+
**Problem:** Rotating a node doesn't update connected pipe endpoints.
|
|
1154
|
+
|
|
1155
|
+
**Solution:** Call `onDiagramChange` or manually recalculate routes:
|
|
1156
|
+
|
|
1157
|
+
```tsx
|
|
1158
|
+
import { generateRoutePoints } from '@procaaso/alphinity-ui-components';
|
|
1159
|
+
|
|
1160
|
+
const updatedPipes = diagram.pipes.map((pipe) => {
|
|
1161
|
+
const source = diagram.nodes.find((n) => n.id === pipe.fromNodeId);
|
|
1162
|
+
const target = diagram.nodes.find((n) => n.id === pipe.toNodeId);
|
|
1163
|
+
|
|
1164
|
+
return {
|
|
1165
|
+
...pipe,
|
|
1166
|
+
routePoints: generateRoutePoints(source, pipe.fromPortId, target, pipe.toPortId),
|
|
1167
|
+
};
|
|
1168
|
+
});
|
|
1169
|
+
```
|
|
1170
|
+
|
|
1171
|
+
Library auto-handles this for node dragging, but not for rotation (yet).
|
|
1172
|
+
|
|
1173
|
+
### 5. Telemetry Updates Causing Performance Issues
|
|
1174
|
+
|
|
1175
|
+
**Problem:** WebSocket sends 100 updates/second, UI lags.
|
|
1176
|
+
|
|
1177
|
+
**Solution:** Throttle updates in your data hook:
|
|
1178
|
+
|
|
1179
|
+
```tsx
|
|
1180
|
+
import { throttle } from 'lodash';
|
|
1181
|
+
|
|
1182
|
+
const updateTelemetry = throttle((data) => {
|
|
1183
|
+
setTelemetry(data);
|
|
1184
|
+
}, 500); // Max 2 updates/second
|
|
1185
|
+
```
|
|
1186
|
+
|
|
1187
|
+
### 6. Multiple Connection Mode Buttons
|
|
1188
|
+
|
|
1189
|
+
**Problem:** User clicks "Connect" again while already connecting.
|
|
1190
|
+
|
|
1191
|
+
**Solution:** Show different UI states:
|
|
1192
|
+
|
|
1193
|
+
```tsx
|
|
1194
|
+
<button
|
|
1195
|
+
onClick={() => setConnectionMode(!connectionMode)}
|
|
1196
|
+
style={{
|
|
1197
|
+
backgroundColor: connectionMode ? '#ef4444' : '#3b82f6',
|
|
1198
|
+
}}
|
|
1199
|
+
>
|
|
1200
|
+
{connectionMode ? '✖ Cancel Connection' : '🔗 Connect Nodes'}
|
|
1201
|
+
</button>;
|
|
1202
|
+
|
|
1203
|
+
{
|
|
1204
|
+
connectionMode && <p style={{ marginTop: 8 }}>Click first node to start connection...</p>;
|
|
1205
|
+
}
|
|
1206
|
+
```
|
|
1207
|
+
|
|
1208
|
+
---
|
|
1209
|
+
|
|
1210
|
+
## Migration from Demo
|
|
1211
|
+
|
|
1212
|
+
```typescript
|
|
1213
|
+
// Example Express.js routes
|
|
1214
|
+
app.get('/api/diagrams/:id', async (req, res) => {
|
|
1215
|
+
const diagram = await db.diagrams.findById(req.params.id);
|
|
1216
|
+
res.json(diagram);
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
app.put('/api/diagrams/:id', async (req, res) => {
|
|
1220
|
+
const diagram = req.body;
|
|
1221
|
+
await db.diagrams.update(req.params.id, diagram);
|
|
1222
|
+
res.json({ success: true });
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
// WebSocket for real-time data
|
|
1226
|
+
wss.on('connection', (ws) => {
|
|
1227
|
+
const interval = setInterval(() => {
|
|
1228
|
+
// Fetch latest values from SCADA/PLC
|
|
1229
|
+
const data = {
|
|
1230
|
+
'PLC1.Tank01.Temp': { value: 72.5, timestamp: new Date(), quality: 'good' },
|
|
1231
|
+
'PLC1.Tank01.Level': { value: 45.2, timestamp: new Date(), quality: 'good' },
|
|
1232
|
+
};
|
|
1233
|
+
ws.send(JSON.stringify(data));
|
|
1234
|
+
}, 1000);
|
|
1235
|
+
|
|
1236
|
+
ws.on('close', () => clearInterval(interval));
|
|
1237
|
+
});
|
|
1238
|
+
```
|
|
1239
|
+
|
|
1240
|
+
---
|
|
1241
|
+
|
|
1242
|
+
## API Backend Example
|
|
1243
|
+
|
|
1244
|
+
```typescript
|
|
1245
|
+
// Example Express.js routes
|
|
1246
|
+
app.get('/api/diagrams/:id', async (req, res) => {
|
|
1247
|
+
const diagram = await db.diagrams.findById(req.params.id);
|
|
1248
|
+
res.json(diagram);
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
app.put('/api/diagrams/:id', async (req, res) => {
|
|
1252
|
+
const diagram = req.body;
|
|
1253
|
+
await db.diagrams.update(req.params.id, diagram);
|
|
1254
|
+
res.json({ success: true });
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
// WebSocket for real-time data
|
|
1258
|
+
wss.on('connection', (ws) => {
|
|
1259
|
+
const interval = setInterval(() => {
|
|
1260
|
+
// Fetch latest values from SCADA/PLC
|
|
1261
|
+
const data = {
|
|
1262
|
+
'PLC1.Tank01.Temp': { value: 72.5, timestamp: new Date(), quality: 'good' },
|
|
1263
|
+
'PLC1.Tank01.Level': { value: 45.2, timestamp: new Date(), quality: 'good' },
|
|
1264
|
+
};
|
|
1265
|
+
ws.send(JSON.stringify(data));
|
|
1266
|
+
}, 1000);
|
|
1267
|
+
|
|
1268
|
+
ws.on('close', () => clearInterval(interval));
|
|
1269
|
+
});
|
|
1270
|
+
```
|
|
1271
|
+
|
|
1272
|
+
---
|
|
1273
|
+
|
|
1274
|
+
## Performance Tuning
|
|
1275
|
+
|
|
1276
|
+
### Large Diagrams (100+ Nodes)
|
|
1277
|
+
|
|
1278
|
+
**Symptom:** Lag during pan/zoom or slow rendering.
|
|
1279
|
+
|
|
1280
|
+
**Solutions:**
|
|
1281
|
+
|
|
1282
|
+
1. **Limit telemetry updates:** Max 1-2 Hz per node
|
|
1283
|
+
2. **Debounce auto-save:** Wait for user to stop editing
|
|
1284
|
+
3. **Lazy load diagrams:** Load summary list, full diagram on demand
|
|
1285
|
+
4. **Consider pagination:** Split large process units across multiple diagrams
|
|
1286
|
+
|
|
1287
|
+
### High-Frequency Data
|
|
1288
|
+
|
|
1289
|
+
**Symptom:** CPU spikes, choppy animation.
|
|
1290
|
+
|
|
1291
|
+
**Solutions:**
|
|
1292
|
+
|
|
1293
|
+
```tsx
|
|
1294
|
+
// Throttle incoming WebSocket messages
|
|
1295
|
+
const throttledUpdate = throttle((data) => {
|
|
1296
|
+
updateTelemetry(data);
|
|
1297
|
+
}, 500); // 2 updates/sec max
|
|
1298
|
+
|
|
1299
|
+
ws.onmessage = (event) => {
|
|
1300
|
+
throttledUpdate(JSON.parse(event.data));
|
|
1301
|
+
};
|
|
1302
|
+
```
|
|
1303
|
+
|
|
1304
|
+
### Memory Leaks
|
|
1305
|
+
|
|
1306
|
+
**Symptom:** Browser memory grows over time in runtime mode.
|
|
1307
|
+
|
|
1308
|
+
**Solutions:**
|
|
1309
|
+
|
|
1310
|
+
```tsx
|
|
1311
|
+
// Clean up WebSocket connections
|
|
1312
|
+
useEffect(() => {
|
|
1313
|
+
const ws = new WebSocket('...');
|
|
1314
|
+
|
|
1315
|
+
return () => {
|
|
1316
|
+
ws.close(); // Important!
|
|
1317
|
+
};
|
|
1318
|
+
}, []);
|
|
1319
|
+
|
|
1320
|
+
// Clean up intervals
|
|
1321
|
+
useEffect(() => {
|
|
1322
|
+
const interval = setInterval(...);
|
|
1323
|
+
|
|
1324
|
+
return () => {
|
|
1325
|
+
clearInterval(interval); // Important!
|
|
1326
|
+
};
|
|
1327
|
+
}, []);
|
|
1328
|
+
```
|
|
1329
|
+
|
|
1330
|
+
---
|
|
1331
|
+
|
|
1332
|
+
## Migration from Demo
|
|
1333
|
+
|
|
1334
|
+
The `RuntimeDemo.tsx` file demonstrates all recommended patterns. To migrate to production:
|
|
1335
|
+
|
|
1336
|
+
1. **Extract state management** → Create `useDiagramStorage` hook
|
|
1337
|
+
2. **Move mode switching** → Implement in your app's routing/layout
|
|
1338
|
+
3. **Add persistence** → Integrate with your database API
|
|
1339
|
+
4. **Add authentication** → Role-based feature flags (engineer vs operator)
|
|
1340
|
+
5. **Connect real data** → Replace mock simulation with SCADA/OPC UA client
|
|
1341
|
+
|
|
1342
|
+
**The patterns in RuntimeDemo are production-ready** - they represent best practices for:
|
|
1343
|
+
|
|
1344
|
+
- Edit/runtime mode separation
|
|
1345
|
+
- Visual port selection
|
|
1346
|
+
- Three-panel layout
|
|
1347
|
+
- Auto-save with debouncing
|
|
1348
|
+
- Keyboard shortcuts
|
|
1349
|
+
- Connection mode state management
|
|
1350
|
+
|
|
1351
|
+
The library handles **"how to render and interact"**, your app handles **"what to show and where data comes from"**.
|