Files
pumpingStation/src/editor/basin-diagram.js
Rene De Ren 8a6ca1baeb 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>
2026-05-05 19:29:34 +02:00

148 lines
6.8 KiB
JavaScript

// 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'); }
}
},
};
})();