Optimizer UI: migrate charts to echarts, remove chart.js (#32837)

This commit is contained in:
Michael Geers 2026-08-14 16:26:29 +02:00 • committed by GitHub
parent 0f265a04f3
commit 9eea54d8e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 770 additions and 1350 deletions

View file

@ -1,7 +1,7 @@
<template>
<div>
<div class="table-responsive">
<table class="table">
<table class="table table-sm align-top w-auto text-nowrap">
<thead>
<tr>
<th scope="col">Battery</th>
@ -11,13 +11,30 @@
<th scope="col">Power Range</th>
<th scope="col">Max Discharge</th>
<th scope="col">Grid Interaction</th>
<th scope="col">Demand Profile</th>
<th scope="col">Demand</th>
<th scope="col">SoC Goals</th>
</tr>
</thead>
<tbody>
<tr v-for="(battery, index) in batteries" :key="index">
<th scope="row">{{ getBatteryTitle(index) }}</th>
<th scope="row">
<div class="d-flex align-items-center gap-2">
<span
class="battery-indicator"
:style="{ backgroundColor: batteryColors[index] }"
></span>
{{ getBatteryTitle(index) }}
</div>
<div class="progress soc-progress mt-2">
<div
class="progress-bar"
:style="{
width: socPercentage(battery.s_initial, index) + '%',
backgroundColor: batteryColors[index],
}"
></div>
</div>
</th>
<td>
<div>{{ formatStateOfCharge(battery.s_initial, index) }}</div>
<div class="text-muted small">
@ -33,7 +50,7 @@
<td>
<div>{{ formatEnergyValue(battery.p_a) }}</div>
<div class="text-muted small">
{{ formatTotalEnergyValue(battery.p_a, index) }}
{{ formatTotalEnergyValue(battery.p_a, index) }} total
</div>
</td>
<td>
@ -41,16 +58,21 @@
</td>
<td>{{ formatPower(battery.d_max) }}</td>
<td>
{{ formatGridInteraction(battery) }}
<span :class="{ 'text-muted': !hasGridInteraction(battery) }">
{{ formatGridInteraction(battery) }}
</span>
</td>
<td>
<span v-if="battery.p_demand?.length" class="badge bg-info">
<span v-if="battery.p_demand?.length">
{{ battery.p_demand.length }} steps
</span>
<span v-else class="text-muted">None</span>
</td>
<td>
<span v-if="battery.s_goal?.length" class="badge bg-warning">
<span
v-if="battery.s_goal?.length"
class="badge rounded-pill text-bg-light"
>
{{ battery.s_goal.length }} goals
</span>
<span v-else class="text-muted">None</span>
@ -93,6 +115,10 @@ export default defineComponent({
type: Array as PropType<BatteryDetail[]>,
required: true,
},
batteryColors: {
type: Array as PropType<string[]>,
default: () => [],
},
currency: {
type: String as PropType<CURRENCY>,
required: true,
@ -102,9 +128,6 @@ export default defineComponent({
formatPower(watts: number): string {
return this.fmtW(watts, this.POWER_UNIT.KW, true, 1);
},
formatEnergy(wh: number): string {
return this.fmtWh(wh, this.POWER_UNIT.KW, true, 1);
},
formatPowerRange(min: number, max: number): string {
const minValue = this.fmtW(min, this.POWER_UNIT.KW, false, 1);
const maxValue = this.fmtW(max, this.POWER_UNIT.KW, true, 1);
@ -122,6 +145,13 @@ export default defineComponent({
const detail = this.batteryDetails[index];
return detail ? detail.title || detail.name : `Battery ${index + 1}`;
},
socPercentage(initialSocWh: number, index: number): number {
const detail = this.batteryDetails[index];
if (detail?.capacity && detail.capacity > 0) {
return (initialSocWh / 1000 / detail.capacity) * 100;
}
return 0;
},
formatStateOfCharge(initialSocWh: number, index: number): string {
const detail = this.batteryDetails[index];
if (detail?.capacity) {
@ -132,12 +162,7 @@ export default defineComponent({
return "-";
},
formatInitialSocPercentage(initialSocWh: number, index: number): string {
const detail = this.batteryDetails[index];
if (detail?.capacity && detail.capacity > 0) {
const percentage = (initialSocWh / 1000 / detail.capacity) * 100;
return this.fmtPercentage(percentage, 0);
}
return "";
return this.fmtPercentage(this.socPercentage(initialSocWh, index), 0);
},
formatSocRangePercentage(minSocWh: number, maxSocWh: number, index: number): string {
const detail = this.batteryDetails[index];
@ -156,6 +181,9 @@ export default defineComponent({
}
return "";
},
hasGridInteraction(battery: BatteryConfig): boolean {
return !!(battery.charge_from_grid || battery.discharge_to_grid);
},
formatGridInteraction(battery: BatteryConfig): string {
const canCharge = battery.charge_from_grid;
const canDischarge = battery.discharge_to_grid;
@ -175,8 +203,43 @@ export default defineComponent({
</script>
<style scoped>
.table {
font-size: 0.8125rem;
}
.table td,
.table th {
font-variant-numeric: tabular-nums;
padding-right: 1.75rem;
padding-top: 0.625rem;
padding-bottom: 0.625rem;
border-color: rgba(147, 148, 158, 0.1);
}
.table .small {
font-size: 0.75rem;
}
.table thead th {
color: var(--bs-gray-medium);
font-size: 0.6875rem;
font-weight: normal;
text-transform: uppercase;
letter-spacing: 0.03em;
padding-top: 0;
padding-bottom: 0.25rem;
border-color: rgba(147, 148, 158, 0.25);
}
.battery-indicator {
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
flex-shrink: 0;
}
.badge {
font-size: 0.75rem;
}
.soc-progress {
height: 0.25rem;
}
.table .text-muted {
color: var(--bs-gray-medium) !important;
}
</style>

View file

@ -1,14 +1,6 @@
<template>
<div>
<div class="chart-container my-3">
<Chart
ref="chartRef"
type="bar"
:data="chartData"
:options="chartOptions"
:height="300"
/>
</div>
<div ref="chartEl" class="charge-chart my-3"></div>
<LegendList :legends="legends" :device-colors="deviceColors" />
</div>
</template>
@ -16,49 +8,28 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarController,
LineController,
BarElement,
LineElement,
PointElement,
Title,
Tooltip,
Legend as ChartLegendPlugin,
type ChartOptions,
type ChartData,
} from "chart.js";
import { Chart } from "vue-chartjs";
FONT_FAMILY,
forecastYAxis,
tooltipStyle,
tooltipTable,
type TooltipRow,
} from "../Forecast/echarts";
import type { EvoptData } from "./TimeSeriesDataTable.vue";
import type { CURRENCY, BatteryDetail, DeviceColors } from "@/types/evcc";
import type { BatteryDetail, DeviceColors } from "@/types/evcc";
import formatter from "@/mixins/formatter";
import echartsChart from "@/mixins/echartsChart";
import colors from "@/colors";
import LegendList from "../Sessions/LegendList.vue";
import type { Legend } from "../Sessions/types";
import "../Sessions/chartConfig";
import { slotTimes, slotXAxis, formatSlotRange, whToKW, loadpointTitle } from "./chart";
ChartJS.register(
CategoryScale,
LinearScale,
BarController,
LineController,
BarElement,
LineElement,
PointElement,
Title,
Tooltip,
ChartLegendPlugin
);
const GRID_LABEL = "Grid Power";
const SOLAR_LABEL = "Solar Forecast";
export default defineComponent({
name: "ChargeChart",
components: {
Chart,
LegendList,
},
mixins: [formatter],
components: { LegendList },
mixins: [formatter, echartsChart],
props: {
evopt: {
type: Object as PropType<EvoptData>,
@ -72,10 +43,6 @@ export default defineComponent({
type: String,
default: "",
},
currency: {
type: String as PropType<CURRENCY>,
required: true,
},
batteryColors: {
type: Array as PropType<string[]>,
default: () => [],
@ -83,343 +50,176 @@ export default defineComponent({
deviceColors: { type: Object as PropType<DeviceColors>, default: () => ({}) },
},
computed: {
timeLabels(): string[] {
const startTime = new Date(this.timestamp);
return this.evopt.req.time_series.dt.map((_, index) => {
// Calculate cumulative time from dt array
let cumulativeSeconds = 0;
for (let i = 0; i < index; i++) {
cumulativeSeconds += this.evopt.req.time_series.dt[i] || 0;
}
const currentTime = new Date(startTime.getTime() + cumulativeSeconds * 1000);
const hour = currentTime.getHours();
const minute = currentTime.getMinutes();
// Only show labels at exact hour boundaries divisible by 4
if (minute === 0 && hour % 4 === 0) {
return hour.toString();
}
return "";
consumptionLabel(): string {
return this.$t("main.history.group.consumer");
},
consumptionColor(): string {
return colors.muted || "";
},
// stack and legend order: loadpoints first, then batteries
entryOrder(): number[] {
const batteries: number[] = [];
const vehicles: number[] = [];
this.batteryDetails.forEach((d, i) =>
(d.type === "battery" ? batteries : vehicles).push(i)
);
return [...vehicles, ...batteries];
},
times(): number[] {
return slotTimes(this.timestamp, this.evopt.req.time_series.dt);
},
gridPower(): number[] {
const gridImport = this.evopt.res.grid_import || [];
const gridExport = this.evopt.res.grid_export || [];
return gridImport.map((imp, i) => {
const importKW = this.toKW(imp, i);
const exportKW = this.toKW(gridExport[i] || 0, i);
return importKW > 0 ? importKW : -exportKW;
});
},
chartData(): ChartData {
const datasets: any[] = [];
// 1. Grid power data (import/export)
datasets.push(...this.getGridPowerDatasets());
// 2. Solar Forecast
datasets.push(...this.getSolarDatasets());
// 3. Household Demand (power)
datasets.push(...this.getHouseholdDatasets());
// 4. Battery power data
datasets.push(...this.getBatteryPowerDatasets());
return {
labels: this.timeLabels,
datasets: datasets,
};
chartSeries(): Record<string, unknown>[] {
const series: Record<string, unknown>[] = [
{
name: GRID_LABEL,
type: "line",
z: 4,
data: this.gridPower,
showSymbol: false,
smooth: 0.2,
lineStyle: { color: colors.grid || "", width: 2 },
itemStyle: { color: colors.grid || "" },
emphasis: { disabled: true },
},
{
name: SOLAR_LABEL,
type: "line",
z: 4,
data: this.evopt.req.time_series.ft.map(this.toKW),
showSymbol: false,
smooth: 0.2,
lineStyle: { color: colors.self || "", width: 3 },
itemStyle: { color: colors.self || "" },
emphasis: { disabled: true },
},
{
name: this.consumptionLabel,
type: "bar",
stack: "charge",
data: this.evopt.req.time_series.gt.map(this.toKW),
itemStyle: { color: this.consumptionColor },
emphasis: { disabled: true },
},
];
this.entryOrder.forEach((index) => {
const battery = this.evopt.res.batteries[index];
if (!battery) return;
// charging positive, discharging negative; one of both is always zero
const power = battery.charging_power.map((charging, i) => {
const chargingKW = this.toKW(charging, i);
const dischargingKW = this.toKW(battery.discharging_power[i] || 0, i);
return chargingKW > 0 ? chargingKW : -dischargingKW;
});
series.push({
name: this.getBatteryTitle(index),
type: "bar",
stack: "charge",
data: power,
itemStyle: { color: this.batteryColors[index] },
emphasis: { disabled: true },
});
});
return series;
},
chartOptions(): ChartOptions {
chartOption(): Record<string, unknown> {
return {
responsive: true,
maintainAspectRatio: false,
color: colors.text || "",
animation: false,
interaction: {
mode: "index",
intersect: false,
},
hover: {
mode: "index",
intersect: false,
},
elements: {
point: {
radius: 0, // Hide points by default
hoverRadius: 6, // Show points on hover
textStyle: { fontFamily: FONT_FAMILY },
grid: { top: 10, right: 36, bottom: 34, left: 0, borderWidth: 0 },
tooltip: {
trigger: "axis",
axisPointer: {
type: "line",
lineStyle: { color: colors.muted || "", opacity: 0.4 },
},
// no chart anchor: category-axis values are scalars, convertToPixel
// would choke on them; follow the pointer instead
...tooltipStyle(colors.text || ""),
formatter: this.tooltipFormatter,
},
plugins: {
title: { display: false },
legend: { display: false },
tooltip: {
backgroundColor: "#000000cc",
boxPadding: 5,
usePointStyle: false,
borderWidth: 0.00001,
mode: "index",
intersect: false,
callbacks: {
title: (context) => {
const index = context[0]?.dataIndex;
return this.formatTimeRange(index ?? 0);
},
label: (context) => {
const label = context.dataset.label || "";
const value = context.parsed.y ?? 0;
// Special handling for Grid Power
if (label === "Grid Power") {
if (value > 0) {
return `Grid Import: ${this.formatValue(Math.abs(value))} kW`;
} else if (value < 0) {
return `Grid Export: ${this.formatValue(Math.abs(value))} kW`;
} else {
return `Grid: 0 kW`;
}
}
// Power axis (kW)
return `${label}: ${this.formatValue(value)} kW`;
},
},
xAxis: slotXAxis(this.times),
yAxis: forecastYAxis({
min: undefined,
position: "right",
splitNumber: 5,
axisLabel: {
color: colors.muted || "",
formatter: (v: number) => this.fmtNumber(v, 0),
},
datalabels: {
display: false,
},
},
scales: {
x: {
title: {
display: false,
},
stacked: true,
grid: {
display: true,
drawOnChartArea: true,
drawTicks: true,
color: "transparent",
tickLength: 4,
},
ticks: {
autoSkip: false,
maxRotation: 0,
minRotation: 0,
callback: (_value, index) => {
const startTime = new Date(this.timestamp);
// Calculate cumulative time from dt array
let cumulativeSeconds = 0;
for (let i = 0; i < index; i++) {
cumulativeSeconds += this.evopt.req.time_series.dt[i] || 0;
}
const currentTime = new Date(
startTime.getTime() + cumulativeSeconds * 1000
);
const hour = currentTime.getHours();
const minute = currentTime.getMinutes();
// Show ticks at exact hour boundaries
if (minute === 0) {
// Show labels only at hours divisible by 4
const step = window.innerWidth < 576 ? 6 : 4;
if (hour % step === 0) {
return hour.toString();
}
// Show tick but no label for other hours
return "";
}
// Return undefined to skip this tick entirely
return undefined;
},
},
},
y: {
type: "linear",
position: "left",
title: {
display: true,
text: "Power (kW)",
},
stacked: true,
grid: {
drawOnChartArea: true,
color: colors.border || "",
lineWidth: 1,
},
// Keep scales purely based on values, no fixed boundaries
},
},
}),
series: this.chartSeries,
};
},
legends(): Legend[] {
const batteryTitles = new Set(
(this.evopt?.res?.batteries || []).map((_, i) => this.getBatteryTitle(i))
);
return this.chartData.datasets
.filter((dataset) => !dataset.hidden)
.map((dataset) => {
const label = dataset.label || "";
const isLine = dataset.type === "line";
return {
label,
color: (dataset.backgroundColor || dataset.borderColor) as string,
value: "", // Required by Legend type, but not used in this context
type: isLine ? "line" : "area",
id: batteryTitles.has(label) ? label : undefined,
};
const legends: Legend[] = [
{ label: GRID_LABEL, color: colors.grid || "", value: "", type: "line" },
{ label: SOLAR_LABEL, color: colors.self || "", value: "", type: "line" },
{
label: this.consumptionLabel,
color: this.consumptionColor,
value: "",
type: "area",
},
];
this.entryOrder.forEach((i) => {
const detail = this.batteryDetails[i];
if (!detail) return;
legends.push({
label: this.getBatteryTitle(i),
color: this.batteryColors[i] || "",
value: "",
type: "area",
id: detail.type === "vehicle" ? loadpointTitle(detail) : undefined,
});
});
return legends;
},
},
methods: {
getSolarDatasets() {
return [
{
label: "Solar Forecast",
data: this.evopt.req.time_series.ft.map(this.convertWhToKW),
borderColor: colors.self,
backgroundColor: colors.self,
fill: false,
tension: 0.2,
borderJoinStyle: "round",
borderCapStyle: "round",
pointRadius: 0,
pointHoverRadius: 6,
borderWidth: 3,
yAxisID: "y",
type: "line" as const,
stack: "solar",
},
];
toKW(wh: number, index: number): number {
return whToKW(wh, this.evopt.req.time_series.dt[index] || 0);
},
getBatteryPowerDatasets() {
const datasets: any[] = [];
if (this.evopt.res.batteries?.length > 0) {
this.evopt.res.batteries.forEach((battery, index) => {
// Use passed battery colors (same as SoC)
const baseColor = this.batteryColors[index];
// Combined charging/discharging power as one line (same color as SoC)
// Charging = positive, Discharging = negative
const combinedPower = battery.charging_power.map((chargingPower, timeIndex) => {
const dischargingPower = battery.discharging_power[timeIndex] || 0;
const chargingKW = this.convertWhToKW(chargingPower, timeIndex);
const dischargingKW = this.convertWhToKW(dischargingPower, timeIndex);
// Return charging as positive, discharging as negative
// One should be zero, the other should have the value
return chargingKW > 0 ? chargingKW : -dischargingKW;
});
datasets.push({
label: this.getBatteryTitle(index),
data: combinedPower,
backgroundColor: baseColor,
borderWidth: 0,
yAxisID: "y",
type: "bar" as const,
stack: "charge",
});
});
}
return datasets;
},
getHouseholdDatasets() {
const householdPower = this.evopt.req.time_series.gt.map(this.convertWhToKW);
// Use the next color in the palette after all battery colors
const batteryCount = this.batteryColors.length;
const householdColor = colors.palette[batteryCount % colors.palette.length];
return [
{
label: "Household",
data: householdPower,
backgroundColor: householdColor,
borderWidth: 0,
yAxisID: "y",
type: "bar" as const,
stack: "charge",
},
];
},
getGridPowerDatasets() {
const datasets: any[] = [];
// Get grid import and export data
const gridImport = this.evopt.res.grid_import || [];
const gridExport = this.evopt.res.grid_export || [];
// Combine grid import and export into a single line
// Grid import is positive, grid export is negative (one is always zero)
const gridPower = gridImport.map((importValue, index) => {
const exportValue = gridExport[index] || 0;
const importKW = this.convertWhToKW(importValue, index);
const exportKW = this.convertWhToKW(exportValue, index);
// Return import as positive, export as negative
return importKW > 0 ? importKW : -exportKW;
});
datasets.push({
label: "Grid Power",
data: gridPower,
borderColor: "#666666", // Dark gray
backgroundColor: "#666666", // Dark gray
fill: false,
tension: 0.2,
borderWidth: 2, // Same thickness as price chart lines
pointRadius: 0,
pointHoverRadius: 6,
yAxisID: "y",
type: "line" as const,
stack: "grid",
});
return datasets;
},
convertWhToKW(wh: number, index: number): number {
// Convert Wh to kW by normalizing against time duration
// Power (kW) = Energy (Wh) / Time (h) / 1000
const dtSeconds = this.evopt.req.time_series.dt[index] || 0;
const hours = dtSeconds / 3600; // Convert seconds to hours
return wh / hours / 1000;
},
formatValue: (value: number): string => {
formatValue(value: number): string {
return value.toFixed(2);
},
getBatteryTitle(index: number): string {
const detail = this.batteryDetails[index];
return detail ? detail.title || detail.name : `Battery ${index + 1}`;
},
formatTimeRange(index: number): string {
const startTime = new Date(this.timestamp);
// Calculate cumulative time from dt array
let cumulativeSeconds = 0;
for (let i = 0; i < index; i++) {
cumulativeSeconds += this.evopt.req.time_series.dt[i] || 0;
}
const slotStart = new Date(startTime.getTime() + cumulativeSeconds * 1000);
const slotDuration = this.evopt.req.time_series.dt[index] || 0;
const slotEnd = new Date(slotStart.getTime() + slotDuration * 1000);
const formatTime = (date: Date): string => {
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${hours}:${minutes}`;
};
return `${formatTime(slotStart)} - ${formatTime(slotEnd)}`;
tooltipFormatter(
params: { dataIndex: number; seriesName?: string; value?: number }[]
): string {
const arr = Array.isArray(params) ? params : [params];
if (!arr.length) return "";
const dt = this.evopt.req.time_series.dt;
const head = formatSlotRange(this.times, dt, arr[0]!.dataIndex);
const rows: TooltipRow[] = arr
.filter((p) => p.value != null && !Number.isNaN(p.value))
.map((p) => {
const value = p.value as number;
if (p.seriesName === GRID_LABEL) {
const name = value > 0 ? "Grid Import" : value < 0 ? "Grid Export" : "Grid";
return { name, values: [`${this.formatValue(Math.abs(value))} kW`] };
}
return { name: p.seriesName, values: [`${this.formatValue(value)} kW`] };
});
return tooltipTable(head, rows);
},
},
});
</script>
<style scoped>
.chart-container {
position: relative;
.charge-chart {
height: 300px;
width: 100%;
}

View file

@ -1,309 +0,0 @@
<template>
<div>
<div class="chart-container my-3">
<Chart
ref="chartRef"
type="line"
:data="chartData"
:options="chartOptions"
:height="150"
/>
</div>
<LegendList :legends="legends" />
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
LineController,
LineElement,
PointElement,
Title,
Tooltip,
Legend as ChartLegendPlugin,
type ChartOptions,
type ChartData,
} from "chart.js";
import { Chart } from "vue-chartjs";
import type { EvoptData } from "./TimeSeriesDataTable.vue";
import type { CURRENCY } from "@/types/evcc";
import formatter from "@/mixins/formatter";
import colors from "@/colors";
import LegendList from "../Sessions/LegendList.vue";
import type { Legend } from "../Sessions/types";
import "../Sessions/chartConfig";
const tension = 0;
ChartJS.register(
CategoryScale,
LinearScale,
LineController,
LineElement,
PointElement,
Title,
Tooltip,
ChartLegendPlugin
);
export default defineComponent({
name: "PriceChart",
components: {
Chart,
LegendList,
},
mixins: [formatter],
props: {
evopt: {
type: Object as PropType<EvoptData>,
required: true,
},
timestamp: {
type: String,
default: "",
},
currency: {
type: String as PropType<CURRENCY>,
required: true,
},
},
computed: {
timeLabels(): string[] {
const startTime = new Date(this.timestamp);
return this.evopt.req.time_series.dt.map((_, index) => {
// Calculate cumulative time from dt array
let cumulativeSeconds = 0;
for (let i = 0; i < index; i++) {
cumulativeSeconds += this.evopt.req.time_series.dt[i] || 0;
}
const currentTime = new Date(startTime.getTime() + cumulativeSeconds * 1000);
const hour = currentTime.getHours();
const minute = currentTime.getMinutes();
// Only show labels at exact hour boundaries divisible by 4
if (minute === 0 && hour % 4 === 0) {
return hour.toString();
}
return "";
});
},
chartData(): ChartData {
const datasets: any[] = [];
// Price data only
datasets.push(...this.getPriceDatasets());
return {
labels: this.timeLabels,
datasets: datasets,
};
},
chartOptions(): ChartOptions {
return {
responsive: true,
maintainAspectRatio: false,
color: colors.text || "",
animation: false,
interaction: {
mode: "index",
intersect: false,
},
elements: {
point: {
radius: 0, // Hide points by default
hoverRadius: 6, // Show points on hover
},
},
plugins: {
title: { display: false },
legend: { display: false },
tooltip: {
backgroundColor: "#000000cc",
boxPadding: 5,
usePointStyle: false,
borderWidth: 0.00001,
mode: "index",
intersect: false,
callbacks: {
title: (context) => {
const index = context[0]?.dataIndex;
return this.formatTimeRange(index ?? 0);
},
label: (context) => {
const label = context.dataset.label || "";
const value = context.parsed.y ?? 0;
// Price axis (currency/kWh)
return `${label}: ${this.formatPrice(value)}`;
},
},
},
datalabels: {
display: false,
},
},
scales: {
x: {
title: {
display: false,
},
grid: {
display: true,
drawOnChartArea: true,
drawTicks: true,
color: "transparent",
tickLength: 4,
},
ticks: {
autoSkip: false,
maxRotation: 0,
minRotation: 0,
callback: (_value, index) => {
const startTime = new Date(this.timestamp);
// Calculate cumulative time from dt array
let cumulativeSeconds = 0;
for (let i = 0; i < index; i++) {
cumulativeSeconds += this.evopt.req.time_series.dt[i] || 0;
}
const currentTime = new Date(
startTime.getTime() + cumulativeSeconds * 1000
);
const hour = currentTime.getHours();
const minute = currentTime.getMinutes();
// Show ticks at exact hour boundaries
if (minute === 0) {
// Show labels only at hours divisible by 4
const step = window.innerWidth < 576 ? 6 : 4;
if (hour % step === 0) {
return hour.toString();
}
// Show tick but no label for other hours
return "";
}
// Return undefined to skip this tick entirely
return undefined;
},
},
},
y: {
type: "linear",
position: "left",
title: {
display: true,
text: this.pricePerKWhUnit(this.currency, false),
},
grid: {
drawOnChartArea: true,
},
// Keep scales purely based on values, no fixed boundaries
},
},
};
},
legends(): Legend[] {
return this.chartData.datasets
.filter((dataset) => !dataset.hidden)
.map((dataset) => {
const label = dataset.label || "";
return {
label,
color: (dataset.backgroundColor || dataset.borderColor) as string,
value: "", // Required by Legend type, but not used in this context
type: "line",
};
});
},
},
methods: {
getPriceDatasets() {
const datasets: any[] = [];
// Convert raw price (currency/Wh) to the display unit per kWh (e.g. ct/kWh)
const factor = this.pricePerKWhDisplayFactor(this.currency);
const convertPrice = (price: number): number => price * 1000 * factor;
// Grid Import Price (solid line, price color)
datasets.push({
label: "Import",
data: this.evopt.req.time_series.p_N.map(convertPrice),
borderColor: colors.grid,
backgroundColor: colors.grid,
fill: false,
tension,
stepped: true,
borderJoinStyle: "round",
borderCapStyle: "round",
pointRadius: 0,
pointHoverRadius: 6,
borderWidth: 2,
yAxisID: "y",
type: "line" as const,
});
// Grid Export Price (solid line, price color)
datasets.push({
label: "Export",
data: this.evopt.req.time_series.p_E.map(convertPrice),
borderColor: colors.price,
backgroundColor: colors.price,
fill: false,
tension,
stepped: true,
borderJoinStyle: "round",
borderCapStyle: "round",
pointRadius: 0,
pointHoverRadius: 6,
borderWidth: 2,
yAxisID: "y",
type: "line" as const,
});
return datasets;
},
formatPrice(price: number): string {
// price is already in display unit (see convertPrice); undo the factor so the
// formatter re-applies it and appends the matching unit
const factor = this.pricePerKWhDisplayFactor(this.currency);
return this.fmtPricePerKWh(price / factor, this.currency);
},
formatTimeRange(index: number): string {
const startTime = new Date(this.timestamp);
// Calculate cumulative time from dt array
let cumulativeSeconds = 0;
for (let i = 0; i < index; i++) {
cumulativeSeconds += this.evopt.req.time_series.dt[i] || 0;
}
const slotStart = new Date(startTime.getTime() + cumulativeSeconds * 1000);
const slotDuration = this.evopt.req.time_series.dt[index] || 0;
const slotEnd = new Date(slotStart.getTime() + slotDuration * 1000);
const formatTime = (date: Date): string => {
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${hours}:${minutes}`;
};
return `${formatTime(slotStart)} - ${formatTime(slotEnd)}`;
},
},
});
</script>
<style scoped>
.chart-container {
position: relative;
height: 150px;
width: 100%;
}
</style>

View file

@ -1,284 +1,152 @@
<template>
<div>
<div v-for="(_battery, index) in evopt.res.batteries" :key="index" class="mb-3">
<div class="mb-2" style="font-size: 0.875rem; font-weight: bold">
{{ getBatteryTitle(index) }}
</div>
<div
:class="
index === evopt.res.batteries.length - 1
? 'chart-container-small-with-labels'
: 'chart-container-small'
"
>
<Chart
:ref="`chartRef${index}`"
type="bar"
:data="getBatteryChartData(index)"
:options="getBatteryChartOptions(index)"
:height="75"
/>
</div>
</div>
<LegendList
:legends="[{ label: entry.title, color: entry.color, value: '', type: 'area' }]"
/>
<div ref="chartEl" class="soc-chart" :class="{ 'soc-chart--labels': showXAxis }"></div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend as ChartLegendPlugin,
type ChartOptions,
type ChartData,
} from "chart.js";
import { Chart } from "vue-chartjs";
import type { EvoptData } from "./TimeSeriesDataTable.vue";
import type { CURRENCY, BatteryDetail } from "@/types/evcc";
FONT_FAMILY,
forecastXAxes,
forecastYAxis,
tooltipStyle,
tooltipTable,
} from "../Forecast/echarts";
import colors, { dimColor } from "@/colors";
import formatter from "@/mixins/formatter";
import colors from "@/colors";
import "../Sessions/chartConfig";
import echartsChart from "@/mixins/echartsChart";
import { is12hFormat } from "@/units";
import type { EvoptData } from "./TimeSeriesDataTable.vue";
import LegendList from "../Sessions/LegendList.vue";
import { slotTimes } from "./chart";
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, ChartLegendPlugin);
export interface SocChartEntry {
index: number; // index into evopt.res.batteries
title: string;
capacity: number; // kWh
color: string;
}
export default defineComponent({
name: "SocChart",
components: {
Chart,
},
mixins: [formatter],
components: { LegendList },
mixins: [formatter, echartsChart],
props: {
evopt: {
type: Object as PropType<EvoptData>,
required: true,
},
batteryDetails: {
type: Array as PropType<BatteryDetail[]>,
entry: {
type: Object as PropType<SocChartEntry>,
required: true,
},
timestamp: {
type: String,
default: "",
},
currency: {
type: String as PropType<CURRENCY>,
required: true,
},
batteryColors: {
type: Array as PropType<string[]>,
default: () => [],
},
showXAxis: { type: Boolean, default: false },
},
computed: {},
methods: {
getTimeLabels(): string[] {
const startTime = new Date(this.timestamp);
return this.evopt.req.time_series.dt.map((_, index) => {
// Calculate cumulative time from dt array
let cumulativeSeconds = 0;
for (let i = 0; i < index; i++) {
cumulativeSeconds += this.evopt.req.time_series.dt[i] || 0;
}
const currentTime = new Date(startTime.getTime() + cumulativeSeconds * 1000);
const hour = currentTime.getHours();
const minute = currentTime.getMinutes();
// Only show labels at exact hour boundaries divisible by 4
if (minute === 0 && hour % 4 === 0) {
return hour.toString();
}
return "";
computed: {
times(): number[] {
return slotTimes(this.timestamp, this.evopt.req.time_series.dt);
},
endTime(): number {
const dt = this.evopt.req.time_series.dt;
const last = this.times[this.times.length - 1];
return last === undefined ? 0 : last + (dt[dt.length - 1] || 0) * 1000;
},
socSeries(): [number, number][] {
const soc = this.evopt.res.batteries[this.entry.index]?.state_of_charge || [];
const capacityWh = this.entry.capacity * 1000;
return this.times.map((t, i) => {
const wh = soc[i] ?? 0;
return [t, capacityWh > 0 ? (wh / capacityWh) * 100 : 0] as [number, number];
});
},
getBatteryChartData(batteryIndex: number): ChartData {
const battery = this.evopt.res.batteries[batteryIndex];
if (!battery) {
return { labels: [], datasets: [] };
xAxes(): Record<string, unknown>[] {
const stepHours = is12hFormat() ? 6 : 4;
const [hourAxis, dayAxis] = forecastXAxes(
this.times[0] ?? 0,
this.endTime,
this.hourShort,
this.weekdayShort,
stepHours
);
if (!this.showXAxis) {
(hourAxis as Record<string, unknown>)["axisLabel"] = { show: false };
}
const baseColor = this.batteryColors[batteryIndex] || "";
return [hourAxis, dayAxis];
},
chartOption(): Record<string, unknown> {
return {
labels: this.getTimeLabels(),
datasets: [
animation: false,
textStyle: { fontFamily: FONT_FAMILY },
grid: {
top: 10,
right: 36,
bottom: this.showXAxis ? 34 : 8,
left: 0,
borderWidth: 0,
},
tooltip: {
trigger: "axis",
axisPointer: {
type: "line",
lineStyle: { color: colors.muted || "", opacity: 0.4 },
},
...tooltipStyle(colors.text || "", () => this.chart),
formatter: this.tooltipFormatter,
},
xAxis: this.xAxes,
yAxis: forecastYAxis({
min: 0,
max: 100,
position: "right",
interval: 50,
axisLabel: {
color: colors.muted || "",
formatter: (v: number) => this.fmtNumber(v, 0),
},
}),
series: [
{
label: this.getBatteryTitle(batteryIndex),
data: battery.state_of_charge.map((socWh) =>
this.convertWhToPercentage(socWh, batteryIndex)
),
backgroundColor: baseColor,
borderWidth: 0,
yAxisID: "y",
type: "line",
step: "start",
z: 3,
data: this.socSeries,
showSymbol: false,
lineStyle: { color: this.entry.color, width: 2 },
itemStyle: { color: this.entry.color },
areaStyle: { color: dimColor(this.entry.color) },
emphasis: { disabled: true },
},
],
};
},
getBatteryChartOptions(batteryIndex: number): ChartOptions {
const isLastBattery = batteryIndex === this.evopt.res.batteries.length - 1;
return {
responsive: true,
maintainAspectRatio: false,
color: colors.text || "",
animation: false,
interaction: {
mode: "index",
intersect: false,
},
plugins: {
title: { display: false },
legend: { display: false },
tooltip: {
backgroundColor: "#000000cc",
boxPadding: 5,
usePointStyle: false,
borderWidth: 0.00001,
mode: "index",
intersect: false,
callbacks: {
title: (context) => {
const index = context[0]?.dataIndex;
return this.formatTimeRange(index ?? 0);
},
label: (context) => {
const value = context.parsed.y ?? 0;
return `SoC: ${this.formatValue(value)}%`;
},
},
},
datalabels: {
display: false,
},
},
scales: {
x: {
title: {
display: false,
},
grid: {
display: true,
drawOnChartArea: true,
drawTicks: true,
color: "transparent",
tickLength: 4,
},
ticks: {
display: isLastBattery,
autoSkip: false,
maxRotation: 0,
minRotation: 0,
callback: (_value, index) => {
const startTime = new Date(this.timestamp);
// Calculate cumulative time from dt array
let cumulativeSeconds = 0;
for (let i = 0; i < index; i++) {
cumulativeSeconds += this.evopt.req.time_series.dt[i] || 0;
}
const currentTime = new Date(
startTime.getTime() + cumulativeSeconds * 1000
);
const hour = currentTime.getHours();
const minute = currentTime.getMinutes();
// Show ticks at exact hour boundaries
if (minute === 0) {
// Show labels only at hours divisible by 4
const step = window.innerWidth < 576 ? 6 : 4;
if (hour % step === 0) {
return hour.toString();
}
// Show tick but no label for other hours
return "";
}
// Return undefined to skip this tick entirely
return undefined;
},
},
},
y: {
type: "linear",
position: "left",
min: 0,
max: 100,
title: {
display: false,
},
grid: {
drawOnChartArea: true,
color: colors.border || "",
lineWidth: 1,
},
ticks: {
callback: (value) => `${value}%`,
},
},
},
};
},
convertWhToPercentage(wh: number, batteryIndex: number): number {
const detail = this.batteryDetails[batteryIndex];
if (detail?.capacity && detail.capacity > 0) {
return (wh / 1000 / detail.capacity) * 100;
}
return 0;
},
formatValue: (value: number): string => {
return value.toFixed(2);
},
getBatteryTitle(index: number): string {
const detail = this.batteryDetails[index];
return detail ? detail.title || detail.name : `Battery ${index + 1}`;
},
formatTimeRange(index: number): string {
const startTime = new Date(this.timestamp);
// Calculate cumulative time from dt array
let cumulativeSeconds = 0;
for (let i = 0; i < index; i++) {
cumulativeSeconds += this.evopt.req.time_series.dt[i] || 0;
}
const slotStart = new Date(startTime.getTime() + cumulativeSeconds * 1000);
const slotDuration = this.evopt.req.time_series.dt[index] || 0;
const slotEnd = new Date(slotStart.getTime() + slotDuration * 1000);
const formatTime = (date: Date): string => {
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${hours}:${minutes}`;
};
return `${formatTime(slotStart)} - ${formatTime(slotEnd)}`;
},
methods: {
tooltipFormatter(params: { axisValue: number; value?: [number, number] }[]): string {
const arr = Array.isArray(params) ? params : [params];
const v = arr[0]?.value?.[1];
if (v == null || Number.isNaN(v)) return "";
const t = new Date(arr[0]!.axisValue);
const head = `${this.weekdayShort(t)} ${this.fmtHourMinute(t)}`;
return tooltipTable(head, [{ values: [this.fmtPercentage(v)] }]);
},
},
});
</script>
<style scoped>
.chart-container-small {
position: relative;
height: 75px;
.soc-chart {
height: 54px;
width: 100%;
}
.chart-container-small-with-labels {
position: relative;
height: 95px;
width: 100%;
}
.battery-indicator {
width: 1rem;
height: 1rem;
border-radius: 50%;
flex-shrink: 0;
.soc-chart--labels {
height: 80px;
}
</style>

View file

@ -1,187 +1,68 @@
<template>
<div>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th scope="col" class="text-nowrap">Data Series</th>
<th
v-for="(_, index) in timeSlots"
:key="index"
scope="col"
:class="['text-end']"
>
{{ formatHour(index) }}
</th>
</tr>
</thead>
<tbody>
<!-- Request Data -->
<tr class="table-secondary">
<td :colspan="timeSlots.length + 1" class="fw-bold text-start">
Request Data
</td>
</tr>
<tr>
<td class="fw-medium text-nowrap text-start">Solar Forecast (kW)</td>
<td
v-for="(value, index) in evopt.req.time_series.ft"
:key="index"
:class="['text-end', { 'text-muted': value === 0 }]"
>
{{ formatEnergyToPower(value, index) }}
</td>
</tr>
<tr>
<td class="fw-medium text-nowrap text-start">Household Demand (kW)</td>
<td
v-for="(value, index) in evopt.req.time_series.gt"
:key="index"
:class="['text-end', { 'text-muted': value === 0 }]"
>
{{ formatEnergyToPower(value, index) }}
</td>
</tr>
<tr>
<td class="fw-medium text-nowrap text-start">Time Step Duration (h)</td>
<td
v-for="(value, index) in evopt.req.time_series.dt"
:key="index"
:class="['text-end']"
>
{{ formatDuration(value) }}
</td>
</tr>
<tr>
<td class="fw-medium text-nowrap text-start">
{{ gridImportPriceLabel }}
</td>
<td
v-for="(value, index) in evopt.req.time_series.p_N"
:key="index"
:class="['text-end', { 'text-muted': value === 0 }]"
>
{{ fmtPricePerKWh(value * 1000, currency, false, false) }}
</td>
</tr>
<tr>
<td class="fw-medium text-nowrap text-start">
{{ gridFeedinPriceLabel }}
</td>
<td
v-for="(value, index) in evopt.req.time_series.p_E"
:key="index"
:class="['text-end', { 'text-muted': value === 0 }]"
>
{{ fmtPricePerKWh(value * 1000, currency, false, false) }}
</td>
</tr>
<!-- Response Data -->
<tr class="table-secondary">
<td :colspan="timeSlots.length + 1" class="fw-bold text-start">
Response Data
</td>
</tr>
<tr>
<td class="fw-medium text-nowrap text-start">Grid Export (kW)</td>
<td
v-for="(value, index) in evopt.res.grid_export"
:key="index"
:class="['text-end', { 'text-muted': value === 0 }]"
>
{{ formatEnergyToPower(value, index) }}
</td>
</tr>
<tr>
<td class="fw-medium text-nowrap text-start">Grid Import (kW)</td>
<td
v-for="(value, index) in evopt.res.grid_import"
:key="index"
:class="['text-end', { 'text-muted': value === 0 }]"
>
{{ formatEnergyToPower(value, index) }}
</td>
</tr>
<tr>
<td class="fw-medium text-nowrap text-start">⬇ Import / ⬆ Export</td>
<td
v-for="(value, index) in evopt.res.flow_direction"
:key="index"
:class="['text-end']"
>
<span :title="value === 1 ? 'Export to Grid' : 'Import from Grid'">
{{ value === 1 ? "⬆" : "⬇" }}
</span>
</td>
</tr>
<!-- Battery Response Data -->
<template
v-for="(battery, batteryIndex) in evopt.res.batteries"
:key="batteryIndex"
<div class="table-responsive">
<table class="table table-sm text-nowrap w-auto">
<thead>
<tr>
<th scope="col" class="sticky-col"></th>
<th
v-for="hour in hourGroups"
:key="hour.key"
scope="col"
:colspan="hour.span"
class="text-start hour-start"
>
<tr>
<td :colspan="timeSlots.length + 1" class="fw-bold text-start">
<div class="d-flex align-items-center">
<span
class="battery-indicator me-2"
:style="{ backgroundColor: batteryColors[batteryIndex] }"
></span>
{{ getBatteryTitle(batteryIndex) }}
</div>
</td>
</tr>
<tr>
<td class="fw-medium text-nowrap text-start">Charging Power (kW)</td>
<td
v-for="(value, index) in battery.charging_power"
:key="index"
:class="['text-end', { 'text-muted': value === 0 }]"
>
{{ formatEnergyToPower(value, index) }}
</td>
</tr>
<tr>
<td class="fw-medium text-nowrap text-start">Discharging Power (kW)</td>
<td
v-for="(value, index) in battery.discharging_power"
:key="index"
:class="['text-end', { 'text-muted': value === 0 }]"
>
{{ formatEnergyToPower(value, index) }}
</td>
</tr>
<tr>
<td class="fw-medium text-nowrap text-start">State of Charge (kWh)</td>
<td
v-for="(value, index) in battery.state_of_charge"
:key="index"
:class="['text-end', { 'text-muted': value === 0 }]"
>
{{ formatEnergy(value) }}
</td>
</tr>
<tr>
<td class="fw-medium text-nowrap text-start">State of Charge (%)</td>
<td
v-for="(value, index) in battery.state_of_charge"
:key="index"
:class="['text-end', { 'text-muted': value === 0 }]"
>
{{ formatSocPercentage(value, batteryIndex) }}
</td>
</tr>
</template>
</tbody>
</table>
</div>
{{ hour.label }}
</th>
</tr>
</thead>
<tbody>
<template v-for="(group, groupIndex) in groups" :key="groupIndex">
<tr v-if="group.title">
<th scope="row" class="sticky-col group-header">
<div class="d-flex align-items-center gap-2">
<span
v-if="group.color"
class="battery-indicator"
:style="{ backgroundColor: group.color }"
></span>
<span
v-else-if="group.dash"
class="dash"
:style="{ backgroundColor: group.dash }"
></span>
{{ group.title }}
</div>
</th>
<td :colspan="slotCount"></td>
</tr>
<tr v-for="row in group.rows" :key="row.label + (row.unit || '')">
<th scope="row" class="sticky-col fw-normal">
{{ row.label }}
<span v-if="row.unit" class="text-muted small">{{ row.unit }}</span>
</th>
<td
v-for="(value, index) in row.display"
:key="index"
class="text-end"
:class="{
zero: row.nums[index] === 0,
'hour-start': hourStarts[index],
}"
:style="heatStyle(row, index)"
>
{{ value }}
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import formatter from "@/mixins/formatter";
import colors from "@/colors";
import type { CURRENCY, BatteryDetail } from "@/types/evcc";
export interface EvoptData {
@ -193,11 +74,15 @@ export interface EvoptData {
gt: number[];
dt: number[];
};
batteries?: Array<{
c_min: number;
c_max: number;
d_max: number;
}>;
};
res: {
grid_export: number[];
grid_import: number[];
flow_direction: number[];
batteries: Array<{
charging_power: number[];
discharging_power: number[];
@ -206,6 +91,23 @@ export interface EvoptData {
};
}
interface Row {
label: string;
unit?: string;
display: string[];
nums: number[];
heatColor?: string;
aMax?: number;
ref?: number; // heat reference; defaults to the row's absolute maximum
}
interface RowGroup {
title?: string;
color?: string;
dash?: string;
rows: Row[];
}
export default defineComponent({
name: "TimeSeriesDataTable",
mixins: [formatter],
@ -214,6 +116,10 @@ export default defineComponent({
type: Object as PropType<EvoptData>,
required: true,
},
mode: {
type: String as PropType<"request" | "response">,
required: true,
},
batteryDetails: {
type: Array as PropType<BatteryDetail[]>,
required: true,
@ -230,79 +136,206 @@ export default defineComponent({
type: Array as PropType<string[]>,
default: () => [],
},
dimmedBatteryColors: {
type: Array as PropType<string[]>,
default: () => [],
},
},
computed: {
timeSlots() {
// Use the dt array length to determine number of time slots
return this.evopt?.req.time_series.dt || [];
slotCount(): number {
return this.evopt?.req.time_series.dt.length || 0;
},
gridFeedinPriceLabel() {
return `Grid Feedin (${this.pricePerKWhUnit(this.currency)})`;
hourStarts(): boolean[] {
return this.timestamps.map((ts, i) => i > 0 && new Date(ts).getMinutes() === 0);
},
gridImportPriceLabel() {
return `Grid Import (${this.pricePerKWhUnit(this.currency)})`;
// one th per hour, spanning its slots; midnight carries the weekday
hourGroups(): { key: string; label: string; span: number }[] {
const groups: { key: string; label: string; span: number }[] = [];
for (let i = 0; i < this.slotCount; i++) {
const d = new Date(this.timestamps[i] || 0);
const key = `${d.getDate()}-${d.getHours()}`;
const last = groups[groups.length - 1];
if (last?.key === key) {
last.span++;
} else {
const hour = d.getHours();
const label = hour === 0 ? `${this.weekdayShort(d)} 0` : String(hour);
groups.push({ key, label, span: 1 });
}
}
return groups;
},
groups(): RowGroup[] {
return this.mode === "request" ? this.requestGroups() : this.responseGroups();
},
},
methods: {
formatEnergyToPower(wh: number, index: number): string {
// Convert Wh to kW by normalizing against time duration
const dtSeconds = this.evopt.req.time_series.dt[index] || 0;
const hours = dtSeconds / 3600; // Convert seconds to hours
const watts = wh / hours; // Convert to W (power)
return this.fmtW(watts, this.POWER_UNIT.KW, false, 1);
requestGroups(): RowGroup[] {
const ts = this.evopt.req.time_series;
const priceUnit = this.pricePerKWhUnit(this.currency);
return [
{
rows: [
this.powerRow("Solar Forecast", ts.ft, colors.self || ""),
this.powerRow("Household Demand", ts.gt, colors.muted || ""),
{
label: "Time Step",
unit: "h",
display: ts.dt.map((s) => (s / 3600).toFixed(2)),
nums: ts.dt,
},
this.priceRow("Grid Import", priceUnit, ts.p_N, colors.price || ""),
this.priceRow("Grid Export", priceUnit, ts.p_E, colors.export || ""),
],
},
];
},
formatEnergy(wh: number): string {
return this.fmtWh(wh, this.POWER_UNIT.KW, false, 1);
responseGroups(): RowGroup[] {
const res = this.evopt.res;
const groups: RowGroup[] = [
{
title: "Grid",
dash: colors.grid || "",
rows: [
this.powerRow("Grid Export", res.grid_export, colors.export || ""),
this.powerRow("Grid Import", res.grid_import, colors.muted || ""),
],
},
];
res.batteries.forEach((battery, i) => {
const color = this.batteryColors[i] || "";
const config = this.evopt.req.batteries?.[i];
const capacity = this.batteryDetails[i]?.capacity || 0;
groups.push({
title: this.getBatteryTitle(i),
color,
rows: [
this.powerRow("Charging Power", battery.charging_power, color, {
ref: (config?.c_max || 0) / 1000,
}),
this.powerRow("Discharging Power", battery.discharging_power, color, {
ref: (config?.d_max || 0) / 1000,
}),
{
label: "State of Charge",
unit: "kWh",
display: battery.state_of_charge.map((wh) =>
this.fmtWh(wh, this.POWER_UNIT.KW, false, 1)
),
nums: battery.state_of_charge,
heatColor: color,
ref: capacity * 1000,
},
{
label: "State of Charge",
unit: "%",
display: battery.state_of_charge.map((wh) =>
capacity > 0 ? this.fmtNumber((wh / 1000 / capacity) * 100, 0) : "-"
),
nums: battery.state_of_charge.map((wh) =>
capacity > 0 ? (wh / 1000 / capacity) * 100 : 0
),
heatColor: color,
aMax: 0.3,
ref: 100,
},
],
});
});
return groups;
},
formatDuration: (seconds: number): string => {
return (seconds / 3600).toFixed(2);
powerRow(label: string, whValues: number[], color: string, opts: Partial<Row> = {}): Row {
const dt = this.evopt.req.time_series.dt;
const nums = whValues.map((wh, i) => wh / ((dt[i] || 1) / 3600) / 1000);
return {
label,
unit: "kW",
display: nums.map((kw) => this.fmtNumber(kw, 1)),
nums,
heatColor: color,
...opts,
};
},
formatHour(index: number): string {
if (this.timestamps.length <= index) {
return "";
}
// Show label on fresh hour or on first quarter hour for slot 0
const ts = new Date(this.timestamps[index]);
const minutes = ts.getMinutes();
if (minutes === 0 || (index === 0 && minutes < 15)) {
return ts.getHours().toString();
}
return "";
priceRow(label: string, unit: string, prices: number[], color: string): Row {
return {
label,
unit,
display: prices.map((p) =>
this.fmtPricePerKWh(p * 1000, this.currency, false, false)
),
nums: prices,
heatColor: color,
aMax: 0.4,
};
},
getBatteryTitle(index: number): string {
const detail = this.batteryDetails[index];
return detail ? detail.title || detail.name : `Battery ${index + 1}`;
},
formatSocPercentage(socWh: number, batteryIndex: number): string {
const detail = this.batteryDetails[batteryIndex];
if (detail?.capacity && detail.capacity > 0) {
const percentage = (socWh / 1000 / detail.capacity) * 100;
return this.fmtNumber(percentage, 0);
}
return "-";
heatStyle(row: Row, index: number): Record<string, string> | undefined {
if (!row.heatColor) return undefined;
const ref = row.ref ?? Math.max(...row.nums.map(Math.abs));
if (!ref) return undefined;
const ratio = Math.min(1, Math.abs(row.nums[index] ?? 0) / ref);
const alpha = 0.06 + (row.aMax ?? 0.5) * ratio;
const hex = Math.round(alpha * 255)
.toString(16)
.padStart(2, "0");
return { backgroundColor: row.heatColor.trim() + hex };
},
},
});
</script>
<style scoped>
.table {
font-size: 0.8125rem;
}
.table .small {
font-size: 0.75rem;
}
.table td,
.table th {
font-variant-numeric: tabular-nums;
padding: 0.25rem 0.5rem;
border-color: rgba(147, 148, 158, 0.1);
}
.table td:not(:first-child) {
padding-left: 1rem;
.table thead th {
color: var(--bs-gray-medium);
font-weight: normal;
border-left: 1px solid rgba(147, 148, 158, 0.25);
}
.sticky-col {
position: sticky;
left: 0;
z-index: 1;
background: var(--evcc-box);
border-left: none;
border-right: 1px solid rgba(147, 148, 158, 0.25);
padding-right: 1rem;
}
.table thead .sticky-col {
border: none;
}
.table thead th:nth-child(2) {
border-left: none;
}
.group-header {
font-weight: bold;
padding-top: 0.75rem;
}
.hour-start {
border-left: 1px solid rgba(147, 148, 158, 0.25);
}
.zero {
color: var(--bs-gray-medium);
}
.battery-indicator {
width: 1rem;
height: 1rem;
border-radius: 50%;
flex-shrink: 0;
}
.dash {
width: 1rem;
height: 2px;
border-radius: 1px;
flex-shrink: 0;
}
</style>

View file

@ -0,0 +1,54 @@
import { xAxisLabelStyle } from "../Forecast/echarts";
import type { BatteryDetail } from "@/types/evcc";
// loadpoint part of a vehicle entry title: "Carport (blue e-Golf)" → "Carport"
export function loadpointTitle(detail: BatteryDetail): string {
const title = detail.title || detail.name || "";
return title.replace(/\s*\([^)]*\)$/, "");
}
// slot start times (ms) from the first timestamp plus cumulative dt seconds
export function slotTimes(timestamp: string, dt: number[]): number[] {
const start = new Date(timestamp).getTime();
const res: number[] = [];
let acc = 0;
for (const s of dt) {
res.push(start + acc * 1000);
acc += s;
}
return res;
}
// category x axis over slot times, labels at full hours every 4h (6h on mobile)
export function slotXAxis(times: number[]) {
const step = window.innerWidth < 576 ? 6 : 4;
return {
type: "category",
data: times.map(String),
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: {
...xAxisLabelStyle(),
interval: 0,
formatter: (value: string) => {
const d = new Date(Number(value));
if (d.getMinutes() !== 0 || d.getHours() % step !== 0) return "";
return String(d.getHours());
},
},
};
}
export function formatSlotRange(times: number[], dt: number[], index: number): string {
const start = new Date(times[index] ?? 0);
const end = new Date(start.getTime() + (dt[index] || 0) * 1000);
const f = (d: Date) =>
`${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
return `${f(start)} - ${f(end)}`;
}
// slot energy (Wh) to average power (kW)
export function whToKW(wh: number, dtSeconds: number): number {
return wh / (dtSeconds / 3600) / 1000;
}

View file

@ -1,92 +0,0 @@
import {
Chart,
Tooltip,
type ChartComponentLike,
type ChartType,
type Plugin,
type Point,
type TooltipItem,
} from "chart.js";
import colors from "@/colors";
import { attachTouchTooltipGate } from "@/utils/swipe";
import type { Context } from "chartjs-plugin-datalabels";
// Register common components
export function registerChartComponents(components: ChartComponentLike[]) {
Chart.register(...components);
}
// Set default configurations immediately
Chart.defaults.font.family = window
.getComputedStyle(document.documentElement)
.getPropertyValue("--bs-font-sans-serif");
Chart.defaults.font.size = 14;
Chart.defaults.layout.padding = 0;
// touch tooltips: show on dwell, follow the finger, hide when it lifts; mouse hover unchanged
const touchDismissPlugin: Plugin = {
id: "touchDismiss",
afterInit(chart) {
attachTouchTooltipGate(chart.canvas.parentElement ?? chart.canvas, () => {
if (!chart.ctx) return; // destroyed
chart.setActiveElements([]);
chart.tooltip?.setActiveElements([], { x: 0, y: 0 });
chart.update();
});
},
};
Chart.register(touchDismissPlugin);
// Custom tooltip positioners
(Tooltip.positioners as any)["center"] = function () {
const { chart } = this;
return {
x: chart.width / 2,
y: chart.height / 2,
xAlign: "center",
yAlign: "center",
};
};
(Tooltip.positioners as any)["topBottomCenter"] = function (
_context: Context,
eventPosition: Point
) {
const { chart } = this;
const { height, width } = chart;
const isTop = (eventPosition.y ?? 0) > height / 2;
const yPadding = height / 5;
const y = isTop ? yPadding : height - yPadding;
const x = width / 2;
return { x, y, xAlign: "center", yAlign: "center" };
};
export const commonOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
datalabels: { display: false },
tooltip: {
backgroundColor: "#000000cc",
boxPadding: 5,
usePointStyle: false,
borderWidth: 0.00001,
mode: "index",
intersect: false,
},
},
};
export function tooltipLabelColor(useBorder = false) {
return function (item: TooltipItem<ChartType>) {
const { backgroundColor, borderColor } = item.element.options;
const color = useBorder ? borderColor : backgroundColor;
return {
borderColor: !item.raw ? colors.muted : "#fff",
borderWidth: 1,
backgroundColor: color,
};
};
}

View file

@ -18,37 +18,64 @@
<div class="row">
<main class="col-12">
<div v-if="evopt">
<Card title="Result: Charging Plan" edge-to-edge class="box-pull-out mb-4">
<h2 class="mt-2 mb-4">Optimizer Plan</h2>
<Card
title="Charging Plan"
subtitle="kW"
edge-to-edge
class="box-pull-out mb-4"
>
<ChargeChart
:evopt="evopt"
:battery-details="evopt.details.batteryDetails"
:timestamp="evopt.details.timestamp[0]"
:currency="currency"
:battery-colors="batteryColors"
:device-colors="deviceColors"
/>
</Card>
<Card title="Result: SoC Projection" edge-to-edge class="box-pull-out mb-4">
<SocChart
<Card
v-if="socEntries.length"
title="SoC Projections"
subtitle="%"
edge-to-edge
class="box-pull-out mb-4"
>
<div
v-for="(entry, idx) in socEntries"
:key="entry.index"
:class="{ 'mb-3': idx < socEntries.length - 1 }"
>
<SocChart
:evopt="evopt"
:entry="entry"
:timestamp="evopt.details.timestamp[0]"
:show-x-axis="idx === socEntries.length - 1"
/>
</div>
</Card>
<Card
title="Timeline"
:subtitle="timeSeriesSubtitle"
edge-to-edge
class="box-pull-out mb-4"
>
<TimeSeriesDataTable
:evopt="evopt"
mode="response"
:battery-details="evopt.details.batteryDetails"
:timestamp="evopt.details.timestamp[0]"
:timestamps="evopt.details.timestamp"
:currency="currency"
:battery-colors="batteryColors"
/>
</Card>
<Card title="Input: Grid Prices" edge-to-edge class="box-pull-out mb-4">
<PriceChart
:evopt="evopt"
:timestamp="evopt.details.timestamp[0]"
:currency="currency"
/>
</Card>
<h2 class="section-title mb-4">Optimizer Inputs</h2>
<Card
title="Input: Battery"
title="Batteries"
:subtitle="batteryEfficiencySubtitle"
edge-to-edge
class="box-pull-out mb-4"
@ -56,42 +83,46 @@
<BatteryConfigurationTable
:batteries="evopt.req.batteries"
:battery-details="evopt.details.batteryDetails"
:battery-colors="batteryColors"
:currency="currency"
/>
</Card>
<Card title="Time Series" edge-to-edge class="box-pull-out mb-4">
<Card
title="Environment"
:subtitle="timeSeriesSubtitle"
edge-to-edge
class="box-pull-out mb-4"
>
<TimeSeriesDataTable
:evopt="evopt"
mode="request"
:battery-details="evopt.details.batteryDetails"
:timestamps="evopt.details.timestamp"
:currency="currency"
:battery-colors="batteryColors"
:dimmed-battery-colors="dimmedBatteryColors"
/>
</Card>
<Card title="Raw Data" edge-to-edge class="box-pull-out mb-4">
<div class="mb-4">
<p class="mb-2">Request:</p>
<div class="position-relative">
<pre
class="p-3 overflow-auto"
style="background-color: var(--evcc-gray-10)"
>{{ formattedRequest }}</pre>
<CopyButton :content="formattedRequest" />
</div>
</div>
<h2 class="section-title mb-4">Raw Data</h2>
<div>
<p class="mb-2">Response:</p>
<div class="position-relative">
<pre
class="p-3 overflow-auto"
style="background-color: var(--evcc-gray-10)"
>{{ formattedResponse }}</pre>
<CopyButton :content="formattedResponse" />
</div>
<Card title="Request" edge-to-edge class="box-pull-out mb-4">
<div class="position-relative">
<pre
class="p-3 overflow-auto"
style="background-color: var(--evcc-gray-10)"
>{{ formattedRequest }}</pre>
<CopyButton :content="formattedRequest" />
</div>
</Card>
<Card title="Response" edge-to-edge class="box-pull-out mb-4">
<div class="position-relative">
<pre
class="p-3 overflow-auto"
style="background-color: var(--evcc-gray-10)"
>{{ formattedResponse }}</pre>
<CopyButton :content="formattedResponse" />
</div>
</Card>
</div>
@ -109,17 +140,17 @@ import Header from "../components/Top/Header.vue";
import Card from "../components/Helper/Card.vue";
import OptimizeHeader from "../components/Optimize/OptimizeHeader.vue";
import BatteryConfigurationTable from "../components/Optimize/BatteryConfigurationTable.vue";
import SocChart from "../components/Optimize/SocChart.vue";
import SocChart, { type SocChartEntry } from "../components/Optimize/SocChart.vue";
import ChargeChart from "../components/Optimize/ChargeChart.vue";
import PriceChart from "../components/Optimize/PriceChart.vue";
import TimeSeriesDataTable from "../components/Optimize/TimeSeriesDataTable.vue";
import CopyButton from "../components/Optimize/CopyButton.vue";
import { formatCompactJson } from "../components/Optimize/compactJson";
import { loadpointTitle } from "../components/Optimize/chart";
import api from "../api";
import store from "../store";
import formatter from "../mixins/formatter";
import { resolveColors, deviceColorMap } from "../colors";
import { CURRENCY } from "../types/evcc";
import { resolveColors, deviceColorMap, batteryColor } from "../colors";
import { CURRENCY, type BatteryDetail } from "../types/evcc";
export default defineComponent({
name: "Optimize",
@ -130,7 +161,6 @@ export default defineComponent({
BatteryConfigurationTable,
SocChart,
ChargeChart,
PriceChart,
TimeSeriesDataTable,
CopyButton,
},
@ -172,19 +202,47 @@ export default defineComponent({
deviceColors() {
return deviceColorMap(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}`
);
batteryDetails(): BatteryDetail[] {
return this.evopt?.details?.batteryDetails || [];
},
batteryColors() {
if (!this.evopt?.res.batteries) return [];
const palette = resolveColors(this.batteryTitles, this.deviceColors);
return this.batteryTitles.map((t) => palette[t] || "");
loadpointColorKeys(): string[] {
return [
...new Set(
this.batteryDetails.filter((d) => d.type === "vehicle").map(loadpointTitle)
),
];
},
dimmedBatteryColors() {
return (this.batteryColors || []).map((color) => this.dimColorBy25Percent(color));
// loadpoints share the picker palette with History
loadpointPalette() {
return resolveColors(this.loadpointColorKeys, this.deviceColors);
},
// per-entry colors aligned with res.batteries: dedicated battery palette
// for home batteries (same as battery page and history), picker palette
// for loadpoints
batteryColors(): string[] {
let batteryIndex = 0;
return this.batteryDetails.map((d) => {
if (d.type === "battery") return batteryColor(batteryIndex++);
const key = loadpointTitle(d);
return this.loadpointPalette[key] || "";
});
},
// loadpoints first, then batteries, matching the charging plan order
socEntries(): SocChartEntry[] {
const entries = this.batteryDetails.map((d, i) => ({
index: i,
type: d.type,
title: d.title || d.name,
capacity: d.capacity,
color: this.batteryColors[i] || "",
}));
return [
...entries.filter((e) => e.type === "vehicle"),
...entries.filter((e) => e.type === "battery"),
];
},
timeSeriesSubtitle(): string {
return `${this.evopt?.req.time_series.dt.length || 0} steps ・ ${this.horizonHours} h horizon`;
},
formattedRequest() {
return this.evopt?.req ? formatCompactJson(this.evopt.req) : "";
@ -207,10 +265,12 @@ export default defineComponent({
changeChargingStrategy(value: string) {
api.post(`optimizerchargingstrategy/${value}`);
},
dimColorBy25Percent(color: string): string {
// Convert color to 25% opacity (40 in hex = 25% of 255)
return color?.toLowerCase().replace(/ff$/, "40") || color;
},
},
});
</script>
<style scoped>
.section-title {
margin-top: 4rem;
}
</style>