229 lines
10 KiB
JavaScript
229 lines
10 KiB
JavaScript
|
|
// 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:<ul style="margin:4px 0 0 18px;padding:0;">'
|
|||
|
|
+ issues.map((i) => `<li>${i}</li>`).join('') + '</ul>';
|
|||
|
|
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);
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
})();
|