497 lines
15 KiB
TypeScript
497 lines
15 KiB
TypeScript
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' }} />
|
||
TČ běh
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export const StatePanel = memo(StatePanelRaw, (prev, next) => {
|
||
return prev.slots === next.slots && prev.nowIndex === next.nowIndex
|
||
})
|