evcc-io/assets/js/components/Optimize/ChargeChart.vue

226 lines
6.5 KiB
Vue

<template>
<div>
<div ref="chartEl" class="charge-chart my-3"></div>
<LegendList :legends="legends" :device-colors="deviceColors" />
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import {
FONT_FAMILY,
forecastYAxis,
tooltipStyle,
tooltipTable,
type TooltipRow,
} from "../Forecast/echarts";
import type { EvoptData } from "./TimeSeriesDataTable.vue";
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 { slotTimes, slotXAxis, formatSlotRange, whToKW, loadpointTitle } from "./chart";
const GRID_LABEL = "Grid Power";
const SOLAR_LABEL = "Solar Forecast";
export default defineComponent({
name: "ChargeChart",
components: { LegendList },
mixins: [formatter, echartsChart],
props: {
evopt: {
type: Object as PropType<EvoptData>,
required: true,
},
batteryDetails: {
type: Array as PropType<BatteryDetail[]>,
required: true,
},
timestamp: {
type: String,
default: "",
},
batteryColors: {
type: Array as PropType<string[]>,
default: () => [],
},
deviceColors: { type: Object as PropType<DeviceColors>, default: () => ({}) },
},
computed: {
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;
});
},
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;
},
chartOption(): Record<string, unknown> {
return {
animation: false,
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,
},
xAxis: slotXAxis(this.times, this.weekdayShort),
yAxis: forecastYAxis({
min: undefined,
position: "right",
splitNumber: 5,
axisLabel: {
color: colors.muted || "",
formatter: (v: number) => this.fmtNumber(v, 0),
},
}),
series: this.chartSeries,
};
},
legends(): Legend[] {
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: {
toKW(wh: number, index: number): number {
return whToKW(wh, this.evopt.req.time_series.dt[index] || 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}`;
},
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>
.charge-chart {
height: 300px;
width: 100%;
}
</style>