// 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'); // Optional stopLevel — explicit pump-off threshold. Drawn as its // own marker line; does NOT shift the ramp foot. Must be < startLevel // for the marker to render. const stopRaw = fNum('stopLevel'); const stop = Number.isFinite(stopRaw) && stopRaw >= 0 && Number.isFinite(start) && stopRaw < start ? stopRaw : null; // 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 armRaw = fNum('shiftArmPercent'); const armPct = Number.isFinite(armRaw) ? Math.max(0, Math.min(100, armRaw)) : 95; 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(' '); }; // Up curve. Foot is startLevel (the configured pump-on threshold and // ramp foot per the runtime in _controlLevelBased). The OFF baseline // is drawn for level < startLevel; at startLevel demand jumps from // OFF to 0 % and ramps up to 100 % at maxLevel. 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, start, max)); // Shifted-DOWN curve (only when shift enabled): represents the // worst-case held-then-ramp path drawn for hold=100 % (the SVG // ideal). Geometry: 100 % flat from levelMax back to shiftLevel, // then linear/log ramp from (shiftLevel, 100 %) down to // (startLevel, 0 %), then OFF below startLevel. // Real runtime hold value depends on where direction flips, so the // preview shows the maximum extent. const buildShiftedDown = () => { if (![start, shift].every(Number.isFinite) || shift <= start) return ''; const pts = []; // OFF baseline far-left to startLevel pts.push(`${xFor(levelMin)},${yForPct(yOffPct)}`); pts.push(`${xFor(start)},${yForPct(yOffPct)}`); // Jump 0 % at startLevel pts.push(`${xFor(start)},${yForPct(0)}`); // Ramp start→shift = 0..100 % (peak hold = 100 % for this preview) for (let i = 0; i <= 24; i++) { const t = i / 24; const lvl = start + t * (shift - start); pts.push(`${xFor(lvl)},${yForPct(scale(t) * 100)}`); } // Held at 100 % from shift → far-right pts.push(`${xFor(levelMax)},${yForPct(100)}`); return pts.join(' '); }; if (down) { if (shiftEnabled) { down.setAttribute('points', buildShiftedDown()); down.style.display = ''; if (downLabel) downLabel.style.display = ''; } else { down.setAttribute('points', ''); down.style.display = 'none'; if (downLabel) downLabel.style.display = 'none'; } } // Horizontal arming-% line — only meaningful when shift enabled. const armLine = document.getElementById('ps-mode-line-armPercent'); const armLabel = document.getElementById('ps-mode-label-armPercent'); if (armLine && armLabel) { if (shiftEnabled) { const yArm = yForPct(armPct); armLine.setAttribute('y1', yArm); armLine.setAttribute('y2', yArm); armLabel.setAttribute('y', yArm - 2); armLabel.textContent = `arm ${Math.round(armPct)}%`; armLine.style.display = ''; armLabel.style.display = ''; } else { armLine.style.display = 'none'; armLabel.style.display = 'none'; } } // Vertical level markers — line only. Axis labels were removed; // identification comes from line colour + side-panel labels + // hover coupling. [ ['dryRunLevel', dryRun], ['startLevel', start], ['stopLevel', stop], ['inflowLevel', inlet], ['maxLevel', max], ['overflowLevel', overflow], ].forEach(([id, level]) => { const line = document.getElementById(`ps-mode-line-${id}`); if (!line) return; if (!Number.isFinite(level)) { line.style.display = 'none'; return; } const x = xFor(level); line.style.display = ''; line.setAttribute('x1', x); line.setAttribute('x2', 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 (line only). const shiftLine = document.getElementById('ps-mode-line-shiftLevel'); if (shiftLine) { if (shiftEnabled && Number.isFinite(shift)) { const x = xFor(shift); shiftLine.setAttribute('x1', x); shiftLine.setAttribute('x2', x); shiftLine.style.display = ''; } else { shiftLine.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 armRow = document.getElementById('ps-shiftArmPercent-row'); if (armRow) armRow.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); } } // Auto-default shiftArmPercent to 95 % when shift is enabled and the // current value is missing / out of [0, 100]. const armInput = document.getElementById('node-input-shiftArmPercent'); if (shiftEnabled && armInput) { const cur = parseFloat(armInput.value); if (!Number.isFinite(cur) || cur < 0 || cur > 100) { armInput.value = 95; } } // 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 (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 armVal = Number(armInput?.value); if (!Number.isFinite(armVal) || armVal <= 0 || armVal > 100) issues.push('shiftArmPercent must be in (0, 100]'); } const warnBox = document.getElementById('ps-mode-validation'); if (warnBox) { if (issues.length) { warnBox.innerHTML = '⚠ Fix before deploy:'; warnBox.style.display = ''; } else { warnBox.style.display = 'none'; } } window._psModeValidationIssues = issues; // Read-only readouts in the side panel — number only; the row's // .ps-unit span already shows "m". const fmt = (v) => Number.isFinite(v) ? v.toFixed(2) : '—'; const setText = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = fmt(val); }; setText('ps-mode-readout-dryRun', dryRun); setText('ps-mode-readout-inflow', inlet); setText('ps-mode-readout-overflow', overflow); }, }; })();