second version
This commit is contained in:
339
frontend/src/components/charts/EnergyChart.tsx
Normal file
339
frontend/src/components/charts/EnergyChart.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
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 }
|
||||
|
||||
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: '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 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, 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
|
||||
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.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('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.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',
|
||||
'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.dashed ? 'dashed' : 'solid',
|
||||
}}
|
||||
/>
|
||||
{item.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
frontend/src/components/charts/ForecastPanel.tsx
Normal file
37
frontend/src/components/charts/ForecastPanel.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { ForecastDayTotal } from '../../types/dashboard'
|
||||
|
||||
type Props = {
|
||||
days: ForecastDayTotal[]
|
||||
}
|
||||
|
||||
export function ForecastPanel({ days }: Props) {
|
||||
const max = Math.max(1, ...days.map((d) => d.kwh))
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-800/90 bg-slate-900/50 p-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Předpověď výroby FVE (7 dní)
|
||||
</h3>
|
||||
{days.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-slate-500">Žádná data forecastu.</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-2.5">
|
||||
{days.map((d) => (
|
||||
<li key={d.date} className="flex items-center gap-3 text-sm">
|
||||
<span className="w-24 shrink-0 text-slate-400">{d.label}</span>
|
||||
<div className="h-2.5 min-w-0 flex-1 overflow-hidden rounded-full bg-slate-800">
|
||||
<div
|
||||
className="h-full rounded-full bg-amber-500/80 transition-all"
|
||||
style={{ width: `${Math.min(100, (d.kwh / max) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-16 shrink-0 text-right tabular-nums text-amber-200/90">
|
||||
{d.kwh.toFixed(1)} kWh
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
51
frontend/src/components/charts/NegPricePanel.tsx
Normal file
51
frontend/src/components/charts/NegPricePanel.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { NegPriceItem } from '../../types/dashboard'
|
||||
|
||||
type Props = {
|
||||
items: NegPriceItem[]
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString('cs-CZ', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Europe/Prague',
|
||||
})
|
||||
}
|
||||
|
||||
export function NegPricePanel({ items }: Props) {
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-800/90 bg-slate-900/50 p-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Záporné ceny (nadcházející)
|
||||
</h3>
|
||||
{items.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-slate-500">V dostupných datech nejsou záporné ceny.</p>
|
||||
) : (
|
||||
<ul className="mt-3 max-h-48 space-y-2 overflow-y-auto text-sm">
|
||||
{items.map((it) => (
|
||||
<li
|
||||
key={it.interval_start}
|
||||
className="flex flex-col gap-0.5 rounded-lg border border-slate-700/60 bg-slate-950/40 px-2 py-1.5"
|
||||
>
|
||||
<span className="text-slate-300">{fmtTime(it.interval_start)}</span>
|
||||
<span className="tabular-nums text-xs text-slate-400">
|
||||
nákup:{' '}
|
||||
<span className={it.buy != null && it.buy < 0 ? 'text-emerald-400' : ''}>
|
||||
{it.buy == null ? '—' : `${it.buy.toFixed(3)} Kč/kWh`}
|
||||
</span>
|
||||
{' · '}
|
||||
prodej:{' '}
|
||||
<span className={it.sell != null && it.sell < 0 ? 'text-red-300' : ''}>
|
||||
{it.sell == null ? '—' : `${it.sell.toFixed(3)} Kč/kWh`}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
90
frontend/src/components/charts/RegimeBar.tsx
Normal file
90
frontend/src/components/charts/RegimeBar.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import type { SlotData } from '../../types/dashboard'
|
||||
|
||||
type Props = {
|
||||
slots: SlotData[]
|
||||
nowIndex: number
|
||||
chartPaddingLeft: number
|
||||
chartPaddingRight: number
|
||||
/** Pixely plot oblasti z EnergyChart (chartArea), pokud známe – přesnější zarovnání. */
|
||||
chartArea: { left: number; right: number } | null
|
||||
}
|
||||
|
||||
const REGIME_STYLES: Record<
|
||||
string,
|
||||
{ bg: string; fg: string; bgPlan: string; fgPlan: string }
|
||||
> = {
|
||||
AUTO: { bg: '#1D9E7518', fg: '#0F6E56', bgPlan: '#1D9E7510', fgPlan: '#0F6E5699' },
|
||||
SELF_SUSTAIN: { bg: '#E24B4A18', fg: '#993C1D', bgPlan: '#E24B4A10', fgPlan: '#993C1D99' },
|
||||
CHARGE_CHEAP: { bg: '#EF9F2718', fg: '#854F0B', bgPlan: '#EF9F2710', fgPlan: '#854F0B99' },
|
||||
PRESERVE: { bg: '#53B0AA18', fg: '#185FA5', bgPlan: '#53B0AA10', fgPlan: '#185FA599' },
|
||||
MANUAL: { bg: '#88878018', fg: '#5F5E5A', bgPlan: '#88878010', fgPlan: '#5F5E5A99' },
|
||||
}
|
||||
|
||||
const DEFAULT_STYLE = { bg: '#88878018', fg: '#5F5E5A', bgPlan: '#88878010', fgPlan: '#5F5E5A99' }
|
||||
|
||||
function normCode(code: string | null): string {
|
||||
return (code ?? 'AUTO').toUpperCase().replace(/-/g, '_')
|
||||
}
|
||||
|
||||
export function RegimeBar({ slots, nowIndex, chartPaddingLeft, chartPaddingRight, chartArea }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas || !slots.length) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const h = 28
|
||||
const w = canvas.clientWidth || canvas.parentElement?.clientWidth || 300
|
||||
canvas.width = Math.floor(w * dpr)
|
||||
canvas.height = Math.floor(h * dpr)
|
||||
canvas.style.height = `${h}px`
|
||||
canvas.style.width = `${w}px`
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
|
||||
const plotLeft = chartArea?.left ?? chartPaddingLeft
|
||||
const plotRight = chartArea?.right ?? w - chartPaddingRight
|
||||
const plotW = Math.max(1, plotRight - plotLeft)
|
||||
const n = slots.length
|
||||
const segW = plotW / n
|
||||
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const s = slots[i]!
|
||||
const code = normCode(s.regime_code)
|
||||
const st = REGIME_STYLES[code] ?? DEFAULT_STYLE
|
||||
const planned = s.regime_is_planned
|
||||
ctx.fillStyle = planned ? st.bgPlan : st.bg
|
||||
const x0 = plotLeft + i * segW
|
||||
ctx.fillRect(x0, 0, segW + 0.5, h)
|
||||
}
|
||||
|
||||
if (nowIndex >= 0 && nowIndex < n) {
|
||||
const x = plotLeft + nowIndex * segW
|
||||
ctx.save()
|
||||
ctx.strokeStyle = '#378ADD'
|
||||
ctx.setLineDash([4, 3])
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, 0)
|
||||
ctx.lineTo(x, h)
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
}
|
||||
}, [slots, nowIndex, chartPaddingLeft, chartPaddingRight, chartArea])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="block w-full"
|
||||
style={{ height: 28 }}
|
||||
aria-hidden
|
||||
height={28}
|
||||
/>
|
||||
)
|
||||
}
|
||||
230
frontend/src/components/charts/SocTuvChart.tsx
Normal file
230
frontend/src/components/charts/SocTuvChart.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { Chart } from 'chart.js/auto'
|
||||
import type { TooltipItem } from 'chart.js'
|
||||
|
||||
import type { SlotData } from '../../types/dashboard'
|
||||
import { CHART_LAYOUT_PADDING } from './chartConstants'
|
||||
import {
|
||||
computeNegWeekendRanges,
|
||||
createNowLinePluginRef,
|
||||
createSlotBackgroundPluginRefs,
|
||||
} from './chartPlugins'
|
||||
|
||||
type Props = {
|
||||
slots: SlotData[]
|
||||
nowIndex: number
|
||||
}
|
||||
|
||||
export function SocTuvChart({ slots, nowIndex }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const chartRef = useRef<Chart | null>(null)
|
||||
|
||||
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 socReal = slots.map((s, i) => (i <= nowIndex ? s.soc_actual_pct : null))
|
||||
const socPlan = slots.map((s) => s.soc_plan_pct)
|
||||
const tuvReal = slots.map((s, i) => (i <= nowIndex ? s.tuv_actual_c : null))
|
||||
const tuvPlan = slots.map((s) => s.tuv_plan_c)
|
||||
return { socReal, socPlan, tuvReal, tuvPlan }
|
||||
}, [slots, nowIndex])
|
||||
|
||||
const bgPlugin = useMemo(
|
||||
() => createSlotBackgroundPluginRefs(slotsRef, negRangesRef),
|
||||
[],
|
||||
)
|
||||
const nowPlugin = useMemo(() => createNowLinePluginRef(nowIndexRef, 'teď'), [])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas || !windowKey) return
|
||||
|
||||
const chart = new Chart(canvas, {
|
||||
type: 'line',
|
||||
plugins: [bgPlugin, nowPlugin],
|
||||
data: {
|
||||
labels: [...labels],
|
||||
datasets: [
|
||||
{
|
||||
label: 'SoC ■',
|
||||
data: series.socReal,
|
||||
borderColor: '#1D9E75',
|
||||
backgroundColor: '#1D9E7526',
|
||||
fill: true,
|
||||
borderWidth: 1.2,
|
||||
pointRadius: 0,
|
||||
tension: 0.2,
|
||||
yAxisID: 'y',
|
||||
},
|
||||
{
|
||||
label: 'SoC plán',
|
||||
data: series.socPlan,
|
||||
borderColor: '#1D9E75',
|
||||
borderDash: [5, 4],
|
||||
fill: false,
|
||||
borderWidth: 1,
|
||||
pointRadius: 0,
|
||||
tension: 0.2,
|
||||
yAxisID: 'y',
|
||||
},
|
||||
{
|
||||
label: 'TUV ■',
|
||||
data: series.tuvReal,
|
||||
borderColor: '#EF9F27',
|
||||
backgroundColor: '#EF9F2726',
|
||||
fill: true,
|
||||
borderWidth: 1.2,
|
||||
pointRadius: 0,
|
||||
tension: 0.2,
|
||||
yAxisID: 'y1',
|
||||
},
|
||||
{
|
||||
label: 'TUV cíl',
|
||||
data: series.tuvPlan,
|
||||
borderColor: '#EF9F27',
|
||||
borderDash: [5, 4],
|
||||
fill: false,
|
||||
borderWidth: 1,
|
||||
pointRadius: 0,
|
||||
tension: 0.2,
|
||||
yAxisID: 'y1',
|
||||
},
|
||||
{
|
||||
label: '_layout',
|
||||
data: slots.map(() => 0),
|
||||
borderColor: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 0,
|
||||
pointRadius: 0,
|
||||
yAxisID: 'y2',
|
||||
order: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
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'>) {
|
||||
if (ctx.dataset.label === '_layout') return ''
|
||||
if (!ctx.dataset.label) return ''
|
||||
const v = ctx.parsed.y as number | null
|
||||
if (v == null || Number.isNaN(v)) return `${ctx.dataset.label}: —`
|
||||
if (ctx.dataset.yAxisID === 'y') return `${ctx.dataset.label}: ${v.toFixed(1)} %`
|
||||
return `${ctx.dataset.label}: ${v.toFixed(1)} °C`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'category',
|
||||
offset: false,
|
||||
grid: { color: 'rgba(148,163,184,0.1)' },
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
maxRotation: 0,
|
||||
autoSkip: false,
|
||||
callback(_v: string | number, i: number) {
|
||||
return i % 8 === 0 ? labelsRef.current[i] ?? '' : ''
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
position: 'left',
|
||||
min: 0,
|
||||
max: 100,
|
||||
grid: { color: 'rgba(148,163,184,0.12)' },
|
||||
ticks: { color: '#1D9E75', font: { size: 9 } },
|
||||
title: { display: true, text: '% SoC', color: '#1D9E75', font: { size: 10 } },
|
||||
},
|
||||
y1: {
|
||||
position: 'right',
|
||||
min: 30,
|
||||
max: 75,
|
||||
grid: { drawOnChartArea: false },
|
||||
ticks: { color: '#EF9F27', font: { size: 9 } },
|
||||
title: { display: true, text: 'TUV °C', color: '#EF9F27', font: { size: 10 } },
|
||||
},
|
||||
y2: {
|
||||
type: 'linear',
|
||||
position: 'right',
|
||||
display: false,
|
||||
min: 0,
|
||||
max: 1,
|
||||
grid: { display: false },
|
||||
ticks: { display: false },
|
||||
weight: 0.35,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
chartRef.current = chart
|
||||
const ro = new ResizeObserver(() => chart.resize())
|
||||
ro.observe(canvas.parentElement ?? canvas)
|
||||
return () => {
|
||||
ro.disconnect()
|
||||
chart.destroy()
|
||||
chartRef.current = null
|
||||
}
|
||||
}, [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
|
||||
if (dss[0]) dss[0].data = s.socReal
|
||||
if (dss[1]) dss[1].data = s.socPlan
|
||||
if (dss[2]) dss[2].data = s.tuvReal
|
||||
if (dss[3]) dss[3].data = s.tuvPlan
|
||||
if (dss[4]) dss[4].data = slots.map(() => 0)
|
||||
ch.update('none')
|
||||
}, [labels, series, slots, slots.length])
|
||||
|
||||
return (
|
||||
<div className="h-[100px] w-full">
|
||||
<canvas ref={canvasRef} className="max-h-[100px] w-full" role="img" aria-label="SoC a TUV" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
frontend/src/components/charts/chartConstants.ts
Normal file
16
frontend/src/components/charts/chartConstants.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const CHART_LAYOUT_PADDING = { left: 8, right: 45 } as const
|
||||
|
||||
export const SLOT_MS = 15 * 60 * 1000
|
||||
export const SLOT_COUNT_BACK = 60
|
||||
export const SLOT_COUNT_FWD = 144
|
||||
export const TOTAL_SLOTS = SLOT_COUNT_BACK + SLOT_COUNT_FWD
|
||||
|
||||
export function floorSlotUtcMs(ms: number): number {
|
||||
return Math.floor(ms / SLOT_MS) * SLOT_MS
|
||||
}
|
||||
|
||||
/** Index aktuálního 15min slotu v okně [0, TOTAL_SLOTS). */
|
||||
export function currentSlotIndexInWindow(windowStartMs: number): number {
|
||||
const cur = floorSlotUtcMs(Date.now())
|
||||
return Math.round((cur - windowStartMs) / SLOT_MS)
|
||||
}
|
||||
200
frontend/src/components/charts/chartPlugins.ts
Normal file
200
frontend/src/components/charts/chartPlugins.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import type { MutableRefObject } from 'react'
|
||||
import type { Chart, Plugin } from 'chart.js'
|
||||
|
||||
import type { SlotData } from '../../types/dashboard'
|
||||
|
||||
export type NegWeekendRange = { start: number; end: number }
|
||||
|
||||
const SELL_NEG = 'rgba(226,75,74,0.07)'
|
||||
const BUY_NEG = 'rgba(29,158,117,0.07)'
|
||||
const WEEKEND_NEG = 'rgba(239,159,39,0.07)'
|
||||
|
||||
function isWeekendPrague(iso: string): boolean {
|
||||
const w = new Date(iso).toLocaleDateString('en-US', { timeZone: 'Europe/Prague', weekday: 'short' })
|
||||
return w === 'Sat' || w === 'Sun'
|
||||
}
|
||||
|
||||
export function computeNegWeekendRanges(slots: SlotData[], nowIndex: number): NegWeekendRange[] {
|
||||
const ranges: NegWeekendRange[] = []
|
||||
let i = 0
|
||||
const n = slots.length
|
||||
while (i < n) {
|
||||
if (i <= nowIndex) {
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
const s = slots[i]!
|
||||
const buy = s.buy_price
|
||||
if (!(buy != null && buy < 0 && isWeekendPrague(s.interval_start))) {
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
const start = i
|
||||
while (
|
||||
i < n &&
|
||||
slots[i]!.buy_price != null &&
|
||||
slots[i]!.buy_price! < 0 &&
|
||||
isWeekendPrague(slots[i]!.interval_start)
|
||||
) {
|
||||
i += 1
|
||||
}
|
||||
ranges.push({ start, end: i })
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
export function createSlotBackgroundPlugin(
|
||||
slots: SlotData[],
|
||||
_nowIndex: number,
|
||||
negWeekendRanges: NegWeekendRange[],
|
||||
): Plugin {
|
||||
return {
|
||||
id: 'emsSlotBg',
|
||||
beforeDatasetsDraw(chart: Chart) {
|
||||
const { ctx, chartArea } = chart
|
||||
if (!chartArea || !slots.length) return
|
||||
const n = slots.length
|
||||
const w = chartArea.width / n
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const s = slots[i]!
|
||||
const x0 = chartArea.left + i * w
|
||||
let fill: string | null = null
|
||||
if (s.sell_price != null && s.sell_price < 0) fill = SELL_NEG
|
||||
else if (s.buy_price != null && s.buy_price < 0) fill = BUY_NEG
|
||||
if (fill) {
|
||||
ctx.save()
|
||||
ctx.fillStyle = fill
|
||||
ctx.fillRect(x0, chartArea.top, w, chartArea.bottom - chartArea.top)
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
for (const r of negWeekendRanges) {
|
||||
if (r.start >= n || r.end <= r.start) continue
|
||||
const x0 = chartArea.left + r.start * w
|
||||
const x1 = chartArea.left + r.end * w
|
||||
ctx.save()
|
||||
ctx.fillStyle = WEEKEND_NEG
|
||||
ctx.fillRect(x0, chartArea.top, x1 - x0, chartArea.bottom - chartArea.top)
|
||||
ctx.strokeStyle = 'rgba(239,159,39,0.45)'
|
||||
ctx.setLineDash([4, 3])
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeRect(x0 + 0.5, chartArea.top + 0.5, x1 - x0 - 1, chartArea.bottom - chartArea.top - 1)
|
||||
ctx.restore()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Pozadí slotů – čte aktuální slots z ref (bez přepínání instance grafu). */
|
||||
export function createSlotBackgroundPluginRefs(
|
||||
slotsRef: MutableRefObject<SlotData[]>,
|
||||
negRangesRef: MutableRefObject<NegWeekendRange[]>,
|
||||
): Plugin {
|
||||
return {
|
||||
id: 'emsSlotBgRef',
|
||||
beforeDatasetsDraw(chart: Chart) {
|
||||
const { ctx, chartArea } = chart
|
||||
const slots = slotsRef.current
|
||||
const negWeekendRanges = negRangesRef.current
|
||||
if (!chartArea || !slots.length) return
|
||||
const n = slots.length
|
||||
const w = chartArea.width / n
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const s = slots[i]!
|
||||
const x0 = chartArea.left + i * w
|
||||
let fill: string | null = null
|
||||
if (s.sell_price != null && s.sell_price < 0) fill = SELL_NEG
|
||||
else if (s.buy_price != null && s.buy_price < 0) fill = BUY_NEG
|
||||
if (fill) {
|
||||
ctx.save()
|
||||
ctx.fillStyle = fill
|
||||
ctx.fillRect(x0, chartArea.top, w, chartArea.bottom - chartArea.top)
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
for (const r of negWeekendRanges) {
|
||||
if (r.start >= n || r.end <= r.start) continue
|
||||
const x0 = chartArea.left + r.start * w
|
||||
const x1 = chartArea.left + r.end * w
|
||||
ctx.save()
|
||||
ctx.fillStyle = WEEKEND_NEG
|
||||
ctx.fillRect(x0, chartArea.top, x1 - x0, chartArea.bottom - chartArea.top)
|
||||
ctx.strokeStyle = 'rgba(239,159,39,0.45)'
|
||||
ctx.setLineDash([4, 3])
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeRect(x0 + 0.5, chartArea.top + 0.5, x1 - x0 - 1, chartArea.bottom - chartArea.top - 1)
|
||||
ctx.restore()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Čára „teď“ – index z ref. */
|
||||
export function createNowLinePluginRef(nowIndexRef: MutableRefObject<number>, label: string): Plugin {
|
||||
return {
|
||||
id: 'emsNowLineRef',
|
||||
afterDatasetsDraw(chart: Chart) {
|
||||
const nowIndex = nowIndexRef.current
|
||||
const { ctx, chartArea } = chart
|
||||
const labels = chart.data.labels
|
||||
if (!chartArea || !labels?.length) return
|
||||
const n = labels.length
|
||||
if (nowIndex < 0 || nowIndex >= n) return
|
||||
const w = chartArea.width / n
|
||||
const x = chartArea.left + nowIndex * w
|
||||
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = '#378ADD'
|
||||
ctx.setLineDash([5, 4])
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.moveTo(x, chartArea.top)
|
||||
ctx.lineTo(x, chartArea.bottom)
|
||||
ctx.stroke()
|
||||
|
||||
ctx.setLineDash([])
|
||||
ctx.fillStyle = '#378ADD'
|
||||
ctx.font = '600 10px system-ui, sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.textBaseline = 'top'
|
||||
ctx.fillText(label, Math.min(x + 3, chartArea.right - 28), chartArea.top + 2)
|
||||
ctx.restore()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createNowLinePlugin(nowIndex: number, label: string): Plugin {
|
||||
return {
|
||||
id: 'emsNowLine',
|
||||
afterDatasetsDraw(chart: Chart) {
|
||||
const { ctx, chartArea } = chart
|
||||
const labels = chart.data.labels
|
||||
if (!chartArea || !labels?.length) return
|
||||
const n = labels.length
|
||||
if (nowIndex < 0 || nowIndex >= n) return
|
||||
const w = chartArea.width / n
|
||||
const x = chartArea.left + nowIndex * w
|
||||
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = '#378ADD'
|
||||
ctx.setLineDash([5, 4])
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.moveTo(x, chartArea.top)
|
||||
ctx.lineTo(x, chartArea.bottom)
|
||||
ctx.stroke()
|
||||
|
||||
ctx.setLineDash([])
|
||||
ctx.fillStyle = '#378ADD'
|
||||
ctx.font = '600 10px system-ui, sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.textBaseline = 'top'
|
||||
ctx.fillText(label, Math.min(x + 3, chartArea.right - 28), chartArea.top + 2)
|
||||
ctx.restore()
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user