#!/usr/bin/env node /** * Step 1: Tab Restructure + Per-tab link-outs * - Creates 4 new tabs (PS West, PS North, PS South, Treatment) * - Renames WWTP tab to "Telemetry / InfluxDB" * - Moves nodes to their new tabs * - Creates per-tab link-out nodes for influx + process * - Rewires nodes to use local link-outs * - Recalculates coordinates for clean layout */ 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); // ============================================= // 1a. Create 4 new tabs // ============================================= flow.push( { id: "demo_tab_ps_west", type: "tab", label: "PS West", disabled: false, info: "Pumping Station West (Urban Catchment - 2 pumps, Level-based)" }, { id: "demo_tab_ps_north", type: "tab", label: "PS North", disabled: false, info: "Pumping Station North (Industrial - 1 pump, Flow-based)" }, { id: "demo_tab_ps_south", type: "tab", label: "PS South", disabled: false, info: "Pumping Station South (Residential - 1 pump, Manual)" }, { id: "demo_tab_treatment", type: "tab", label: "Biological Treatment", disabled: false, info: "Merge point, Reactor, Settler, Effluent Measurements" } ); // ============================================= // 1b. Rename existing WWTP tab // ============================================= const wwtpTab = byId("demo_tab_wwtp"); wwtpTab.label = "Telemetry / InfluxDB"; wwtpTab.info = "InfluxDB write chain, process debug, Grafana dashboard API, shared infrastructure"; // ============================================= // 1c. Move nodes to new tabs // ============================================= const moveMap = { // PS West tab "demo_comment_ps": "demo_tab_ps_west", "demo_ps_west": "demo_tab_ps_west", "demo_pump_w1": "demo_tab_ps_west", "demo_pump_w2": "demo_tab_ps_west", "demo_mgc_west": "demo_tab_ps_west", "demo_inj_west_mode": "demo_tab_ps_west", "demo_inj_west_flow": "demo_tab_ps_west", "demo_fn_west_flow_sim": "demo_tab_ps_west", "demo_inj_w1_mode": "demo_tab_ps_west", "demo_inj_w2_mode": "demo_tab_ps_west", "demo_inj_calib_west": "demo_tab_ps_west", "demo_fn_level_to_pressure_w": "demo_tab_ps_west", "demo_meas_pt_w_up": "demo_tab_ps_west", "demo_meas_pt_w_down": "demo_tab_ps_west", "demo_mon_west": "demo_tab_ps_west", "demo_link_ps_west_dash": "demo_tab_ps_west", // PS North tab "demo_comment_ps_north": "demo_tab_ps_north", "demo_ps_north": "demo_tab_ps_north", "demo_pump_n1": "demo_tab_ps_north", "demo_inj_north_mode": "demo_tab_ps_north", "demo_inj_north_flow": "demo_tab_ps_north", "demo_fn_north_flow_sim": "demo_tab_ps_north", "demo_inj_n1_mode": "demo_tab_ps_north", "demo_inj_calib_north": "demo_tab_ps_north", "demo_comment_north_outflow": "demo_tab_ps_north", "demo_meas_ft_n1": "demo_tab_ps_north", "demo_fn_level_to_pressure_n": "demo_tab_ps_north", "demo_meas_pt_n_up": "demo_tab_ps_north", "demo_meas_pt_n_down": "demo_tab_ps_north", "demo_mon_north": "demo_tab_ps_north", "demo_link_ps_north_dash": "demo_tab_ps_north", // PS South tab "demo_comment_ps_south": "demo_tab_ps_south", "demo_ps_south": "demo_tab_ps_south", "demo_pump_s1": "demo_tab_ps_south", "demo_inj_south_mode": "demo_tab_ps_south", "demo_inj_south_flow": "demo_tab_ps_south", "demo_fn_south_flow_sim": "demo_tab_ps_south", "demo_inj_s1_mode": "demo_tab_ps_south", "demo_inj_calib_south": "demo_tab_ps_south", "demo_fn_level_to_pressure_s": "demo_tab_ps_south", "demo_meas_pt_s_up": "demo_tab_ps_south", "demo_meas_pt_s_down": "demo_tab_ps_south", "demo_mon_south": "demo_tab_ps_south", "demo_link_ps_south_dash": "demo_tab_ps_south", // Treatment tab "demo_comment_treatment": "demo_tab_treatment", "demo_meas_flow": "demo_tab_treatment", "demo_meas_do": "demo_tab_treatment", "demo_meas_nh4": "demo_tab_treatment", "demo_reactor": "demo_tab_treatment", "demo_inj_reactor_tick": "demo_tab_treatment", "demo_settler": "demo_tab_treatment", "demo_monster": "demo_tab_treatment", "demo_inj_monster_flow": "demo_tab_treatment", "demo_fn_monster_flow": "demo_tab_treatment", "demo_comment_effluent_meas": "demo_tab_treatment", "demo_meas_eff_flow": "demo_tab_treatment", "demo_meas_eff_do": "demo_tab_treatment", "demo_meas_eff_nh4": "demo_tab_treatment", "demo_meas_eff_no3": "demo_tab_treatment", "demo_meas_eff_tss": "demo_tab_treatment", "demo_comment_pressure": "demo_tab_treatment", "demo_link_reactor_dash": "demo_tab_treatment", "demo_link_meas_dash": "demo_tab_treatment", "demo_link_eff_meas_dash": "demo_tab_treatment" }; for (const [nodeId, tabId] of Object.entries(moveMap)) { const node = byId(nodeId); if (node) { node.z = tabId; } else { console.warn(`WARNING: Node ${nodeId} not found for move`); } } // ============================================= // 1c-coords. Recalculate coordinates per tab // ============================================= // PS West layout (2 pumps + MGC) const psWestCoords = { "demo_comment_ps": { x: 340, y: 40 }, "demo_inj_calib_west": { x: 120, y: 80 }, "demo_inj_w1_mode": { x: 120, y: 120 }, "demo_inj_west_mode": { x: 120, y: 200 }, "demo_inj_west_flow": { x: 120, y: 240 }, "demo_inj_w2_mode": { x: 120, y: 320 }, "demo_fn_west_flow_sim": { x: 360, y: 240 }, "demo_pump_w1": { x: 600, y: 120 }, "demo_pump_w2": { x: 600, y: 320 }, "demo_mgc_west": { x: 600, y: 220 }, "demo_ps_west": { x: 860, y: 220 }, "demo_fn_level_to_pressure_w": { x: 360, y: 420 }, "demo_meas_pt_w_up": { x: 560, y: 420 }, "demo_meas_pt_w_down": { x: 560, y: 480 }, "demo_mon_west": { x: 1080, y: 160 }, "demo_link_ps_west_dash": { x: 1080, y: 220 }, }; // PS North layout (1 pump, no MGC) const psNorthCoords = { "demo_comment_ps_north": { x: 340, y: 40 }, "demo_inj_calib_north": { x: 120, y: 80 }, "demo_inj_n1_mode": { x: 120, y: 120 }, "demo_inj_north_mode": { x: 120, y: 200 }, "demo_inj_north_flow": { x: 120, y: 240 }, "demo_fn_north_flow_sim": { x: 360, y: 240 }, "demo_pump_n1": { x: 600, y: 120 }, "demo_ps_north": { x: 860, y: 200 }, "demo_comment_north_outflow":{ x: 200, y: 320 }, "demo_meas_ft_n1": { x: 560, y: 340 }, "demo_fn_level_to_pressure_n":{ x: 360, y: 420 }, "demo_meas_pt_n_up": { x: 560, y: 420 }, "demo_meas_pt_n_down": { x: 560, y: 480 }, "demo_mon_north": { x: 1080, y: 140 }, "demo_link_ps_north_dash": { x: 1080, y: 200 }, }; // PS South layout (1 pump, no MGC) const psSouthCoords = { "demo_comment_ps_south": { x: 340, y: 40 }, "demo_inj_calib_south": { x: 120, y: 80 }, "demo_inj_s1_mode": { x: 120, y: 120 }, "demo_inj_south_mode": { x: 120, y: 200 }, "demo_inj_south_flow": { x: 120, y: 240 }, "demo_fn_south_flow_sim": { x: 360, y: 240 }, "demo_pump_s1": { x: 600, y: 120 }, "demo_ps_south": { x: 860, y: 200 }, "demo_fn_level_to_pressure_s":{ x: 360, y: 380 }, "demo_meas_pt_s_up": { x: 560, y: 380 }, "demo_meas_pt_s_down": { x: 560, y: 440 }, "demo_mon_south": { x: 1080, y: 140 }, "demo_link_ps_south_dash": { x: 1080, y: 200 }, }; // Treatment layout const treatmentCoords = { "demo_comment_treatment": { x: 200, y: 40 }, "demo_meas_flow": { x: 400, y: 120 }, "demo_meas_do": { x: 400, y: 180 }, "demo_meas_nh4": { x: 400, y: 240 }, "demo_inj_reactor_tick": { x: 600, y: 80 }, "demo_reactor": { x: 800, y: 180 }, "demo_settler": { x: 800, y: 320 }, "demo_monster": { x: 800, y: 420 }, "demo_inj_monster_flow": { x: 560, y: 420 }, "demo_fn_monster_flow": { x: 660, y: 460 }, "demo_comment_effluent_meas":{ x: 200, y: 520 }, "demo_meas_eff_flow": { x: 400, y: 560 }, "demo_meas_eff_do": { x: 400, y: 620 }, "demo_meas_eff_nh4": { x: 400, y: 680 }, "demo_meas_eff_no3": { x: 400, y: 740 }, "demo_meas_eff_tss": { x: 400, y: 800 }, "demo_comment_pressure": { x: 200, y: 860 }, "demo_link_reactor_dash": { x: 1020, y: 180 }, "demo_link_meas_dash": { x: 620, y: 180 }, "demo_link_eff_meas_dash": { x: 620, y: 620 }, }; // Apply coordinates for (const [nodeId, coords] of Object.entries({...psWestCoords, ...psNorthCoords, ...psSouthCoords, ...treatmentCoords})) { const node = byId(nodeId); if (node) { node.x = coords.x; node.y = coords.y; } } // ============================================= // 1d. Create per-tab link-out nodes // ============================================= // Determine which tab each moved node belongs to const tabForNode = {}; for (const n of flow) { if (n.z) tabForNode[n.id] = n.z; } // Map from tab → influx link-out ID const influxLinkOutMap = { "demo_tab_ps_west": "demo_link_influx_out_west", "demo_tab_ps_north": "demo_link_influx_out_north", "demo_tab_ps_south": "demo_link_influx_out_south", "demo_tab_treatment": "demo_link_influx_out_treatment", }; // Map from tab → process link-out ID const processLinkOutMap = { "demo_tab_ps_west": "demo_link_process_out_west", "demo_tab_ps_north": "demo_link_process_out_north", "demo_tab_ps_south": "demo_link_process_out_south", "demo_tab_treatment": "demo_link_process_out_treatment", }; // Link-out node positions per tab const linkOutPositions = { "demo_tab_ps_west": { influx: { x: 1080, y: 280 }, process: { x: 1080, y: 320 } }, "demo_tab_ps_north": { influx: { x: 1080, y: 260 }, process: { x: 1080, y: 300 } }, "demo_tab_ps_south": { influx: { x: 1080, y: 260 }, process: { x: 1080, y: 300 } }, "demo_tab_treatment": { influx: { x: 1020, y: 280 }, process: { x: 1020, y: 320 } }, }; // Create influx link-out nodes for (const [tabId, nodeId] of Object.entries(influxLinkOutMap)) { const pos = linkOutPositions[tabId].influx; flow.push({ id: nodeId, type: "link out", z: tabId, name: "→ InfluxDB", mode: "link", links: ["demo_link_influx_in"], x: pos.x, y: pos.y }); } // Create process link-out nodes for (const [tabId, nodeId] of Object.entries(processLinkOutMap)) { const pos = linkOutPositions[tabId].process; flow.push({ id: nodeId, type: "link out", z: tabId, name: "→ Process debug", mode: "link", links: ["demo_link_process_in"], x: pos.x, y: pos.y }); } // ============================================= // 1d-rewire. Rewire nodes to use local link-outs // ============================================= // For every node that references "demo_link_influx_out" or "demo_link_process_out" // in its wires, replace with the per-tab version for (const node of flow) { if (!node.wires || !node.z) continue; const tab = node.z; const localInflux = influxLinkOutMap[tab]; const localProcess = processLinkOutMap[tab]; for (let portIdx = 0; portIdx < node.wires.length; portIdx++) { for (let wireIdx = 0; wireIdx < node.wires[portIdx].length; wireIdx++) { if (node.wires[portIdx][wireIdx] === "demo_link_influx_out" && localInflux) { node.wires[portIdx][wireIdx] = localInflux; } if (node.wires[portIdx][wireIdx] === "demo_link_process_out" && localProcess) { node.wires[portIdx][wireIdx] = localProcess; } } } } // Update the link-in nodes to reference all new link-out IDs const influxIn = byId("demo_link_influx_in"); influxIn.links = Object.values(influxLinkOutMap); // Also keep the old one if any nodes on the telemetry tab still reference it // (the dashapi, telemetry nodes that stayed on demo_tab_wwtp) influxIn.links.push("demo_link_influx_out"); const processIn = byId("demo_link_process_in"); processIn.links = Object.values(processLinkOutMap); processIn.links.push("demo_link_process_out"); // Keep old link-out nodes on telemetry tab (they may still be needed // by nodes that remain there, like dashapi) // Update their links arrays too const oldInfluxOut = byId("demo_link_influx_out"); if (oldInfluxOut) { oldInfluxOut.links = ["demo_link_influx_in"]; // Move to bottom of telemetry tab oldInfluxOut.x = 1135; oldInfluxOut.y = 500; } const oldProcessOut = byId("demo_link_process_out"); if (oldProcessOut) { oldProcessOut.links = ["demo_link_process_in"]; oldProcessOut.x = 1135; oldProcessOut.y = 540; } // ============================================= // Validate // ============================================= 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); // Check for broken wire references const allIds = new Set(flow.map(n => n.id)); let brokenWires = 0; 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}`); brokenWires++; } } } } if (brokenWires === 0) console.log('All wire references valid ✓'); // Check link-in/link-out pairing for (const n of flow) { if (n.type === 'link out' && n.links) { for (const linkTarget of n.links) { if (!allIds.has(linkTarget)) { console.warn(`BROKEN LINK: ${n.id} links to missing ${linkTarget}`); } } } if (n.type === 'link in' && n.links) { for (const linkSource of n.links) { if (!allIds.has(linkSource)) { console.warn(`BROKEN LINK: ${n.id} expects link from missing ${linkSource}`); } } } } // Write fs.writeFileSync(FLOW_PATH, JSON.stringify(flow, null, 2) + '\n'); console.log(`\nWrote ${FLOW_PATH} (${flow.length} nodes)`);