Charts: color picker for loadpoints and consumers (#30021)
This commit is contained in:
parent
41c169ee9a
commit
62ecd175a6
27 changed files with 994 additions and 144 deletions
90
assets/js/colors.test.ts
Normal file
90
assets/js/colors.test.ts
Normal file
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
310
assets/js/components/Helper/ColorPickerPopover.vue
Normal file
310
assets/js/components/Helper/ColorPickerPopover.vue
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="modelValue"
|
||||
ref="popoverEl"
|
||||
class="color-picker-popover shadow rounded-3 border"
|
||||
role="dialog"
|
||||
:aria-label="title || 'Color picker'"
|
||||
@click.stop
|
||||
>
|
||||
<div class="footer-row">
|
||||
<button
|
||||
type="button"
|
||||
class="auto-btn text-truncate text-uppercase fw-semibold text-body border rounded bg-body"
|
||||
:class="{ 'is-selected': mode === 'auto' }"
|
||||
@click="pickAuto"
|
||||
>
|
||||
{{ $t("colors.auto") }}
|
||||
</button>
|
||||
<div
|
||||
class="custom-input-wrap d-flex align-items-center border rounded bg-body"
|
||||
:class="{ 'is-selected': mode === 'custom' }"
|
||||
>
|
||||
<label
|
||||
class="custom-preview position-relative overflow-hidden border rounded flex-shrink-0"
|
||||
:style="{ backgroundColor: customHex || '#00000000' }"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
class="custom-native position-absolute opacity-0 p-0 border-0 bg-transparent w-100 h-100 top-0 start-0"
|
||||
:value="nativeColorValue"
|
||||
@input="onNativeInput"
|
||||
/>
|
||||
</label>
|
||||
<span class="custom-hash text-secondary">#</span>
|
||||
<input
|
||||
ref="hexInputEl"
|
||||
type="text"
|
||||
class="custom-input border-0 bg-transparent font-monospace text-uppercase text-body flex-grow-1"
|
||||
:value="hexInputValue"
|
||||
:aria-label="$t('colors.hex')"
|
||||
spellcheck="false"
|
||||
maxlength="6"
|
||||
@input="onHexInput"
|
||||
@focus="hexFocused = true"
|
||||
@blur="onHexBlur"
|
||||
@keydown.enter.prevent="onHexCommit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swatch-grid border-top mt-2 pt-2">
|
||||
<button
|
||||
v-for="hex in palette"
|
||||
:key="hex"
|
||||
type="button"
|
||||
class="swatch border-0 p-0 d-flex align-items-center justify-content-center position-relative rounded text-white"
|
||||
:class="{ 'swatch--selected': isSwatchSelected(hex) }"
|
||||
:style="{ backgroundColor: hex }"
|
||||
:title="hex"
|
||||
@click="pick(hex)"
|
||||
>
|
||||
<svg
|
||||
v-if="isSwatchSelected(hex)"
|
||||
class="swatch-check"
|
||||
viewBox="0 0 16 16"
|
||||
width="14"
|
||||
height="14"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M3.5 8.5l3 3 6-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="popover-arrow" data-popper-arrow></div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import { createPopper, type Instance as PopperInstance } from "@popperjs/core";
|
||||
import colors, { HEX_RE, normalizeHex } from "../../colors";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ColorPickerPopover",
|
||||
props: {
|
||||
modelValue: { type: Boolean, default: false },
|
||||
anchorEl: { type: Object as PropType<HTMLElement | null>, default: null },
|
||||
color: { type: String as PropType<string | null>, default: null },
|
||||
title: { type: String, default: "" },
|
||||
},
|
||||
emits: ["update:modelValue", "update:color"],
|
||||
data() {
|
||||
return {
|
||||
palette: colors.palette,
|
||||
popper: null as PopperInstance | null,
|
||||
hexInputValue: "",
|
||||
hexFocused: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
customHex(): string {
|
||||
return normalizeHex(this.color);
|
||||
},
|
||||
nativeColorValue(): string {
|
||||
return this.customHex || "#000000";
|
||||
},
|
||||
matchesPalette(): boolean {
|
||||
if (!this.customHex) return false;
|
||||
return this.palette.some((p) => normalizeHex(p) === this.customHex);
|
||||
},
|
||||
mode(): "auto" | "palette" | "custom" {
|
||||
if (this.hexFocused) return "custom";
|
||||
if (!this.customHex) return "auto";
|
||||
if (this.matchesPalette) return "palette";
|
||||
return "custom";
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
modelValue(open: boolean) {
|
||||
if (open) {
|
||||
this.hexInputValue = this.customHex.replace(/^#/, "");
|
||||
this.hexFocused = false;
|
||||
this.$nextTick(this.initPopper);
|
||||
document.addEventListener("mousedown", this.onDocClick, true);
|
||||
document.addEventListener("keydown", this.onKey, true);
|
||||
} else {
|
||||
this.teardown();
|
||||
}
|
||||
},
|
||||
color(c: string) {
|
||||
this.hexInputValue = normalizeHex(c).replace(/^#/, "");
|
||||
},
|
||||
anchorEl() {
|
||||
if (this.modelValue) this.$nextTick(this.initPopper);
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.teardown();
|
||||
},
|
||||
methods: {
|
||||
isSwatchSelected(hex: string): boolean {
|
||||
if (!this.customHex) return false;
|
||||
return normalizeHex(hex) === this.customHex;
|
||||
},
|
||||
pick(hex: string) {
|
||||
this.$emit("update:color", hex);
|
||||
},
|
||||
pickAuto() {
|
||||
this.hexFocused = false;
|
||||
(this.$refs["hexInputEl"] as HTMLInputElement | undefined)?.blur();
|
||||
this.$emit("update:color", "");
|
||||
},
|
||||
onHexInput(e: Event) {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
this.hexInputValue = v;
|
||||
const trimmed = v.trim();
|
||||
if (HEX_RE.test(trimmed)) {
|
||||
this.$emit("update:color", "#" + trimmed.toUpperCase());
|
||||
}
|
||||
},
|
||||
onHexCommit() {
|
||||
const v = this.hexInputValue.trim();
|
||||
if (v === "") {
|
||||
this.$emit("update:color", "");
|
||||
return;
|
||||
}
|
||||
if (HEX_RE.test(v)) {
|
||||
this.$emit("update:color", "#" + v.toUpperCase());
|
||||
} else {
|
||||
this.hexInputValue = this.customHex.replace(/^#/, "");
|
||||
}
|
||||
},
|
||||
onHexBlur() {
|
||||
this.hexFocused = false;
|
||||
this.onHexCommit();
|
||||
},
|
||||
onNativeInput(e: Event) {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
this.$emit("update:color", v.toUpperCase());
|
||||
},
|
||||
initPopper() {
|
||||
if (!this.anchorEl || !this.$refs["popoverEl"]) return;
|
||||
this.popper?.destroy();
|
||||
this.popper = createPopper(this.anchorEl, this.$refs["popoverEl"] as HTMLElement, {
|
||||
placement: "top",
|
||||
modifiers: [
|
||||
{ name: "offset", options: { offset: [0, 10] } },
|
||||
{ name: "arrow", options: { padding: 8 } },
|
||||
{ name: "preventOverflow", options: { padding: 8 } },
|
||||
],
|
||||
});
|
||||
},
|
||||
teardown() {
|
||||
this.popper?.destroy();
|
||||
this.popper = null;
|
||||
document.removeEventListener("mousedown", this.onDocClick, true);
|
||||
document.removeEventListener("keydown", this.onKey, true);
|
||||
},
|
||||
onDocClick(e: MouseEvent) {
|
||||
const t = e.target as Node;
|
||||
const inside =
|
||||
(this.$refs["popoverEl"] as HTMLElement | undefined)?.contains(t) ||
|
||||
this.anchorEl?.contains(t);
|
||||
if (!inside) this.close();
|
||||
},
|
||||
onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") this.close();
|
||||
},
|
||||
close() {
|
||||
this.$emit("update:modelValue", false);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.color-picker-popover {
|
||||
z-index: 1080;
|
||||
background: var(--evcc-box);
|
||||
padding: 0.6rem;
|
||||
width: 16rem;
|
||||
}
|
||||
.swatch-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.swatch {
|
||||
cursor: pointer;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
.swatch--selected {
|
||||
outline: 1px solid var(--evcc-default-text);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.swatch-check {
|
||||
pointer-events: none;
|
||||
}
|
||||
.footer-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.auto-btn {
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.06em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.auto-btn.is-selected {
|
||||
border-color: var(--evcc-default-text) !important;
|
||||
}
|
||||
.custom-input-wrap {
|
||||
padding: 0.15rem 0.5rem;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.custom-input-wrap.is-selected {
|
||||
border-color: var(--evcc-default-text) !important;
|
||||
}
|
||||
.custom-preview {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.custom-native {
|
||||
cursor: pointer;
|
||||
}
|
||||
.custom-input {
|
||||
width: 7ch;
|
||||
min-width: 0;
|
||||
font-size: 0.8rem;
|
||||
outline: none;
|
||||
padding: 0.15rem 0;
|
||||
color: inherit;
|
||||
}
|
||||
.popover-arrow {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
.color-picker-popover[data-popper-placement^="top"] .popover-arrow {
|
||||
bottom: -6px;
|
||||
}
|
||||
.color-picker-popover[data-popper-placement^="bottom"] .popover-arrow {
|
||||
top: -6px;
|
||||
}
|
||||
.popover-arrow::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--evcc-box);
|
||||
border-right: 1px solid var(--bs-border-color-translucent);
|
||||
border-bottom: 1px solid var(--bs-border-color-translucent);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.color-picker-popover[data-popper-placement^="bottom"] .popover-arrow::before {
|
||||
transform: rotate(225deg);
|
||||
top: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
:height="300"
|
||||
/>
|
||||
</div>
|
||||
<LegendList :legends="legends" />
|
||||
<LegendList :legends="legends" :device-colors="deviceColors" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -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<string[]>,
|
||||
default: () => [],
|
||||
},
|
||||
deviceColors: { type: Object as PropType<DeviceColors>, 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,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<PolarArea :data="chartData" :options="options" />
|
||||
</div>
|
||||
<div class="col-12 col-md-6 d-flex align-items-center py-0 py-md-3">
|
||||
<LegendList :legends="legends" grid />
|
||||
<LegendList :legends="legends" :device-colors="deviceColors" grid />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -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<DeviceColors>, default: () => ({}) },
|
||||
suggestedMax: { type: Number, default: 0 },
|
||||
costType: { type: String as PropType<TYPES>, default: TYPES.PRICE },
|
||||
},
|
||||
|
|
@ -84,6 +85,7 @@ export default defineComponent({
|
|||
label: label,
|
||||
color: dataset.borderColor[index],
|
||||
value: this.formatValue(dataValue),
|
||||
id: label || undefined,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<Doughnut :data="chartData" :options="options" />
|
||||
</div>
|
||||
<div class="col-12 col-md-6 d-flex align-items-center">
|
||||
<LegendList :legends="legends" grid />
|
||||
<LegendList :legends="legends" :device-colors="deviceColors" grid />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -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<DeviceColors>, default: () => ({}) },
|
||||
currency: { type: String as PropType<CURRENCY>, default: CURRENCY.EUR },
|
||||
costType: { type: String as PropType<TYPES>, 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,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<div style="position: relative; height: 300px" class="my-3">
|
||||
<Bar :data="chartData" :options="options" />
|
||||
</div>
|
||||
<LegendList :legends="legends" />
|
||||
<LegendList :legends="legends" :device-colors="deviceColors" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -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<PERIODS>, default: PERIODS.TOTAL },
|
||||
currency: { type: String as PropType<CURRENCY>, default: CURRENCY.EUR },
|
||||
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) },
|
||||
deviceColors: { type: Object as PropType<DeviceColors>, 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,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<Doughnut :data="chartData" :options="options" />
|
||||
</div>
|
||||
<div class="col-12 col-md-6 d-flex align-items-center">
|
||||
<LegendList :legends="legends" grid />
|
||||
<LegendList :legends="legends" :device-colors="deviceColors" grid />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -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<Session[]>, default: () => [] },
|
||||
groupBy: { type: String as PropType<GROUPS>, default: GROUPS.NONE },
|
||||
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {}, solar: {} }) },
|
||||
deviceColors: { type: Object as PropType<DeviceColors>, 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,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<div style="position: relative; height: 300px" class="my-3">
|
||||
<Bar :data="chartData" :options="options" />
|
||||
</div>
|
||||
<LegendList :legends="legends" />
|
||||
<LegendList :legends="legends" :device-colors="deviceColors" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -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<GROUPS>, default: GROUPS.NONE },
|
||||
period: { type: String as PropType<PERIODS>, default: PERIODS.TOTAL },
|
||||
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) },
|
||||
deviceColors: { type: Object as PropType<DeviceColors>, 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() {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<ul
|
||||
class="root p-0 d-flex flex-wrap column-gap-4 row-gap-2 overflow-hidden"
|
||||
class="root p-0 d-flex flex-wrap column-gap-4 row-gap-2"
|
||||
:class="{
|
||||
'root--small-equal-widths': smallEqualWidths,
|
||||
'root--grid': grid,
|
||||
|
|
@ -9,17 +9,43 @@
|
|||
<li
|
||||
v-for="legend in legends"
|
||||
:key="legend.label"
|
||||
class="legend-item d-flex align-items-baseline gap-2 no-wrap overflow-hidden"
|
||||
class="legend-item d-flex align-items-baseline gap-2 no-wrap"
|
||||
:class="{
|
||||
'legend-item--focusable': legend.focusable,
|
||||
'legend-item--dim': legend.dim,
|
||||
}"
|
||||
:role="legend.focusable ? 'button' : undefined"
|
||||
:tabindex="legend.focusable ? 0 : undefined"
|
||||
@click="legend.focusable && onFocus(legend)"
|
||||
@keydown.enter.prevent="legend.focusable && onFocus(legend)"
|
||||
@keydown.space.prevent="legend.focusable && onFocus(legend)"
|
||||
>
|
||||
<div
|
||||
v-if="legend.color"
|
||||
<button
|
||||
v-if="legend.color && isPickable(legend)"
|
||||
type="button"
|
||||
class="legend-color-btn align-self-center me-1 bg-transparent border-0 p-0 d-inline-flex"
|
||||
:style="{ '--badge-color': legend.color }"
|
||||
:aria-label="legend.label"
|
||||
@click.stop="onPick(legend, $event)"
|
||||
>
|
||||
<span
|
||||
class="legend-color"
|
||||
:class="colorClass(legend)"
|
||||
:style="{
|
||||
backgroundColor: legend.color,
|
||||
borderColor: legend.color,
|
||||
}"
|
||||
></span>
|
||||
</button>
|
||||
<span
|
||||
v-else-if="legend.color"
|
||||
class="legend-color align-self-center me-1"
|
||||
:class="colorClass(legend)"
|
||||
:style="{
|
||||
backgroundColor: legend.color,
|
||||
borderColor: legend.color,
|
||||
}"
|
||||
></div>
|
||||
></span>
|
||||
<div class="legend-label text-nowrap">{{ legend.label }}</div>
|
||||
<div
|
||||
v-for="value in valueList(legend.value)"
|
||||
|
|
@ -29,19 +55,48 @@
|
|||
{{ value }}
|
||||
</div>
|
||||
</li>
|
||||
<ColorPickerPopover
|
||||
v-model="pickerOpen"
|
||||
:anchor-el="pickerAnchor"
|
||||
:color="pickerColor"
|
||||
:title="pickerTitle"
|
||||
@update:color="onColorChange"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import type { Legend } from "./types";
|
||||
import type { DeviceColors } from "@/types/evcc";
|
||||
import ColorPickerPopover from "../Helper/ColorPickerPopover.vue";
|
||||
import api from "../../api";
|
||||
|
||||
export default defineComponent({
|
||||
name: "LegendList",
|
||||
components: { ColorPickerPopover },
|
||||
props: {
|
||||
legends: Array as PropType<Legend[]>,
|
||||
grid: Boolean,
|
||||
smallEqualWidths: Boolean,
|
||||
deviceColors: { type: Object as PropType<DeviceColors>, default: () => ({}) },
|
||||
},
|
||||
emits: ["focus"],
|
||||
data() {
|
||||
return {
|
||||
pickerOpen: false,
|
||||
pickerAnchor: null as HTMLElement | null,
|
||||
pickerId: "",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
pickerTitle(): string {
|
||||
const found = (this.legends || []).find((l) => l.id === this.pickerId);
|
||||
return found?.label || this.pickerId;
|
||||
},
|
||||
pickerColor(): string {
|
||||
return this.deviceColors[this.pickerId] || "";
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
valueList(value: Legend["value"]) {
|
||||
|
|
@ -51,6 +106,30 @@ export default defineComponent({
|
|||
colorClass(legend: Legend) {
|
||||
return legend.type === "line" ? "legend-color--line" : "legend-color--area";
|
||||
},
|
||||
isPickable(legend: Legend) {
|
||||
return !!legend.id;
|
||||
},
|
||||
onPick(legend: Legend, e: MouseEvent) {
|
||||
if (!legend.id) return;
|
||||
if (this.pickerOpen && this.pickerId === legend.id) {
|
||||
this.pickerOpen = false;
|
||||
return;
|
||||
}
|
||||
this.pickerAnchor = (e.currentTarget as HTMLElement) || null;
|
||||
this.pickerId = legend.id;
|
||||
this.pickerOpen = true;
|
||||
},
|
||||
onFocus(legend: Legend) {
|
||||
this.$emit("focus", legend);
|
||||
},
|
||||
async onColorChange(color: string) {
|
||||
if (!this.pickerId) return;
|
||||
try {
|
||||
await api.put("devicecolors", { title: this.pickerId, color });
|
||||
} catch (e) {
|
||||
console.error("set device color failed", e);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<PolarArea :data="chartData" :options="options" />
|
||||
</div>
|
||||
<div class="col-12 col-md-6 d-flex align-items-center py-0 py-md-3">
|
||||
<LegendList :legends="legends" grid />
|
||||
<LegendList :legends="legends" :device-colors="deviceColors" grid />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -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<DeviceColors>, default: () => ({}) },
|
||||
},
|
||||
computed: {
|
||||
chartData() {
|
||||
|
|
@ -82,6 +84,7 @@ export default defineComponent({
|
|||
label: label,
|
||||
color: dataset.borderColor[index],
|
||||
value: this.fmtPercentage(dataValue, 1),
|
||||
id: label || undefined,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ declare global {
|
|||
|
||||
export type AuthProviders = Record<string, { id: string; authenticated: boolean }>;
|
||||
|
||||
export type DeviceColors = Record<string, string>;
|
||||
|
||||
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<string, Vehicle>;
|
||||
statistics?: Statistics;
|
||||
authDisabled?: boolean;
|
||||
|
|
|
|||
|
|
@ -86,49 +86,13 @@
|
|||
:from="displayFrom"
|
||||
:to="displayTo"
|
||||
/>
|
||||
<ul
|
||||
<LegendList
|
||||
v-if="hasEntityLegend(group) || hasForecastLegend(group)"
|
||||
class="entity-legend p-0 mt-4 mb-0 d-flex flex-wrap column-gap-4 row-gap-2"
|
||||
>
|
||||
<li
|
||||
v-for="legend in entityLegends(group)"
|
||||
:key="legend.entityIndex"
|
||||
class="entity-legend-item d-flex align-items-baseline gap-2 no-wrap"
|
||||
:class="{
|
||||
'entity-legend-item--dim': isDimmed(group, legend.entityIndex),
|
||||
}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="toggleFocus(group, legend.entityIndex)"
|
||||
@keydown.enter.prevent="toggleFocus(group, legend.entityIndex)"
|
||||
@keydown.space.prevent="toggleFocus(group, legend.entityIndex)"
|
||||
>
|
||||
<span
|
||||
class="entity-legend-dot align-self-center"
|
||||
:style="{ backgroundColor: legend.color }"
|
||||
></span>
|
||||
<span class="text-nowrap">{{ legend.label }}</span>
|
||||
<span class="text-muted text-nowrap">{{ legend.value }}</span>
|
||||
</li>
|
||||
<li
|
||||
v-if="hasForecastLegend(group)"
|
||||
class="entity-legend-item entity-legend-item--static d-flex align-items-baseline gap-2 no-wrap"
|
||||
:class="{
|
||||
'entity-legend-item--dim': (focusedEntity[group] ?? null) !== null,
|
||||
}"
|
||||
>
|
||||
<span
|
||||
class="entity-legend-dot entity-legend-dot--line align-self-center"
|
||||
:style="{ backgroundColor: groupColor('forecast') }"
|
||||
></span>
|
||||
<span class="text-nowrap">
|
||||
{{ $t("main.history.group.forecast") }}
|
||||
</span>
|
||||
<span class="text-muted text-nowrap">
|
||||
{{ forecastTotalLabel }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
class="mt-4"
|
||||
:legends="legendsForGroup(group)"
|
||||
:device-colors="deviceColors"
|
||||
@focus="onLegendFocus(group, $event)"
|
||||
/>
|
||||
</section>
|
||||
<p v-if="visibleGroups.length" class="text-end mt-3 mb-0">
|
||||
<a
|
||||
|
|
@ -157,9 +121,11 @@ import GroupChart, {
|
|||
stepAlpha,
|
||||
} from "../components/History/GroupChart.vue";
|
||||
import type { Legend } from "../components/Sessions/types";
|
||||
import type { DeviceColors } from "@/types/evcc";
|
||||
import { PERIODS } from "../components/Sessions/types";
|
||||
import { GROUP_ORDER, groupColor } from "../components/History/groups";
|
||||
import colors from "../colors";
|
||||
import colors, { resolveColors } from "../colors";
|
||||
import LegendList from "../components/Sessions/LegendList.vue";
|
||||
import formatter, { POWER_UNIT } from "../mixins/formatter";
|
||||
import api from "../api";
|
||||
import store from "../store";
|
||||
|
|
@ -178,6 +144,7 @@ export default defineComponent({
|
|||
DateNavigator,
|
||||
PeriodHeader,
|
||||
GroupChart,
|
||||
LegendList,
|
||||
},
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
|
|
@ -202,6 +169,9 @@ export default defineComponent({
|
|||
return { title: this.$t("main.history.title") };
|
||||
},
|
||||
computed: {
|
||||
deviceColors(): DeviceColors {
|
||||
return store.state.deviceColors ?? {};
|
||||
},
|
||||
effectivePeriod(): PERIODS {
|
||||
return this.period && HISTORY_PERIODS.includes(this.period)
|
||||
? (this.period as PERIODS)
|
||||
|
|
@ -398,6 +368,30 @@ export default defineComponent({
|
|||
},
|
||||
methods: {
|
||||
groupColor,
|
||||
legendsForGroup(group: string): Legend[] {
|
||||
const items = this.entityLegends(group);
|
||||
const focused = this.focusedEntity[group] ?? null;
|
||||
const list: Legend[] = items.map((l) => ({
|
||||
...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<string, string> = {};
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
:timestamp="evopt.details.timestamp[0]"
|
||||
:currency="currency"
|
||||
:battery-colors="batteryColors"
|
||||
:device-colors="deviceColors"
|
||||
/>
|
||||
|
||||
<h3 class="fw-normal mb-4">Result: SoC Projection</h3>
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -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"
|
||||
/>
|
||||
<CostGroupedChart
|
||||
v-else
|
||||
:sessions="currentTypeSessions"
|
||||
:color-mappings="colorMappings"
|
||||
:device-colors="deviceColors"
|
||||
:group-by="selectedGroupWithoutNone"
|
||||
:cost-type="activeType"
|
||||
:currency="currency"
|
||||
|
|
@ -199,7 +205,7 @@ import IconSelectGroup from "../components/Helper/IconSelectGroup.vue";
|
|||
import IconSelectItem from "../components/Helper/IconSelectItem.vue";
|
||||
import SelectGroup from "../components/Helper/SelectGroup.vue";
|
||||
import CustomSelect from "../components/Helper/CustomSelect.vue";
|
||||
import colors from "../colors";
|
||||
import colors, { resolveColors } from "../colors";
|
||||
import settings from "../settings";
|
||||
import PeriodSelector from "../components/Sessions/PeriodSelector.vue";
|
||||
import DateNavigator from "../components/Sessions/DateNavigator.vue";
|
||||
|
|
@ -497,6 +503,9 @@ export default defineComponent({
|
|||
}
|
||||
return this.csvHrefLink();
|
||||
},
|
||||
deviceColors() {
|
||||
return store.state.deviceColors ?? {};
|
||||
},
|
||||
colorMappings() {
|
||||
const lastThreeMonths = new Date();
|
||||
lastThreeMonths.setMonth(lastThreeMonths.getMonth() - 3);
|
||||
|
|
@ -512,40 +521,36 @@ export default defineComponent({
|
|||
}, {});
|
||||
};
|
||||
|
||||
// Assign colors based on energy usage
|
||||
const assignColors = (
|
||||
// Ordered key list: sessions ranked by recent energy first, then any
|
||||
// remaining keys encountered in the dataset.
|
||||
const orderedKeys = (
|
||||
energyAggregation: Record<string, number>,
|
||||
colorType: Exclude<GROUPS, GROUPS.NONE>
|
||||
) => {
|
||||
const result: Record<string, string> = {};
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -36,4 +36,5 @@ const (
|
|||
RemoteLastSeen = "remoteLastSeen"
|
||||
AuthDisabled = "authDisabled"
|
||||
AuthProviders = "authProviders"
|
||||
DeviceColors = "deviceColors"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
54
server/http_devicecolors_handler.go
Normal file
54
server/http_devicecolors_handler.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
34
tests/device-colors.evcc.yaml
Normal file
34
tests/device-colors.evcc.yaml
Normal file
|
|
@ -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
|
||||
83
tests/device-colors.spec.ts
Normal file
83
tests/device-colors.spec.ts
Normal file
|
|
@ -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<void> {
|
||||
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);
|
||||
});
|
||||
73
tests/device-colors.sql
Normal file
73
tests/device-colors.sql
Normal file
|
|
@ -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);
|
||||
30
ui/colors.go
Normal file
30
ui/colors.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue