From 5b03c0c15390c824e63de7a290f8d5840e1fa83e Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 11 Feb 2025 23:56:45 +0100 Subject: [PATCH] wip chart --- assets/js/colors.js | 4 +- .../js/components/Energyflow/Energyflow.vue | 4 +- assets/js/components/ForecastChart.vue | 124 ++++++++++++------ assets/js/components/ForecastModal.vue | 58 ++++++-- assets/js/components/IconSelectItem.vue | 15 ++- assets/js/components/Sessions/chartConfig.js | 1 + assets/js/utils/forecast.ts | 25 ++-- i18n/en.toml | 5 + package-lock.json | 10 ++ package.json | 1 + 10 files changed, 178 insertions(+), 69 deletions(-) diff --git a/assets/js/colors.js b/assets/js/colors.js index cfa7104a47..87e3f7d489 100644 --- a/assets/js/colors.js +++ b/assets/js/colors.js @@ -40,7 +40,7 @@ export const dimColor = (color) => { }; export const lighterColor = (color) => { - return color.toLowerCase().replace(/ff$/, "99"); + return color.toLowerCase().replace(/ff$/, "aa"); }; export const fullColor = (color) => { @@ -63,6 +63,8 @@ function updateCssColors() { // update colors on theme change const darkModeMatcher = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)"); darkModeMatcher?.addEventListener("change", updateCssColors); +// initialize colors updateCssColors(); +window.requestAnimationFrame(updateCssColors); export default colors; diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue index 42c859eec7..4005af037d 100644 --- a/assets/js/components/Energyflow/Energyflow.vue +++ b/assets/js/components/Energyflow/Energyflow.vue @@ -236,7 +236,7 @@ import AnimatedNumber from "../AnimatedNumber.vue"; import settings from "../../settings"; import { CO2_TYPE } from "../../units"; import collector from "../../mixins/collector"; -import { todaysEnergy } from "../../utils/forecast"; +import { energyByDay } from "../../utils/forecast"; export default { name: "Energyflow", components: { @@ -392,7 +392,7 @@ export default { solarForecastToday() { const slots = this.forecast.solar; if (!slots?.length) return null; - return todaysEnergy(slots); + return energyByDay(slots, 0); }, }, watch: { diff --git a/assets/js/components/ForecastChart.vue b/assets/js/components/ForecastChart.vue index 46466b95a8..5a4d01b63c 100644 --- a/assets/js/components/ForecastChart.vue +++ b/assets/js/components/ForecastChart.vue @@ -1,7 +1,9 @@ @@ -21,11 +23,13 @@ import { PointElement, Filler, } from "chart.js"; +import ChartDataLabels from "chartjs-plugin-datalabels"; import "chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm"; import { registerChartComponents, commonOptions } from "./Sessions/chartConfig"; import formatter, { POWER_UNIT } from "../mixins/formatter"; import colors, { lighterColor } from "../colors"; -import type { PriceSlot } from "../utils/forecast"; +import { energyByDay, highestSlotIndexByDay, type PriceSlot } from "../utils/forecast"; + registerChartComponents([ BarController, BarElement, @@ -37,6 +41,7 @@ registerChartComponents([ Legend, Tooltip, PointElement, + ChartDataLabels, ]); export default defineComponent({ @@ -67,45 +72,60 @@ export default defineComponent({ gridSlots() { return this.filterSlots(this.grid); }, + maxPriceIndex() { + return this.gridSlots.reduce((max, slot, index) => { + return slot.price > this.gridSlots[max].price ? index : max; + }, 0); + }, + minPriceIndex() { + return this.gridSlots.reduce((min, slot, index) => { + return slot.price < this.gridSlots[min].price ? index : min; + }, 0); + }, + solarHighlights() { + return [0, 1, 2].map((day) => { + const energy = energyByDay(this.solarSlots, day); + const index = highestSlotIndexByDay(this.solarSlots, day); + return { index, energy }; + }); + }, chartData() { - const datasets = []; + const vThis = this; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const datasets: any[] = []; if (this.solarSlots.length > 0) { datasets.push({ - label: "solar forecast", + label: "solar", type: "line", - data: this.solarSlots.map((slot) => ({ + data: this.solarSlots.map((slot, index) => ({ y: slot.price, x: new Date(slot.start), + highlight: this.solarHighlights.find(({ index: i }) => i === index)?.energy, })), yAxisID: "yForecast", backgroundColor: lighterColor(colors.self), borderColor: colors.self, fill: "origin", - tension: 0.25, + tension: 0.5, pointRadius: 0, - pointHoverRadius: 6, - borderWidth: 2, spanGaps: true, }); } if (this.gridSlots.length > 0) { datasets.push({ - label: "grid price", - data: this.gridSlots.map((slot) => ({ + label: "price", + data: this.gridSlots.map((slot, index) => ({ y: slot.price, x: new Date(slot.start), + highlight: index === this.maxPriceIndex || index === this.minPriceIndex, })), yAxisID: "yPrice", - borderRadius: 2, + borderRadius: 8, backgroundColor: colors.light, borderColor: colors.light, }); } return { - labels: Array.from( - { length: 48 }, - (_, i) => new Date(this.startDate.getTime() + i * 60 * 60 * 1000) - ), datasets, }; }, @@ -114,15 +134,38 @@ export default defineComponent({ return { ...commonOptions, locale: this.$i18n?.locale, + layout: { padding: { top: 32 } }, color: colors.text, borderSkipped: false, - maxBarThickness: 40, animation: false, - interaction: { - mode: "nearest", - }, + categoryPercentage: 0.7, plugins: { ...commonOptions.plugins, + datalabels: { + backgroundColor: function (context) { + return context.dataset.borderColor; + }, + align: "end", + anchor: "end", + borderRadius: 4, + color: "white", + font: { + weight: "bold", + }, + formatter: function (data, ctx) { + if (data.highlight) { + if (ctx.dataset.label === "price") { + return vThis.fmtPricePerKWh(data.y, vThis.currency, true, true); + } + if (ctx.dataset.label === "solar") { + return vThis.fmtWh(data.highlight, POWER_UNIT.AUTO); + } + return null; + } + return null; + }, + padding: 6, + }, tooltip: { ...commonOptions.plugins.tooltip, axis: "x", @@ -175,6 +218,7 @@ export default defineComponent({ grid: { display: true, color: colors.border, + offset: false, lineWidth: function (context) { if (context.type !== "tick") { return 0; @@ -188,15 +232,11 @@ export default defineComponent({ autoSkip: false, maxRotation: 0, minRotation: 0, + source: "data", align: "center", callback: function (value) { const date = new Date(value); const hour = date.getHours(); - const mins = date.getMinutes(); - console.log(date, hour, mins); - if (mins !== 0) { - return ""; - } if (hour === 0) { return [hour, vThis.weekdayShort(date)]; } @@ -208,27 +248,19 @@ export default defineComponent({ }, }, yForecast: { + display: false, border: { display: false }, - grid: { color: colors.border }, - title: { - text: "kW", - display: true, - color: colors.muted, - }, + grid: { color: colors.border, drawOnChartArea: false }, ticks: { - callback: (value) => this.fmtW(value, POWER_UNIT.KW, false), + callback: (value) => this.fmtW(value, POWER_UNIT.KW, true), color: colors.muted, - maxTicksLimit: 6, + maxTicksLimit: 3, }, position: "right", min: 0, }, yPrice: { - title: { - text: this.pricePerKWhUnit(this.currency), - display: true, - color: colors.muted, - }, + display: false, border: { display: false }, grid: { color: colors.border, @@ -236,9 +268,9 @@ export default defineComponent({ }, ticks: { callback: (value) => - this.fmtPricePerKWh(value, this.currency, true, false), + this.fmtPricePerKWh(value, this.currency, true, true), color: colors.muted, - maxTicksLimit: 6, + maxTicksLimit: 3, }, position: "left", }, @@ -253,3 +285,15 @@ export default defineComponent({ }, }); + + diff --git a/assets/js/components/ForecastModal.vue b/assets/js/components/ForecastModal.vue index d8664ac1b9..cd8315a7c7 100644 --- a/assets/js/components/ForecastModal.vue +++ b/assets/js/components/ForecastModal.vue @@ -7,10 +7,28 @@ @open="modalVisible" @closed="modalInvisible" > - +
+ + + + + +
-
Solar
-

{{ solarToday }}
{{ solarTomorrow }}

+ @@ -19,18 +37,28 @@ import { defineComponent } from "vue"; import type { PropType } from "vue"; import GenericModal from "./GenericModal.vue"; import ForecastChart from "./ForecastChart.vue"; -import { todaysEnergy, tomorrowsEnergy, type PriceSlot } from "../utils/forecast"; -import formatter, { POWER_UNIT } from "../mixins/formatter"; +import IconSelectItem from "./IconSelectItem.vue"; +import IconSelectGroup from "./IconSelectGroup.vue"; +import DynamicPriceIcon from "./MaterialIcon/DynamicPrice.vue"; +import { type PriceSlot } from "../utils/forecast"; +import formatter from "../mixins/formatter"; interface Forecast { grid?: PriceSlot[]; solar?: PriceSlot[]; co2?: PriceSlot[]; + currency?: string; } +export const TYPES = { + SOLAR: "solar", + PRICE: "price", + CO2: "co2", +}; + export default defineComponent({ name: "ForecastModal", - components: { GenericModal, ForecastChart }, + components: { GenericModal, ForecastChart, IconSelectItem, IconSelectGroup }, mixins: [formatter], props: { forecast: { type: Object as PropType, default: () => ({}) }, @@ -39,6 +67,8 @@ export default defineComponent({ data: function () { return { isModalVisible: false, + selectedType: TYPES.PRICE, + types: Object.values(TYPES), }; }, computed: { @@ -48,13 +78,12 @@ export default defineComponent({ solarSlots() { return this.forecast?.solar || []; }, - solarToday() { - const energy = this.fmtWh(todaysEnergy(this.solarSlots), POWER_UNIT.KW); - return `${energy} remaining today`; - }, - solarTomorrow() { - const energy = this.fmtWh(tomorrowsEnergy(this.solarSlots), POWER_UNIT.KW); - return `${energy} tomorrow`; + typeIcons() { + return { + [TYPES.SOLAR]: "shopicon-regular-sun", + [TYPES.PRICE]: DynamicPriceIcon, + [TYPES.CO2]: "shopicon-regular-eco1", + }; }, }, methods: { @@ -64,6 +93,9 @@ export default defineComponent({ modalInvisible: function () { this.isModalVisible = false; }, + updateType: function (type: string) { + this.selectedType = type; + }, }, }); diff --git a/assets/js/components/IconSelectItem.vue b/assets/js/components/IconSelectItem.vue index 0a95e12a72..6017295ba5 100644 --- a/assets/js/components/IconSelectItem.vue +++ b/assets/js/components/IconSelectItem.vue @@ -3,12 +3,16 @@ @@ -23,6 +27,7 @@ export default { active: Boolean, label: String, disabled: Boolean, + hideLabelOnMobile: Boolean, }, emits: ["click"], }; @@ -51,4 +56,10 @@ export default { width: auto; padding: 0 1rem; } +@media (max-width: 992px) { + .btn.hideLabelOnMobile { + width: 32px; + padding: 0; + } +} diff --git a/assets/js/components/Sessions/chartConfig.js b/assets/js/components/Sessions/chartConfig.js index a69cbc8ba5..6bde1c0dc7 100644 --- a/assets/js/components/Sessions/chartConfig.js +++ b/assets/js/components/Sessions/chartConfig.js @@ -40,6 +40,7 @@ export const commonOptions = { maintainAspectRatio: false, plugins: { legend: { display: false }, + datalabels: { display: false }, tooltip: { backgroundColor: "#000000cc", boxPadding: 5, diff --git a/assets/js/utils/forecast.ts b/assets/js/utils/forecast.ts index 60390a8043..190700d19f 100644 --- a/assets/js/utils/forecast.ts +++ b/assets/js/utils/forecast.ts @@ -41,17 +41,20 @@ function filterSlotsByDate(slots: PriceSlot[], dayString: string): PriceSlot[] { }); } -// return the energy for today from now on -export function todaysEnergy(slots: PriceSlot[]): number { - const now = new Date(); - const todaysSlots = filterSlotsByDate(slots, toDayString(now)); - return aggregateEnergy(todaysSlots, true); +// return the energy for a given day (0 = today, 1 = tomorrow, etc.) +export function energyByDay(slots: PriceSlot[], day: number = 0): number { + const date = new Date(); + date.setDate(date.getDate() + day); + const daySlots = filterSlotsByDate(slots, toDayString(date)); + return aggregateEnergy(daySlots, true); } -// return the energy for tomorrow -export function tomorrowsEnergy(slots: PriceSlot[]): number { - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - const tomorrowsSlots = filterSlotsByDate(slots, toDayString(tomorrow)); - return aggregateEnergy(tomorrowsSlots, true); +// return the highest slot for a given day (0 = today, 1 = tomorrow, etc.) +export function highestSlotIndexByDay(slots: PriceSlot[], day: number = 0): number { + const date = new Date(); + date.setDate(date.getDate() + day); + const daySlots = filterSlotsByDate(slots, toDayString(date)); + const sortedSlots = daySlots.sort((a, b) => b.price - a.price); + const highestSlot = sortedSlots[0] || {}; + return slots.findIndex((slot) => slot.start === highestSlot.start); } diff --git a/i18n/en.toml b/i18n/en.toml index 6e3a975a66..8cf569da9d 100644 --- a/i18n/en.toml +++ b/i18n/en.toml @@ -436,6 +436,11 @@ modalUpdateStatusStart = "Installation started:" [forecast] modalTitle = "Forecast" +[forecast.type] +co2 = "CO₂" +price = "Price" +solar = "Solar" + [header] about = "About" blog = "Blog" diff --git a/package-lock.json b/package-lock.json index 49c3b668b1..5f6b4eb057 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "canvas-confetti": "^1.4.0", "chart.js": "^4.4.4", "chartjs-adapter-dayjs-4": "^1.0.4", + "chartjs-plugin-datalabels": "^2.2.0", "countup.js": "^2.3.2", "dayjs": "^1.11.13", "eslint": "^9.13.0", @@ -4386,6 +4387,15 @@ "dayjs": "^1.9.7" } }, + "node_modules/chartjs-plugin-datalabels": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz", + "integrity": "sha512-14ZU30lH7n89oq+A4bWaJPnAG8a7ZTk7dKf48YAzMvJjQtjrgg5Dpk9f+LbjCF6bpx3RAGTeL13IXpKQYyRvlw==", + "license": "MIT", + "peerDependencies": { + "chart.js": ">=3.0.0" + } + }, "node_modules/check-error": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", diff --git a/package.json b/package.json index 8ce1181428..f3a325ab79 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "canvas-confetti": "^1.4.0", "chart.js": "^4.4.4", "chartjs-adapter-dayjs-4": "^1.0.4", + "chartjs-plugin-datalabels": "^2.2.0", "countup.js": "^2.3.2", "dayjs": "^1.11.13", "eslint": "^9.13.0",