Compare commits

...

1 Commits

Author SHA1 Message Date
znetsixe
d641d2248d Editor: interactive basin diagram — inputs placed at each threshold line
Replaces the static parameters-diagram-above-form-rows layout with a
single interactive SVG where every threshold input sits directly on
the tank at its proportional y-position. Typing a value repositions
the corresponding line + input + label live.

What moved into the diagram (via <foreignObject> holding real
<input> elements with their existing node-input-* IDs so Node-RED
save/restore is untouched):

  basinHeight    — top of tank (fixed at rim by definition)
  overflowLevel  — weir crest (red, dashed)
  maxLevel       — 100 % demand line (orange, dashed)
  startLevel     — ramp-start line (green, dashed)
  minLevel       — MGC-shutdown line (purple, dashed)
  inflowLevel    — Inlet arrow + input on left
  outflowLevel   — Outlet arrow + input on right
  dryRunLevel    — read-only, computed from outflow × (1+dryRunPct/100)

Also in the diagram:
- Dead-volume band fills the area below outflowLevel dynamically
- Warning ribbon appears below the tank if ordering invariants break
  (mirrors specificClass._validateThresholdOrdering)
- All positions scale against the user's basinHeight; if empty, a
  default 5 m scale is used just to keep the diagram readable

What stayed as regular form rows:
- Basin Volume (m³) — not a height, can't be placed on a y-axis
- minLevel / startLevel / maxLevel were in the Control Strategy >
  Level-based section; removed from there and moved into the diagram
  (the level-based subsection now contains a one-line pointer)
- Safety % inputs (dryRun, overfill) stay in the Safety section with
  their derived-level readouts, now synced with the diagram

No schema changes, no field additions, no behaviour changes in the
runtime. Pure editor-UX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:28:18 +02:00

View File

@@ -173,31 +173,93 @@
setNumberField('node-input-flowSetpoint', this.flowSetpoint);
setNumberField('node-input-flowDeadband', this.flowDeadband);
// Live-compute derived safety levels so the operator can see
// what the % will actually trip at. Mirrors the code formula
// in specificClass._validateThresholdOrdering.
const fNum = (id) => parseFloat(document.getElementById(`node-input-${id}`)?.value);
const updateDerivedLevels = () => {
// 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));
};
const placeRow = (id, y) => {
if (y == null) return;
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}`);
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);
};
const redraw = () => {
const basinH = fNum('basinHeight') || 5;
placeRow('overflowLevel', yForLevel(fNum('overflowLevel'), basinH));
placeRow('maxLevel', yForLevel(fNum('maxLevel'), basinH));
placeRow('startLevel', yForLevel(fNum('startLevel'), basinH));
placeRow('minLevel', yForLevel(fNum('minLevel'), basinH));
placeRow('inflowLevel', yForLevel(fNum('inflowLevel'), basinH));
const outflowY = yForLevel(fNum('outflowLevel'), basinH);
placeRow('outflowLevel', outflowY);
// Dead-volume band fills from outflowLevel down to the floor
const deadvol = document.getElementById('ps-deadvol');
if (deadvol && outflowY != null) {
deadvol.setAttribute('y', outflowY);
deadvol.setAttribute('height', Math.max(0, DIAG.botY - outflowY));
}
// Derived dryRunLevel (safety, from %)
const basedOn = document.getElementById('node-input-minHeightBasedOn')?.value || 'outlet';
const refLow = basedOn === 'inlet' ? fNum('inflowLevel') : fNum('outflowLevel');
const dryRunPct = fNum('dryRunThresholdPercent');
const overfillPct = fNum('overfillThresholdPercent');
const overflow = fNum('overflowLevel');
const dryRunLvl = Number.isFinite(refLow) && Number.isFinite(dryRunPct)
? refLow * (1 + dryRunPct / 100) : null;
const overfillLvl = Number.isFinite(overflow) && Number.isFinite(overfillPct)
? overflow * (overfillPct / 100) : null;
const dryEl = document.getElementById('derived-dryRunLevel');
const ovfEl = document.getElementById('derived-overfillLevel');
if (dryEl) dryEl.textContent = dryRunLvl != null ? `→ dryRunLevel ≈ ${dryRunLvl.toFixed(2)} m` : '→ dryRunLevel ≈ — m';
if (ovfEl) ovfEl.textContent = overfillLvl != null ? `→ overfillLevel ≈ ${overfillLvl.toFixed(2)} m` : '→ overfillLevel ≈ — m';
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;
placeRow('dryRunLevel', yForLevel(dryLvl, basinH));
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 (same values, second view)
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'); }
}
};
['inflowLevel','outflowLevel','overflowLevel','minHeightBasedOn','dryRunThresholdPercent','overfillThresholdPercent']
.forEach((id) => {
const el = document.getElementById(`node-input-${id}`);
if (el) { el.addEventListener('input', updateDerivedLevels); el.addEventListener('change', updateDerivedLevels); }
});
setTimeout(updateDerivedLevels, 50);
['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 ------------------- //
},
@@ -250,76 +312,106 @@
<hr>
<h4>Basin Geometry</h4>
<p style="font-size:12px;color:#777;margin:0 0 8px 0;">All heights measured from the basin floor (0 m).</p>
<h4>Basin parameters</h4>
<p style="font-size:12px;color:#777;margin:0 0 8px 0;">Heights are measured from the basin floor (0 m). Enter values next to each line the diagram scales to whatever you enter.</p>
<details open style="margin:0 0 12px 0;padding:6px;background:#fafafa;border:1px solid #e5e5e5;border-radius:4px;">
<summary style="cursor:pointer;font-size:12px;color:#0c99d9;user-select:none;font-weight:600;">📐 Parameters diagram</summary>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 440 310" style="display:block;width:100%;max-width:440px;margin-top:6px;" font-family="Arial,sans-serif" font-size="11">
<defs>
<marker id="ps-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#1F4E79" />
</marker>
</defs>
<!-- Tank body -->
<rect x="170" y="30" width="100" height="260" fill="#F0F8FF" stroke="#333" stroke-width="1.5" />
<!-- Dead-volume band at bottom -->
<rect x="171" y="270" width="98" height="19" fill="#AACCE0" />
<!-- basinHeight (rim) -->
<line x1="165" y1="30" x2="275" y2="30" stroke="#333" stroke-width="1.5" />
<text x="280" y="34" fill="#333" font-size="10">basinHeight</text>
<!-- overflowLevel -->
<line x1="165" y1="55" x2="275" y2="55" stroke="#C0392B" stroke-dasharray="4 2" stroke-width="1.5" />
<text x="280" y="58" fill="#C0392B" font-size="10">overflowLevel</text>
<!-- maxLevel -->
<line x1="165" y1="90" x2="275" y2="90" stroke="#D68910" stroke-dasharray="4 2" stroke-width="1.5" />
<text x="280" y="93" fill="#D68910" font-size="10">maxLevel</text>
<!-- startLevel -->
<line x1="165" y1="150" x2="275" y2="150" stroke="#1E8449" stroke-dasharray="4 2" stroke-width="1.5" />
<text x="280" y="153" fill="#1E8449" font-size="10">startLevel</text>
<!-- Inlet arrow -->
<line x1="130" y1="180" x2="170" y2="180" stroke="#1F4E79" stroke-width="2" marker-end="url(#ps-arrow)" />
<text x="125" y="177" text-anchor="end" fill="#1F4E79" font-size="10" font-weight="bold">Inlet</text>
<text x="125" y="189" text-anchor="end" fill="#777" font-size="9">bottom of pipe</text>
<!-- minLevel -->
<line x1="165" y1="215" x2="275" y2="215" stroke="#6C3483" stroke-dasharray="4 2" stroke-width="1.5" />
<text x="280" y="218" fill="#6C3483" font-size="10">minLevel</text>
<!-- dryRunLevel -->
<line x1="165" y1="245" x2="275" y2="245" stroke="#C0392B" stroke-dasharray="4 2" stroke-width="1.5" />
<text x="280" y="248" fill="#C0392B" font-size="10">dryRunLevel</text>
<text x="280" y="259" fill="#999" font-style="italic" font-size="9">safety from %</text>
<!-- Outlet arrow -->
<line x1="270" y1="278" x2="310" y2="278" stroke="#1F4E79" stroke-width="2" marker-end="url(#ps-arrow)" />
<text x="315" y="275" fill="#1F4E79" font-size="10" font-weight="bold">Outlet</text>
<text x="315" y="287" fill="#777" font-size="9">top of pipe</text>
<!-- Floor / datum -->
<line x1="165" y1="290" x2="275" y2="290" stroke="#000" stroke-width="2" />
<text x="155" y="294" text-anchor="end" fill="#000" font-size="10">0 m (datum)</text>
</svg>
</details>
<style>
#ps-basin-diagram input[type=number] {
width: 100%; height: 20px; box-sizing: border-box;
font-size: 11px; padding: 1px 4px; margin: 0;
border: 1px solid #ccc; border-radius: 3px; background: #fff;
}
#ps-basin-diagram input[type=number]:focus { outline: 1px solid #0c99d9; border-color: #0c99d9; }
</style>
<svg id="ps-basin-diagram" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 430"
style="display:block;width:100%;max-width:540px;margin:0 0 12px 0;background:#fff;border:1px solid #e5e5e5;border-radius:4px;"
font-family="Arial,sans-serif" font-size="11">
<defs>
<marker id="ps-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#1F4E79" />
</marker>
</defs>
<!-- Tank body -->
<rect x="200" y="40" width="120" height="340" fill="#F0F8FF" stroke="#333" stroke-width="1.5" />
<!-- Dead-volume band (y + height updated dynamically below outflowLevel) -->
<rect id="ps-deadvol" x="201" width="118" fill="#AACCE0" />
<!-- basinHeight always at tank rim (y=40 in viewBox coords) -->
<line id="ps-line-basinHeight" x1="195" y1="40" x2="325" y2="40" stroke="#333" stroke-width="1.5" />
<text id="ps-label-basinHeight" x="330" y="44" fill="#333">basinHeight</text>
<foreignObject id="ps-fo-basinHeight" x="425" y="29" width="70" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-basinHeight" min="0" step="0.1" />
</foreignObject>
<text id="ps-unit-basinHeight" x="500" y="44" fill="#555">m</text>
<!-- overflowLevel -->
<line id="ps-line-overflowLevel" x1="195" x2="325" stroke="#C0392B" stroke-dasharray="4 2" stroke-width="1.5" />
<text id="ps-label-overflowLevel" x="330" fill="#C0392B">overflowLevel</text>
<foreignObject id="ps-fo-overflowLevel" x="425" width="70" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-overflowLevel" min="0" step="0.01" />
</foreignObject>
<text id="ps-unit-overflowLevel" x="500" fill="#555">m</text>
<!-- maxLevel -->
<line id="ps-line-maxLevel" x1="195" x2="325" stroke="#D68910" stroke-dasharray="4 2" stroke-width="1.5" />
<text id="ps-label-maxLevel" x="330" fill="#D68910">maxLevel</text>
<foreignObject id="ps-fo-maxLevel" x="425" width="70" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-maxLevel" min="0" step="0.01" />
</foreignObject>
<text id="ps-unit-maxLevel" x="500" fill="#555">m</text>
<!-- startLevel -->
<line id="ps-line-startLevel" x1="195" x2="325" stroke="#1E8449" stroke-dasharray="4 2" stroke-width="1.5" />
<text id="ps-label-startLevel" x="330" fill="#1E8449">startLevel</text>
<foreignObject id="ps-fo-startLevel" x="425" width="70" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-startLevel" min="0" step="0.01" />
</foreignObject>
<text id="ps-unit-startLevel" x="500" fill="#555">m</text>
<!-- Inlet arrow + input on the left -->
<line id="ps-line-inflowLevel" x1="140" x2="200" stroke="#1F4E79" stroke-width="2" marker-end="url(#ps-arrow)" />
<text id="ps-label-inflowLevel" x="135" text-anchor="end" fill="#1F4E79" font-weight="bold">Inlet</text>
<text id="ps-sub-inflowLevel" x="135" text-anchor="end" fill="#777" font-size="9">bottom of pipe</text>
<foreignObject id="ps-fo-inflowLevel" x="5" width="70" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-inflowLevel" min="0" step="0.01" />
</foreignObject>
<text id="ps-unit-inflowLevel" x="80" fill="#555">m</text>
<!-- minLevel -->
<line id="ps-line-minLevel" x1="195" x2="325" stroke="#6C3483" stroke-dasharray="4 2" stroke-width="1.5" />
<text id="ps-label-minLevel" x="330" fill="#6C3483">minLevel</text>
<foreignObject id="ps-fo-minLevel" x="425" width="70" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-minLevel" min="0" step="0.01" />
</foreignObject>
<text id="ps-unit-minLevel" x="500" fill="#555">m</text>
<!-- dryRunLevel (derived, read-only) -->
<line id="ps-line-dryRunLevel" x1="195" x2="325" stroke="#C0392B" stroke-dasharray="1 2" stroke-width="1" opacity="0.6" />
<text id="ps-label-dryRunLevel" x="330" fill="#C0392B" font-size="10" font-style="italic">dryRunLevel m (safety from %)</text>
<!-- Outlet arrow on right, input below the threshold column -->
<line id="ps-line-outflowLevel" x1="320" x2="360" stroke="#1F4E79" stroke-width="2" marker-end="url(#ps-arrow)" />
<text id="ps-label-outflowLevel" x="365" fill="#1F4E79" font-weight="bold">Outlet</text>
<text id="ps-sub-outflowLevel" x="365" fill="#777" font-size="9">top of pipe</text>
<foreignObject id="ps-fo-outflowLevel" x="425" width="70" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-outflowLevel" min="0" step="0.01" />
</foreignObject>
<text id="ps-unit-outflowLevel" x="500" fill="#555">m</text>
<!-- Floor / datum -->
<line x1="195" y1="380" x2="325" y2="380" stroke="#000" stroke-width="2" />
<text x="330" y="384" fill="#000">0 m (datum)</text>
<!-- Ordering-warning ribbon -->
<text id="ps-warning" x="260" y="410" text-anchor="middle" fill="#C0392B" font-size="10" font-style="italic" visibility="hidden"></text>
</svg>
<div class="form-row">
<label for="node-input-basinVolume"><i class="fa fa-cube"></i> Basin Volume (m³)</label>
<input type="number" id="node-input-basinVolume" min="0" step="0.1" />
</div>
<div class="form-row">
<label for="node-input-basinHeight"><i class="fa fa-arrows-v"></i> Basin Height (m)</label>
<input type="number" id="node-input-basinHeight" min="0" step="0.1" />
</div>
<!-- Inlet/Outlet elevations -->
<div class="form-row">
<label for="node-input-inflowLevel"><i class="fa fa-long-arrow-up"></i> Inlet (bottom of pipe, m)</label>
<input type="number" id="node-input-inflowLevel" min="0" step="0.01" />
</div>
<div class="form-row">
<label for="node-input-outflowLevel"><i class="fa fa-long-arrow-down"></i> Outlet (top of pipe, m)</label>
<input type="number" id="node-input-outflowLevel" min="0" step="0.01" />
</div>
<div class="form-row">
<label for="node-input-overflowLevel"><i class="fa fa-tint"></i> Overflow (weir crest, m)</label>
<input type="number" id="node-input-overflowLevel" min="0" step="0.01" />
</div>
<hr>
@@ -334,18 +426,7 @@
</div>
<div id="ps-mode-levelbased" class="ps-mode-section">
<div class="form-row">
<label for="node-input-minLevel">minLevel (m)</label>
<input type="number" id="node-input-minLevel" placeholder="m" />
</div>
<div class="form-row">
<label for="node-input-startLevel">startLevel (m)</label>
<input type="number" id="node-input-startLevel" placeholder="m" />
</div>
<div class="form-row">
<label for="node-input-maxLevel">maxLevel (m)</label>
<input type="number" id="node-input-maxLevel" placeholder="m" />
</div>
<p style="font-size:12px;color:#777;margin:0;">Level-based uses <code>minLevel</code> / <code>startLevel</code> / <code>maxLevel</code> from the diagram above.</p>
</div>
<div id="ps-mode-flowbased" class="ps-mode-section" style="display:none">