Forecast UI: add feed-in, edge-to-edge layout, design improvements (#29090)
This commit is contained in:
parent
794a26d916
commit
812ffa47b8
17 changed files with 484 additions and 178 deletions
|
|
@ -21,7 +21,7 @@
|
||||||
--evcc-darkest-green: #076f20ff;
|
--evcc-darkest-green: #076f20ff;
|
||||||
--evcc-darkest-green-rgb: 7, 111, 32;
|
--evcc-darkest-green-rgb: 7, 111, 32;
|
||||||
--evcc-yellow: #faf000;
|
--evcc-yellow: #faf000;
|
||||||
--evcc-dark-yellow: #bbb400;
|
--evcc-dark-yellow: #f6bb0f;
|
||||||
--evcc-orange: #ff9000;
|
--evcc-orange: #ff9000;
|
||||||
--evcc-orange-rgb: 255, 144, 0;
|
--evcc-orange-rgb: 255, 144, 0;
|
||||||
--evcc-red: #fc440f;
|
--evcc-red: #fc440f;
|
||||||
|
|
@ -39,6 +39,7 @@
|
||||||
--evcc-pv: var(--evcc-dark-green);
|
--evcc-pv: var(--evcc-dark-green);
|
||||||
--evcc-battery: var(--evcc-darker-green);
|
--evcc-battery: var(--evcc-darker-green);
|
||||||
--evcc-export: var(--evcc-yellow);
|
--evcc-export: var(--evcc-yellow);
|
||||||
|
--evcc-export-contrast: var(--evcc-dark-yellow);
|
||||||
--evcc-price: #ff912fff;
|
--evcc-price: #ff912fff;
|
||||||
--evcc-co2: #00916eff;
|
--evcc-co2: #00916eff;
|
||||||
--evcc-background: var(--bs-gray-bright);
|
--evcc-background: var(--bs-gray-bright);
|
||||||
|
|
@ -100,6 +101,8 @@
|
||||||
|
|
||||||
:root.dark {
|
:root.dark {
|
||||||
--evcc-grid: var(--bs-gray-medium);
|
--evcc-grid: var(--bs-gray-medium);
|
||||||
|
--evcc-export: var(--evcc-yellow);
|
||||||
|
--evcc-export-contrast: var(--evcc-yellow);
|
||||||
--evcc-background: var(--bs-gray-deep);
|
--evcc-background: var(--bs-gray-deep);
|
||||||
--evcc-box: var(--bs-gray-dark);
|
--evcc-box: var(--bs-gray-dark);
|
||||||
--evcc-box-border: var(--bs-gray-darker);
|
--evcc-box-border: var(--bs-gray-darker);
|
||||||
|
|
@ -186,6 +189,10 @@ h5 {
|
||||||
color: var(--evcc-co2) !important;
|
color: var(--evcc-co2) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-export {
|
||||||
|
color: var(--evcc-export-contrast) !important;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--bs-primary);
|
color: var(--bs-primary);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ const colors: {
|
||||||
pricePerKWh: string | null;
|
pricePerKWh: string | null;
|
||||||
price: string | null;
|
price: string | null;
|
||||||
co2: string | null;
|
co2: string | null;
|
||||||
|
export: string | null;
|
||||||
background: string | null;
|
background: string | null;
|
||||||
light: string | null;
|
light: string | null;
|
||||||
selfPalette: string[];
|
selfPalette: string[];
|
||||||
|
|
@ -31,6 +32,7 @@ const colors: {
|
||||||
pricePerKWh: null,
|
pricePerKWh: null,
|
||||||
price: null,
|
price: null,
|
||||||
co2: null,
|
co2: null,
|
||||||
|
export: null,
|
||||||
background: null,
|
background: null,
|
||||||
light: null,
|
light: null,
|
||||||
selfPalette: ["#0FDE41FF", "#FFBD2FFF", "#FD6158FF", "#03C1EFFF", "#0F662DFF", "#FF922EFF"],
|
selfPalette: ["#0FDE41FF", "#FFBD2FFF", "#FD6158FF", "#03C1EFFF", "#0F662DFF", "#FF922EFF"],
|
||||||
|
|
@ -75,6 +77,7 @@ export function updateCssColors() {
|
||||||
colors.grid = style.getPropertyValue("--evcc-grid");
|
colors.grid = style.getPropertyValue("--evcc-grid");
|
||||||
colors.price = style.getPropertyValue("--evcc-price");
|
colors.price = style.getPropertyValue("--evcc-price");
|
||||||
colors.co2 = style.getPropertyValue("--evcc-co2");
|
colors.co2 = style.getPropertyValue("--evcc-co2");
|
||||||
|
colors.export = style.getPropertyValue("--evcc-export-contrast");
|
||||||
colors.background = style.getPropertyValue("--evcc-background");
|
colors.background = style.getPropertyValue("--evcc-background");
|
||||||
colors.pricePerKWh = style.getPropertyValue("--bs-gray-medium");
|
colors.pricePerKWh = style.getPropertyValue("--bs-gray-medium");
|
||||||
colors.co2PerKWh = style.getPropertyValue("--bs-gray-medium");
|
colors.co2PerKWh = style.getPropertyValue("--bs-gray-medium");
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ export default defineComponent({
|
||||||
{
|
{
|
||||||
type: "line",
|
type: "line",
|
||||||
data: this.slots.map((s) => [s.start, s.value]),
|
data: this.slots.map((s) => [s.start, s.value]),
|
||||||
smooth: 0.05,
|
smooth: true,
|
||||||
symbol: "circle",
|
symbol: "circle",
|
||||||
symbolSize: 6,
|
symbolSize: 6,
|
||||||
showSymbol: false,
|
showSymbol: false,
|
||||||
|
|
|
||||||
57
assets/js/components/Forecast/Co2Details.vue
Normal file
57
assets/js/components/Forecast/Co2Details.vue
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="average" class="row gx-2 mt-1">
|
||||||
|
<div class="col-6">
|
||||||
|
<small>
|
||||||
|
<span class="text-gray">{{ $t("forecast.co2.range") }}</span>
|
||||||
|
<br />
|
||||||
|
<span class="text-co2 fw-bold">{{ range }}</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 text-end">
|
||||||
|
<small>
|
||||||
|
<span class="text-gray">{{ $t("forecast.co2.average") }}</span>
|
||||||
|
<br />
|
||||||
|
<span class="text-co2 fw-bold">{{ average }}</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, type PropType } from "vue";
|
||||||
|
import formatter from "@/mixins/formatter";
|
||||||
|
import type { ForecastSlot } from "./types";
|
||||||
|
|
||||||
|
const MAX_HOURS = 96;
|
||||||
|
const SLOTS_PER_HOUR = 4;
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "Co2Details",
|
||||||
|
mixins: [formatter],
|
||||||
|
props: {
|
||||||
|
co2: { type: Array as PropType<ForecastSlot[]> },
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
upcomingSlots(): ForecastSlot[] {
|
||||||
|
if (!Array.isArray(this.co2)) return [];
|
||||||
|
const now = new Date();
|
||||||
|
return this.co2
|
||||||
|
.filter((slot) => new Date(slot.end) > now)
|
||||||
|
.slice(0, MAX_HOURS * SLOTS_PER_HOUR);
|
||||||
|
},
|
||||||
|
average(): string {
|
||||||
|
if (this.upcomingSlots.length === 0) return "";
|
||||||
|
const avg =
|
||||||
|
this.upcomingSlots.reduce((a, s) => a + s.value, 0) / this.upcomingSlots.length;
|
||||||
|
return this.fmtCo2Medium(avg);
|
||||||
|
},
|
||||||
|
range(): string {
|
||||||
|
if (this.upcomingSlots.length === 0) return "";
|
||||||
|
const values = this.upcomingSlots.map((s) => s.value);
|
||||||
|
const min = Math.min(...values);
|
||||||
|
const max = Math.max(...values);
|
||||||
|
return `${this.fmtNumber(min, 0)} – ${this.fmtCo2Medium(max)}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
107
assets/js/components/Forecast/GridDetails.vue
Normal file
107
assets/js/components/Forecast/GridDetails.vue
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="hasBothTariffs" class="row gx-2 mt-1">
|
||||||
|
<div class="col-6">
|
||||||
|
<small>
|
||||||
|
<span class="text-gray">{{ $t("main.energyflow.gridImport") }}</span>
|
||||||
|
<br />
|
||||||
|
<div class="d-flex flex-column flex-md-row column-gap-3 text-price fw-bold">
|
||||||
|
<span class="text-nowrap">{{ gridSummary!.avg }}</span>
|
||||||
|
<span v-if="gridSummary!.range" class="text-nowrap">{{
|
||||||
|
gridSummary!.range
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 text-end">
|
||||||
|
<small>
|
||||||
|
<span class="text-gray">{{ $t("main.energyflow.pvExport") }}</span>
|
||||||
|
<div
|
||||||
|
class="d-flex flex-column flex-md-row column-gap-3 justify-content-end text-export fw-bold"
|
||||||
|
>
|
||||||
|
<span class="text-nowrap">{{ feedinSummary!.avg }}</span>
|
||||||
|
<span v-if="feedinSummary!.range" class="text-nowrap">{{
|
||||||
|
feedinSummary!.range
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<a href="#" class="text-gray" @click.prevent="toggleFeedin">{{
|
||||||
|
showFeedin ? $t("forecast.hideLine") : $t("forecast.showLine")
|
||||||
|
}}</a>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="gridSummary" class="row gx-2 mt-1">
|
||||||
|
<div class="col-6">
|
||||||
|
<small>
|
||||||
|
<span class="text-gray">{{ $t("forecast.price.range") }}</span>
|
||||||
|
<br />
|
||||||
|
<span class="text-price fw-bold">{{ gridSummary.range || gridSummary.avg }}</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 text-end">
|
||||||
|
<small>
|
||||||
|
<span class="text-gray">{{ $t("forecast.price.average") }}</span>
|
||||||
|
<br />
|
||||||
|
<span class="text-price fw-bold">{{ gridSummary.avg }}</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, type PropType } from "vue";
|
||||||
|
import formatter from "@/mixins/formatter";
|
||||||
|
import type { CURRENCY } from "@/types/evcc";
|
||||||
|
import type { ForecastSlot } from "./types";
|
||||||
|
import { isStaticTariff } from "@/utils/forecast";
|
||||||
|
|
||||||
|
const MAX_HOURS = 96;
|
||||||
|
const SLOTS_PER_HOUR = 4;
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "GridDetails",
|
||||||
|
mixins: [formatter],
|
||||||
|
props: {
|
||||||
|
grid: { type: Array as PropType<ForecastSlot[]> },
|
||||||
|
feedin: { type: Array as PropType<ForecastSlot[]> },
|
||||||
|
currency: { type: String as PropType<CURRENCY> },
|
||||||
|
showFeedin: { type: Boolean, default: true },
|
||||||
|
},
|
||||||
|
emits: ["toggle-feedin"],
|
||||||
|
computed: {
|
||||||
|
gridSummary(): { avg: string; range: string } | null {
|
||||||
|
return this.summarize(this.grid);
|
||||||
|
},
|
||||||
|
feedinSummary(): { avg: string; range: string } | null {
|
||||||
|
return this.summarize(this.feedin);
|
||||||
|
},
|
||||||
|
hasBothTariffs(): boolean {
|
||||||
|
return !!this.gridSummary && !!this.feedinSummary;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toggleFeedin() {
|
||||||
|
this.$emit("toggle-feedin");
|
||||||
|
},
|
||||||
|
summarize(slots?: ForecastSlot[]): { avg: string; range: string } | null {
|
||||||
|
const upcoming = this.upcomingSlots(slots);
|
||||||
|
if (upcoming.length === 0) return null;
|
||||||
|
const values = upcoming.map((s) => s.value);
|
||||||
|
const avg = values.reduce((a, b) => a + b, 0) / values.length;
|
||||||
|
const fmtAvg = this.fmtPricePerKWh(avg, this.currency, false, true);
|
||||||
|
if (isStaticTariff(upcoming)) return { avg: fmtAvg, range: "" };
|
||||||
|
const min = Math.min(...values);
|
||||||
|
const max = Math.max(...values);
|
||||||
|
const fmtMin = this.fmtPricePerKWh(min, this.currency, false, false);
|
||||||
|
const fmtMax = this.fmtPricePerKWh(max, this.currency, false, true);
|
||||||
|
return { avg: `⌀ ${fmtAvg}`, range: `${fmtMin} – ${fmtMax}` };
|
||||||
|
},
|
||||||
|
upcomingSlots(slots?: ForecastSlot[]): ForecastSlot[] {
|
||||||
|
if (!Array.isArray(slots)) return [];
|
||||||
|
const now = new Date();
|
||||||
|
return slots
|
||||||
|
.filter((slot) => new Date(slot.end) > now)
|
||||||
|
.slice(0, MAX_HOURS * SLOTS_PER_HOUR);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, type PropType } from "vue";
|
import { defineComponent, type PropType } from "vue";
|
||||||
import {
|
import {
|
||||||
|
echarts,
|
||||||
FONT_FAMILY,
|
FONT_FAMILY,
|
||||||
markPointLabel,
|
markPointLabel,
|
||||||
tooltipStyle,
|
tooltipStyle,
|
||||||
|
|
@ -18,7 +19,7 @@ import {
|
||||||
minSlotIndex,
|
minSlotIndex,
|
||||||
maxSlotIndex,
|
maxSlotIndex,
|
||||||
} from "./echarts";
|
} from "./echarts";
|
||||||
import colors from "@/colors";
|
import colors, { lighterColor } from "@/colors";
|
||||||
import formatter from "@/mixins/formatter";
|
import formatter from "@/mixins/formatter";
|
||||||
import chartMixin from "./chartMixin";
|
import chartMixin from "./chartMixin";
|
||||||
import type { CURRENCY } from "@/types/evcc";
|
import type { CURRENCY } from "@/types/evcc";
|
||||||
|
|
@ -29,18 +30,19 @@ export default defineComponent({
|
||||||
mixins: [formatter, chartMixin],
|
mixins: [formatter, chartMixin],
|
||||||
props: {
|
props: {
|
||||||
grid: { type: Array as PropType<ForecastSlot[]>, required: true },
|
grid: { type: Array as PropType<ForecastSlot[]>, required: true },
|
||||||
|
feedin: { type: Array as PropType<ForecastSlot[]> },
|
||||||
currency: { type: String as PropType<CURRENCY> },
|
currency: { type: String as PropType<CURRENCY> },
|
||||||
zoom: { type: Boolean, default: false },
|
zoom: { type: Boolean, default: false },
|
||||||
},
|
},
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
hoveredIndex: -1 as number,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
computed: {
|
||||||
slots(): ForecastSlot[] {
|
slots(): ForecastSlot[] {
|
||||||
return filterForecastSlots(this.grid, this.startDate, this.endDate);
|
return filterForecastSlots(this.grid, this.startDate, this.endDate);
|
||||||
},
|
},
|
||||||
|
feedinSlots(): ForecastSlot[] {
|
||||||
|
return this.feedin
|
||||||
|
? filterForecastSlots(this.feedin, this.startDate, this.endDate)
|
||||||
|
: [];
|
||||||
|
},
|
||||||
markPoints(): { coord: [string, number]; value: string }[] {
|
markPoints(): { coord: [string, number]; value: string }[] {
|
||||||
const slots = this.slots;
|
const slots = this.slots;
|
||||||
if (!slots.length) return [];
|
if (!slots.length) return [];
|
||||||
|
|
@ -62,48 +64,63 @@ export default defineComponent({
|
||||||
return points;
|
return points;
|
||||||
},
|
},
|
||||||
yAxisConfig(): Record<string, unknown> {
|
yAxisConfig(): Record<string, unknown> {
|
||||||
const values = this.slots.map((s) => s.value);
|
const values = [
|
||||||
|
...this.slots.map((s) => s.value),
|
||||||
|
...this.feedinSlots.map((s) => s.value),
|
||||||
|
];
|
||||||
const dataMin = Math.min(...values);
|
const dataMin = Math.min(...values);
|
||||||
const dataMax = Math.max(...values);
|
const dataMax = Math.max(...values);
|
||||||
const fullMin = Math.min(0, dataMin);
|
const rangeMin = this.zoom ? dataMin : Math.min(0, dataMin);
|
||||||
const fullRange = dataMax - fullMin || 1;
|
const rangeMax = Math.max(0, dataMax);
|
||||||
const rawInterval = fullRange / 5;
|
const range = rangeMax - rangeMin || 1;
|
||||||
|
const rawInterval = range / 5;
|
||||||
const magnitude = Math.pow(10, Math.floor(Math.log10(rawInterval)));
|
const magnitude = Math.pow(10, Math.floor(Math.log10(rawInterval)));
|
||||||
const nice = [1, 2, 2.5, 5, 10].find((n) => n * magnitude >= rawInterval) || 10;
|
const nice = [1, 2, 2.5, 5, 10].find((n) => n * magnitude >= rawInterval) || 10;
|
||||||
const interval = nice * magnitude;
|
const interval = nice * magnitude;
|
||||||
|
|
||||||
if (this.zoom) {
|
|
||||||
return {
|
|
||||||
min: Math.floor(dataMin / interval) * interval,
|
|
||||||
max: Math.ceil(dataMax / interval) * interval,
|
|
||||||
interval,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
min: fullMin,
|
min: Math.floor(rangeMin / interval) * interval,
|
||||||
max: Math.ceil(dataMax / interval) * interval,
|
max: Math.ceil(rangeMax / interval) * interval,
|
||||||
interval,
|
interval,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
chartOption(): Record<string, unknown> {
|
chartOption(): Record<string, unknown> {
|
||||||
const priceColor = colors.price || "";
|
const priceColor = colors.price || "";
|
||||||
|
const exportColor = colors.export || "";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||||
const vThis = this;
|
const vThis = this;
|
||||||
return {
|
return {
|
||||||
animationDuration: 0,
|
animationDuration: 0,
|
||||||
|
animationDurationUpdate: 300,
|
||||||
textStyle: { fontFamily: FONT_FAMILY },
|
textStyle: { fontFamily: FONT_FAMILY },
|
||||||
grid: forecastGrid(),
|
grid: forecastGrid(),
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: "axis",
|
trigger: "axis",
|
||||||
axisPointer: { type: "line", snap: true, lineStyle: { color: "transparent" } },
|
axisPointer: { type: "line", snap: true, lineStyle: { color: "transparent" } },
|
||||||
...tooltipStyle(priceColor, () => this.chart),
|
...tooltipStyle(priceColor, () => this.chart),
|
||||||
formatter(params: { value: [string, number] }[]) {
|
formatter(params: { value: [string, number]; seriesIndex: number }[]) {
|
||||||
const p = params[0];
|
const p = params[0];
|
||||||
if (!p) return "";
|
if (!p) return "";
|
||||||
const d = new Date(p.value[0]);
|
const d = new Date(p.value[0]);
|
||||||
const time = `${vThis.weekdayShort(d)} ${vThis.fmtHourMinute(d)}`;
|
const time = `${vThis.weekdayShort(d)} ${vThis.fmtHourMinute(d)}`;
|
||||||
return `${time}<br/>${vThis.fmtPricePerKWh(p.value[1], vThis.currency, false, true)}`;
|
const lines = [time];
|
||||||
|
const showLabels = params.length > 1;
|
||||||
|
const labels = [
|
||||||
|
vThis.$t("main.energyflow.gridImport"),
|
||||||
|
vThis.$t("main.energyflow.pvExport"),
|
||||||
|
];
|
||||||
|
for (const s of params) {
|
||||||
|
const price = vThis.fmtPricePerKWh(
|
||||||
|
s.value[1],
|
||||||
|
vThis.currency,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
const label = showLabels ? `${labels[s.seriesIndex]}: ` : "";
|
||||||
|
lines.push(`${label}${price}`);
|
||||||
|
}
|
||||||
|
return lines.join("<br/>");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
xAxis: forecastXAxes(this.startDate, this.endDate, this.weekdayShort),
|
xAxis: forecastXAxes(this.startDate, this.endDate, this.weekdayShort),
|
||||||
|
|
@ -121,46 +138,56 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
series: [
|
series: [
|
||||||
{
|
this.priceSeries(this.slots, priceColor, this.markPoints),
|
||||||
type: "bar",
|
this.priceSeries(this.feedinSlots, exportColor),
|
||||||
cursor: "default",
|
|
||||||
data: this.slots.map((s, i) => ({
|
|
||||||
value: [clampStart(s.start, this.startDate), s.value],
|
|
||||||
itemStyle:
|
|
||||||
this.hoveredIndex >= 0 && i !== this.hoveredIndex
|
|
||||||
? { opacity: 0.33 }
|
|
||||||
: undefined,
|
|
||||||
})),
|
|
||||||
barMaxWidth: 2,
|
|
||||||
barMinWidth: 2,
|
|
||||||
itemStyle: {
|
|
||||||
color: priceColor,
|
|
||||||
borderRadius: 2,
|
|
||||||
},
|
|
||||||
emphasis: { disabled: true },
|
|
||||||
markPoint: markPointLabel(
|
|
||||||
priceColor,
|
|
||||||
this.tooltipVisible ? [] : this.markPoints,
|
|
||||||
this.startDate,
|
|
||||||
this.endDate
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
methods: {
|
||||||
this.chart!.on("showTip", (params: unknown) => {
|
priceSeries(
|
||||||
const p = params as { dataIndex?: number };
|
slots: ForecastSlot[],
|
||||||
if (p.dataIndex != null) this.hoveredIndex = p.dataIndex;
|
color: string,
|
||||||
});
|
points?: { coord: [string, number]; value: string }[]
|
||||||
this.chart!.on("hideTip", () => {
|
): Record<string, unknown> {
|
||||||
this.hoveredIndex = -1;
|
const avg = slots.length ? slots.reduce((a, s) => a + s.value, 0) / slots.length : 0;
|
||||||
});
|
const gradientDown = avg >= 0;
|
||||||
this.chart!.getZr().on("mouseout", () => {
|
return {
|
||||||
this.tooltipVisible = false;
|
type: "line",
|
||||||
this.hoveredIndex = -1;
|
step: "start",
|
||||||
});
|
cursor: "default",
|
||||||
|
showSymbol: false,
|
||||||
|
data: slots.map((s) => ({
|
||||||
|
value: [clampStart(s.start, this.startDate), s.value],
|
||||||
|
})),
|
||||||
|
lineStyle: { color, width: 2 },
|
||||||
|
areaStyle: {
|
||||||
|
color: new echarts.graphic.LinearGradient(
|
||||||
|
0,
|
||||||
|
gradientDown ? 0 : 1,
|
||||||
|
0,
|
||||||
|
gradientDown ? 1 : 0,
|
||||||
|
[
|
||||||
|
{ offset: 0, color: lighterColor(color) || color },
|
||||||
|
{ offset: 0.75, color: color + "00" },
|
||||||
|
{ offset: 1, color: color + "00" },
|
||||||
|
]
|
||||||
|
),
|
||||||
|
},
|
||||||
|
itemStyle: { color },
|
||||||
|
emphasis: { disabled: true },
|
||||||
|
...(points
|
||||||
|
? {
|
||||||
|
markPoint: markPointLabel(
|
||||||
|
color,
|
||||||
|
this.tooltipVisible ? [] : points,
|
||||||
|
this.startDate,
|
||||||
|
this.endDate
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ export default defineComponent({
|
||||||
{
|
{
|
||||||
type: "line",
|
type: "line",
|
||||||
data,
|
data,
|
||||||
smooth: 0.05,
|
smooth: true,
|
||||||
symbol: "circle",
|
symbol: "circle",
|
||||||
symbolSize: 6,
|
symbolSize: 6,
|
||||||
showSymbol: false,
|
showSymbol: false,
|
||||||
|
|
|
||||||
79
assets/js/components/Forecast/SolarDetails.vue
Normal file
79
assets/js/components/Forecast/SolarDetails.vue
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="days.length" class="row gx-2 mt-1">
|
||||||
|
<div v-for="day in days" :key="day.key" class="col-4" :class="`text-${day.align}`">
|
||||||
|
<small>
|
||||||
|
<span class="text-gray">{{ day.label }}</span>
|
||||||
|
<br />
|
||||||
|
<div
|
||||||
|
class="d-flex flex-column flex-md-row column-gap-2"
|
||||||
|
:class="`justify-content-md-${day.align}`"
|
||||||
|
>
|
||||||
|
<span class="text-primary fw-bold">{{ day.energy }}</span>
|
||||||
|
<span v-if="day.note" class="text-gray">{{ day.note }}</span>
|
||||||
|
</div>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, type PropType } from "vue";
|
||||||
|
import formatter, { POWER_UNIT } from "@/mixins/formatter";
|
||||||
|
import type { SolarDetails } from "./types";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "SolarDetails",
|
||||||
|
mixins: [formatter],
|
||||||
|
props: {
|
||||||
|
solar: { type: Object as PropType<SolarDetails> },
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
days(): {
|
||||||
|
key: string;
|
||||||
|
energy: string;
|
||||||
|
label: string;
|
||||||
|
align: string;
|
||||||
|
note: string;
|
||||||
|
}[] {
|
||||||
|
const s = this.solar;
|
||||||
|
if (!s) return [];
|
||||||
|
const dayAfterTomorrow = new Date();
|
||||||
|
dayAfterTomorrow.setDate(dayAfterTomorrow.getDate() + 2);
|
||||||
|
const dayAfterLabel = this.weekdayLong(dayAfterTomorrow);
|
||||||
|
const days = [
|
||||||
|
{
|
||||||
|
key: "today",
|
||||||
|
data: s.today,
|
||||||
|
label: this.$t("forecast.solar.today"),
|
||||||
|
align: "start",
|
||||||
|
note: this.$t("forecast.solar.remaining"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "tomorrow",
|
||||||
|
data: s.tomorrow,
|
||||||
|
label: this.$t("forecast.solar.tomorrow"),
|
||||||
|
align: "center",
|
||||||
|
note: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "dayAfterTomorrow",
|
||||||
|
data: s.dayAfterTomorrow,
|
||||||
|
label: dayAfterLabel,
|
||||||
|
align: "end",
|
||||||
|
note:
|
||||||
|
s.dayAfterTomorrow && !s.dayAfterTomorrow.complete
|
||||||
|
? this.$t("forecast.solar.partly")
|
||||||
|
: "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return days.map((d) => ({
|
||||||
|
key: d.key,
|
||||||
|
energy: d.data?.energy ? this.fmtWh(d.data.energy, POWER_UNIT.AUTO) : "-",
|
||||||
|
label: d.label,
|
||||||
|
align: d.align,
|
||||||
|
note: d.data?.energy ? d.note : "",
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
.forecast-chart-scroll {
|
.forecast-chart-scroll {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
-webkit-touch-callout: none;
|
-webkit-touch-callout: none;
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ export function markPointLabel(
|
||||||
animation: true,
|
animation: true,
|
||||||
clip: true,
|
clip: true,
|
||||||
symbol: "rect",
|
symbol: "rect",
|
||||||
symbolSize: 1,
|
symbolSize: 0,
|
||||||
label: {
|
label: {
|
||||||
show: true,
|
show: true,
|
||||||
position: "top",
|
position: "top",
|
||||||
|
|
@ -63,7 +63,7 @@ export function markPointLabel(
|
||||||
color: colors.background,
|
color: colors.background,
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
padding: [5, 10],
|
padding: [5, 15],
|
||||||
offset: [0, -2],
|
offset: [0, -2],
|
||||||
formatter: (p: { data: { value: string } }) => p.data.value,
|
formatter: (p: { data: { value: string } }) => p.data.value,
|
||||||
},
|
},
|
||||||
|
|
@ -111,7 +111,7 @@ export function tooltipStyle(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function forecastGrid() {
|
export function forecastGrid() {
|
||||||
return { top: 36, right: 16, bottom: 4, left: 34, borderWidth: 0 };
|
return { top: 36, right: 16, bottom: 16, left: 24, borderWidth: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function forecastXAxes(startDate: Date, endDate: Date, weekdayShort: (d: Date) => string) {
|
export function forecastXAxes(startDate: Date, endDate: Date, weekdayShort: (d: Date) => string) {
|
||||||
|
|
@ -167,13 +167,13 @@ export function forecastYAxis(overrides: Record<string, unknown> = {}) {
|
||||||
axisLine: { show: false },
|
axisLine: { show: false },
|
||||||
axisTick: { show: false },
|
axisTick: { show: false },
|
||||||
splitLine: {
|
splitLine: {
|
||||||
showMinLine: false,
|
showMinLine: true,
|
||||||
showMaxLine: false,
|
showMaxLine: false,
|
||||||
lineStyle: { color: colors.border || "" },
|
lineStyle: { color: colors.border || "" },
|
||||||
},
|
},
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
opacity: 0.5,
|
opacity: 0.75,
|
||||||
...(axisLabel as Record<string, unknown>),
|
...(axisLabel as Record<string, unknown>),
|
||||||
},
|
},
|
||||||
...rest,
|
...rest,
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,11 @@ export default defineComponent({
|
||||||
weekday: "short",
|
weekday: "short",
|
||||||
}).format(date);
|
}).format(date);
|
||||||
},
|
},
|
||||||
|
weekdayLong(date: Date) {
|
||||||
|
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||||
|
weekday: "long",
|
||||||
|
}).format(date);
|
||||||
|
},
|
||||||
fmtAbsoluteDate(date: Date) {
|
fmtAbsoluteDate(date: Date) {
|
||||||
const weekday = this.weekdayPrefix(date);
|
const weekday = this.weekdayPrefix(date);
|
||||||
const hour = new Intl.DateTimeFormat(this.$i18n?.locale, {
|
const hour = new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ const SESSIONS_GROUP = "sessions_group";
|
||||||
const SESSIONS_TYPE = "sessions_type";
|
const SESSIONS_TYPE = "sessions_type";
|
||||||
const SETTINGS_SOLAR_ADJUSTED = "settings_solar_adjusted";
|
const SETTINGS_SOLAR_ADJUSTED = "settings_solar_adjusted";
|
||||||
const SETTINGS_PRICE_ZOOM = "settings_price_zoom";
|
const SETTINGS_PRICE_ZOOM = "settings_price_zoom";
|
||||||
|
const SETTINGS_HIDE_FEEDIN = "settings_hide_feedin";
|
||||||
const LAST_BATTERY_SMART_COST_LIMIT = "last_battery_smart_cost_limit";
|
const LAST_BATTERY_SMART_COST_LIMIT = "last_battery_smart_cost_limit";
|
||||||
const LAST_TARGET_TIME = "last_target_time";
|
const LAST_TARGET_TIME = "last_target_time";
|
||||||
const LAST_SOC_GOAL = "last_soc_goal";
|
const LAST_SOC_GOAL = "last_soc_goal";
|
||||||
|
|
@ -124,6 +125,7 @@ export interface Settings {
|
||||||
sessionsType: string;
|
sessionsType: string;
|
||||||
solarAdjusted: boolean;
|
solarAdjusted: boolean;
|
||||||
priceZoom: boolean;
|
priceZoom: boolean;
|
||||||
|
hideFeedin: boolean;
|
||||||
loadpoints: Record<string, LoadpointSettings>;
|
loadpoints: Record<string, LoadpointSettings>;
|
||||||
lastBatterySmartCostLimit: number | undefined;
|
lastBatterySmartCostLimit: number | undefined;
|
||||||
lastTargetTime: string | null;
|
lastTargetTime: string | null;
|
||||||
|
|
@ -151,6 +153,7 @@ const settings: Settings = reactive({
|
||||||
sessionsType: read(SESSIONS_TYPE),
|
sessionsType: read(SESSIONS_TYPE),
|
||||||
solarAdjusted: readBool(SETTINGS_SOLAR_ADJUSTED),
|
solarAdjusted: readBool(SETTINGS_SOLAR_ADJUSTED),
|
||||||
priceZoom: readBool(SETTINGS_PRICE_ZOOM),
|
priceZoom: readBool(SETTINGS_PRICE_ZOOM),
|
||||||
|
hideFeedin: readBool(SETTINGS_HIDE_FEEDIN),
|
||||||
loadpoints: readJSON(LOADPOINTS),
|
loadpoints: readJSON(LOADPOINTS),
|
||||||
lastBatterySmartCostLimit: readNumber(LAST_BATTERY_SMART_COST_LIMIT),
|
lastBatterySmartCostLimit: readNumber(LAST_BATTERY_SMART_COST_LIMIT),
|
||||||
lastTargetTime: read(LAST_TARGET_TIME),
|
lastTargetTime: read(LAST_TARGET_TIME),
|
||||||
|
|
@ -177,6 +180,7 @@ watch(() => settings.sessionsGroup, save(SESSIONS_GROUP));
|
||||||
watch(() => settings.sessionsType, save(SESSIONS_TYPE));
|
watch(() => settings.sessionsType, save(SESSIONS_TYPE));
|
||||||
watch(() => settings.solarAdjusted, saveBool(SETTINGS_SOLAR_ADJUSTED));
|
watch(() => settings.solarAdjusted, saveBool(SETTINGS_SOLAR_ADJUSTED));
|
||||||
watch(() => settings.priceZoom, saveBool(SETTINGS_PRICE_ZOOM));
|
watch(() => settings.priceZoom, saveBool(SETTINGS_PRICE_ZOOM));
|
||||||
|
watch(() => settings.hideFeedin, saveBool(SETTINGS_HIDE_FEEDIN));
|
||||||
watch(() => settings.loadpoints, saveJSON(LOADPOINTS), { deep: true });
|
watch(() => settings.loadpoints, saveJSON(LOADPOINTS), { deep: true });
|
||||||
watch(() => settings.lastBatterySmartCostLimit, saveNumber(LAST_BATTERY_SMART_COST_LIMIT));
|
watch(() => settings.lastBatterySmartCostLimit, saveNumber(LAST_BATTERY_SMART_COST_LIMIT));
|
||||||
watch(() => settings.lastTargetTime, save(LAST_TARGET_TIME));
|
watch(() => settings.lastTargetTime, save(LAST_TARGET_TIME));
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { findLowestSumSlotIndex } from "./forecast";
|
import { findLowestSumSlotIndex, isStaticTariff } from "./forecast";
|
||||||
|
|
||||||
describe("findLowestSumSlotIndex", () => {
|
describe("findLowestSumSlotIndex", () => {
|
||||||
test("finds lowest sum with span of 4", () => {
|
test("finds lowest sum with span of 4", () => {
|
||||||
|
|
@ -55,3 +55,31 @@ describe("findLowestSumSlotIndex", () => {
|
||||||
expect(findLowestSumSlotIndex(slots, 2)).toBe(0);
|
expect(findLowestSumSlotIndex(slots, 2)).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isStaticTariff", () => {
|
||||||
|
test("returns false for undefined", () => {
|
||||||
|
expect(isStaticTariff(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for empty array", () => {
|
||||||
|
expect(isStaticTariff([])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns true when all values are identical", () => {
|
||||||
|
const slots = [
|
||||||
|
{ start: "2025-01-01T00:00:00Z", end: "2025-01-01T00:15:00Z", value: 0.25 },
|
||||||
|
{ start: "2025-01-01T00:15:00Z", end: "2025-01-01T00:30:00Z", value: 0.25 },
|
||||||
|
{ start: "2025-01-01T00:30:00Z", end: "2025-01-01T00:45:00Z", value: 0.25 },
|
||||||
|
];
|
||||||
|
expect(isStaticTariff(slots)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false when values differ", () => {
|
||||||
|
const slots = [
|
||||||
|
{ start: "2025-01-01T00:00:00Z", end: "2025-01-01T00:15:00Z", value: 0.25 },
|
||||||
|
{ start: "2025-01-01T00:15:00Z", end: "2025-01-01T00:30:00Z", value: 0.3 },
|
||||||
|
{ start: "2025-01-01T00:30:00Z", end: "2025-01-01T00:45:00Z", value: 0.25 },
|
||||||
|
];
|
||||||
|
expect(isStaticTariff(slots)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { TimeseriesEntry, SolarDetails } from "../components/Forecast/types";
|
import type { ForecastSlot, TimeseriesEntry, SolarDetails } from "../components/Forecast/types";
|
||||||
import deepCopy from "./deepClone";
|
import deepCopy from "./deepClone";
|
||||||
|
|
||||||
export enum ForecastType {
|
export enum ForecastType {
|
||||||
|
|
@ -65,6 +65,12 @@ export function adjustedSolar(solar?: SolarDetails): SolarDetails | undefined {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isStaticTariff(slots?: ForecastSlot[]): boolean {
|
||||||
|
if (!Array.isArray(slots) || slots.length === 0) return false;
|
||||||
|
const firstValue = slots[0].value;
|
||||||
|
return slots.every((slot) => slot.value === firstValue);
|
||||||
|
}
|
||||||
|
|
||||||
export interface SlotWithValue {
|
export interface SlotWithValue {
|
||||||
start: string | Date;
|
start: string | Date;
|
||||||
value: number;
|
value: number;
|
||||||
|
|
|
||||||
|
|
@ -41,19 +41,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="row">
|
<div v-else class="row">
|
||||||
<main class="col-12">
|
<main class="col-12 d-flex flex-column">
|
||||||
<section v-if="forecast.solar" class="mb-5">
|
<section v-if="forecast.solar" class="mb-5">
|
||||||
<div class="d-flex flex-wrap gap-3 align-items-baseline my-4">
|
<div class="d-flex align-items-baseline my-4">
|
||||||
<h3
|
<h3 class="fw-normal mb-0">{{ $t("forecast.type.solar") }}</h3>
|
||||||
class="fw-normal mb-0 d-flex gap-3 flex-wrap align-items-baseline overflow-hidden"
|
|
||||||
>
|
|
||||||
<span class="d-block text-nowrap text-truncate">{{
|
|
||||||
$t("forecast.type.solar")
|
|
||||||
}}</span>
|
|
||||||
<small v-if="solarSubtitle" class="d-block text-nowrap text-truncate">{{
|
|
||||||
solarSubtitle
|
|
||||||
}}</small>
|
|
||||||
</h3>
|
|
||||||
<div
|
<div
|
||||||
v-if="showSolarAdjust"
|
v-if="showSolarAdjust"
|
||||||
class="form-check form-switch ms-auto mb-0 text-nowrap"
|
class="form-check form-switch ms-auto mb-0 text-nowrap"
|
||||||
|
|
@ -67,32 +58,31 @@
|
||||||
@change="changeAdjusted"
|
@change="changeAdjusted"
|
||||||
/>
|
/>
|
||||||
<label class="form-check-label text-muted" for="solarForecastAdjust">
|
<label class="form-check-label text-muted" for="solarForecastAdjust">
|
||||||
{{ solarAdjustText }}
|
<span class="d-md-none">{{ solarAdjustTextShort }}</span>
|
||||||
|
<span class="d-none d-md-inline">{{ solarAdjustTextMedium }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SolarChart
|
<div class="chart-edge">
|
||||||
:solar="solar"
|
<SolarChart
|
||||||
:raw-solar="forecast.solar"
|
:solar="solar"
|
||||||
:chart-width="chartWidth"
|
:raw-solar="forecast.solar"
|
||||||
:end-date="chartEndDate"
|
:chart-width="chartWidth"
|
||||||
:scroll-left="scrollLeft"
|
:end-date="chartEndDate"
|
||||||
@scroll="onChartScroll"
|
:scroll-left="scrollLeft"
|
||||||
/>
|
@scroll="onChartScroll"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SolarDetails :solar="solar" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="forecast.grid" class="mb-5">
|
<section
|
||||||
<div class="d-flex flex-wrap gap-3 align-items-baseline my-4">
|
v-if="forecast.grid"
|
||||||
<h3
|
class="mb-5"
|
||||||
class="fw-normal mb-0 d-flex gap-3 flex-wrap align-items-baseline overflow-hidden"
|
:style="isGridStatic ? { order: 1 } : undefined"
|
||||||
>
|
>
|
||||||
<span class="d-block text-nowrap text-truncate">{{
|
<div class="d-flex align-items-baseline my-4">
|
||||||
$t("forecast.type.price")
|
<h3 class="fw-normal mb-0">{{ $t("forecast.type.price") }}</h3>
|
||||||
}}</span>
|
|
||||||
<small v-if="priceSubtitle" class="d-block text-nowrap text-truncate">{{
|
|
||||||
priceSubtitle
|
|
||||||
}}</small>
|
|
||||||
</h3>
|
|
||||||
<div class="form-check form-switch ms-auto mb-0 text-nowrap">
|
<div class="form-check form-switch ms-auto mb-0 text-nowrap">
|
||||||
<input
|
<input
|
||||||
id="priceZoom"
|
id="priceZoom"
|
||||||
|
|
@ -107,35 +97,39 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<PriceChart
|
<div class="chart-edge">
|
||||||
|
<PriceChart
|
||||||
|
:grid="forecast.grid"
|
||||||
|
:feedin="showFeedin ? forecast.feedin : undefined"
|
||||||
|
:currency="currency"
|
||||||
|
:zoom="priceZoom"
|
||||||
|
:chart-width="chartWidth"
|
||||||
|
:end-date="chartEndDate"
|
||||||
|
:scroll-left="scrollLeft"
|
||||||
|
@scroll="onChartScroll"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<GridDetails
|
||||||
:grid="forecast.grid"
|
:grid="forecast.grid"
|
||||||
|
:feedin="forecast.feedin"
|
||||||
:currency="currency"
|
:currency="currency"
|
||||||
:zoom="priceZoom"
|
:show-feedin="showFeedin"
|
||||||
:chart-width="chartWidth"
|
@toggle-feedin="toggleFeedin"
|
||||||
:end-date="chartEndDate"
|
|
||||||
:scroll-left="scrollLeft"
|
|
||||||
@scroll="onChartScroll"
|
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="forecast.co2">
|
<section v-if="forecast.co2" class="mb-5">
|
||||||
<h3
|
<h3 class="fw-normal my-4">{{ $t("forecast.type.co2") }}</h3>
|
||||||
class="fw-normal my-4 d-flex gap-3 flex-wrap align-items-baseline overflow-hidden"
|
<div class="chart-edge">
|
||||||
>
|
<Co2Chart
|
||||||
<span class="d-block text-nowrap text-truncate">{{
|
:co2="forecast.co2"
|
||||||
$t("forecast.type.co2")
|
:chart-width="chartWidth"
|
||||||
}}</span>
|
:end-date="chartEndDate"
|
||||||
<small v-if="co2Subtitle" class="d-block text-nowrap text-truncate">{{
|
:scroll-left="scrollLeft"
|
||||||
co2Subtitle
|
@scroll="onChartScroll"
|
||||||
}}</small>
|
/>
|
||||||
</h3>
|
</div>
|
||||||
<Co2Chart
|
<Co2Details :co2="forecast.co2" />
|
||||||
:co2="forecast.co2"
|
|
||||||
:chart-width="chartWidth"
|
|
||||||
:end-date="chartEndDate"
|
|
||||||
:scroll-left="scrollLeft"
|
|
||||||
@scroll="onChartScroll"
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -149,17 +143,18 @@ import { defineComponent } from "vue";
|
||||||
import Header from "../components/Top/Header.vue";
|
import Header from "../components/Top/Header.vue";
|
||||||
import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue";
|
import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue";
|
||||||
import SolarChart from "../components/Forecast/SolarChart.vue";
|
import SolarChart from "../components/Forecast/SolarChart.vue";
|
||||||
|
import SolarDetails from "../components/Forecast/SolarDetails.vue";
|
||||||
import PriceChart from "../components/Forecast/PriceChart.vue";
|
import PriceChart from "../components/Forecast/PriceChart.vue";
|
||||||
|
import GridDetails from "../components/Forecast/GridDetails.vue";
|
||||||
import Co2Chart from "../components/Forecast/Co2Chart.vue";
|
import Co2Chart from "../components/Forecast/Co2Chart.vue";
|
||||||
import formatter, { POWER_UNIT } from "@/mixins/formatter";
|
import Co2Details from "../components/Forecast/Co2Details.vue";
|
||||||
|
import formatter from "@/mixins/formatter";
|
||||||
import settings from "@/settings";
|
import settings from "@/settings";
|
||||||
import store from "../store";
|
import store from "../store";
|
||||||
import { adjustedSolar, ForecastType } from "@/utils/forecast";
|
import { adjustedSolar, ForecastType, isStaticTariff } from "@/utils/forecast";
|
||||||
import type { ForecastSlot } from "../components/Forecast/types";
|
|
||||||
|
|
||||||
const MIN_HOURS = 76;
|
const MIN_HOURS = 76;
|
||||||
const MAX_HOURS = 96;
|
const MAX_HOURS = 96;
|
||||||
const SLOTS_PER_HOUR = 4;
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: "Forecast",
|
name: "Forecast",
|
||||||
|
|
@ -167,8 +162,11 @@ export default defineComponent({
|
||||||
TopHeader: Header,
|
TopHeader: Header,
|
||||||
DynamicPriceIcon,
|
DynamicPriceIcon,
|
||||||
SolarChart,
|
SolarChart,
|
||||||
|
SolarDetails,
|
||||||
PriceChart,
|
PriceChart,
|
||||||
|
GridDetails,
|
||||||
Co2Chart,
|
Co2Chart,
|
||||||
|
Co2Details,
|
||||||
},
|
},
|
||||||
mixins: [formatter],
|
mixins: [formatter],
|
||||||
data() {
|
data() {
|
||||||
|
|
@ -233,6 +231,17 @@ export default defineComponent({
|
||||||
priceZoom() {
|
priceZoom() {
|
||||||
return settings.priceZoom;
|
return settings.priceZoom;
|
||||||
},
|
},
|
||||||
|
showFeedin() {
|
||||||
|
return !settings.hideFeedin;
|
||||||
|
},
|
||||||
|
isGridStatic(): boolean {
|
||||||
|
if (!this.forecast.grid) return false;
|
||||||
|
if (!isStaticTariff(this.forecast.grid)) return false;
|
||||||
|
if (this.forecast.feedin?.length) {
|
||||||
|
return isStaticTariff(this.forecast.feedin);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
showSolarAdjust() {
|
showSolarAdjust() {
|
||||||
return !!this.forecast.solar && this.experimental;
|
return !!this.forecast.solar && this.experimental;
|
||||||
},
|
},
|
||||||
|
|
@ -241,48 +250,16 @@ export default defineComponent({
|
||||||
? adjustedSolar(this.forecast.solar)
|
? adjustedSolar(this.forecast.solar)
|
||||||
: this.forecast.solar;
|
: this.forecast.solar;
|
||||||
},
|
},
|
||||||
solarAdjustText() {
|
solarAdjustPercent(): string {
|
||||||
const text = this.$t("forecast.solarAdjustShort");
|
|
||||||
const scale = this.forecast.solar?.scale || 1;
|
const scale = this.forecast.solar?.scale || 1;
|
||||||
const percentDiff = scale * 100 - 100;
|
const percentDiff = scale * 100 - 100;
|
||||||
return `${text} (${this.fmtPercentage(percentDiff, 0, true)})`;
|
return this.fmtPercentage(percentDiff, 0, true);
|
||||||
},
|
},
|
||||||
solarSubtitle(): string {
|
solarAdjustTextShort(): string {
|
||||||
const s = this.solar;
|
return `${this.$t("forecast.solarAdjustShort")} (${this.solarAdjustPercent})`;
|
||||||
if (!s) return "";
|
|
||||||
const today = s.today?.energy ? this.fmtWh(s.today.energy, POWER_UNIT.AUTO) : "";
|
|
||||||
const tomorrow = s.tomorrow?.energy
|
|
||||||
? this.fmtWh(s.tomorrow.energy, POWER_UNIT.AUTO)
|
|
||||||
: "";
|
|
||||||
if (!today && !tomorrow) return "";
|
|
||||||
const parts = [];
|
|
||||||
if (today) parts.push(`${today} ${this.$t("forecast.solar.remaining")}`);
|
|
||||||
if (tomorrow) parts.push(`${tomorrow} ${this.$t("forecast.solar.tomorrow")}`);
|
|
||||||
return parts.join(" ・ ");
|
|
||||||
},
|
},
|
||||||
priceSubtitle(): string {
|
solarAdjustTextMedium(): string {
|
||||||
const slots = this.upcomingSlots(this.forecast.grid);
|
return `${this.$t("forecast.solarAdjustMedium")} (${this.solarAdjustPercent})`;
|
||||||
if (slots.length === 0) return "";
|
|
||||||
const values = slots.map((s) => s.value);
|
|
||||||
const min = Math.min(...values);
|
|
||||||
const max = Math.max(...values);
|
|
||||||
const avg = values.reduce((a, b) => a + b, 0) / values.length;
|
|
||||||
const fmtMin = this.fmtPricePerKWh(min, this.currency, false, false);
|
|
||||||
const fmtMax = this.fmtPricePerKWh(max, this.currency, false, true);
|
|
||||||
const fmtAvg = this.fmtPricePerKWh(avg, this.currency, false, true);
|
|
||||||
return `⌀ ${fmtAvg} ・ ${fmtMin} – ${fmtMax}`;
|
|
||||||
},
|
|
||||||
co2Subtitle(): string {
|
|
||||||
const slots = this.upcomingSlots(this.forecast.co2);
|
|
||||||
if (slots.length === 0) return "";
|
|
||||||
const values = slots.map((s) => s.value);
|
|
||||||
const min = Math.min(...values);
|
|
||||||
const max = Math.max(...values);
|
|
||||||
const avg = values.reduce((a, b) => a + b, 0) / values.length;
|
|
||||||
const fmtMin = this.fmtNumber(min, 0);
|
|
||||||
const fmtMax = this.fmtCo2Medium(max);
|
|
||||||
const fmtAvg = this.fmtCo2Medium(avg);
|
|
||||||
return `⌀ ${fmtAvg} ・ ${fmtMin} – ${fmtMax}`;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
@ -292,6 +269,9 @@ export default defineComponent({
|
||||||
togglePriceZoom() {
|
togglePriceZoom() {
|
||||||
settings.priceZoom = !settings.priceZoom;
|
settings.priceZoom = !settings.priceZoom;
|
||||||
},
|
},
|
||||||
|
toggleFeedin() {
|
||||||
|
settings.hideFeedin = !settings.hideFeedin;
|
||||||
|
},
|
||||||
onChartScroll(val: number) {
|
onChartScroll(val: number) {
|
||||||
if (this.isScrolling) return;
|
if (this.isScrolling) return;
|
||||||
this.isScrolling = true;
|
this.isScrolling = true;
|
||||||
|
|
@ -300,13 +280,6 @@ export default defineComponent({
|
||||||
this.isScrolling = false;
|
this.isScrolling = false;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
upcomingSlots(slots?: ForecastSlot[]): ForecastSlot[] {
|
|
||||||
if (!Array.isArray(slots)) return [];
|
|
||||||
const now = new Date();
|
|
||||||
return slots
|
|
||||||
.filter((slot) => new Date(slot.end) > now)
|
|
||||||
.slice(0, MAX_HOURS * SLOTS_PER_HOUR);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -321,4 +294,10 @@ export default defineComponent({
|
||||||
border-radius: 2rem;
|
border-radius: 2rem;
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
.chart-edge {
|
||||||
|
margin-left: -1.5rem;
|
||||||
|
margin-right: -1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
11
i18n/en.json
11
i18n/en.json
|
|
@ -796,8 +796,8 @@
|
||||||
"titleChoice": "What Do You Want To Add?",
|
"titleChoice": "What Do You Want To Add?",
|
||||||
"type": {
|
"type": {
|
||||||
"co2": "CO₂ Intensity",
|
"co2": "CO₂ Intensity",
|
||||||
"feedIn": "Feed-In Price",
|
"feedIn": "Grid Export Price",
|
||||||
"grid": "Grid Consumption Price",
|
"grid": "Grid Import Price",
|
||||||
"planner": "Planner",
|
"planner": "Planner",
|
||||||
"solar": "Solar"
|
"solar": "Solar"
|
||||||
},
|
},
|
||||||
|
|
@ -972,6 +972,7 @@
|
||||||
"setup": "Set up tariffs and forecasts",
|
"setup": "Set up tariffs and forecasts",
|
||||||
"solar": "See expected solar production for today and the coming days. Will also be used for automatic charging optimization in the future."
|
"solar": "See expected solar production for today and the coming days. Will also be used for automatic charging optimization in the future."
|
||||||
},
|
},
|
||||||
|
"hideLine": "hide line",
|
||||||
"modalTitle": "Forecast",
|
"modalTitle": "Forecast",
|
||||||
"price": {
|
"price": {
|
||||||
"average": "Average",
|
"average": "Average",
|
||||||
|
|
@ -980,6 +981,7 @@
|
||||||
"range": "Range"
|
"range": "Range"
|
||||||
},
|
},
|
||||||
"priceZoom": "zoom in",
|
"priceZoom": "zoom in",
|
||||||
|
"showLine": "show line",
|
||||||
"solar": {
|
"solar": {
|
||||||
"dayAfterTomorrow": "Day after tomorrow",
|
"dayAfterTomorrow": "Day after tomorrow",
|
||||||
"partly": "partly",
|
"partly": "partly",
|
||||||
|
|
@ -988,7 +990,8 @@
|
||||||
"tomorrow": "Tomorrow"
|
"tomorrow": "Tomorrow"
|
||||||
},
|
},
|
||||||
"solarAdjust": "Adjust solar forecast based on real production data{percent}.",
|
"solarAdjust": "Adjust solar forecast based on real production data{percent}.",
|
||||||
"solarAdjustShort": "real data adjust",
|
"solarAdjustMedium": "real data adjust",
|
||||||
|
"solarAdjustShort": "adjust",
|
||||||
"type": {
|
"type": {
|
||||||
"co2": "CO₂ Emissions",
|
"co2": "CO₂ Emissions",
|
||||||
"price": "Grid Price",
|
"price": "Grid Price",
|
||||||
|
|
@ -1153,7 +1156,7 @@
|
||||||
"batteryTooltip": "{energy} of {total} ({soc})",
|
"batteryTooltip": "{energy} of {total} ({soc})",
|
||||||
"forecast": "Forecast: ",
|
"forecast": "Forecast: ",
|
||||||
"forecastTooltip": "forecast: remaining solar production today",
|
"forecastTooltip": "forecast: remaining solar production today",
|
||||||
"gridImport": "Grid use",
|
"gridImport": "Grid import",
|
||||||
"homePower": "Consumption",
|
"homePower": "Consumption",
|
||||||
"loadpoints": "Charger| Charger | {count} chargers",
|
"loadpoints": "Charger| Charger | {count} chargers",
|
||||||
"loadpointsLimit": "{limit} limit",
|
"loadpointsLimit": "{limit} limit",
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ test.describe("grid meter", async () => {
|
||||||
// check in main ui
|
// check in main ui
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.getByTestId("visualization").click();
|
await page.getByTestId("visualization").click();
|
||||||
await expect(page.getByTestId("energyflow")).toContainText(["Grid use", "5.0 kW"].join(""));
|
await expect(page.getByTestId("energyflow")).toContainText(["Grid import", "5.0 kW"].join(""));
|
||||||
|
|
||||||
// delete #1
|
// delete #1
|
||||||
await page.goto("/#/config");
|
await page.goto("/#/config");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue