359 lines
11 KiB
TypeScript
359 lines
11 KiB
TypeScript
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<string>
|
|
onToggle: (key: string) => void
|
|
onChartArea?: (area: ChartArea) => void
|
|
}
|
|
|
|
export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }: Props) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
const chartRef = useRef<Chart | null>(null)
|
|
const onChartAreaRef = useRef(onChartArea)
|
|
onChartAreaRef.current = onChartArea
|
|
|
|
const slotsRef = useRef<SlotData[]>([])
|
|
const negRangesRef = useRef<ReturnType<typeof computeNegWeekendRanges>>([])
|
|
const nowIndexRef = useRef(0)
|
|
const labelsRef = useRef<string[]>([])
|
|
|
|
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 (
|
|
<div className="flex flex-col gap-2">
|
|
<div className="h-[260px] w-full">
|
|
<canvas ref={canvasRef} className="max-h-[260px] w-full" role="img" aria-label="Graf výkonů a cen" />
|
|
</div>
|
|
<div className="flex flex-wrap gap-x-3 gap-y-1.5 px-1">
|
|
{ENERGY_LEGEND.map((item) => {
|
|
const off = hidden.has(item.key)
|
|
return (
|
|
<button
|
|
key={item.key}
|
|
type="button"
|
|
onClick={() => onToggle(item.key)}
|
|
className={`inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-[11px] transition hover:bg-white/5 ${
|
|
off ? 'text-slate-500 line-through opacity-60' : 'text-slate-200'
|
|
}`}
|
|
>
|
|
<span
|
|
className="h-2.5 w-4 shrink-0 rounded-sm border border-white/10"
|
|
style={{
|
|
backgroundColor: off ? 'transparent' : item.color,
|
|
borderStyle: item.dashStyle === 'dotted' ? 'dotted' : item.dashed ? 'dashed' : 'solid',
|
|
}}
|
|
/>
|
|
{item.label}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|