// 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: