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 { reactive } from "vue";
|
||||||
|
import type { DeviceColors } from "./types/evcc";
|
||||||
|
|
||||||
// alternatives
|
// alternatives
|
||||||
// const COLORS = [ "#40916C", "#52B788", "#74C69D", "#95D5B2", "#B7E4C7", "#D8F3DC", "#081C15", "#1B4332", "#2D6A4F"];
|
// const COLORS = [ "#40916C", "#52B788", "#74C69D", "#95D5B2", "#B7E4C7", "#D8F3DC", "#081C15", "#1B4332", "#2D6A4F"];
|
||||||
|
|
@ -35,25 +36,27 @@ const colors: {
|
||||||
export: null,
|
export: null,
|
||||||
background: null,
|
background: null,
|
||||||
light: null,
|
light: null,
|
||||||
selfPalette: ["#0FDE41FF", "#FFBD2FFF", "#FD6158FF", "#03C1EFFF", "#0F662DFF", "#FF922EFF"],
|
selfPalette: ["#0FDE41", "#FFBD2F", "#FD6158", "#03C1EF", "#0F662D", "#FF922E"],
|
||||||
palette: [
|
palette: [
|
||||||
// Dynamic palette (vehicles, loadpoints, …): optimized for neighbor contrast, avoids overlap with system colors (solar, battery, grid, …).
|
// Dynamic palette (vehicles, loadpoints, …): optimized for neighbor contrast, avoids overlap with system colors (solar, battery, grid, …).
|
||||||
"#06B6D4FF", // Cyan
|
"#0EA5E9", // Sky
|
||||||
"#2B7FFFFF", // Blue
|
"#EC4899", // Pink
|
||||||
"#6366F1FF", // Indigo
|
"#34D399", // Mint
|
||||||
"#A855F7FF", // Violet
|
"#F97316", // Orange
|
||||||
"#D946EFFF", // Magenta
|
"#7C3AED", // Violet
|
||||||
"#EC4899FF", // Pink
|
"#84CC16", // Lime
|
||||||
"#FB7185FF", // Coral
|
"#F43F5E", // Rose
|
||||||
"#475569FF", // Slate
|
"#2563EB", // Blue
|
||||||
"#1E40AFFF", // Royal
|
"#FACC15", // Yellow
|
||||||
"#6D28D9FF", // Purple
|
"#D946EF", // Magenta
|
||||||
"#BE185DFF", // Crimson
|
"#10B981", // Emerald
|
||||||
"#D2691EFF", // Sienna
|
"#1E40AF", // Royal
|
||||||
"#67E8F9FF", // Glacier
|
"#BE185D", // Crimson
|
||||||
"#C084FCFF", // Lilac
|
"#F59E0B", // Marigold
|
||||||
"#FFD580FF", // Sand
|
"#6366F1", // Indigo
|
||||||
"#94A3B8FF", // Steel
|
"#14B8A6", // Teal
|
||||||
|
"#A855F7", // Lavender
|
||||||
|
"#DC2626", // Red
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -67,6 +70,38 @@ const setAlpha = (color: string | null, alpha: string): string | undefined => {
|
||||||
return c;
|
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 dimColor = (color: string | null) => setAlpha(color, "20");
|
||||||
|
|
||||||
export const lighterColor = (color: string | null) => setAlpha(color, "aa");
|
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,
|
forecastYAxis,
|
||||||
tooltipStyle,
|
tooltipStyle,
|
||||||
} from "../Forecast/echarts";
|
} 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 formatter, { POWER_UNIT } from "@/mixins/formatter";
|
||||||
import { PERIODS } from "../Sessions/types";
|
import { PERIODS } from "../Sessions/types";
|
||||||
import { is12hFormat } from "@/units";
|
import { is12hFormat } from "@/units";
|
||||||
|
|
@ -204,12 +205,16 @@ export default defineComponent({
|
||||||
// stacked segments stay visually distinguishable.
|
// stacked segments stay visually distinguishable.
|
||||||
if (this.group === "loadpoint" || this.group === "meter") {
|
if (this.group === "loadpoint" || this.group === "meter") {
|
||||||
const mutedColor = colors.muted || this.color;
|
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
|
// Virtual "other consumers" entity renders in a neutral gray to set
|
||||||
// it apart from explicit meter entities.
|
// it apart from explicit meter entities.
|
||||||
if (s.virtual) return mutedColor;
|
if (s.virtual) return mutedColor;
|
||||||
const idx = s.paletteIndex ?? i;
|
return palette[s.name] || this.color;
|
||||||
return colors.palette[idx % colors.palette.length] || this.color;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (this.series.length <= 1) return [this.color];
|
if (this.series.length <= 1) return [this.color];
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
:height="300"
|
:height="300"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<LegendList :legends="legends" />
|
<LegendList :legends="legends" :device-colors="deviceColors" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -32,7 +32,7 @@ import {
|
||||||
} from "chart.js";
|
} from "chart.js";
|
||||||
import { Chart } from "vue-chartjs";
|
import { Chart } from "vue-chartjs";
|
||||||
import type { EvoptData } from "./TimeSeriesDataTable.vue";
|
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 formatter from "@/mixins/formatter";
|
||||||
import colors from "@/colors";
|
import colors from "@/colors";
|
||||||
import LegendList from "../Sessions/LegendList.vue";
|
import LegendList from "../Sessions/LegendList.vue";
|
||||||
|
|
@ -79,6 +79,7 @@ export default defineComponent({
|
||||||
type: Array as PropType<string[]>,
|
type: Array as PropType<string[]>,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
|
deviceColors: { type: Object as PropType<DeviceColors>, default: () => ({}) },
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
timeLabels(): string[] {
|
timeLabels(): string[] {
|
||||||
|
|
@ -244,6 +245,9 @@ export default defineComponent({
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
legends(): Legend[] {
|
legends(): Legend[] {
|
||||||
|
const batteryTitles = new Set(
|
||||||
|
(this.evopt?.res?.batteries || []).map((_, i) => this.getBatteryTitle(i))
|
||||||
|
);
|
||||||
return this.chartData.datasets
|
return this.chartData.datasets
|
||||||
.filter((dataset) => !dataset.hidden)
|
.filter((dataset) => !dataset.hidden)
|
||||||
.map((dataset) => {
|
.map((dataset) => {
|
||||||
|
|
@ -255,6 +259,7 @@ export default defineComponent({
|
||||||
color: (dataset.backgroundColor || dataset.borderColor) as string,
|
color: (dataset.backgroundColor || dataset.borderColor) as string,
|
||||||
value: "", // Required by Legend type, but not used in this context
|
value: "", // Required by Legend type, but not used in this context
|
||||||
type: isLine ? "line" : "area",
|
type: isLine ? "line" : "area",
|
||||||
|
id: batteryTitles.has(label) ? label : undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<PolarArea :data="chartData" :options="options" />
|
<PolarArea :data="chartData" :options="options" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6 d-flex align-items-center py-0 py-md-3">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -17,7 +17,7 @@ import formatter from "@/mixins/formatter";
|
||||||
import colors, { dimColor } from "@/colors";
|
import colors, { dimColor } from "@/colors";
|
||||||
import LegendList from "./LegendList.vue";
|
import LegendList from "./LegendList.vue";
|
||||||
import { defineComponent, type PropType } from "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";
|
import { TYPES, GROUPS, type Session } from "./types.ts";
|
||||||
|
|
||||||
registerChartComponents([RadialLinearScale, ArcElement, Legend, Tooltip]);
|
registerChartComponents([RadialLinearScale, ArcElement, Legend, Tooltip]);
|
||||||
|
|
@ -34,6 +34,7 @@ export default defineComponent({
|
||||||
default: GROUPS.LOADPOINT,
|
default: GROUPS.LOADPOINT,
|
||||||
},
|
},
|
||||||
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) },
|
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) },
|
||||||
|
deviceColors: { type: Object as PropType<DeviceColors>, default: () => ({}) },
|
||||||
suggestedMax: { type: Number, default: 0 },
|
suggestedMax: { type: Number, default: 0 },
|
||||||
costType: { type: String as PropType<TYPES>, default: TYPES.PRICE },
|
costType: { type: String as PropType<TYPES>, default: TYPES.PRICE },
|
||||||
},
|
},
|
||||||
|
|
@ -84,6 +85,7 @@ export default defineComponent({
|
||||||
label: label,
|
label: label,
|
||||||
color: dataset.borderColor[index],
|
color: dataset.borderColor[index],
|
||||||
value: this.formatValue(dataValue),
|
value: this.formatValue(dataValue),
|
||||||
|
id: label || undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<Doughnut :data="chartData" :options="options" />
|
<Doughnut :data="chartData" :options="options" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6 d-flex align-items-center">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -25,7 +25,7 @@ import formatter from "@/mixins/formatter";
|
||||||
import colors from "@/colors";
|
import colors from "@/colors";
|
||||||
import { TYPES, GROUPS, type Session } from "./types";
|
import { TYPES, GROUPS, type Session } from "./types";
|
||||||
import { defineComponent, type PropType } from "vue";
|
import { defineComponent, type PropType } from "vue";
|
||||||
import { CURRENCY } from "@/types/evcc";
|
import { CURRENCY, type DeviceColors } from "@/types/evcc";
|
||||||
|
|
||||||
registerChartComponents([DoughnutController, ArcElement, LinearScale, Legend, Tooltip]);
|
registerChartComponents([DoughnutController, ArcElement, LinearScale, Legend, Tooltip]);
|
||||||
|
|
||||||
|
|
@ -40,6 +40,7 @@ export default defineComponent({
|
||||||
default: GROUPS.LOADPOINT,
|
default: GROUPS.LOADPOINT,
|
||||||
},
|
},
|
||||||
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) },
|
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) },
|
||||||
|
deviceColors: { type: Object as PropType<DeviceColors>, default: () => ({}) },
|
||||||
currency: { type: String as PropType<CURRENCY>, default: CURRENCY.EUR },
|
currency: { type: String as PropType<CURRENCY>, default: CURRENCY.EUR },
|
||||||
costType: { type: String as PropType<TYPES>, default: TYPES.PRICE },
|
costType: { type: String as PropType<TYPES>, default: TYPES.PRICE },
|
||||||
},
|
},
|
||||||
|
|
@ -81,6 +82,7 @@ export default defineComponent({
|
||||||
label: label,
|
label: label,
|
||||||
color: dataset.backgroundColor[index],
|
color: dataset.backgroundColor[index],
|
||||||
value: [this.formatValue(dataValue), fmtShare(dataValue)],
|
value: [this.formatValue(dataValue), fmtShare(dataValue)],
|
||||||
|
id: label || undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<div style="position: relative; height: 300px" class="my-3">
|
<div style="position: relative; height: 300px" class="my-3">
|
||||||
<Bar :data="chartData" :options="options" />
|
<Bar :data="chartData" :options="options" />
|
||||||
</div>
|
</div>
|
||||||
<LegendList :legends="legends" />
|
<LegendList :legends="legends" :device-colors="deviceColors" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -28,7 +28,7 @@ import LegendList from "./LegendList.vue";
|
||||||
import formatter from "@/mixins/formatter";
|
import formatter from "@/mixins/formatter";
|
||||||
import colors from "@/colors";
|
import colors from "@/colors";
|
||||||
import { TYPES, GROUPS, PERIODS, type Session } from "./types";
|
import { TYPES, GROUPS, PERIODS, type Session } from "./types";
|
||||||
import { CURRENCY } from "@/types/evcc";
|
import { CURRENCY, type DeviceColors } from "@/types/evcc";
|
||||||
|
|
||||||
registerChartComponents([
|
registerChartComponents([
|
||||||
BarController,
|
BarController,
|
||||||
|
|
@ -52,6 +52,7 @@ export default defineComponent({
|
||||||
period: { type: String as PropType<PERIODS>, default: PERIODS.TOTAL },
|
period: { type: String as PropType<PERIODS>, default: PERIODS.TOTAL },
|
||||||
currency: { type: String as PropType<CURRENCY>, default: CURRENCY.EUR },
|
currency: { type: String as PropType<CURRENCY>, default: CURRENCY.EUR },
|
||||||
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) },
|
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) },
|
||||||
|
deviceColors: { type: Object as PropType<DeviceColors>, default: () => ({}) },
|
||||||
suggestedMaxAvgCost: { type: Number, default: 0 },
|
suggestedMaxAvgCost: { type: Number, default: 0 },
|
||||||
suggestedMaxCost: { type: Number, default: 0 },
|
suggestedMaxCost: { type: Number, default: 0 },
|
||||||
},
|
},
|
||||||
|
|
@ -195,6 +196,7 @@ export default defineComponent({
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
legends() {
|
legends() {
|
||||||
|
const pickable = this.groupBy !== GROUPS.NONE;
|
||||||
return this.chartData.datasets.map((dataset) => {
|
return this.chartData.datasets.map((dataset) => {
|
||||||
let value = null;
|
let value = null;
|
||||||
let type: "area" | "line" = "area";
|
let type: "area" | "line" = "area";
|
||||||
|
|
@ -225,6 +227,7 @@ export default defineComponent({
|
||||||
color: dataset.backgroundColor,
|
color: dataset.backgroundColor,
|
||||||
value,
|
value,
|
||||||
type,
|
type,
|
||||||
|
id: pickable && type !== "line" ? dataset.label || undefined : undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<Doughnut :data="chartData" :options="options" />
|
<Doughnut :data="chartData" :options="options" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6 d-flex align-items-center">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -25,6 +25,7 @@ import { registerChartComponents, commonOptions, tooltipLabelColor } from "./cha
|
||||||
import formatter, { POWER_UNIT } from "@/mixins/formatter";
|
import formatter, { POWER_UNIT } from "@/mixins/formatter";
|
||||||
import colors from "@/colors";
|
import colors from "@/colors";
|
||||||
import { GROUPS, type Session } from "./types";
|
import { GROUPS, type Session } from "./types";
|
||||||
|
import type { DeviceColors } from "@/types/evcc";
|
||||||
|
|
||||||
registerChartComponents([DoughnutController, ArcElement, LinearScale, Legend, Tooltip]);
|
registerChartComponents([DoughnutController, ArcElement, LinearScale, Legend, Tooltip]);
|
||||||
|
|
||||||
|
|
@ -36,6 +37,7 @@ export default defineComponent({
|
||||||
sessions: { type: Array as PropType<Session[]>, default: () => [] },
|
sessions: { type: Array as PropType<Session[]>, default: () => [] },
|
||||||
groupBy: { type: String as PropType<GROUPS>, default: GROUPS.NONE },
|
groupBy: { type: String as PropType<GROUPS>, default: GROUPS.NONE },
|
||||||
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {}, solar: {} }) },
|
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {}, solar: {} }) },
|
||||||
|
deviceColors: { type: Object as PropType<DeviceColors>, default: () => ({}) },
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
chartData() {
|
chartData() {
|
||||||
|
|
@ -86,12 +88,14 @@ export default defineComponent({
|
||||||
maxEnergy < 1 ? POWER_UNIT.W : maxEnergy > 1e4 ? POWER_UNIT.MW : POWER_UNIT.KW;
|
maxEnergy < 1 ? POWER_UNIT.W : maxEnergy > 1e4 ? POWER_UNIT.MW : POWER_UNIT.KW;
|
||||||
const fmtShare = (value: number) => this.fmtPercentage((100 / total) * value, 1);
|
const fmtShare = (value: number) => this.fmtPercentage((100 / total) * value, 1);
|
||||||
const fmtValue = (value: number) => this.fmtWh(value * 1e3, unit);
|
const fmtValue = (value: number) => this.fmtWh(value * 1e3, unit);
|
||||||
|
const pickable = this.groupBy !== GROUPS.NONE;
|
||||||
return this.chartData.labels.map((label, index) => {
|
return this.chartData.labels.map((label, index) => {
|
||||||
const dataValue = dataset.data[index] as number;
|
const dataValue = dataset.data[index] as number;
|
||||||
return {
|
return {
|
||||||
label: label,
|
label: label,
|
||||||
color: dataset.backgroundColor[index],
|
color: dataset.backgroundColor[index],
|
||||||
value: [fmtValue(dataValue), fmtShare(dataValue)],
|
value: [fmtValue(dataValue), fmtShare(dataValue)],
|
||||||
|
id: pickable ? label || undefined : undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<div style="position: relative; height: 300px" class="my-3">
|
<div style="position: relative; height: 300px" class="my-3">
|
||||||
<Bar :data="chartData" :options="options" />
|
<Bar :data="chartData" :options="options" />
|
||||||
</div>
|
</div>
|
||||||
<LegendList :legends="legends" />
|
<LegendList :legends="legends" :device-colors="deviceColors" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -27,6 +27,7 @@ import LegendList from "./LegendList.vue";
|
||||||
import formatter, { POWER_UNIT } from "@/mixins/formatter";
|
import formatter, { POWER_UNIT } from "@/mixins/formatter";
|
||||||
import colors from "@/colors";
|
import colors from "@/colors";
|
||||||
import { GROUPS, PERIODS, type Session } from "./types";
|
import { GROUPS, PERIODS, type Session } from "./types";
|
||||||
|
import type { DeviceColors } from "@/types/evcc";
|
||||||
|
|
||||||
registerChartComponents([BarController, BarElement, CategoryScale, LinearScale, Legend, Tooltip]);
|
registerChartComponents([BarController, BarElement, CategoryScale, LinearScale, Legend, Tooltip]);
|
||||||
|
|
||||||
|
|
@ -39,6 +40,7 @@ export default defineComponent({
|
||||||
groupBy: { type: String as PropType<GROUPS>, default: GROUPS.NONE },
|
groupBy: { type: String as PropType<GROUPS>, default: GROUPS.NONE },
|
||||||
period: { type: String as PropType<PERIODS>, default: PERIODS.TOTAL },
|
period: { type: String as PropType<PERIODS>, default: PERIODS.TOTAL },
|
||||||
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) },
|
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) },
|
||||||
|
deviceColors: { type: Object as PropType<DeviceColors>, default: () => ({}) },
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
firstDay() {
|
firstDay() {
|
||||||
|
|
@ -148,6 +150,7 @@ export default defineComponent({
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
legends() {
|
legends() {
|
||||||
|
const pickable = this.groupBy !== GROUPS.NONE;
|
||||||
return this.chartData.datasets.map((dataset) => ({
|
return this.chartData.datasets.map((dataset) => ({
|
||||||
label: dataset.label || "",
|
label: dataset.label || "",
|
||||||
color: dataset.backgroundColor,
|
color: dataset.backgroundColor,
|
||||||
|
|
@ -155,6 +158,7 @@ export default defineComponent({
|
||||||
dataset.data.reduce((acc, curr) => acc + curr, 0) * 1e3,
|
dataset.data.reduce((acc, curr) => acc + curr, 0) * 1e3,
|
||||||
POWER_UNIT.AUTO
|
POWER_UNIT.AUTO
|
||||||
),
|
),
|
||||||
|
id: pickable ? dataset.label || undefined : undefined,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
options() {
|
options() {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<ul
|
<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="{
|
:class="{
|
||||||
'root--small-equal-widths': smallEqualWidths,
|
'root--small-equal-widths': smallEqualWidths,
|
||||||
'root--grid': grid,
|
'root--grid': grid,
|
||||||
|
|
@ -9,17 +9,43 @@
|
||||||
<li
|
<li
|
||||||
v-for="legend in legends"
|
v-for="legend in legends"
|
||||||
:key="legend.label"
|
: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
|
<button
|
||||||
v-if="legend.color"
|
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="legend-color align-self-center me-1"
|
||||||
:class="colorClass(legend)"
|
:class="colorClass(legend)"
|
||||||
:style="{
|
:style="{
|
||||||
backgroundColor: legend.color,
|
backgroundColor: legend.color,
|
||||||
borderColor: legend.color,
|
borderColor: legend.color,
|
||||||
}"
|
}"
|
||||||
></div>
|
></span>
|
||||||
<div class="legend-label text-nowrap">{{ legend.label }}</div>
|
<div class="legend-label text-nowrap">{{ legend.label }}</div>
|
||||||
<div
|
<div
|
||||||
v-for="value in valueList(legend.value)"
|
v-for="value in valueList(legend.value)"
|
||||||
|
|
@ -29,19 +55,48 @@
|
||||||
{{ value }}
|
{{ value }}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
<ColorPickerPopover
|
||||||
|
v-model="pickerOpen"
|
||||||
|
:anchor-el="pickerAnchor"
|
||||||
|
:color="pickerColor"
|
||||||
|
:title="pickerTitle"
|
||||||
|
@update:color="onColorChange"
|
||||||
|
/>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, type PropType } from "vue";
|
import { defineComponent, type PropType } from "vue";
|
||||||
import type { Legend } from "./types";
|
import type { Legend } from "./types";
|
||||||
|
import type { DeviceColors } from "@/types/evcc";
|
||||||
|
import ColorPickerPopover from "../Helper/ColorPickerPopover.vue";
|
||||||
|
import api from "../../api";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: "LegendList",
|
name: "LegendList",
|
||||||
|
components: { ColorPickerPopover },
|
||||||
props: {
|
props: {
|
||||||
legends: Array as PropType<Legend[]>,
|
legends: Array as PropType<Legend[]>,
|
||||||
grid: Boolean,
|
grid: Boolean,
|
||||||
smallEqualWidths: 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: {
|
methods: {
|
||||||
valueList(value: Legend["value"]) {
|
valueList(value: Legend["value"]) {
|
||||||
|
|
@ -51,6 +106,30 @@ export default defineComponent({
|
||||||
colorClass(legend: Legend) {
|
colorClass(legend: Legend) {
|
||||||
return legend.type === "line" ? "legend-color--line" : "legend-color--area";
|
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>
|
</script>
|
||||||
|
|
@ -63,6 +142,15 @@ export default defineComponent({
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
flex-shrink: 0;
|
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 {
|
.legend-color--area {
|
||||||
|
|
@ -75,6 +163,19 @@ export default defineComponent({
|
||||||
align-self: center;
|
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 {
|
.legend-label {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
flex-grow: 0;
|
flex-grow: 0;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<PolarArea :data="chartData" :options="options" />
|
<PolarArea :data="chartData" :options="options" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6 d-flex align-items-center py-0 py-md-3">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -18,6 +18,7 @@ import formatter from "@/mixins/formatter";
|
||||||
import colors, { dimColor } from "@/colors";
|
import colors, { dimColor } from "@/colors";
|
||||||
import LegendList from "./LegendList.vue";
|
import LegendList from "./LegendList.vue";
|
||||||
import { GROUPS, type Session } from "./types.ts";
|
import { GROUPS, type Session } from "./types.ts";
|
||||||
|
import type { DeviceColors } from "@/types/evcc";
|
||||||
|
|
||||||
registerChartComponents([RadialLinearScale, ArcElement, Legend, Tooltip]);
|
registerChartComponents([RadialLinearScale, ArcElement, Legend, Tooltip]);
|
||||||
|
|
||||||
|
|
@ -32,6 +33,7 @@ export default defineComponent({
|
||||||
default: GROUPS.LOADPOINT,
|
default: GROUPS.LOADPOINT,
|
||||||
},
|
},
|
||||||
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) },
|
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) },
|
||||||
|
deviceColors: { type: Object as PropType<DeviceColors>, default: () => ({}) },
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
chartData() {
|
chartData() {
|
||||||
|
|
@ -82,6 +84,7 @@ export default defineComponent({
|
||||||
label: label,
|
label: label,
|
||||||
color: dataset.borderColor[index],
|
color: dataset.borderColor[index],
|
||||||
value: this.fmtPercentage(dataValue, 1),
|
value: this.fmtPercentage(dataValue, 1),
|
||||||
|
id: label || undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@ export interface Legend {
|
||||||
color: any;
|
color: any;
|
||||||
value: string | string[];
|
value: string | string[];
|
||||||
type?: "area" | "line";
|
type?: "area" | "line";
|
||||||
|
id?: string;
|
||||||
|
dim?: boolean;
|
||||||
|
focusable?: boolean;
|
||||||
|
focusKey?: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Column {
|
export interface Column {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ declare global {
|
||||||
|
|
||||||
export type AuthProviders = Record<string, { id: string; authenticated: boolean }>;
|
export type AuthProviders = Record<string, { id: string; authenticated: boolean }>;
|
||||||
|
|
||||||
|
export type DeviceColors = Record<string, string>;
|
||||||
|
|
||||||
export interface MqttConfig {
|
export interface MqttConfig {
|
||||||
broker: string;
|
broker: string;
|
||||||
topic: string;
|
topic: string;
|
||||||
|
|
@ -109,6 +111,7 @@ export interface State {
|
||||||
smartCostAvailable?: boolean;
|
smartCostAvailable?: boolean;
|
||||||
smartCostType?: SMART_COST_TYPE;
|
smartCostType?: SMART_COST_TYPE;
|
||||||
siteTitle?: string;
|
siteTitle?: string;
|
||||||
|
deviceColors?: DeviceColors;
|
||||||
vehicles: Record<string, Vehicle>;
|
vehicles: Record<string, Vehicle>;
|
||||||
statistics?: Statistics;
|
statistics?: Statistics;
|
||||||
authDisabled?: boolean;
|
authDisabled?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -86,49 +86,13 @@
|
||||||
:from="displayFrom"
|
:from="displayFrom"
|
||||||
:to="displayTo"
|
:to="displayTo"
|
||||||
/>
|
/>
|
||||||
<ul
|
<LegendList
|
||||||
v-if="hasEntityLegend(group) || hasForecastLegend(group)"
|
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"
|
class="mt-4"
|
||||||
>
|
:legends="legendsForGroup(group)"
|
||||||
<li
|
:device-colors="deviceColors"
|
||||||
v-for="legend in entityLegends(group)"
|
@focus="onLegendFocus(group, $event)"
|
||||||
: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>
|
|
||||||
</section>
|
</section>
|
||||||
<p v-if="visibleGroups.length" class="text-end mt-3 mb-0">
|
<p v-if="visibleGroups.length" class="text-end mt-3 mb-0">
|
||||||
<a
|
<a
|
||||||
|
|
@ -157,9 +121,11 @@ import GroupChart, {
|
||||||
stepAlpha,
|
stepAlpha,
|
||||||
} from "../components/History/GroupChart.vue";
|
} from "../components/History/GroupChart.vue";
|
||||||
import type { Legend } from "../components/Sessions/types";
|
import type { Legend } from "../components/Sessions/types";
|
||||||
|
import type { DeviceColors } from "@/types/evcc";
|
||||||
import { PERIODS } from "../components/Sessions/types";
|
import { PERIODS } from "../components/Sessions/types";
|
||||||
import { GROUP_ORDER, groupColor } from "../components/History/groups";
|
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 formatter, { POWER_UNIT } from "../mixins/formatter";
|
||||||
import api from "../api";
|
import api from "../api";
|
||||||
import store from "../store";
|
import store from "../store";
|
||||||
|
|
@ -178,6 +144,7 @@ export default defineComponent({
|
||||||
DateNavigator,
|
DateNavigator,
|
||||||
PeriodHeader,
|
PeriodHeader,
|
||||||
GroupChart,
|
GroupChart,
|
||||||
|
LegendList,
|
||||||
},
|
},
|
||||||
mixins: [formatter],
|
mixins: [formatter],
|
||||||
props: {
|
props: {
|
||||||
|
|
@ -202,6 +169,9 @@ export default defineComponent({
|
||||||
return { title: this.$t("main.history.title") };
|
return { title: this.$t("main.history.title") };
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
deviceColors(): DeviceColors {
|
||||||
|
return store.state.deviceColors ?? {};
|
||||||
|
},
|
||||||
effectivePeriod(): PERIODS {
|
effectivePeriod(): PERIODS {
|
||||||
return this.period && HISTORY_PERIODS.includes(this.period)
|
return this.period && HISTORY_PERIODS.includes(this.period)
|
||||||
? (this.period as PERIODS)
|
? (this.period as PERIODS)
|
||||||
|
|
@ -398,6 +368,30 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
groupColor,
|
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 {
|
hasEntityLegend(group: string): boolean {
|
||||||
const list = this.displaySeries(group);
|
const list = this.displaySeries(group);
|
||||||
if (!list.length) return false;
|
if (!list.length) return false;
|
||||||
|
|
@ -412,12 +406,22 @@ export default defineComponent({
|
||||||
const list = this.displaySeries(group);
|
const list = this.displaySeries(group);
|
||||||
const baseColor = groupColor(group);
|
const baseColor = groupColor(group);
|
||||||
const n = list.length;
|
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) => {
|
const colorFor = (i: number, s: HistorySeries) => {
|
||||||
if (s.virtual) return colors.muted || baseColor;
|
if (s.virtual) return colors.muted || baseColor;
|
||||||
if (group === "loadpoint" || group === "meter") {
|
if (isPickGroup) return palette[s.name] || baseColor;
|
||||||
const idx = s.paletteIndex ?? i;
|
|
||||||
return colors.palette[idx % colors.palette.length] || baseColor;
|
|
||||||
}
|
|
||||||
return alphaColor(baseColor, stepAlpha(i, Math.max(n, 1)));
|
return alphaColor(baseColor, stepAlpha(i, Math.max(n, 1)));
|
||||||
};
|
};
|
||||||
return list.map((s, i) => {
|
return list.map((s, i) => {
|
||||||
|
|
@ -431,6 +435,7 @@ export default defineComponent({
|
||||||
label: s.name,
|
label: s.name,
|
||||||
color: colorFor(i, s),
|
color: colorFor(i, s),
|
||||||
value: this.fmtWh(watts, POWER_UNIT.AUTO),
|
value: this.fmtWh(watts, POWER_UNIT.AUTO),
|
||||||
|
id: isPickGroup && !s.virtual ? s.name : undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -570,24 +575,6 @@ export default defineComponent({
|
||||||
opacity: 0.8;
|
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 {
|
.history-csv-link {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
@ -596,13 +583,6 @@ export default defineComponent({
|
||||||
color: var(--evcc-default-text);
|
color: var(--evcc-default-text);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
.entity-legend-dot--line {
|
|
||||||
height: 2px;
|
|
||||||
border-radius: 1px;
|
|
||||||
}
|
|
||||||
.entity-legend-item--static {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
.history-tile-title {
|
.history-tile-title {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@
|
||||||
:timestamp="evopt.details.timestamp[0]"
|
:timestamp="evopt.details.timestamp[0]"
|
||||||
:currency="currency"
|
:currency="currency"
|
||||||
:battery-colors="batteryColors"
|
:battery-colors="batteryColors"
|
||||||
|
:device-colors="deviceColors"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<h3 class="fw-normal mb-4">Result: SoC Projection</h3>
|
<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 api from "../api";
|
||||||
import store from "../store";
|
import store from "../store";
|
||||||
import formatter from "../mixins/formatter";
|
import formatter from "../mixins/formatter";
|
||||||
import colors from "../colors";
|
import { resolveColors } from "../colors";
|
||||||
import { CURRENCY } from "../types/evcc";
|
import { CURRENCY } from "../types/evcc";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
|
|
@ -190,12 +191,19 @@ export default defineComponent({
|
||||||
return "bg-secondary";
|
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() {
|
batteryColors() {
|
||||||
if (!this.evopt?.res.batteries) return [];
|
if (!this.evopt?.res.batteries) return [];
|
||||||
|
const palette = resolveColors(this.batteryTitles, this.deviceColors);
|
||||||
return this.evopt.res.batteries.map(
|
return this.batteryTitles.map((t) => palette[t] || "");
|
||||||
(_, index) => colors.palette[index % colors.palette.length] || ""
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
dimmedBatteryColors() {
|
dimmedBatteryColors() {
|
||||||
return (this.batteryColors || []).map((color) => this.dimColorBy25Percent(color));
|
return (this.batteryColors || []).map((color) => this.dimColorBy25Percent(color));
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@
|
||||||
class="mb-5"
|
class="mb-5"
|
||||||
:sessions="currentSessions"
|
:sessions="currentSessions"
|
||||||
:color-mappings="colorMappings"
|
:color-mappings="colorMappings"
|
||||||
|
:device-colors="deviceColors"
|
||||||
:group-by="selectedGroup"
|
:group-by="selectedGroup"
|
||||||
:period="period"
|
:period="period"
|
||||||
/>
|
/>
|
||||||
|
|
@ -86,6 +87,7 @@
|
||||||
class="mb-5"
|
class="mb-5"
|
||||||
:sessions="currentTypeSessions"
|
:sessions="currentTypeSessions"
|
||||||
:color-mappings="colorMappings"
|
:color-mappings="colorMappings"
|
||||||
|
:device-colors="deviceColors"
|
||||||
:group-by="selectedGroup"
|
:group-by="selectedGroup"
|
||||||
:cost-type="activeType"
|
:cost-type="activeType"
|
||||||
:currency="currency"
|
:currency="currency"
|
||||||
|
|
@ -106,6 +108,7 @@
|
||||||
v-else
|
v-else
|
||||||
:sessions="currentSessions"
|
:sessions="currentSessions"
|
||||||
:color-mappings="colorMappings"
|
:color-mappings="colorMappings"
|
||||||
|
:device-colors="deviceColors"
|
||||||
:group-by="selectedGroupWithoutNone"
|
:group-by="selectedGroupWithoutNone"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -113,6 +116,7 @@
|
||||||
v-else
|
v-else
|
||||||
:sessions="currentTypeSessions"
|
:sessions="currentTypeSessions"
|
||||||
:color-mappings="colorMappings"
|
:color-mappings="colorMappings"
|
||||||
|
:device-colors="deviceColors"
|
||||||
:suggested-max-price="suggestedMaxAvgCost"
|
:suggested-max-price="suggestedMaxAvgCost"
|
||||||
:group-by="selectedGroupWithoutNone"
|
:group-by="selectedGroupWithoutNone"
|
||||||
:cost-type="activeType"
|
:cost-type="activeType"
|
||||||
|
|
@ -125,12 +129,14 @@
|
||||||
v-if="activeType === types.SOLAR"
|
v-if="activeType === types.SOLAR"
|
||||||
:sessions="currentSessions"
|
:sessions="currentSessions"
|
||||||
:color-mappings="colorMappings"
|
:color-mappings="colorMappings"
|
||||||
|
:device-colors="deviceColors"
|
||||||
:group-by="selectedGroupWithoutNone"
|
:group-by="selectedGroupWithoutNone"
|
||||||
/>
|
/>
|
||||||
<CostGroupedChart
|
<CostGroupedChart
|
||||||
v-else
|
v-else
|
||||||
:sessions="currentTypeSessions"
|
:sessions="currentTypeSessions"
|
||||||
:color-mappings="colorMappings"
|
:color-mappings="colorMappings"
|
||||||
|
:device-colors="deviceColors"
|
||||||
:group-by="selectedGroupWithoutNone"
|
:group-by="selectedGroupWithoutNone"
|
||||||
:cost-type="activeType"
|
:cost-type="activeType"
|
||||||
:currency="currency"
|
:currency="currency"
|
||||||
|
|
@ -199,7 +205,7 @@ import IconSelectGroup from "../components/Helper/IconSelectGroup.vue";
|
||||||
import IconSelectItem from "../components/Helper/IconSelectItem.vue";
|
import IconSelectItem from "../components/Helper/IconSelectItem.vue";
|
||||||
import SelectGroup from "../components/Helper/SelectGroup.vue";
|
import SelectGroup from "../components/Helper/SelectGroup.vue";
|
||||||
import CustomSelect from "../components/Helper/CustomSelect.vue";
|
import CustomSelect from "../components/Helper/CustomSelect.vue";
|
||||||
import colors from "../colors";
|
import colors, { resolveColors } from "../colors";
|
||||||
import settings from "../settings";
|
import settings from "../settings";
|
||||||
import PeriodSelector from "../components/Sessions/PeriodSelector.vue";
|
import PeriodSelector from "../components/Sessions/PeriodSelector.vue";
|
||||||
import DateNavigator from "../components/Sessions/DateNavigator.vue";
|
import DateNavigator from "../components/Sessions/DateNavigator.vue";
|
||||||
|
|
@ -497,6 +503,9 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
return this.csvHrefLink();
|
return this.csvHrefLink();
|
||||||
},
|
},
|
||||||
|
deviceColors() {
|
||||||
|
return store.state.deviceColors ?? {};
|
||||||
|
},
|
||||||
colorMappings() {
|
colorMappings() {
|
||||||
const lastThreeMonths = new Date();
|
const lastThreeMonths = new Date();
|
||||||
lastThreeMonths.setMonth(lastThreeMonths.getMonth() - 3);
|
lastThreeMonths.setMonth(lastThreeMonths.getMonth() - 3);
|
||||||
|
|
@ -512,40 +521,36 @@ export default defineComponent({
|
||||||
}, {});
|
}, {});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Assign colors based on energy usage
|
// Ordered key list: sessions ranked by recent energy first, then any
|
||||||
const assignColors = (
|
// remaining keys encountered in the dataset.
|
||||||
|
const orderedKeys = (
|
||||||
energyAggregation: Record<string, number>,
|
energyAggregation: Record<string, number>,
|
||||||
colorType: Exclude<GROUPS, GROUPS.NONE>
|
colorType: Exclude<GROUPS, GROUPS.NONE>
|
||||||
) => {
|
): string[] => {
|
||||||
const result: Record<string, string> = {};
|
const ranked = Object.entries(energyAggregation)
|
||||||
let colorIndex = 0;
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([k]) => k)
|
||||||
// Assign colors by used energy in the last three months
|
.filter(Boolean);
|
||||||
const sortedEntries = Object.entries(energyAggregation).sort((a, b) => b[1] - a[1]);
|
const seen = new Set(ranked);
|
||||||
sortedEntries.forEach(([key]) => {
|
for (const s of this.sessionsWithDefaults) {
|
||||||
if (key && !result[key]) {
|
const k = s[colorType];
|
||||||
result[key] = colors.palette[colorIndex % colors.palette.length] || "";
|
if (k && !seen.has(k)) {
|
||||||
colorIndex++;
|
ranked.push(k);
|
||||||
|
seen.add(k);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
return ranked;
|
||||||
// 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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadpointEnergy = aggregateEnergy(GROUPS.LOADPOINT);
|
const overrides = this.deviceColors;
|
||||||
const loadpointColors = assignColors(loadpointEnergy, GROUPS.LOADPOINT);
|
const loadpointColors = resolveColors(
|
||||||
|
orderedKeys(aggregateEnergy(GROUPS.LOADPOINT), GROUPS.LOADPOINT),
|
||||||
const vehicleEnergy = aggregateEnergy(GROUPS.VEHICLE);
|
overrides
|
||||||
const vehicleColors = assignColors(vehicleEnergy, GROUPS.VEHICLE);
|
);
|
||||||
|
const vehicleColors = resolveColors(
|
||||||
|
orderedKeys(aggregateEnergy(GROUPS.VEHICLE), GROUPS.VEHICLE),
|
||||||
|
overrides
|
||||||
|
);
|
||||||
|
|
||||||
const solar = { self: colors.self, grid: colors.grid };
|
const solar = { self: colors.self, grid: colors.grid };
|
||||||
const cost = { price: colors.price, co2: colors.co2 };
|
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/network"
|
||||||
"github.com/evcc-io/evcc/server/remote"
|
"github.com/evcc-io/evcc/server/remote"
|
||||||
"github.com/evcc-io/evcc/server/updater"
|
"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"
|
||||||
"github.com/evcc-io/evcc/util/auth"
|
"github.com/evcc-io/evcc/util/auth"
|
||||||
"github.com/evcc-io/evcc/util/pipe"
|
"github.com/evcc-io/evcc/util/pipe"
|
||||||
|
|
@ -360,6 +361,7 @@ func runRoot(cmd *cobra.Command, args []string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// publish initial settings
|
// publish initial settings
|
||||||
|
valueChan <- util.Param{Key: keys.DeviceColors, Val: ui.GetDeviceColors()}
|
||||||
valueChan <- util.Param{Key: keys.EEBus, Val: globalconfig.ConfigStatus{
|
valueChan <- util.Param{Key: keys.EEBus, Val: globalconfig.ConfigStatus{
|
||||||
Config: conf.EEBus.Redacted(),
|
Config: conf.EEBus.Redacted(),
|
||||||
Status: eebus.GetStatus(),
|
Status: eebus.GetStatus(),
|
||||||
|
|
|
||||||
|
|
@ -36,4 +36,5 @@ const (
|
||||||
RemoteLastSeen = "remoteLastSeen"
|
RemoteLastSeen = "remoteLastSeen"
|
||||||
AuthDisabled = "authDisabled"
|
AuthDisabled = "authDisabled"
|
||||||
AuthProviders = "authProviders"
|
AuthProviders = "authProviders"
|
||||||
|
DeviceColors = "deviceColors"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,10 @@
|
||||||
"noBattery": "Keine Batterien konfiguriert.",
|
"noBattery": "Keine Batterien konfiguriert.",
|
||||||
"usageTab": "Batterienutzung"
|
"usageTab": "Batterienutzung"
|
||||||
},
|
},
|
||||||
|
"colors": {
|
||||||
|
"auto": "Auto",
|
||||||
|
"hex": "Hex-Farbe"
|
||||||
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"aux": {
|
"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.",
|
"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.",
|
"noBattery": "No batteries configured.",
|
||||||
"usageTab": "Battery usage"
|
"usageTab": "Battery usage"
|
||||||
},
|
},
|
||||||
|
"colors": {
|
||||||
|
"auto": "Auto",
|
||||||
|
"hex": "Hex color"
|
||||||
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"aux": {
|
"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.",
|
"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},
|
"energyhistory": {"GET", "/history/energy", energyHistoryHandler},
|
||||||
"optimize": {"POST", "/optimize", getHandler(site.Optimize)},
|
"optimize": {"POST", "/optimize", getHandler(site.Optimize)},
|
||||||
"telemetry2": {"POST", "/settings/telemetry/{value:[01truefalse]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
|
"telemetry2": {"POST", "/settings/telemetry/{value:[01truefalse]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
|
||||||
|
"devicecolors": {"PUT", "/devicecolors", updateDeviceColor(site)},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, r := range routes {
|
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