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