Migrate to new Gitea instance (gitea.wbd-rd.nl)
- Update all submodule URLs from gitea.centraal.wbd-rd.nl to gitea.wbd-rd.nl - Add settler as proper submodule in .gitmodules - Add agent skills, function anchors, decisions, and improvements - Add Docker configuration and scripts - Add manuals and third_party docs - Update .gitignore with secrets and build artifacts - Remove stale .tgz build artifact Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
613
scripts/transform-flow-step4.js
Normal file
613
scripts/transform-flow-step4.js
Normal file
@@ -0,0 +1,613 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Step 4: Manual Controls per PS Detail Page
|
||||
* - Creates 3 PS detail pages (/ps-west, /ps-north, /ps-south) with control groups
|
||||
* - Adds control widgets: mode switches, pump speed sliders
|
||||
* - Format functions to convert dashboard inputs to process node messages
|
||||
* - Link-in/out routing between dashboard tab and PS tabs
|
||||
* - Per-PS monitoring charts on detail pages
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const FLOW_PATH = path.join(__dirname, '..', 'docker', 'demo-flow.json');
|
||||
const flow = JSON.parse(fs.readFileSync(FLOW_PATH, 'utf8'));
|
||||
|
||||
const byId = (id) => flow.find(n => n.id === id);
|
||||
|
||||
// =============================================
|
||||
// Helper to create a standard set of controls for a PS
|
||||
// =============================================
|
||||
function createPSDetailPage(config) {
|
||||
const {
|
||||
psKey, // 'west', 'north', 'south'
|
||||
psLabel, // 'PS West', 'PS North', 'PS South'
|
||||
pagePath, // '/ps-west'
|
||||
pageOrder, // 2, 3, 4
|
||||
psNodeId, // 'demo_ps_west'
|
||||
pumps, // [{id: 'demo_pump_w1', label: 'W1'}, ...]
|
||||
controlModes, // ['levelbased','flowbased','manual']
|
||||
defaultMode, // 'levelbased'
|
||||
maxFlow, // 300
|
||||
basinHeight, // 4
|
||||
tabId, // 'demo_tab_ps_west'
|
||||
} = config;
|
||||
|
||||
const prefix = `demo_ctrl_${psKey}`;
|
||||
const nodes = [];
|
||||
|
||||
// === Page ===
|
||||
nodes.push({
|
||||
id: `demo_ui_page_ps_${psKey}_detail`,
|
||||
type: "ui-page",
|
||||
name: `${psLabel} Detail`,
|
||||
ui: "demo_ui_base",
|
||||
path: pagePath,
|
||||
icon: "water_drop",
|
||||
layout: "grid",
|
||||
theme: "demo_ui_theme",
|
||||
breakpoints: [{ name: "Default", px: "0", cols: "12" }],
|
||||
order: pageOrder,
|
||||
className: ""
|
||||
});
|
||||
|
||||
// === Groups ===
|
||||
nodes.push(
|
||||
{
|
||||
id: `${prefix}_grp_controls`,
|
||||
type: "ui-group",
|
||||
name: `${psLabel} Controls`,
|
||||
page: `demo_ui_page_ps_${psKey}_detail`,
|
||||
width: "6",
|
||||
height: "1",
|
||||
order: 1,
|
||||
showTitle: true,
|
||||
className: ""
|
||||
},
|
||||
{
|
||||
id: `${prefix}_grp_monitoring`,
|
||||
type: "ui-group",
|
||||
name: `${psLabel} Monitoring`,
|
||||
page: `demo_ui_page_ps_${psKey}_detail`,
|
||||
width: "6",
|
||||
height: "1",
|
||||
order: 2,
|
||||
showTitle: true,
|
||||
className: ""
|
||||
},
|
||||
{
|
||||
id: `${prefix}_grp_charts`,
|
||||
type: "ui-group",
|
||||
name: `${psLabel} Trends`,
|
||||
page: `demo_ui_page_ps_${psKey}_detail`,
|
||||
width: "12",
|
||||
height: "1",
|
||||
order: 3,
|
||||
showTitle: true,
|
||||
className: ""
|
||||
}
|
||||
);
|
||||
|
||||
// === PS Mode button group ===
|
||||
const modeOptions = controlModes.map(m => ({
|
||||
label: m === 'levelbased' ? 'Level' : m === 'flowbased' ? 'Flow' : m.charAt(0).toUpperCase() + m.slice(1),
|
||||
value: m,
|
||||
valueType: "str"
|
||||
}));
|
||||
|
||||
nodes.push({
|
||||
id: `${prefix}_mode`,
|
||||
type: "ui-button-group",
|
||||
z: "demo_tab_dashboard",
|
||||
group: `${prefix}_grp_controls`,
|
||||
name: `${psLabel} Mode`,
|
||||
label: "Station Mode",
|
||||
tooltip: "",
|
||||
order: 1,
|
||||
width: "6",
|
||||
height: "1",
|
||||
passthru: false,
|
||||
options: modeOptions,
|
||||
x: 120, y: 100 + pageOrder * 300,
|
||||
wires: [[`${prefix}_fn_mode`]]
|
||||
});
|
||||
|
||||
// Format: PS mode → setMode message
|
||||
nodes.push({
|
||||
id: `${prefix}_fn_mode`,
|
||||
type: "function",
|
||||
z: "demo_tab_dashboard",
|
||||
name: `Fmt ${psLabel} Mode`,
|
||||
func: `msg.topic = 'setMode';\nmsg.payload = msg.payload;\nreturn msg;`,
|
||||
outputs: 1,
|
||||
x: 320, y: 100 + pageOrder * 300,
|
||||
wires: [[`${prefix}_link_cmd_out`]]
|
||||
});
|
||||
|
||||
// === Manual Flow slider ===
|
||||
nodes.push({
|
||||
id: `${prefix}_flow`,
|
||||
type: "ui-slider",
|
||||
z: "demo_tab_dashboard",
|
||||
group: `${prefix}_grp_controls`,
|
||||
name: `${psLabel} Flow`,
|
||||
label: "Manual Flow (m\u00b3/h)",
|
||||
tooltip: "",
|
||||
order: 2,
|
||||
width: "6",
|
||||
height: "1",
|
||||
passthru: false,
|
||||
outs: "end",
|
||||
min: 0,
|
||||
max: maxFlow,
|
||||
step: 1,
|
||||
x: 120, y: 140 + pageOrder * 300,
|
||||
wires: [[`${prefix}_fn_flow`]]
|
||||
});
|
||||
|
||||
// Format: flow slider → q_in message
|
||||
nodes.push({
|
||||
id: `${prefix}_fn_flow`,
|
||||
type: "function",
|
||||
z: "demo_tab_dashboard",
|
||||
name: `Fmt ${psLabel} Flow`,
|
||||
func: `msg.topic = 'q_in';\nmsg.payload = { value: Number(msg.payload), unit: 'm3/h' };\nreturn msg;`,
|
||||
outputs: 1,
|
||||
x: 320, y: 140 + pageOrder * 300,
|
||||
wires: [[`${prefix}_link_cmd_out`]]
|
||||
});
|
||||
|
||||
// === Pump controls ===
|
||||
pumps.forEach((pump, pIdx) => {
|
||||
const yOff = 180 + pageOrder * 300 + pIdx * 80;
|
||||
|
||||
// Pump mode button group
|
||||
nodes.push({
|
||||
id: `${prefix}_pump_${pump.label.toLowerCase()}_mode`,
|
||||
type: "ui-button-group",
|
||||
z: "demo_tab_dashboard",
|
||||
group: `${prefix}_grp_controls`,
|
||||
name: `${pump.label} Mode`,
|
||||
label: `${pump.label} Mode`,
|
||||
tooltip: "",
|
||||
order: 3 + pIdx * 2,
|
||||
width: "3",
|
||||
height: "1",
|
||||
passthru: false,
|
||||
options: [
|
||||
{ label: "Auto", value: "auto", valueType: "str" },
|
||||
{ label: "Virtual", value: "virtualControl", valueType: "str" },
|
||||
{ label: "Physical", value: "fysicalControl", valueType: "str" }
|
||||
],
|
||||
x: 120, y: yOff,
|
||||
wires: [[`${prefix}_fn_pump_${pump.label.toLowerCase()}_mode`]]
|
||||
});
|
||||
|
||||
// Format: pump mode
|
||||
nodes.push({
|
||||
id: `${prefix}_fn_pump_${pump.label.toLowerCase()}_mode`,
|
||||
type: "function",
|
||||
z: "demo_tab_dashboard",
|
||||
name: `Fmt ${pump.label} Mode`,
|
||||
func: `msg.topic = 'setMode';\nmsg.payload = msg.payload;\nmsg._targetNode = '${pump.id}';\nreturn msg;`,
|
||||
outputs: 1,
|
||||
x: 320, y: yOff,
|
||||
wires: [[`${prefix}_link_pump_${pump.label.toLowerCase()}_out`]]
|
||||
});
|
||||
|
||||
// Pump speed slider
|
||||
nodes.push({
|
||||
id: `${prefix}_pump_${pump.label.toLowerCase()}_speed`,
|
||||
type: "ui-slider",
|
||||
z: "demo_tab_dashboard",
|
||||
group: `${prefix}_grp_controls`,
|
||||
name: `${pump.label} Speed`,
|
||||
label: `${pump.label} Speed (%)`,
|
||||
tooltip: "",
|
||||
order: 4 + pIdx * 2,
|
||||
width: "3",
|
||||
height: "1",
|
||||
passthru: false,
|
||||
outs: "end",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
x: 120, y: yOff + 40,
|
||||
wires: [[`${prefix}_fn_pump_${pump.label.toLowerCase()}_speed`]]
|
||||
});
|
||||
|
||||
// Format: pump speed → execMovement
|
||||
nodes.push({
|
||||
id: `${prefix}_fn_pump_${pump.label.toLowerCase()}_speed`,
|
||||
type: "function",
|
||||
z: "demo_tab_dashboard",
|
||||
name: `Fmt ${pump.label} Speed`,
|
||||
func: `msg.topic = 'execMovement';\nmsg.payload = { source: 'dashboard', action: 'setpoint', setpoint: Number(msg.payload) };\nmsg._targetNode = '${pump.id}';\nreturn msg;`,
|
||||
outputs: 1,
|
||||
x: 320, y: yOff + 40,
|
||||
wires: [[`${prefix}_link_pump_${pump.label.toLowerCase()}_out`]]
|
||||
});
|
||||
|
||||
// Link-out for pump commands (dashboard → PS tab)
|
||||
nodes.push({
|
||||
id: `${prefix}_link_pump_${pump.label.toLowerCase()}_out`,
|
||||
type: "link out",
|
||||
z: "demo_tab_dashboard",
|
||||
name: `→ ${pump.label} Cmd`,
|
||||
mode: "link",
|
||||
links: [`${prefix}_link_pump_${pump.label.toLowerCase()}_in`],
|
||||
x: 520, y: yOff + 20
|
||||
});
|
||||
|
||||
// Link-in on PS tab
|
||||
nodes.push({
|
||||
id: `${prefix}_link_pump_${pump.label.toLowerCase()}_in`,
|
||||
type: "link in",
|
||||
z: tabId,
|
||||
name: `← ${pump.label} Cmd`,
|
||||
links: [`${prefix}_link_pump_${pump.label.toLowerCase()}_out`],
|
||||
x: 120, y: 540 + pIdx * 60,
|
||||
wires: [[pump.id]]
|
||||
});
|
||||
});
|
||||
|
||||
// === PS command link-out (dashboard → PS tab) ===
|
||||
nodes.push({
|
||||
id: `${prefix}_link_cmd_out`,
|
||||
type: "link out",
|
||||
z: "demo_tab_dashboard",
|
||||
name: `→ ${psLabel} Cmd`,
|
||||
mode: "link",
|
||||
links: [`${prefix}_link_cmd_in`],
|
||||
x: 520, y: 120 + pageOrder * 300
|
||||
});
|
||||
|
||||
// Link-in on PS tab for PS-level commands
|
||||
nodes.push({
|
||||
id: `${prefix}_link_cmd_in`,
|
||||
type: "link in",
|
||||
z: tabId,
|
||||
name: `← ${psLabel} Cmd`,
|
||||
links: [`${prefix}_link_cmd_out`],
|
||||
x: 120, y: 480,
|
||||
wires: [[psNodeId]]
|
||||
});
|
||||
|
||||
// === Monitoring widgets on detail page ===
|
||||
// Re-use existing data from the PS parse functions on dashboard tab
|
||||
// Create a link-in to receive PS data and parse for detail page
|
||||
|
||||
nodes.push({
|
||||
id: `${prefix}_link_detail_data_out`,
|
||||
type: "link out",
|
||||
z: tabId,
|
||||
name: `→ ${psLabel} Detail`,
|
||||
mode: "link",
|
||||
links: [`${prefix}_link_detail_data_in`],
|
||||
x: 1080, y: 400
|
||||
});
|
||||
|
||||
// Add to PS node wires[0]
|
||||
const psNode = byId(psNodeId);
|
||||
if (psNode && psNode.wires && psNode.wires[0]) {
|
||||
psNode.wires[0].push(`${prefix}_link_detail_data_out`);
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
id: `${prefix}_link_detail_data_in`,
|
||||
type: "link in",
|
||||
z: "demo_tab_dashboard",
|
||||
name: `← ${psLabel} Detail`,
|
||||
links: [`${prefix}_link_detail_data_out`],
|
||||
x: 75, y: 50 + pageOrder * 300,
|
||||
wires: [[`${prefix}_fn_detail_parse`]]
|
||||
});
|
||||
|
||||
// Parse function for detail monitoring
|
||||
nodes.push({
|
||||
id: `${prefix}_fn_detail_parse`,
|
||||
type: "function",
|
||||
z: "demo_tab_dashboard",
|
||||
name: `Parse ${psLabel} Detail`,
|
||||
func: `const p = msg.payload || {};
|
||||
const cache = context.get('c') || {};
|
||||
const keys = Object.keys(p);
|
||||
const pick = (prefixes) => { for (const pfx of prefixes) { const k = keys.find(k => k.startsWith(pfx)); if (k) { const v = Number(p[k]); if (Number.isFinite(v)) return v; } } return null; };
|
||||
|
||||
const level = pick(['level.predicted.atequipment','level.measured.atequipment']);
|
||||
const volume = pick(['volume.predicted.atequipment']);
|
||||
const netFlow = pick(['netFlowRate.predicted.atequipment']);
|
||||
const fillPct = pick(['volumePercent.predicted.atequipment']);
|
||||
const direction = p.direction || cache.direction || '?';
|
||||
|
||||
if (level !== null) cache.level = level;
|
||||
if (volume !== null) cache.volume = volume;
|
||||
if (netFlow !== null) cache.netFlow = netFlow;
|
||||
if (fillPct !== null) cache.fillPct = fillPct;
|
||||
cache.direction = direction;
|
||||
context.set('c', cache);
|
||||
|
||||
const now = Date.now();
|
||||
const dirArrow = cache.direction === 'filling' ? '\\u2191' : cache.direction === 'emptying' ? '\\u2193' : '\\u2014';
|
||||
const status = [
|
||||
dirArrow + ' ' + (cache.direction || ''),
|
||||
cache.netFlow !== undefined ? Math.abs(cache.netFlow).toFixed(0) + ' m\\u00b3/h' : '',
|
||||
].filter(s => s.trim()).join(' | ');
|
||||
|
||||
return [
|
||||
cache.level !== undefined ? {topic:'${psLabel} Level', payload: cache.level, timestamp: now} : null,
|
||||
cache.netFlow !== undefined ? {topic:'${psLabel} Flow', payload: cache.netFlow, timestamp: now} : null,
|
||||
{topic:'${psLabel} Status', payload: status},
|
||||
cache.fillPct !== undefined ? {payload: Number(cache.fillPct.toFixed(1))} : null,
|
||||
cache.level !== undefined ? {payload: Number(cache.level.toFixed(2))} : null
|
||||
];`,
|
||||
outputs: 5,
|
||||
x: 280, y: 50 + pageOrder * 300,
|
||||
wires: [
|
||||
[`${prefix}_chart_level`],
|
||||
[`${prefix}_chart_flow`],
|
||||
[`${prefix}_text_status`],
|
||||
[`${prefix}_gauge_fill`],
|
||||
[`${prefix}_gauge_tank`]
|
||||
]
|
||||
});
|
||||
|
||||
// Level chart
|
||||
nodes.push({
|
||||
id: `${prefix}_chart_level`,
|
||||
type: "ui-chart",
|
||||
z: "demo_tab_dashboard",
|
||||
group: `${prefix}_grp_charts`,
|
||||
name: `${psLabel} Level`,
|
||||
label: "Basin Level (m)",
|
||||
order: 1,
|
||||
width: "6",
|
||||
height: "5",
|
||||
chartType: "line",
|
||||
category: "topic",
|
||||
categoryType: "msg",
|
||||
xAxisType: "time",
|
||||
yAxisLabel: "m",
|
||||
removeOlder: "10",
|
||||
removeOlderUnit: "60",
|
||||
action: "append",
|
||||
pointShape: "false",
|
||||
pointRadius: 0,
|
||||
interpolation: "linear",
|
||||
showLegend: true,
|
||||
xAxisProperty: "",
|
||||
xAxisPropertyType: "timestamp",
|
||||
yAxisProperty: "payload",
|
||||
yAxisPropertyType: "msg",
|
||||
colors: ["#0094ce", "#FF7F0E", "#2CA02C"],
|
||||
textColor: ["#aaaaaa"],
|
||||
textColorDefault: false,
|
||||
gridColor: ["#333333"],
|
||||
gridColorDefault: false,
|
||||
x: 510, y: 30 + pageOrder * 300,
|
||||
wires: []
|
||||
});
|
||||
|
||||
// Flow chart
|
||||
nodes.push({
|
||||
id: `${prefix}_chart_flow`,
|
||||
type: "ui-chart",
|
||||
z: "demo_tab_dashboard",
|
||||
group: `${prefix}_grp_charts`,
|
||||
name: `${psLabel} Flow`,
|
||||
label: "Net Flow (m\u00b3/h)",
|
||||
order: 2,
|
||||
width: "6",
|
||||
height: "5",
|
||||
chartType: "line",
|
||||
category: "topic",
|
||||
categoryType: "msg",
|
||||
xAxisType: "time",
|
||||
yAxisLabel: "m\u00b3/h",
|
||||
removeOlder: "10",
|
||||
removeOlderUnit: "60",
|
||||
action: "append",
|
||||
pointShape: "false",
|
||||
pointRadius: 0,
|
||||
interpolation: "linear",
|
||||
showLegend: true,
|
||||
xAxisProperty: "",
|
||||
xAxisPropertyType: "timestamp",
|
||||
yAxisProperty: "payload",
|
||||
yAxisPropertyType: "msg",
|
||||
colors: ["#4fc3f7", "#FF7F0E", "#2CA02C"],
|
||||
textColor: ["#aaaaaa"],
|
||||
textColorDefault: false,
|
||||
gridColor: ["#333333"],
|
||||
gridColorDefault: false,
|
||||
x: 510, y: 60 + pageOrder * 300,
|
||||
wires: []
|
||||
});
|
||||
|
||||
// Status text
|
||||
nodes.push({
|
||||
id: `${prefix}_text_status`,
|
||||
type: "ui-text",
|
||||
z: "demo_tab_dashboard",
|
||||
group: `${prefix}_grp_monitoring`,
|
||||
name: `${psLabel} Status`,
|
||||
label: "Status",
|
||||
order: 1,
|
||||
width: "6",
|
||||
height: "1",
|
||||
format: "{{msg.payload}}",
|
||||
layout: "row-spread",
|
||||
x: 510, y: 80 + pageOrder * 300,
|
||||
wires: []
|
||||
});
|
||||
|
||||
// Fill % gauge
|
||||
nodes.push({
|
||||
id: `${prefix}_gauge_fill`,
|
||||
type: "ui-gauge",
|
||||
z: "demo_tab_dashboard",
|
||||
group: `${prefix}_grp_monitoring`,
|
||||
name: `${psLabel} Fill`,
|
||||
gtype: "gauge-34",
|
||||
gstyle: "Rounded",
|
||||
title: "Fill",
|
||||
units: "%",
|
||||
prefix: "",
|
||||
suffix: "%",
|
||||
min: 0,
|
||||
max: 100,
|
||||
segments: [
|
||||
{ color: "#f44336", from: 0 },
|
||||
{ color: "#ff9800", from: 10 },
|
||||
{ color: "#4caf50", from: 25 },
|
||||
{ color: "#ff9800", from: 75 },
|
||||
{ color: "#f44336", from: 90 }
|
||||
],
|
||||
width: 3,
|
||||
height: 3,
|
||||
order: 2,
|
||||
className: "",
|
||||
x: 700, y: 80 + pageOrder * 300,
|
||||
wires: []
|
||||
});
|
||||
|
||||
// Tank gauge
|
||||
nodes.push({
|
||||
id: `${prefix}_gauge_tank`,
|
||||
type: "ui-gauge",
|
||||
z: "demo_tab_dashboard",
|
||||
group: `${prefix}_grp_monitoring`,
|
||||
name: `${psLabel} Tank`,
|
||||
gtype: "gauge-tank",
|
||||
gstyle: "Rounded",
|
||||
title: "Level",
|
||||
units: "m",
|
||||
prefix: "",
|
||||
suffix: "m",
|
||||
min: 0,
|
||||
max: basinHeight,
|
||||
segments: [
|
||||
{ color: "#f44336", from: 0 },
|
||||
{ color: "#ff9800", from: basinHeight * 0.08 },
|
||||
{ color: "#2196f3", from: basinHeight * 0.25 },
|
||||
{ color: "#ff9800", from: basinHeight * 0.62 },
|
||||
{ color: "#f44336", from: basinHeight * 0.8 }
|
||||
],
|
||||
width: 3,
|
||||
height: 4,
|
||||
order: 3,
|
||||
className: "",
|
||||
x: 700, y: 40 + pageOrder * 300,
|
||||
wires: []
|
||||
});
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// Create detail pages for each PS
|
||||
// =============================================
|
||||
|
||||
const westNodes = createPSDetailPage({
|
||||
psKey: 'west',
|
||||
psLabel: 'PS West',
|
||||
pagePath: '/ps-west',
|
||||
pageOrder: 2,
|
||||
psNodeId: 'demo_ps_west',
|
||||
pumps: [
|
||||
{ id: 'demo_pump_w1', label: 'W1' },
|
||||
{ id: 'demo_pump_w2', label: 'W2' }
|
||||
],
|
||||
controlModes: ['levelbased', 'flowbased', 'manual'],
|
||||
defaultMode: 'levelbased',
|
||||
maxFlow: 300,
|
||||
basinHeight: 4,
|
||||
tabId: 'demo_tab_ps_west',
|
||||
});
|
||||
|
||||
const northNodes = createPSDetailPage({
|
||||
psKey: 'north',
|
||||
psLabel: 'PS North',
|
||||
pagePath: '/ps-north',
|
||||
pageOrder: 3,
|
||||
psNodeId: 'demo_ps_north',
|
||||
pumps: [
|
||||
{ id: 'demo_pump_n1', label: 'N1' }
|
||||
],
|
||||
controlModes: ['levelbased', 'flowbased', 'manual'],
|
||||
defaultMode: 'flowbased',
|
||||
maxFlow: 200,
|
||||
basinHeight: 3,
|
||||
tabId: 'demo_tab_ps_north',
|
||||
});
|
||||
|
||||
const southNodes = createPSDetailPage({
|
||||
psKey: 'south',
|
||||
psLabel: 'PS South',
|
||||
pagePath: '/ps-south',
|
||||
pageOrder: 4,
|
||||
psNodeId: 'demo_ps_south',
|
||||
pumps: [
|
||||
{ id: 'demo_pump_s1', label: 'S1' }
|
||||
],
|
||||
controlModes: ['levelbased', 'flowbased', 'manual'],
|
||||
defaultMode: 'manual',
|
||||
maxFlow: 100,
|
||||
basinHeight: 2.5,
|
||||
tabId: 'demo_tab_ps_south',
|
||||
});
|
||||
|
||||
flow.push(...westNodes, ...northNodes, ...southNodes);
|
||||
|
||||
// =============================================
|
||||
// Validate
|
||||
// =============================================
|
||||
const allIds = new Set(flow.map(n => n.id));
|
||||
let issues = 0;
|
||||
// Check for duplicate IDs
|
||||
const idCounts = {};
|
||||
flow.forEach(n => { idCounts[n.id] = (idCounts[n.id] || 0) + 1; });
|
||||
for (const [id, count] of Object.entries(idCounts)) {
|
||||
if (count > 1) { console.warn(`DUPLICATE ID: ${id} (${count} instances)`); issues++; }
|
||||
}
|
||||
|
||||
for (const n of flow) {
|
||||
if (!n.wires) continue;
|
||||
for (const port of n.wires) {
|
||||
for (const target of port) {
|
||||
if (!allIds.has(target)) {
|
||||
console.warn(`BROKEN WIRE: ${n.id} → ${target}`);
|
||||
issues++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (n.type === 'link out' && n.links) {
|
||||
for (const lt of n.links) {
|
||||
if (!allIds.has(lt)) { console.warn(`BROKEN LINK OUT: ${n.id} → ${lt}`); issues++; }
|
||||
}
|
||||
}
|
||||
if (n.type === 'link in' && n.links) {
|
||||
for (const ls of n.links) {
|
||||
if (!allIds.has(ls)) { console.warn(`BROKEN LINK IN: ${n.id} ← ${ls}`); issues++; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (issues === 0) console.log('All references valid ✓');
|
||||
else console.log(`Found ${issues} issues`);
|
||||
|
||||
// Count nodes per tab
|
||||
const tabCounts = {};
|
||||
for (const n of flow) {
|
||||
if (n.z) tabCounts[n.z] = (tabCounts[n.z] || 0) + 1;
|
||||
}
|
||||
console.log('Nodes per tab:', JSON.stringify(tabCounts, null, 2));
|
||||
console.log('Total nodes:', flow.length);
|
||||
|
||||
// Count new nodes added
|
||||
const newNodeCount = westNodes.length + northNodes.length + southNodes.length;
|
||||
console.log(`Added ${newNodeCount} new nodes (${westNodes.length} west + ${northNodes.length} north + ${southNodes.length} south)`);
|
||||
|
||||
// Write
|
||||
fs.writeFileSync(FLOW_PATH, JSON.stringify(flow, null, 2) + '\n');
|
||||
console.log(`Wrote ${FLOW_PATH}`);
|
||||
Reference in New Issue
Block a user