Forecast UI: add feed-in, edge-to-edge layout, design improvements (#29090)

This commit is contained in:
Michael Geers 2026-04-14 16:46:59 +02:00 • committed by GitHub
parent 794a26d916
commit 812ffa47b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 484 additions and 178 deletions

View file

@ -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);
}

View file

@ -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");

View file

@ -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,

View 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>

View 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>

View file

@ -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>

View file

@ -103,7 +103,7 @@ export default defineComponent({
{
type: "line",
data,
smooth: 0.05,
smooth: true,
symbol: "circle",
symbolSize: 6,
showSymbol: false,

View 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>

View file

@ -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;

View file

@ -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,

View file

@ -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, {

View file

@ -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));

View file

@ -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);
});
});

View file

@ -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;

View file

@ -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,32 +58,31 @@
@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>
<SolarChart
:solar="solar"
:raw-solar="forecast.solar"
:chart-width="chartWidth"
:end-date="chartEndDate"
:scroll-left="scrollLeft"
@scroll="onChartScroll"
/>
<div class="chart-edge">
<SolarChart
:solar="solar"
:raw-solar="forecast.solar"
:chart-width="chartWidth"
:end-date="chartEndDate"
: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"
>
<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>
<section
v-if="forecast.grid"
class="mb-5"
:style="isGridStatic ? { order: 1 } : undefined"
>
<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,35 +97,39 @@
</label>
</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"
:feedin="forecast.feedin"
:currency="currency"
:zoom="priceZoom"
:chart-width="chartWidth"
:end-date="chartEndDate"
:scroll-left="scrollLeft"
@scroll="onChartScroll"
: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>
<Co2Chart
:co2="forecast.co2"
:chart-width="chartWidth"
:end-date="chartEndDate"
:scroll-left="scrollLeft"
@scroll="onChartScroll"
/>
<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"
:end-date="chartEndDate"
: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>

View file

@ -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",

View file

@ -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");