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-rgb: 7, 111, 32;
|
||||
--evcc-yellow: #faf000;
|
||||
--evcc-dark-yellow: #bbb400;
|
||||
--evcc-dark-yellow: #f6bb0f;
|
||||
--evcc-orange: #ff9000;
|
||||
--evcc-orange-rgb: 255, 144, 0;
|
||||
--evcc-red: #fc440f;
|
||||
|
|
@ -39,6 +39,7 @@
|
|||
--evcc-pv: var(--evcc-dark-green);
|
||||
--evcc-battery: var(--evcc-darker-green);
|
||||
--evcc-export: var(--evcc-yellow);
|
||||
--evcc-export-contrast: var(--evcc-dark-yellow);
|
||||
--evcc-price: #ff912fff;
|
||||
--evcc-co2: #00916eff;
|
||||
--evcc-background: var(--bs-gray-bright);
|
||||
|
|
@ -100,6 +101,8 @@
|
|||
|
||||
:root.dark {
|
||||
--evcc-grid: var(--bs-gray-medium);
|
||||
--evcc-export: var(--evcc-yellow);
|
||||
--evcc-export-contrast: var(--evcc-yellow);
|
||||
--evcc-background: var(--bs-gray-deep);
|
||||
--evcc-box: var(--bs-gray-dark);
|
||||
--evcc-box-border: var(--bs-gray-darker);
|
||||
|
|
@ -186,6 +189,10 @@ h5 {
|
|||
color: var(--evcc-co2) !important;
|
||||
}
|
||||
|
||||
.text-export {
|
||||
color: var(--evcc-export-contrast) !important;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const colors: {
|
|||
pricePerKWh: string | null;
|
||||
price: string | null;
|
||||
co2: string | null;
|
||||
export: string | null;
|
||||
background: string | null;
|
||||
light: string | null;
|
||||
selfPalette: string[];
|
||||
|
|
@ -31,6 +32,7 @@ const colors: {
|
|||
pricePerKWh: null,
|
||||
price: null,
|
||||
co2: null,
|
||||
export: null,
|
||||
background: null,
|
||||
light: null,
|
||||
selfPalette: ["#0FDE41FF", "#FFBD2FFF", "#FD6158FF", "#03C1EFFF", "#0F662DFF", "#FF922EFF"],
|
||||
|
|
@ -75,6 +77,7 @@ export function updateCssColors() {
|
|||
colors.grid = style.getPropertyValue("--evcc-grid");
|
||||
colors.price = style.getPropertyValue("--evcc-price");
|
||||
colors.co2 = style.getPropertyValue("--evcc-co2");
|
||||
colors.export = style.getPropertyValue("--evcc-export-contrast");
|
||||
colors.background = style.getPropertyValue("--evcc-background");
|
||||
colors.pricePerKWh = style.getPropertyValue("--bs-gray-medium");
|
||||
colors.co2PerKWh = style.getPropertyValue("--bs-gray-medium");
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ export default defineComponent({
|
|||
{
|
||||
type: "line",
|
||||
data: this.slots.map((s) => [s.start, s.value]),
|
||||
smooth: 0.05,
|
||||
smooth: true,
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
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">
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import {
|
||||
echarts,
|
||||
FONT_FAMILY,
|
||||
markPointLabel,
|
||||
tooltipStyle,
|
||||
|
|
@ -18,7 +19,7 @@ import {
|
|||
minSlotIndex,
|
||||
maxSlotIndex,
|
||||
} from "./echarts";
|
||||
import colors from "@/colors";
|
||||
import colors, { lighterColor } from "@/colors";
|
||||
import formatter from "@/mixins/formatter";
|
||||
import chartMixin from "./chartMixin";
|
||||
import type { CURRENCY } from "@/types/evcc";
|
||||
|
|
@ -29,18 +30,19 @@ export default defineComponent({
|
|||
mixins: [formatter, chartMixin],
|
||||
props: {
|
||||
grid: { type: Array as PropType<ForecastSlot[]>, required: true },
|
||||
feedin: { type: Array as PropType<ForecastSlot[]> },
|
||||
currency: { type: String as PropType<CURRENCY> },
|
||||
zoom: { type: Boolean, default: false },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hoveredIndex: -1 as number,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
slots(): ForecastSlot[] {
|
||||
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 }[] {
|
||||
const slots = this.slots;
|
||||
if (!slots.length) return [];
|
||||
|
|
@ -62,48 +64,63 @@ export default defineComponent({
|
|||
return points;
|
||||
},
|
||||
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 dataMax = Math.max(...values);
|
||||
const fullMin = Math.min(0, dataMin);
|
||||
const fullRange = dataMax - fullMin || 1;
|
||||
const rawInterval = fullRange / 5;
|
||||
const rangeMin = this.zoom ? dataMin : Math.min(0, dataMin);
|
||||
const rangeMax = Math.max(0, dataMax);
|
||||
const range = rangeMax - rangeMin || 1;
|
||||
const rawInterval = range / 5;
|
||||
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 interval = nice * magnitude;
|
||||
|
||||
if (this.zoom) {
|
||||
return {
|
||||
min: Math.floor(dataMin / interval) * interval,
|
||||
max: Math.ceil(dataMax / interval) * interval,
|
||||
interval,
|
||||
};
|
||||
}
|
||||
return {
|
||||
min: fullMin,
|
||||
max: Math.ceil(dataMax / interval) * interval,
|
||||
min: Math.floor(rangeMin / interval) * interval,
|
||||
max: Math.ceil(rangeMax / interval) * interval,
|
||||
interval,
|
||||
};
|
||||
},
|
||||
chartOption(): Record<string, unknown> {
|
||||
const priceColor = colors.price || "";
|
||||
const exportColor = colors.export || "";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
const vThis = this;
|
||||
return {
|
||||
animationDuration: 0,
|
||||
animationDurationUpdate: 300,
|
||||
textStyle: { fontFamily: FONT_FAMILY },
|
||||
grid: forecastGrid(),
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: { type: "line", snap: true, lineStyle: { color: "transparent" } },
|
||||
...tooltipStyle(priceColor, () => this.chart),
|
||||
formatter(params: { value: [string, number] }[]) {
|
||||
formatter(params: { value: [string, number]; seriesIndex: number }[]) {
|
||||
const p = params[0];
|
||||
if (!p) return "";
|
||||
const d = new Date(p.value[0]);
|
||||
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),
|
||||
|
|
@ -121,46 +138,56 @@ export default defineComponent({
|
|||
},
|
||||
}),
|
||||
series: [
|
||||
{
|
||||
type: "bar",
|
||||
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
|
||||
),
|
||||
},
|
||||
this.priceSeries(this.slots, priceColor, this.markPoints),
|
||||
this.priceSeries(this.feedinSlots, exportColor),
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.chart!.on("showTip", (params: unknown) => {
|
||||
const p = params as { dataIndex?: number };
|
||||
if (p.dataIndex != null) this.hoveredIndex = p.dataIndex;
|
||||
});
|
||||
this.chart!.on("hideTip", () => {
|
||||
this.hoveredIndex = -1;
|
||||
});
|
||||
this.chart!.getZr().on("mouseout", () => {
|
||||
this.tooltipVisible = false;
|
||||
this.hoveredIndex = -1;
|
||||
});
|
||||
methods: {
|
||||
priceSeries(
|
||||
slots: ForecastSlot[],
|
||||
color: string,
|
||||
points?: { coord: [string, number]; value: string }[]
|
||||
): Record<string, unknown> {
|
||||
const avg = slots.length ? slots.reduce((a, s) => a + s.value, 0) / slots.length : 0;
|
||||
const gradientDown = avg >= 0;
|
||||
return {
|
||||
type: "line",
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ export default defineComponent({
|
|||
{
|
||||
type: "line",
|
||||
data,
|
||||
smooth: 0.05,
|
||||
smooth: true,
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
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 {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export function markPointLabel(
|
|||
animation: true,
|
||||
clip: true,
|
||||
symbol: "rect",
|
||||
symbolSize: 1,
|
||||
symbolSize: 0,
|
||||
label: {
|
||||
show: true,
|
||||
position: "top",
|
||||
|
|
@ -63,7 +63,7 @@ export function markPointLabel(
|
|||
color: colors.background,
|
||||
backgroundColor: color,
|
||||
borderRadius: 4,
|
||||
padding: [5, 10],
|
||||
padding: [5, 15],
|
||||
offset: [0, -2],
|
||||
formatter: (p: { data: { value: string } }) => p.data.value,
|
||||
},
|
||||
|
|
@ -111,7 +111,7 @@ export function tooltipStyle(
|
|||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -167,13 +167,13 @@ export function forecastYAxis(overrides: Record<string, unknown> = {}) {
|
|||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: {
|
||||
showMinLine: false,
|
||||
showMinLine: true,
|
||||
showMaxLine: false,
|
||||
lineStyle: { color: colors.border || "" },
|
||||
},
|
||||
axisLabel: {
|
||||
fontSize: 10,
|
||||
opacity: 0.5,
|
||||
opacity: 0.75,
|
||||
...(axisLabel as Record<string, unknown>),
|
||||
},
|
||||
...rest,
|
||||
|
|
|
|||
|
|
@ -221,6 +221,11 @@ export default defineComponent({
|
|||
weekday: "short",
|
||||
}).format(date);
|
||||
},
|
||||
weekdayLong(date: Date) {
|
||||
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||
weekday: "long",
|
||||
}).format(date);
|
||||
},
|
||||
fmtAbsoluteDate(date: Date) {
|
||||
const weekday = this.weekdayPrefix(date);
|
||||
const hour = new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const SESSIONS_GROUP = "sessions_group";
|
|||
const SESSIONS_TYPE = "sessions_type";
|
||||
const SETTINGS_SOLAR_ADJUSTED = "settings_solar_adjusted";
|
||||
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_TARGET_TIME = "last_target_time";
|
||||
const LAST_SOC_GOAL = "last_soc_goal";
|
||||
|
|
@ -124,6 +125,7 @@ export interface Settings {
|
|||
sessionsType: string;
|
||||
solarAdjusted: boolean;
|
||||
priceZoom: boolean;
|
||||
hideFeedin: boolean;
|
||||
loadpoints: Record<string, LoadpointSettings>;
|
||||
lastBatterySmartCostLimit: number | undefined;
|
||||
lastTargetTime: string | null;
|
||||
|
|
@ -151,6 +153,7 @@ const settings: Settings = reactive({
|
|||
sessionsType: read(SESSIONS_TYPE),
|
||||
solarAdjusted: readBool(SETTINGS_SOLAR_ADJUSTED),
|
||||
priceZoom: readBool(SETTINGS_PRICE_ZOOM),
|
||||
hideFeedin: readBool(SETTINGS_HIDE_FEEDIN),
|
||||
loadpoints: readJSON(LOADPOINTS),
|
||||
lastBatterySmartCostLimit: readNumber(LAST_BATTERY_SMART_COST_LIMIT),
|
||||
lastTargetTime: read(LAST_TARGET_TIME),
|
||||
|
|
@ -177,6 +180,7 @@ watch(() => settings.sessionsGroup, save(SESSIONS_GROUP));
|
|||
watch(() => settings.sessionsType, save(SESSIONS_TYPE));
|
||||
watch(() => settings.solarAdjusted, saveBool(SETTINGS_SOLAR_ADJUSTED));
|
||||
watch(() => settings.priceZoom, saveBool(SETTINGS_PRICE_ZOOM));
|
||||
watch(() => settings.hideFeedin, saveBool(SETTINGS_HIDE_FEEDIN));
|
||||
watch(() => settings.loadpoints, saveJSON(LOADPOINTS), { deep: true });
|
||||
watch(() => settings.lastBatterySmartCostLimit, saveNumber(LAST_BATTERY_SMART_COST_LIMIT));
|
||||
watch(() => settings.lastTargetTime, save(LAST_TARGET_TIME));
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import { findLowestSumSlotIndex } from "./forecast";
|
||||
import { findLowestSumSlotIndex, isStaticTariff } from "./forecast";
|
||||
|
||||
describe("findLowestSumSlotIndex", () => {
|
||||
test("finds lowest sum with span of 4", () => {
|
||||
|
|
@ -55,3 +55,31 @@ describe("findLowestSumSlotIndex", () => {
|
|||
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";
|
||||
|
||||
export enum ForecastType {
|
||||
|
|
@ -65,6 +65,12 @@ export function adjustedSolar(solar?: SolarDetails): SolarDetails | undefined {
|
|||
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 {
|
||||
start: string | Date;
|
||||
value: number;
|
||||
|
|
|
|||
|
|
@ -41,19 +41,10 @@
|
|||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<div class="d-flex flex-wrap gap-3 align-items-baseline my-4">
|
||||
<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 class="d-flex align-items-baseline my-4">
|
||||
<h3 class="fw-normal mb-0">{{ $t("forecast.type.solar") }}</h3>
|
||||
<div
|
||||
v-if="showSolarAdjust"
|
||||
class="form-check form-switch ms-auto mb-0 text-nowrap"
|
||||
|
|
@ -67,10 +58,12 @@
|
|||
@change="changeAdjusted"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-edge">
|
||||
<SolarChart
|
||||
:solar="solar"
|
||||
:raw-solar="forecast.solar"
|
||||
|
|
@ -79,20 +72,17 @@
|
|||
:scroll-left="scrollLeft"
|
||||
@scroll="onChartScroll"
|
||||
/>
|
||||
</div>
|
||||
<SolarDetails :solar="solar" />
|
||||
</section>
|
||||
|
||||
<section v-if="forecast.grid" class="mb-5">
|
||||
<div class="d-flex flex-wrap gap-3 align-items-baseline my-4">
|
||||
<h3
|
||||
class="fw-normal mb-0 d-flex gap-3 flex-wrap align-items-baseline overflow-hidden"
|
||||
<section
|
||||
v-if="forecast.grid"
|
||||
class="mb-5"
|
||||
:style="isGridStatic ? { order: 1 } : undefined"
|
||||
>
|
||||
<span class="d-block text-nowrap text-truncate">{{
|
||||
$t("forecast.type.price")
|
||||
}}</span>
|
||||
<small v-if="priceSubtitle" class="d-block text-nowrap text-truncate">{{
|
||||
priceSubtitle
|
||||
}}</small>
|
||||
</h3>
|
||||
<div class="d-flex align-items-baseline my-4">
|
||||
<h3 class="fw-normal mb-0">{{ $t("forecast.type.price") }}</h3>
|
||||
<div class="form-check form-switch ms-auto mb-0 text-nowrap">
|
||||
<input
|
||||
id="priceZoom"
|
||||
|
|
@ -107,8 +97,10 @@
|
|||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-edge">
|
||||
<PriceChart
|
||||
:grid="forecast.grid"
|
||||
:feedin="showFeedin ? forecast.feedin : undefined"
|
||||
:currency="currency"
|
||||
:zoom="priceZoom"
|
||||
:chart-width="chartWidth"
|
||||
|
|
@ -116,19 +108,19 @@
|
|||
:scroll-left="scrollLeft"
|
||||
@scroll="onChartScroll"
|
||||
/>
|
||||
</div>
|
||||
<GridDetails
|
||||
:grid="forecast.grid"
|
||||
:feedin="forecast.feedin"
|
||||
:currency="currency"
|
||||
:show-feedin="showFeedin"
|
||||
@toggle-feedin="toggleFeedin"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section v-if="forecast.co2">
|
||||
<h3
|
||||
class="fw-normal my-4 d-flex gap-3 flex-wrap align-items-baseline overflow-hidden"
|
||||
>
|
||||
<span class="d-block text-nowrap text-truncate">{{
|
||||
$t("forecast.type.co2")
|
||||
}}</span>
|
||||
<small v-if="co2Subtitle" class="d-block text-nowrap text-truncate">{{
|
||||
co2Subtitle
|
||||
}}</small>
|
||||
</h3>
|
||||
<section v-if="forecast.co2" class="mb-5">
|
||||
<h3 class="fw-normal my-4">{{ $t("forecast.type.co2") }}</h3>
|
||||
<div class="chart-edge">
|
||||
<Co2Chart
|
||||
:co2="forecast.co2"
|
||||
:chart-width="chartWidth"
|
||||
|
|
@ -136,6 +128,8 @@
|
|||
:scroll-left="scrollLeft"
|
||||
@scroll="onChartScroll"
|
||||
/>
|
||||
</div>
|
||||
<Co2Details :co2="forecast.co2" />
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -149,17 +143,18 @@ import { defineComponent } from "vue";
|
|||
import Header from "../components/Top/Header.vue";
|
||||
import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue";
|
||||
import SolarChart from "../components/Forecast/SolarChart.vue";
|
||||
import SolarDetails from "../components/Forecast/SolarDetails.vue";
|
||||
import PriceChart from "../components/Forecast/PriceChart.vue";
|
||||
import GridDetails from "../components/Forecast/GridDetails.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 store from "../store";
|
||||
import { adjustedSolar, ForecastType } from "@/utils/forecast";
|
||||
import type { ForecastSlot } from "../components/Forecast/types";
|
||||
import { adjustedSolar, ForecastType, isStaticTariff } from "@/utils/forecast";
|
||||
|
||||
const MIN_HOURS = 76;
|
||||
const MAX_HOURS = 96;
|
||||
const SLOTS_PER_HOUR = 4;
|
||||
|
||||
export default defineComponent({
|
||||
name: "Forecast",
|
||||
|
|
@ -167,8 +162,11 @@ export default defineComponent({
|
|||
TopHeader: Header,
|
||||
DynamicPriceIcon,
|
||||
SolarChart,
|
||||
SolarDetails,
|
||||
PriceChart,
|
||||
GridDetails,
|
||||
Co2Chart,
|
||||
Co2Details,
|
||||
},
|
||||
mixins: [formatter],
|
||||
data() {
|
||||
|
|
@ -233,6 +231,17 @@ export default defineComponent({
|
|||
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() {
|
||||
return !!this.forecast.solar && this.experimental;
|
||||
},
|
||||
|
|
@ -241,48 +250,16 @@ export default defineComponent({
|
|||
? adjustedSolar(this.forecast.solar)
|
||||
: this.forecast.solar;
|
||||
},
|
||||
solarAdjustText() {
|
||||
const text = this.$t("forecast.solarAdjustShort");
|
||||
solarAdjustPercent(): string {
|
||||
const scale = this.forecast.solar?.scale || 1;
|
||||
const percentDiff = scale * 100 - 100;
|
||||
return `${text} (${this.fmtPercentage(percentDiff, 0, true)})`;
|
||||
return this.fmtPercentage(percentDiff, 0, true);
|
||||
},
|
||||
solarSubtitle(): string {
|
||||
const s = this.solar;
|
||||
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(" ・ ");
|
||||
solarAdjustTextShort(): string {
|
||||
return `${this.$t("forecast.solarAdjustShort")} (${this.solarAdjustPercent})`;
|
||||
},
|
||||
priceSubtitle(): string {
|
||||
const slots = this.upcomingSlots(this.forecast.grid);
|
||||
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}`;
|
||||
solarAdjustTextMedium(): string {
|
||||
return `${this.$t("forecast.solarAdjustMedium")} (${this.solarAdjustPercent})`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -292,6 +269,9 @@ export default defineComponent({
|
|||
togglePriceZoom() {
|
||||
settings.priceZoom = !settings.priceZoom;
|
||||
},
|
||||
toggleFeedin() {
|
||||
settings.hideFeedin = !settings.hideFeedin;
|
||||
},
|
||||
onChartScroll(val: number) {
|
||||
if (this.isScrolling) return;
|
||||
this.isScrolling = true;
|
||||
|
|
@ -300,13 +280,6 @@ export default defineComponent({
|
|||
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>
|
||||
|
|
@ -321,4 +294,10 @@ export default defineComponent({
|
|||
border-radius: 2rem;
|
||||
max-width: 480px;
|
||||
}
|
||||
@media (max-width: 575.98px) {
|
||||
.chart-edge {
|
||||
margin-left: -1.5rem;
|
||||
margin-right: -1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
11
i18n/en.json
11
i18n/en.json
|
|
@ -796,8 +796,8 @@
|
|||
"titleChoice": "What Do You Want To Add?",
|
||||
"type": {
|
||||
"co2": "CO₂ Intensity",
|
||||
"feedIn": "Feed-In Price",
|
||||
"grid": "Grid Consumption Price",
|
||||
"feedIn": "Grid Export Price",
|
||||
"grid": "Grid Import Price",
|
||||
"planner": "Planner",
|
||||
"solar": "Solar"
|
||||
},
|
||||
|
|
@ -972,6 +972,7 @@
|
|||
"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."
|
||||
},
|
||||
"hideLine": "hide line",
|
||||
"modalTitle": "Forecast",
|
||||
"price": {
|
||||
"average": "Average",
|
||||
|
|
@ -980,6 +981,7 @@
|
|||
"range": "Range"
|
||||
},
|
||||
"priceZoom": "zoom in",
|
||||
"showLine": "show line",
|
||||
"solar": {
|
||||
"dayAfterTomorrow": "Day after tomorrow",
|
||||
"partly": "partly",
|
||||
|
|
@ -988,7 +990,8 @@
|
|||
"tomorrow": "Tomorrow"
|
||||
},
|
||||
"solarAdjust": "Adjust solar forecast based on real production data{percent}.",
|
||||
"solarAdjustShort": "real data adjust",
|
||||
"solarAdjustMedium": "real data adjust",
|
||||
"solarAdjustShort": "adjust",
|
||||
"type": {
|
||||
"co2": "CO₂ Emissions",
|
||||
"price": "Grid Price",
|
||||
|
|
@ -1153,7 +1156,7 @@
|
|||
"batteryTooltip": "{energy} of {total} ({soc})",
|
||||
"forecast": "Forecast: ",
|
||||
"forecastTooltip": "forecast: remaining solar production today",
|
||||
"gridImport": "Grid use",
|
||||
"gridImport": "Grid import",
|
||||
"homePower": "Consumption",
|
||||
"loadpoints": "Charger| Charger | {count} chargers",
|
||||
"loadpointsLimit": "{limit} limit",
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ test.describe("grid meter", async () => {
|
|||
// check in main ui
|
||||
await page.goto("/");
|
||||
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
|
||||
await page.goto("/#/config");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue