Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ios-palette-charts.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 1 addition & 2 deletions web/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand All @@ -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; }
261 changes: 130 additions & 131 deletions web/app.js

Large diffs are not rendered by default.

150 changes: 150 additions & 0 deletions web/chart-palette.test.mjs
Original file line number Diff line number Diff line change
@@ -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)");
});
});
47 changes: 28 additions & 19 deletions web/diagnose.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
}

Expand Down Expand Up @@ -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;
Expand All @@ -542,25 +549,27 @@
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
const ew = s.loadpoint_w || 0;
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) => {
Expand All @@ -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) => {
Expand All @@ -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) => {
Expand Down
75 changes: 53 additions & 22 deletions web/energy-history.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
};
}

Expand Down Expand Up @@ -152,7 +161,7 @@
var legend = document.getElementById('energy-history-legend');
if (legend) {
legend.innerHTML = dailySeries.map(function (series) {
return '<span><i style="background:' + series.color + '"></i>' + series.label + '</span>';
return '<span><i style="background:' + C[series.role] + '"></i>' + series.label + '</span>';
}).join('') + (days.some(function (day) { return day.history_quality === 'estimated'; }) ? '<span>Home use includes estimates</span>' : '') + (days.some(function (day) { return dailySeries.some(function (series) { return day[series.key] == null; }); }) ? '<span>? Unknown</span>' : '');
}
if (!days.length) {
Expand All @@ -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;
Expand All @@ -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);
});
});

Expand Down
Loading
Loading