import * as echarts from "echarts/core"; import colors from "@/colors"; import escapeHtml from "@/utils/escapeHtml"; import type { ForecastSlot } from "@/types/evcc"; import { BarChart, LineChart } from "echarts/charts"; import { GridComponent, TooltipComponent, MarkPointComponent, MarkLineComponent, MarkAreaComponent, GraphicComponent, AxisPointerComponent, } from "echarts/components"; import { SVGRenderer } from "echarts/renderers"; echarts.use([ BarChart, LineChart, GridComponent, TooltipComponent, AxisPointerComponent, MarkPointComponent, MarkLineComponent, MarkAreaComponent, GraphicComponent, SVGRenderer, ]); export const FONT_FAMILY = "Montserrat, sans-serif"; export function markPointLabel( color: string, data: { coord: [string, number]; value: string; label?: { offset?: [number, number] } }[], startDate?: Date, endDate?: Date ) { // threshold: points within the first 5% of the time range get shifted right const threshold = startDate && endDate ? startDate.getTime() + (endDate.getTime() - startDate.getTime()) * 0.05 : 0; const adjustedData = data.map((d) => { const ts = new Date(d.coord[0]).getTime(); if (threshold && ts < threshold) { const baseOffset = d.label?.offset ?? [0, -2]; return { ...d, label: { ...d.label, offset: [baseOffset[0] + 30, baseOffset[1]] as [number, number], }, }; } return d; }); return { animation: true, clip: true, symbol: "rect", symbolSize: 0, label: { show: true, position: "top", fontFamily: FONT_FAMILY, fontSize: 14, fontWeight: "bold" as const, color: colors.background, backgroundColor: color, borderRadius: 4, padding: [5, 15], offset: [0, -2], formatter: (p: { data: { value: string } }) => p.data.value, }, data: adjustedData, }; } export function tooltipStyle( color: string, getChart?: () => { convertToPixel: echarts.ECharts["convertToPixel"] } | null ) { return { confine: true, backgroundColor: color, borderColor: color, borderWidth: 0, padding: [5, 10], extraCssText: "box-shadow: none; border-radius: 4px; text-align: center; z-index: 1000;", position( point: [number, number], params: { value: [string, number] }[] | { value: [string, number] }, el: HTMLElement ): [number, number] { const w = el?.offsetWidth || 0; const h = el?.offsetHeight || 0; const margin = 8; const arr = Array.isArray(params) ? params : [params]; const p = arr[0]; const chart = getChart?.(); if (chart && p?.value) { const pixelY = chart.convertToPixel({ seriesIndex: 0 }, p.value)?.[1]; if (pixelY != null) { return [point[0] - w / 2, pixelY - h - margin]; } } return [point[0] - w / 2, point[1] - h - margin]; }, textStyle: { fontFamily: FONT_FAMILY, fontSize: 14, fontWeight: "bold" as const, color: colors.background, }, }; } export interface TooltipRow { name?: string; values: string[]; total?: boolean; } // Shared tooltip: bold date headline, non-bold rows, optional name + value columns. export function tooltipTable(head: string, rows: TooltipRow[], headers?: string[]): string { const hasName = rows.some((r) => r.name != null); const valueCols = Math.max(1, ...rows.map((r) => r.values.length)); const colCount = (hasName ? 1 : 0) + valueCols; const valClsFn = (i: number): string => { // first of two unnamed value cols if (!hasName && valueCols > 1 && i === 0) return "text-start"; // named or multiple cols if (hasName || valueCols > 1) return "text-end ps-3"; // lone value return "text-center"; }; const headerRow = headers?.length ? `${hasName ? "" : ""}${headers .map((h, i) => `${h}`) .join("")}` : ""; const rowHtml = (r: TooltipRow) => { const cls = r.total ? " pt-1" : ""; const nameTd = hasName ? `${escapeHtml(r.name ?? "")}` : ""; const valTds = r.values .map((v, i) => `${v}`) .join(""); return `${nameTd}${valTds}`; }; const body = rows .filter((r) => !r.total) .map(rowHtml) .join(""); const footRows = rows.filter((r) => r.total); const foot = footRows.length ? `${footRows .map(rowHtml) .join("")}` : ""; return `${headerRow}${body}${foot}
${head}
`; } export function forecastGrid() { return { top: 36, right: 16, bottom: 16, left: 24, borderWidth: 0 }; } // common x-axis label styling across time-based charts export function xAxisLabelStyle() { return { color: colors.muted || "", fontSize: 14, lineHeight: Math.round(14 * 1.1), margin: 4, }; } export function forecastXAxes( startDate: Date | number, endDate: Date | number, hourShort: (d: Date) => string, weekdayShort: (d: Date) => string, stepHours = 4 ) { return [ { type: "time", min: startDate, max: endDate, minInterval: 3600 * 1000, maxInterval: 3600 * 1000, axisLabel: { ...xAxisLabelStyle(), hideOverlap: false, formatter: (value: number) => { const date = new Date(value); const h = date.getHours(); if (h % stepHours !== 0) return ""; const label = hourShort(date); if (h === 0) return `${label}\n${weekdayShort(date)}`; return label; }, }, splitLine: { show: false }, axisLine: { show: false }, axisTick: { show: false }, }, { type: "time", position: "bottom", min: startDate, max: endDate, minInterval: 24 * 3600 * 1000, maxInterval: 24 * 3600 * 1000, axisLabel: { show: false }, axisLine: { show: false }, axisTick: { show: false }, splitLine: { show: true, showMinLine: false, showMaxLine: false, lineStyle: { color: colors.border || "", type: "dashed" }, }, }, ]; } export function forecastYAxis(overrides: Record = {}) { const { axisLabel, ...rest } = overrides; return { type: "value", min: 0, axisLine: { show: false }, axisTick: { show: false }, splitLine: { showMinLine: true, showMaxLine: false, lineStyle: { color: colors.border || "" }, }, axisLabel: { fontSize: 10, opacity: 0.75, ...(axisLabel as Record), }, ...rest, }; } export function clampStart(ts: string, startDate: Date): string { return new Date(ts) < startDate ? startDate.toISOString() : ts; } export function filterForecastSlots( slots: ForecastSlot[], startDate: Date, endDate: Date ): ForecastSlot[] { if (!Array.isArray(slots)) return []; return slots.filter((s) => new Date(s.end) > startDate && new Date(s.start) <= endDate); } export function minSlotIndex(slots: ForecastSlot[]): number { return slots.reduce((min, s, i) => (s.value < (slots[min]?.value ?? Infinity) ? i : min), 0); } export function maxSlotIndex(slots: ForecastSlot[]): number { return slots.reduce((max, s, i) => (s.value > (slots[max]?.value || 0) ? i : max), 0); } export { echarts };