diff --git a/.changeset/no-raw-minus-charts.md b/.changeset/no-raw-minus-charts.md new file mode 100644 index 000000000..13ad98078 --- /dev/null +++ b/.changeset/no-raw-minus-charts.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +The dashboard no longer prints a minus sign on grid and battery power. Charts label their axes with sizes and name what sits on each side of zero: above it what adds to the grid meter's reading (import, charge, load, the car), below it what takes away (export, discharge, solar). Grid and battery values say their direction in a word, such as "500 W export" or "1.4 kW discharge", in tooltips, the live strip, targets, driver cards, the charger card's plan table, the per-phase watts under the fuse bars and the plan's safety note. A battery that misses its target reads "short" or "over" along the target's direction. The Overview flow shows each device's size with "charging" or "discharging" under it, and a battery FTW only observes says so under its title. Home use keeps its sign for now. diff --git a/web/app.css b/web/app.css index 82a81b6f7..256f9e2c5 100644 --- a/web/app.css +++ b/web/app.css @@ -1637,6 +1637,14 @@ body.ftw-app .live-stat-value.is-export { color: var(--green-e); } body.ftw-app .live-stat-value.is-charging { color: var(--cyan); } body.ftw-app .live-stat-value.is-discharging { color: var(--energy-gives); } body.ftw-app .live-stat-value.is-neutral { color: var(--fg-dim); } +/* The direction word under a two-way value, so a narrow cell keeps the + size on one line. */ +body.ftw-app .live-stat-dir { + display: block; + font-size: 10px; + font-weight: 500; + letter-spacing: 0.06em; +} /* 24 h battery + SoC history block — sits below the live power chart. * Section label matches the Plan card's chart-section labels. */ diff --git a/web/app.js b/web/app.js index 6903f25b1..fbbc0721a 100644 --- a/web/app.js +++ b/web/app.js @@ -310,14 +310,29 @@ const lastUpdate = $("last-update"); const versionEl = $("version"); // ---- Formatting ---- - function formatW(w) { - if (w == null || !isFinite(w)) return "—"; - const abs = Math.abs(w); - if (abs >= 1000) { - return (w / 1000).toFixed(1) + " kW"; + // The UI never shows a raw minus. power-words.js, loaded before this file, + // gives a size and the word for its direction; it is read when used. If + // that file failed to load, the page still draws, with sizes and no words. + function powerWords() { return globalThis.ftwPowerWords || SIZES_ONLY; } + var SIZES_ONLY = (function () { + function size(w) { + if (w == null || !isFinite(w)) return "—"; + var abs = Math.abs(w); + return abs >= 1000 ? (abs / 1000).toFixed(1) + " kW" : Math.round(abs) + " W"; } - return Math.round(w) + " W"; - } + var none = { above: "", below: "" }; + return { + formatW: size, formatSignedW: size, formatPower: size, + formatTarget: function (w) { return "→ " + size(w); }, + directionWord: function () { return ""; }, + trackingGap: function (a, t) { return (Number(a) || 0) - (Number(t) || 0); }, + trackingText: function () { return ""; }, + SIDES: { live: none, battery: none, plan: none, diagnose: none }, + drawSides: function () {}, + }; + })(); + function formatW(w) { return powerWords().formatW(w); } + function formatPower(w, kind) { return powerWords().formatPower(w, kind); } // Fuse bar color. The bar is grid power, so it takes the grid colour, as // on the phone; from 90 % it takes the alarm, because operators just need @@ -354,14 +369,6 @@ if (kwh >= 10) return kwh.toFixed(1) + " kWh"; return kwh.toFixed(2) + " kWh"; } - function formatSignedWh(wh) { - if (wh == null) return "Unknown"; - var v = Number(wh) || 0; - var abs = Math.abs(v); - var sign = v > 0.5 ? "+" : v < -0.5 ? "-" : "±"; - if (abs >= 1000) return sign + (abs / 1000).toFixed(2) + " kWh"; - return sign + Math.round(abs) + " Wh"; - } function formatWhMagnitude(wh) { if (wh == null) return "Unknown"; var abs = Math.abs(Number(wh) || 0); @@ -381,10 +388,25 @@ if (kind === "bat") return v > 0 ? "is-charging" : "is-discharging"; return "is-neutral"; } + // The live strip's value: grid and battery go both ways, so a word says + // which, on its own line; solar and car do not. Home use keeps Core's + // sign (see formatSignedW). + function liveStatParts(key, w) { + if (key === "load") return { size: powerWords().formatSignedW(w), word: "" }; + var kind = key === "grid" ? "grid" : key === "bat" ? "battery" : null; + return { size: formatW(w), word: kind ? powerWords().directionWord(w, kind) : "" }; + } function updateLiveStat(key, w, cls) { var el = document.getElementById("live-stat-" + key); if (!el) return; - el.textContent = formatW(w); + var parts = liveStatParts(key, w); + el.textContent = parts.size; + if (parts.word) { + var dir = document.createElement("span"); + dir.className = "live-stat-dir"; + dir.textContent = " " + parts.word; + el.appendChild(dir); + } el.className = "live-stat-value " + (cls || "is-neutral"); } function updateLiveSocStat(soc) { @@ -396,9 +418,43 @@ } function batteryTargetLine(targetW) { if (targetW == null || !isFinite(targetW)) return ""; - // Just the target — the "· charging/discharging" suffix overflowed the - // node circle; the live W value + SoC% already convey direction. - return "target " + formatW(targetW); + // Just the target and one short word; "charging/discharging" overflowed + // the node circle. + return "target " + formatPower(targetW, "battery"); + } + + // A live tooltip row's value and target. The grid and batteries go both + // ways, so their rows carry a word; Home use keeps Core's sign; the other + // rows are sizes. + function tooltipPowerText(lab, i) { + var kind = lab.name === "Grid" ? "grid" : "target" in lab ? "battery" : null; + var actual = kind ? formatPower(lab.data[i], kind) + : lab.name === "Load" ? powerWords().formatSignedW(lab.data[i]) + : formatW(lab.data[i]); + var target = lab.target && i < lab.target.length && Math.abs(lab.target[i]) > 1 + ? powerWords().formatTarget(lab.target[i], lab.data[i], kind || "") + : ""; + return { actual: actual, target: target }; + } + + // A forecast tooltip row: the plan's battery and grid say their + // direction, predicted Home use keeps its sign like the live value, and + // the other rows are sizes or text as given. + function forecastRowText(lab) { + if (lab.literal) return lab.val; + if (lab.showSign) return formatPower(lab.val, lab.name === "Grid" ? "grid" : "battery"); + if (lab.name === "Load pred") return powerWords().formatSignedW(lab.val); + return formatW(lab.val); + } + + // A driver card's battery row: the reading, its target and how far apart + // they are. + function batteryRowParts(actualW, targetW) { + return { + actual: formatPower(actualW, "battery"), + target: powerWords().formatTarget(targetW, actualW, "battery"), + tracking: powerWords().trackingText(actualW, targetW), + }; } function hasControllableBatteryDrivers(data) { @@ -509,17 +565,13 @@ if (lastFlowStatus) paintEnergyFlow(lastFlowStatus); }; - function energyFlowOpts(data) { - var byDriver = {}; - (data.dispatch || []).forEach(function (d) { - if (d && d.driver) byDriver[d.driver] = Number(d.target_w) || 0; - }); + function energyFlowOpts() { + // The battery bubble prints a size, so the line under it is the + // readings' own word: charging, discharging, idle or observe only. The + // target it used to show there did not fit beside that word; it stays + // on the battery card in the Values view and on the driver card. return { idleW: (typeof window.FTW_FLOW_IDLE_W === "number" && window.FTW_FLOW_IDLE_W) || 42, - batterySub: function (name, d) { - if (d.observe_only) return "observe only"; - return batteryTargetLine(byDriver[name]); - }, }; } @@ -543,7 +595,7 @@ queueFlowReplay(); return; } - var opts = energyFlowOpts(data); opts.live = lastFlowLive; + var opts = energyFlowOpts(); opts.live = lastFlowLive; lastFlowReadings = window.ftwFlowReadingsFromStatus(data, opts); (lastFlowReadings.planets || []).forEach(function (p) { if (p.role !== "ev" || p.placeholder || !p.name) return; @@ -651,7 +703,7 @@ var targetDisp = document.getElementById("grid-target-display"); if (targetDisp) { var t = data.grid_target_w || 0; - targetDisp.textContent = t === 0 ? "target 0" : "target " + formatW(t); + targetDisp.textContent = t === 0 ? "target 0" : "target " + formatPower(t, "grid"); } var slotBadge = document.getElementById("grid-slot-badge"); if (slotBadge) { @@ -663,7 +715,8 @@ // directions so the operator sees the slot's true exposure. var impWh = slot.import_wh; var expWh = slot.export_wh; - slotBadge.textContent = "15m ↑" + formatWhMagnitude(impWh) + " ↓" + formatWhMagnitude(expWh); + // The same arrows as the flow's daily line: ↓ import, ↑ export. + slotBadge.textContent = "15m ↓" + formatWhMagnitude(impWh) + " ↑" + formatWhMagnitude(expWh); slotBadge.className = "grid-slot-badge" + (impWh > expWh + 5 ? " slot-import" : expWh > impWh + 5 ? " slot-export" : ""); } else { @@ -699,7 +752,7 @@ } // Load - loadW.textContent = formatW(data.load_w); + loadW.textContent = powerWords().formatSignedW(data.load_w); var loadDir = document.getElementById("load-dir"); if (loadDir) { loadDir.textContent = data.load_w == null || !isFinite(data.load_w) ? "no data" @@ -836,7 +889,7 @@ // dragged then mouse-released doesn't revert before Save is clicked. if (gridTargetSlider && !gridTargetDirty) { gridTargetSlider.value = data.grid_target_w; - gridTargetValue.textContent = formatW(data.grid_target_w); + gridTargetValue.textContent = formatPower(data.grid_target_w, "grid"); } // Peak import ceiling. Backed by the new peak_import_ceiling_w // (hard rule across modes); 0 = disabled. Falls back to the legacy @@ -971,7 +1024,7 @@ if (bvw) { var sw = rawW; if (Math.abs(sw) < 1) sw = 0; - bvw.textContent = Math.round(sw) + " W"; + bvw.textContent = formatPower(sw, "grid"); bvw.classList.toggle("export", rawW < -1); } } @@ -1104,6 +1157,7 @@ var chartFontTooltip = smallScreen ? "14px monospace" : "10px monospace"; var chartFontTooltipS = smallScreen ? "13px monospace" : "9px monospace"; var chartNowStrokeW = smallScreen ? 2 : 1; + var chartFontSides = smallScreen ? "11px monospace" : "10px monospace"; if (canvas.width !== w * dpr || canvas.height !== h * dpr) { canvas.width = w * dpr; canvas.height = h * dpr; @@ -1449,8 +1503,17 @@ for (var i2 = 0; i2 <= steps; i2++) { var yVal = yMin + (yRange * i2 / steps); var ly = pad.top + plotH - (plotH * i2 / steps); + // Today's totals never go below zero; that strip is only headroom. + if (chartView === "energy" && yVal < 0) continue; ctx.fillText(chartView === "energy" ? yVal.toFixed(1) + " kWh" : formatW(yVal), 2, ly + 4); } + // The labels are sizes; these words say what sits on each side of zero. + if (chartView !== "energy" && yMin < 0 && yMax > 0) { + var zeroLabelY = pad.top + plotH * (1 - (0 - yMin) / yRange); + ctx.font = chartFontSides; + powerWords().drawSides(ctx, pad.left + 6, zeroLabelY, powerWords().SIDES.live, C.dim, C.surface); + ctx.font = chartFontAxis; + } // Time labels ctx.fillStyle = C.muted; @@ -1613,7 +1676,19 @@ var ts = chartHistory.timestamps[i] || 0; var timeStr = ts > 0 ? new Date(ts).toLocaleTimeString() : ""; var lineHeight = 16; + // Wide enough for the longest row: name, value and target. var boxW = 200; + if (chartView !== "energy") { + labels.forEach(function (lab) { + if (i >= lab.data.length) return; + var t = tooltipPowerText(lab, i); + ctx.font = fontTooltip; + var need = 30 + ctx.measureText(lab.name).width + ctx.measureText(t.actual).width; + ctx.font = fontTooltipS; + if (t.target) need += 8 + ctx.measureText(t.target).width; + boxW = Math.max(boxW, Math.ceil(need)); + }); + } var boxH = (labels.length + 1) * lineHeight + 10; // Position tooltip (avoid going off-screen) @@ -1643,16 +1718,16 @@ ctx.fillStyle = C.text; ctx.fillText(lab.data[i].toFixed(2) + " kWh", boxX + boxW - 6, y); } else { - var actual = formatW(lab.data[i]); + var text = tooltipPowerText(lab, i); ctx.fillStyle = C.text; - ctx.fillText(actual, boxX + boxW - 6, y); - // Inline target as dim "(→ -674 W)" so user sees commanded vs actual - // in one glance. Skip when target is 0 to reduce visual noise. - if (lab.target && i < lab.target.length && Math.abs(lab.target[i]) > 1) { - var actualW = ctx.measureText(actual).width; + ctx.fillText(text.actual, boxX + boxW - 6, y); + // Inline target as dim "→ 674 W discharge" so user sees commanded vs + // actual in one glance. + if (text.target) { + var actualW = ctx.measureText(text.actual).width; ctx.fillStyle = C.dim; ctx.font = fontTooltipS; - ctx.fillText("→ " + formatW(lab.target[i]), boxX + boxW - 10 - actualW, y); + ctx.fillText(text.target, boxX + boxW - 10 - actualW, y); ctx.font = fontTooltip; } } @@ -1716,8 +1791,7 @@ ctx.fillText(lab.name, boxX + 18, y); ctx.fillStyle = C.text; ctx.textAlign = "right"; - var val = lab.literal ? lab.val : formatW(lab.val); - ctx.fillText(val, boxX + boxW - 6, y); + ctx.fillText(forecastRowText(lab), boxX + boxW - 6, y); ctx.textAlign = "left"; }); @@ -1786,10 +1860,6 @@ return actions; } - function formatOptionalW(w) { - return w == null ? "—" : formatW(w); - } - // --------------------------------------------------------------------- // V2X manual-command surface. Dashboard driver cards are rendered here // rather than in a framework so the static UI keeps working without a @@ -1847,13 +1917,13 @@ v2xBtn.disabled = true; if (status) { status.className = "v2x-command-status"; - status.textContent = "Sending " + formatW(powerW) + "…"; + status.textContent = "Sending " + formatPower(powerW, "v2x") + "…"; } v2xCommand(v2xName, powerW) .then(function () { if (status) { status.className = "v2x-command-status ok"; - status.textContent = powerW === 0 ? "Stopped" : "Sent " + formatW(powerW); + status.textContent = powerW === 0 ? "Stopped" : "Sent " + formatPower(powerW, "v2x"); } setTimeout(fetchStatus, 600); }) @@ -1967,7 +2037,7 @@ var v2xClass = v2xWVal < -100 ? "stat-ok" : (v2xWVal > 100 ? "stat-warn" : (connected ? "stat-warn" : "stat-dim")); var vehicleSoc = d.v2x_vehicle_soc != null ? formatSoc(d.v2x_vehicle_soc) : "—"; var dcSummary = (d.v2x_dc_w != null || d.v2x_dc_v != null || d.v2x_dc_a != null) - ? formatOptionalW(d.v2x_dc_w) + " · " + + ? (d.v2x_dc_w == null ? "—" : formatPower(d.v2x_dc_w, "v2x")) + " · " + (d.v2x_dc_v != null ? d.v2x_dc_v.toFixed(0) + " V" : "—") + " · " + (d.v2x_dc_a != null ? d.v2x_dc_a.toFixed(1) + " A" : "—") : "—"; @@ -1985,7 +2055,7 @@ body = '
' + ' State' + escHtml(statusLabel) + '' + - ' Power' + formatW(v2xWVal) + '' + + ' Power' + formatPower(v2xWVal, "v2x") + '' + ' Vehicle SoC' + vehicleSoc + '' + ' DC' + escHtml(dcSummary) + '' + ' Session' + escHtml(session) + '' + @@ -2035,21 +2105,24 @@ // Battery target + tracking deviation. Skip if no dispatch (planner // hasn't run) OR this driver has no battery (target meaningless). var batteryRow = - ' Battery' + formatW(batWVal) + ""; + ' Battery' + formatPower(batWVal, "battery") + ""; var disp = (dispatchByDriver || {})[name]; if (disp && d.bat_w != null && !d.observe_only) { - var dev = batWVal - disp.target_w; + // The same gap the words below describe, so a warning colour + // never sits on "on target". + var dev = powerWords().trackingGap(batWVal, disp.target_w); var devClass = Math.abs(dev) > 200 ? "stat-warn" : "stat-dim"; + var row = batteryRowParts(batWVal, disp.target_w); batteryRow = - ' Battery' + formatW(batWVal) + - ' → ' + formatW(disp.target_w) + '' + - ' Δ ' + formatW(dev) + '' + + ' Battery' + row.actual + + ' ' + row.target + '' + + ' ' + row.tracking + '' + ""; } body = '
' + - ' Meter' + formatW(meterW) + "" + + ' Meter' + formatPower(meterW, "grid") + "" + ' PV' + formatW(-pvWVal) + "" + batteryRow + (d.observe_only @@ -2111,7 +2184,7 @@ item.innerHTML = '' + escHtml(d.driver) + "" + "" + - '' + formatW(d.target_w) + "" + + '' + formatPower(d.target_w, "battery") + "" + (d.clamped ? 'CLAMPED' : "") + ""; dispatchList.appendChild(item); @@ -2674,7 +2747,7 @@ // poll skips overwrite while dirty. Mirrors the peak slider pattern. if (gridTargetSlider) { gridTargetSlider.addEventListener("input", function () { - gridTargetValue.textContent = formatW(Number(gridTargetSlider.value)); + gridTargetValue.textContent = formatPower(Number(gridTargetSlider.value), "grid"); gridTargetDirty = true; if (gridTargetSend) gridTargetSend.disabled = false; }); @@ -5264,9 +5337,11 @@ ctx.font = "10px ui-monospace, SFMono-Regular, Menlo, Monaco, monospace"; ctx.textAlign = "right"; ctx.textBaseline = "middle"; - // battery: max charge / max discharge labels at the edges + // battery: max charge / max discharge at the edges, as sizes; the words + // beside the zero line say which side is which. ctx.fillText((batMax / 1000).toFixed(1) + "kW", pad.left - 4, pad.top + 6); - ctx.fillText("-" + (batMax / 1000).toFixed(1) + "kW", pad.left - 4, batMid + (batH / 2) - 6); + ctx.fillText((batMax / 1000).toFixed(1) + "kW", pad.left - 4, batMid + (batH / 2) - 6); + powerWords().drawSides(ctx, pad.left + 4, batMid, powerWords().SIDES.battery, C.dim, C.surface); // SoC: 100% / 0% on the right ctx.textAlign = "left"; ctx.fillText("100%", pad.left + plotW + 4, socTop + 6); diff --git a/web/components/energy-flow-readings.js b/web/components/energy-flow-readings.js index a251506fc..af4f1e30a 100644 --- a/web/components/energy-flow-readings.js +++ b/web/components/energy-flow-readings.js @@ -73,11 +73,9 @@ function placeholderPlanet(partial) { * @param {object} status GET /api/status body * @param {object} [opts] * @param {number} [opts.idleW] - * @param {(name: string, driver: object, batW: number) => string} [opts.batterySub] */ export function flowReadingsFromStatus(status, opts) { const thresholdW = (opts && opts.idleW) || idleW(); - const batterySub = opts && opts.batterySub; const planets = []; const today = (status && status.energy && status.energy.today) || {}; const importWh = num(today.import_wh); @@ -155,10 +153,10 @@ export function flowReadingsFromStatus(status, opts) { if (batW !== null) { const bIdle = isIdle(batW, thresholdW); const soc = num(d.bat_soc); - const defaultSub = d.observe_only === true - ? "observe only" - : bIdle ? "idle" : batW >= 0 ? "charging" : "discharging"; - const sub = batterySub ? batterySub(name, d, batW) : defaultSub; + // The bubble prints a size, so this line says the direction. The + // bubble has no room for "observe only" on the same line; the + // component puts it under the title. + const sub = bIdle ? "idle" : batW >= 0 ? "charging" : "discharging"; const planet = { id: `bat-${name}`, corner: "top-right", title: "BATTERY", role: "battery", name, kw: batW / 1000, toHub: batW < 0, @@ -169,6 +167,7 @@ export function flowReadingsFromStatus(status, opts) { dailyScope: "aggregate", dailyAggregateMembers: batDailyMembers, clickable: d.observe_only !== true, + observeOnly: d.observe_only === true, }; if (online) { live.battery = true; diff --git a/web/components/ftw-energy-flow.js b/web/components/ftw-energy-flow.js index c17acf4b0..4ca98e4c6 100644 --- a/web/components/ftw-energy-flow.js +++ b/web/components/ftw-energy-flow.js @@ -653,12 +653,21 @@ class FtwEnergyFlow extends FtwElement { !p.aggregated && (layerClass === "ind" || aggregateMemberCount > 1) && Math.max(p._groupSize || 1, aggregateMemberCount || 1) > 1; + const grouped = p._groupSize > 1 && !!p.name; return renderCircleNode({ pos: p._pos, - value: p.placeholder ? "—" : fmtKw(p.kw), + // A planet prints its size; the word under it gives the direction, + // because the UI never shows a raw minus. The hub keeps Core's sign + // for Home use (#1768). + value: p.placeholder ? "—" : fmtKw(Math.abs(p.kw)), title: p.title, - nameLabel: p._groupSize > 1 && p.name ? p.name.toUpperCase() : null, - sub: p.sub, + // The title's second line names the device in a group, or says + // that FTW only observes it; a phone's larger title font has room + // for one word. A grouped device keeps its name there and says + // "observe only" under the size instead. + nameLabel: grouped ? p.name.toUpperCase() + : p.observeOnly ? (this._compact ? "OBSERVED" : "OBSERVE ONLY") : null, + sub: grouped && p.observeOnly ? "observe only" : p.sub, color: p.color, soc: p.placeholder ? null : p.soc, chargeLimit: p.placeholder ? null : p.chargeLimit, @@ -1662,8 +1671,9 @@ function renderDailyParts(parts) { } function fmtKw(kw) { - // Input is kilowatts. Sub-kW values render as plain integer watts - // (no decimals) so a "30 W" import or "−12 W" battery trickle stays + // Input is kilowatts: a size for a planet, Home use as Core signs it + // for the hub. Sub-kW values render as plain integer watts (no + // decimals) so a "30 W" import or "12 W" battery trickle stays // visible as the real number. The visual "idle / balanced" // affordances (beam dimming, sub-label text) live on a separate // ±FLOW_IDLE_W threshold; this formatter only handles display. diff --git a/web/diagnose.js b/web/diagnose.js index 0c83ce89b..3aa0ec516 100644 --- a/web/diagnose.js +++ b/web/diagnose.js @@ -601,9 +601,12 @@ ctx.textAlign = 'right'; ctx.fillText(maxPrice.toFixed(0) + 'ö', pad.l - 4, priceY0 + 10); ctx.fillText('0', pad.l - 4, priceY0 + priceH - 2); - ctx.fillText('+' + (maxAbsPower / 1000).toFixed(1) + 'kW', pad.l - 4, powerY0 + 10); + ctx.fillText(powerAxisLabel(maxAbsPower), pad.l - 4, powerY0 + 10); ctx.fillText('0', pad.l - 4, powerMidY + 3); - ctx.fillText('−' + (maxAbsPower / 1000).toFixed(1) + 'kW', pad.l - 4, powerY0 + powerH - 2); + ctx.fillText(powerAxisLabel(-maxAbsPower), pad.l - 4, powerY0 + powerH - 2); + // The power labels are sizes; these words say what sits on each side. + const words = globalThis.ftwPowerWords; + if (words) words.drawSides(ctx, pad.l + 6, powerMidY, words.SIDES.diagnose, C.dim, C.panel); ctx.fillText('100%', pad.l - 4, socY0 + 10); ctx.fillText('0', pad.l - 4, socY0 + socH - 2); @@ -616,6 +619,10 @@ } // ---- Helpers ---- + // The chart's power axis shows sizes, never a raw minus. + function powerAxisLabel(w) { + return (Math.abs(w) / 1000).toFixed(1) + 'kW'; + } function fmtHHMM(ts) { const d = new Date(ts); return d.getHours().toString().padStart(2, '0') + ':' + diff --git a/web/index.html b/web/index.html index 6db9c151d..d65ff6b19 100644 --- a/web/index.html +++ b/web/index.html @@ -996,6 +996,7 @@

Price bars (top of the chart)

+ diff --git a/web/loadpoints.js b/web/loadpoints.js index 031159e0d..47254c283 100644 --- a/web/loadpoints.js +++ b/web/loadpoints.js @@ -43,6 +43,14 @@ return Math.round(w) + ' W'; } + // The plan's battery power as a size and its word ("1.2 kW discharge"), + // never a raw minus; signed, as before, only if power-words.js is missing. + function batteryW(w) { + const words = globalThis.ftwPowerWords; + if (w == null || !isFinite(w)) return '—'; + return words ? words.formatPower(w, 'battery') : fmtW(w); + } + function fmtPct(frac) { if (frac == null || !isFinite(frac)) return '—'; return (frac * 100).toFixed(1) + '%'; @@ -215,7 +223,7 @@ `${fmtPriceOre(a.price_ore)}` + `${fmtW(a.loadpoint_w || 0)}` + `${fmtPct(a.loadpoint_soc)}` + - `${fmtW(a.battery_w)}` + + `${batteryW(a.battery_w)}` + `${escapeHtml(a.reason || '')}` + ''; }).join(''); diff --git a/web/no-raw-minus.test.mjs b/web/no-raw-minus.test.mjs new file mode 100644 index 000000000..3a6777fb4 --- /dev/null +++ b/web/no-raw-minus.test.mjs @@ -0,0 +1,350 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { describe, it } from "node:test"; +import vm from "node:vm"; + +import "./power-words.js"; +import { flowReadingsFromStatus } from "./components/energy-flow-readings.js"; +import { derivePlanBrief } from "./plan-brief.js"; + +// FTW's rule: the UI never shows a raw minus. It shows a size, and a short +// word says the direction: import or export, charge or discharge. Home use +// keeps Core's sign for now (#1768); see formatSignedW. +const words = globalThis.ftwPowerWords; +const read = (path) => readFileSync(new URL(`./${path}`, import.meta.url), "utf8"); + +// One function's source, found by name and closed by matching braces, so a +// test does not hang on whatever happens to follow it in the file. +function fn(source, name) { + const start = source.search(new RegExp(`function ${name}\\(`)); + assert.ok(start >= 0, `function ${name} not found`); + return closed(source, start, source.indexOf("{", start)); +} +function closed(source, start, open) { + let depth = 0; + for (let i = open; i < source.length; i++) { + if (source[i] === "{") depth++; + else if (source[i] === "}" && --depth === 0) return source.slice(start, i + 1); + } + throw new Error("not closed"); +} +function run(code, context = {}) { + vm.createContext(context); + vm.runInContext(code, context); + return context; +} +// The first argument of every fillText call, read with brackets and quotes +// balanced. +function fillTextArgs(source) { + const out = []; + let at = 0; + while ((at = source.indexOf("fillText(", at)) >= 0) { + let i = at + "fillText(".length; + const start = i; + let depth = 0; + let quote = null; + for (; i < source.length; i++) { + const c = source[i]; + if (quote) { + if (c === "\\") i++; + else if (c === quote) quote = null; + } else if (c === "'" || c === '"' || c === "`") quote = c; + else if ("([{".includes(c)) depth++; + else if (")]}".includes(c)) { if (depth === 0) break; depth--; } + else if (c === "," && depth === 0) break; + } + out.push(source.slice(start, i).trim()); + at = i; + } + return out; +} +const NEGATIVE = [-0.4, -5, -10.5, -950, -999.6, -1000, -1234, -12700]; +const noMinus = (text) => assert.doesNotMatch(String(text), /[-−]/, String(text)); +const noArrow = (text) => assert.doesNotMatch(String(text), /[↑↓]/, String(text)); + +describe("power in words", () => { + it("gives any power as a size", () => { + for (const w of NEGATIVE) { + noMinus(words.formatW(w)); + assert.equal(words.formatW(w), words.formatW(-w)); + } + assert.equal(words.formatW(-1234), "1.2 kW"); + assert.equal(words.formatW(null), "—"); + }); + + it("says the direction in a word for both signs, and none near zero", () => { + const cases = [ + [500, "grid", "500 W import"], [-500, "grid", "500 W export"], + [1400, "battery", "1.4 kW charge"], [-1400, "battery", "1.4 kW discharge"], + [3000, "v2x", "3.0 kW charge"], [-3000, "v2x", "3.0 kW discharge"], + [8, "grid", "8 W"], [-8, "battery", "8 W"], [0, "grid", "0 W"], [-1400, "solar", "1.4 kW"], + ]; + for (const [w, kind, want] of cases) { + assert.equal(words.formatPower(w, kind), want, `${w} ${kind}`); + noMinus(words.formatPower(w, kind)); + noArrow(words.formatPower(w, kind)); + } + }); + + it("keeps Core's sign on Home use until a word is chosen for it", () => { + assert.equal(words.formatSignedW(-300), "-300 W"); + assert.equal(words.formatSignedW(-1234), "-1.2 kW"); + assert.equal(words.formatSignedW(900), "900 W"); + assert.equal(words.formatSignedW(-0.3), "0 W"); + }); + + it("names a target's direction only when it differs from the reading's", () => { + assert.equal(words.formatTarget(-1200, -1100, "battery"), "→ 1.2 kW"); + assert.equal(words.formatTarget(800, -1100, "battery"), "→ 800 W charge"); + assert.equal(words.formatTarget(-2000, 5, "grid"), "→ 2.0 kW export"); + }); + + it("measures how far a device is from its target along the target's direction", () => { + assert.equal(words.trackingText(-700, -1000), "300 W short"); + assert.equal(words.trackingText(1300, 1000), "300 W over"); + assert.equal(words.trackingText(-1300, -1000), "300 W over"); + assert.equal(words.trackingText(-1005, -1000), "on target"); + // Going the wrong way is short by the whole distance. + assert.equal(words.trackingText(1300, -1000), "2.3 kW short"); + assert.equal(words.trackingText(1000, -1000), "2.0 kW short"); + assert.equal(words.trackingText(-1000, 1000), "2.0 kW short"); + // A target near zero has no direction; the reading's size is compared. + assert.equal(words.trackingText(-500, 5), "495 W over"); + assert.equal(words.trackingGap(1300, -1000), -2300); + }); + + it("names both sides of every chart in words, with one separator", () => { + for (const [chart, sides] of Object.entries(words.SIDES)) { + for (const text of [sides.above, sides.below]) { + noArrow(text); + assert.doesNotMatch(text, /[/,;]|home/, `${chart}: ${text}`); + } + } + assert.equal(words.SIDES.live.below, "export · discharge · solar"); + assert.equal(words.SIDES.live.above, "import · charge · load · car"); + const drawn = []; + const ctx = { + save() {}, restore() {}, fillRect() { drawn.push("back"); }, measureText: (t) => ({ width: t.length * 5 }), + fillText(text, x, y) { drawn.push([text, y]); }, + }; + words.drawSides(ctx, 10, 100, words.SIDES.battery, "ink", "panel"); + assert.deepEqual(drawn, ["back", ["charge", 93], "back", ["discharge", 107]]); + }); +}); + +describe("the dashboard uses the words where a power goes both ways", () => { + const app = read("app.js"); + const helpers = ["powerWords", "formatW", "formatPower", "liveStatParts", "updateLiveStat", "batteryTargetLine", + "tooltipPowerText", "forecastRowText", "batteryRowParts"].map((name) => fn(app, name)).join("\n"); + const sizesOnly = (() => { + const start = app.indexOf(" var SIZES_ONLY = (function () {"); + assert.ok(start >= 0, "SIZES_ONLY"); + return closed(app, start, app.indexOf("{", start)) + ")();"; + })(); + + function dashboard(context = { ftwPowerWords: words }) { + const els = {}; + // Just enough of an element: a child's text joins its parent's. + const element = () => ({ + textContent: "", className: "", + appendChild(child) { this.textContent += child.textContent; }, + }); + const document = { getElementById: (id) => (els[id] ||= element()), createElement: element }; + return { els, ...run(sizesOnly + "\n" + helpers, { ...context, document }) }; + } + + it("writes the live strip with a word for the grid and the battery", () => { + const d = dashboard(); + d.updateLiveStat("bat", -1400, "is-discharging"); + d.updateLiveStat("grid", -500, "is-export"); + d.updateLiveStat("load", 900, "is-neutral"); + assert.equal(d.els["live-stat-bat"].textContent, "1.4 kW discharge"); + assert.equal(d.els["live-stat-grid"].textContent, "500 W export"); + assert.equal(d.els["live-stat-load"].textContent, "900 W"); + d.updateLiveStat("bat", 1400, "is-charging"); + assert.equal(d.els["live-stat-bat"].textContent, "1.4 kW charge"); + // Home use below zero keeps its sign. + d.updateLiveStat("load", -300, "is-neutral"); + assert.equal(d.els["live-stat-load"].textContent, "-300 W"); + }); + + it("writes the targets, tooltip rows and driver rows with their words", () => { + const d = dashboard(); + assert.equal(d.batteryTargetLine(-1500), "target 1.5 kW discharge"); + assert.deepEqual({ ...d.tooltipPowerText({ name: "Grid", data: [-500] }, 0) }, { actual: "500 W export", target: "" }); + assert.deepEqual({ ...d.tooltipPowerText({ name: "Sungrow", data: [-1100], target: [-1200] }, 0) }, + { actual: "1.1 kW discharge", target: "→ 1.2 kW" }); + assert.deepEqual({ ...d.tooltipPowerText({ name: "Sungrow", data: [-1100], target: [800] }, 0) }, + { actual: "1.1 kW discharge", target: "→ 800 W charge" }); + assert.deepEqual({ ...d.tooltipPowerText({ name: "Easee EV", data: [3000] }, 0) }, { actual: "3.0 kW", target: "" }); + assert.deepEqual({ ...d.tooltipPowerText({ name: "Load", data: [-300] }, 0) }, { actual: "-300 W", target: "" }); + assert.deepEqual({ ...d.batteryRowParts(-700, -1000) }, { actual: "700 W discharge", target: "→ 1.0 kW", tracking: "300 W short" }); + assert.deepEqual({ ...d.batteryRowParts(1300, -1000) }, { actual: "1.3 kW charge", target: "→ 1.0 kW discharge", tracking: "2.3 kW short" }); + // The warning colour follows the same gap as the words. + assert.match(app, /var dev = powerWords\(\)\.trackingGap\(batWVal, disp\.target_w\);/); + }); + + it("writes the forecast tooltip's battery and grid with their words", () => { + const d = dashboard(); + assert.equal(d.forecastRowText({ name: "Battery", val: -1700, showSign: true }), "1.7 kW discharge"); + assert.equal(d.forecastRowText({ name: "Battery", val: 1700, showSign: true }), "1.7 kW charge"); + assert.equal(d.forecastRowText({ name: "Grid", val: -500, showSign: true }), "500 W export"); + assert.equal(d.forecastRowText({ name: "Grid", val: 500, showSign: true }), "500 W import"); + assert.equal(d.forecastRowText({ name: "PV pred", val: 2100 }), "2.1 kW"); + assert.equal(d.forecastRowText({ name: "Load pred", val: -300 }), "-300 W"); + assert.equal(d.forecastRowText({ name: "SoC", val: "64%", literal: true }), "64%"); + }); + + it("still draws sizes if power-words.js did not load", () => { + const d = dashboard({}); + d.updateLiveStat("bat", -1400, "is-discharging"); + assert.equal(d.els["live-stat-bat"].textContent, "1.4 kW"); + assert.equal(d.batteryTargetLine(-1500), "target 1.5 kW"); + assert.equal(d.forecastRowText({ name: "Grid", val: -500, showSign: true }), "500 W"); + }); + + it("never prints a signed grid or battery power as a bare size", () => { + // Each of these can be negative and has no word beside it on screen. + // (The V2X confirm prints formatW(powerW) after the word "Discharge".) + // (The forecast rows go through forecastRowText, tested above.) + const signed = ["batWVal", "meterW", "d.target_w", "disp.target_w", "v2xWVal", "sw", "dev", + "data.grid_target_w", "Number(gridTargetSlider.value)", "targetW", "t", "d.v2x_dc_w"]; + for (const expr of signed) { + assert.ok(!app.includes(`formatW(${expr})`), `formatW(${expr}) drops the direction`); + } + // Nor as a hand-made "… W", the way the per-phase watts used to print. + // A size taken with Math.abs first is fine. + const handMade = [...app.matchAll(/Math\.round\(([^;\n]*?)\)\s*\+\s*" ?W"/g)] + .map((m) => m[1]).filter((arg) => !/^(abs|Math\.abs\(.*\))$/.test(arg)); + assert.deepEqual(handMade, []); + // The grid and battery cards print the size and set the word on the next lines. + for (const [value, word] of [["data.grid_w", "gridDir"], ["data.bat_w", "batDir"]]) { + const at = app.indexOf(`formatW(${value})`); + assert.ok(at > 0 && app.slice(at, at + 400).includes(`${word}.textContent`), value); + assert.equal(app.indexOf(`formatW(${value})`, at + 1), -1, `${value} is printed bare elsewhere`); + } + assert.ok(app.includes('bvw.textContent = formatPower(sw, "grid");'), "per-phase watts"); + // The live chart's slot badge uses the flow's arrows: ↓ import, ↑ export. + assert.ok(app.includes('"15m ↓" + formatWhMagnitude(impWh) + " ↑" + formatWhMagnitude(expWh)')); + }); + + it("words the plan's battery column on the charger card", () => { + const loadpoints = read("loadpoints.js"); + const { batteryW } = run(fn(loadpoints, "fmtW") + "\n" + fn(loadpoints, "batteryW"), { ftwPowerWords: words }); + assert.equal(batteryW(-1200), "1.2 kW discharge"); + assert.equal(batteryW(900), "900 W charge"); + assert.match(loadpoints, /\$\{batteryW\(a\.battery_w\)\}<\/td>/); + }); +}); + +describe("charts show sizes, never a raw minus", () => { + it("labels the plan and diagnose power axes as sizes", () => { + for (const path of ["plan.js", "diagnose.js"]) { + const { powerAxisLabel } = run(fn(read(path), "powerAxisLabel")); + assert.equal(powerAxisLabel(-12700), "12.7kW", path); + for (const w of NEGATIVE) noMinus(powerAxisLabel(w)); + } + }); + + it("knows every text a chart draws, and none of them starts with a sign", () => { + // Each fillText on these canvases, by its first argument. A new one + // fails here until someone says why it cannot start with a sign. + const known = { + "app.js": { + '"waiting for data..."': "words", + 'chartView === "energy" ? yVal.toFixed(1) + " kWh" : formatW(yVal)': "a size; the energy view skips ticks below zero", + 'chartRange + " ago"': "a range", + '"now"': "words", + ageStr: "live or an age", + timeStr: "a clock", + "lab.name": "a series name", + 'lab.data[i].toFixed(2) + " kWh"': "today's totals, never below zero", + "text.actual": "tooltipPowerText, tested above", + "text.target": "tooltipPowerText, tested above", + 'hh + " predicted"': "a clock", + "forecastRowText(lab)": "tested above; its literal rows are SoC and price", + reason: "the plan's reason", + '"Battery measurements unknown"': "words", + '(batMax / 1000).toFixed(1) + "kW"': "the largest battery size, never below zero", + '"100%"': "words", '"0%"': "words", '"24h ago"': "words", + }, + "plan.js": { + "'No devices yet. Add one in Settings.'": "words", + "fmtHHMM(t)": "a clock", + "toDisplay(priceMax, state.currency).toFixed(0) + ' ' + priceUnit.axis": "the highest price", + "toDisplay(priceMin, state.currency).toFixed(0)": "the lowest price: a price below zero keeps its sign", + "'Price'": "words", "'Power'": "words", "'Battery'": "words", "'SoC'": "words", + "powerAxisLabel(pMagMax)": "a size", "powerAxisLabel(-pMagMax)": "a size", + "'100%'": "words", "'0%'": "words", + }, + "diagnose.js": { + "maxPrice.toFixed(0) + 'ö'": "the highest price, at least 1", + "'0'": "zero", + "powerAxisLabel(maxAbsPower)": "a size", "powerAxisLabel(-maxAbsPower)": "a size", + "'100%'": "words", + "fmtHHMM(slots[i].slot_start_ms)": "a clock", + }, + "power-words.js": { text: "the side words, checked above" }, + }; + for (const [path, allowed] of Object.entries(known)) { + for (const arg of fillTextArgs(read(path))) { + assert.ok(arg in allowed, `${path}: fillText(${arg}) is new; say why it cannot start with a sign`); + assert.doesNotMatch(arg, /^(['"`])[-−+]/, `${path}: ${arg}`); + } + } + }); + + it("draws the words beside zero on every chart with a lower side", () => { + const app = read("app.js"); + assert.match(app, /drawSides\(ctx, pad\.left \+ 6, zeroLabelY, powerWords\(\)\.SIDES\.live,/); + assert.match(app, /drawSides\(ctx, pad\.left \+ 4, batMid, powerWords\(\)\.SIDES\.battery,/); + const plan = read("plan.js"); + assert.match(plan, /drawSides\(ctx, pad\.l \+ 6, powerYCenter, globalThis\.ftwPowerWords\.SIDES\.plan,/); + // After the battery bars and the SoC line, so they cannot paint over it. + assert.ok(plan.indexOf("SIDES.plan") > plan.indexOf("ctx.fillText('SoC'"), "plan side words drawn last"); + assert.match(read("diagnose.js"), /words\.drawSides\(ctx, pad\.l \+ 6, powerMidY, words\.SIDES\.diagnose,/); + // Loaded before the dashboard script that reads it. + const html = read("index.html"); + assert.ok(html.indexOf('src="/power-words.js"') < html.indexOf('src="/app.js')); + }); + + it("prints the flow's planets as sizes and keeps the hub's sign", () => { + const flow = read("components/ftw-energy-flow.js"); + assert.match(flow, /value: p\.placeholder \? "—" : fmtKw\(Math\.abs\(p\.kw\)\),/); + assert.match(flow, /fmtKw\(load\)/); + const { fmtKw } = run(fn(flow, "fmtKw")); + assert.equal(fmtKw(Math.abs(-1.2)), "1.20 kW"); + assert.equal(fmtKw(-0.3), "-300 W"); + }); + + it("keeps the direction word under an observed battery and says it is observed", () => { + const battery = (batW, observeOnly) => flowReadingsFromStatus({ + drivers: { battery: { bat_w: batW, bat_soc: 0.5, observe_only: observeOnly, status: "ok" } }, + }).planets.find((p) => p.role === "battery"); + assert.equal(battery(-1500, true).sub, "discharging"); + assert.equal(battery(1500, true).sub, "charging"); + assert.equal(battery(0, true).sub, "idle"); + assert.equal(battery(-1500, true).observeOnly, true); + assert.equal(battery(-1500, false).observeOnly, false); + // "Observe only" and the direction do not fit on one line of the + // bubble, so the component puts "observe only" under the title. + const flow = read("components/ftw-energy-flow.js"); + assert.match(flow, /p\.observeOnly \? \(this\._compact \? "OBSERVED" : "OBSERVE ONLY"\) : null/); + assert.match(flow, /sub: grouped && p\.observeOnly \? "observe only" : p\.sub,/); + }); + + it("says the direction in the plan tooltip and brief", () => { + const plan = read("plan.js"); + assert.match(plan, /\$\{\(Math\.abs\(a\.battery_w\) \/ 1000\)\.toFixed\(1\)\} kW \(\$\{dir\}\)/); + assert.match(plan, /\$\{globalThis\.ftwPowerWords\.formatPower\(a\.grid_w, 'grid'\)\}<\/b>/); + const now = Date.now(); + const brief = derivePlanBrief({ + enabled: true, + plan: { actions: [{ slot_start_ms: now, slot_len_min: 15, battery_w: -1800, loadpoint_w: 0, pv_limit_w: 0 }] }, + status: { mode: "planner_passive_arbitrage", dispatch: [{ driver: "battery", clamped: true, target_w: -1800 }] }, + now, + }); + assert.match(brief.constraint, /Safety adjusted battery to 1\.8 kW discharge/); + noMinus(brief.constraint); + }); +}); diff --git a/web/peak-import-ceiling.test.mjs b/web/peak-import-ceiling.test.mjs index c13b3c80b..fcb4a810e 100644 --- a/web/peak-import-ceiling.test.mjs +++ b/web/peak-import-ceiling.test.mjs @@ -9,6 +9,8 @@ import vm from "node:vm"; // ceiling and nothing this browser stored. const source = readFileSync(new URL("./app.js", import.meta.url), "utf8"); +// index.html loads the shared power words before app.js. +const powerWords = readFileSync(new URL("./power-words.js", import.meta.url), "utf8"); function element(id) { const listeners = new Map(); @@ -100,6 +102,7 @@ function rig(status) { window.localStorage = storage; sandbox.globalThis = sandbox; vm.createContext(sandbox); + vm.runInContext(powerWords, sandbox); vm.runInContext(source, sandbox); const settle = () => new Promise((resolve) => setImmediate(resolve)); diff --git a/web/plan-brief.js b/web/plan-brief.js index 4170c09d6..1d126b971 100644 --- a/web/plan-brief.js +++ b/web/plan-brief.js @@ -1,5 +1,9 @@ import { plannedEVWatts } from "./ev-plan.js"; import { fillPlanSoC } from "./plan-soc.js"; +import "./power-words.js"; + +// The same size-and-word rule as the rest of the page. +const powerWords = globalThis.ftwPowerWords; function formatClock(tsMs) { const date = new Date(tsMs); @@ -49,13 +53,6 @@ function actionLabel(action) { return "Keep the battery steady"; } -function briefPower(watts) { - const value = Number(watts) || 0; - return Math.abs(value) >= 1000 - ? `${(value / 1000).toFixed(1)} kW` - : `${Math.round(value)} W`; -} - function fractionOrNull(value) { if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1.001) return null; return Math.min(1, value); @@ -321,7 +318,7 @@ export function derivePlanBrief({ let constraint = cautionOff ? cautionCopy : "No active safety adjustment"; if (clamps.length) { const adjusted = clamps - .map((dispatch) => `${dispatch.driver || "device"} to ${briefPower(dispatch.target_w)}`) + .map((dispatch) => `${dispatch.driver || "device"} to ${powerWords.formatPower(Number(dispatch.target_w) || 0, "battery")}`) .join(", "); constraint = `Safety adjusted ${adjusted} to stay within battery or site limits`; } else if (stale) { diff --git a/web/plan.js b/web/plan.js index cd486cecc..d4c3ab0d0 100644 --- a/web/plan.js +++ b/web/plan.js @@ -4,6 +4,7 @@ import { plannedEVWatts, createChargingTimeline } from "./ev-plan.js"; // the middle, SoC + PV line on bottom. Refreshes every 30s. import { derivePlanBrief, unavailablePlannerCopy } from "./plan-brief.js"; +import "./power-words.js"; import { forecastMargins, evShortfallWh } from "./plan-forecast.js"; import { fillPlanSoC } from "./plan-soc.js"; import { setActiveCurrency, toDisplay, unitFor } from "./components/price-units.js"; @@ -69,6 +70,12 @@ import { return w; } + // The power axis shows sizes, never a raw minus; the words beside the + // centre line say what sits on each side. + function powerAxisLabel(w) { + return (Math.abs(w) / 1000).toFixed(1) + 'kW'; + } + // Horizon controls the x-axis bounds; mirrors the price chart's // 3-position pill so operators have a consistent affordance across // both charts. Persisted in localStorage so a user who prefers @@ -709,17 +716,10 @@ import { ctx.stroke(); ctx.fillStyle = C.dim; ctx.textAlign = 'right'; - ctx.fillText('+' + (pMagMax / 1000).toFixed(1) + 'kW', pad.l - 6, powerY(pMagMax) + 4); - ctx.fillText('−' + (pMagMax / 1000).toFixed(1) + 'kW', pad.l - 6, powerY(-pMagMax) + 4); + ctx.fillText(powerAxisLabel(pMagMax), pad.l - 6, powerY(pMagMax) + 4); + ctx.fillText(powerAxisLabel(-pMagMax), pad.l - 6, powerY(-pMagMax) + 4); ctx.textAlign = 'left'; - // "Power" heading + tiny sign-convention legend so readers don't have - // to remember that positive means "into the site". Placed just below - // the heading at lower opacity to read as a subtitle. ctx.fillText('Power', pad.l + 4, powerY0 + 12); - ctx.fillStyle = C.muted; - ctx.font = '9px system-ui, sans-serif'; - ctx.fillText('+ import / charge − export / discharge', pad.l + 40, powerY0 + 12); - ctx.font = '11px system-ui, sans-serif'; // Skip every battery-related draw layer (action band, bars, SoC // line + axis labels) when the site has no battery reporter. @@ -796,6 +796,12 @@ import { ctx.fillText('SoC', pad.l + 4, socY0 + 12); } + // The axis labels are sizes; these words say what sits on each side. + // Drawn after the bars and the SoC line, so nothing paints over them. + ctx.font = '9px system-ui, sans-serif'; + globalThis.ftwPowerWords.drawSides(ctx, pad.l + 6, powerYCenter, globalThis.ftwPowerWords.SIDES.plan, C.dim, C.panel); + ctx.font = '11px system-ui, sans-serif'; + // ---- Summary ---- // Structured as labeled pieces with `title=` tooltips so every number // in the header is self-explanatory on hover. Was a single flat string @@ -1027,11 +1033,10 @@ import { } if (a.battery_w != null) { const dir = a.battery_w > 100 ? 'charge' : a.battery_w < -100 ? 'discharge' : 'idle'; - lines.push(`
Battery${(a.battery_w / 1000).toFixed(1)} kW (${dir})
`); + lines.push(`
Battery${(Math.abs(a.battery_w) / 1000).toFixed(1)} kW (${dir})
`); } if (a.grid_w != null) { - const gdir = a.grid_w > 0 ? 'import' : 'export'; - lines.push(`
Grid${(Math.abs(a.grid_w) / 1000).toFixed(1)} kW ${gdir}
`); + lines.push(`
Grid${globalThis.ftwPowerWords.formatPower(a.grid_w, 'grid')}
`); } const endSoc = socPercent(a.soc); if (endSoc != null) lines.push(`
SoC (end)${endSoc.toFixed(0)}%
`); diff --git a/web/power-words.js b/web/power-words.js new file mode 100644 index 000000000..11cca77c6 --- /dev/null +++ b/web/power-words.js @@ -0,0 +1,119 @@ +// Power in words. The UI never shows a raw minus: it shows a size, and a +// short word says which way the power goes. Above zero sits what adds to +// the grid meter's reading (import, charge, the load, the car); below sits +// what takes away from it (export, discharge, solar). +// +// One file for every page: index.html loads it as a plain script before +// app.js, and modules import it for its side effect. It has no import or +// export of its own, so both ways read the same globalThis.ftwPowerWords. +(function (root) { + "use strict"; + + // The only direction words, as the phone app uses them. + var WORDS = { + grid: ["import", "export"], + battery: ["charge", "discharge"], + v2x: ["charge", "discharge"], + }; + // Within this of zero a power has no direction worth a word. + var QUIET_W = 10; + + // The size of a power: "500 W", "1.2 kW". Never signed. + function formatW(w) { + if (w == null || !isFinite(w)) return "—"; + var abs = Math.abs(w); + if (abs >= 1000) return (abs / 1000).toFixed(1) + " kW"; + return Math.round(abs) + " W"; + } + + // Home use, signed as Core sends it. A negative value is Core saying it + // cannot see some production (#1768), not ordinary use, so it keeps its + // sign until a word is chosen for it. + function formatSignedW(w) { + if (w == null || !isFinite(w)) return "—"; + return (w < 0 && formatW(w) !== "0 W" ? "-" : "") + formatW(w); + } + + // "import" or "export" for the grid, "charge" or "discharge" for a + // battery or a two-way charger; "" near zero or for anything else. + function directionWord(w, kind) { + var words = WORDS[kind]; + if (!words || w == null || !isFinite(w) || Math.abs(w) <= QUIET_W) return ""; + return w > 0 ? words[0] : words[1]; + } + + // A two-way power as a size and its word: "1.4 kW discharge". + function formatPower(w, kind) { + var word = directionWord(w, kind); + return formatW(w) + (word ? " " + word : ""); + } + + // A target beside a reading: its size, and its word only when it goes + // the other way from the reading, so "→ 1.2 kW" rather than repeating + // "discharge". + function formatTarget(targetW, actualW, kind) { + var word = directionWord(targetW, kind); + return "→ " + (word && word !== directionWord(actualW, kind) ? formatPower(targetW, kind) : formatW(targetW)); + } + + // How far a device is from its target: "300 W short" when it moves less + // power than asked, "300 W over" when more. Measured along the target's + // direction, so a battery that charges against a discharge target is + // short by the whole distance. A target near zero has no direction, and + // the reading's size is compared with it instead. + function trackingGap(actualW, targetW) { + var actual = Number(actualW) || 0; + var target = Number(targetW) || 0; + return Math.abs(target) > QUIET_W + ? Math.sign(target) * (actual - target) + : Math.abs(actual) - Math.abs(target); + } + function trackingText(actualW, targetW) { + var gap = trackingGap(actualW, targetW); + if (Math.abs(gap) <= QUIET_W) return "on target"; + return formatW(gap) + (gap < 0 ? " short" : " over"); + } + + // What each chart draws above and below zero, where its axis labels are + // sizes. One separator everywhere. + var SIDES = { + live: { above: "import · charge · load · car", below: "export · discharge · solar" }, + battery: { above: "charge", below: "discharge" }, + plan: { above: "charge · load · car", below: "discharge · solar" }, + diagnose: { above: "import · charge · car", below: "export · discharge · solar" }, + }; + + // Write a chart's two sides beside its zero line, at x. Each word sits on + // a backing in the panel colour, so a series line under it cannot make it + // unreadable. The caller sets the font. + function drawSides(ctx, x, zeroY, sides, ink, panel) { + var rows = [[sides.above, zeroY - 7], [sides.below, zeroY + 7]]; + ctx.save(); + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + for (var i = 0; i < rows.length; i++) { + var text = rows[i][0]; + var y = rows[i][1]; + var w = ctx.measureText(text).width; + ctx.globalAlpha = 0.85; + ctx.fillStyle = panel; + ctx.fillRect(x - 3, y - 6, w + 6, 12); + ctx.globalAlpha = 1; + ctx.fillStyle = ink; + ctx.fillText(text, x, y); + } + ctx.restore(); + } + + root.ftwPowerWords = { + formatW: formatW, + formatSignedW: formatSignedW, + directionWord: directionWord, + formatPower: formatPower, + formatTarget: formatTarget, + trackingGap: trackingGap, + trackingText: trackingText, + SIDES: SIDES, + drawSides: drawSides, + }; +})(globalThis);