Editor: dynamic input bounds + full hierarchy validation, layout polish

Bounds (new src/editor/bounds.js):
- Sets HTML5 min/max on every level + percent input each redraw,
  derived from the current values of related inputs so the spinner
  stops at the basin hierarchy:
  0 < outflowLevel < dryRunLevel < startLevel ≤ inflowLevel
      ≤ shiftLevel ≤ maxLevel ≤ overflowLevel ≤ basinHeight
- dryRunPercent capped so dryRunLevel ≤ startLevel given current outflow.
- shiftArmPercent ∈ [1, 100]; highVolumeSafety% ∈ [1, 100].

Validation:
- New visible ribbon above the basin diagram (#ps-basin-validation)
  listing every hierarchy violation. The in-SVG warning text is now a
  small reminder ("⚠ N ordering issues").
- basin-diagram.js owns hierarchy issues; mode-preview.js trimmed to
  only own shift-specific issues (shift > start, shift ≤ max,
  shiftArmPercent range, shiftLevel required-when-enabled).
- oneditsave blocks Deploy on the union of _psBasinValidationIssues
  and _psModeValidationIssues with a RED.notify listing all problems.

Layout polish:
- Side panel widened to 220 px with minmax(0, 1fr) first column so long
  labels can no longer push the rows past the panel edge.
- Basin SVG max-width 380 → 360, gap between side panel and SVG bumped
  14 → 28 px. Tank shifted right (x=145 width=110) so the inlet
  "bottom of pipe" sub-label is no longer clipped on the left edge.
- "0 m (datum)" moved below the tank (y=395, centred) so it can't
  collide with "Outlet / top of pipe" when outflowLevel is near floor.
- Zone labels shortened (Spare / Sewage + buffer / Buffer / Dead vol)
  and only show when the bracketing thresholds are ≥ 28 px apart, so
  they never sit on a threshold label.
- Mode preview axis labels under the chart removed — line colour +
  side-panel labels + hover-couple already identify each line. Stub
  <text> elements left hidden to keep the redraw loop simple. Arm-%
  line + label trimmed in 10 px on the right so they're not clipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rene De Ren
2026-05-06 14:10:22 +02:00
parent de9a79b888
commit 62bc73f2f9
6 changed files with 225 additions and 78 deletions

View File

@@ -69,12 +69,18 @@
}
for (const it of items) placeItem(it.id, it.y);
// Zone labels show only when the gap between the bracketing
// thresholds is at least MIN_ZONE_GAP px high — otherwise the label
// collides with one of the threshold labels (which sit at threshold
// y ±6 px text-height). 28 px keeps a 6 px clear gap above and
// below the zone label.
const MIN_ZONE_GAP = 28;
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) {
if (!top || !bot || (bot.y - top.y) < MIN_ZONE_GAP) {
el.setAttribute('visibility', 'hidden'); return;
}
el.setAttribute('y', (top.y + bot.y) / 2 + 3);
@@ -127,21 +133,59 @@
const d2 = document.getElementById('derived-highVolumeSafetyLevel');
if (d2) d2.textContent = fmt(highLvl);
const warn = document.getElementById('ps-warning');
// Hierarchy validation. Soft '≤' relations follow the user's choice:
// start ≤ inflow, max ≤ overflow, overflow ≤ basinHeight (equality OK).
// dryRunLevel must be < startLevel strictly (otherwise the runtime
// would trip dry-run before it could ramp).
// Re-read the raw value (basinH falls back to 5 for diagram scaling;
// here we want null when the user hasn't entered anything so the
// ≤-checks below are skipped rather than false-flagged).
const basinHraw = fNum('basinHeight');
const start = fNum('startLevel');
const inlet = fNum('inflowLevel');
const max = fNum('maxLevel');
const ovfl = fNum('overflowLevel');
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}`);
const ok = (a, b, op) => {
if (!Number.isFinite(a) || !Number.isFinite(b)) return true;
return op === '<' ? a < b : a <= b;
};
if (Number.isFinite(refLow) && refLow <= 0)
issues.push('outflowLevel must be > 0');
if (!ok(dryLvl, start, '<'))
issues.push(`dryRunLevel (${(dryLvl ?? NaN).toFixed(2)} m, derived) must be < startLevel — lower dryRun% or raise startLevel`);
if (!ok(start, inlet, '<='))
issues.push('startLevel must be ≤ inflowLevel');
if (!ok(inlet, max, '<='))
issues.push('inflowLevel must be ≤ maxLevel');
if (!ok(max, ovfl, '<='))
issues.push('maxLevel must be ≤ overflowLevel');
if (!ok(ovfl, basinHraw, '<='))
issues.push('overflowLevel must be ≤ basinHeight');
// Visible ribbon above the basin diagram.
const warnDiv = document.getElementById('ps-basin-validation');
if (warnDiv) {
if (issues.length) {
warnDiv.innerHTML = '⚠ Fix before deploy:<ul style="margin:4px 0 0 18px;padding:0;">'
+ issues.map((i) => `<li>${i}</li>`).join('') + '</ul>';
warnDiv.style.display = '';
} else {
warnDiv.style.display = 'none';
}
}
// Legacy in-SVG warning text — kept for the small reminder inside
// the diagram. Only shows the count.
const warn = document.getElementById('ps-warning');
if (warn) {
if (issues.length) { warn.setAttribute('visibility', 'visible'); warn.textContent = `⚠ Check ordering: ${issues.join(', ')}`; }
else { warn.setAttribute('visibility', 'hidden'); }
if (issues.length) {
warn.setAttribute('visibility', 'visible');
warn.textContent = `${issues.length} ordering issue${issues.length > 1 ? 's' : ''}`;
} else {
warn.setAttribute('visibility', 'hidden');
}
}
window._psBasinValidationIssues = issues;
},
};
})();

89
src/editor/bounds.js Normal file
View File

@@ -0,0 +1,89 @@
// PumpingStation editor — dynamic input bounds.
// Sets HTML5 min/max attributes on every level and percent input based on
// the current values of related inputs, so the up/down arrows stop at
// values that respect the basin hierarchy:
//
// 0 < outflowLevel < dryRunLevel < startLevel ≤ inflowLevel
// ≤ shiftLevel ≤ maxLevel ≤ overflowLevel ≤ basinHeight
//
// The user can still type out-of-range values via the keyboard (HTML5
// min/max only constrain the spinner). The validation ribbons in
// basin-diagram.js and mode-preview.js catch typed violations and the
// oneditsave handler blocks Deploy until they're resolved.
(function () {
const ns = window.PSEditor = window.PSEditor || {};
const fNum = (id) => ns.fNum(id);
const EPS = 0.001; // smallest meaningful step (mm-precision)
const setBounds = (id, min, max) => {
const el = document.getElementById(`node-input-${id}`);
if (!el) return;
if (Number.isFinite(min)) el.setAttribute('min', String(min));
else el.removeAttribute('min');
if (Number.isFinite(max)) el.setAttribute('max', String(max));
else el.removeAttribute('max');
};
ns.bounds = {
apply() {
const basinHeight = fNum('basinHeight');
const outflow = fNum('outflowLevel');
const dryPct = fNum('dryRunThresholdPercent');
const start = fNum('startLevel');
const inlet = fNum('inflowLevel');
const max = fNum('maxLevel');
const overflow = fNum('overflowLevel');
const shiftEnabled = !!document.getElementById('node-input-enableShiftedRamp')?.checked;
// Derived dryRunLevel (lower bound for startLevel).
const dryRun = (Number.isFinite(outflow) && Number.isFinite(dryPct))
? outflow * (1 + dryPct / 100) : null;
// Geometry — basin envelope.
setBounds('basinHeight', EPS, undefined);
setBounds('basinVolume', EPS, undefined);
// Levels (each capped by the next-higher level if defined).
setBounds('outflowLevel', EPS,
Number.isFinite(start) && Number.isFinite(dryPct)
? start / (1 + dryPct / 100) - EPS // keep dryRun < start
: (start ?? inlet ?? max ?? overflow ?? basinHeight));
setBounds('startLevel',
Number.isFinite(dryRun) ? dryRun + EPS : EPS,
inlet ?? max ?? overflow ?? basinHeight);
setBounds('inflowLevel',
start ?? EPS,
max ?? overflow ?? basinHeight);
setBounds('maxLevel',
inlet ?? start ?? EPS,
overflow ?? basinHeight);
setBounds('overflowLevel',
max ?? inlet ?? start ?? EPS,
basinHeight);
// Shift inputs (only relevant when shifted ramp enabled).
if (shiftEnabled) {
setBounds('shiftLevel',
Number.isFinite(start) ? start : EPS,
max ?? overflow ?? basinHeight);
setBounds('shiftArmPercent', 1, 100);
}
// Percentages.
// dryRun% capped so dryRunLevel ≤ startLevel.
let dryMax = 99;
if (Number.isFinite(start) && Number.isFinite(outflow) && outflow > 0) {
dryMax = Math.max(0, Math.min(99, ((start / outflow) - 1) * 100));
}
setBounds('dryRunThresholdPercent', 0, dryMax);
// highVol% bounded (1, 100). Equal to 100 means no margin to overflow.
setBounds('highVolumeSafetyThresholdPercent', 1, 100);
},
};
})();

View File

@@ -146,7 +146,9 @@
}
}
// Vertical level markers + axis labels.
// Vertical level markers — line only. Axis labels were removed;
// identification comes from line colour + side-panel labels +
// hover coupling.
[
['dryRunLevel', dryRun],
['startLevel', start],
@@ -155,18 +157,14 @@
['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 (!line) 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.
@@ -192,19 +190,15 @@
setBand('ps-zone-safetyHigh', xMax, xOvf);
setBand('ps-zone-overflow', xOvf, plotR);
// Shift level marker.
// Shift level marker (line only).
const shiftLine = document.getElementById('ps-mode-line-shiftLevel');
const shiftLabel = document.getElementById('ps-mode-label-shiftLevel');
if (shiftLine && shiftLabel) {
if (shiftLine) {
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';
}
}
@@ -237,16 +231,11 @@
}
}
// Validation: ordering constraints.
// Validation: only mode-specific (shift) ordering. Basin-level
// hierarchy (start ≤ inlet ≤ max ≤ overflow ≤ basinHeight,
// dryRun < start) is owned by basin-diagram.js so it shows in the
// basin section near the offending inputs.
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)) {

View File

@@ -93,8 +93,19 @@
ns.modePreview.redraw
);
// Whenever any level/percent input changes, refresh the bounds first
// so the next redraw + validation sees the correct min/max attrs.
ns.bindRedraw(
['basinHeight', 'basinVolume', 'overflowLevel', 'maxLevel',
'inflowLevel', 'startLevel', 'outflowLevel',
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent',
'enableShiftedRamp', 'shiftLevel', 'shiftArmPercent'],
() => ns.bounds?.apply()
);
// Initial render + hover-couple wiring once the DOM is settled.
setTimeout(() => {
ns.bounds?.apply();
ns.basinDiagram.redraw();
ns.modePreview.redraw();
ns.hoverCouple?.init();

View File

@@ -10,8 +10,12 @@
ns.oneditsave = function () {
const node = this;
// Block save if the inline validator surfaced any issues.
const issues = window._psModeValidationIssues || [];
// Block save if EITHER validator surfaced any issues. basin-diagram
// owns hierarchy issues (start ≤ inlet ≤ max ≤ overflow ≤ basinHeight,
// dryRun < start). mode-preview owns shift-specific issues.
const basinIssues = window._psBasinValidationIssues || [];
const modeIssues = window._psModeValidationIssues || [];
const issues = [...basinIssues, ...modeIssues];
if (issues.length) {
if (typeof RED !== 'undefined' && RED.notify) {
RED.notify('PumpingStation config invalid:<br>• ' + issues.join('<br>• '),