Level-armed shift, derived dryRunLevel, side-panel editor + manual q_out
Runtime (specificClass.js): - Replace direction-based hysteresis with level-armed _shiftArmed state. Arms when level rises past shiftLevel; disarms when level drops below startLevel. While armed, ramp foot moves to startLevel and ramp top to shiftLevel — both ends shift left, then saturate at 100 % up to maxLevel. - _scaleLevelToFlowPercent now takes (rampStartLevel, rampTopLevel) so the saturation point follows the shift state. - New setManualOutflow mirroring setManualInflow. Adapter (nodeClass.js): - Pipe enableShiftedRamp / shiftLevel through to control.levelbased. - New q_out topic handler. Editor (pumpingStation.html + new src/editor/ modules): - Split monolithic <script> into modules: index.js (helpers), basin-diagram.js, mode-preview.js, hover-couple.js, oneditprepare.js, oneditsave.js — served via /pumpingStation/editor/:file. - Mode preview redrawn per the SVG diagrams: OFF tier below 0 %, 0 % flat from start→inlet, ramp inlet→max, optional shifted-down curve start→shift with 100 % saturation past shift. - Mode preview gains zone bands (dryRun / safetyLow / safe / safetyHigh / overflow), level markers (dryRun derived, start, inlet, max, shift, overflow), validation ribbon that blocks save on bad ordering. - Auto-default shiftLevel to 0.9 × maxLevel on enable so the marker is always visible. - All level inputs moved to a side panel left of each diagram, color- coded to match line strokes; hover-couple highlights the paired SVG line on input focus / mouseover. - Removed UI for non-static parameters: minHeightBasedOn, pipelineLength, maxDischargeHead, staticHead, defaultFluid, maxInflowRate, temperatureReferenceDegC, timeleftToFullOrEmptyThresholdSeconds, inletPipeDiameter, outletPipeDiameter, minLevel (now derived = dryRunLevel). - foreignObject inputs in basin SVG removed (single source of truth in side panel). Dashboard example (examples/basic-dashboard.flow.json): - Add manual Q_OUT slider + q_out builder mirroring the existing q_in trio so the basin can be exercised end-to-end without a connected rotating-machine downstream. Tests (test/basic/specificClass.test.js): - Replace direction-shift test with two new cases covering shift-disabled hold-zone behaviour and shift-armed/disarmed transitions through shiftLevel and startLevel boundaries. 53/53 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
147
src/editor/basin-diagram.js
Normal file
147
src/editor/basin-diagram.js
Normal file
@@ -0,0 +1,147 @@
|
||||
// PumpingStation editor — interactive basin SVG (top of the editor).
|
||||
// Places threshold lines, derived safety levels, zone labels, dead-volume
|
||||
// band, and ordering warnings. Same formulas as
|
||||
// specificClass._validateThresholdOrdering.
|
||||
|
||||
(function () {
|
||||
const ns = window.PSEditor = window.PSEditor || {};
|
||||
const fNum = (id) => ns.fNum(id);
|
||||
|
||||
// viewBox y bounds of the tank rect (now 120,40)..(240,380); width
|
||||
// shrunk to 360 in the new side-panel layout. y-bounds unchanged.
|
||||
const DIAG = { topY: 40, botY: 380 };
|
||||
|
||||
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.
|
||||
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');
|
||||
};
|
||||
|
||||
ns.basinDiagram = {
|
||||
redraw() {
|
||||
const basinH = fNum('basinHeight') || 5;
|
||||
|
||||
const refLow = fNum('outflowLevel');
|
||||
const dryPct = fNum('dryRunThresholdPercent');
|
||||
const highPct = fNum('highVolumeSafetyThresholdPercent');
|
||||
const ovf = fNum('overflowLevel');
|
||||
const dryLvl = (refLow != null && dryPct != null) ? refLow * (1 + dryPct / 100) : null;
|
||||
const highLvl = (ovf != null && highPct != null) ? ovf * (highPct / 100) : null;
|
||||
|
||||
// Right-column stack. TWO anchors: basinHeight pinned at the rim,
|
||||
// outflowLevel pinned at its proportional y. Two passes (top-down +
|
||||
// bottom-up) maintain a minimum vertical gap.
|
||||
const items = [
|
||||
{ id: 'basinHeight', yIdeal: DIAG.topY, pinned: true },
|
||||
{ id: 'overflowLevel', yIdeal: yForLevel(fNum('overflowLevel'), basinH) },
|
||||
{ id: 'highVolumeSafetyLevel', yIdeal: yForLevel(highLvl, basinH) },
|
||||
{ id: 'inflowLevelGuide', yIdeal: yForLevel(fNum('inflowLevel'), 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;
|
||||
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);
|
||||
}
|
||||
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);
|
||||
|
||||
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', 'highVolumeSafetyLevel');
|
||||
placeZone('sewage', 'highVolumeSafetyLevel', 'inflowLevelGuide');
|
||||
placeZone('buffer1', 'inflowLevelGuide', 'dryRunLevel');
|
||||
placeZone('buffer2', 'dryRunLevel', 'outflowLevel');
|
||||
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');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
// SVG labels — keep them short, side panel shows the numeric value.
|
||||
const dryLbl = document.getElementById('ps-label-dryRunLevel');
|
||||
if (dryLbl) dryLbl.textContent = 'dryRunLevel';
|
||||
const highLbl = document.getElementById('ps-label-highVolumeSafetyLevel');
|
||||
if (highLbl) highLbl.textContent = 'highVolumeSafety';
|
||||
|
||||
// Side-panel read-only displays — number only ("m" is shown in the unit span).
|
||||
const fmt = (v) => Number.isFinite(v) ? v.toFixed(2) : '—';
|
||||
const d1 = document.getElementById('derived-dryRunLevel');
|
||||
if (d1) d1.textContent = fmt(dryLvl);
|
||||
const d2 = document.getElementById('derived-highVolumeSafetyLevel');
|
||||
if (d2) d2.textContent = fmt(highLvl);
|
||||
|
||||
const warn = document.getElementById('ps-warning');
|
||||
const issues = [];
|
||||
const pairs = [
|
||||
['outflowLevel', 'inflowLevel', '<'],
|
||||
['inflowLevel', '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'); }
|
||||
}
|
||||
},
|
||||
};
|
||||
})();
|
||||
29
src/editor/hover-couple.js
Normal file
29
src/editor/hover-couple.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// PumpingStation editor — hover-coupling between side-panel input rows
|
||||
// and the SVG markers they control. Each .ps-row that carries
|
||||
// data-couples-line="<svg-element-id>" highlights that SVG line on
|
||||
// mouseenter and clears the highlight on mouseleave.
|
||||
|
||||
(function () {
|
||||
const ns = window.PSEditor = window.PSEditor || {};
|
||||
|
||||
ns.hoverCouple = {
|
||||
init() {
|
||||
document.querySelectorAll('.ps-diag-side .ps-row[data-couples-line]').forEach((row) => {
|
||||
const targetId = row.getAttribute('data-couples-line');
|
||||
const target = document.getElementById(targetId);
|
||||
if (!target) return;
|
||||
const enter = () => target.classList.add('ps-line-highlight');
|
||||
const leave = () => target.classList.remove('ps-line-highlight');
|
||||
row.addEventListener('mouseenter', enter);
|
||||
row.addEventListener('mouseleave', leave);
|
||||
// Also highlight while the input inside the row has focus, so
|
||||
// the user keeps the visual feedback while typing.
|
||||
const input = row.querySelector('input');
|
||||
if (input) {
|
||||
input.addEventListener('focus', enter);
|
||||
input.addEventListener('blur', leave);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
})();
|
||||
30
src/editor/index.js
Normal file
30
src/editor/index.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// PumpingStation editor — shared namespace + helpers.
|
||||
// Loaded first by pumpingStation.html via /pumpingStation/editor/index.js.
|
||||
// Each sibling module attaches additional members to window.PSEditor.
|
||||
|
||||
(function () {
|
||||
const ns = window.PSEditor = window.PSEditor || {};
|
||||
|
||||
// Read a numeric value from an input by node-input-<id>; null if blank/NaN.
|
||||
ns.fNum = (id) => {
|
||||
const v = parseFloat(document.getElementById(`node-input-${id}`)?.value);
|
||||
return Number.isFinite(v) ? v : null;
|
||||
};
|
||||
|
||||
// Set a numeric input's value, or blank if not finite.
|
||||
ns.setNumberField = (id, val) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = Number.isFinite(val) ? val : '';
|
||||
};
|
||||
|
||||
// Add input + change listeners to a list of node-input-* ids.
|
||||
ns.bindRedraw = (ids, handler) => {
|
||||
ids.forEach((id) => {
|
||||
const el = document.getElementById(`node-input-${id}`);
|
||||
if (el) {
|
||||
el.addEventListener('input', handler);
|
||||
el.addEventListener('change', handler);
|
||||
}
|
||||
});
|
||||
};
|
||||
})();
|
||||
228
src/editor/mode-preview.js
Normal file
228
src/editor/mode-preview.js
Normal file
@@ -0,0 +1,228 @@
|
||||
// PumpingStation editor — level-based mode preview SVG.
|
||||
// Draws zone bands, level markers, the up curve (inflowLevel→maxLevel) and
|
||||
// the optional shifted-down curve (startLevel→shiftLevel). Computes
|
||||
// validation issues and stashes them on window._psModeValidationIssues
|
||||
// for oneditsave to read.
|
||||
|
||||
(function () {
|
||||
const ns = window.PSEditor = window.PSEditor || {};
|
||||
const fNum = (id) => ns.fNum(id);
|
||||
|
||||
// Derive dryRunLevel the same way the basin diagram does.
|
||||
// dryRunLevel = outflowLevel × (1 + dryRunThresholdPercent/100).
|
||||
// Returns null if either input is missing.
|
||||
ns.deriveDryRunLevel = () => {
|
||||
const refLow = fNum('outflowLevel');
|
||||
const dryPct = fNum('dryRunThresholdPercent');
|
||||
if (refLow == null || dryPct == null) return null;
|
||||
return refLow * (1 + dryPct / 100);
|
||||
};
|
||||
|
||||
ns.modePreview = {
|
||||
redraw() {
|
||||
const svg = document.getElementById('ps-levelbased-mode-diagram');
|
||||
if (!svg) return;
|
||||
const start = fNum('startLevel');
|
||||
const inlet = fNum('inflowLevel');
|
||||
const max = fNum('maxLevel');
|
||||
// dryRunLevel is derived from the basin's outflowLevel + dryRun%
|
||||
// (no separate input). Below dryRunLevel the runtime hard-stops;
|
||||
// we draw it as the leftmost vertical marker so the user sees
|
||||
// exactly where it lands.
|
||||
const dryRun = ns.deriveDryRunLevel();
|
||||
const overflow = fNum('overflowLevel');
|
||||
const shiftEnabled = !!document.getElementById('node-input-enableShiftedRamp')?.checked;
|
||||
const shiftRaw = fNum('shiftLevel');
|
||||
const shift = Number.isFinite(shiftRaw) && shiftRaw > 0 ? Math.min(shiftRaw, max ?? shiftRaw) : null;
|
||||
const curveType = document.getElementById('node-input-levelCurveType')?.value || 'linear';
|
||||
const factorRaw = parseFloat(document.getElementById('node-input-logCurveFactor')?.value);
|
||||
const factor = Number.isFinite(factorRaw) && factorRaw > 0 ? factorRaw : 9;
|
||||
|
||||
// Plot window is FIXED relative to basin geometry so that moving any
|
||||
// single level slides only that line, not all the others. Lower bound
|
||||
// is the basin floor (0); upper bound is overflowLevel (or maxLevel
|
||||
// if overflow isn't set) plus a small margin.
|
||||
const upperRefs = [max, overflow].filter(Number.isFinite);
|
||||
const upperBase = upperRefs.length ? Math.max(...upperRefs) : 1;
|
||||
const pad = Math.max(upperBase * 0.05, 0.1);
|
||||
const levelMin = 0;
|
||||
const levelMax = upperBase + pad;
|
||||
|
||||
// Plot rectangle (viewBox px).
|
||||
const x0 = 52, x1 = 390, y0 = 140, y1 = 24;
|
||||
const yOffPx = 160;
|
||||
const yOffPct = -((yOffPx - y0) / (y0 - y1)) * 100;
|
||||
const xFor = (level) => x0 + ((level - levelMin) / (levelMax - levelMin)) * (x1 - x0);
|
||||
const yForPct = (pct) => y0 - (pct / 100) * (y0 - y1);
|
||||
const scale = (x) => {
|
||||
const clamped = Math.max(0, Math.min(1, x));
|
||||
if (curveType === 'log') return Math.log1p(factor * clamped) / Math.log1p(factor);
|
||||
return clamped;
|
||||
};
|
||||
|
||||
// Path with three flat regions and a ramp:
|
||||
// [levelMin..startX] OFF (pump off; below startLevel)
|
||||
// [startX..footX] 0 % (system armed but not yet ramping)
|
||||
// [footX..topX] ramp (linear or log scaled 0..100 %)
|
||||
// [topX..levelMax] 100 % (saturated)
|
||||
// Up curve: startX=startLevel, footX=inflowLevel, topX=maxLevel.
|
||||
// Shifted-down: startX=footX=startLevel, topX=shiftLevel.
|
||||
const buildPath = (startX, footX, topX) => {
|
||||
if (![startX, footX, topX].every(Number.isFinite) || topX <= footX) return '';
|
||||
const pts = [];
|
||||
pts.push(`${xFor(levelMin)},${yForPct(yOffPct)}`);
|
||||
pts.push(`${xFor(startX)},${yForPct(yOffPct)}`);
|
||||
pts.push(`${xFor(startX)},${yForPct(0)}`);
|
||||
if (footX > startX) pts.push(`${xFor(footX)},${yForPct(0)}`);
|
||||
for (let i = 0; i <= 24; i++) {
|
||||
const t = i / 24;
|
||||
const level = footX + t * (topX - footX);
|
||||
pts.push(`${xFor(level)},${yForPct(scale(t) * 100)}`);
|
||||
}
|
||||
pts.push(`${xFor(levelMax)},${yForPct(100)}`);
|
||||
return pts.join(' ');
|
||||
};
|
||||
|
||||
const up = document.getElementById('ps-mode-curve-up');
|
||||
const down = document.getElementById('ps-mode-curve-down');
|
||||
const downLabel = document.getElementById('ps-mode-curve-down-label');
|
||||
if (up) up.setAttribute('points', buildPath(start, inlet, max));
|
||||
if (down) {
|
||||
if (shiftEnabled) {
|
||||
const shiftedTop = Number.isFinite(shift) && shift > start ? shift : max;
|
||||
down.setAttribute('points', buildPath(start, start, shiftedTop));
|
||||
down.style.display = '';
|
||||
if (downLabel) downLabel.style.display = '';
|
||||
} else {
|
||||
down.setAttribute('points', '');
|
||||
down.style.display = 'none';
|
||||
if (downLabel) downLabel.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical level markers + axis labels.
|
||||
[
|
||||
['dryRunLevel', dryRun],
|
||||
['startLevel', start],
|
||||
['inflowLevel', inlet],
|
||||
['maxLevel', max],
|
||||
['overflowLevel', overflow],
|
||||
].forEach(([id, level]) => {
|
||||
const line = document.getElementById(`ps-mode-line-${id}`);
|
||||
const label = document.getElementById(`ps-mode-label-${id}`);
|
||||
if (!line || !label) return;
|
||||
if (!Number.isFinite(level)) {
|
||||
line.style.display = 'none';
|
||||
label.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const x = xFor(level);
|
||||
line.style.display = '';
|
||||
label.style.display = '';
|
||||
line.setAttribute('x1', x); line.setAttribute('x2', x);
|
||||
label.setAttribute('x', x);
|
||||
});
|
||||
|
||||
// Background zone bands.
|
||||
const plotL = xFor(levelMin);
|
||||
const plotR = xFor(levelMax);
|
||||
const setBand = (id, a, b) => {
|
||||
const r = document.getElementById(id);
|
||||
if (!r) return;
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b) || b <= a) {
|
||||
r.setAttribute('x', 0); r.setAttribute('width', 0);
|
||||
return;
|
||||
}
|
||||
r.setAttribute('x', a);
|
||||
r.setAttribute('width', b - a);
|
||||
};
|
||||
const xMin = Number.isFinite(dryRun) ? xFor(dryRun) : plotL;
|
||||
const xStart = Number.isFinite(start) ? xFor(start) : xMin;
|
||||
const xMax = Number.isFinite(max) ? xFor(max) : plotR;
|
||||
const xOvf = Number.isFinite(overflow) ? xFor(overflow) : xMax;
|
||||
setBand('ps-zone-dryRun', plotL, xMin);
|
||||
setBand('ps-zone-safetyLow', xMin, xStart);
|
||||
setBand('ps-zone-safe', xStart, xMax);
|
||||
setBand('ps-zone-safetyHigh', xMax, xOvf);
|
||||
setBand('ps-zone-overflow', xOvf, plotR);
|
||||
|
||||
// Shift level marker.
|
||||
const shiftLine = document.getElementById('ps-mode-line-shiftLevel');
|
||||
const shiftLabel = document.getElementById('ps-mode-label-shiftLevel');
|
||||
if (shiftLine && shiftLabel) {
|
||||
if (shiftEnabled && Number.isFinite(shift)) {
|
||||
const x = xFor(shift);
|
||||
shiftLine.setAttribute('x1', x); shiftLine.setAttribute('x2', x);
|
||||
shiftLabel.setAttribute('x', x);
|
||||
shiftLine.style.display = '';
|
||||
shiftLabel.style.display = '';
|
||||
} else {
|
||||
shiftLine.style.display = 'none';
|
||||
shiftLabel.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Title + row visibility.
|
||||
const curveLabel = document.getElementById('ps-mode-curve-label');
|
||||
if (curveLabel) curveLabel.textContent = curveType === 'log' ? 'log curve: fast early response' : 'linear curve';
|
||||
const shiftRow = document.getElementById('ps-shiftLevel-row');
|
||||
if (shiftRow) shiftRow.style.display = shiftEnabled ? '' : 'none';
|
||||
const logRow = document.getElementById('ps-log-factor-row');
|
||||
if (logRow) logRow.style.display = curveType === 'log' ? '' : 'none';
|
||||
|
||||
// Auto-default shiftLevel when shift is enabled and current value
|
||||
// is missing/out-of-range. Visible default avoids a hidden ramp.
|
||||
const shiftInput = document.getElementById('node-input-shiftLevel');
|
||||
if (shiftEnabled && shiftInput && Number.isFinite(max)) {
|
||||
const cur = parseFloat(shiftInput.value);
|
||||
if (!Number.isFinite(cur) || cur <= 0 || cur >= max) {
|
||||
shiftInput.value = (max * 0.9).toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
// Validation: ordering constraints.
|
||||
const issues = [];
|
||||
if (Number.isFinite(dryRun) && Number.isFinite(start) && dryRun >= start)
|
||||
issues.push('dryRunLevel (derived) must be < startLevel — increase startLevel or lower dryRun%');
|
||||
if (Number.isFinite(start) && Number.isFinite(inlet) && start >= inlet)
|
||||
issues.push('startLevel must be < inflowLevel (set in basin above)');
|
||||
if (Number.isFinite(inlet) && Number.isFinite(max) && inlet >= max)
|
||||
issues.push('inflowLevel must be < maxLevel');
|
||||
if (Number.isFinite(max) && Number.isFinite(overflow) && max > overflow)
|
||||
issues.push('maxLevel must be ≤ overflowLevel');
|
||||
if (shiftEnabled) {
|
||||
const shiftVal = Number(shiftInput?.value);
|
||||
if (Number.isFinite(shiftVal)) {
|
||||
if (Number.isFinite(start) && shiftVal <= start)
|
||||
issues.push('shiftLevel must be > startLevel');
|
||||
if (Number.isFinite(max) && shiftVal > max)
|
||||
issues.push('shiftLevel must be ≤ maxLevel');
|
||||
} else {
|
||||
issues.push('shiftLevel is required when shifted ramp is enabled');
|
||||
}
|
||||
}
|
||||
const warnBox = document.getElementById('ps-mode-validation');
|
||||
if (warnBox) {
|
||||
if (issues.length) {
|
||||
warnBox.innerHTML = '⚠ Fix before deploy:<ul style="margin:4px 0 0 18px;padding:0;">'
|
||||
+ issues.map((i) => `<li>${i}</li>`).join('') + '</ul>';
|
||||
warnBox.style.display = '';
|
||||
} else {
|
||||
warnBox.style.display = 'none';
|
||||
}
|
||||
}
|
||||
window._psModeValidationIssues = issues;
|
||||
|
||||
// Read-only readouts in the side panel — number only; the row's
|
||||
// .ps-unit span already shows "m".
|
||||
const fmt = (v) => Number.isFinite(v) ? v.toFixed(2) : '—';
|
||||
const setText = (id, val) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = fmt(val);
|
||||
};
|
||||
setText('ps-mode-readout-dryRun', dryRun);
|
||||
setText('ps-mode-readout-inflow', inlet);
|
||||
setText('ps-mode-readout-overflow', overflow);
|
||||
},
|
||||
};
|
||||
})();
|
||||
101
src/editor/oneditprepare.js
Normal file
101
src/editor/oneditprepare.js
Normal file
@@ -0,0 +1,101 @@
|
||||
// PumpingStation editor — oneditprepare entry. Wires up form-field
|
||||
// initialization, control-mode toggle, safety toggles, and binds
|
||||
// redraws for the basin diagram + level-based mode preview.
|
||||
|
||||
(function () {
|
||||
const ns = window.PSEditor = window.PSEditor || {};
|
||||
|
||||
ns.oneditprepare = function () {
|
||||
const node = this;
|
||||
|
||||
// Wait for menu data (asset/logger/position dropdowns) before init.
|
||||
const waitForMenuData = () => {
|
||||
if (window.EVOLV?.nodes?.pumpingStation?.initEditor) {
|
||||
window.EVOLV.nodes.pumpingStation.initEditor(node);
|
||||
} else {
|
||||
setTimeout(waitForMenuData, 50);
|
||||
}
|
||||
};
|
||||
waitForMenuData();
|
||||
|
||||
const refHeightEl = document.getElementById('node-input-refHeight');
|
||||
if (refHeightEl) refHeightEl.value = node.refHeight || 'NAP';
|
||||
|
||||
// Safety toggle pairs — each toggle enables/disables its threshold input.
|
||||
const dryRunToggle = document.getElementById('node-input-enableDryRunProtection');
|
||||
const dryRunPercent = document.getElementById('node-input-dryRunThresholdPercent');
|
||||
const highVolumeToggle = document.getElementById('node-input-enableHighVolumeSafety');
|
||||
const highVolumePercent = document.getElementById('node-input-highVolumeSafetyThresholdPercent');
|
||||
|
||||
const toggleInput = (toggleEl, inputEl) => {
|
||||
if (!toggleEl || !inputEl) return;
|
||||
inputEl.disabled = !toggleEl.checked;
|
||||
inputEl.parentElement.classList.toggle('disabled', inputEl.disabled);
|
||||
};
|
||||
|
||||
if (dryRunToggle && dryRunPercent) {
|
||||
dryRunToggle.checked = !!node.enableDryRunProtection;
|
||||
dryRunPercent.value = Number.isFinite(node.dryRunThresholdPercent) ? node.dryRunThresholdPercent : 2;
|
||||
dryRunToggle.addEventListener('change', () => toggleInput(dryRunToggle, dryRunPercent));
|
||||
toggleInput(dryRunToggle, dryRunPercent);
|
||||
}
|
||||
|
||||
if (highVolumeToggle && highVolumePercent) {
|
||||
highVolumeToggle.checked = node.enableHighVolumeSafety !== undefined
|
||||
? !!node.enableHighVolumeSafety
|
||||
: !!node.enableOverfillProtection;
|
||||
const highVolumePct = node.highVolumeSafetyThresholdPercent ?? node.overfillThresholdPercent;
|
||||
highVolumePercent.value = Number.isFinite(highVolumePct) ? highVolumePct : 98;
|
||||
highVolumeToggle.addEventListener('change', () => toggleInput(highVolumeToggle, highVolumePercent));
|
||||
toggleInput(highVolumeToggle, highVolumePercent);
|
||||
}
|
||||
|
||||
// Control-mode section toggle (levelbased / manual).
|
||||
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 = node.controlMode === 'manual' ? 'manual' : 'levelbased';
|
||||
toggleModeSections(modeSelect.value);
|
||||
modeSelect.addEventListener('change', (e) => toggleModeSections(e.target.value));
|
||||
}
|
||||
|
||||
// Numeric field defaults.
|
||||
ns.setNumberField('node-input-startLevel', node.startLevel);
|
||||
ns.setNumberField('node-input-maxLevel', node.maxLevel);
|
||||
ns.setNumberField('node-input-logCurveFactor', node.logCurveFactor);
|
||||
ns.setNumberField('node-input-shiftLevel', node.shiftLevel);
|
||||
ns.setNumberField('node-input-flowSetpoint', node.flowSetpoint);
|
||||
ns.setNumberField('node-input-flowDeadband', node.flowDeadband);
|
||||
|
||||
const curveSelect = document.getElementById('node-input-levelCurveType');
|
||||
if (curveSelect) curveSelect.value = node.levelCurveType || node.curveType || 'linear';
|
||||
const shiftCheckbox = document.getElementById('node-input-enableShiftedRamp');
|
||||
if (shiftCheckbox) shiftCheckbox.checked = !!node.enableShiftedRamp;
|
||||
|
||||
// Bind redraws to the inputs each diagram cares about.
|
||||
ns.bindRedraw(
|
||||
['basinHeight', 'overflowLevel', 'inflowLevel', 'outflowLevel',
|
||||
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent'],
|
||||
ns.basinDiagram.redraw
|
||||
);
|
||||
ns.bindRedraw(
|
||||
// dryRunLevel is derived (outflowLevel + dryRunThresholdPercent),
|
||||
// so the mode preview must redraw when either of those change.
|
||||
['startLevel', 'maxLevel', 'inflowLevel', 'outflowLevel', 'overflowLevel',
|
||||
'dryRunThresholdPercent',
|
||||
'levelCurveType', 'logCurveFactor', 'enableShiftedRamp', 'shiftLevel'],
|
||||
ns.modePreview.redraw
|
||||
);
|
||||
|
||||
// Initial render + hover-couple wiring once the DOM is settled.
|
||||
setTimeout(() => {
|
||||
ns.basinDiagram.redraw();
|
||||
ns.modePreview.redraw();
|
||||
ns.hoverCouple?.init();
|
||||
}, 60);
|
||||
};
|
||||
})();
|
||||
63
src/editor/oneditsave.js
Normal file
63
src/editor/oneditsave.js
Normal file
@@ -0,0 +1,63 @@
|
||||
// PumpingStation editor — oneditsave handler. Validates, saves shared
|
||||
// menu sections (logger/position), then persists pumpingStation-specific
|
||||
// fields onto the node. Throws if validation fails to keep the editor open.
|
||||
|
||||
(function () {
|
||||
const ns = window.PSEditor = window.PSEditor || {};
|
||||
|
||||
const parseNum = (id) => parseFloat(document.getElementById(id)?.value);
|
||||
|
||||
ns.oneditsave = function () {
|
||||
const node = this;
|
||||
|
||||
// Block save if the inline validator surfaced any issues.
|
||||
const issues = window._psModeValidationIssues || [];
|
||||
if (issues.length) {
|
||||
if (typeof RED !== 'undefined' && RED.notify) {
|
||||
RED.notify('PumpingStation config invalid:<br>• ' + issues.join('<br>• '),
|
||||
{ type: 'error', timeout: 6000 });
|
||||
}
|
||||
throw new Error('PumpingStation: invalid config — ' + issues.join('; '));
|
||||
}
|
||||
|
||||
window.EVOLV?.nodes?.pumpingStation?.loggerMenu?.saveEditor?.(node);
|
||||
window.EVOLV?.nodes?.pumpingStation?.positionMenu?.saveEditor?.(node);
|
||||
|
||||
node.refHeight = document.getElementById('node-input-refHeight').value || 'NAP';
|
||||
node.simulator = document.getElementById('node-input-simulator').checked;
|
||||
|
||||
[
|
||||
'basinVolume', 'basinHeight', 'inflowLevel', 'outflowLevel', 'overflowLevel',
|
||||
'basinBottomRef',
|
||||
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent',
|
||||
].forEach((field) => {
|
||||
const el = document.getElementById(`node-input-${field}`);
|
||||
if (el) node[field] = parseFloat(el.value) || 0;
|
||||
});
|
||||
|
||||
node.enableDryRunProtection = document.getElementById('node-input-enableDryRunProtection').checked;
|
||||
node.enableHighVolumeSafety = document.getElementById('node-input-enableHighVolumeSafety').checked;
|
||||
// Deprecated aliases kept for existing runtime/schema compatibility.
|
||||
node.enableOverfillProtection = node.enableHighVolumeSafety;
|
||||
node.overfillThresholdPercent = node.highVolumeSafetyThresholdPercent;
|
||||
|
||||
node.controlMode = document.getElementById('node-input-controlMode').value || 'levelbased';
|
||||
node.levelCurveType = document.getElementById('node-input-levelCurveType')?.value || 'linear';
|
||||
node.logCurveFactor = parseNum('node-input-logCurveFactor');
|
||||
node.startLevel = parseNum('node-input-startLevel');
|
||||
node.maxLevel = parseNum('node-input-maxLevel');
|
||||
// minLevel is no longer a user input — it's the derived dryRunLevel
|
||||
// (outflowLevel × (1 + dryRunThresholdPercent/100)). The runtime still
|
||||
// uses node.minLevel as the unconditional STOP threshold; we set it
|
||||
// here so that semantic survives the UI change.
|
||||
const _dryRun = ns.deriveDryRunLevel?.();
|
||||
if (Number.isFinite(_dryRun)) node.minLevel = _dryRun;
|
||||
node.enableShiftedRamp = !!document.getElementById('node-input-enableShiftedRamp')?.checked;
|
||||
const shiftLevelVal = parseNum('node-input-shiftLevel');
|
||||
node.shiftLevel = Number.isFinite(shiftLevelVal) ? shiftLevelVal : 0;
|
||||
const flowSetpoint = parseNum('node-input-flowSetpoint');
|
||||
const flowDeadband = parseNum('node-input-flowDeadband');
|
||||
if (Number.isFinite(flowSetpoint)) node.flowSetpoint = flowSetpoint;
|
||||
if (Number.isFinite(flowDeadband)) node.flowDeadband = flowDeadband;
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user