Files
ems/frontend/src/components/StatePanel.tsx
Dusan Vojacek 9f4126946d second version
2026-04-03 14:23:16 +02:00

497 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
})