From 0ab7091e968ec5316c5ef07138ff507bfab307e6 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 25 Oct 2026 19:18:34 +0100 Subject: [PATCH 1/2] feat(web): the charts take the iOS app's palette The canvases and chart palettes that kept raw hex after #1857 now read colour roles. window.ftwThemeColors gains the energy and state roles (solar, stores, gives, grid, car, live, alarm), and app.js's chart colours gain the same, so every canvas draws in the theme it shows. - Plan: prices are ink by tercile (32/50/80 %), tariff and VAT fainter ink3. The battery stores in cyan and gives in orange, the car is violet, the home is ink, the now line is ink. Plan marks draw at 75 %, as on the phone. - History: home use is ink, solar amber, and the grid one pink both ways, so export hangs below the zero line. - Live chart and 24 h battery chart: grid, solar, home, every battery in the storing hue and every car in violet, each a step fainter when a site has several. BATTERY_PALETTE and EV_PALETTE are gone. The 24 h chart redraws when the theme changes. - Diagnose chart, heating charts (ink levels told apart by dash), the car plan timeline and the legends follow. - style.css: diagnostics reasons, driver warning card, status bar, setup banner, fuse bars, plan tooltip and the remaining white overlays move to roles. Scrims and shadows stay black. Co-Authored-By: Claude Opus 5.5 --- .changeset/ios-palette-charts.md | 5 + web/app.css | 3 +- web/app.js | 235 ++++++++++++++----------------- web/chart-palette.test.mjs | 98 +++++++++++++ web/diagnose.js | 47 ++++--- web/energy-history.js | 63 ++++++--- web/heating.js | 49 ++++--- web/index.html | 36 ++--- web/plan.js | 123 +++++++++------- web/style.css | 139 +++++++++--------- web/theme-runtime.js | 23 ++- web/theme-runtime.test.mjs | 46 +++++- 12 files changed, 520 insertions(+), 347 deletions(-) create mode 100644 .changeset/ios-palette-charts.md create mode 100644 web/chart-palette.test.mjs diff --git a/.changeset/ios-palette-charts.md b/.changeset/ios-palette-charts.md new file mode 100644 index 000000000..f6c758c05 --- /dev/null +++ b/.changeset/ios-palette-charts.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +The dashboard's charts now use the phone app's colours too. On the Plan, prices are shades of ink, the battery is cyan when it stores power and orange when it gives power, the car is violet and the home is ink. History draws grid export below the zero line in the grid's one pink. The live chart, the 24-hour battery chart, the heating charts, the car plan and the status marks in device cards and settings follow, and every chart redraws when the theme changes. diff --git a/web/app.css b/web/app.css index 82a81b6f7..ae633ca27 100644 --- a/web/app.css +++ b/web/app.css @@ -3123,7 +3123,7 @@ body.ftw-app .twin-profile-toggle button:focus-visible { .ev-charge-levels > div { display: flex; flex-direction: column; gap: 5px; } .ev-charge-levels span, .ev-charge-levels small { font-size: .78rem; color: var(--text-dim); } .ev-charge-levels strong { font-size: 1.8rem; font-weight: 600; line-height: 1.2; } -.ev-timeline { --ev-plan-color: #22d3ee; margin: 16px 0; padding: 14px; border: 1px solid var(--line); border-radius: 10px; } +.ev-timeline { --ev-plan-color: var(--violet); margin: 16px 0; padding: 14px; border: 1px solid var(--line); border-radius: 10px; } .ev-timeline-heading { display: flex; justify-content: space-between; gap: 12px; font-size: .85rem; } .ev-timeline-heading > span { color: var(--text-dim); white-space: nowrap; } .ev-plan-track { position: relative; height: 28px; margin-top: 14px; border-radius: 5px; overflow: hidden; background: color-mix(in srgb, var(--line) 65%, transparent); } @@ -3144,5 +3144,4 @@ body.ftw-app .twin-profile-toggle button:focus-visible { .ev-plan-ticks > span:first-child { text-align: left; } .ev-plan-ticks > span:last-child { text-align: right; } .ev-plan-ticks small { display: block; font: inherit; margin-top: 3px; } -[data-theme="light"] .ev-timeline { --ev-plan-color: #0891b2; } .ev-charge-levels[hidden] { display: none; } diff --git a/web/app.js b/web/app.js index 6903f25b1..efab930dc 100644 --- a/web/app.js +++ b/web/app.js @@ -120,35 +120,24 @@ var chartEVs = {}; var statusDisplayState = {}; - // Resolve a CSS custom property off :root at call time. Lazy on - // purpose: a runtime theme toggle rewrites the oklch values, so we - // re-read every paint rather than caching at module load. - function cssVar(name) { - try { - var v = getComputedStyle(document.documentElement) - .getPropertyValue(name).trim(); - return v || null; - } catch (_) { - return null; - } + // Colour is energy: every battery takes the storing hue and every car the + // car's. A site with several draws each one a step fainter, in name order, + // so their lines still read apart; the legend names them. Canvas strokes + // read the resolved role; legend swatches keep var() so they follow the + // theme. + var SERIES_STEPS = [1, 0.72, 0.5, 0.34]; + function seriesStep(group, name) { + var i = Object.keys(group).sort().indexOf(name); + return SERIES_STEPS[Math.max(0, i) % SERIES_STEPS.length]; + } + function stepSwatch(role, step) { + return "color-mix(in srgb, var(" + role + ") " + Math.round(step * 100) + "%, transparent)"; } - - // Deterministic color palette for battery series — each driver gets a - // stable color based on name hash so reload is consistent. - var BATTERY_PALETTE = [ - "#f59e0b", "#8b5cf6", "#ec4899", "#06b6d4", - "#eab308", "#14b8a6", "#f43f5e", "#a855f7", - ]; function batteryColor(name) { - // Route the "pixii" driver through the theme's --violet token so - // it visibly separates from "laddning bil" (which hashes to the - // same purple slot in EV_PALETTE). Other names fall through. - if (name === "pixii") return cssVar('--violet') || BATTERY_PALETTE[7]; - var h = 0; - for (var i = 0; i < name.length; i++) { - h = ((h << 5) - h + name.charCodeAt(i)) | 0; - } - return BATTERY_PALETTE[Math.abs(h) % BATTERY_PALETTE.length]; + return hexAlpha(chartColors().stores, seriesStep(chartBatteries, name)); + } + function batterySwatch(name) { + return stepSwatch("--cyan", seriesStep(chartBatteries, name)); } // A driver name → presentation label. Capitalize first letter so // "pixii" → "Pixii"; everything else passes through as-is. @@ -171,43 +160,37 @@ // Append a legend item for any newly-discovered battery driver. Uses // the same markup as the static legend entries so the click handler - // (delegated on #chart-legend) picks them up automatically. - function syncBatteryLegend() { + // (delegated on #chart-legend) picks them up automatically. A new name + // can shift the others' steps, so every swatch is set again. + function syncSeriesLegend(group, prefix, swatchFor, labelFor) { var host = document.getElementById("chart-legend"); if (!host) return; - Object.keys(chartBatteries).forEach(function (name) { - var key = "bat:" + name; - if (host.querySelector('[data-toggle="' + cssEscape(key) + '"]')) return; - var span = document.createElement("span"); - span.className = "legend-item"; - span.dataset.toggle = key; - if (legendHidden[key]) span.classList.add("legend-off"); - var swatch = document.createElement("span"); - swatch.className = "legend-color"; - swatch.style.background = batteryColor(name); - span.appendChild(swatch); - span.appendChild(document.createTextNode(" " + batteryLabel(name))); - host.appendChild(span); + Object.keys(group).forEach(function (name) { + var key = prefix + name; + var span = host.querySelector('[data-toggle="' + cssEscape(key) + '"]'); + if (!span) { + span = document.createElement("span"); + span.className = "legend-item"; + span.dataset.toggle = key; + if (legendHidden[key]) span.classList.add("legend-off"); + var swatch = document.createElement("span"); + swatch.className = "legend-color"; + span.appendChild(swatch); + span.appendChild(document.createTextNode(" " + labelFor(name))); + host.appendChild(span); + } + span.querySelector(".legend-color").style.background = swatchFor(name); }); } + function syncBatteryLegend() { + syncSeriesLegend(chartBatteries, "bat:", batterySwatch, batteryLabel); + } - // EV chargers use a distinct palette (magenta/fuchsia family) so they - // can't be confused with batteries (amber/purple) or PV (green) in - // the live chart. Deterministic hash → stable color across reloads. - var EV_PALETTE = [ - "#ec4899", "#d946ef", "#f97316", "#0ea5e9", - "#84cc16", "#f43f5e", "#a855f7", "#14b8a6", - ]; function evColor(name) { - // Route "laddning bil" through the theme's --cyan token so it - // doesn't collide with the Pixii battery purple (both hash to - // the same purple slot in their respective palettes). - if (name === "laddning bil") return cssVar('--cyan') || EV_PALETTE[6]; - var h = 0; - for (var i = 0; i < name.length; i++) { - h = ((h << 5) - h + name.charCodeAt(i)) | 0; - } - return EV_PALETTE[Math.abs(h) % EV_PALETTE.length]; + return hexAlpha(chartColors().car, seriesStep(chartEVs, name)); + } + function evSwatch(name) { + return stepSwatch("--violet", seriesStep(chartEVs, name)); } function evLabel(name) { if (!name) return name; @@ -222,22 +205,7 @@ return slot; } function syncEVLegend() { - var host = document.getElementById("chart-legend"); - if (!host) return; - Object.keys(chartEVs).forEach(function (name) { - var key = "ev:" + name; - if (host.querySelector('[data-toggle="' + cssEscape(key) + '"]')) return; - var span = document.createElement("span"); - span.className = "legend-item"; - span.dataset.toggle = key; - if (legendHidden[key]) span.classList.add("legend-off"); - var swatch = document.createElement("span"); - swatch.className = "legend-color"; - swatch.style.background = evColor(name); - span.appendChild(swatch); - span.appendChild(document.createTextNode(" " + evLabel(name))); - host.appendChild(span); - }); + syncSeriesLegend(chartEVs, "ev:", evSwatch, evLabel); } // Minimal CSS.escape polyfill (legend keys contain ':'). function cssEscape(s) { return String(s).replace(/[^a-zA-Z0-9_-]/g, function(c) { return "\\" + c; }); } @@ -1145,11 +1113,11 @@ if (chartView === "energy") { function toKwh(arr) { return arr.map(function(x){ return x == null ? null : x / 1000; }); } series = [ - { data: toKwh(chartHistory.e_import), color: "#ef4444", width: 2, dash: [], name: "Import", fill: true }, - { data: toKwh(chartHistory.e_export), color: "#22c55e", width: 2, dash: [], name: "Export", fill: true }, - { data: toKwh(chartHistory.e_pv), color: "#10b981", width: 2, dash: [], name: "PV", fill: true }, - { data: toKwh(chartHistory.e_charged), color: "#3b82f6", width: 2, dash: [], name: "Charged", fill: false }, - { data: toKwh(chartHistory.e_discharged), color: "#f59e0b", width: 2, dash: [], name: "Discharged", fill: false }, + { data: toKwh(chartHistory.e_import), color: C.grid, width: 2, dash: [], name: "Import", fill: true }, + { data: toKwh(chartHistory.e_export), color: C.grid, width: 2, dash: [], name: "Export", fill: true }, + { data: toKwh(chartHistory.e_pv), color: C.solar, width: 2, dash: [], name: "PV", fill: true }, + { data: toKwh(chartHistory.e_charged), color: C.stores, width: 2, dash: [], name: "Charged", fill: false }, + { data: toKwh(chartHistory.e_discharged), color: C.gives, width: 2, dash: [], name: "Discharged", fill: false }, { data: toKwh(chartHistory.e_load), color: C.load, width: 2, dash: [], name: "Load", fill: false }, ]; } else { @@ -1157,8 +1125,8 @@ smoothedPVSeries = smoothSeriesForChart(chartHistory.pv, chartHistory.timestamps, chartSmoothMs); smoothedLoadSeries = smoothSeriesForChart(chartHistory.load, chartHistory.timestamps, chartSmoothMs); series = [ - { data: smoothedGridSeries, color: "#ef4444", width: 2, dash: [], name: "Grid", fill: true, toggle: "grid" }, - { data: smoothedPVSeries, color: "#22c55e", width: 2, dash: [], name: "PV", fill: true, toggle: "pv" }, + { data: smoothedGridSeries, color: C.grid, width: 2, dash: [], name: "Grid", fill: true, toggle: "grid" }, + { data: smoothedPVSeries, color: C.solar, width: 2, dash: [], name: "PV", fill: true, toggle: "pv" }, { data: smoothedLoadSeries, color: C.load, width: 1.5, dash: [], name: "Load", fill: false, toggle: "load" }, ]; // Append one actual/target pair per discovered battery driver. @@ -1244,14 +1212,14 @@ // it's immediately obvious what's measured vs predicted. var xNowShade = pad.left + plotW * (now - windowStart) / totalMs; if (xNowShade < pad.left + plotW) { - ctx.fillStyle = "rgba(251,191,36,0.06)"; // warm amber + ctx.fillStyle = hexAlpha(C.text, 0.04); // a trace of ink ctx.fillRect(xNowShade, pad.top, pad.left + plotW - xNowShade, plotH); } // Grid lines (drawn inside clip so they only appear in the plot area). // Walk yMin..yMax in yStep increments so every line lands on a round // number — that's what lets the y-axis labels stay readable. - ctx.strokeStyle = C.grid; + ctx.strokeStyle = C.line; ctx.lineWidth = 0.5; ctx.font = chartFontAxis; var steps = Math.round(yRange / yStep); @@ -1367,13 +1335,14 @@ ctx.stroke(); ctx.setLineDash([]); }; - if (!legendHidden.pv_fc) drawForecast("pv_w", "#86efac", lastActualPV); - if (!legendHidden.load_fc) drawForecast("load_w", "#fde68a", lastActualLoad); + // A forecast is a plan's mark: its hue, weaker than live. + if (!legendHidden.pv_fc) drawForecast("pv_w", hexAlpha(C.solar, 0.75), lastActualPV); + if (!legendHidden.load_fc) drawForecast("load_w", hexAlpha(C.load, 0.75), lastActualLoad); } // ---- Now-line separator (between past actuals and future forecast) ---- var xNow = tsToX(now); - ctx.strokeStyle = "rgba(251,191,36,0.75)"; + ctx.strokeStyle = hexAlpha(C.text, 0.75); ctx.lineWidth = 1.2; ctx.setLineDash([4, 4]); ctx.beginPath(); @@ -1381,8 +1350,8 @@ ctx.lineTo(xNow, pad.top + plotH); ctx.stroke(); ctx.setLineDash([]); - // No in-canvas "now" / "predicted →" labels — the amber shaded - // band + dashed vertical divider already communicate the boundary, + // No in-canvas "now" / "predicted →" labels — the shaded band + + // dashed vertical divider already communicate the boundary, // and the "now" text on the x-axis keeps the anchor obvious. ctx.restore(); @@ -1436,7 +1405,7 @@ // where present is. Stroke width bumps on small screens so the // marker stays visible alongside the larger axis labels. var nowX = pad.left + plotW; - ctx.strokeStyle = C.grid; + ctx.strokeStyle = C.line; ctx.lineWidth = chartNowStrokeW; ctx.beginPath(); ctx.moveTo(nowX, pad.top); @@ -1469,7 +1438,8 @@ var fresh = ageMs < 5000; // Dot flashes briefly when fresh data lands (discrete, not breathing) var dotFlash = sinceFlash < 400 ? (1 - sinceFlash / 400) : 0; - ctx.fillStyle = fresh ? "#22c55e" : "#f59e0b"; + // Live is a state; a reading that has gone quiet falls to ink2. + ctx.fillStyle = fresh ? C.live : C.dim; ctx.globalAlpha = 0.35 + dotFlash * 0.5; ctx.beginPath(); ctx.arc(w - pad.right - 78, pad.top + 4, 3.5 + dotFlash * 3, 0, Math.PI * 2); @@ -1479,7 +1449,7 @@ ctx.arc(w - pad.right - 78, pad.top + 4, 2.5, 0, Math.PI * 2); ctx.fill(); ctx.font = chartFontTooltip; - ctx.fillStyle = fresh ? C.dim : "#f59e0b"; + ctx.fillStyle = C.dim; ctx.fillText(ageStr, w - pad.right - 70, pad.top + 8); } @@ -1503,8 +1473,10 @@ } } - // hex like "#ef4444" → "rgba(239,68,68,a)" + // "#rrggbb" or a resolved "rgb(r, g, b)" → "rgba(r,g,b,a)" function hexAlpha(hex, alpha) { + var rgb = /^rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(hex); + if (rgb) return "rgba(" + rgb[1] + "," + rgb[2] + "," + rgb[3] + "," + alpha + ")"; var h = hex.replace("#", ""); if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2]; var r = parseInt(h.substr(0,2), 16); @@ -1528,24 +1500,28 @@ _colorProbe.style.color = "var(" + name + ", " + (fallback || "#888") + ")"; return getComputedStyle(_colorProbe).color || fallback || "#888"; } - // Theme-aware chart chrome colors. Series hues stay fixed (they read on - // both themes); only text / gridlines / tooltip surface / the neutral - // "load" line flip — those are what go invisible on a light background. - // Cached so the animation loop doesn't getComputedStyle every frame — - // only recomputed when the active theme actually changes. + // Theme-aware chart colours: chrome, and the energy and state roles the + // series draw in. Cached so the animation loop doesn't getComputedStyle + // every frame — only recomputed when the active theme actually changes. var _chartColors = null, _chartColorsTheme = null; function chartColors() { var theme = document.documentElement.getAttribute("data-theme") || ""; if (_chartColors && _chartColorsTheme === theme) return _chartColors; _chartColorsTheme = theme; _chartColors = { - text: cssColor("--fg", "#e6e6e6"), - dim: cssColor("--fg-dim", "#aaaaaa"), - muted: cssColor("--fg-muted", "#888888"), - grid: cssColor("--line", "#2a2a2a"), - surface: cssColor("--ink-raised", "#161616"), - accent: cssColor("--accent-e", "#fbbf24"), - load: cssColor("--white-s", "#e8e8e8"), + text: cssColor("--fg", "#eef3ff"), + dim: cssColor("--fg-dim", "#9aa5b8"), + muted: cssColor("--fg-muted", "#727d90"), + line: cssColor("--line", "#1b2433"), + surface: cssColor("--ink-raised", "#0b111c"), + accent: cssColor("--accent-e", "#eef3ff"), + load: cssColor("--white-s", "#eef3ff"), + solar: cssColor("--amber", "#ffe14d"), + stores: cssColor("--cyan", "#18d9ff"), + gives: cssColor("--energy-gives", "#ffb000"), + grid: cssColor("--red-e", "#ff5fa8"), + car: cssColor("--violet", "#b69cff"), + live: cssColor("--live", "#83f52c"), }; return _chartColors; } @@ -1584,16 +1560,16 @@ // Tooltip box — labels match current view var labels = chartView === "energy" ? [ - { name: "Import", data: chartHistory.e_import, color: "#ef4444" }, - { name: "Export", data: chartHistory.e_export, color: "#22c55e" }, - { name: "PV", data: chartHistory.e_pv, color: "#10b981" }, - { name: "Charged", data: chartHistory.e_charged, color: "#3b82f6" }, - { name: "Discharged", data: chartHistory.e_discharged, color: "#f59e0b" }, + { name: "Import", data: chartHistory.e_import, color: C.grid }, + { name: "Export", data: chartHistory.e_export, color: C.grid }, + { name: "PV", data: chartHistory.e_pv, color: C.solar }, + { name: "Charged", data: chartHistory.e_charged, color: C.stores }, + { name: "Discharged", data: chartHistory.e_discharged, color: C.gives }, { name: "Load", data: chartHistory.e_load, color: C.load }, ] : (function () { var rows = [ - { name: "Grid", data: chartHistory.grid, color: "#ef4444" }, - { name: "PV", data: chartHistory.pv, color: "#22c55e" }, + { name: "Grid", data: chartHistory.grid, color: C.grid }, + { name: "PV", data: chartHistory.pv, color: C.solar }, { name: "Load", data: chartHistory.load, color: C.load }, ]; // Battery rows render their target inline as "actual W (→ target W)" @@ -1622,7 +1598,7 @@ var boxY = l.pad.top + 5; ctx.fillStyle = C.surface; - ctx.strokeStyle = C.grid; + ctx.strokeStyle = C.line; ctx.lineWidth = 1; ctx.fillRect(boxX, boxY, boxW, boxH); ctx.strokeRect(boxX, boxY, boxW, boxH); @@ -1670,7 +1646,7 @@ var x = l.pad.left + l.plotW * (ts - l.windowStart) / l.totalMs; // Vertical line - ctx.strokeStyle = "rgba(251,191,36,0.4)"; + ctx.strokeStyle = hexAlpha(C.text, 0.4); ctx.lineWidth = 1; ctx.setLineDash([2, 2]); ctx.beginPath(); @@ -1681,12 +1657,12 @@ // Tooltip box for forecast values var labels = [ - { name: "PV pred", val: -a.pv_w, color: "#86efac" }, - { name: "Load pred", val: a.load_w, color: "#fde68a" }, - { name: "Battery", val: a.battery_w, color: "#f59e0b", showSign: true }, - { name: "Grid", val: a.grid_w, color: "#ef4444", showSign: true }, - { name: "SoC", val: a.soc != null ? Math.round(a.soc * 100) + "%" : "—", color: "#60a5fa", literal: true }, - { name: "Price", val: fmtPricePerKwh(a.price_ore), color: "#fbbf24", literal: true }, + { name: "PV pred", val: -a.pv_w, color: C.solar }, + { name: "Load pred", val: a.load_w, color: C.load }, + { name: "Battery", val: a.battery_w, color: a.battery_w < 0 ? C.gives : C.stores, showSign: true }, + { name: "Grid", val: a.grid_w, color: C.grid, showSign: true }, + { name: "SoC", val: a.soc != null ? Math.round(a.soc * 100) + "%" : "—", color: C.stores, literal: true }, + { name: "Price", val: fmtPricePerKwh(a.price_ore), color: hexAlpha(C.text, 0.5), literal: true }, ]; var lineHeight = 16; @@ -4910,11 +4886,11 @@ var legend = document.getElementById("chart-legend"); if (!legend) return; var items = chartView === "energy" ? [ - ["#ef4444", "Import"], ["#22c55e", "Export"], ["#10b981", "PV"], - ["#3b82f6", "Charged"], ["#f59e0b", "Discharged"], ["var(--white-s)", "Load"], + ["var(--red-e)", "Import"], ["var(--green-e)", "Export"], ["var(--amber)", "PV"], + ["var(--cyan)", "Charged"], ["var(--energy-gives)", "Discharged"], ["var(--white-s)", "Load"], ] : [ - ["#ef4444", "Grid"], ["#22c55e", "PV"], ["var(--white-s)", "Load"], - ["#f59e0b", "Ferroamp"], ["#8b5cf6", "Sungrow"], + ["var(--red-e)", "Grid"], ["var(--amber)", "PV"], ["var(--white-s)", "Load"], + [stepSwatch("--cyan", 1), "Ferroamp"], [stepSwatch("--cyan", 0.72), "Sungrow"], ]; legend.innerHTML = items.map(function(it) { return ' '+it[1]+''; @@ -5215,15 +5191,15 @@ }; // Zero baseline for battery - ctx.strokeStyle = C.grid; + ctx.strokeStyle = C.line; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(pad.left, batMid); ctx.lineTo(pad.left + plotW, batMid); ctx.stroke(); - // Battery action bars. Charge (positive) above zero in amber-ish - // (matches Plan's "charge" colour), discharge in violet-ish. + // Battery action bars. Charge (positive) above zero in the storing + // hue, discharge below in the giving hue, as on the Plan. var barW = Math.max(1, plotW / points.length - 0.5); for (var j = 0; j < points.length; j++) { var p = points[j]; @@ -5231,7 +5207,7 @@ if (Math.abs(bw) < 50) continue; // suppress noise var x = xOf(p.ts_ms); var y = yOfBat(bw); - ctx.fillStyle = bw > 0 ? "rgba(251,191,36,0.85)" : "rgba(167,139,250,0.85)"; + ctx.fillStyle = bw > 0 ? C.stores : C.gives; var h = Math.abs(y - batMid); var top = bw > 0 ? y : batMid; ctx.fillRect(x - barW / 2, top, barW, h); @@ -5245,7 +5221,7 @@ var v = Math.max(0, Math.min(100, pct || 0)); return socTop + socH - (v / 100) * socH; }; - ctx.strokeStyle = "rgba(34,211,238,0.95)"; + ctx.strokeStyle = C.stores; ctx.lineWidth = 1.5; ctx.beginPath(); var started = false; @@ -5334,5 +5310,8 @@ // shared; renderLiveHistory still re-measures the canvas each call. fetchLiveHistory(); }); + // The 24h chart draws in theme roles, so it redraws when the theme flips. + // The live chart redraws every frame and needs no listener. + window.addEventListener("ftw-theme-change", function () { fetchLiveHistory(); }); requestAnimationFrame(animationFrame); })(); diff --git a/web/chart-palette.test.mjs b/web/chart-palette.test.mjs new file mode 100644 index 000000000..2c5919162 --- /dev/null +++ b/web/chart-palette.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { describe, it } from "node:test"; +import vm from "node:vm"; + +const read = (path) => readFileSync(new URL(`./${path}`, import.meta.url), "utf8"); + +// One distinct value per role, so a draw call shows which role it used. +const ROLES = { + text: "ink", dim: "ink2", muted: "ink3", line: "line", panel: "panel", accent: "ink", + solar: "solar", stores: "stores", gives: "gives", grid: "grid", car: "car", + live: "live", alarm: "alarm", +}; + +function recordingContext() { + const calls = []; + const ctx = { + fillStyle: "", strokeStyle: "", globalAlpha: 1, lineWidth: 1, font: "", textAlign: "", + fillRect(x, y, w, h) { calls.push({ fill: this.fillStyle, alpha: this.globalAlpha, x, y, w, h }); }, + }; + for (const name of ["scale", "clearRect", "beginPath", "moveTo", "lineTo", "stroke", "fillText", "save", "restore", "setLineDash", "setTransform"]) { + ctx[name] = () => {}; + } + return { ctx, calls }; +} + +describe("charts draw in theme roles", () => { + it("carries none of the old fixed chart hues", () => { + // Red and green grid and prices, amber charge, purple discharge, blue + // SoC, cyan car, and the heating chart's own hues. + const legacy = /#(?:ef4444|22c55e|f59e0b|8b5cf6|60a5fa|86efac|fde68a|22d3ee|10b981|3b82f6|eab308|f97316|38bdf8|a78bfa|f472b6|fbbf24|0891b2|fdba74|facc15|67e8f9|7788ff|93c5fd)\b|rgba\(\s*(?:34,\s*197,\s*94|239,\s*68,\s*68|245,\s*158,\s*11|139,\s*92,\s*246|96,\s*165,\s*250|34,\s*211,\s*238|251,\s*191,\s*36|253,\s*230,\s*138|148,\s*163,\s*184|100,\s*116,\s*139|167,\s*139,\s*250|6,\s*182,\s*212|251,\s*146,\s*60|250,\s*204,\s*21)/gi; + for (const path of ["plan.js", "energy-history.js", "diagnose.js", "heating.js", "app.js", "index.html", "style.css"]) { + assert.deepEqual(read(path).match(legacy) || [], [], path); + } + assert.match(read("app.css"), /\.ev-timeline \{ --ev-plan-color: var\(--violet\);/); + }); + + it("draws plan prices as ink by level and the battery in its two hues", () => { + const plan = read("plan.js"); + assert.match(plan, /PRICE_INK = \{ cheap: 0\.32, flat: 0\.5, dear: 0\.8/); + assert.match(plan, /\{ ore: spotOre,\s+color: C\.text,\s+alpha: level/); + assert.match(plan, /ctx\.fillStyle = a\.battery_w >= 0 \? C\.stores : C\.gives;/); + assert.match(plan, /ctx\.strokeStyle = C\.car;/); + }); + + it("hangs grid export below the zero line, in the grid's one hue", async () => { + const { ctx, calls } = recordingContext(); + const canvas = { clientWidth: 600, width: 0, height: 0, getContext: () => ctx }; + const legend = { innerHTML: "" }; + const els = { "energy-history-chart": canvas, "energy-history-legend": legend }; + const day = (d, load, pv, imp, exp) => ({ day: d, load_wh: load, pv_wh: pv, import_wh: imp, export_wh: exp }); + const days = [day("2026-10-24", 20000, 10000, 15000, 0), day("2026-10-25", 10000, 30000, 2000, 10000)]; + const context = { + document: { getElementById: (id) => els[id] || null }, + window: { + devicePixelRatio: 1, + ftwThemeColors: { palette: () => ({ ...ROLES }) }, + addEventListener() {}, + }, + fetch: async () => ({ ok: true, json: async () => ({ days }) }), + }; + vm.createContext(context); + vm.runInContext(read("energy-history.js"), context); + await context.window.ftwEnergyHistoryLoad(); + await new Promise((resolve) => setImmediate(resolve)); + + // Four bars a day: home use, solar, import, export. + assert.equal(calls.length, 8); + assert.deepEqual(calls.map((bar) => bar.fill), ["ink", "solar", "grid", "grid", "ink", "solar", "grid", "grid"]); + const [, , , , , solar, imported, exported] = calls; + // Import stands on the zero line; export starts on it and goes down. + const zeroY = imported.y + imported.h; + assert.equal(exported.y, zeroY); + assert.ok(exported.h > 0); + assert.equal(solar.y + solar.h, zeroY); + assert.match(legend.innerHTML, /background:grid"><\/i>Grid export, below the line/); + }); + + it("gives every battery the storing hue and every car the car's, a step fainter each", () => { + const app = read("app.js"); + assert.doesNotMatch(app, /BATTERY_PALETTE|EV_PALETTE/); + const start = app.indexOf(" var SERIES_STEPS"); + const end = app.indexOf(" // A driver name → presentation label."); + const hex = app.slice(app.indexOf(" function hexAlpha("), app.indexOf(" // Resolve a CSS custom property to a concrete")); + const context = { + chartBatteries: { sungrow: {}, ferroamp: {} }, + chartEVs: { easee: {} }, + chartColors: () => ({ stores: "rgb(24, 217, 255)", car: "rgb(182, 156, 255)" }), + }; + vm.createContext(context); + vm.runInContext(app.slice(start, end) + hex + app.slice(app.indexOf(" function evColor("), app.indexOf(" function evLabel(")), context); + assert.equal(context.batteryColor("ferroamp"), "rgba(24,217,255,1)"); + assert.equal(context.batteryColor("sungrow"), "rgba(24,217,255,0.72)"); + assert.equal(context.evColor("easee"), "rgba(182,156,255,1)"); + assert.equal(context.batterySwatch("sungrow"), "color-mix(in srgb, var(--cyan) 72%, transparent)"); + assert.equal(context.evSwatch("easee"), "color-mix(in srgb, var(--violet) 100%, transparent)"); + }); +}); diff --git a/web/diagnose.js b/web/diagnose.js index 0c83ce89b..b7664168d 100644 --- a/web/diagnose.js +++ b/web/diagnose.js @@ -51,12 +51,19 @@ return window.ftwThemeColors ? window.ftwThemeColors.palette() : { - text: '#e8e8e8', - dim: '#a0a0a0', - muted: '#858585', - line: '#2a2a2a', - panel: '#161616', - accent: '#f5b942', + text: '#eef3ff', + dim: '#9aa5b8', + muted: '#727d90', + line: '#1b2433', + panel: '#0b111c', + accent: '#eef3ff', + solar: '#ffe14d', + stores: '#18d9ff', + gives: '#ffb000', + grid: '#ff5fa8', + car: '#b69cff', + live: '#83f52c', + alarm: '#ff453a', }; } @@ -516,18 +523,18 @@ ctx.fillRect(pad.l, socY0, plotW, socH); ctx.restore(); - // Price bars — green cheap, red expensive (relative to horizon mean) + // Price bars — a level of ink against the horizon mean, the dearer + // the stronger. A price is not a state, so no red and no green. const priceMean = slots.reduce((a, s) => a + s.price_ore, 0) / nSlots; + ctx.fillStyle = C.text; slots.forEach((s, i) => { const x = pad.l + i * barW; const h = (s.price_ore / maxPrice) * priceH; const y = priceY0 + priceH - h; - // Cheap slots (below mean) in green, expensive in red - ctx.fillStyle = s.price_ore < priceMean - ? 'rgba(34,197,94,0.75)' - : 'rgba(239,68,68,0.75)'; + ctx.globalAlpha = s.price_ore < priceMean ? 0.32 : 0.8; ctx.fillRect(x, y, barW, h); }); + ctx.globalAlpha = 1; // Power zero line const powerMidY = powerY0 + powerH / 2; @@ -542,11 +549,12 @@ const powerScale = (powerH / 2) / maxAbsPower; slots.forEach((s, i) => { const x = pad.l + i * barW; - // Battery — orange charge up, purple discharge down + // Battery — stores up, gives down, in its two hues const bw = s.battery_w || 0; + ctx.globalAlpha = 0.75; if (bw !== 0) { const h = bw * powerScale; - ctx.fillStyle = bw > 0 ? 'rgba(245,158,11,0.75)' : 'rgba(139,92,246,0.75)'; + ctx.fillStyle = bw > 0 ? C.stores : C.gives; ctx.fillRect(x, powerMidY - h, barW, h); } // EV — stacked atop battery when both charging @@ -554,13 +562,14 @@ if (ew > 0) { const bh = (bw > 0 ? bw : 0) * powerScale; const h = ew * powerScale; - ctx.fillStyle = 'rgba(6,182,212,0.75)'; + ctx.fillStyle = C.car; ctx.fillRect(x, powerMidY - bh - h, barW, h); } + ctx.globalAlpha = 1; }); // Grid line — overlay - ctx.strokeStyle = '#ef4444'; + ctx.strokeStyle = C.grid; ctx.lineWidth = 1.5; ctx.beginPath(); slots.forEach((s, i) => { @@ -571,8 +580,8 @@ }); ctx.stroke(); - // PV line — green, negative is pulled up visually (PV is −W site sign) - ctx.strokeStyle = '#22c55e'; + // PV line — solar, negative is pulled up visually (PV is −W site sign) + ctx.strokeStyle = C.solar; ctx.lineWidth = 1; ctx.beginPath(); slots.forEach((s, i) => { @@ -583,8 +592,8 @@ }); ctx.stroke(); - // SoC line — blue, in bottom band - ctx.strokeStyle = '#60a5fa'; + // SoC line — the battery's storing hue, in bottom band + ctx.strokeStyle = C.stores; ctx.lineWidth = 2; ctx.beginPath(); slots.forEach((s, i) => { diff --git a/web/energy-history.js b/web/energy-history.js index 452c20615..5512863f8 100644 --- a/web/energy-history.js +++ b/web/energy-history.js @@ -19,23 +19,32 @@ pv_generation: 'Solar', consumer_use: 'Home use', vehicle_charge: 'Vehicle charge', vehicle_discharge: 'Vehicle discharge' }; + // Colour is energy: the home is ink and the grid one hue both ways, so + // export hangs below the zero line and the side of zero says sold. var dailySeries = [ - { key: 'load_wh', label: 'Home use', color: '#eab308' }, - { key: 'pv_wh', label: 'Solar', color: '#22c55e' }, - { key: 'import_wh', label: 'Grid import', color: '#ef4444' }, - { key: 'export_wh', label: 'Grid export', color: '#f97316' } + { key: 'load_wh', label: 'Home use', role: 'text' }, + { key: 'pv_wh', label: 'Solar', role: 'solar' }, + { key: 'import_wh', label: 'Grid import', role: 'grid' }, + { key: 'export_wh', label: 'Grid export, below the line', role: 'grid', below: true } ]; function canvasColors() { return window.ftwThemeColors ? window.ftwThemeColors.palette() : { - text: '#e8e8e8', - dim: '#a0a0a0', - muted: '#858585', - line: '#2a2a2a', - panel: '#161616', - accent: '#f5b942' + text: '#eef3ff', + dim: '#9aa5b8', + muted: '#727d90', + line: '#1b2433', + panel: '#0b111c', + accent: '#eef3ff', + solar: '#ffe14d', + stores: '#18d9ff', + gives: '#ffb000', + grid: '#ff5fa8', + car: '#b69cff', + live: '#83f52c', + alarm: '#ff453a' }; } @@ -152,7 +161,7 @@ var legend = document.getElementById('energy-history-legend'); if (legend) { legend.innerHTML = dailySeries.map(function (series) { - return '' + series.label + ''; + return '' + series.label + ''; }).join('') + (days.some(function (day) { return day.history_quality === 'estimated'; }) ? 'Home use includes estimates' : '') + (days.some(function (day) { return dailySeries.some(function (series) { return day[series.key] == null; }); }) ? '? Unknown' : ''); } if (!days.length) { @@ -165,24 +174,34 @@ var pad = { left: 52, right: 12, top: 14, bottom: 42 }; var plotW = Math.max(1, width - pad.left - pad.right); var plotH = Math.max(1, height - pad.top - pad.bottom); - var maxWh = Math.max.apply(null, days.flatMap(function (day) { - return dailySeries.map(function (series) { return Math.max(0, Number(day[series.key]) || 0); }); - })); - maxWh = Math.max(1000, maxWh); + var upWh = 0; + var downWh = 0; + days.forEach(function (day) { + dailySeries.forEach(function (series) { + var value = Math.max(0, Number(day[series.key]) || 0); + if (series.below) downWh = Math.max(downWh, value); + else upWh = Math.max(upWh, value); + }); + }); + upWh = Math.max(1000, upWh); + var whToPx = plotH / (upWh + downWh); + var zeroY = pad.top + upWh * whToPx; var textColor = C.dim; var borderColor = C.line; ctx.font = '11px system-ui, sans-serif'; ctx.fillStyle = textColor; ctx.strokeStyle = borderColor; ctx.lineWidth = 1; - for (var tick = 0; tick <= 4; tick += 1) { - var y = pad.top + plotH * tick / 4; + // Four steps above zero, and the same steps below while export reaches. + var stepWh = upWh / 4; + for (var tick = 4; tick >= -4 && -tick * stepWh <= downWh + 1; tick -= 1) { + var y = zeroY - tick * stepWh * whToPx; ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(width - pad.right, y); ctx.stroke(); ctx.textAlign = 'right'; - ctx.fillText((maxWh * (1 - tick / 4) / 1000).toFixed(0) + ' kWh', pad.left - 7, y + 4); + ctx.fillText((Math.abs(tick) * stepWh / 1000).toFixed(0) + ' kWh', pad.left - 7, y + 4); } var groupW = plotW / days.length; @@ -194,13 +213,13 @@ dailySeries.forEach(function (series, seriesIndex) { if (day[series.key] == null) { ctx.fillStyle = C.muted; ctx.textAlign = 'center'; - ctx.fillText('?', startX + seriesIndex * (barW + barGap) + barW / 2, pad.top + plotH - 4); + ctx.fillText('?', startX + seriesIndex * (barW + barGap) + barW / 2, zeroY - 4); return; } var value = Math.max(0, Number(day[series.key]) || 0); - var barH = plotH * value / maxWh; - ctx.fillStyle = series.color; - ctx.fillRect(startX + seriesIndex * (barW + barGap), pad.top + plotH - barH, barW, barH); + var barH = value * whToPx; + ctx.fillStyle = C[series.role]; + ctx.fillRect(startX + seriesIndex * (barW + barGap), series.below ? zeroY : zeroY - barH, barW, barH); }); }); diff --git a/web/heating.js b/web/heating.js index a591a5d19..aa40e8d71 100644 --- a/web/heating.js +++ b/web/heating.js @@ -153,7 +153,7 @@ '.ftw-hpc-note{font-family:var(--sans);font-size:0.68rem;color:var(--fg-muted);margin-top:6px}', '.ftw-hpc-err{font-family:var(--sans);font-size:0.68rem;color:var(--fg);margin-top:6px}', '.ftw-hp-leg{font-family:var(--mono);font-size:0.62rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--fg-muted);display:inline-flex;align-items:center;gap:4px}', - '.ftw-hp-leg-dot{width:8px;height:8px;border-radius:2px;display:inline-block}', + '.ftw-hp-leg-dot{width:14px;height:0;border-top:2px solid;display:inline-block}', '.ftw-hp-erow{display:grid;grid-template-columns:1fr auto auto;gap:7px 18px;align-items:baseline}', '.ftw-hp-ehead{font-size:0.58rem;letter-spacing:0.12em;text-transform:uppercase;color:var(--fg-muted);font-family:var(--mono)}', '.ftw-hp-elabel{font-family:var(--mono);font-size:0.66rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--fg-muted)}', @@ -220,17 +220,29 @@ } // ── Month temperature chart (outdoor / supply / return) ────────── - // Multi-line SVG; line hues are fixed (read on both themes), axis chrome - // uses theme tokens. Spans whatever history exists (fills toward a month). + // Multi-line SVG. Colour is energy and a temperature is not one, so the + // lines are ink levels told apart by dash, as the legend shows. Spans + // whatever history exists (fills toward a month). var TCHART = [ - { key: 'outdoor', label: 'Outdoor (BT1)', color: '#38bdf8' }, - { key: 'supply', label: 'Supply (BT2)', color: '#ef4444' }, - { key: 'ret', label: 'Return (BT3)', color: '#22c55e' }, - { key: 'extract', label: 'Extract air (BT20)', color: '#a78bfa' }, + { key: 'outdoor', label: 'Outdoor (BT1)', color: 'var(--fg-muted)', dash: 'dashed' }, + { key: 'supply', label: 'Supply (BT2)', color: 'var(--fg)', dash: 'solid' }, + { key: 'ret', label: 'Return (BT3)', color: 'var(--fg-dim)', dash: 'solid' }, + { key: 'extract', label: 'Extract air (BT20)', color: 'var(--fg-muted)', dash: 'dotted' }, ]; + // SVG dash patterns for the legend's border styles. + var DASH = { solid: '', dashed: '6 4', dotted: '1.5 3' }; + function chartLine(d, s) { + return ''; + } + function chartLegend(series) { + return series.map(function (s) { + return '' + s.label + ''; + }).join(''); + } function tempChartBlock(temps) { if (!temps) return ''; - var lines = TCHART.map(function (s) { return { color: s.color, points: temps[s.key] || [] }; }); + var lines = TCHART.map(function (s) { return { series: s, points: temps[s.key] || [] }; }); var all = []; lines.forEach(function (l) { l.points.forEach(function (p) { if (p && p.v != null) all.push(p); }); }); if (all.length < 2) return ''; @@ -252,11 +264,9 @@ var pp = l.points.filter(function (p) { return p && p.v != null; }); if (pp.length < 2) return ''; var d = pp.map(function (p, i) { return (i ? 'L' : 'M') + X(p.ts) + ',' + Y(p.v); }).join(' '); - return ''; - }).join(''); - var legend = TCHART.map(function (s) { - return '' + s.label + ''; + return chartLine(d, l.series); }).join(''); + var legend = chartLegend(TCHART); var yax = [vMax, (vMin + vMax) / 2, vMin].map(function (v) { return '' + Math.round(v) + '°'; }).join(''); @@ -274,15 +284,16 @@ // Power over 24h — Total drawn / Compressor / Internal add. heat, all in kW. // Separate from sparkline() so the ~937 tiny detail sparklines stay axis-free. + // The heat pump is part of the home, so its power is ink too. var PCHART = [ - { key: 'total', label: 'Total drawn', color: 'var(--accent-e)', scale: 0.001 }, - { key: 'compressor', label: 'Compressor', color: '#38bdf8', scale: 0.001 }, - { key: 'internal', label: 'Internal add. heat', color: '#f472b6', scale: 0.001 }, + { key: 'total', label: 'Total drawn', color: 'var(--fg)', dash: 'solid', scale: 0.001 }, + { key: 'compressor', label: 'Compressor', color: 'var(--fg-dim)', dash: 'solid', scale: 0.001 }, + { key: 'internal', label: 'Internal add. heat', color: 'var(--fg-muted)', dash: 'dashed', scale: 0.001 }, ]; function powerChartBlock(power) { if (!power) return ''; var lines = PCHART.map(function (s) { - return { color: s.color, points: (power[s.key] || []).map(function (p) { + return { series: s, points: (power[s.key] || []).map(function (p) { return { ts: p.ts, v: p.v == null ? null : p.v * s.scale }; }) }; }); @@ -303,11 +314,9 @@ var pp = l.points.filter(function (p) { return p && p.v != null; }); if (pp.length < 2) return ''; var d = pp.map(function (p, i) { return (i ? 'L' : 'M') + X(p.ts) + ',' + Y(p.v); }).join(' '); - return ''; - }).join(''); - var legend = PCHART.map(function (s) { - return '' + s.label + ''; + return chartLine(d, l.series); }).join(''); + var legend = chartLegend(PCHART); var yax = [vMax, vMax / 2, 0].map(function (v) { return '' + v.toFixed(1) + ''; }).join(''); var fmtTime = function (t) { return new Date(t).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); }; var xticks = 4, xax = ''; diff --git a/web/index.html b/web/index.html index 172118e91..029001e36 100644 --- a/web/index.html +++ b/web/index.html @@ -443,11 +443,11 @@

Live

- Grid - PV - PV fc + Grid + PV + PV fc Load - Load fc + Load fc
@@ -622,16 +622,16 @@

Plan

- Spot (cheap) - Spot (expensive) - Grid tariff - VAT - PV forecast - Load forecast - - Charge - Discharge - SoC + Spot (cheap) + Spot (expensive) + Grid tariff + VAT + PV forecast + Load forecast + + Charge + Discharge + SoC