History: darken stacked bar shades, dim non-hovered slots, tooltip formatting (#31029)
This commit is contained in:
parent
2dee05cf68
commit
a3926a7b69
9 changed files with 167 additions and 66 deletions
|
|
@ -1,5 +1,12 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import colors, { dimColor, lighterColor, fullColor, resolveColors, deviceColorMap } from "./colors";
|
||||
import colors, {
|
||||
dimColor,
|
||||
lighterColor,
|
||||
fullColor,
|
||||
darken,
|
||||
resolveColors,
|
||||
deviceColorMap,
|
||||
} from "./colors";
|
||||
import type { DeviceColors } from "./types/evcc";
|
||||
|
||||
describe("setAlpha helpers", () => {
|
||||
|
|
@ -26,6 +33,19 @@ describe("setAlpha helpers", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("darken", () => {
|
||||
it("scales rgb toward black by factor, staying opaque", () => {
|
||||
expect(darken("#ffffff", 0.5)).toBe("#808080");
|
||||
expect(darken("#0fde41", 1)).toBe("#0fde41");
|
||||
expect(darken("#0fde41", 0)).toBe("#000000");
|
||||
});
|
||||
|
||||
it("clamps factor and ignores non-6-digit hex", () => {
|
||||
expect(darken("#abcdef", 2)).toBe("#abcdef");
|
||||
expect(darken("rgb(1,2,3)", 0.5)).toBe("rgb(1,2,3)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveColors", () => {
|
||||
const palette = colors.palette;
|
||||
|
||||
|
|
|
|||
|
|
@ -119,6 +119,17 @@ export const lighterColor = (color: string | null) => setAlpha(color, "aa");
|
|||
|
||||
export const fullColor = (color: string | null) => setAlpha(color, "ff");
|
||||
|
||||
// Darken an opaque hex color: scale rgb toward black by `factor` (0-1), stays opaque.
|
||||
export function darken(color: string, factor: number): string {
|
||||
if (!/^#[0-9a-f]{6}$/i.test(color)) return color;
|
||||
const f = Math.max(0, Math.min(1, factor));
|
||||
const channel = (o: number) =>
|
||||
Math.round(parseInt(color.slice(o, o + 2), 16) * f)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
return `#${channel(1)}${channel(3)}${channel(5)}`;
|
||||
}
|
||||
|
||||
export function updateCssColors() {
|
||||
const style = window.getComputedStyle(document.documentElement);
|
||||
colors.text = style.getPropertyValue("--evcc-default-text");
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
FONT_FAMILY,
|
||||
markPointLabel,
|
||||
tooltipStyle,
|
||||
tooltipTable,
|
||||
forecastGrid,
|
||||
forecastXAxes,
|
||||
forecastYAxis,
|
||||
|
|
@ -80,7 +81,7 @@ export default defineComponent({
|
|||
if (!p) return "";
|
||||
const d = new Date(p.value[0]);
|
||||
const time = `${vThis.weekdayShort(d)} ${vThis.fmtHourMinute(d)}`;
|
||||
return `${time}<br/>${vThis.fmtCo2Medium(p.value[1])}`;
|
||||
return tooltipTable(time, [{ values: [vThis.fmtCo2Medium(p.value[1])] }]);
|
||||
},
|
||||
},
|
||||
xAxis: forecastXAxes(this.startDate, this.endDate, this.weekdayShort),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
FONT_FAMILY,
|
||||
markPointLabel,
|
||||
tooltipStyle,
|
||||
tooltipTable,
|
||||
forecastGrid,
|
||||
forecastXAxes,
|
||||
forecastYAxis,
|
||||
|
|
@ -104,23 +105,16 @@ export default defineComponent({
|
|||
if (!p) return "";
|
||||
const d = new Date(p.value[0]);
|
||||
const time = `${vThis.weekdayShort(d)} ${vThis.fmtHourMinute(d)}`;
|
||||
const lines = [time];
|
||||
const showLabels = params.length > 1;
|
||||
const labels = [
|
||||
vThis.$t("main.energyflow.gridImport"),
|
||||
vThis.$t("main.energyflow.pvExport"),
|
||||
];
|
||||
for (const s of params) {
|
||||
const price = vThis.fmtPricePerKWh(
|
||||
s.value[1],
|
||||
vThis.currency,
|
||||
true,
|
||||
true
|
||||
);
|
||||
const label = showLabels ? `${labels[s.seriesIndex]}: ` : "";
|
||||
lines.push(`${label}${price}`);
|
||||
}
|
||||
return lines.join("<br/>");
|
||||
const rows = params.map((s) => ({
|
||||
name: showLabels ? labels[s.seriesIndex] : undefined,
|
||||
values: [vThis.fmtPricePerKWh(s.value[1], vThis.currency, true, true)],
|
||||
}));
|
||||
return tooltipTable(time, rows);
|
||||
},
|
||||
},
|
||||
xAxis: forecastXAxes(this.startDate, this.endDate, this.weekdayShort),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
FONT_FAMILY,
|
||||
markPointLabel,
|
||||
tooltipStyle,
|
||||
tooltipTable,
|
||||
forecastGrid,
|
||||
forecastXAxes,
|
||||
forecastYAxis,
|
||||
|
|
@ -83,7 +84,9 @@ export default defineComponent({
|
|||
if (!p) return "";
|
||||
const d = new Date(p.value[0]);
|
||||
const time = `${this.weekdayShort(d)} ${this.fmtHourMinute(d)}`;
|
||||
return `${time}<br/>${this.fmtW(p.value[1], POWER_UNIT.AUTO)}`;
|
||||
return tooltipTable(time, [
|
||||
{ values: [this.fmtW(p.value[1], POWER_UNIT.AUTO)] },
|
||||
]);
|
||||
},
|
||||
},
|
||||
xAxis: forecastXAxes(this.startDate, this.endDate, this.weekdayShort),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import * as echarts from "echarts/core";
|
||||
import colors from "@/colors";
|
||||
import escapeHtml from "@/utils/escapeHtml";
|
||||
import type { ForecastSlot } from "./types";
|
||||
import { BarChart, LineChart } from "echarts/charts";
|
||||
import {
|
||||
|
|
@ -110,6 +111,35 @@ export function tooltipStyle(
|
|||
};
|
||||
}
|
||||
|
||||
export interface TooltipRow {
|
||||
name?: string;
|
||||
values: string[];
|
||||
}
|
||||
|
||||
// Shared tooltip: bold date headline, non-bold rows, optional name + value columns.
|
||||
export function tooltipTable(head: string, rows: TooltipRow[], headers?: string[]): string {
|
||||
const hasName = rows.some((r) => r.name != null);
|
||||
const valueCols = Math.max(1, ...rows.map((r) => r.values.length));
|
||||
const colCount = (hasName ? 1 : 0) + valueCols;
|
||||
// lone value centers under the date; multiple columns line up right
|
||||
const valCls = hasName || valueCols > 1 ? "fw-normal text-end ps-3" : "fw-normal text-center";
|
||||
const headerRow = headers?.length
|
||||
? `<tr>${hasName ? "<td></td>" : ""}${headers
|
||||
.map((h) => `<td class="fw-normal text-end ps-3">${h}</td>`)
|
||||
.join("")}</tr>`
|
||||
: "";
|
||||
const body = rows
|
||||
.map((r) => {
|
||||
const nameTd = hasName
|
||||
? `<td class="fw-normal text-start">${escapeHtml(r.name ?? "")}</td>`
|
||||
: "";
|
||||
const valTds = r.values.map((v) => `<td class="${valCls}">${v}</td>`).join("");
|
||||
return `<tr>${nameTd}${valTds}</tr>`;
|
||||
})
|
||||
.join("");
|
||||
return `<table class="lh-sm"><thead><tr><th colspan="${colCount}" class="fw-bold text-center">${head}</th></tr></thead><tbody>${headerRow}${body}</tbody></table>`;
|
||||
}
|
||||
|
||||
export function forecastGrid() {
|
||||
return { top: 36, right: 16, bottom: 16, left: 24, borderWidth: 0 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ import {
|
|||
forecastGrid,
|
||||
forecastYAxis,
|
||||
tooltipStyle,
|
||||
tooltipTable,
|
||||
} from "../Forecast/echarts";
|
||||
import colors, { lighterColor, resolveColors, deviceColorMap } from "@/colors";
|
||||
import colors, { resolveColors, deviceColorMap, darken } from "@/colors";
|
||||
import store from "@/store";
|
||||
import formatter, { POWER_UNIT } from "@/mixins/formatter";
|
||||
import { PERIODS } from "../Sessions/types";
|
||||
|
|
@ -50,16 +51,6 @@ export function stepAlpha(i: number, n: number): number {
|
|||
return Math.max(minAlpha, 1 - (n - 1 - i) * step);
|
||||
}
|
||||
|
||||
export function alphaColor(color: string, alpha: number): string {
|
||||
const c = (color || "").trim().toLowerCase();
|
||||
const a = Math.round(Math.max(0, Math.min(1, alpha)) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
if (c.length === 7) return c + a;
|
||||
if (c.length === 9) return c.slice(0, 7) + a;
|
||||
return c;
|
||||
}
|
||||
|
||||
// Symmetric axis regardless of whether the period contains both directions.
|
||||
const BIDIRECTIONAL_GROUPS: ReadonlySet<string> = new Set(["grid", "battery"]);
|
||||
|
||||
|
|
@ -95,6 +86,7 @@ export default defineComponent({
|
|||
previousFocusedEntity: number | null;
|
||||
previousPeriod: PERIODS;
|
||||
previousSeriesKey: string;
|
||||
activeSlot: number | null;
|
||||
} {
|
||||
return {
|
||||
chart: null,
|
||||
|
|
@ -103,6 +95,7 @@ export default defineComponent({
|
|||
previousFocusedEntity: this.focusedEntity as number | null,
|
||||
previousPeriod: this.period as PERIODS,
|
||||
previousSeriesKey: "",
|
||||
activeSlot: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -205,8 +198,21 @@ export default defineComponent({
|
|||
categoryKeys(): string[] {
|
||||
return this.categoryTimestamps.map((t) => this.timestampKey(t));
|
||||
},
|
||||
// Which category slots carry a bar, so hover can skip empty slots.
|
||||
slotsWithData(): boolean[] {
|
||||
const index = new Map(this.categoryKeys.map((k, i) => [k, i]));
|
||||
const has = new Array(this.categoryKeys.length).fill(false);
|
||||
for (const s of this.visibleSeries) {
|
||||
for (const slot of s.data) {
|
||||
if (slot.energy <= 0 && slot.returnEnergy <= 0) continue;
|
||||
const idx = index.get(this.timestampKey(new Date(slot.start).getTime()));
|
||||
if (idx !== undefined) has[idx] = true;
|
||||
}
|
||||
}
|
||||
return has;
|
||||
},
|
||||
entryColors(): string[] {
|
||||
// Picker groups color per entity; pv/battery use alpha steps of the group color.
|
||||
// Picker groups color per entity; pv/battery use darker steps of the group color.
|
||||
if (hasColorPicker(this.group)) {
|
||||
const mutedColor = colors.muted || this.color;
|
||||
const titles: string[] = [];
|
||||
|
|
@ -222,9 +228,7 @@ export default defineComponent({
|
|||
});
|
||||
}
|
||||
if (this.series.length <= 1) return [this.color];
|
||||
return this.series.map((_, i) =>
|
||||
alphaColor(this.color, stepAlpha(i, this.series.length))
|
||||
);
|
||||
return this.series.map((_, i) => darken(this.color, stepAlpha(i, this.series.length)));
|
||||
},
|
||||
echartsSeries() {
|
||||
const cats = this.categoryKeys;
|
||||
|
|
@ -305,10 +309,15 @@ export default defineComponent({
|
|||
}
|
||||
}
|
||||
|
||||
// hover dims all but the active slot (onChartMouseMove); silent stops single-segment highlight
|
||||
const barEmphasis = {
|
||||
silent: true,
|
||||
emphasis: { focus: "self" },
|
||||
blur: { itemStyle: { opacity: 0.25 } },
|
||||
};
|
||||
this.series.forEach((s, i) => {
|
||||
const c = this.entryColors[i] || this.color;
|
||||
const returnEnergyColor =
|
||||
(s.group === "grid" && colors.export) || lighterColor(c) || c;
|
||||
const returnEnergyColor = (s.group === "grid" && colors.export) || c;
|
||||
const energyValues = energyByEntity[i]!;
|
||||
const returnEnergyValues = returnEnergyByEntity[i]!;
|
||||
const energyName =
|
||||
|
|
@ -361,6 +370,7 @@ export default defineComponent({
|
|||
itemStyle: { color: c, borderRadius: [0, 0, 0, 0] },
|
||||
barCategoryGap: "25%",
|
||||
barGap: "10%",
|
||||
...barEmphasis,
|
||||
});
|
||||
result.push({
|
||||
id: `entity-${stableIdx}-returnEnergy`,
|
||||
|
|
@ -371,6 +381,7 @@ export default defineComponent({
|
|||
itemStyle: { color: returnEnergyColor, borderRadius: [0, 0, 0, 0] },
|
||||
barCategoryGap: "25%",
|
||||
barGap: "10%",
|
||||
...barEmphasis,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -404,6 +415,17 @@ export default defineComponent({
|
|||
? (t: number) => this.fmtMonthNarrow(new Date(t))
|
||||
: (t: number) => this.fmtMonth(new Date(t), true);
|
||||
},
|
||||
// Column headers for bidirectional tooltips (grid: imported/exported,
|
||||
// battery: charged/discharged). Null when the group has no direction labels.
|
||||
directionHeaders(): string[] | null {
|
||||
if (!this.isBidirectional) return null;
|
||||
const energyKey = `main.history.direction.${this.group}.energy`;
|
||||
const returnEnergyKey = `main.history.direction.${this.group}.returnEnergy`;
|
||||
const energy = this.$t(energyKey);
|
||||
const returnEnergy = this.$t(returnEnergyKey);
|
||||
if (energy === energyKey || returnEnergy === returnEnergyKey) return null;
|
||||
return [String(energy), String(returnEnergy)];
|
||||
},
|
||||
tooltipDateLabel(): (t: number) => string {
|
||||
if (this.period === PERIODS.DAY) {
|
||||
return (t) => this.fmtTimeSlot(new Date(t), 15 * 60 * 1000);
|
||||
|
|
@ -426,7 +448,12 @@ export default defineComponent({
|
|||
grid: { ...forecastGrid(), left: 0, right: 36 },
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: { type: "shadow", shadowStyle: { color: "transparent" } },
|
||||
// transparent shadow snaps to slots without a band; triggerEmphasis off (it hard-codes notBlur), we dim slots in onChartMouseMove
|
||||
axisPointer: {
|
||||
type: "shadow",
|
||||
triggerEmphasis: false,
|
||||
shadowStyle: { color: "transparent" },
|
||||
},
|
||||
...tooltipStyle(this.tooltipColor),
|
||||
// Allow the tooltip to float above the 180px chart container instead
|
||||
// of being clamped by `confine: true` — otherwise tall bars push the
|
||||
|
|
@ -493,7 +520,7 @@ export default defineComponent({
|
|||
const first = params.find((p) => p.dataIndex != null);
|
||||
if (!first) return "";
|
||||
const ts = cats[first.dataIndex];
|
||||
const head = `<div>${ts != null ? tooltipDate(ts) : ""}</div>`;
|
||||
const head = ts != null ? tooltipDate(ts) : "";
|
||||
const formatValue = (v: number) => {
|
||||
const watts = Math.abs(v) * 1000;
|
||||
return this.period === PERIODS.DAY
|
||||
|
|
@ -525,31 +552,17 @@ export default defineComponent({
|
|||
);
|
||||
const showName = this.series.length > 1 && this.focusedEntity === null;
|
||||
|
||||
if (this.isBidirectional) {
|
||||
const rows = indices
|
||||
.map((i) => {
|
||||
const t = totals.get(i) ?? { energy: 0, returnEnergy: 0 };
|
||||
const val = `<strong>${formatValue(t.energy)} / ${formatValue(t.returnEnergy)}</strong>`;
|
||||
const name = nameByIdx.get(i) ?? "";
|
||||
return showName
|
||||
? `<div>${name}: ${val}</div>`
|
||||
: `<div>${val}</div>`;
|
||||
})
|
||||
.join("");
|
||||
return head + rows;
|
||||
}
|
||||
|
||||
const rows = indices
|
||||
.map((i) => {
|
||||
const t = totals.get(i) ?? { energy: 0, returnEnergy: 0 };
|
||||
const val = `<strong>${formatValue(t.energy + t.returnEnergy)}</strong>`;
|
||||
const name = nameByIdx.get(i) ?? "";
|
||||
return showName
|
||||
? `<div>${name}: ${val}</div>`
|
||||
: `<div>${val}</div>`;
|
||||
})
|
||||
.join("");
|
||||
return head + rows;
|
||||
const rows = indices.map((i) => {
|
||||
const t = totals.get(i) ?? { energy: 0, returnEnergy: 0 };
|
||||
const values = this.isBidirectional
|
||||
? [formatValue(t.energy), formatValue(t.returnEnergy)]
|
||||
: [formatValue(t.energy + t.returnEnergy)];
|
||||
return {
|
||||
name: showName ? (nameByIdx.get(i) ?? "") : undefined,
|
||||
values,
|
||||
};
|
||||
});
|
||||
return tooltipTable(head, rows, this.directionHeaders ?? undefined);
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
|
|
@ -663,6 +676,9 @@ export default defineComponent({
|
|||
const el = this.$refs["chartEl"] as HTMLElement;
|
||||
this.chart = markRaw(echarts.init(el));
|
||||
this.chart.setOption((this as unknown as WithChartOption).chartOption);
|
||||
const zr = this.chart.getZr();
|
||||
zr.on("mousemove", this.onChartMouseMove);
|
||||
zr.on("globalout", this.clearHighlight);
|
||||
this.mediaQuery = window.matchMedia("(max-width: 575.98px)");
|
||||
this.isMobile = this.mediaQuery.matches;
|
||||
this.mediaQuery.addEventListener("change", this.onMediaChange);
|
||||
|
|
@ -677,6 +693,31 @@ export default defineComponent({
|
|||
resize() {
|
||||
this.chart?.resize();
|
||||
},
|
||||
// highlight hovered slot, dim rest. manual because built-in axis highlight hard-codes notBlur
|
||||
onChartMouseMove(e: { offsetX: number; offsetY: number }) {
|
||||
if (!this.chart) return;
|
||||
const point: [number, number] = [e.offsetX, e.offsetY];
|
||||
if (!this.chart.containPixel({ gridIndex: 0 }, point)) {
|
||||
this.clearHighlight();
|
||||
return;
|
||||
}
|
||||
const grid = this.chart.convertFromPixel({ gridIndex: 0 }, point) as number[];
|
||||
const slot = Math.round(grid[0]!);
|
||||
if (slot === this.activeSlot) return;
|
||||
// Skip empty slots, else hovering a gap would dim the whole chart.
|
||||
if (!this.slotsWithData[slot]) {
|
||||
this.clearHighlight();
|
||||
return;
|
||||
}
|
||||
this.activeSlot = slot;
|
||||
this.chart.dispatchAction({ type: "downplay" });
|
||||
this.chart.dispatchAction({ type: "highlight", dataIndex: slot });
|
||||
},
|
||||
clearHighlight() {
|
||||
if (this.activeSlot === null) return;
|
||||
this.activeSlot = null;
|
||||
this.chart?.dispatchAction({ type: "downplay" });
|
||||
},
|
||||
onMediaChange(e: MediaQueryListEvent) {
|
||||
this.isMobile = e.matches;
|
||||
},
|
||||
|
|
|
|||
5
assets/js/utils/escapeHtml.ts
Normal file
5
assets/js/utils/escapeHtml.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export default function escapeHtml(s: string): string {
|
||||
const el = document.createElement("div");
|
||||
el.textContent = s;
|
||||
return el.innerHTML;
|
||||
}
|
||||
|
|
@ -116,16 +116,12 @@ import Header from "../components/Top/Header.vue";
|
|||
import PeriodSelector from "../components/Sessions/PeriodSelector.vue";
|
||||
import DateNavigator from "../components/Sessions/DateNavigator.vue";
|
||||
import PeriodHeader from "../components/Sessions/PeriodHeader.vue";
|
||||
import GroupChart, {
|
||||
type HistorySeries,
|
||||
alphaColor,
|
||||
stepAlpha,
|
||||
} from "../components/History/GroupChart.vue";
|
||||
import GroupChart, { type HistorySeries, 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, hasColorPicker } from "../components/History/groups";
|
||||
import colors, { resolveColors, deviceColorMap } from "../colors";
|
||||
import colors, { resolveColors, deviceColorMap, darken } from "../colors";
|
||||
import LegendList from "../components/Sessions/LegendList.vue";
|
||||
import { handleDownloadClick } from "@/utils/native";
|
||||
import formatter, { POWER_UNIT } from "../mixins/formatter";
|
||||
|
|
@ -422,7 +418,7 @@ export default defineComponent({
|
|||
const colorFor = (i: number, s: HistorySeries) => {
|
||||
if (s.virtual) return colors.muted || baseColor;
|
||||
if (colorPicker) return palette[s.title] || baseColor;
|
||||
return alphaColor(baseColor, stepAlpha(i, Math.max(n, 1)));
|
||||
return darken(baseColor, stepAlpha(i, Math.max(n, 1)));
|
||||
};
|
||||
return list.map((s, i) => {
|
||||
let sum = 0;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue