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 (
{slots.map((s, i) => isFourHourTick(s.interval_start) ? ( {TIME_PRAGUE.format(new Date(s.interval_start))} ) : null, )}
) } function SegmentBar({ segments, nowIndex, showNowLabel, }: { segments: TrackSegment[] nowIndex: number showNowLabel?: boolean }) { const n = TOTAL_SLOTS const leftPct = (nowIndex / n) * 100 return (
{segments.map((seg, idx) => (
{seg.label} {seg.exportBanOverlay ? (
0!
) : null}
))}
{showNowLabel ? ( teď ) : null}
) } function TrackRow({ label, segments, nowIndex, showNowLabel, }: { label: string segments: TrackSegment[] nowIndex: number showNowLabel?: boolean }) { return (
{label}
) } 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 (

Energetický tok

  • Import
  • Export
  • Klid
  • Zákaz exportu (0!)

Variabilní zátěže

  • EV nabíjení
  • Nepřipojeno
  • TČ běh
) } export const StatePanel = memo(StatePanelRaw, (prev, next) => { return prev.slots === next.slots && prev.nowIndex === next.nowIndex })