P2 wave 1: extract concerns from pumpingStation specificClass
Splits pumpingStation/src/ into focused concern modules. specificClass.js
will be slimmed to an orchestrator in P2.9 (integration); for now both
the inlined logic AND the new modules coexist so tests stay green
throughout.
src/basin/ BasinGeometry + thresholdValidator (pure)
src/measurement/ flowAggregator + measurementRouter + calibration
src/control/ levelBased + flowBased(stub) + manual + index dispatcher
src/safety/ safetyController split into dryRun + overfill rules
src/commands/ registry array + handlers (canonical names from start)
src/editor.js 260 lines of SVG basin-diagram redraw, was inline in .html
examples/standalone-demo.js was if(require.main===module) at bottom of specificClass.js
CONTRACT.md canonical inputs + outputs + emitted events
Modified:
src/specificClass.js removed the 170-line standalone demo block
pumpingStation.html oneditprepare/oneditsave delegate to editor.{init,save}
pumpingStation.js added admin endpoint serving src/editor.js
102 basic tests pass (60 new + 42 existing).
specificClass.js itself is unchanged in behaviour — integration is P2.9.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,8 +8,9 @@
|
||||
| **Control Module** | `#a9daee` | zwart |
|
||||
|
||||
-->
|
||||
<script src="/pumpingStation/menu.js"></script> <!-- Load the menu script for dynamic dropdowns -->
|
||||
<script src="/pumpingStation/configData.js"></script> <!-- Load the config script for node information -->
|
||||
<script src="/pumpingStation/menu.js"></script> <!-- Load the menu script for dynamic dropdowns -->
|
||||
<script src="/pumpingStation/configData.js"></script> <!-- Load the config script for node information -->
|
||||
<script src="/pumpingStation/editor.js"></script> <!-- Load the basin-diagram editor logic -->
|
||||
|
||||
<script>//test
|
||||
RED.nodes.registerType("pumpingStation", {
|
||||
@@ -86,296 +87,14 @@
|
||||
return this.positionIcon + " PumpingStation";
|
||||
},
|
||||
|
||||
oneditprepare: function() {
|
||||
const waitForMenuData = () => {
|
||||
if (window.EVOLV?.nodes?.pumpingStation?.initEditor) {
|
||||
window.EVOLV.nodes.pumpingStation.initEditor(this);
|
||||
} else {
|
||||
setTimeout(waitForMenuData, 50);
|
||||
}
|
||||
};
|
||||
// Wait for the menu data to be ready before initializing the editor
|
||||
waitForMenuData();
|
||||
|
||||
// NODE SPECIFIC
|
||||
document.getElementById("node-input-basinVolume");
|
||||
document.getElementById("node-input-basinHeight");
|
||||
document.getElementById("node-input-inflowLevel");
|
||||
document.getElementById("node-input-outflowLevel");
|
||||
document.getElementById("node-input-overflowLevel");
|
||||
document.getElementById("node-input-refHeight");
|
||||
document.getElementById("node-input-basinBottomRef");
|
||||
|
||||
const refHeightEl = document.getElementById("node-input-refHeight");
|
||||
if (refHeightEl) {
|
||||
refHeightEl.value = this.refHeight || "NAP";
|
||||
}
|
||||
|
||||
const minHeightBasedOnEl = document.getElementById("node-input-minHeightBasedOn");
|
||||
if (minHeightBasedOnEl) {
|
||||
minHeightBasedOnEl.value = this.minHeightBasedOn;
|
||||
}
|
||||
|
||||
const dryRunToggle = document.getElementById("node-input-enableDryRunProtection");
|
||||
const dryRunPercent = document.getElementById("node-input-dryRunThresholdPercent");
|
||||
const overfillToggle = document.getElementById("node-input-enableOverfillProtection");
|
||||
const overfillPercent = document.getElementById("node-input-overfillThresholdPercent");
|
||||
|
||||
const toggleInput = (toggleEl, inputEl) => {
|
||||
if (!toggleEl || !inputEl) { return; }
|
||||
inputEl.disabled = !toggleEl.checked;
|
||||
inputEl.parentElement.classList.toggle('disabled', inputEl.disabled);
|
||||
};
|
||||
|
||||
if (dryRunToggle && dryRunPercent) {
|
||||
dryRunToggle.checked = !!this.enableDryRunProtection;
|
||||
dryRunPercent.value = Number.isFinite(this.dryRunThresholdPercent) ? this.dryRunThresholdPercent : 2;
|
||||
dryRunToggle.addEventListener('change', () => toggleInput(dryRunToggle, dryRunPercent));
|
||||
toggleInput(dryRunToggle, dryRunPercent);
|
||||
}
|
||||
|
||||
if (overfillToggle && overfillPercent) {
|
||||
overfillToggle.checked = !!this.enableOverfillProtection;
|
||||
overfillPercent.value = Number.isFinite(this.overfillThresholdPercent) ? this.overfillThresholdPercent : 98;
|
||||
overfillToggle.addEventListener('change', () => toggleInput(overfillToggle, overfillPercent));
|
||||
toggleInput(overfillToggle, overfillPercent);
|
||||
}
|
||||
|
||||
const timeLeftInput = document.getElementById("node-input-timeleftToFullOrEmptyThresholdSeconds");
|
||||
if (timeLeftInput) {
|
||||
timeLeftInput.value = Number.isFinite(this.timeleftToFullOrEmptyThresholdSeconds)
|
||||
? this.timeleftToFullOrEmptyThresholdSeconds
|
||||
: 0;
|
||||
}
|
||||
|
||||
// control mode toggle UI
|
||||
const toggleModeSections = (val) => {
|
||||
document.querySelectorAll('.ps-mode-section').forEach((el) => el.style.display = 'none');
|
||||
const active = document.getElementById(`ps-mode-${val}`);
|
||||
if (active) active.style.display = '';
|
||||
};
|
||||
|
||||
const modeSelect = document.getElementById('node-input-controlMode');
|
||||
if (modeSelect) {
|
||||
modeSelect.value = this.controlMode || 'none';
|
||||
toggleModeSections(modeSelect.value);
|
||||
modeSelect.addEventListener('change', (e) => toggleModeSections(e.target.value));
|
||||
}
|
||||
|
||||
const setNumberField = (id, val) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = Number.isFinite(val) ? val : '';
|
||||
};
|
||||
|
||||
setNumberField('node-input-startLevel', this.startLevel);
|
||||
setNumberField('node-input-minLevel', this.minLevel);
|
||||
setNumberField('node-input-maxLevel', this.maxLevel);
|
||||
setNumberField('node-input-flowSetpoint', this.flowSetpoint);
|
||||
setNumberField('node-input-flowDeadband', this.flowDeadband);
|
||||
|
||||
// Interactive diagram: place every threshold line/input at its
|
||||
// proportional y on the tank, plus compute derived safety levels
|
||||
// (dryRunLevel, overfillLevel) that are shown both in the diagram
|
||||
// and next to the safety-% fields. Same formulas as
|
||||
// specificClass._validateThresholdOrdering.
|
||||
const DIAG = { topY: 40, botY: 380 };
|
||||
const fNum = (id) => {
|
||||
const v = parseFloat(document.getElementById(`node-input-${id}`)?.value);
|
||||
return Number.isFinite(v) ? v : null;
|
||||
};
|
||||
const yForLevel = (val, basinH) => {
|
||||
if (val == null || !basinH) return null;
|
||||
const y = DIAG.botY - (val / basinH) * (DIAG.botY - DIAG.topY);
|
||||
return Math.max(DIAG.topY - 8, Math.min(DIAG.botY + 8, y));
|
||||
};
|
||||
// Place a row — line, label, input, unit all share the same y.
|
||||
// The diagram is a schematic ordered list (value order is
|
||||
// preserved, but the y-positions are distributed with a
|
||||
// guaranteed minimum gap for readability), not a strictly
|
||||
// proportional rendering.
|
||||
const placeItem = (id, y) => {
|
||||
const line = document.getElementById(`ps-line-${id}`);
|
||||
const label = document.getElementById(`ps-label-${id}`);
|
||||
const unit = document.getElementById(`ps-unit-${id}`);
|
||||
const fo = document.getElementById(`ps-fo-${id}`);
|
||||
const sub = document.getElementById(`ps-sub-${id}`);
|
||||
const lead = document.getElementById(`ps-leader-${id}`);
|
||||
if (line) { line.setAttribute('y1', y); line.setAttribute('y2', y); }
|
||||
if (label) label.setAttribute('y', y + 4);
|
||||
if (unit) unit.setAttribute('y', y + 4);
|
||||
if (fo) fo.setAttribute('y', y - 11);
|
||||
if (sub) sub.setAttribute('y', y + 15);
|
||||
if (lead) lead.setAttribute('visibility', 'hidden');
|
||||
};
|
||||
|
||||
const redraw = () => {
|
||||
const basinH = fNum('basinHeight') || 5;
|
||||
|
||||
// Derived safety levels (participate in the right-column stack)
|
||||
const basedOn = document.getElementById('node-input-minHeightBasedOn')?.value || 'outlet';
|
||||
const refLow = basedOn === 'inlet' ? fNum('inflowLevel') : fNum('outflowLevel');
|
||||
const dryPct = fNum('dryRunThresholdPercent');
|
||||
const ovfPct = fNum('overfillThresholdPercent');
|
||||
const ovf = fNum('overflowLevel');
|
||||
const dryLvl = (refLow != null && dryPct != null) ? refLow * (1 + dryPct / 100) : null;
|
||||
const ovfLvl = (ovf != null && ovfPct != null) ? ovf * (ovfPct / 100) : null;
|
||||
|
||||
// Right-column stack. TWO anchors: basinHeight pinned at the
|
||||
// tank rim (top) and outflowLevel pinned at its proportional y
|
||||
// (bottom). Everything between is nudged to maintain a minimum
|
||||
// vertical gap via two passes — top-down from the rim, then
|
||||
// bottom-up from the outlet — so the dashed lines keep their
|
||||
// value-order and outlet stays near the floor where it belongs.
|
||||
const items = [
|
||||
{ id: 'basinHeight', yIdeal: DIAG.topY, pinned: true },
|
||||
{ id: 'overflowLevel', yIdeal: yForLevel(fNum('overflowLevel'), basinH) },
|
||||
{ id: 'maxLevel', yIdeal: yForLevel(fNum('maxLevel'), basinH) },
|
||||
{ id: 'startLevel', yIdeal: yForLevel(fNum('startLevel'), basinH) },
|
||||
{ id: 'minLevel', yIdeal: yForLevel(fNum('minLevel'), basinH) },
|
||||
{ id: 'dryRunLevel', yIdeal: yForLevel(dryLvl, basinH) },
|
||||
{ id: 'outflowLevel', yIdeal: yForLevel(fNum('outflowLevel'), basinH), pinned: true },
|
||||
].filter(it => it.yIdeal != null);
|
||||
|
||||
const GAP = 36;
|
||||
items.sort((a, b) => a.yIdeal - b.yIdeal);
|
||||
for (const it of items) it.y = it.yIdeal;
|
||||
// Pass 1: top-down — push DOWN to maintain GAP; pinned items don't move
|
||||
for (let i = 1; i < items.length; i++) {
|
||||
if (items[i].pinned) continue;
|
||||
items[i].y = Math.max(items[i].y, items[i - 1].y + GAP);
|
||||
}
|
||||
// Pass 2: bottom-up — push UP so outflow's pin propagates up the stack
|
||||
for (let i = items.length - 2; i >= 0; i--) {
|
||||
if (items[i].pinned) continue;
|
||||
items[i].y = Math.min(items[i].y, items[i + 1].y - GAP);
|
||||
}
|
||||
for (const it of items) placeItem(it.id, it.y);
|
||||
|
||||
// Zone labels between adjacent thresholds (italic, centered).
|
||||
// Hidden if either bracketing threshold is missing, or the gap
|
||||
// is too small to read (< 14 px).
|
||||
const placeZone = (zoneId, topId, botId) => {
|
||||
const el = document.getElementById(`ps-zone-${zoneId}`);
|
||||
if (!el) return;
|
||||
const top = items.find(it => it.id === topId);
|
||||
const bot = items.find(it => it.id === botId);
|
||||
if (!top || !bot || (bot.y - top.y) < 14) {
|
||||
el.setAttribute('visibility', 'hidden'); return;
|
||||
}
|
||||
el.setAttribute('y', (top.y + bot.y) / 2 + 3);
|
||||
el.setAttribute('visibility', 'visible');
|
||||
};
|
||||
placeZone('spare', 'overflowLevel', 'maxLevel');
|
||||
placeZone('sewage', 'maxLevel', 'startLevel');
|
||||
placeZone('buffer1', 'startLevel', 'minLevel');
|
||||
placeZone('buffer2', 'minLevel', 'dryRunLevel');
|
||||
// "Dead volume" sits inside the blue band between outflowLevel and the floor
|
||||
const outflowPinned = items.find(it => it.id === 'outflowLevel');
|
||||
const deadLbl = document.getElementById('ps-zone-dead');
|
||||
if (deadLbl && outflowPinned && (DIAG.botY - outflowPinned.y) > 14) {
|
||||
deadLbl.setAttribute('y', (outflowPinned.y + DIAG.botY) / 2 + 3);
|
||||
deadLbl.setAttribute('visibility', 'visible');
|
||||
} else if (deadLbl) {
|
||||
deadLbl.setAttribute('visibility', 'hidden');
|
||||
}
|
||||
|
||||
// Inlet arrow — sole item on the left, no stacking concerns
|
||||
const inflowY = yForLevel(fNum('inflowLevel'), basinH);
|
||||
if (inflowY != null) {
|
||||
const line = document.getElementById('ps-line-inflowLevel');
|
||||
const lbl = document.getElementById('ps-label-inflowLevel');
|
||||
const sub = document.getElementById('ps-sub-inflowLevel');
|
||||
const fo = document.getElementById('ps-fo-inflowLevel');
|
||||
const unit = document.getElementById('ps-unit-inflowLevel');
|
||||
if (line) { line.setAttribute('y1', inflowY); line.setAttribute('y2', inflowY); }
|
||||
if (lbl) lbl.setAttribute('y', inflowY - 4);
|
||||
if (sub) sub.setAttribute('y', inflowY + 8);
|
||||
if (fo) fo.setAttribute('y', inflowY - 11);
|
||||
if (unit) unit.setAttribute('y', inflowY + 4);
|
||||
}
|
||||
|
||||
// Dead-volume band: from the (possibly-nudged) outflow line
|
||||
// down to the floor. Use the nudged y so the band meets the
|
||||
// outflow line exactly.
|
||||
const outflowItem = items.find(it => it.id === 'outflowLevel');
|
||||
const deadvol = document.getElementById('ps-deadvol');
|
||||
if (deadvol && outflowItem) {
|
||||
deadvol.setAttribute('y', outflowItem.y);
|
||||
deadvol.setAttribute('height', Math.max(0, DIAG.botY - outflowItem.y));
|
||||
}
|
||||
|
||||
// dryRunLevel label text (derived, read-only)
|
||||
const dryLbl = document.getElementById('ps-label-dryRunLevel');
|
||||
if (dryLbl) dryLbl.textContent = dryLvl != null
|
||||
? `dryRunLevel ≈ ${dryLvl.toFixed(2)} m (safety — from %)`
|
||||
: 'dryRunLevel ≈ — m (safety — from %)';
|
||||
|
||||
// Safety-section readouts (second view, beneath the diagram)
|
||||
const d1 = document.getElementById('derived-dryRunLevel');
|
||||
if (d1) d1.textContent = dryLvl != null ? `→ dryRunLevel ≈ ${dryLvl.toFixed(2)} m` : '→ dryRunLevel ≈ — m';
|
||||
const d2 = document.getElementById('derived-overfillLevel');
|
||||
if (d2) d2.textContent = ovfLvl != null ? `→ overfillLevel ≈ ${ovfLvl.toFixed(2)} m` : '→ overfillLevel ≈ — m';
|
||||
|
||||
// Ordering warning ribbon
|
||||
const warn = document.getElementById('ps-warning');
|
||||
const issues = [];
|
||||
const pairs = [
|
||||
['outflowLevel', 'inflowLevel', '<'],
|
||||
['inflowLevel', 'overflowLevel', '<'],
|
||||
['minLevel', 'startLevel', '<='],
|
||||
['startLevel', 'maxLevel', '<'],
|
||||
['maxLevel', 'overflowLevel', '<='],
|
||||
];
|
||||
for (const [a, b, op] of pairs) {
|
||||
const av = fNum(a), bv = fNum(b);
|
||||
if (av == null || bv == null) continue;
|
||||
if (op === '<' ? !(av < bv) : !(av <= bv)) issues.push(`${a} ${op} ${b}`);
|
||||
}
|
||||
if (warn) {
|
||||
if (issues.length) { warn.setAttribute('visibility', 'visible'); warn.textContent = `⚠ Check ordering: ${issues.join(', ')}`; }
|
||||
else { warn.setAttribute('visibility', 'hidden'); }
|
||||
}
|
||||
};
|
||||
['basinHeight','overflowLevel','maxLevel','startLevel','minLevel','inflowLevel','outflowLevel',
|
||||
'dryRunThresholdPercent','overfillThresholdPercent','minHeightBasedOn'].forEach((id) => {
|
||||
const el = document.getElementById(`node-input-${id}`);
|
||||
if (el) { el.addEventListener('input', redraw); el.addEventListener('change', redraw); }
|
||||
});
|
||||
setTimeout(redraw, 60);
|
||||
|
||||
//------------------- END OF CUSTOM config UI ELEMENTS ------------------- //
|
||||
oneditprepare: function () {
|
||||
window.EVOLV?.nodes?.pumpingStation?.editor?.init(this);
|
||||
},
|
||||
oneditsave: function () {
|
||||
const node = this;
|
||||
|
||||
//window.EVOLV?.nodes?.pumpingStation?.assetMenu?.saveEditor?.(node);
|
||||
window.EVOLV?.nodes?.pumpingStation?.loggerMenu?.saveEditor?.(node);
|
||||
window.EVOLV?.nodes?.pumpingStation?.positionMenu?.saveEditor?.(node);
|
||||
|
||||
//node specific
|
||||
node.refHeight = document.getElementById("node-input-refHeight").value || "NAP";
|
||||
node.minHeightBasedOn = document.getElementById("node-input-minHeightBasedOn").value || "outlet";
|
||||
node.simulator = document.getElementById("node-input-simulator").checked;
|
||||
|
||||
["basinVolume","basinHeight","inflowLevel","outflowLevel","overflowLevel","basinBottomRef","timeleftToFullOrEmptyThresholdSeconds","dryRunThresholdPercent","overfillThresholdPercent"]
|
||||
.forEach(field => {
|
||||
node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0;
|
||||
});
|
||||
|
||||
node.refHeight = document.getElementById("node-input-refHeight").value || "";
|
||||
node.enableDryRunProtection = document.getElementById("node-input-enableDryRunProtection").checked;
|
||||
node.enableOverfillProtection = document.getElementById("node-input-enableOverfillProtection").checked;
|
||||
|
||||
// control strategy
|
||||
node.controlMode = document.getElementById('node-input-controlMode').value || 'none';
|
||||
|
||||
const parseNum = (id) => parseFloat(document.getElementById(id)?.value);
|
||||
node.startLevel = parseNum('node-input-startLevel');
|
||||
node.minLevel = parseNum('node-input-minLevel');
|
||||
node.maxLevel = parseNum('node-input-maxLevel');
|
||||
node.flowSetpoint = parseNum('node-input-flowSetpoint');
|
||||
node.flowDeadband = parseNum('node-input-flowDeadband');
|
||||
|
||||
window.EVOLV?.nodes?.pumpingStation?.editor?.save(node);
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user