425 lines
11 KiB
Vue
425 lines
11 KiB
Vue
<template>
|
|
<div class="mb-5">
|
|
<div class="chart-container my-3">
|
|
<Chart
|
|
ref="chartRef"
|
|
type="bar"
|
|
:data="chartData"
|
|
:options="chartOptions"
|
|
:height="300"
|
|
/>
|
|
</div>
|
|
<LegendList :legends="legends" :device-colors="deviceColors" />
|
|
</div>
|
|
</template>
|
|
|
|
<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";
|
|
import type { EvoptData } from "./TimeSeriesDataTable.vue";
|
|
import type { CURRENCY, BatteryDetail, DeviceColors } from "@/types/evcc";
|
|
import formatter from "@/mixins/formatter";
|
|
import colors from "@/colors";
|
|
import LegendList from "../Sessions/LegendList.vue";
|
|
import type { Legend } from "../Sessions/types";
|
|
|
|
ChartJS.register(
|
|
CategoryScale,
|
|
LinearScale,
|
|
BarController,
|
|
LineController,
|
|
BarElement,
|
|
LineElement,
|
|
PointElement,
|
|
Title,
|
|
Tooltip,
|
|
ChartLegendPlugin
|
|
);
|
|
|
|
export default defineComponent({
|
|
name: "ChargeChart",
|
|
components: {
|
|
Chart,
|
|
LegendList,
|
|
},
|
|
mixins: [formatter],
|
|
props: {
|
|
evopt: {
|
|
type: Object as PropType<EvoptData>,
|
|
required: true,
|
|
},
|
|
batteryDetails: {
|
|
type: Array as PropType<BatteryDetail[]>,
|
|
required: true,
|
|
},
|
|
timestamp: {
|
|
type: String,
|
|
default: "",
|
|
},
|
|
currency: {
|
|
type: String as PropType<CURRENCY>,
|
|
required: true,
|
|
},
|
|
batteryColors: {
|
|
type: Array as PropType<string[]>,
|
|
default: () => [],
|
|
},
|
|
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 "";
|
|
});
|
|
},
|
|
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,
|
|
};
|
|
},
|
|
chartOptions(): ChartOptions {
|
|
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
|
|
},
|
|
},
|
|
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`;
|
|
},
|
|
},
|
|
},
|
|
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
|
|
},
|
|
},
|
|
};
|
|
},
|
|
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,
|
|
};
|
|
});
|
|
},
|
|
},
|
|
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",
|
|
},
|
|
];
|
|
},
|
|
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 => {
|
|
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)}`;
|
|
},
|
|
},
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.chart-container {
|
|
position: relative;
|
|
height: 300px;
|
|
width: 100%;
|
|
}
|
|
</style>
|