// 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); // 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) < MIN_ZONE_GAP) { 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); // 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 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: