import { useEffect, useMemo, useRef } from 'react' import { Chart } from 'chart.js/auto' import type { ChartArea, TooltipItem } from 'chart.js' import type { SlotData } from '../../types/dashboard' import { CHART_LAYOUT_PADDING } from './chartConstants' import { computeNegWeekendRanges, createNowLinePluginRef, createSlotBackgroundPluginRefs, } from './chartPlugins' const COL = { fve: '#EF9F27', baz: '#378ADD', ev: '#534AB7', tc: '#D4537E', bat: '#1D9E75', sit: '#E24B4A', buy: '#E24B4A', sell: '#1D9E75', } as const function kwFromW(w: number | null | undefined): number | null { if (w == null || Number.isNaN(Number(w))) return null return Number(w) / 1000 } function sumW(a: number | null, b: number | null): number | null { if (a == null && b == null) return null return (a ?? 0) + (b ?? 0) } export type EnergyLegendItem = { key: string label: string color: string dashed?: boolean dashStyle?: 'dashed' | 'dotted' } export const ENERGY_LEGEND: EnergyLegendItem[] = [ { key: 'fve_real', label: 'FVE skutečnost', color: COL.fve }, { key: 'fve_pred', label: 'FVE předpověď', color: COL.fve, dashed: true }, { key: 'fve_corr', label: 'FVE korigovaná', color: COL.fve, dashed: true, dashStyle: 'dotted' }, { key: 'baz_real', label: 'Spotřeba skutečnost', color: COL.baz }, { key: 'baz_pred', label: 'Spotřeba předpověď', color: COL.baz, dashed: true }, { key: 'ev', label: 'EV plán', color: COL.ev }, { key: 'tc', label: 'TČ plán', color: COL.tc }, { key: 'bat', label: 'Baterie', color: COL.bat }, { key: 'sit', label: 'Síť', color: COL.sit }, { key: 'buy_price', label: 'Cena nákup', color: COL.buy, dashed: true }, { key: 'sell_price', label: 'Cena prodej', color: COL.sell, dashed: true }, ] type Props = { slots: SlotData[] nowIndex: number hidden: Set onToggle: (key: string) => void onChartArea?: (area: ChartArea) => void } export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }: Props) { const canvasRef = useRef(null) const chartRef = useRef(null) const onChartAreaRef = useRef(onChartArea) onChartAreaRef.current = onChartArea const slotsRef = useRef([]) const negRangesRef = useRef>([]) const nowIndexRef = useRef(0) const labelsRef = useRef([]) slotsRef.current = slots nowIndexRef.current = nowIndex const labels = useMemo( () => slots.map((s) => { const d = new Date(s.interval_start) return d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Prague', }) }), [slots], ) labelsRef.current = labels const negRanges = useMemo(() => computeNegWeekendRanges(slots, nowIndex), [slots, nowIndex]) negRangesRef.current = negRanges const windowKey = useMemo( () => (slots.length ? `${slots[0]!.interval_start}|${slots.length}` : ''), [slots], ) const series = useMemo(() => { const fveReal = slots.map((s, i) => (i <= nowIndex ? kwFromW(s.pv_power_w) : null)) const fvePred = slots.map((s) => kwFromW(sumW(s.pv_a_forecast_w, s.pv_b_forecast_w))) const fveCorr = slots.map((s) => kwFromW(s.pv_forecast_corrected_w ?? sumW(s.pv_a_forecast_w, s.pv_b_forecast_w)), ) const bazReal = slots.map((s, i) => (i <= nowIndex ? kwFromW(s.load_power_w) : null)) const bazPred = slots.map((s) => kwFromW(s.load_baseline_w)) const ev = slots.map((s) => kwFromW(sumW(s.ev1_setpoint_w, s.ev2_setpoint_w))) const tc = slots.map((s) => kwFromW(s.heat_pump_setpoint_w)) const bat = slots.map((s, i) => i <= nowIndex ? kwFromW(s.battery_power_w) : kwFromW(s.battery_setpoint_w), ) const sit = slots.map((s, i) => i <= nowIndex ? kwFromW(s.grid_power_w) : kwFromW(s.grid_setpoint_w), ) const buy = slots.map((s) => (s.buy_price == null ? null : s.buy_price)) const sell = slots.map((s) => (s.sell_price == null ? null : s.sell_price)) return { fveReal, fvePred, fveCorr, bazReal, bazPred, ev, tc, bat, sit, buy, sell } }, [slots, nowIndex]) const bgPlugin = useMemo( () => createSlotBackgroundPluginRefs(slotsRef, negRangesRef), [], ) const nowPlugin = useMemo(() => createNowLinePluginRef(nowIndexRef, 'teď'), []) useEffect(() => { const canvas = canvasRef.current if (!canvas || !windowKey) return const mkDs = ( key: string, label: string, d: (number | null)[], color: string, opts: { fill?: boolean | 'origin' dashed?: boolean dash?: number[] yAxisID?: string order: number borderWidth?: number }, ) => ({ label, data: d, borderColor: color, backgroundColor: opts.fill === true ? `${color}33` : opts.fill === 'origin' ? `${color}40` : undefined, fill: opts.fill ?? false, borderDash: opts.dash ?? (opts.dashed ? [5, 4] : undefined), borderWidth: opts.borderWidth ?? (opts.dashed ? 1 : 1.2), pointRadius: 0, hitRadius: 6, tension: 0.15, yAxisID: opts.yAxisID ?? 'y', order: opts.order, hidden: hidden.has(key), }) const chart = new Chart(canvas, { type: 'line', plugins: [bgPlugin, nowPlugin], data: { labels: [...labels], datasets: [ mkDs('sit', 'Síť', series.sit, COL.sit, { fill: 'origin', order: 2 }), mkDs('bat', 'Baterie', series.bat, COL.bat, { fill: 'origin', order: 3 }), mkDs('ev', 'EV plán', series.ev, COL.ev, { fill: true, order: 4 }), mkDs('tc', 'TČ plán', series.tc, COL.tc, { fill: true, order: 5 }), mkDs('baz_real', 'Spotřeba ■', series.bazReal, COL.baz, { fill: true, order: 6 }), mkDs('fve_real', 'FVE ■', series.fveReal, COL.fve, { fill: true, order: 7 }), mkDs('baz_pred', 'Spotřeba ···', series.bazPred, COL.baz, { dashed: true, order: 8 }), mkDs('fve_pred', 'FVE ···', series.fvePred, COL.fve, { dashed: true, order: 9 }), mkDs('fve_corr', 'FVE (korig.)', series.fveCorr, COL.fve, { dashed: true, dash: [2, 3], order: 9, borderWidth: 1, }), mkDs('buy_price', 'Nákup', series.buy, COL.buy, { dashed: true, yAxisID: 'y1', order: 10, borderWidth: 1, }), mkDs('sell_price', 'Prodej', series.sell, COL.sell, { dashed: true, yAxisID: 'y1', order: 11, borderWidth: 1, }), ], }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, layout: { padding: { ...CHART_LAYOUT_PADDING } }, plugins: { legend: { display: false }, tooltip: { callbacks: { title(items: TooltipItem<'line'>[]) { const i = items[0]?.dataIndex ?? 0 return labelsRef.current[i] ?? '' }, label(ctx: TooltipItem<'line'>) { const label = ctx.dataset.label ?? '' const v = ctx.parsed.y as number | null if (v == null || Number.isNaN(v)) return `${label}: —` if (ctx.dataset.yAxisID === 'y1') return `${label}: ${v.toFixed(3)} Kč/kWh` return `${label}: ${v.toFixed(2)} kW` }, }, }, }, scales: { x: { type: 'category', offset: false, grid: { color: 'rgba(148,163,184,0.12)' }, ticks: { color: '#94a3b8', maxRotation: 0, autoSkip: false, callback(_val: string | number, i: number) { return i % 8 === 0 ? labelsRef.current[i] ?? '' : '' }, }, }, y: { position: 'left', grid: { color: 'rgba(148,163,184,0.12)' }, ticks: { color: '#94a3b8', font: { size: 10 } }, title: { display: true, text: 'kW', color: '#64748b', font: { size: 10 } }, }, y1: { position: 'right', grid: { drawOnChartArea: false }, ticks: { color: '#94a3b8', font: { size: 9 } }, title: { display: true, text: 'Kč/kWh', color: '#64748b', font: { size: 10 } }, }, }, }, }) chartRef.current = chart requestAnimationFrame(() => { const a = chart.chartArea if (a) onChartAreaRef.current?.(a) }) const ro = new ResizeObserver(() => { chart.resize() requestAnimationFrame(() => { const a = chart.chartArea if (a) onChartAreaRef.current?.(a) }) }) ro.observe(canvas.parentElement ?? canvas) return () => { ro.disconnect() chart.destroy() chartRef.current = null } // Jen při změně okna (první slot / počet); data dorovnává druhý effect. }, [windowKey, bgPlugin, nowPlugin]) useEffect(() => { const ch = chartRef.current if (!ch || !slots.length) return ch.data.labels = [...labels] const dss = ch.data.datasets if (!dss?.length) return const s = series const rows: (number | null)[][] = [ s.sit, s.bat, s.ev, s.tc, s.bazReal, s.fveReal, s.bazPred, s.fvePred, s.fveCorr, s.buy, s.sell, ] rows.forEach((data, i) => { const ds = dss[i] if (ds) ds.data = data }) ch.update('none') requestAnimationFrame(() => { const a = ch.chartArea if (a) onChartAreaRef.current?.(a) }) }, [labels, series, slots.length]) const keys = [ 'sit', 'bat', 'ev', 'tc', 'baz_real', 'fve_real', 'baz_pred', 'fve_pred', 'fve_corr', 'buy_price', 'sell_price', ] as const useEffect(() => { const ch = chartRef.current const dss = ch?.data.datasets if (!ch || !dss?.length) return keys.forEach((k, i) => { const ds = dss[i] if (ds) ds.hidden = hidden.has(k) }) ch.update('none') }, [hidden]) return (
{ENERGY_LEGEND.map((item) => { const off = hidden.has(item.key) return ( ) })}
) }