- 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>
614 lines
18 KiB
JavaScript
614 lines
18 KiB
JavaScript
#!/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}`);
|