Optimizer UI: migrate charts to echarts, remove chart.js (#32837)
This commit is contained in:
parent
0f265a04f3
commit
9eea54d8e4
11 changed files with 770 additions and 1350 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
54
assets/js/components/Optimize/chart.ts
Normal file
54
assets/js/components/Optimize/chart.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue