Add initial forecast UI (#18709)
This commit is contained in:
parent
9606299c92
commit
629a969c9f
24 changed files with 1275 additions and 18 deletions
|
|
@ -47,6 +47,8 @@
|
|||
--evcc-pv: var(--evcc-dark-green);
|
||||
--evcc-battery: var(--evcc-darker-green);
|
||||
--evcc-export: var(--evcc-yellow);
|
||||
--evcc-price: #ff912fff;
|
||||
--evcc-co2: #00916eff;
|
||||
--evcc-background: var(--bs-gray-bright);
|
||||
--evcc-box: var(--bs-white);
|
||||
--evcc-box-border: var(--bs-gray-brighter);
|
||||
|
|
@ -165,6 +167,14 @@ h5 {
|
|||
color: var(--bs-primary) !important;
|
||||
}
|
||||
|
||||
.text-price {
|
||||
color: var(--evcc-price) !important;
|
||||
}
|
||||
|
||||
.text-co2 {
|
||||
color: var(--evcc-co2) !important;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,9 +15,10 @@ const colors = reactive({
|
|||
grid: null,
|
||||
co2PerKWh: null,
|
||||
pricePerKWh: null,
|
||||
price: "#FF912FFF",
|
||||
co2: "#00916EFF",
|
||||
price: null,
|
||||
co2: null,
|
||||
background: null,
|
||||
light: null,
|
||||
selfPalette: ["#0FDE41FF", "#FFBD2FFF", "#FD6158FF", "#03C1EFFF", "#0F662DFF", "#FF922EFF"],
|
||||
palette: [
|
||||
"#03C1EFFF",
|
||||
|
|
@ -35,6 +36,18 @@ const colors = reactive({
|
|||
],
|
||||
});
|
||||
|
||||
export const dimColor = (color) => {
|
||||
return color.toLowerCase().replace(/ff$/, "20");
|
||||
};
|
||||
|
||||
export const lighterColor = (color) => {
|
||||
return color.toLowerCase().replace(/ff$/, "aa");
|
||||
};
|
||||
|
||||
export const fullColor = (color) => {
|
||||
return color.toLowerCase().replace(/20$/, "ff");
|
||||
};
|
||||
|
||||
function updateCssColors() {
|
||||
const style = window.getComputedStyle(document.documentElement);
|
||||
colors.text = style.getPropertyValue("--evcc-default-text");
|
||||
|
|
@ -42,22 +55,19 @@ function updateCssColors() {
|
|||
colors.border = style.getPropertyValue("--bs-border-color-translucent");
|
||||
colors.self = style.getPropertyValue("--evcc-self");
|
||||
colors.grid = style.getPropertyValue("--evcc-grid");
|
||||
colors.price = style.getPropertyValue("--evcc-price");
|
||||
colors.co2 = style.getPropertyValue("--evcc-co2");
|
||||
colors.background = style.getPropertyValue("--evcc-background");
|
||||
colors.pricePerKWh = style.getPropertyValue("--bs-gray-medium");
|
||||
colors.co2PerKWh = style.getPropertyValue("--bs-gray-medium");
|
||||
colors.light = style.getPropertyValue("--bs-gray-light");
|
||||
}
|
||||
|
||||
// update colors on theme change
|
||||
const darkModeMatcher = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)");
|
||||
darkModeMatcher?.addEventListener("change", updateCssColors);
|
||||
// initialize colors
|
||||
updateCssColors();
|
||||
|
||||
export const dimColor = (color) => {
|
||||
return color.toLowerCase().replace(/ff$/, "20");
|
||||
};
|
||||
|
||||
export const fullColor = (color) => {
|
||||
return color.toLowerCase().replace(/20$/, "ff");
|
||||
};
|
||||
window.requestAnimationFrame(updateCssColors);
|
||||
|
||||
export default colors;
|
||||
|
|
|
|||
|
|
@ -83,8 +83,15 @@
|
|||
icon="sun"
|
||||
:power="pvProduction"
|
||||
:powerTooltip="pvTooltip"
|
||||
:details="solarForecast"
|
||||
:detailsFmt="forecastFmt"
|
||||
:detailsTooltip="forecastTooltip(solarForecast)"
|
||||
:detailsInactive="solarForecast === 0"
|
||||
:detailsIcon="solarForecast !== undefined ? 'forecast' : undefined"
|
||||
:detailsClickable="solarForecast !== undefined"
|
||||
:powerUnit="powerUnit"
|
||||
data-testid="energyflow-entry-production"
|
||||
@details-clicked="openForecastModal"
|
||||
/>
|
||||
<EnergyflowEntry
|
||||
v-if="batteryConfigured"
|
||||
|
|
@ -229,7 +236,7 @@ import AnimatedNumber from "../AnimatedNumber.vue";
|
|||
import settings from "../../settings";
|
||||
import { CO2_TYPE } from "../../units";
|
||||
import collector from "../../mixins/collector";
|
||||
|
||||
import { energyByDay } from "../../utils/forecast";
|
||||
export default {
|
||||
name: "Energyflow",
|
||||
components: {
|
||||
|
|
@ -266,6 +273,7 @@ export default {
|
|||
prioritySoc: { type: Number },
|
||||
bufferSoc: { type: Number },
|
||||
bufferStartSoc: { type: Number },
|
||||
forecast: { type: Object, default: () => ({}) },
|
||||
},
|
||||
data: () => {
|
||||
return { detailsOpen: false, detailsCompleteHeight: null, ready: false };
|
||||
|
|
@ -381,6 +389,11 @@ export default {
|
|||
}
|
||||
return this.fmtPricePerKWh(this.batteryGridChargeLimit, this.currency, true);
|
||||
},
|
||||
solarForecast() {
|
||||
const slots = this.forecast.solar || [];
|
||||
if (slots.length === 0) return undefined;
|
||||
return energyByDay(slots, 0);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
pvConfigured() {
|
||||
|
|
@ -418,6 +431,12 @@ export default {
|
|||
}
|
||||
return result;
|
||||
},
|
||||
forecastTooltip(value) {
|
||||
if (value !== null) {
|
||||
return [this.$t("main.energyflow.forecastTooltip")];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
detailsValue(price, co2) {
|
||||
if (this.co2Available) {
|
||||
return co2;
|
||||
|
|
@ -430,6 +449,12 @@ export default {
|
|||
}
|
||||
return this.fmtPricePerKWh(value, this.currency, true);
|
||||
},
|
||||
forecastFmt(value) {
|
||||
if (value === null) {
|
||||
return "";
|
||||
}
|
||||
return `${this.fmtWh(value, POWER_UNIT.KW)}`;
|
||||
},
|
||||
kw: function (watt) {
|
||||
return this.fmtW(watt, this.powerUnit);
|
||||
},
|
||||
|
|
@ -447,6 +472,10 @@ export default {
|
|||
);
|
||||
modal.show();
|
||||
},
|
||||
openForecastModal() {
|
||||
const modal = Modal.getOrCreateInstance(document.getElementById("forecastModal"));
|
||||
modal.show();
|
||||
},
|
||||
dischargePower(power) {
|
||||
return Math.abs(Math.max(0, power));
|
||||
},
|
||||
|
|
|
|||
|
|
@ -13,12 +13,19 @@
|
|||
<div
|
||||
ref="details"
|
||||
class="fw-normal"
|
||||
:class="{ 'text-decoration-underline': detailsClickable }"
|
||||
:class="{
|
||||
'text-decoration-underline': detailsClickable,
|
||||
'evcc-gray': detailsInactive,
|
||||
}"
|
||||
data-testid="energyflow-entry-details"
|
||||
data-bs-toggle="tooltip"
|
||||
:tabindex="detailsClickable ? 0 : undefined"
|
||||
@click="detailsClicked"
|
||||
>
|
||||
<ForecastIcon
|
||||
v-if="detailsIcon === 'forecast'"
|
||||
class="ms-2 me-1 d-inline-block"
|
||||
/>
|
||||
<AnimatedNumber v-if="!isNaN(details)" :to="details" :format="detailsFmt" />
|
||||
</div>
|
||||
<div ref="power" class="power" data-bs-toggle="tooltip" @click="powerClicked">
|
||||
|
|
@ -41,10 +48,11 @@ import BatteryIcon from "./BatteryIcon.vue";
|
|||
import formatter from "../../mixins/formatter";
|
||||
import AnimatedNumber from "../AnimatedNumber.vue";
|
||||
import VehicleIcon from "../VehicleIcon";
|
||||
import ForecastIcon from "../MaterialIcon/Forecast.vue";
|
||||
|
||||
export default {
|
||||
name: "EnergyflowEntry",
|
||||
components: { BatteryIcon, AnimatedNumber, VehicleIcon },
|
||||
components: { BatteryIcon, AnimatedNumber, VehicleIcon, ForecastIcon },
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
name: { type: String },
|
||||
|
|
@ -54,9 +62,11 @@ export default {
|
|||
powerTooltip: { type: Array },
|
||||
powerUnit: { type: String },
|
||||
details: { type: Number },
|
||||
detailsIcon: { type: String },
|
||||
detailsFmt: { type: Function },
|
||||
detailsTooltip: { type: Array },
|
||||
detailsClickable: { type: Boolean },
|
||||
detailsInactive: { type: Boolean },
|
||||
},
|
||||
emits: ["details-clicked"],
|
||||
data() {
|
||||
|
|
@ -107,9 +117,6 @@ export default {
|
|||
);
|
||||
},
|
||||
updateDetailsTooltip() {
|
||||
if (this.detailsClickable) {
|
||||
return;
|
||||
}
|
||||
this.detailsTooltipInstance = this.updateTooltip(
|
||||
this.detailsTooltipInstance,
|
||||
this.detailsTooltip,
|
||||
|
|
@ -143,6 +150,8 @@ export default {
|
|||
if (this.detailsClickable) {
|
||||
this.$emit("details-clicked");
|
||||
}
|
||||
// hide tooltip, chrome needs a timeout
|
||||
setTimeout(() => this.detailsTooltipInstance?.hide(), 10);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
35
assets/js/components/ForecastActiveSlot.vue
Normal file
35
assets/js/components/ForecastActiveSlot.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<div v-if="activeSlot" class="text-end">
|
||||
<span class="text-nowrap">{{ day }} {{ start }}</span
|
||||
>{{ " " }}<span class="text-nowrap">– {{ end }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import type { PropType } from "vue";
|
||||
import { type PriceSlot } from "../utils/forecast";
|
||||
import formatter from "../mixins/formatter";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ForecastActiveSlot",
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
activeSlot: { type: Object as PropType<PriceSlot | null> },
|
||||
},
|
||||
computed: {
|
||||
day() {
|
||||
const startDate = new Date(this.activeSlot!.start);
|
||||
return this.weekdayShort(startDate);
|
||||
},
|
||||
start() {
|
||||
const startDate = new Date(this.activeSlot!.start);
|
||||
return this.hourShort(startDate);
|
||||
},
|
||||
end() {
|
||||
const endDate = new Date(this.activeSlot!.end);
|
||||
return this.hourShort(endDate);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
417
assets/js/components/ForecastChart.vue
Normal file
417
assets/js/components/ForecastChart.vue
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="overflow-x-auto overflow-x-md-hidden chart-container border-1"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<div style="position: relative; height: 220px" class="chart user-select-none">
|
||||
<Bar ref="chart" :data="chartData" :options="options" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import { Bar } from "vue-chartjs";
|
||||
import {
|
||||
BarController,
|
||||
BarElement,
|
||||
LineController,
|
||||
LineElement,
|
||||
LinearScale,
|
||||
TimeSeriesScale,
|
||||
Legend,
|
||||
Tooltip,
|
||||
PointElement,
|
||||
Filler,
|
||||
} from "chart.js";
|
||||
import ChartDataLabels from "chartjs-plugin-datalabels";
|
||||
import "chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm";
|
||||
import { registerChartComponents, commonOptions } from "./Sessions/chartConfig";
|
||||
import formatter, { POWER_UNIT } from "../mixins/formatter";
|
||||
import colors, { lighterColor } from "../colors";
|
||||
import {
|
||||
energyByDay,
|
||||
highestSlotIndexByDay,
|
||||
type PriceSlot,
|
||||
ForecastType,
|
||||
} from "../utils/forecast";
|
||||
|
||||
registerChartComponents([
|
||||
BarController,
|
||||
BarElement,
|
||||
LineController,
|
||||
LineElement,
|
||||
Filler,
|
||||
LinearScale,
|
||||
TimeSeriesScale,
|
||||
Legend,
|
||||
Tooltip,
|
||||
PointElement,
|
||||
ChartDataLabels,
|
||||
]);
|
||||
|
||||
export default defineComponent({
|
||||
name: "ForecastChart",
|
||||
components: { Bar },
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
grid: { type: Array as PropType<PriceSlot[]> },
|
||||
solar: { type: Array as PropType<PriceSlot[]> },
|
||||
co2: { type: Array as PropType<PriceSlot[]> },
|
||||
currency: { type: String as PropType<string> },
|
||||
selected: { type: String as PropType<ForecastType> },
|
||||
},
|
||||
emits: ["selected"],
|
||||
data(): {
|
||||
selectedIndex: number | null;
|
||||
ignoreEvents: boolean;
|
||||
ignoreEventsTimeout: ReturnType<typeof setTimeout> | null;
|
||||
} {
|
||||
return {
|
||||
selectedIndex: null,
|
||||
ignoreEvents: false,
|
||||
ignoreEventsTimeout: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
startDate() {
|
||||
const now = new Date();
|
||||
const slots = this.grid || this.co2 || this.solar || [];
|
||||
const currentSlot = slots.find(({ start, end }) => {
|
||||
return new Date(start) <= now && new Date(end) > now;
|
||||
});
|
||||
if (currentSlot) {
|
||||
return new Date(currentSlot.start);
|
||||
}
|
||||
return now;
|
||||
},
|
||||
solarSlots() {
|
||||
return this.filterSlots(this.solar);
|
||||
},
|
||||
gridSlots() {
|
||||
return this.filterSlots(this.grid);
|
||||
},
|
||||
co2Slots() {
|
||||
return this.filterSlots(this.co2);
|
||||
},
|
||||
currentSlots() {
|
||||
switch (this.selected) {
|
||||
case ForecastType.Price:
|
||||
return this.gridSlots;
|
||||
case ForecastType.Solar:
|
||||
return this.solarSlots;
|
||||
case ForecastType.Co2:
|
||||
return this.co2Slots;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
},
|
||||
selectedSlot() {
|
||||
return this.selectedIndex !== null ? this.currentSlots[this.selectedIndex] : null;
|
||||
},
|
||||
maxPriceIndex() {
|
||||
return this.maxIndex(this.gridSlots);
|
||||
},
|
||||
minPriceIndex() {
|
||||
return this.minIndex(this.gridSlots);
|
||||
},
|
||||
maxCo2Index() {
|
||||
return this.maxIndex(this.co2Slots);
|
||||
},
|
||||
minCo2Index() {
|
||||
return this.minIndex(this.co2Slots);
|
||||
},
|
||||
maxSolarIndex() {
|
||||
return this.maxIndex(this.solarSlots);
|
||||
},
|
||||
solarHighlights() {
|
||||
return [0, 1, 2].map((day) => {
|
||||
const energy = energyByDay(this.solarSlots, day);
|
||||
const index = highestSlotIndexByDay(this.solarSlots, day);
|
||||
return { index, energy };
|
||||
});
|
||||
},
|
||||
chartData() {
|
||||
const datasets: unknown[] = [];
|
||||
if (this.solarSlots.length > 0) {
|
||||
const active = this.selected === ForecastType.Solar;
|
||||
const color = active ? colors.self : colors.border;
|
||||
datasets.push({
|
||||
label: ForecastType.Solar,
|
||||
type: "line",
|
||||
data: this.solarSlots.map((slot, index) => ({
|
||||
y: slot.price,
|
||||
x: new Date(slot.start),
|
||||
highlight:
|
||||
active &&
|
||||
(this.selectedIndex !== null
|
||||
? this.selectedIndex === index
|
||||
: this.solarHighlights.find(({ index: i }) => i === index)?.energy),
|
||||
})),
|
||||
yAxisID: "yForecast",
|
||||
backgroundColor: lighterColor(color),
|
||||
borderColor: color,
|
||||
fill: "origin",
|
||||
tension: 0.5,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: active ? 4 : 0,
|
||||
spanGaps: true,
|
||||
order: active ? 0 : 1,
|
||||
});
|
||||
}
|
||||
if (this.gridSlots.length > 0) {
|
||||
const active = this.selected === ForecastType.Price;
|
||||
const color = active ? colors.price : colors.border;
|
||||
datasets.push({
|
||||
label: ForecastType.Price,
|
||||
data: this.gridSlots.map((slot, index) => ({
|
||||
y: slot.price,
|
||||
x: new Date(slot.start),
|
||||
highlight:
|
||||
active &&
|
||||
(this.selectedIndex !== null
|
||||
? this.selectedIndex === index
|
||||
: index === this.maxPriceIndex || index === this.minPriceIndex),
|
||||
})),
|
||||
yAxisID: "yPrice",
|
||||
borderRadius: 8,
|
||||
backgroundColor: color,
|
||||
borderColor: color,
|
||||
order: active ? 0 : 1,
|
||||
});
|
||||
}
|
||||
if (this.co2Slots.length > 0) {
|
||||
const active = this.selected === ForecastType.Co2;
|
||||
const color = active ? colors.co2 : colors.border;
|
||||
datasets.push({
|
||||
label: ForecastType.Co2,
|
||||
type: "line",
|
||||
data: this.co2Slots.map((slot, index) => {
|
||||
const dataActive =
|
||||
active &&
|
||||
(this.selectedIndex !== null
|
||||
? this.selectedIndex === index
|
||||
: index === this.maxCo2Index || index === this.minCo2Index);
|
||||
return {
|
||||
y: slot.price,
|
||||
x: new Date(slot.start),
|
||||
highlight: dataActive,
|
||||
active: dataActive,
|
||||
};
|
||||
}),
|
||||
yAxisID: "yCo2",
|
||||
backgroundColor: color,
|
||||
borderColor: color,
|
||||
tension: 0.25,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: active ? 4 : 0,
|
||||
spanGaps: true,
|
||||
order: active ? 0 : 1,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
datasets,
|
||||
};
|
||||
},
|
||||
options() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
const vThis = this;
|
||||
return {
|
||||
...commonOptions,
|
||||
locale: this.$i18n?.locale,
|
||||
layout: { padding: { top: 32 } },
|
||||
color: colors.text,
|
||||
borderSkipped: false,
|
||||
animation: {
|
||||
duration: 500, // --evcc-transition-medium
|
||||
colors: true,
|
||||
numbers: false,
|
||||
},
|
||||
interaction: {
|
||||
mode: "index",
|
||||
axis: "x",
|
||||
intersect: false,
|
||||
},
|
||||
categoryPercentage: 0.7,
|
||||
events: ["mousemove", "click", "touchstart", "touchend"],
|
||||
onHover: function (event, active) {
|
||||
if (["touchend", "click"].includes(event.type)) {
|
||||
vThis.selectIndex(null, true);
|
||||
return;
|
||||
}
|
||||
const element = active.find(({ datasetIndex }) => {
|
||||
const { label } = event.chart.getDatasetMeta(datasetIndex);
|
||||
return label === vThis.selected;
|
||||
});
|
||||
vThis.selectIndex(element ? element.index : null);
|
||||
},
|
||||
plugins: {
|
||||
...commonOptions.plugins,
|
||||
datalabels: {
|
||||
backgroundColor: function (context) {
|
||||
return context.dataset.borderColor;
|
||||
},
|
||||
align: function (context) {
|
||||
// rotate label position to avoid horizontal clipping
|
||||
const step = 20;
|
||||
const adjust = {
|
||||
0: step * 2,
|
||||
1: step * 1,
|
||||
46: step * -1,
|
||||
47: step * -2,
|
||||
};
|
||||
return -90 + (adjust[context.dataIndex] || 0);
|
||||
},
|
||||
anchor: "end",
|
||||
offset: 8,
|
||||
padding: function (context) {
|
||||
const data = context.dataset.data[context.dataIndex];
|
||||
const x = typeof data.highlight === "number" ? 32 : 8;
|
||||
return {
|
||||
x,
|
||||
y: 4,
|
||||
};
|
||||
},
|
||||
borderRadius: 4,
|
||||
color: colors.background,
|
||||
font: { weight: "bold" },
|
||||
formatter: function (data, ctx) {
|
||||
if (data.highlight) {
|
||||
switch (ctx.dataset.label) {
|
||||
case ForecastType.Price:
|
||||
return vThis.fmtPricePerKWh(
|
||||
data.y,
|
||||
vThis.currency,
|
||||
true,
|
||||
true
|
||||
);
|
||||
case ForecastType.Co2:
|
||||
return vThis.fmtGrams(data.y);
|
||||
case ForecastType.Solar:
|
||||
if (data.highlight === true) {
|
||||
return vThis.fmtW(data.y, POWER_UNIT.AUTO);
|
||||
} else {
|
||||
return vThis.fmtWh(data.highlight, POWER_UNIT.AUTO);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
tooltip: null,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: "timeseries",
|
||||
display: true,
|
||||
time: { unit: "day" },
|
||||
border: { display: false },
|
||||
grid: {
|
||||
display: true,
|
||||
color: colors.border,
|
||||
offset: false,
|
||||
lineWidth: function (context) {
|
||||
if (context.type !== "tick") {
|
||||
return 0;
|
||||
}
|
||||
const label = context.tick?.label;
|
||||
return Array.isArray(label) ? 1 : 0;
|
||||
},
|
||||
},
|
||||
ticks: {
|
||||
color: colors.muted,
|
||||
autoSkip: false,
|
||||
maxRotation: 0,
|
||||
minRotation: 0,
|
||||
source: "data",
|
||||
align: "center",
|
||||
callback: function (value) {
|
||||
const date = new Date(value);
|
||||
const hour = date.getHours();
|
||||
const hourFmt = vThis.hourShort(date);
|
||||
if (hour === 0) {
|
||||
return [hourFmt, vThis.weekdayShort(date)];
|
||||
}
|
||||
if (hour % 6 === 0) {
|
||||
return hourFmt;
|
||||
}
|
||||
return "";
|
||||
},
|
||||
},
|
||||
},
|
||||
yForecast: { display: false, min: 0, max: this.yMax(this.solarSlots) },
|
||||
yCo2: { display: false, min: 0, max: this.yMax(this.co2Slots) },
|
||||
yPrice: { display: false, min: 0, max: this.yMax(this.gridSlots) },
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selectedSlot(slot) {
|
||||
this.$emit("selected", slot);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
filterSlots(slots: PriceSlot[] = []) {
|
||||
return slots.filter((slot) => new Date(slot.end) > this.startDate).slice(0, 48);
|
||||
},
|
||||
onMouseLeave() {
|
||||
this.selectIndex(null, true);
|
||||
},
|
||||
selectIndex(index: number | null, timeout = false) {
|
||||
if (this.ignoreEvents) return;
|
||||
this.selectedIndex = index;
|
||||
|
||||
// reset hover state (points, highlights)
|
||||
if (this.selectedIndex === null) {
|
||||
this.$nextTick(() => {
|
||||
// @ts-expect-error unknown chart type
|
||||
this.$refs.chart?.chart?.setActiveElements([]);
|
||||
});
|
||||
}
|
||||
|
||||
// ignore events after selection reset because chart.js triggers delayed mousemove events
|
||||
if (timeout) {
|
||||
this.ignoreEvents = true;
|
||||
this.ignoreEventsTimeout = setTimeout(() => {
|
||||
this.ignoreEvents = false;
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
yMax(slots: PriceSlot[] = []): number | undefined {
|
||||
const value = this.maxValue(slots);
|
||||
return value ? value * 1.15 : undefined;
|
||||
},
|
||||
maxIndex(slots: PriceSlot[] = []) {
|
||||
return slots.reduce((max, slot, index) => {
|
||||
return slot.price > slots[max].price ? index : max;
|
||||
}, 0);
|
||||
},
|
||||
minIndex(slots: PriceSlot[] = []) {
|
||||
return slots.reduce((min, slot, index) => {
|
||||
return slot.price < slots[min].price ? index : min;
|
||||
}, 0);
|
||||
},
|
||||
maxValue(slots: PriceSlot[] = []) {
|
||||
return slots[this.maxIndex(slots)]?.price || null;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart {
|
||||
width: 780px;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.chart {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
190
assets/js/components/ForecastDetails.vue
Normal file
190
assets/js/components/ForecastDetails.vue
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
<template>
|
||||
<div v-if="isSolar && solarEnergy" class="row">
|
||||
<div class="col-6 col-sm-4 mb-3 d-flex flex-column">
|
||||
<div class="label">{{ label("today") }}</div>
|
||||
<div class="value d-flex flex-column flex-lg-row gap-lg-2 align-items-lg-baseline">
|
||||
<div class="text-primary text-nowrap">{{ solarEnergy.today.energy }}</div>
|
||||
<div class="extraValue text-nowrap">{{ label("remaining") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 mb-3 d-flex flex-column align-items-end align-items-sm-center">
|
||||
<div class="label">{{ label("tomorrow") }}</div>
|
||||
<div
|
||||
class="value d-flex flex-column flex-lg-row gap-lg-2 align-items-end align-items-sm-center align-items-lg-baseline"
|
||||
>
|
||||
<div class="text-primary text-nowrap">{{ solarEnergy.tomorrow.energy }}</div>
|
||||
<div v-if="solarEnergy.tomorrow.incomplete" class="extraValue text-nowrap">
|
||||
{{ label("partly") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 mb-3 d-flex flex-column align-items-start align-items-sm-end">
|
||||
<div class="label">{{ label("dayAfterTomorrow") }}</div>
|
||||
<div
|
||||
class="value d-flex flex-column flex-lg-row gap-lg-2 align-items-start align-items-sm-end align-items-lg-baseline"
|
||||
>
|
||||
<div class="text-primary text-nowrap">
|
||||
{{ solarEnergy.dayAfterTomorrow.energy }}
|
||||
</div>
|
||||
<div v-if="solarEnergy.dayAfterTomorrow.incomplete" class="extraValue text-nowrap">
|
||||
{{ label("partly") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="row">
|
||||
<div class="col-12 col-sm-6 col-lg-4 mb-3 d-flex flex-column">
|
||||
<div class="label">{{ label("range") }}</div>
|
||||
<div class="value text-price text-nowrap" :class="highlightColor">
|
||||
{{ priceRange }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="col-12 col-sm-6 col-lg-4 mb-3 d-flex flex-column align-items-sm-end align-items-lg-center"
|
||||
>
|
||||
<div class="label">{{ label("average") }}</div>
|
||||
<div class="value text-price text-nowrap" :class="highlightColor">
|
||||
{{ averagePrice }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="col-12 col-sm-6 col-lg-4 mb-3 d-flex flex-column align-items-sm-start align-items-lg-end"
|
||||
>
|
||||
<div class="label">{{ label("lowestHour") }}</div>
|
||||
<div class="value text-price text-nowrap" :class="highlightColor">
|
||||
{{ lowestPriceHour }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import {
|
||||
energyByDay,
|
||||
dayStringByOffset,
|
||||
filterSlotsByDate,
|
||||
ForecastType,
|
||||
type PriceSlot,
|
||||
} from "../utils/forecast";
|
||||
import formatter, { POWER_UNIT } from "../mixins/formatter";
|
||||
|
||||
const LOCALES_WITHOUT_DAY_AFTER_TOMORROW = ["en", "tr"];
|
||||
|
||||
export interface Energy {
|
||||
energy: string;
|
||||
incomplete: boolean;
|
||||
}
|
||||
|
||||
export interface EnergyByDay {
|
||||
today: Energy;
|
||||
tomorrow: Energy;
|
||||
dayAfterTomorrow: Energy;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: "ForecastDetails",
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
type: { type: String as () => ForecastType, required: true },
|
||||
slots: { type: Array as () => PriceSlot[], default: () => [] },
|
||||
currency: { type: String },
|
||||
},
|
||||
computed: {
|
||||
isSolar() {
|
||||
return this.type === ForecastType.Solar;
|
||||
},
|
||||
upcomingSlots() {
|
||||
const now = new Date();
|
||||
return this.slots.filter((slot) => new Date(slot.end) > now).slice(0, 48);
|
||||
},
|
||||
solarEnergy(): EnergyByDay | undefined {
|
||||
if (!this.isSolar) return;
|
||||
|
||||
const days = ["today", "tomorrow", "dayAfterTomorrow"];
|
||||
return days.reduce((acc, day, index) => {
|
||||
const energy = energyByDay(this.slots, index);
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + index);
|
||||
const dayString = dayStringByOffset(index);
|
||||
const count = filterSlotsByDate(this.slots, dayString).length;
|
||||
const empty = count === 0;
|
||||
const complete = count === 24;
|
||||
const energyFmt = empty ? "-" : this.fmtWh(energy, POWER_UNIT.AUTO);
|
||||
acc[day] = {
|
||||
energy: energyFmt,
|
||||
incomplete: energy && !complete && !empty,
|
||||
};
|
||||
return acc;
|
||||
}, {} as EnergyByDay);
|
||||
},
|
||||
averagePrice() {
|
||||
if (this.isSolar) return "";
|
||||
const slots = this.upcomingSlots;
|
||||
const price = slots.reduce((acc, slot) => acc + slot.price, 0) / slots.length;
|
||||
return this.fmtValue(price, true);
|
||||
},
|
||||
priceRange() {
|
||||
if (this.isSolar) return "";
|
||||
const slots = this.upcomingSlots;
|
||||
const min = Math.min(...slots.map((slot) => slot.price));
|
||||
const max = Math.max(...slots.map((slot) => slot.price));
|
||||
return `${this.fmtValue(min, false)} – ${this.fmtValue(max, true)}`;
|
||||
},
|
||||
lowestPriceHour() {
|
||||
if (this.isSolar) return "";
|
||||
const slots = this.upcomingSlots;
|
||||
const min = Math.min(...slots.map((slot) => slot.price));
|
||||
const slot = slots.find((slot) => slot.price === min);
|
||||
if (!slot) return "";
|
||||
const start = new Date(slot.start);
|
||||
const end = new Date(slot.end);
|
||||
return `${this.weekdayShort(start)} ${this.hourShort(start)} – ${this.hourShort(end)}`;
|
||||
},
|
||||
highlightColor() {
|
||||
const colors = {
|
||||
[ForecastType.Price]: "text-price",
|
||||
[ForecastType.Co2]: "text-co2",
|
||||
};
|
||||
return colors[this.type];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
label(key: string) {
|
||||
// special case "day after tomorrow"
|
||||
if (
|
||||
key === "dayAfterTomorrow" &&
|
||||
LOCALES_WITHOUT_DAY_AFTER_TOMORROW.includes(this.$i18n.locale)
|
||||
) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + 2);
|
||||
return this.fmtDayMonth(date);
|
||||
}
|
||||
|
||||
return this.$t(`forecast.${this.type}.${key}`);
|
||||
},
|
||||
fmtValue(value: number, withUnit = true) {
|
||||
if (this.type === ForecastType.Price) {
|
||||
return this.fmtPricePerKWh(value, this.currency, false, withUnit);
|
||||
}
|
||||
return withUnit ? this.fmtCo2Medium(value) : this.fmtNumber(value, 0);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.value {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.extraValue {
|
||||
color: var(--evcc-gray);
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
}
|
||||
.label {
|
||||
color: var(--evcc-gray);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
</style>
|
||||
135
assets/js/components/ForecastModal.vue
Normal file
135
assets/js/components/ForecastModal.vue
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<template>
|
||||
<GenericModal
|
||||
id="forecastModal"
|
||||
:title="$t('forecast.modalTitle')"
|
||||
size="xl"
|
||||
data-testid="forecast-modal"
|
||||
@open="modalVisible"
|
||||
@closed="modalInvisible"
|
||||
>
|
||||
<div v-if="isModalVisible" class="d-flex justify-content-between align-items-center gap-2">
|
||||
<ForecastTypeSelect v-model="selectedType" :forecast="forecast" />
|
||||
<ForecastActiveSlot :activeSlot="selectedSlot" />
|
||||
</div>
|
||||
|
||||
<ForecastChart
|
||||
class="my-3"
|
||||
:grid="forecast.grid"
|
||||
:solar="forecast.solar"
|
||||
:co2="forecast.co2"
|
||||
:currency="currency"
|
||||
:selected="selectedType"
|
||||
@selected="updateSlot"
|
||||
/>
|
||||
|
||||
<ForecastDetails :type="selectedType" :slots="selectedSlots" :currency="currency" />
|
||||
</GenericModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import "@h2d2/shopicons/es/regular/eco1";
|
||||
import "@h2d2/shopicons/es/regular/sun";
|
||||
import { defineComponent } from "vue";
|
||||
import type { PropType } from "vue";
|
||||
import GenericModal from "./GenericModal.vue";
|
||||
import ForecastChart from "./ForecastChart.vue";
|
||||
import ForecastTypeSelect from "./ForecastTypeSelect.vue";
|
||||
import ForecastDetails from "./ForecastDetails.vue";
|
||||
import ForecastActiveSlot from "./ForecastActiveSlot.vue";
|
||||
import { type PriceSlot, ForecastType } from "../utils/forecast";
|
||||
import formatter from "../mixins/formatter";
|
||||
|
||||
interface Forecast {
|
||||
grid?: PriceSlot[];
|
||||
solar?: PriceSlot[];
|
||||
co2?: PriceSlot[];
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: "ForecastModal",
|
||||
components: {
|
||||
GenericModal,
|
||||
ForecastChart,
|
||||
ForecastTypeSelect,
|
||||
ForecastDetails,
|
||||
ForecastActiveSlot,
|
||||
},
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
forecast: { type: Object as PropType<Forecast>, default: () => ({}) },
|
||||
currency: { type: String },
|
||||
},
|
||||
data: function (): {
|
||||
isModalVisible: boolean;
|
||||
selectedType: ForecastType;
|
||||
selectedSlot: PriceSlot | null;
|
||||
} {
|
||||
return {
|
||||
isModalVisible: false,
|
||||
selectedType: ForecastType.Solar,
|
||||
selectedSlot: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
selectedSlots() {
|
||||
const slots = {
|
||||
[ForecastType.Solar]: this.forecast.solar,
|
||||
[ForecastType.Price]: this.forecast.grid,
|
||||
[ForecastType.Co2]: this.forecast.co2,
|
||||
};
|
||||
return slots[this.selectedType] || [];
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
isModalVisible: function (newVal) {
|
||||
if (newVal) {
|
||||
this.updateSelectedType();
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.updateSelectedType();
|
||||
},
|
||||
methods: {
|
||||
modalVisible() {
|
||||
this.isModalVisible = true;
|
||||
},
|
||||
modalInvisible() {
|
||||
this.isModalVisible = false;
|
||||
},
|
||||
updateSlot(slot: PriceSlot | null) {
|
||||
this.selectedSlot = slot;
|
||||
},
|
||||
updateSelectedType() {
|
||||
const availableTypes = {
|
||||
[ForecastType.Solar]: !!this.forecast.solar,
|
||||
[ForecastType.Price]: !!this.forecast.grid,
|
||||
[ForecastType.Co2]: !!this.forecast.co2,
|
||||
};
|
||||
|
||||
// selection has data, do nothing
|
||||
if (availableTypes[this.selectedType]) return;
|
||||
// fallback to first available type
|
||||
this.selectedType =
|
||||
Object.values(ForecastType).find((type) => availableTypes[type]) ||
|
||||
Object.values(ForecastType)[0];
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.value {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.extraValue {
|
||||
color: var(--evcc-gray);
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
}
|
||||
.label {
|
||||
color: var(--evcc-gray);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
</style>
|
||||
57
assets/js/components/ForecastTypeSelect.vue
Normal file
57
assets/js/components/ForecastTypeSelect.vue
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<template>
|
||||
<IconSelectGroup>
|
||||
<IconSelectItem
|
||||
v-for="type in types"
|
||||
:key="type"
|
||||
:active="selectedType === type"
|
||||
:label="$t(`forecast.type.${type}`)"
|
||||
:disabled="!availableTypes[type]"
|
||||
hideLabelOnMobile
|
||||
@click="$emit('update:modelValue', type)"
|
||||
>
|
||||
<component :is="typeIcons[type]"></component>
|
||||
</IconSelectItem>
|
||||
</IconSelectGroup>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import "@h2d2/shopicons/es/regular/eco1";
|
||||
import "@h2d2/shopicons/es/regular/sun";
|
||||
import { defineComponent } from "vue";
|
||||
import IconSelectItem from "./IconSelectItem.vue";
|
||||
import IconSelectGroup from "./IconSelectGroup.vue";
|
||||
import DynamicPriceIcon from "./MaterialIcon/DynamicPrice.vue";
|
||||
import { ForecastType } from "../utils/forecast";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ForecastTypeSelect",
|
||||
components: { IconSelectItem, IconSelectGroup },
|
||||
props: {
|
||||
modelValue: { type: String as () => ForecastType, required: true },
|
||||
forecast: { type: Object, required: true },
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
computed: {
|
||||
selectedType() {
|
||||
return this.modelValue;
|
||||
},
|
||||
types() {
|
||||
return Object.values(ForecastType);
|
||||
},
|
||||
availableTypes() {
|
||||
return {
|
||||
[ForecastType.Solar]: !!this.forecast.solar,
|
||||
[ForecastType.Price]: !!this.forecast.grid,
|
||||
[ForecastType.Co2]: !!this.forecast.co2,
|
||||
};
|
||||
},
|
||||
typeIcons() {
|
||||
return {
|
||||
[ForecastType.Solar]: "shopicon-regular-sun",
|
||||
[ForecastType.Price]: DynamicPriceIcon,
|
||||
[ForecastType.Co2]: "shopicon-regular-eco1",
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
@ -3,12 +3,16 @@
|
|||
<button
|
||||
type="button"
|
||||
class="btn gap-2 btn-sm"
|
||||
:class="{ active, withLabel: !!label }"
|
||||
:class="{ active, withLabel: !!label, hideLabelOnMobile }"
|
||||
:disabled="disabled"
|
||||
@click="$emit('click', value)"
|
||||
>
|
||||
<slot></slot>
|
||||
<span v-if="label" class="text-nowrap text-truncate">
|
||||
<span
|
||||
v-if="label"
|
||||
class="text-nowrap text-truncate"
|
||||
:class="{ 'd-none d-md-inline': hideLabelOnMobile }"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</button>
|
||||
|
|
@ -23,6 +27,7 @@ export default {
|
|||
active: Boolean,
|
||||
label: String,
|
||||
disabled: Boolean,
|
||||
hideLabelOnMobile: Boolean,
|
||||
},
|
||||
emits: ["click"],
|
||||
};
|
||||
|
|
@ -51,4 +56,10 @@ export default {
|
|||
width: auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.btn.hideLabelOnMobile {
|
||||
width: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
17
assets/js/components/MaterialIcon/Forecast.vue
Normal file
17
assets/js/components/MaterialIcon/Forecast.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<template>
|
||||
<svg :style="svgStyle" viewBox="0 0 48 48">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.78,31c1.791,-3.381 3.675,-6.042 5.643,-9.059c1.422,-2.18 2.889,-4.118 4.384,-5.67l2.881,2.774c-1.338,1.39 -2.642,3.13 -3.914,5.081c-1.523,2.335 -3,4.445 -4.418,6.874l-4.576,-0Zm32.023,0c-0.661,-1.3 -1.346,-2.567 -2.051,-3.781l3.458,-2.011c1.067,1.836 2.089,3.787 3.055,5.792l-4.462,0Zm-16.172,-14.32l-1.915,-3.512c1.409,-0.768 2.842,-1.169 4.286,-1.168c1.35,0.001 2.702,0.328 4.04,0.954l-1.696,3.623c-0.78,-0.365 -1.562,-0.576 -2.348,-0.577c-0.8,-0.001 -1.586,0.254 -2.367,0.68Zm7.78,2.072l2.729,-2.924c1.591,1.484 3.149,3.369 4.639,5.53l-3.292,2.271c-1.313,-1.902 -2.675,-3.57 -4.076,-4.877Z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import icon from "../../mixins/icon";
|
||||
|
||||
export default {
|
||||
name: "Forecast",
|
||||
mixins: [icon],
|
||||
};
|
||||
</script>
|
||||
|
|
@ -40,6 +40,7 @@ export const commonOptions = {
|
|||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
datalabels: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: "#000000cc",
|
||||
boxPadding: 5,
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ export default {
|
|||
sponsor: { type: Object, default: () => ({}) },
|
||||
smartCostType: String,
|
||||
fatal: Object,
|
||||
forecast: Object,
|
||||
},
|
||||
computed: {
|
||||
batteryConfigured: function () {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,16 @@
|
|||
{{ $t("batterySettings.modalTitle") }}
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="forecastAvailable">
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item"
|
||||
data-testid="topnavigation-forecast"
|
||||
@click="openForecastModal"
|
||||
>
|
||||
{{ $t("forecast.modalTitle") }}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<router-link class="dropdown-item" to="/config" active-class="active">
|
||||
<span
|
||||
|
|
@ -142,6 +152,7 @@ export default {
|
|||
return {};
|
||||
},
|
||||
},
|
||||
forecast: Object,
|
||||
battery: Array,
|
||||
fatal: Object,
|
||||
},
|
||||
|
|
@ -181,6 +192,10 @@ export default {
|
|||
batteryModalAvailable() {
|
||||
return this.batteryConfigured;
|
||||
},
|
||||
forecastAvailable() {
|
||||
const { grid, solar, co2 } = this.forecast || {};
|
||||
return grid || solar || co2;
|
||||
},
|
||||
showLogout() {
|
||||
return isLoggedIn();
|
||||
},
|
||||
|
|
@ -219,6 +234,10 @@ export default {
|
|||
);
|
||||
modal.show();
|
||||
},
|
||||
openForecastModal() {
|
||||
const modal = Modal.getOrCreateInstance(document.getElementById("forecastModal"));
|
||||
modal.show();
|
||||
},
|
||||
openNativeSettings() {
|
||||
sendToApp({ type: "settings" });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -187,6 +187,14 @@ export default {
|
|||
minute: "numeric",
|
||||
}).format(date);
|
||||
},
|
||||
hourShort: function (date) {
|
||||
const locale = this.$i18n?.locale;
|
||||
// special: use shorter german format
|
||||
if (locale === "de") return date.getHours();
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
hour: "numeric",
|
||||
}).format(date);
|
||||
},
|
||||
weekdayShort: function (date) {
|
||||
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||
weekday: "short",
|
||||
|
|
|
|||
|
|
@ -61,6 +61,11 @@ export default function setupRouter(i18n) {
|
|||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/energy",
|
||||
component: () => import("./views/Energy.vue"),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: "/log",
|
||||
component: () => import("./views/Log.vue"),
|
||||
|
|
|
|||
30
assets/js/utils/forecast.test.ts
Normal file
30
assets/js/utils/forecast.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { describe, expect, test, vi } from "vitest";
|
||||
import { aggregateEnergy, type PriceSlot } from "./forecast";
|
||||
|
||||
const slots: PriceSlot[] = [
|
||||
{ start: "2050-03-20T10:00:00+01:00", end: "2050-03-20T10:30:00+01:00", price: 2000 }, // 1kWh
|
||||
{ start: "2050-03-20T10:30:00+01:00", end: "2050-03-20T11:00:00+01:00", price: 4000 }, // 2kWh
|
||||
{ start: "2050-03-20T11:00:00+01:00", end: "2050-03-20T11:30:00+01:00", price: 3000 }, // 1.5kWh
|
||||
{ start: "2050-03-20T11:30:00+01:00", end: "2050-03-20T12:00:00+01:00", price: 2000 }, // 1kWh
|
||||
{ start: "2050-03-20T12:00:00+01:00", end: "2050-03-20T12:30:00+01:00", price: 0 }, // 0kWh
|
||||
{ start: "2050-03-20T12:30:00+01:00", end: "2050-03-20T13:00:00+01:00", price: 0 }, // 0kWh
|
||||
];
|
||||
|
||||
describe("aggregateEnergy", () => {
|
||||
test("aggregates energy from power values for 30min slots", () => {
|
||||
const result = aggregateEnergy(slots);
|
||||
expect(result).toBeCloseTo(5500);
|
||||
});
|
||||
test("ignore slots in the past", () => {
|
||||
// will ignore the first two slots
|
||||
vi.setSystemTime("2050-03-20T11:00:00+01:00");
|
||||
const result = aggregateEnergy(slots, true);
|
||||
expect(result).toBeCloseTo(2500);
|
||||
});
|
||||
test("correctly split in-between slots", () => {
|
||||
// start in the middle of the 4th slot
|
||||
vi.setSystemTime("2050-03-20T11:45:00+01:00");
|
||||
const result = aggregateEnergy(slots, true);
|
||||
expect(result).toBeCloseTo(500);
|
||||
});
|
||||
});
|
||||
70
assets/js/utils/forecast.ts
Normal file
70
assets/js/utils/forecast.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
export interface PriceSlot {
|
||||
start: string;
|
||||
end: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export enum ForecastType {
|
||||
Solar = "solar",
|
||||
Price = "price",
|
||||
Co2 = "co2",
|
||||
}
|
||||
|
||||
export function aggregateEnergy(slots: PriceSlot[], ignorePast: boolean = false): number {
|
||||
const now = new Date();
|
||||
return slots.reduce((acc: number, { start, end, price: power }: PriceSlot) => {
|
||||
let startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
if (ignorePast) {
|
||||
if (endDate < now) {
|
||||
return acc; // ignore past slots
|
||||
}
|
||||
if (startDate < now) {
|
||||
startDate = now; // count this slot from now on
|
||||
}
|
||||
}
|
||||
const hours = (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60); // convert ms to hours
|
||||
const energy = power * hours; // Wh
|
||||
return acc + energy;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// return the date in local YYYY-MM-DD format
|
||||
function toDayString(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// return only slots that are on a given date, ignores slots that are in the past
|
||||
export function filterSlotsByDate(slots: PriceSlot[], dayString: string): PriceSlot[] {
|
||||
const now = new Date();
|
||||
return slots.filter(({ start, end }) => {
|
||||
const isPast = new Date(end) < now;
|
||||
const dateMatches = toDayString(new Date(start)) === dayString;
|
||||
return !isPast && dateMatches;
|
||||
});
|
||||
}
|
||||
|
||||
// return the energy for a given day (0 = today, 1 = tomorrow, etc.)
|
||||
export function energyByDay(slots: PriceSlot[], day: number = 0): number {
|
||||
const dayString = dayStringByOffset(day);
|
||||
const daySlots = filterSlotsByDate(slots, dayString);
|
||||
return aggregateEnergy(daySlots, true);
|
||||
}
|
||||
|
||||
export function dayStringByOffset(day: number): string {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + day);
|
||||
return toDayString(date);
|
||||
}
|
||||
|
||||
// return the highest slot for a given day (0 = today, 1 = tomorrow, etc.)
|
||||
export function highestSlotIndexByDay(slots: PriceSlot[], day: number = 0): number {
|
||||
const dayString = dayStringByOffset(day);
|
||||
const daySlots = filterSlotsByDate(slots, dayString);
|
||||
const sortedSlots = daySlots.sort((a, b) => b.price - a.price);
|
||||
const highestSlot = sortedSlots[0] || {};
|
||||
return slots.findIndex((slot) => slot.start === highestSlot.start);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
<GlobalSettingsModal v-bind="globalSettingsProps" />
|
||||
<BatterySettingsModal v-if="batteryModalAvailabe" v-bind="batterySettingsProps" />
|
||||
<ForecastModal v-bind="forecastModalProps" />
|
||||
<HelpModal />
|
||||
<PasswordModal />
|
||||
<LoginModal />
|
||||
|
|
@ -15,6 +16,7 @@
|
|||
import store from "../store";
|
||||
import GlobalSettingsModal from "../components/GlobalSettingsModal.vue";
|
||||
import BatterySettingsModal from "../components/BatterySettingsModal.vue";
|
||||
import ForecastModal from "../components/ForecastModal.vue";
|
||||
import OfflineIndicator from "../components/OfflineIndicator.vue";
|
||||
import PasswordModal from "../components/PasswordModal.vue";
|
||||
import LoginModal from "../components/LoginModal.vue";
|
||||
|
|
@ -37,6 +39,7 @@ export default {
|
|||
GlobalSettingsModal,
|
||||
HelpModal,
|
||||
BatterySettingsModal,
|
||||
ForecastModal,
|
||||
PasswordModal,
|
||||
LoginModal,
|
||||
OfflineIndicator,
|
||||
|
|
@ -69,6 +72,9 @@ export default {
|
|||
offlineIndicatorProps() {
|
||||
return this.collectProps(OfflineIndicator, store.state);
|
||||
},
|
||||
forecastModalProps() {
|
||||
return this.collectProps(ForecastModal, store.state);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
version: function (now, prev) {
|
||||
|
|
|
|||
111
assets/js/views/Energy.vue
Normal file
111
assets/js/views/Energy.vue
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<template>
|
||||
<div class="container px-4 safe-area-inset">
|
||||
<TopHeader title="Energy Overview" />
|
||||
<div class="alert alert-light mb-3">This page is work in progress.</div>
|
||||
<div class="row">
|
||||
<main class="col-12">
|
||||
<div v-if="forecastAvailable">
|
||||
<h3 class="fw-normal my-4">
|
||||
{{ $t("forecast.modalTitle") }}
|
||||
</h3>
|
||||
<div class="d-flex justify-content-start">
|
||||
<IconSelectGroup>
|
||||
<IconSelectItem
|
||||
v-for="type in types"
|
||||
:key="type"
|
||||
:active="selectedType === type"
|
||||
:label="$t(`forecast.type.${type}`)"
|
||||
hideLabelOnMobile
|
||||
@click="updateType(type)"
|
||||
>
|
||||
<component :is="typeIcons[type]"></component>
|
||||
</IconSelectItem>
|
||||
</IconSelectGroup>
|
||||
</div>
|
||||
|
||||
<ForecastChart
|
||||
class="my-3"
|
||||
:grid="forecast.grid"
|
||||
:solar="forecast.solar"
|
||||
:co2="forecast.co2"
|
||||
:currency="currency"
|
||||
:selected="selectedType"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p>nothing to see here</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import TopHeader from "../components/TopHeader.vue";
|
||||
import ForecastChart from "../components/ForecastChart.vue";
|
||||
import IconSelectItem from "../components/IconSelectItem.vue";
|
||||
import IconSelectGroup from "../components/IconSelectGroup.vue";
|
||||
import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue";
|
||||
import { type PriceSlot } from "../utils/forecast";
|
||||
import formatter from "../mixins/formatter";
|
||||
import store from "../store";
|
||||
|
||||
interface Forecast {
|
||||
grid?: PriceSlot[];
|
||||
solar?: PriceSlot[];
|
||||
co2?: PriceSlot[];
|
||||
}
|
||||
|
||||
export const TYPES = {
|
||||
SOLAR: "solar",
|
||||
PRICE: "price",
|
||||
CO2: "co2",
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: "Energy",
|
||||
components: {
|
||||
TopHeader,
|
||||
IconSelectGroup,
|
||||
IconSelectItem,
|
||||
ForecastChart,
|
||||
},
|
||||
mixins: [formatter],
|
||||
data() {
|
||||
return {
|
||||
selectedType: TYPES.PRICE,
|
||||
types: Object.values(TYPES),
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return { title: `${this.$t("sessions.title")} | evcc` };
|
||||
},
|
||||
computed: {
|
||||
typeIcons() {
|
||||
return {
|
||||
[TYPES.SOLAR]: "shopicon-regular-sun",
|
||||
[TYPES.PRICE]: DynamicPriceIcon,
|
||||
[TYPES.CO2]: "shopicon-regular-eco1",
|
||||
};
|
||||
},
|
||||
forecastAvailable() {
|
||||
const { grid, solar, co2 } = this.forecast;
|
||||
return grid || solar || co2;
|
||||
},
|
||||
forecast(): Forecast {
|
||||
return store.state?.forecast || {};
|
||||
},
|
||||
currency() {
|
||||
return store.state?.currency;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
updateType(type: string) {
|
||||
this.selectedType = type;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
26
i18n/de.toml
26
i18n/de.toml
|
|
@ -434,6 +434,31 @@ modalUpdateNow = "Jetzt installieren"
|
|||
modalUpdateStarted = "Starte die neue Version von evcc ..."
|
||||
modalUpdateStatusStart = "Installation gestartet:"
|
||||
|
||||
[forecast]
|
||||
modalTitle = "Vorhersage"
|
||||
|
||||
[forecast.co2]
|
||||
average = "Durchschnitt"
|
||||
lowestHour = "Sauberste Stunde"
|
||||
range = "Bereich"
|
||||
|
||||
[forecast.price]
|
||||
average = "Durchschnitt"
|
||||
lowestHour = "Günstigste Stunde"
|
||||
range = "Bereich"
|
||||
|
||||
[forecast.solar]
|
||||
dayAfterTomorrow = "Übermorgen"
|
||||
partly = "teilweise"
|
||||
remaining = "verbleibend"
|
||||
today = "Heute"
|
||||
tomorrow = "Morgen"
|
||||
|
||||
[forecast.type]
|
||||
co2 = "CO₂"
|
||||
price = "Preis"
|
||||
solar = "Solar"
|
||||
|
||||
[header]
|
||||
about = "Über"
|
||||
blog = "Blog"
|
||||
|
|
@ -524,6 +549,7 @@ batteryGridChargeActive = "Netzladen aktiv"
|
|||
batteryGridChargeLimit = "Netzladen wenn"
|
||||
batteryHold = "Batterie (gesperrt)"
|
||||
batteryTooltip = "{energy} von {total} ({soc})"
|
||||
forecastTooltip = "Vorhersage: erwartete Sonnenenergie heute"
|
||||
gridImport = "Netzbezug"
|
||||
homePower = "Verbrauch"
|
||||
loadpoints = "Ladepunkt | Ladepunkt | {count} Ladepunkte"
|
||||
|
|
|
|||
26
i18n/en.toml
26
i18n/en.toml
|
|
@ -433,6 +433,31 @@ modalUpdateNow = "Install now"
|
|||
modalUpdateStarted = "Starting the new version of evcc…"
|
||||
modalUpdateStatusStart = "Installation started:"
|
||||
|
||||
[forecast]
|
||||
modalTitle = "Forecast"
|
||||
|
||||
[forecast.co2]
|
||||
average = "Average"
|
||||
lowestHour = "Cleanest hour"
|
||||
range = "Range"
|
||||
|
||||
[forecast.price]
|
||||
average = "Average"
|
||||
lowestHour = "Cheapest hour"
|
||||
range = "Range"
|
||||
|
||||
[forecast.solar]
|
||||
dayAfterTomorrow = "Day after tomorrow"
|
||||
partly = "partly"
|
||||
remaining = "remaining"
|
||||
today = "Today"
|
||||
tomorrow = "Tomorrow"
|
||||
|
||||
[forecast.type]
|
||||
co2 = "CO₂"
|
||||
price = "Price"
|
||||
solar = "Solar"
|
||||
|
||||
[header]
|
||||
about = "About"
|
||||
blog = "Blog"
|
||||
|
|
@ -523,6 +548,7 @@ batteryGridChargeActive = "grid charging active"
|
|||
batteryGridChargeLimit = "grid charging when"
|
||||
batteryHold = "Battery (locked)"
|
||||
batteryTooltip = "{energy} of {total} ({soc})"
|
||||
forecastTooltip = "forecast: remaining solar production today"
|
||||
gridImport = "Grid use"
|
||||
homePower = "Consumption"
|
||||
loadpoints = "Charger| Charger | {count} chargers"
|
||||
|
|
|
|||
31
package-lock.json
generated
31
package-lock.json
generated
|
|
@ -27,7 +27,10 @@
|
|||
"bootstrap": "^5.2.2",
|
||||
"canvas-confetti": "^1.4.0",
|
||||
"chart.js": "^4.4.4",
|
||||
"chartjs-adapter-dayjs-4": "^1.0.4",
|
||||
"chartjs-plugin-datalabels": "^2.2.0",
|
||||
"countup.js": "^2.3.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"eslint": "^9.13.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-import": "^2.25.3",
|
||||
|
|
@ -4371,6 +4374,28 @@
|
|||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chartjs-adapter-dayjs-4": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-adapter-dayjs-4/-/chartjs-adapter-dayjs-4-1.0.4.tgz",
|
||||
"integrity": "sha512-yy9BAYW4aNzPVrCWZetbILegTRb7HokhgospPoC3b5iZ5qdlqNmXts2KdSp6AqnjkPAp/YWyHDxLvIvwt5x81w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"chart.js": ">=4.0.1",
|
||||
"dayjs": "^1.9.7"
|
||||
}
|
||||
},
|
||||
"node_modules/chartjs-plugin-datalabels": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz",
|
||||
"integrity": "sha512-14ZU30lH7n89oq+A4bWaJPnAG8a7ZTk7dKf48YAzMvJjQtjrgg5Dpk9f+LbjCF6bpx3RAGTeL13IXpKQYyRvlw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": ">=3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/check-error": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
|
||||
|
|
@ -4718,6 +4743,12 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.13",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
|
||||
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||
|
|
|
|||
|
|
@ -34,7 +34,10 @@
|
|||
"bootstrap": "^5.2.2",
|
||||
"canvas-confetti": "^1.4.0",
|
||||
"chart.js": "^4.4.4",
|
||||
"chartjs-adapter-dayjs-4": "^1.0.4",
|
||||
"chartjs-plugin-datalabels": "^2.2.0",
|
||||
"countup.js": "^2.3.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"eslint": "^9.13.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-import": "^2.25.3",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue