second version

This commit is contained in:
Dusan Vojacek
2026-04-03 14:23:16 +02:00
parent 897b95f728
commit 9f4126946d
105 changed files with 9738 additions and 1470 deletions

View File

@@ -0,0 +1,307 @@
import axios from 'axios'
import { RefreshCw } from 'lucide-react'
import { memo, useCallback, useEffect, useState } from 'react'
import { getCommandJournal, getDeyeRegisters, type DeyeRegistersLive, type ModbusJournalCommandDto } from '../api/backend'
const BATT_VOLTAGE_V = 51.2
const POLL_REGISTERS_MS = 30_000
const POLL_JOURNAL_MS = 60_000
const TZ = 'Europe/Prague'
function fmtTime(iso: string): string {
return new Date(iso).toLocaleString('cs-CZ', {
timeZone: TZ,
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
function ampsToKw(a: number | null | undefined): string {
if (a == null || Number.isNaN(a)) return '—'
return `${((a * BATT_VOLTAGE_V) / 1000).toFixed(2)} kW`
}
function fmtW(w: number | null | undefined): string {
if (w == null || Number.isNaN(w)) return '—'
return `${w} W`
}
function journalSignature(cmds: ModbusJournalCommandDto[]): string {
return cmds
.map(
(c) =>
`${c.id}:${c.status}:${c.attempt_count}:${c.value_written ?? ''}:${c.value_verified ?? ''}`,
)
.join('|')
}
function statusBadgeClass(status: string): string {
const u = status.toLowerCase()
if (u === 'verified') return 'bg-emerald-600/25 text-emerald-200 ring-1 ring-emerald-500/40'
if (u === 'written') return 'bg-sky-600/25 text-sky-200 ring-1 ring-sky-500/40'
if (u === 'pending' || u === 'retrying') return 'bg-slate-600/30 text-slate-300 ring-1 ring-slate-500/35'
if (u === 'failed' || u === 'mismatch')
return u === 'mismatch'
? 'bg-red-600/30 text-red-100 font-bold ring-1 ring-red-500/50'
: 'bg-red-600/25 text-red-200 ring-1 ring-red-500/40'
return 'bg-slate-600/30 text-slate-300 ring-1 ring-slate-500/35'
}
type LiveSectionProps = {
live: DeyeRegistersLive | null
liveLoading: boolean
onRefresh: () => void
}
const LiveRegistersSection = memo(
function LiveRegistersSection({ live, liveLoading, onRefresh }: LiveSectionProps) {
return (
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-slate-200">Živé registry</h3>
<button
type="button"
onClick={() => onRefresh()}
disabled={liveLoading}
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-600 bg-slate-800/80 px-3 py-1.5 text-xs font-medium text-slate-100 hover:bg-slate-700 disabled:opacity-50"
>
<RefreshCw className={`h-3.5 w-3.5 ${liveLoading ? 'animate-spin' : ''}`} aria-hidden />
Refresh
</button>
</div>
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2">
<Metric label="Max nabíjení" reg={108} unitA={live?.reg108_charge_a} kwHint />
<Metric label="Max vybíjení" reg={109} unitA={live?.reg109_discharge_a} kwHint />
<Metric
label="Limit control"
reg={142}
sub="0 = selling first, 1 = zero export"
valueText={live?.reg142_limit_control != null ? String(live.reg142_limit_control) : undefined}
/>
<Metric label="Energy mode" reg={141} valueText={live?.reg141_energy_mode != null ? String(live.reg141_energy_mode) : undefined} />
<Metric
label="Peak shaving switch"
reg={178}
sub="Bit45: 10 = disable při exportu, 11 = enable při IDLE/CHARGE"
valueText={live?.reg178_peak_shaving_switch != null ? String(live.reg178_peak_shaving_switch) : undefined}
/>
<Metric
label="Grid peak shaving W"
reg={191}
sub="EMS nezapisuje nastavit v SolarmanApp (výkon peak shavingu v W)"
valueText={fmtW(live?.reg191_peak_shaving_w)}
/>
</div>
{live?.read_at ? (
<p className="mt-3 text-[10px] text-slate-500">Načteno: {fmtTime(live.read_at)}</p>
) : null}
</div>
)
},
(a, b) =>
a.liveLoading === b.liveLoading &&
a.live?.read_at === b.live?.read_at &&
a.live?.reg108_charge_a === b.live?.reg108_charge_a &&
a.live?.reg109_discharge_a === b.live?.reg109_discharge_a &&
a.live?.reg141_energy_mode === b.live?.reg141_energy_mode &&
a.live?.reg142_limit_control === b.live?.reg142_limit_control &&
a.live?.reg143_export_limit_w === b.live?.reg143_export_limit_w &&
a.live?.reg178_peak_shaving_switch === b.live?.reg178_peak_shaving_switch &&
a.live?.reg191_peak_shaving_w === b.live?.reg191_peak_shaving_w,
)
type MetricProps = {
label: string
reg: number
unitA?: number | null
kwHint?: boolean
valueText?: string
sub?: string
}
function Metric({ label, reg, unitA, kwHint, valueText, sub }: MetricProps) {
const main =
valueText ??
(unitA != null && !Number.isNaN(unitA) ? `${unitA} A` : '—')
const extra = kwHint ? ampsToKw(unitA ?? null) : null
return (
<div className="rounded-lg border border-slate-800/80 bg-slate-950/40 px-3 py-2">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">{label}</p>
<p className="mt-0.5 font-mono text-sm text-slate-100">
reg {reg}: {main}
{extra && extra !== '—' ? <span className="text-slate-400"> · {extra}</span> : null}
</p>
{sub ? <p className="mt-0.5 text-[10px] text-slate-500">{sub}</p> : null}
</div>
)
}
type JournalSectionProps = {
commands: ModbusJournalCommandDto[]
}
const JournalSection = memo(
function JournalSection({ commands }: JournalSectionProps) {
return (
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<h3 className="mb-3 text-sm font-semibold text-slate-200">Posledních 50 zápisů</h3>
<div
className="overflow-x-auto"
style={{
maxHeight: '400px',
overflowY: 'auto',
borderRadius: 'var(--border-radius-md)',
border: '0.5px solid var(--color-border-tertiary)',
}}
>
<table className="w-full border-collapse text-left text-xs">
<thead>
<tr className="text-slate-500">
<th className="py-2 pr-2 font-medium">Čas</th>
<th className="py-2 pr-2 font-medium">Reg</th>
<th className="py-2 pr-2 font-medium">Popis</th>
<th className="py-2 pr-2 font-medium">Hodnota</th>
<th className="py-2 pr-2 font-medium">Pokus</th>
<th className="py-2 font-medium">Status</th>
</tr>
</thead>
<tbody>
{commands.length === 0 ? (
<tr>
<td colSpan={6} className="py-4 text-slate-500">
Žádné záznamy v journalu.
</td>
</tr>
) : (
commands.map((c) => (
<tr key={c.id} className="border-t border-slate-800/80">
<td className="whitespace-nowrap py-1.5 pr-2 font-mono text-slate-400">
{fmtTime(c.created_at)}
</td>
<td className="pr-2 font-mono text-slate-300">{c.register}</td>
<td className="max-w-[140px] truncate pr-2 text-slate-400" title={c.register_name ?? ''}>
{c.register_name ?? '—'}
</td>
<td className="pr-2 font-mono tabular-nums text-slate-200">
{c.value_to_write}
{c.value_verified != null ? (
<span className="text-slate-500"> {c.value_verified}</span>
) : null}
</td>
<td className="pr-2 font-mono tabular-nums text-slate-300">{c.attempt_count}</td>
<td className="py-1.5">
<span
className={`inline-block rounded-md px-2 py-0.5 text-[10px] font-semibold uppercase ${statusBadgeClass(c.status)}`}
>
{c.status}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
},
(a, b) => journalSignature(a.commands) === journalSignature(b.commands),
)
function ControlPanelImpl({ siteId }: { siteId: number }) {
const [live, setLive] = useState<DeyeRegistersLive | null>(null)
const [liveError, setLiveError] = useState<string | null>(null)
const [liveLoading, setLiveLoading] = useState(false)
const [commands, setCommands] = useState<ModbusJournalCommandDto[]>([])
const [journalError, setJournalError] = useState<string | null>(null)
const fetchRegisters = useCallback(async () => {
setLiveLoading(true)
setLiveError(null)
try {
const data = await getDeyeRegisters(siteId)
setLive(data)
} catch (e: unknown) {
let msg = 'Chyba čtení registrů'
if (axios.isAxiosError(e)) {
const d = e.response?.data as { detail?: string } | undefined
if (typeof d?.detail === 'string') msg = d.detail
} else if (e instanceof Error) {
msg = e.message
}
setLiveError(msg)
setLive(null)
} finally {
setLiveLoading(false)
}
}, [siteId])
const fetchJournal = useCallback(async () => {
setJournalError(null)
try {
const res = await getCommandJournal(siteId, 50)
setCommands(res.commands)
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Chyba načtení journalu'
setJournalError(msg)
setCommands([])
}
}, [siteId])
useEffect(() => {
void fetchRegisters()
}, [fetchRegisters])
useEffect(() => {
void fetchJournal()
}, [fetchJournal])
useEffect(() => {
const t = window.setInterval(() => void fetchRegisters(), POLL_REGISTERS_MS)
return () => window.clearInterval(t)
}, [fetchRegisters])
useEffect(() => {
const t = window.setInterval(() => void fetchJournal(), POLL_JOURNAL_MS)
return () => window.clearInterval(t)
}, [fetchJournal])
const apiError = liveError ?? journalError
return (
<div className="space-y-4">
{apiError ? (
<div
role="alert"
className="rounded-lg border border-red-500/45 bg-red-950/50 px-4 py-3 text-sm text-red-100"
>
<p className="font-semibold text-red-50">Chyba API řízení / Modbus</p>
{liveError ? (
<p className="mt-1.5">
<span className="text-red-200/90">GET /control/registers: </span>
{liveError}
</p>
) : null}
{journalError ? (
<p className={liveError ? 'mt-2' : 'mt-1.5'}>
<span className="text-red-200/90">Journal: </span>
{journalError}
</p>
) : null}
</div>
) : null}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
<LiveRegistersSection live={live} liveLoading={liveLoading} onRefresh={fetchRegisters} />
<JournalSection commands={commands} />
</div>
</div>
)
}
export const ControlPanel = memo(ControlPanelImpl)

View File

@@ -0,0 +1,77 @@
type Props = {
modeName: string
activatedAt: string | null
nextReplanIn: number | null
onReplan: () => void
onModeChange: () => void
}
const MODE_DOT: Record<string, string> = {
AUTO: '#1D9E75',
SELF_SUSTAIN: '#E24B4A',
CHARGE_CHEAP: '#EF9F27',
PRESERVE: '#378ADD',
MANUAL: '#888780',
}
function fmtActivatedPrague(iso: string | null): string | null {
if (!iso) return null
return new Intl.DateTimeFormat('cs-CZ', {
timeZone: 'Europe/Prague',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(new Date(iso))
}
export function ModeBar({ modeName, activatedAt, nextReplanIn, onReplan, onModeChange }: Props) {
const code = (modeName || 'AUTO').toUpperCase().replace(/-/g, '_')
const dot = MODE_DOT[code] ?? MODE_DOT.MANUAL!
const tAct = fmtActivatedPrague(activatedAt)
const subParts: string[] = []
if (tAct) subParts.push(`aktivní od ${tAct}`)
if (nextReplanIn != null) subParts.push(`příští replan za ${nextReplanIn} min`)
return (
<>
<style>{`
@keyframes ems-mode-auto-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.ems-mode-auto-pulse {
animation: ems-mode-auto-pulse 2s ease-in-out infinite;
}
`}</style>
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-slate-700/90 bg-slate-950/95 px-3 py-2 text-sm text-slate-200">
<div className="flex min-w-0 flex-1 items-center gap-2">
<span
className={`inline-block h-2.5 w-2.5 shrink-0 rounded-full ${code === 'AUTO' ? 'ems-mode-auto-pulse' : ''}`}
style={{ backgroundColor: dot }}
aria-hidden
/>
<span className="font-semibold tracking-wide text-slate-100">{code}</span>
{subParts.length > 0 ? (
<span className="truncate text-xs text-slate-400 sm:text-sm">{subParts.join(' · ')}</span>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={onReplan}
className="rounded-md border border-slate-600 bg-slate-800/80 px-3 py-1.5 text-xs font-medium text-slate-100 hover:bg-slate-700"
>
Přeplánovat
</button>
<button
type="button"
onClick={onModeChange}
className="rounded-md border border-slate-600 bg-slate-800/80 px-3 py-1.5 text-xs font-medium text-slate-100 hover:bg-slate-700"
>
Změnit režim
</button>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,64 @@
export interface NegPricePrediction {
predicted_date: string
window_start_hour: number
window_end_hour: number
probability_pct: number
expected_min_price: number | null
reason: string
}
function pad2(n: number): string {
return n.toString().padStart(2, '0')
}
function borderClass(pct: number): string {
if (pct >= 70) return 'border-l-emerald-500'
if (pct >= 50) return 'border-l-amber-400'
return 'border-l-slate-500'
}
export function NegPricePanel({
predictions,
insufficientHistory = false,
}: {
predictions: NegPricePrediction[]
insufficientHistory?: boolean
}) {
if (insufficientHistory) {
return (
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4 text-sm text-slate-400">
Predikce bude dostupná po 4 týdnech provozu.
</div>
)
}
if (!predictions.length) {
return (
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4 text-sm text-slate-400">
Žádné záporné ceny v příštích 7 dnech.
</div>
)
}
return (
<div className="space-y-2">
{predictions.map((p, i) => (
<article
key={`${p.predicted_date}-${p.window_start_hour}-${i}`}
className={`rounded-lg border border-slate-800 border-l-4 bg-slate-900/60 py-3 pl-3 pr-4 ${borderClass(p.probability_pct)}`}
>
<p className="text-xs font-medium text-slate-300">
{p.predicted_date} · {pad2(p.window_start_hour)}:00{pad2(p.window_end_hour)}:00
</p>
<p className="mt-1 text-sm text-slate-400">{p.reason}</p>
<p className="mt-2 text-xs tabular-nums text-slate-500">
{p.probability_pct.toFixed(0)}% jistota
{p.expected_min_price != null
? ` · očekávané min. ${p.expected_min_price.toFixed(2)} Kč/kWh`
: ''}
</p>
</article>
))}
</div>
)
}

View File

@@ -0,0 +1,140 @@
import type { Notification, NotificationAction, NotificationLevel } from '../types/dashboard'
type Props = {
notifications: Notification[]
onReplan?: () => void
onImportPrices?: () => void
onSwitchAuto?: () => void
}
const LEVEL_STYLES: Record<
NotificationLevel,
{ bg: string; border: string; icon: string; iconClass: string }
> = {
success: {
bg: '#1D9E7508',
border: '#1D9E7544',
icon: '⚡',
iconClass: 'text-emerald-500',
},
info: {
bg: '#E6F1FB08',
border: '#378ADD44',
icon: '',
iconClass: 'text-blue-400',
},
warning: {
bg: '#EF9F2708',
border: '#EF9F2744',
icon: '⚠',
iconClass: 'text-amber-400',
},
error: {
bg: '#E24B4A08',
border: '#E24B4A44',
icon: '✕',
iconClass: 'text-red-400',
},
}
function fmtEtaMinutes(mins: number): string {
const h = Math.floor(mins / 60)
const m = mins % 60
if (h > 0) return `za ${h}h ${m}min`
return `za ${m}min`
}
function ActionControls({
action,
onReplan,
onImportPrices,
onSwitchAuto,
}: {
action: NotificationAction | null | undefined
onReplan?: () => void
onImportPrices?: () => void
onSwitchAuto?: () => void
}) {
if (action === 'connect_ev') {
return (
<span className="rounded-md bg-slate-800 px-2 py-0.5 text-[10px] font-semibold uppercase text-slate-300">
Připoj auto
</span>
)
}
if (action === 'replan') {
return (
<button
type="button"
onClick={onReplan}
className="rounded-md border border-slate-600 bg-slate-900/60 px-2 py-1 text-xs font-medium text-slate-100 hover:bg-slate-800"
>
Přeplánovat nyní
</button>
)
}
if (action === 'import_prices') {
return (
<button
type="button"
onClick={onImportPrices}
className="rounded-md border border-slate-600 bg-slate-900/60 px-2 py-1 text-xs font-medium text-slate-100 hover:bg-slate-800"
>
Importovat ceny
</button>
)
}
if (action === 'switch_auto') {
return (
<button
type="button"
onClick={onSwitchAuto}
className="rounded-md border border-emerald-700/60 bg-emerald-950/40 px-2 py-1 text-xs font-medium text-emerald-100 hover:bg-emerald-900/50"
>
Přepnout na AUTO
</button>
)
}
return null
}
export function NotificationBar({ notifications, onReplan, onImportPrices, onSwitchAuto }: Props) {
const shown = notifications.slice(0, 2)
if (shown.length === 0) return null
return (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{shown.map((n) => {
const st = LEVEL_STYLES[n.level] ?? LEVEL_STYLES.info!
return (
<div
key={n.id}
className="flex gap-3 rounded-xl border px-3 py-2.5 text-sm"
style={{ backgroundColor: st.bg, borderColor: st.border }}
>
<span className={`shrink-0 text-lg leading-none ${st.iconClass}`} aria-hidden>
{st.icon}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="font-bold text-slate-100">{n.title}</p>
{n.eta_minutes != null && n.eta_minutes >= 0 ? (
<span className="text-xs text-slate-400">{fmtEtaMinutes(n.eta_minutes)}</span>
) : null}
</div>
<p className="mt-0.5 text-slate-300">{n.body}</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<ActionControls
action={n.action}
onReplan={onReplan}
onImportPrices={onImportPrices}
onSwitchAuto={onSwitchAuto}
/>
</div>
</div>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,496 @@
import { memo, useMemo } from 'react'
import { SLOT_MS, TOTAL_SLOTS } from './charts/chartConstants'
import type { SlotData } from '../types/dashboard'
export type StatePanelProps = {
slots: SlotData[]
nowIndex: number
}
/** Stav segmentu pro jeden track */
export type TrackSegment = {
widthPct: number
label: string
color: string
textColor: string
isFuture: boolean
tooltip?: string
/** Zákaz exportu (záporná prodejní cena) overlay „0!“ */
exportBanOverlay?: boolean
}
const TIME_PRAGUE = new Intl.DateTimeFormat('cs-CZ', {
timeZone: 'Europe/Prague',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
function slotRangeLabel(slots: SlotData[], i0: number, i1: number): string {
const t0 = new Date(slots[i0]!.interval_start).getTime()
const t1 = new Date(slots[i1]!.interval_start).getTime() + SLOT_MS
return `${TIME_PRAGUE.format(t0)}${TIME_PRAGUE.format(t1)}`
}
function fmtMoney(v: number | null | undefined): string | null {
if (v == null || Number.isNaN(v)) return null
return `${v.toFixed(2)} Kč/kWh`
}
function avg(nums: number[]): number {
if (nums.length === 0) return 0
return nums.reduce((a, b) => a + b, 0) / nums.length
}
type GridKind = 'import' | 'export' | 'idle'
function gridKind(s: SlotData): GridKind {
const sp = s.grid_setpoint_w
const pw = s.grid_power_w
const imp = (sp != null && sp > 500) || (pw != null && pw > 500)
const exp = (sp != null && sp < -500) || (pw != null && pw < -500)
if (imp) return 'import'
if (exp) return 'export'
return 'idle'
}
function gridFlowW(s: SlotData): number {
return s.grid_setpoint_w ?? s.grid_power_w ?? 0
}
export function buildGridSegments(slots: SlotData[], nowIndex: number): TrackSegment[] {
const n = slots.length
if (n === 0) return []
const out: TrackSegment[] = []
let i = 0
while (i < n) {
const g = gridKind(slots[i]!)
const neg = slots[i]!.sell_price != null && slots[i]!.sell_price! < 0
const fut = i > nowIndex
const start = i
const gw: number[] = []
const buys: number[] = []
const sells: number[] = []
while (i < n) {
const s = slots[i]!
if (gridKind(s) !== g) break
if ((s.sell_price != null && s.sell_price < 0) !== neg) break
if ((i > nowIndex) !== fut) break
gw.push(gridFlowW(s))
if (s.buy_price != null) buys.push(s.buy_price)
if (s.sell_price != null) sells.push(s.sell_price)
i++
}
const count = i - start
const widthPct = (count / n) * 100
const avgW = avg(gw)
const avgBuy = buys.length ? avg(buys) : null
const avgSell = sells.length ? avg(sells) : null
let color = '#88878012'
let textColor = '#5F5E5A'
let label = ''
if (g === 'import') {
color = '#E24B4A1A'
textColor = '#993C1D'
label = `${(avgW / 1000).toFixed(1)} kW`
} else if (g === 'export') {
color = '#1D9E751A'
textColor = '#0F6E56'
label = `${(Math.abs(avgW) / 1000).toFixed(1)} kW`
}
const range = slotRangeLabel(slots, start, i - 1)
let tooltip = range
if (g === 'import') {
tooltip += ` · import ${(avgW / 1000).toFixed(1)} kW`
const p = fmtMoney(avgBuy)
if (p) tooltip += ` · cena nákup ${p}`
} else if (g === 'export') {
tooltip += ` · export ${(Math.abs(avgW) / 1000).toFixed(1)} kW`
const p = fmtMoney(avgSell)
if (p) tooltip += ` · cena prodej ${p}`
} else {
tooltip += ' · síť v klidu'
const p = fmtMoney(avgSell ?? avgBuy)
if (p) tooltip += ` · cena ${p}`
}
out.push({
widthPct,
label,
color,
textColor,
isFuture: fut,
tooltip,
exportBanOverlay: neg,
})
}
return out
}
type BatKind = 'fve' | 'grid' | 'dis' | 'idle'
function batKind(s: SlotData): BatKind {
const bsp = s.battery_setpoint_w
const bpw = s.battery_power_w
const gsp = s.grid_setpoint_w
const gpw = s.grid_power_w
if ((bsp != null && bsp < -500) || (bpw != null && bpw < -500)) return 'dis'
const gridHeavy = (gsp != null && gsp > 500) || (gpw != null && gpw > 500)
if (bsp != null && bsp > 500) {
if (gsp != null && gsp > 500) return 'grid'
if (gridHeavy) return 'grid'
return 'fve'
}
if (bpw != null && bpw > 500) {
if (gridHeavy) return 'grid'
return 'fve'
}
return 'idle'
}
export function buildBatterySegments(slots: SlotData[], nowIndex: number): TrackSegment[] {
const n = slots.length
if (n === 0) return []
const out: TrackSegment[] = []
let i = 0
while (i < n) {
const k = batKind(slots[i]!)
const fut = i > nowIndex
const start = i
while (i < n) {
if (batKind(slots[i]!) !== k) break
if ((i > nowIndex) !== fut) break
i++
}
const count = i - start
const widthPct = (count / n) * 100
let color = '#88878012'
let textColor = '#5F5E5A'
let label = ''
if (k === 'fve') {
color = '#1D9E751A'
textColor = '#0F6E56'
label = 'nabíjení FVE'
} else if (k === 'grid') {
color = '#378ADD1A'
textColor = '#185FA5'
label = 'nabíjení sítě'
} else if (k === 'dis') {
color = '#EF9F271A'
textColor = '#854F0B'
label = 'vybíjení'
}
const range = slotRangeLabel(slots, start, i - 1)
out.push({
widthPct,
label,
color,
textColor,
isFuture: fut,
tooltip: `${range} · ${label}`,
})
}
return out
}
export type DeviceKind = 'ev1' | 'ev2' | 'tc'
function evSegmentKind(sp: number | null): 'charge' | 'idle' | 'off' {
if (sp === null) return 'off'
if (sp > 0) return 'charge'
return 'idle'
}
function tcRunning(s: SlotData): boolean {
if (s.heat_pump_enabled === true) return true
if (s.heat_pump_enabled === false) return false
return (s.heat_pump_setpoint_w ?? 0) > 0
}
export function buildDeviceSegments(
slots: SlotData[],
nowIndex: number,
device: DeviceKind,
): TrackSegment[] {
const n = slots.length
if (n === 0) return []
const out: TrackSegment[] = []
if (device === 'tc') {
let i = 0
while (i < n) {
const on = tcRunning(slots[i]!)
const fut = i > nowIndex
const start = i
while (i < n) {
if (tcRunning(slots[i]!) !== on) break
if ((i > nowIndex) !== fut) break
i++
}
const count = i - start
const widthPct = (count / n) * 100
out.push({
widthPct,
label: on ? '6kW' : '',
color: on ? '#D4537E1A' : '#88878012',
textColor: on ? '#8B3055' : '#5F5E5A',
isFuture: fut,
tooltip: `${slotRangeLabel(slots, start, i - 1)} · ${on ? 'TČ běží' : 'TČ odstaveno'}`,
})
}
return out
}
const pick = (s: SlotData) => (device === 'ev1' ? s.ev1_setpoint_w : s.ev2_setpoint_w)
let i = 0
while (i < n) {
const ek = evSegmentKind(pick(slots[i]!))
const fut = i > nowIndex
const start = i
const pws: number[] = []
while (i < n) {
const s = slots[i]!
if (evSegmentKind(pick(s)) !== ek) break
if ((i > nowIndex) !== fut) break
const sp = pick(s)
if (sp != null && sp > 0) pws.push(sp)
i++
}
const count = i - start
const widthPct = (count / n) * 100
if (ek === 'off') {
out.push({
widthPct,
label: 'nepřipojeno',
color: '#88878008',
textColor: '#5F5E5A',
isFuture: fut,
tooltip: `${slotRangeLabel(slots, start, i - 1)} · vozidlo nepřipojeno`,
})
} else if (ek === 'charge') {
const avgW = avg(pws.length ? pws : [0])
out.push({
widthPct,
label: `${(avgW / 1000).toFixed(1)} kW`,
color: '#534AB71A',
textColor: '#3D3480',
isFuture: fut,
tooltip: `${slotRangeLabel(slots, start, i - 1)} · nabíjení ${(avgW / 1000).toFixed(1)} kW`,
})
} else {
out.push({
widthPct,
label: '',
color: '#88878012',
textColor: '#5F5E5A',
isFuture: fut,
tooltip: `${slotRangeLabel(slots, start, i - 1)} · klid`,
})
}
}
return out
}
function isFourHourTick(iso: string): boolean {
const d = new Date(iso)
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Europe/Prague',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).formatToParts(d)
const hi = parts.find((p) => p.type === 'hour')
const mi = parts.find((p) => p.type === 'minute')
if (!hi || !mi) return false
const h = parseInt(hi.value, 10)
const m = parseInt(mi.value, 10)
return m === 0 && h % 4 === 0
}
function TickRow({ slots }: { slots: SlotData[] }) {
const n = slots.length
if (n === 0) return null
return (
<div className="relative mt-0.5 h-4 w-full">
{slots.map((s, i) =>
isFourHourTick(s.interval_start) ? (
<span
key={`${s.interval_start}-${i}`}
className="absolute top-0 -translate-x-1/2 text-[9px] tabular-nums text-slate-500"
style={{ left: `${((i + 0.5) / n) * 100}%` }}
>
{TIME_PRAGUE.format(new Date(s.interval_start))}
</span>
) : null,
)}
</div>
)
}
function SegmentBar({
segments,
nowIndex,
showNowLabel,
}: {
segments: TrackSegment[]
nowIndex: number
showNowLabel?: boolean
}) {
const n = TOTAL_SLOTS
const leftPct = (nowIndex / n) * 100
return (
<div className="relative min-h-[28px]">
<div className="flex h-[28px] w-full overflow-hidden rounded-sm border border-slate-800/80">
{segments.map((seg, idx) => (
<div
key={idx}
title={seg.tooltip}
className="relative flex min-w-0 shrink-0 items-center justify-center overflow-hidden px-0.5 text-center font-medium leading-tight"
style={{
width: `${seg.widthPct}%`,
background: seg.color,
opacity: seg.isFuture ? 0.6 : 1,
borderLeft: seg.isFuture ? '1px dashed rgba(148,163,184,0.45)' : 'none',
color: seg.textColor,
fontSize: 9,
}}
>
<span className="truncate">{seg.label}</span>
{seg.exportBanOverlay ? (
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center text-[9px] font-bold"
style={{ background: '#E24B4A28', color: '#993C1D' }}
>
0!
</div>
) : null}
</div>
))}
</div>
<div
className="pointer-events-none absolute inset-0 z-10"
aria-hidden
>
{showNowLabel ? (
<span
className="absolute -top-5 z-20 whitespace-nowrap text-[9px] font-semibold text-[#378ADD]"
style={{ left: `${leftPct}%`, transform: 'translateX(-50%)' }}
>
teď
</span>
) : null}
<div
className="absolute bottom-0 top-0 w-[1.5px] bg-[#378ADD]"
style={{ left: `${leftPct}%`, transform: 'translateX(-50%)' }}
/>
</div>
</div>
)
}
function TrackRow({
label,
segments,
nowIndex,
showNowLabel,
}: {
label: string
segments: TrackSegment[]
nowIndex: number
showNowLabel?: boolean
}) {
return (
<div className="grid grid-cols-[52px_1fr] items-center gap-x-1 gap-y-0">
<div className="pr-1 text-right text-[10px] font-medium text-slate-400">{label}</div>
<SegmentBar segments={segments} nowIndex={nowIndex} showNowLabel={showNowLabel} />
</div>
)
}
function StatePanelRaw({ slots, nowIndex }: StatePanelProps) {
const gridSegs = useMemo(() => buildGridSegments(slots, nowIndex), [slots, nowIndex])
const batSegs = useMemo(() => buildBatterySegments(slots, nowIndex), [slots, nowIndex])
const ev1Segs = useMemo(() => buildDeviceSegments(slots, nowIndex, 'ev1'), [slots, nowIndex])
const ev2Segs = useMemo(() => buildDeviceSegments(slots, nowIndex, 'ev2'), [slots, nowIndex])
const tcSegs = useMemo(() => buildDeviceSegments(slots, nowIndex, 'tc'), [slots, nowIndex])
if (slots.length === 0) return null
return (
<div className="space-y-5 rounded-xl border border-slate-800 bg-slate-900/40 p-3">
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
Energetický tok
</p>
<div className="space-y-2">
<TrackRow label="Síť" segments={gridSegs} nowIndex={nowIndex} showNowLabel />
<TrackRow label="Baterie" segments={batSegs} nowIndex={nowIndex} />
</div>
<div className="mt-1 grid grid-cols-[52px_1fr] gap-x-1">
<div />
<TickRow slots={slots} />
</div>
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[10px] text-slate-500">
<li className="flex items-center gap-1">
<span className="h-2 w-3 rounded-sm" style={{ background: '#E24B4A1A' }} />
Import
</li>
<li className="flex items-center gap-1">
<span className="h-2 w-3 rounded-sm" style={{ background: '#1D9E751A' }} />
Export
</li>
<li className="flex items-center gap-1">
<span className="h-2 w-3 rounded-sm" style={{ background: '#88878012' }} />
Klid
</li>
<li className="flex items-center gap-1">
<span className="h-2 w-3 rounded-sm" style={{ background: '#E24B4A28' }} />
Zákaz exportu (0!)
</li>
</ul>
</div>
<div className="border-t border-slate-800 pt-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
Variabilní zátěže
</p>
<div className="space-y-2">
<TrackRow label="Tesla" segments={ev1Segs} nowIndex={nowIndex} />
<TrackRow label="Zoe" segments={ev2Segs} nowIndex={nowIndex} />
<TrackRow label="TČ" segments={tcSegs} nowIndex={nowIndex} />
</div>
<div className="mt-1 grid grid-cols-[52px_1fr] gap-x-1">
<div />
<TickRow slots={slots} />
</div>
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[10px] text-slate-500">
<li className="flex items-center gap-1">
<span className="h-2 w-3 rounded-sm" style={{ background: '#534AB71A' }} />
EV nabíjení
</li>
<li className="flex items-center gap-1">
<span className="h-2 w-3 rounded-sm" style={{ background: '#88878008' }} />
Nepřipojeno
</li>
<li className="flex items-center gap-1">
<span className="h-2 w-3 rounded-sm" style={{ background: '#D4537E1A' }} />
běh
</li>
</ul>
</div>
</div>
)
}
export const StatePanel = memo(StatePanelRaw, (prev, next) => {
return prev.slots === next.slots && prev.nowIndex === next.nowIndex
})

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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}
/>
)
}

View 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>
)
}

View 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)
}

View 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()
},
}
}