diff --git a/assets/js/colors.test.ts b/assets/js/colors.test.ts new file mode 100644 index 000000000..4957fda9a --- /dev/null +++ b/assets/js/colors.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from "vitest"; +import colors, { dimColor, lighterColor, fullColor, resolveColors } from "./colors"; +import type { DeviceColors } from "./types/evcc"; + +describe("setAlpha helpers", () => { + it("appends alpha to 6-digit hex", () => { + expect(dimColor("#abcdef")).toBe("#abcdef20"); + expect(lighterColor("#abcdef")).toBe("#abcdefaa"); + expect(fullColor("#abcdef")).toBe("#abcdefff"); + }); + + it("replaces alpha on 8-digit hex", () => { + expect(dimColor("#abcdefff")).toBe("#abcdef20"); + expect(lighterColor("#abcdef12")).toBe("#abcdefaa"); + expect(fullColor("#abcdef00")).toBe("#abcdefff"); + }); + + it("handles null/undefined", () => { + expect(dimColor(null)).toBeUndefined(); + expect(lighterColor(null)).toBeUndefined(); + expect(fullColor(null)).toBeUndefined(); + }); + + it("returns input unchanged for unexpected length", () => { + expect(dimColor("rgb(1,2,3)")).toBe("rgb(1,2,3)"); + }); +}); + +describe("resolveColors", () => { + const palette = colors.palette; // 18 entries + + it("returns empty map for empty ids", () => { + expect(resolveColors([], {})).toEqual({}); + }); + + it("autoassigns palette in order without overrides", () => { + const ids = ["a", "b", "c"]; + expect(resolveColors(ids, {})).toEqual({ + a: palette[0], + b: palette[1], + c: palette[2], + }); + }); + + it("respects explicit override", () => { + const res = resolveColors(["a", "b"], { a: "#123456" }); + expect(res["a"]).toBe("#123456"); + expect(res["b"]).toBe(palette[0]); + }); + + it("skips overridden palette entry on autoassign", () => { + // override "a" with first palette color → others should not reuse it + const res = resolveColors(["a", "b", "c"], { a: palette[0] }); + expect(res["a"]).toBe(palette[0]); + expect(res["b"]).toBe(palette[1]); + expect(res["c"]).toBe(palette[2]); + }); + + it("empty-string override falls back to autoassign", () => { + const res = resolveColors(["a", "b"], { a: "" }); + expect(res["a"]).toBe(palette[0]); + expect(res["b"]).toBe(palette[1]); + }); + + it("normalizes case when matching taken colors", () => { + // override uses lowercase; should still skip the matching palette entry + const lower = palette[0].toLowerCase(); + const res = resolveColors(["a", "b"], { a: lower }); + expect(res["a"]).toBe(lower); + expect(res["b"]).toBe(palette[1]); + }); + + it("wraps around once palette exhausted", () => { + const ids = Array.from({ length: palette.length + 2 }, (_, i) => `id${i}`); + const res = resolveColors(ids, {}); + expect(res[ids[0]]).toBe(palette[0]); + expect(res[ids[palette.length]]).toBe(palette[0]); + expect(res[ids[palette.length + 1]]).toBe(palette[1]); + }); + + it("with every palette entry overridden, autoassign wraps palette", () => { + const overrides: DeviceColors = {}; + palette.forEach((c, i) => (overrides[`o${i}`] = c)); + const ids = [...Object.keys(overrides), "extra"]; + const res = resolveColors(ids, overrides); + palette.forEach((c, i) => expect(res[`o${i}`]).toBe(c)); + // free list empty → falls back to wrap-around on palette + expect(res["extra"]).toBe(palette[0]); + }); +}); diff --git a/assets/js/colors.ts b/assets/js/colors.ts index da57e444b..aa0ee2f06 100644 --- a/assets/js/colors.ts +++ b/assets/js/colors.ts @@ -1,4 +1,5 @@ import { reactive } from "vue"; +import type { DeviceColors } from "./types/evcc"; // alternatives // const COLORS = [ "#40916C", "#52B788", "#74C69D", "#95D5B2", "#B7E4C7", "#D8F3DC", "#081C15", "#1B4332", "#2D6A4F"]; @@ -35,25 +36,27 @@ const colors: { export: null, background: null, light: null, - selfPalette: ["#0FDE41FF", "#FFBD2FFF", "#FD6158FF", "#03C1EFFF", "#0F662DFF", "#FF922EFF"], + selfPalette: ["#0FDE41", "#FFBD2F", "#FD6158", "#03C1EF", "#0F662D", "#FF922E"], palette: [ // Dynamic palette (vehicles, loadpoints, …): optimized for neighbor contrast, avoids overlap with system colors (solar, battery, grid, …). - "#06B6D4FF", // Cyan - "#2B7FFFFF", // Blue - "#6366F1FF", // Indigo - "#A855F7FF", // Violet - "#D946EFFF", // Magenta - "#EC4899FF", // Pink - "#FB7185FF", // Coral - "#475569FF", // Slate - "#1E40AFFF", // Royal - "#6D28D9FF", // Purple - "#BE185DFF", // Crimson - "#D2691EFF", // Sienna - "#67E8F9FF", // Glacier - "#C084FCFF", // Lilac - "#FFD580FF", // Sand - "#94A3B8FF", // Steel + "#0EA5E9", // Sky + "#EC4899", // Pink + "#34D399", // Mint + "#F97316", // Orange + "#7C3AED", // Violet + "#84CC16", // Lime + "#F43F5E", // Rose + "#2563EB", // Blue + "#FACC15", // Yellow + "#D946EF", // Magenta + "#10B981", // Emerald + "#1E40AF", // Royal + "#BE185D", // Crimson + "#F59E0B", // Marigold + "#6366F1", // Indigo + "#14B8A6", // Teal + "#A855F7", // Lavender + "#DC2626", // Red ], }); @@ -67,6 +70,38 @@ const setAlpha = (color: string | null, alpha: string): string | undefined => { return c; }; +// regex for raw hex (no leading #): 6 digits, used for input validation +export const HEX_RE = /^[0-9a-fA-F]{6}$/; + +// normalize hex to uppercase 7-char #RRGGBB (strips alpha if present) +export const normalizeHex = (color?: string | null): string => { + if (!color) return ""; + let c = color.trim().toUpperCase(); + if (!c.startsWith("#")) c = "#" + c; + return c.slice(0, 7); +}; + +// override wins; rest get next free palette entry, wrap-around when exhausted +export function resolveColors(ids: string[], overrides: DeviceColors = {}): DeviceColors { + const taken = new Set(Object.values(overrides).map(normalizeHex)); + const free = colors.palette.filter((c) => !taken.has(normalizeHex(c))); + const result: DeviceColors = {}; + let idx = 0; + for (const id of ids) { + const ov = overrides[id]; + if (ov) { + result[id] = ov; + } else if (free.length) { + result[id] = free[idx % free.length]; + idx++; + } else { + result[id] = colors.palette[idx % colors.palette.length]; + idx++; + } + } + return result; +} + export const dimColor = (color: string | null) => setAlpha(color, "20"); export const lighterColor = (color: string | null) => setAlpha(color, "aa"); diff --git a/assets/js/components/Helper/ColorPickerPopover.vue b/assets/js/components/Helper/ColorPickerPopover.vue new file mode 100644 index 000000000..e36f2acf5 --- /dev/null +++ b/assets/js/components/Helper/ColorPickerPopover.vue @@ -0,0 +1,310 @@ + + + + + diff --git a/assets/js/components/History/GroupChart.vue b/assets/js/components/History/GroupChart.vue index b0cbc3098..c5e6de912 100644 --- a/assets/js/components/History/GroupChart.vue +++ b/assets/js/components/History/GroupChart.vue @@ -13,7 +13,8 @@ import { forecastYAxis, tooltipStyle, } from "../Forecast/echarts"; -import colors, { lighterColor } from "@/colors"; +import colors, { lighterColor, resolveColors } from "@/colors"; +import store from "@/store"; import formatter, { POWER_UNIT } from "@/mixins/formatter"; import { PERIODS } from "../Sessions/types"; import { is12hFormat } from "@/units"; @@ -204,12 +205,16 @@ export default defineComponent({ // stacked segments stay visually distinguishable. if (this.group === "loadpoint" || this.group === "meter") { const mutedColor = colors.muted || this.color; - return this.series.map((s, i) => { + const titles: string[] = []; + for (const s of this.series) { + if (!s.virtual && !titles.includes(s.name)) titles.push(s.name); + } + const palette = resolveColors(titles, store.state.deviceColors ?? {}); + return this.series.map((s) => { // Virtual "other consumers" entity renders in a neutral gray to set // it apart from explicit meter entities. if (s.virtual) return mutedColor; - const idx = s.paletteIndex ?? i; - return colors.palette[idx % colors.palette.length] || this.color; + return palette[s.name] || this.color; }); } if (this.series.length <= 1) return [this.color]; diff --git a/assets/js/components/Optimize/ChargeChart.vue b/assets/js/components/Optimize/ChargeChart.vue index 1f67cf9ab..5103f9d48 100644 --- a/assets/js/components/Optimize/ChargeChart.vue +++ b/assets/js/components/Optimize/ChargeChart.vue @@ -9,7 +9,7 @@ :height="300" /> - + @@ -32,7 +32,7 @@ import { } from "chart.js"; import { Chart } from "vue-chartjs"; import type { EvoptData } from "./TimeSeriesDataTable.vue"; -import type { CURRENCY, BatteryDetail } from "@/types/evcc"; +import type { CURRENCY, BatteryDetail, DeviceColors } from "@/types/evcc"; import formatter from "@/mixins/formatter"; import colors from "@/colors"; import LegendList from "../Sessions/LegendList.vue"; @@ -79,6 +79,7 @@ export default defineComponent({ type: Array as PropType, default: () => [], }, + deviceColors: { type: Object as PropType, default: () => ({}) }, }, computed: { timeLabels(): string[] { @@ -244,6 +245,9 @@ export default defineComponent({ }; }, legends(): Legend[] { + const batteryTitles = new Set( + (this.evopt?.res?.batteries || []).map((_, i) => this.getBatteryTitle(i)) + ); return this.chartData.datasets .filter((dataset) => !dataset.hidden) .map((dataset) => { @@ -255,6 +259,7 @@ export default defineComponent({ color: (dataset.backgroundColor || dataset.borderColor) as string, value: "", // Required by Legend type, but not used in this context type: isLine ? "line" : "area", + id: batteryTitles.has(label) ? label : undefined, }; }); }, diff --git a/assets/js/components/Sessions/AvgCostGroupedChart.vue b/assets/js/components/Sessions/AvgCostGroupedChart.vue index 5c8f25e97..a62e8446f 100644 --- a/assets/js/components/Sessions/AvgCostGroupedChart.vue +++ b/assets/js/components/Sessions/AvgCostGroupedChart.vue @@ -4,7 +4,7 @@
- +
@@ -17,7 +17,7 @@ import formatter from "@/mixins/formatter"; import colors, { dimColor } from "@/colors"; import LegendList from "./LegendList.vue"; import { defineComponent, type PropType } from "vue"; -import type { CURRENCY } from "@/types/evcc"; +import type { CURRENCY, DeviceColors } from "@/types/evcc"; import { TYPES, GROUPS, type Session } from "./types.ts"; registerChartComponents([RadialLinearScale, ArcElement, Legend, Tooltip]); @@ -34,6 +34,7 @@ export default defineComponent({ default: GROUPS.LOADPOINT, }, colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) }, + deviceColors: { type: Object as PropType, default: () => ({}) }, suggestedMax: { type: Number, default: 0 }, costType: { type: String as PropType, default: TYPES.PRICE }, }, @@ -84,6 +85,7 @@ export default defineComponent({ label: label, color: dataset.borderColor[index], value: this.formatValue(dataValue), + id: label || undefined, }; }); }, diff --git a/assets/js/components/Sessions/CostGroupedChart.vue b/assets/js/components/Sessions/CostGroupedChart.vue index 3f832171e..2b36789b3 100644 --- a/assets/js/components/Sessions/CostGroupedChart.vue +++ b/assets/js/components/Sessions/CostGroupedChart.vue @@ -4,7 +4,7 @@
- +
@@ -25,7 +25,7 @@ import formatter from "@/mixins/formatter"; import colors from "@/colors"; import { TYPES, GROUPS, type Session } from "./types"; import { defineComponent, type PropType } from "vue"; -import { CURRENCY } from "@/types/evcc"; +import { CURRENCY, type DeviceColors } from "@/types/evcc"; registerChartComponents([DoughnutController, ArcElement, LinearScale, Legend, Tooltip]); @@ -40,6 +40,7 @@ export default defineComponent({ default: GROUPS.LOADPOINT, }, colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) }, + deviceColors: { type: Object as PropType, default: () => ({}) }, currency: { type: String as PropType, default: CURRENCY.EUR }, costType: { type: String as PropType, default: TYPES.PRICE }, }, @@ -81,6 +82,7 @@ export default defineComponent({ label: label, color: dataset.backgroundColor[index], value: [this.formatValue(dataValue), fmtShare(dataValue)], + id: label || undefined, }; }); }, diff --git a/assets/js/components/Sessions/CostHistoryChart.vue b/assets/js/components/Sessions/CostHistoryChart.vue index 42d454cc1..eedb19560 100644 --- a/assets/js/components/Sessions/CostHistoryChart.vue +++ b/assets/js/components/Sessions/CostHistoryChart.vue @@ -3,7 +3,7 @@
- + @@ -28,7 +28,7 @@ import LegendList from "./LegendList.vue"; import formatter from "@/mixins/formatter"; import colors from "@/colors"; import { TYPES, GROUPS, PERIODS, type Session } from "./types"; -import { CURRENCY } from "@/types/evcc"; +import { CURRENCY, type DeviceColors } from "@/types/evcc"; registerChartComponents([ BarController, @@ -52,6 +52,7 @@ export default defineComponent({ period: { type: String as PropType, default: PERIODS.TOTAL }, currency: { type: String as PropType, default: CURRENCY.EUR }, colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) }, + deviceColors: { type: Object as PropType, default: () => ({}) }, suggestedMaxAvgCost: { type: Number, default: 0 }, suggestedMaxCost: { type: Number, default: 0 }, }, @@ -195,6 +196,7 @@ export default defineComponent({ }; }, legends() { + const pickable = this.groupBy !== GROUPS.NONE; return this.chartData.datasets.map((dataset) => { let value = null; let type: "area" | "line" = "area"; @@ -225,6 +227,7 @@ export default defineComponent({ color: dataset.backgroundColor, value, type, + id: pickable && type !== "line" ? dataset.label || undefined : undefined, }; }); }, diff --git a/assets/js/components/Sessions/EnergyGroupedChart.vue b/assets/js/components/Sessions/EnergyGroupedChart.vue index 0a902c06c..dc1c3ff00 100644 --- a/assets/js/components/Sessions/EnergyGroupedChart.vue +++ b/assets/js/components/Sessions/EnergyGroupedChart.vue @@ -4,7 +4,7 @@
- +
@@ -25,6 +25,7 @@ import { registerChartComponents, commonOptions, tooltipLabelColor } from "./cha import formatter, { POWER_UNIT } from "@/mixins/formatter"; import colors from "@/colors"; import { GROUPS, type Session } from "./types"; +import type { DeviceColors } from "@/types/evcc"; registerChartComponents([DoughnutController, ArcElement, LinearScale, Legend, Tooltip]); @@ -36,6 +37,7 @@ export default defineComponent({ sessions: { type: Array as PropType, default: () => [] }, groupBy: { type: String as PropType, default: GROUPS.NONE }, colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {}, solar: {} }) }, + deviceColors: { type: Object as PropType, default: () => ({}) }, }, computed: { chartData() { @@ -86,12 +88,14 @@ export default defineComponent({ maxEnergy < 1 ? POWER_UNIT.W : maxEnergy > 1e4 ? POWER_UNIT.MW : POWER_UNIT.KW; const fmtShare = (value: number) => this.fmtPercentage((100 / total) * value, 1); const fmtValue = (value: number) => this.fmtWh(value * 1e3, unit); + const pickable = this.groupBy !== GROUPS.NONE; return this.chartData.labels.map((label, index) => { const dataValue = dataset.data[index] as number; return { label: label, color: dataset.backgroundColor[index], value: [fmtValue(dataValue), fmtShare(dataValue)], + id: pickable ? label || undefined : undefined, }; }); }, diff --git a/assets/js/components/Sessions/EnergyHistoryChart.vue b/assets/js/components/Sessions/EnergyHistoryChart.vue index 6e8323591..e87a8d341 100644 --- a/assets/js/components/Sessions/EnergyHistoryChart.vue +++ b/assets/js/components/Sessions/EnergyHistoryChart.vue @@ -3,7 +3,7 @@
- + @@ -27,6 +27,7 @@ import LegendList from "./LegendList.vue"; import formatter, { POWER_UNIT } from "@/mixins/formatter"; import colors from "@/colors"; import { GROUPS, PERIODS, type Session } from "./types"; +import type { DeviceColors } from "@/types/evcc"; registerChartComponents([BarController, BarElement, CategoryScale, LinearScale, Legend, Tooltip]); @@ -39,6 +40,7 @@ export default defineComponent({ groupBy: { type: String as PropType, default: GROUPS.NONE }, period: { type: String as PropType, default: PERIODS.TOTAL }, colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) }, + deviceColors: { type: Object as PropType, default: () => ({}) }, }, computed: { firstDay() { @@ -148,6 +150,7 @@ export default defineComponent({ }; }, legends() { + const pickable = this.groupBy !== GROUPS.NONE; return this.chartData.datasets.map((dataset) => ({ label: dataset.label || "", color: dataset.backgroundColor, @@ -155,6 +158,7 @@ export default defineComponent({ dataset.data.reduce((acc, curr) => acc + curr, 0) * 1e3, POWER_UNIT.AUTO ), + id: pickable ? dataset.label || undefined : undefined, })); }, options() { diff --git a/assets/js/components/Sessions/LegendList.vue b/assets/js/components/Sessions/LegendList.vue index 6008f2c8c..b3359d5f8 100644 --- a/assets/js/components/Sessions/LegendList.vue +++ b/assets/js/components/Sessions/LegendList.vue @@ -1,6 +1,6 @@ @@ -63,6 +142,15 @@ export default defineComponent({ width: 1rem; height: 1rem; flex-shrink: 0; + display: inline-block; +} +.legend-item--focusable { + cursor: pointer; + user-select: none; + transition: opacity 150ms; +} +.legend-item--dim { + opacity: 0.35; } .legend-color--area { @@ -75,6 +163,19 @@ export default defineComponent({ align-self: center; } +.legend-color-btn { + cursor: pointer; + border-radius: 50%; + transition: box-shadow 120ms ease-out; +} +.legend-color-btn:hover { + box-shadow: 0 0 0 5px color-mix(in srgb, var(--badge-color) 25%, transparent); +} +.legend-color-btn:focus-visible { + outline: 2px solid var(--evcc-default-text); + outline-offset: 2px; +} + .legend-label { flex-shrink: 0; flex-grow: 0; diff --git a/assets/js/components/Sessions/SolarGroupedChart.vue b/assets/js/components/Sessions/SolarGroupedChart.vue index 149a34e41..484959ddf 100644 --- a/assets/js/components/Sessions/SolarGroupedChart.vue +++ b/assets/js/components/Sessions/SolarGroupedChart.vue @@ -4,7 +4,7 @@
- +
@@ -18,6 +18,7 @@ import formatter from "@/mixins/formatter"; import colors, { dimColor } from "@/colors"; import LegendList from "./LegendList.vue"; import { GROUPS, type Session } from "./types.ts"; +import type { DeviceColors } from "@/types/evcc"; registerChartComponents([RadialLinearScale, ArcElement, Legend, Tooltip]); @@ -32,6 +33,7 @@ export default defineComponent({ default: GROUPS.LOADPOINT, }, colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) }, + deviceColors: { type: Object as PropType, default: () => ({}) }, }, computed: { chartData() { @@ -82,6 +84,7 @@ export default defineComponent({ label: label, color: dataset.borderColor[index], value: this.fmtPercentage(dataValue, 1), + id: label || undefined, }; }); }, diff --git a/assets/js/components/Sessions/types.ts b/assets/js/components/Sessions/types.ts index 25ef3ab6c..c15755aaa 100644 --- a/assets/js/components/Sessions/types.ts +++ b/assets/js/components/Sessions/types.ts @@ -21,6 +21,10 @@ export interface Legend { color: any; value: string | string[]; type?: "area" | "line"; + id?: string; + dim?: boolean; + focusable?: boolean; + focusKey?: string | number; } export interface Column { diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 2f9d50a93..c1b036b41 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -24,6 +24,8 @@ declare global { export type AuthProviders = Record; +export type DeviceColors = Record; + export interface MqttConfig { broker: string; topic: string; @@ -109,6 +111,7 @@ export interface State { smartCostAvailable?: boolean; smartCostType?: SMART_COST_TYPE; siteTitle?: string; + deviceColors?: DeviceColors; vehicles: Record; statistics?: Statistics; authDisabled?: boolean; diff --git a/assets/js/views/History.vue b/assets/js/views/History.vue index 3af7ea278..f4bac7ff5 100644 --- a/assets/js/views/History.vue +++ b/assets/js/views/History.vue @@ -86,49 +86,13 @@ :from="displayFrom" :to="displayTo" /> -
    -
  • - - {{ legend.label }} - {{ legend.value }} -
  • -
  • - - - {{ $t("main.history.group.forecast") }} - - - {{ forecastTotalLabel }} - -
  • -
+ class="mt-4" + :legends="legendsForGroup(group)" + :device-colors="deviceColors" + @focus="onLegendFocus(group, $event)" + />

({ + ...l, + focusable: true, + focusKey: l.entityIndex, + dim: focused !== null && focused !== l.entityIndex, + })); + if (this.hasForecastLegend(group)) { + list.push({ + label: this.$t("main.history.group.forecast") as string, + color: groupColor("forecast"), + value: this.forecastTotalLabel, + type: "line", + dim: focused !== null, + }); + } + return list; + }, + onLegendFocus(group: string, legend: Legend) { + if (legend.focusKey == null) return; + this.toggleFocus(group, legend.focusKey as number); + }, hasEntityLegend(group: string): boolean { const list = this.displaySeries(group); if (!list.length) return false; @@ -412,12 +406,22 @@ export default defineComponent({ const list = this.displaySeries(group); const baseColor = groupColor(group); const n = list.length; + const isPickGroup = group === "loadpoint" || group === "meter"; + + // Build palette for pickable groups in displayed order so that user + // overrides take priority and autoassign skips taken palette entries. + let palette: Record = {}; + if (isPickGroup) { + const titles: string[] = []; + for (const s of list) { + if (!s.virtual && !titles.includes(s.name)) titles.push(s.name); + } + palette = resolveColors(titles, this.deviceColors); + } + const colorFor = (i: number, s: HistorySeries) => { if (s.virtual) return colors.muted || baseColor; - if (group === "loadpoint" || group === "meter") { - const idx = s.paletteIndex ?? i; - return colors.palette[idx % colors.palette.length] || baseColor; - } + if (isPickGroup) return palette[s.name] || baseColor; return alphaColor(baseColor, stepAlpha(i, Math.max(n, 1))); }; return list.map((s, i) => { @@ -431,6 +435,7 @@ export default defineComponent({ label: s.name, color: colorFor(i, s), value: this.fmtWh(watts, POWER_UNIT.AUTO), + id: isPickGroup && !s.virtual ? s.name : undefined, }; }); }, @@ -570,24 +575,6 @@ export default defineComponent({ opacity: 0.8; } } -.entity-legend { - list-style: none; -} -.entity-legend-item { - cursor: pointer; - transition: opacity 150ms; - user-select: none; -} -.entity-legend-item--dim { - opacity: 0.35; -} -.entity-legend-dot { - display: inline-block; - width: 1rem; - height: 1rem; - border-radius: 50%; - flex-shrink: 0; -} .history-csv-link { text-decoration: none; } @@ -596,13 +583,6 @@ export default defineComponent({ color: var(--evcc-default-text); text-decoration: underline; } -.entity-legend-dot--line { - height: 2px; - border-radius: 1px; -} -.entity-legend-item--static { - cursor: default; -} .history-tile-title { margin: 0 0 0.5rem; } diff --git a/assets/js/views/Optimize.vue b/assets/js/views/Optimize.vue index 37990f5a8..1d6f011d4 100644 --- a/assets/js/views/Optimize.vue +++ b/assets/js/views/Optimize.vue @@ -43,6 +43,7 @@ :timestamp="evopt.details.timestamp[0]" :currency="currency" :battery-colors="batteryColors" + :device-colors="deviceColors" />

Result: SoC Projection

@@ -146,7 +147,7 @@ import { formatCompactJson } from "../components/Optimize/compactJson"; import api from "../api"; import store from "../store"; import formatter from "../mixins/formatter"; -import colors from "../colors"; +import { resolveColors } from "../colors"; import { CURRENCY } from "../types/evcc"; export default defineComponent({ @@ -190,12 +191,19 @@ export default defineComponent({ return "bg-secondary"; } }, + deviceColors() { + return store.state.deviceColors ?? {}; + }, + batteryTitles(): string[] { + const details = this.evopt?.details?.batteryDetails || []; + return (this.evopt?.res.batteries || []).map( + (_, i) => details[i]?.title || details[i]?.name || `battery-${i}` + ); + }, batteryColors() { if (!this.evopt?.res.batteries) return []; - - return this.evopt.res.batteries.map( - (_, index) => colors.palette[index % colors.palette.length] || "" - ); + const palette = resolveColors(this.batteryTitles, this.deviceColors); + return this.batteryTitles.map((t) => palette[t] || ""); }, dimmedBatteryColors() { return (this.batteryColors || []).map((color) => this.dimColorBy25Percent(color)); diff --git a/assets/js/views/Sessions.vue b/assets/js/views/Sessions.vue index 08e939cb9..2ca3bf132 100644 --- a/assets/js/views/Sessions.vue +++ b/assets/js/views/Sessions.vue @@ -78,6 +78,7 @@ class="mb-5" :sessions="currentSessions" :color-mappings="colorMappings" + :device-colors="deviceColors" :group-by="selectedGroup" :period="period" /> @@ -86,6 +87,7 @@ class="mb-5" :sessions="currentTypeSessions" :color-mappings="colorMappings" + :device-colors="deviceColors" :group-by="selectedGroup" :cost-type="activeType" :currency="currency" @@ -106,6 +108,7 @@ v-else :sessions="currentSessions" :color-mappings="colorMappings" + :device-colors="deviceColors" :group-by="selectedGroupWithoutNone" /> @@ -113,6 +116,7 @@ v-else :sessions="currentTypeSessions" :color-mappings="colorMappings" + :device-colors="deviceColors" :suggested-max-price="suggestedMaxAvgCost" :group-by="selectedGroupWithoutNone" :cost-type="activeType" @@ -125,12 +129,14 @@ v-if="activeType === types.SOLAR" :sessions="currentSessions" :color-mappings="colorMappings" + :device-colors="deviceColors" :group-by="selectedGroupWithoutNone" /> , colorType: Exclude - ) => { - const result: Record = {}; - let colorIndex = 0; - - // Assign colors by used energy in the last three months - const sortedEntries = Object.entries(energyAggregation).sort((a, b) => b[1] - a[1]); - sortedEntries.forEach(([key]) => { - if (key && !result[key]) { - result[key] = colors.palette[colorIndex % colors.palette.length] || ""; - colorIndex++; + ): string[] => { + const ranked = Object.entries(energyAggregation) + .sort((a, b) => b[1] - a[1]) + .map(([k]) => k) + .filter(Boolean); + const seen = new Set(ranked); + for (const s of this.sessionsWithDefaults) { + const k = s[colorType]; + if (k && !seen.has(k)) { + ranked.push(k); + seen.add(k); } - }); - - // Assign colors to remaining entries - this.sessionsWithDefaults.forEach((session) => { - const key = session[colorType]; - if (key && !result[key]) { - result[key] = colors.palette[colorIndex % colors.palette.length] || ""; - colorIndex++; - } - }); - - return result; + } + return ranked; }; - const loadpointEnergy = aggregateEnergy(GROUPS.LOADPOINT); - const loadpointColors = assignColors(loadpointEnergy, GROUPS.LOADPOINT); - - const vehicleEnergy = aggregateEnergy(GROUPS.VEHICLE); - const vehicleColors = assignColors(vehicleEnergy, GROUPS.VEHICLE); + const overrides = this.deviceColors; + const loadpointColors = resolveColors( + orderedKeys(aggregateEnergy(GROUPS.LOADPOINT), GROUPS.LOADPOINT), + overrides + ); + const vehicleColors = resolveColors( + orderedKeys(aggregateEnergy(GROUPS.VEHICLE), GROUPS.VEHICLE), + overrides + ); const solar = { self: colors.self, grid: colors.grid }; const cost = { price: colors.price, co2: colors.co2 }; diff --git a/cmd/root.go b/cmd/root.go index 9e3a28921..0d43db9de 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -24,6 +24,7 @@ import ( "github.com/evcc-io/evcc/server/network" "github.com/evcc-io/evcc/server/remote" "github.com/evcc-io/evcc/server/updater" + "github.com/evcc-io/evcc/ui" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/auth" "github.com/evcc-io/evcc/util/pipe" @@ -360,6 +361,7 @@ func runRoot(cmd *cobra.Command, args []string) { } // publish initial settings + valueChan <- util.Param{Key: keys.DeviceColors, Val: ui.GetDeviceColors()} valueChan <- util.Param{Key: keys.EEBus, Val: globalconfig.ConfigStatus{ Config: conf.EEBus.Redacted(), Status: eebus.GetStatus(), diff --git a/core/keys/global.go b/core/keys/global.go index 2be399c4d..dc33185cf 100644 --- a/core/keys/global.go +++ b/core/keys/global.go @@ -36,4 +36,5 @@ const ( RemoteLastSeen = "remoteLastSeen" AuthDisabled = "authDisabled" AuthProviders = "authProviders" + DeviceColors = "deviceColors" ) diff --git a/i18n/de.json b/i18n/de.json index 6c5ad01e8..f2770b245 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -46,6 +46,10 @@ "noBattery": "Keine Batterien konfiguriert.", "usageTab": "Batterienutzung" }, + "colors": { + "auto": "Auto", + "hex": "Hex-Farbe" + }, "config": { "aux": { "description": "Gerät, das seinen Verbrauch basierend auf verfügbarem Überschuss (z. B. smarter Heizstab) selbstständig anpasst. evcc erwartet, dass dieses Gerät selbstständig seine Leistungsaufnahme reduziert, wenn es notwendig ist.", diff --git a/i18n/en.json b/i18n/en.json index 90e9bf61f..582e88b45 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -46,6 +46,10 @@ "noBattery": "No batteries configured.", "usageTab": "Battery usage" }, + "colors": { + "auto": "Auto", + "hex": "Hex color" + }, "config": { "aux": { "description": "Device that adjusts its consumption based on available surplus (like smart water heaters). evcc expects that this device reduces its power consumption if needed.", diff --git a/server/http.go b/server/http.go index 508b61b57..f60f0695f 100644 --- a/server/http.go +++ b/server/http.go @@ -162,6 +162,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) { "energyhistory": {"GET", "/history/energy", energyHistoryHandler}, "optimize": {"POST", "/optimize", getHandler(site.Optimize)}, "telemetry2": {"POST", "/settings/telemetry/{value:[01truefalse]+}", boolHandler(telemetry.Enable, telemetry.Enabled)}, + "devicecolors": {"PUT", "/devicecolors", updateDeviceColor(site)}, } for _, r := range routes { diff --git a/server/http_devicecolors_handler.go b/server/http_devicecolors_handler.go new file mode 100644 index 000000000..bdcede30c --- /dev/null +++ b/server/http_devicecolors_handler.go @@ -0,0 +1,54 @@ +package server + +import ( + "encoding/json" + "errors" + "maps" + "net/http" + "regexp" + + "github.com/evcc-io/evcc/core/keys" + "github.com/evcc-io/evcc/core/site" + "github.com/evcc-io/evcc/ui" +) + +var hexColorRE = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) + +// updateDeviceColor sets/removes a single title→hex association. +func updateDeviceColor(site site.API) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req struct { + Title string `json:"title"` + Color string `json:"color"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + if req.Title == "" { + jsonError(w, http.StatusBadRequest, errors.New("title required")) + return + } + color := req.Color + if color != "" && !hexColorRE.MatchString(color) { + jsonError(w, http.StatusBadRequest, errors.New("invalid hex color")) + return + } + + m := ui.GetDeviceColors() + m[req.Title] = color + + // clone — Publish serializes async + site.Publish(keys.DeviceColors, maps.Clone(m)) + + // delete only after publish to enforce update in client + if color == "" { + delete(m, req.Title) + } + if err := ui.SaveDeviceColors(m); err != nil { + jsonError(w, http.StatusInternalServerError, err) + return + } + jsonWrite(w, m) + } +} diff --git a/tests/device-colors.evcc.yaml b/tests/device-colors.evcc.yaml new file mode 100644 index 000000000..4866b1397 --- /dev/null +++ b/tests/device-colors.evcc.yaml @@ -0,0 +1,34 @@ +interval: 0.1s + +site: + title: Colors + meters: + grid: grid + +meters: + - name: grid + type: template + template: demo-meter + power: 200 + +loadpoints: + - title: Garage + charger: charger1 + vehicle: honda + - title: Carport + charger: charger2 + vehicle: honda + +vehicles: + - name: honda + title: Honda + +chargers: + - name: charger1 + type: template + template: demo-charger + status: B + - name: charger2 + type: template + template: demo-charger + status: B diff --git a/tests/device-colors.spec.ts b/tests/device-colors.spec.ts new file mode 100644 index 000000000..f532e936d --- /dev/null +++ b/tests/device-colors.spec.ts @@ -0,0 +1,83 @@ +import { test, expect, type Locator, type Page } from "@playwright/test"; +import { start, stop, baseUrl } from "./evcc"; + +test.use({ baseURL: baseUrl() }); + +// from assets/js/colors.ts palette +const SKY = "rgb(14, 165, 233)"; // #0EA5E9 — palette[0] +const PINK = "rgb(236, 72, 153)"; // #EC4899 — palette[1] +const CUSTOM_HEX = "FF0080"; +const CUSTOM_RGB = "rgb(255, 0, 128)"; + +test.beforeAll(async () => { + await start("device-colors.evcc.yaml", "device-colors.sql"); +}); +test.afterAll(async () => { + await stop(); +}); + +function legendBadge(scope: Locator | Page, label: string): Locator { + // first match — multiple charts can show the same legend + return scope.getByLabel(label, { exact: true }).first(); +} + +async function expectLegendColor(scope: Locator | Page, label: string, rgb: string): Promise { + const dot = legendBadge(scope, label).locator("span").first(); + await expect(dot).toHaveCSS("background-color", rgb); +} + +test("device colors: autoassign, override, persistence", async ({ page }) => { + // ---------- Step 1 — Sessions, by-vehicle view ---------- + await page.goto("/#/sessions?year=2026&month=5"); + await expect(page.getByRole("heading", { name: "Charging Sessions" })).toBeVisible(); + await page.getByRole("button", { name: "Vehicle", exact: true }).click(); + await expectLegendColor(page, "Honda", SKY); + + // ---------- Step 2 — Sessions, by-loadpoint + override ---------- + await page.getByRole("button", { name: "Charging point", exact: true }).click(); + // initial autoassign — Garage (more energy) → palette[0], Carport → palette[1] + await expectLegendColor(page, "Garage", SKY); + await expectLegendColor(page, "Carport", PINK); + + // open picker on Garage badge; second palette swatch = PINK + await legendBadge(page, "Garage").click(); + const popover = page.getByRole("dialog"); + await expect(popover).toBeVisible(); + // palette swatches have title=hex + await popover.getByTitle("#EC4899").click(); + await page.keyboard.press("Escape"); + + await expectLegendColor(page, "Garage", PINK); + // non-collision: Carport must shift off PINK to the first free entry (SKY) + await expectLegendColor(page, "Carport", SKY); + + // ---------- Step 3 — History view, same colors ---------- + await page.goto("/#/history?period=day&year=2026&month=5&day=15"); + const lpSection = page + .locator("section") + .filter({ has: page.getByRole("heading", { name: "Charging & Heating" }) }); + await expect(lpSection).toBeVisible({ timeout: 10000 }); + await expectLegendColor(lpSection, "Garage", PINK); + await expectLegendColor(lpSection, "Carport", SKY); + + // ---------- Step 4 — Custom hex on ext meter, reload ---------- + const meterSection = page + .locator("section") + .filter({ has: page.getByRole("heading", { name: "Consumption" }) }); + await expect(meterSection).toBeVisible(); + await legendBadge(meterSection, "Dishwasher").click(); + const popover2 = page.getByRole("dialog"); + await expect(popover2).toBeVisible(); + // save-on-type fires when input matches hex regex + await popover2.getByLabel("Hex color").fill(CUSTOM_HEX); + await page.keyboard.press("Escape"); + + await expectLegendColor(meterSection, "Dishwasher", CUSTOM_RGB); + + // reload: color persists via settings DB + await page.reload(); + const meterSection2 = page + .locator("section") + .filter({ has: page.getByRole("heading", { name: "Consumption" }) }); + await expectLegendColor(meterSection2, "Dishwasher", CUSTOM_RGB); +}); diff --git a/tests/device-colors.sql b/tests/device-colors.sql new file mode 100644 index 000000000..8d47841f6 --- /dev/null +++ b/tests/device-colors.sql @@ -0,0 +1,73 @@ +CREATE TABLE `sessions` ( + `id` integer, + `created` datetime, + `finished` datetime, + `loadpoint` text, + `identifier` text, + `vehicle` text, + `meter_start_kwh` real, + `meter_end_kwh` real, + `charged_kwh` real, + `odometer` real, + `solar_percentage` real, + `price` real, + `price_per_kwh` real, + `co2_per_kwh` real, + `charge_duration` integer, + PRIMARY KEY (`id`) +); + +CREATE TABLE `entities` ( + `id` integer, + `group` text, + `name` text, + PRIMARY KEY (`id`) +); +CREATE UNIQUE INDEX `entities_group_name` ON `entities`(`group`, `name`); + +CREATE TABLE `meters` ( + `meter` integer, + `ts` integer, + `import` real, + `export` real +); +CREATE UNIQUE INDEX `meters_meter_ts` ON `meters`(`meter`, `ts`); + +CREATE TABLE `settings` ( + `key` text + , `value` text + , PRIMARY KEY(`key`) +); + +CREATE TABLE `configs` ( + `id` integer PRIMARY KEY AUTOINCREMENT + , `class` integer + , `type` text + , `title` text + , `icon` text + , `product` text + , `value` text +); + +-- Honda charged on each loadpoint, Garage has more energy than Carport +INSERT INTO `sessions` VALUES (1, '2026-05-01 08:00:00.0+02:00', '2026-05-01 09:00:00.0+02:00', 'Garage', NULL, 'Honda', NULL, NULL, 8.0, NULL, 50, 1.0, 0.2, 100, 3600000000000); +INSERT INTO `sessions` VALUES (2, '2026-05-02 08:00:00.0+02:00', '2026-05-02 09:00:00.0+02:00', 'Carport', NULL, 'Honda', NULL, NULL, 4.0, NULL, 50, 0.5, 0.2, 50, 3600000000000); + +-- db-backed ext meter (class=2 meter, title=Dishwasher, usage=ext) +INSERT INTO `configs`(id, class, type, title, icon, product, value) + VALUES (1, 2, 'template', 'Dishwasher', '', 'Demo meter', + '{"power":"100","template":"demo-meter","usage":"ext"}'); + +-- assign ext meter ref + enable experimental +INSERT INTO `settings`(key, value) VALUES ('extMeters', 'db:1'); +INSERT INTO `settings`(key, value) VALUES ('experimental', 'true'); + +-- history entities matching evcc internal names +INSERT INTO `entities` VALUES (1, 'loadpoint', 'lp-1'); +INSERT INTO `entities` VALUES (2, 'loadpoint', 'lp-2'); +INSERT INTO `entities` VALUES (3, 'meter', 'db:1'); + +-- one 15-min slot at 2026-05-15 12:00:00+02:00 (ts=1778839200) per entity +INSERT INTO `meters` VALUES (1, 1778839200, 2.0, 0); +INSERT INTO `meters` VALUES (2, 1778839200, 1.0, 0); +INSERT INTO `meters` VALUES (3, 1778839200, 0.5, 0); diff --git a/ui/colors.go b/ui/colors.go new file mode 100644 index 000000000..b4960d09c --- /dev/null +++ b/ui/colors.go @@ -0,0 +1,30 @@ +// Package ui persists UI-only state (color overrides, etc.). +package ui + +import ( + "maps" + "sync" + + "github.com/evcc-io/evcc/core/keys" + "github.com/evcc-io/evcc/server/db/settings" +) + +var colorsMu sync.RWMutex + +// GetDeviceColors returns the persisted title→hex map (never nil). +func GetDeviceColors() map[string]string { + colorsMu.RLock() + defer colorsMu.RUnlock() + m := map[string]string{} + _ = settings.Json(keys.DeviceColors, &m) + return m +} + +// SaveDeviceColors persists the title→hex map. +func SaveDeviceColors(m map[string]string) error { + colorsMu.Lock() + defer colorsMu.Unlock() + clean := make(map[string]string, len(m)) + maps.Copy(clean, m) + return settings.SetJson(keys.DeviceColors, clean) +}