Files
EVOLV/scripts/transform-flow-step3.js
znetsixe 6a6c04d34b 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>
2026-03-04 21:07:04 +01:00

584 lines
18 KiB
JavaScript

#!/usr/bin/env node
/**
* Step 3: Overview Dashboard Page + KPI Gauges
* - Creates overview page with chain visualization
* - Adds KPI gauges (Total Flow, DO, TSS, NH4)
* - Link-in nodes to feed overview from merge + reactor + effluent data
* - Reorders all page navigation
*/
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);
// =============================================
// 3a. New config nodes
// =============================================
// Overview page
flow.push({
id: "demo_ui_page_overview",
type: "ui-page",
name: "Plant Overview",
ui: "demo_ui_base",
path: "/overview",
icon: "dashboard",
layout: "grid",
theme: "demo_ui_theme",
breakpoints: [{ name: "Default", px: "0", cols: "12" }],
order: 0,
className: ""
});
// Overview groups
flow.push(
{
id: "demo_ui_grp_overview_chain",
type: "ui-group",
name: "Process Chain",
page: "demo_ui_page_overview",
width: "12",
height: "1",
order: 1,
showTitle: true,
className: ""
},
{
id: "demo_ui_grp_overview_kpi",
type: "ui-group",
name: "Key Indicators",
page: "demo_ui_page_overview",
width: "12",
height: "1",
order: 2,
showTitle: true,
className: ""
}
);
// =============================================
// 3b. Chain visualization - link-in nodes on dashboard tab
// =============================================
// Link-in for merge data (this is what step 2's demo_link_merge_dash links to)
flow.push({
id: "demo_link_merge_dash_in",
type: "link in",
z: "demo_tab_dashboard",
name: "← Merge Data",
links: ["demo_link_merge_dash"],
x: 75, y: 960,
wires: [["demo_fn_overview_parse"]]
});
// We also need reactor and effluent data for the overview.
// Create link-out nodes on treatment tab for overview data
flow.push(
{
id: "demo_link_overview_reactor_out",
type: "link out",
z: "demo_tab_treatment",
name: "→ Overview (Reactor)",
mode: "link",
links: ["demo_link_overview_reactor_in"],
x: 1020, y: 220
},
{
id: "demo_link_overview_reactor_in",
type: "link in",
z: "demo_tab_dashboard",
name: "← Reactor (Overview)",
links: ["demo_link_overview_reactor_out"],
x: 75, y: 1020,
wires: [["demo_fn_overview_reactor_parse"]]
}
);
// Add overview reactor link-out to reactor's wires[0]
const reactor = byId("demo_reactor");
reactor.wires[0].push("demo_link_overview_reactor_out");
// Effluent measurements link for overview KPIs
flow.push(
{
id: "demo_link_overview_eff_out",
type: "link out",
z: "demo_tab_treatment",
name: "→ Overview (Effluent)",
mode: "link",
links: ["demo_link_overview_eff_in"],
x: 620, y: 660
},
{
id: "demo_link_overview_eff_in",
type: "link in",
z: "demo_tab_dashboard",
name: "← Effluent (Overview)",
links: ["demo_link_overview_eff_out"],
x: 75, y: 1080,
wires: [["demo_fn_overview_eff_parse"]]
}
);
// Add overview eff link-out to effluent measurement nodes wires[0]
// TSS and NH4 are the key effluent quality indicators
const effTss = byId("demo_meas_eff_tss");
effTss.wires[0].push("demo_link_overview_eff_out");
const effNh4 = byId("demo_meas_eff_nh4");
effNh4.wires[0].push("demo_link_overview_eff_out");
// =============================================
// 3b. Parse functions for overview
// =============================================
// Parse merge data for chain visualization + total flow gauge
flow.push({
id: "demo_fn_overview_parse",
type: "function",
z: "demo_tab_dashboard",
name: "Parse Overview (Merge)",
func: `const p = msg.payload || {};
const now = Date.now();
// Store in flow context for the template
flow.set('overview_merge', p);
// Output 1: chain vis data, Output 2: total flow gauge
return [
{ topic: 'overview_chain', payload: p },
p.totalInfluentFlow !== undefined ? { topic: 'Total Influent Flow', payload: p.totalInfluentFlow } : null
];`,
outputs: 2,
x: 280, y: 960,
wires: [
["demo_overview_template"],
["demo_gauge_overview_flow"]
]
});
// Parse reactor data for overview
flow.push({
id: "demo_fn_overview_reactor_parse",
type: "function",
z: "demo_tab_dashboard",
name: "Parse Overview (Reactor)",
func: `const p = msg.payload || {};
if (!p.C || !Array.isArray(p.C)) return null;
flow.set('overview_reactor', p);
// Output: DO gauge value
return { topic: 'Reactor DO', payload: Math.round(p.C[0]*100)/100 };`,
outputs: 1,
x: 280, y: 1020,
wires: [["demo_gauge_overview_do"]]
});
// Parse effluent data for overview KPIs
flow.push({
id: "demo_fn_overview_eff_parse",
type: "function",
z: "demo_tab_dashboard",
name: "Parse Overview (Effluent)",
func: `const p = msg.payload || {};
const topic = msg.topic || '';
const val = Number(p.mAbs);
if (!Number.isFinite(val)) return null;
// Route to appropriate gauge based on measurement type
if (topic.includes('TSS') || topic.includes('tss')) {
return [{ topic: 'Effluent TSS', payload: Math.round(val*100)/100 }, null];
}
if (topic.includes('NH4') || topic.includes('ammonium')) {
return [null, { topic: 'Effluent NH4', payload: Math.round(val*100)/100 }];
}
return [null, null];`,
outputs: 2,
x: 280, y: 1080,
wires: [
["demo_gauge_overview_tss"],
["demo_gauge_overview_nh4"]
]
});
// =============================================
// 3b. Chain visualization template
// =============================================
flow.push({
id: "demo_overview_template",
type: "ui-template",
z: "demo_tab_dashboard",
group: "demo_ui_grp_overview_chain",
name: "Process Chain Diagram",
order: 1,
width: "12",
height: "6",
head: "",
format: `<template>
<div class="chain-container">
<svg viewBox="0 0 900 280" class="chain-svg">
<!-- PS West -->
<g @click="navigateTo('/ps-west')" class="chain-block clickable">
<rect x="20" y="20" width="160" height="80" rx="8" :fill="blockColor(merge?.west)"/>
<text x="100" y="50" class="block-title">PS West</text>
<text x="100" y="70" class="block-value">{{ formatPct(merge?.west?.fillPct) }}</text>
<text x="100" y="86" class="block-sub">{{ formatDir(merge?.west?.direction) }}</text>
</g>
<!-- PS North -->
<g @click="navigateTo('/ps-north')" class="chain-block clickable">
<rect x="20" y="120" width="160" height="80" rx="8" :fill="blockColor(merge?.north)"/>
<text x="100" y="150" class="block-title">PS North</text>
<text x="100" y="170" class="block-value">{{ formatPct(merge?.north?.fillPct) }}</text>
<text x="100" y="186" class="block-sub">{{ formatDir(merge?.north?.direction) }}</text>
</g>
<!-- PS South -->
<g @click="navigateTo('/ps-south')" class="chain-block clickable">
<rect x="20" y="220" width="160" height="80" rx="8" :fill="blockColor(merge?.south)"/>
<text x="100" y="250" class="block-title">PS South</text>
<text x="100" y="270" class="block-value">{{ formatPct(merge?.south?.fillPct) }}</text>
<text x="100" y="286" class="block-sub">{{ formatDir(merge?.south?.direction) }}</text>
</g>
<!-- Merge arrows -->
<line x1="180" y1="60" x2="260" y2="160" class="chain-arrow"/>
<line x1="180" y1="160" x2="260" y2="160" class="chain-arrow"/>
<line x1="180" y1="260" x2="260" y2="160" class="chain-arrow"/>
<!-- Merge point -->
<g class="chain-block">
<rect x="260" y="120" width="120" height="80" rx="8" fill="#0f3460"/>
<text x="320" y="150" class="block-title">Merge</text>
<text x="320" y="170" class="block-value">{{ formatFlow(merge?.totalInfluentFlow) }}</text>
<text x="320" y="186" class="block-sub">m\\u00b3/h total</text>
</g>
<!-- Arrow merge → reactor -->
<line x1="380" y1="160" x2="420" y2="160" class="chain-arrow"/>
<!-- Reactor -->
<g @click="navigateTo('/treatment')" class="chain-block clickable">
<rect x="420" y="120" width="140" height="80" rx="8" :fill="reactorColor"/>
<text x="490" y="150" class="block-title">Reactor</text>
<text x="490" y="170" class="block-value">DO: {{ reactorDO }}</text>
<text x="490" y="186" class="block-sub">mg/L</text>
</g>
<!-- Arrow reactor → settler -->
<line x1="560" y1="160" x2="600" y2="160" class="chain-arrow"/>
<!-- Settler -->
<g @click="navigateTo('/treatment')" class="chain-block clickable">
<rect x="600" y="120" width="120" height="80" rx="8" fill="#0f3460"/>
<text x="660" y="150" class="block-title">Settler</text>
<text x="660" y="170" class="block-value">TSS: {{ effTSS }}</text>
<text x="660" y="186" class="block-sub">mg/L</text>
</g>
<!-- Arrow settler → effluent -->
<line x1="720" y1="160" x2="760" y2="160" class="chain-arrow"/>
<!-- Effluent -->
<g class="chain-block">
<rect x="760" y="120" width="120" height="80" rx="8" :fill="effluentColor"/>
<text x="820" y="150" class="block-title">Effluent</text>
<text x="820" y="170" class="block-value">NH4: {{ effNH4 }}</text>
<text x="820" y="186" class="block-sub">mg/L</text>
</g>
</svg>
</div>
</template>
<script>
export default {
data() {
return {
merge: null,
reactorDO: '--',
effTSS: '--',
effNH4: '--'
}
},
computed: {
reactorColor() {
const d = parseFloat(this.reactorDO);
if (isNaN(d)) return '#0f3460';
if (d < 1) return '#f44336';
if (d < 2) return '#ff9800';
return '#1b5e20';
},
effluentColor() {
const n = parseFloat(this.effNH4);
if (isNaN(n)) return '#0f3460';
if (n > 10) return '#f44336';
if (n > 5) return '#ff9800';
return '#1b5e20';
}
},
watch: {
msg(val) {
if (!val) return;
const t = val.topic || '';
if (t === 'overview_chain') {
this.merge = val.payload;
} else if (t === 'Reactor DO') {
this.reactorDO = val.payload?.toFixed(1) || '--';
} else if (t === 'Effluent TSS') {
this.effTSS = val.payload?.toFixed(1) || '--';
} else if (t === 'Effluent NH4') {
this.effNH4 = val.payload?.toFixed(1) || '--';
}
}
},
methods: {
navigateTo(path) {
this.$router.push('/dashboard' + path);
},
blockColor(ps) {
if (!ps || ps.fillPct === undefined) return '#0f3460';
if (ps.fillPct > 90) return '#f44336';
if (ps.fillPct > 75) return '#ff9800';
if (ps.fillPct < 10) return '#f44336';
return '#0f3460';
},
formatPct(v) { return v !== undefined && v !== null ? v.toFixed(0) + '%' : '--'; },
formatFlow(v) { return v !== undefined && v !== null ? v.toFixed(0) : '--'; },
formatDir(d) { return d === 'filling' ? '\\u2191 filling' : d === 'emptying' ? '\\u2193 emptying' : '--'; }
}
}
</script>
<style>
.chain-container { width: 100%; overflow-x: auto; }
.chain-svg { width: 100%; height: auto; min-height: 200px; }
.chain-block text { text-anchor: middle; fill: #e0e0e0; }
.block-title { font-size: 14px; font-weight: bold; }
.block-value { font-size: 13px; fill: #4fc3f7; }
.block-sub { font-size: 10px; fill: #90a4ae; }
.chain-arrow { stroke: #4fc3f7; stroke-width: 2; marker-end: url(#arrowhead); }
.clickable { cursor: pointer; }
.clickable:hover rect { opacity: 0.8; }
</style>`,
templateScope: "local",
className: "",
x: 510, y: 960,
wires: [[]]
});
// =============================================
// 3c. KPI gauges on overview
// =============================================
// Total Influent Flow gauge
flow.push({
id: "demo_gauge_overview_flow",
type: "ui-gauge",
z: "demo_tab_dashboard",
group: "demo_ui_grp_overview_kpi",
name: "Total Influent Flow",
gtype: "gauge-34",
gstyle: "Rounded",
title: "Influent Flow",
units: "m\u00b3/h",
prefix: "",
suffix: "m\u00b3/h",
min: 0,
max: 500,
segments: [
{ color: "#2196f3", from: 0 },
{ color: "#4caf50", from: 50 },
{ color: "#ff9800", from: 350 },
{ color: "#f44336", from: 450 }
],
width: 3,
height: 4,
order: 1,
className: "",
x: 510, y: 1020,
wires: []
});
// Reactor DO gauge
flow.push({
id: "demo_gauge_overview_do",
type: "ui-gauge",
z: "demo_tab_dashboard",
group: "demo_ui_grp_overview_kpi",
name: "Reactor DO",
gtype: "gauge-34",
gstyle: "Rounded",
title: "Reactor DO",
units: "mg/L",
prefix: "",
suffix: "mg/L",
min: 0,
max: 10,
segments: [
{ color: "#f44336", from: 0 },
{ color: "#ff9800", from: 1 },
{ color: "#4caf50", from: 2 },
{ color: "#ff9800", from: 6 },
{ color: "#f44336", from: 8 }
],
width: 3,
height: 4,
order: 2,
className: "",
x: 510, y: 1060,
wires: []
});
// Effluent TSS gauge
flow.push({
id: "demo_gauge_overview_tss",
type: "ui-gauge",
z: "demo_tab_dashboard",
group: "demo_ui_grp_overview_kpi",
name: "Effluent TSS",
gtype: "gauge-34",
gstyle: "Rounded",
title: "Effluent TSS",
units: "mg/L",
prefix: "",
suffix: "mg/L",
min: 0,
max: 50,
segments: [
{ color: "#4caf50", from: 0 },
{ color: "#ff9800", from: 25 },
{ color: "#f44336", from: 40 }
],
width: 3,
height: 4,
order: 3,
className: "",
x: 510, y: 1100,
wires: []
});
// Effluent NH4 gauge
flow.push({
id: "demo_gauge_overview_nh4",
type: "ui-gauge",
z: "demo_tab_dashboard",
group: "demo_ui_grp_overview_kpi",
name: "Effluent NH4",
gtype: "gauge-34",
gstyle: "Rounded",
title: "Effluent NH4",
units: "mg/L",
prefix: "",
suffix: "mg/L",
min: 0,
max: 20,
segments: [
{ color: "#4caf50", from: 0 },
{ color: "#ff9800", from: 5 },
{ color: "#f44336", from: 10 }
],
width: 3,
height: 4,
order: 4,
className: "",
x: 510, y: 1140,
wires: []
});
// =============================================
// 3d. Reorder all page navigation
// =============================================
const pageOrders = {
"demo_ui_page_overview": 0,
"demo_ui_page_influent": 1,
"demo_ui_page_treatment": 5,
"demo_ui_page_telemetry": 6,
};
for (const [pageId, order] of Object.entries(pageOrders)) {
const page = byId(pageId);
if (page) page.order = order;
}
// =============================================
// Feed chain vis and KPIs from merge + reactor + effluent
// We need to also wire the overview_template to receive reactor/eff data
// The parse functions already wire to the template and gauges separately
// But the template needs ALL data sources - let's connect reactor and eff parsers to it too
// =============================================
// Actually, the template needs multiple inputs. Let's connect reactor and eff parse outputs too.
// Modify overview reactor parse to also send to template
const reactorParse = byId("demo_fn_overview_reactor_parse");
// Currently wires to demo_gauge_overview_do. Add template as well.
reactorParse.func = `const p = msg.payload || {};
if (!p.C || !Array.isArray(p.C)) return null;
flow.set('overview_reactor', p);
// Output 1: DO gauge, Output 2: to chain template
const doVal = Math.round(p.C[0]*100)/100;
return [
{ topic: 'Reactor DO', payload: doVal },
{ topic: 'Reactor DO', payload: doVal }
];`;
reactorParse.outputs = 2;
reactorParse.wires = [["demo_gauge_overview_do"], ["demo_overview_template"]];
// Same for effluent parse - add template output
const effParse = byId("demo_fn_overview_eff_parse");
effParse.func = `const p = msg.payload || {};
const topic = msg.topic || '';
const val = Number(p.mAbs);
if (!Number.isFinite(val)) return null;
const rounded = Math.round(val*100)/100;
// Route to appropriate gauge + template based on measurement type
if (topic.includes('TSS') || topic.includes('tss')) {
return [{ topic: 'Effluent TSS', payload: rounded }, null, { topic: 'Effluent TSS', payload: rounded }];
}
if (topic.includes('NH4') || topic.includes('ammonium')) {
return [null, { topic: 'Effluent NH4', payload: rounded }, { topic: 'Effluent NH4', payload: rounded }];
}
return [null, null, null];`;
effParse.outputs = 3;
effParse.wires = [["demo_gauge_overview_tss"], ["demo_gauge_overview_nh4"], ["demo_overview_template"]];
// =============================================
// Validate
// =============================================
const allIds = new Set(flow.map(n => n.id));
let issues = 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}`);
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 ✓');
console.log('Total nodes:', flow.length);
// Write
fs.writeFileSync(FLOW_PATH, JSON.stringify(flow, null, 2) + '\n');
console.log(`Wrote ${FLOW_PATH}`);