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..d9b37c805 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,12 @@ 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 }, + // One pink both ways: export is told apart by its dash, not a hue. + { data: toKwh(chartHistory.e_export), color: C.grid, width: 2, dash: [6, 4], name: "Export", fill: false }, + { 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 +1126,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 +1213,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 +1336,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 +1351,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 +1406,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 +1439,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 +1450,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 +1474,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 +1501,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 +1561,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, dash: true }, + { 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 +1599,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); @@ -1634,8 +1611,20 @@ labels.forEach(function (lab, idx) { if (i >= lab.data.length) return; var y = boxY + (idx + 2) * lineHeight - 4; - ctx.fillStyle = lab.color; - ctx.fillRect(boxX + 6, y - 8, 8, 8); + if (lab.dash) { + // A dashed series gets a dashed mark, as in the legend. + ctx.strokeStyle = lab.color; + ctx.lineWidth = 2; + ctx.setLineDash([3, 2]); + ctx.beginPath(); + ctx.moveTo(boxX + 6, y - 4); + ctx.lineTo(boxX + 14, y - 4); + ctx.stroke(); + ctx.setLineDash([]); + } else { + ctx.fillStyle = lab.color; + ctx.fillRect(boxX + 6, y - 8, 8, 8); + } ctx.fillStyle = lab.dim ? C.muted : C.text; ctx.fillText(lab.name, boxX + 18, y); ctx.textAlign = "right"; @@ -1670,7 +1659,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 +1670,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,14 +4899,17 @@ 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", true], ["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]+''; + var swatch = it[2] + ? '' + : ''; + return ''+swatch+' '+it[1]+''; }).join(''); } @@ -5158,7 +5150,9 @@ // compact stacked canvas of (battery action bars, SoC line) over // the last 24 h. Mirrors the Plan card's lower charts so the two // cards have matching visual weight. Read-only — no interaction. + var lastLiveHistoryItems = null; function renderLiveHistory(items) { + lastLiveHistoryItems = items; var canvas = document.getElementById("live-history-chart"); if (!canvas || !items || !items.length) return; // Normalise the /api/history payload: `ts` for the timestamp and @@ -5215,15 +5209,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 +5225,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 +5239,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 +5328,10 @@ // shared; renderLiveHistory still re-measures the canvas each call. fetchLiveHistory(); }); + // The 24h chart draws in theme roles, so it redraws from what it last + // showed when the theme flips. The live chart redraws every frame. + window.addEventListener("ftw-theme-change", function () { + if (lastLiveHistoryItems) renderLiveHistory(lastLiveHistoryItems); + }); requestAnimationFrame(animationFrame); })(); diff --git a/web/chart-palette.test.mjs b/web/chart-palette.test.mjs new file mode 100644 index 000000000..649950cc0 --- /dev/null +++ b/web/chart-palette.test.mjs @@ -0,0 +1,150 @@ +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 texts = []; + 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 }); }, + fillText(text, x, y) { texts.push({ text, x, y }); }, + }; + for (const name of ["scale", "clearRect", "beginPath", "moveTo", "lineTo", "stroke", "save", "restore", "setLineDash", "setTransform"]) { + ctx[name] = () => {}; + } + return { ctx, calls, texts }; +} + +// Draw History's daily chart for these days and return what it drew. +async function drawHistory(days) { + const { ctx, calls, texts } = 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 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)); + return { calls, texts, legend }; +} +const day = (d, load, pv, imp, exp) => ({ day: d, load_wh: load, pv_wh: pv, import_wh: imp, export_wh: exp }); + +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 { calls, texts, legend } = await drawHistory([day("2026-10-24", 20000, 10000, 15000, 0), day("2026-10-25", 10000, 30000, 2000, 10000)]); + + // 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/); + assert.ok(texts.some((t) => t.text === "sold"), "the lower labels say what they count"); + }); + + it("gives export its own gridlines when it outgrows every bar above zero", async () => { + const { calls, texts } = await drawHistory([day("2026-10-24", 8000, 12000, 3000, 30000), day("2026-10-25", 6000, 9000, 2000, 18000)]); + const exported = calls[3]; // the first day's 30 kWh export + const lowestBar = Math.max(...calls.filter((c) => c.fill === "grid").map((c) => c.y + c.h)); + const labels = texts.filter((t) => / kWh$/.test(t.text)); + const lowest = labels.reduce((a, b) => (b.y > a.y ? b : a)); + // Labels sit 4 px under their gridline. + assert.ok(lowest.y - 4 >= lowestBar - 1e-6, "the lowest gridline reaches the largest export"); + assert.ok(parseFloat(lowest.text) >= 30, lowest.text); + assert.ok(exported.h > 0); + for (const label of labels) assert.doesNotMatch(label.text, /[-−]/, label.text); + }); + + it("tells live export from import by its dash, in the one grid hue", () => { + const app = read("app.js"); + assert.match(app, /e_export\),\s+color: C\.grid,\s+width: 2, dash: \[6, 4\], name: "Export", fill: false/); + assert.match(app, /name: "Export",\s+data: chartHistory\.e_export,\s+color: C\.grid, dash: true/); + assert.match(app, /\["var\(--green-e\)", "Export", true\]/); + }); + + it("maps the live chart's roles to the same tokens as the shared resolver", async () => { + const { createThemeColors } = await import("./theme-runtime.js"); + const shared = createThemeColors((name) => name).palette(); + const app = read("app.js"); + const context = { + document: { + createElement: () => ({ style: {} }), + body: { appendChild() {} }, + documentElement: { getAttribute: () => "dark" }, + }, + getComputedStyle: (el) => ({ color: el.style.color }), + }; + vm.createContext(context); + vm.runInContext(app.slice(app.indexOf(" var _colorProbe = null;"), app.indexOf(" function drawHoverOverlay(")), context); + const live = Object.fromEntries( + Object.entries(context.chartColors()).map(([role, value]) => [role, /^var\((--[a-z0-9-]+)/.exec(value)[1]]), + ); + // The live chart calls the panel its surface; every other name matches. + assert.equal(live.surface, shared.panel); + for (const role of ["text", "dim", "muted", "line", "accent", "solar", "stores", "gives", "grid", "car", "live"]) { + assert.equal(live[role], shared[role], role); + } + }); + + 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..6f87d31ef 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,46 @@ 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); + // One step for both sides, a quarter of the taller one, and whole steps + // each way, so the highest bar and the lowest export bar both reach a + // gridline with a label. + var stepWh = Math.max(upWh, downWh) / 4; + var upSteps = Math.ceil(upWh / stepWh - 1e-9); + var downSteps = Math.ceil(downWh / stepWh - 1e-9); + var whToPx = plotH / ((upSteps + downSteps) * stepWh); + var zeroY = pad.top + upSteps * stepWh * 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; + for (var tick = upSteps; tick >= -downSteps; 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); + } + // The labels below zero carry no sign; this word says what they count. + if (downSteps > 0) { + ctx.fillStyle = C.dim; + ctx.font = '10px system-ui, sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('sold', pad.left + 3, zeroY + 11); + ctx.font = '11px system-ui, sans-serif'; } var groupW = plotW / days.length; @@ -194,13 +225,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..16cf19932 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,30 @@ } // ── 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' }, + // Supply and return run close together, so return also takes a dash. + { key: 'ret', label: 'Return (BT3)', color: 'var(--fg-dim)', dash: 'dashed' }, + { 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 +265,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 +285,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 +315,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..e0191cf18 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