responsivita: touch tooltipy — tap-to-pin panel u Chart.js, trigger click u Recharts

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dusan Vojacek
2026-06-11 14:28:08 +02:00
parent eb360da910
commit 02e0134794
5 changed files with 203 additions and 10 deletions

View File

@@ -11,6 +11,7 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from 'recharts' } from 'recharts'
import { useIsCoarsePointer } from '../../hooks/useMediaQuery'
import type { ChartDayPoint } from '../../types/economics' import type { ChartDayPoint } from '../../types/economics'
type Props = { type Props = {
@@ -93,6 +94,9 @@ function CustomTooltip({
} }
export function EconomicsChart({ points, hasGreenBonus }: Props) { export function EconomicsChart({ points, hasGreenBonus }: Props) {
// Touch zařízení: tooltip na tap (neblokuje scroll), ne hover.
const isCoarse = useIsCoarsePointer()
if (points.length === 0) { if (points.length === 0) {
return ( return (
<div className="flex h-64 items-center justify-center text-sm text-slate-500"> <div className="flex h-64 items-center justify-center text-sm text-slate-500">
@@ -134,7 +138,10 @@ export function EconomicsChart({ points, hasGreenBonus }: Props) {
style: { fontSize: 11, fill: BLUE }, style: { fontSize: 11, fill: BLUE },
}} }}
/> />
<Tooltip content={<CustomTooltip hasGreenBonus={hasGreenBonus} />} /> <Tooltip
trigger={isCoarse ? 'click' : 'hover'}
content={<CustomTooltip hasGreenBonus={hasGreenBonus} />}
/>
<Legend <Legend
wrapperStyle={{ fontSize: 11 }} wrapperStyle={{ fontSize: 11 }}
formatter={(value: string) => { formatter={(value: string) => {

View File

@@ -1,7 +1,9 @@
import { useEffect, useMemo, useRef } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import type { MouseEvent as ReactMouseEvent } from 'react'
import { Chart } from 'chart.js/auto' import { Chart } from 'chart.js/auto'
import type { ChartArea, TooltipItem } from 'chart.js' import type { ChartArea, TooltipItem } from 'chart.js'
import { useIsCoarsePointer } from '../../hooks/useMediaQuery'
import type { SlotData } from '../../types/dashboard' import type { SlotData } from '../../types/dashboard'
import { CHART_LAYOUT_PADDING } from './chartConstants' import { CHART_LAYOUT_PADDING } from './chartConstants'
import { import {
@@ -64,9 +66,14 @@ type Props = {
export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }: Props) { export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null) const canvasRef = useRef<HTMLCanvasElement>(null)
const chartRef = useRef<Chart | null>(null) const chartRef = useRef<Chart | null>(null)
const wrapRef = useRef<HTMLDivElement>(null)
const onChartAreaRef = useRef(onChartArea) const onChartAreaRef = useRef(onChartArea)
onChartAreaRef.current = onChartArea onChartAreaRef.current = onChartArea
// Touch zařízení: hover tooltip vypnutý; tap vybere slot do panelu nad grafem.
const isCoarse = useIsCoarsePointer()
const [touchIdx, setTouchIdx] = useState<number | null>(null)
const slotsRef = useRef<SlotData[]>([]) const slotsRef = useRef<SlotData[]>([])
const negRangesRef = useRef<ReturnType<typeof computeNegWeekendRanges>>([]) const negRangesRef = useRef<ReturnType<typeof computeNegWeekendRanges>>([])
const nowIndexRef = useRef(0) const nowIndexRef = useRef(0)
@@ -200,6 +207,7 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }:
plugins: { plugins: {
legend: { display: false }, legend: { display: false },
tooltip: { tooltip: {
enabled: !isCoarse,
callbacks: { callbacks: {
title(items: TooltipItem<'line'>[]) { title(items: TooltipItem<'line'>[]) {
const i = items[0]?.dataIndex ?? 0 const i = items[0]?.dataIndex ?? 0
@@ -265,8 +273,8 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }:
chart.destroy() chart.destroy()
chartRef.current = null chartRef.current = null
} }
// Jen při změně okna (první slot / počet); data dorovnává druhý effect. // Jen při změně okna (první slot / počet) nebo typu pointeru; data dorovnává druhý effect.
}, [windowKey, bgPlugin, nowPlugin]) }, [windowKey, bgPlugin, nowPlugin, isCoarse])
useEffect(() => { useEffect(() => {
const ch = chartRef.current const ch = chartRef.current
@@ -324,11 +332,87 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }:
ch.update('none') ch.update('none')
}, [hidden]) }, [hidden])
// Tap mimo graf zruší vybraný slot (touch panel).
useEffect(() => {
if (touchIdx == null) return
const onDocDown = (e: PointerEvent) => {
const el = wrapRef.current
if (el && e.target instanceof Node && !el.contains(e.target)) setTouchIdx(null)
}
document.addEventListener('pointerdown', onDocDown)
return () => document.removeEventListener('pointerdown', onDocDown)
}, [touchIdx])
const handleCanvasTap = (ev: ReactMouseEvent<HTMLCanvasElement>) => {
if (!isCoarse) return
const ch = chartRef.current
if (!ch) return
const els = ch.getElementsAtEventForMode(ev.nativeEvent, 'index', { intersect: false }, false)
setTouchIdx(els.length ? els[0]!.index : null)
}
// Texty panelu reusují stejné formátování jako tooltip callbacks (kW / Kč/kWh).
const touchInfo = useMemo(() => {
if (!isCoarse || touchIdx == null || touchIdx < 0 || touchIdx >= slots.length) return null
const rows: Array<{ key: string; label: string; text: string; color: string }> = []
const push = (
key: string,
label: string,
v: number | null | undefined,
color: string,
unit: 'kW' | 'czk',
) => {
if (hidden.has(key)) return
const text =
v == null || Number.isNaN(v)
? '—'
: unit === 'czk'
? `${v.toFixed(3)} Kč/kWh`
: `${v.toFixed(2)} kW`
rows.push({ key, label, text, color })
}
push('fve_real', 'FVE ■', series.fveReal[touchIdx], COL.fve, 'kW')
push('fve_pred', 'FVE ···', series.fvePred[touchIdx], COL.fve, 'kW')
push('fve_corr', 'FVE (korig.)', series.fveCorr[touchIdx], COL.fve, 'kW')
push('baz_real', 'Spotřeba ■', series.bazReal[touchIdx], COL.baz, 'kW')
push('baz_pred', 'Spotřeba ···', series.bazPred[touchIdx], COL.baz, 'kW')
push('ev', 'EV plán', series.ev[touchIdx], COL.ev, 'kW')
push('tc', 'TČ plán', series.tc[touchIdx], COL.tc, 'kW')
push('bat', 'Baterie', series.bat[touchIdx], COL.bat, 'kW')
push('sit', 'Síť', series.sit[touchIdx], COL.sit, 'kW')
push('buy_price', 'Nákup', series.buy[touchIdx], COL.buy, 'czk')
push('sell_price', 'Prodej', series.sell[touchIdx], COL.sell, 'czk')
return { title: labels[touchIdx] ?? '', rows }
}, [isCoarse, touchIdx, series, labels, hidden, slots.length])
return ( return (
<div className="flex flex-col gap-2"> <div ref={wrapRef} className="relative flex flex-col gap-2">
{touchInfo ? (
<div className="absolute inset-x-1 top-0 z-20 rounded-lg border border-slate-700 bg-slate-950/95 px-3 py-2 shadow-xl">
<div className="mb-1 flex items-center justify-between gap-2 text-[11px] font-semibold text-slate-100">
<span>{touchInfo.title}</span>
<button
type="button"
onClick={() => setTouchIdx(null)}
className="px-1 text-slate-400"
aria-label="Zavřít detail slotu"
>
</button>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-[10px]">
{touchInfo.rows.map((r) => (
<span key={r.key} style={{ color: r.color }}>
{r.label}: <span className="text-slate-200">{r.text}</span>
</span>
))}
</div>
</div>
) : null}
<div className="h-chart-sm w-full sm:h-chart-md lg:h-chart-lg"> <div className="h-chart-sm w-full sm:h-chart-md lg:h-chart-lg">
<canvas <canvas
ref={canvasRef} ref={canvasRef}
onClick={handleCanvasTap}
className="max-h-chart-sm w-full sm:max-h-chart-md lg:max-h-chart-lg" className="max-h-chart-sm w-full sm:max-h-chart-md lg:max-h-chart-lg"
role="img" role="img"
aria-label="Graf výkonů a cen" aria-label="Graf výkonů a cen"

View File

@@ -1,7 +1,9 @@
import { useEffect, useMemo, useRef } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import type { MouseEvent as ReactMouseEvent } from 'react'
import { Chart } from 'chart.js/auto' import { Chart } from 'chart.js/auto'
import type { TooltipItem } from 'chart.js' import type { TooltipItem } from 'chart.js'
import { useIsCoarsePointer } from '../../hooks/useMediaQuery'
import type { SlotData } from '../../types/dashboard' import type { SlotData } from '../../types/dashboard'
import { CHART_LAYOUT_PADDING } from './chartConstants' import { CHART_LAYOUT_PADDING } from './chartConstants'
import { import {
@@ -20,6 +22,11 @@ type Props = {
export function SocTuvChart({ slots, nowIndex, liveBatSoc = null }: Props) { export function SocTuvChart({ slots, nowIndex, liveBatSoc = null }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null) const canvasRef = useRef<HTMLCanvasElement>(null)
const chartRef = useRef<Chart | null>(null) const chartRef = useRef<Chart | null>(null)
const wrapRef = useRef<HTMLDivElement>(null)
// Touch zařízení: hover tooltip vypnutý; tap vybere slot do panelu nad grafem.
const isCoarse = useIsCoarsePointer()
const [touchIdx, setTouchIdx] = useState<number | null>(null)
const slotsRef = useRef<SlotData[]>([]) const slotsRef = useRef<SlotData[]>([])
const negRangesRef = useRef<ReturnType<typeof computeNegWeekendRanges>>([]) const negRangesRef = useRef<ReturnType<typeof computeNegWeekendRanges>>([])
@@ -149,6 +156,7 @@ export function SocTuvChart({ slots, nowIndex, liveBatSoc = null }: Props) {
plugins: { plugins: {
legend: { display: false }, legend: { display: false },
tooltip: { tooltip: {
enabled: !isCoarse,
callbacks: { callbacks: {
title(items: TooltipItem<'line'>[]) { title(items: TooltipItem<'line'>[]) {
const i = items[0]?.dataIndex ?? 0 const i = items[0]?.dataIndex ?? 0
@@ -217,7 +225,7 @@ export function SocTuvChart({ slots, nowIndex, liveBatSoc = null }: Props) {
chart.destroy() chart.destroy()
chartRef.current = null chartRef.current = null
} }
}, [windowKey, bgPlugin, nowPlugin]) }, [windowKey, bgPlugin, nowPlugin, isCoarse])
useEffect(() => { useEffect(() => {
const ch = chartRef.current const ch = chartRef.current
@@ -234,9 +242,74 @@ export function SocTuvChart({ slots, nowIndex, liveBatSoc = null }: Props) {
ch.update('none') ch.update('none')
}, [labels, series, slots, slots.length]) }, [labels, series, slots, slots.length])
// Tap mimo graf zruší vybraný slot (touch panel).
useEffect(() => {
if (touchIdx == null) return
const onDocDown = (e: PointerEvent) => {
const el = wrapRef.current
if (el && e.target instanceof Node && !el.contains(e.target)) setTouchIdx(null)
}
document.addEventListener('pointerdown', onDocDown)
return () => document.removeEventListener('pointerdown', onDocDown)
}, [touchIdx])
const handleCanvasTap = (ev: ReactMouseEvent<HTMLCanvasElement>) => {
if (!isCoarse) return
const ch = chartRef.current
if (!ch) return
const els = ch.getElementsAtEventForMode(ev.nativeEvent, 'index', { intersect: false }, false)
setTouchIdx(els.length ? els[0]!.index : null)
}
// Texty panelu reusují stejné formátování jako tooltip callbacks (% / °C).
const touchInfo = useMemo(() => {
if (!isCoarse || touchIdx == null || touchIdx < 0 || touchIdx >= slots.length) return null
const fmt = (v: number | null | undefined, unit: string) =>
v == null || Number.isNaN(v) ? '—' : `${v.toFixed(1)} ${unit}`
return {
title: labels[touchIdx] ?? '',
rows: [
{ key: 'soc', label: 'SoC ■', text: fmt(series.socReal[touchIdx], '%'), color: '#1D9E75' },
{ key: 'soc_plan', label: 'SoC plán', text: fmt(series.socPlan[touchIdx], '%'), color: '#1D9E75' },
{ key: 'tuv', label: 'TUV ■', text: fmt(series.tuvReal[touchIdx], '°C'), color: '#EF9F27' },
{ key: 'tuv_plan', label: 'TUV cíl', text: fmt(series.tuvPlan[touchIdx], '°C'), color: '#EF9F27' },
],
}
}, [isCoarse, touchIdx, series, labels, slots.length])
return ( return (
<div ref={wrapRef} className="relative">
{touchInfo ? (
<div className="absolute inset-x-1 bottom-full z-20 mb-1 rounded-lg border border-slate-700 bg-slate-950/95 px-3 py-2 shadow-xl">
<div className="mb-1 flex items-center justify-between gap-2 text-[11px] font-semibold text-slate-100">
<span>{touchInfo.title}</span>
<button
type="button"
onClick={() => setTouchIdx(null)}
className="px-1 text-slate-400"
aria-label="Zavřít detail slotu"
>
</button>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-[10px]">
{touchInfo.rows.map((r) => (
<span key={r.key} style={{ color: r.color }}>
{r.label}: <span className="text-slate-200">{r.text}</span>
</span>
))}
</div>
</div>
) : null}
<div className="h-[80px] w-full sm:h-[100px]"> <div className="h-[80px] w-full sm:h-[100px]">
<canvas ref={canvasRef} className="max-h-[80px] w-full sm:max-h-[100px]" role="img" aria-label="SoC a TUV" /> <canvas
ref={canvasRef}
onClick={handleCanvasTap}
className="max-h-[80px] w-full sm:max-h-[100px]"
role="img"
aria-label="SoC a TUV"
/>
</div>
</div> </div>
) )
} }

View File

@@ -0,0 +1,23 @@
import { useEffect, useState } from 'react'
/** Reaktivní matchMedia — hodnota se aktualizuje při změně média (rotace, připojení myši…). */
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(() =>
typeof window !== 'undefined' ? window.matchMedia(query).matches : false,
)
useEffect(() => {
const mq = window.matchMedia(query)
const onChange = () => setMatches(mq.matches)
onChange()
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}, [query])
return matches
}
/** Dotykové zařízení bez přesného kurzoru (mobil, tablet) — hover tooltipy nahrazujeme tapem. */
export function useIsCoarsePointer(): boolean {
return useMediaQuery('(pointer: coarse)')
}

View File

@@ -32,6 +32,7 @@ import {
postRunPlan, postRunPlan,
} from '../api/backend' } from '../api/backend'
import { floorSlotUtcMs, SLOT_MS } from '../components/charts/chartConstants' import { floorSlotUtcMs, SLOT_MS } from '../components/charts/chartConstants'
import { useIsCoarsePointer } from '../hooks/useMediaQuery'
import { useSiteStatus } from '../hooks/useSiteStatus' import { useSiteStatus } from '../hooks/useSiteStatus'
import { import {
maskForInterval, maskForInterval,
@@ -899,6 +900,8 @@ function HorizonToggle({
export default function Planning() { export default function Planning() {
const { site, ready: siteReady } = useSiteStatus() const { site, ready: siteReady } = useSiteStatus()
const siteId = site?.site_id ?? null const siteId = site?.site_id ?? null
// Touch zařízení: tooltip grafu na tap (neblokuje scroll), ne hover.
const isCoarse = useIsCoarsePointer()
const [data, setData] = useState<CurrentPlanResponse | null>(null) const [data, setData] = useState<CurrentPlanResponse | null>(null)
const [compareData, setCompareData] = useState<PlanningCompareResponse | null>(null) const [compareData, setCompareData] = useState<PlanningCompareResponse | null>(null)
@@ -1517,7 +1520,10 @@ export default function Planning() {
offset: 10, offset: 10,
}} }}
/> />
<Tooltip content={<PlanTooltip nowMs={nowMs} solverSnap={solverSnap} />} /> <Tooltip
trigger={isCoarse ? 'click' : 'hover'}
content={<PlanTooltip nowMs={nowMs} solverSnap={solverSnap} />}
/>
{chartChargeBands.map((b) => ( {chartChargeBands.map((b) => (
<ReferenceArea <ReferenceArea
key={`chg-${b.x1}-${b.x2}`} key={`chg-${b.x1}-${b.x2}`}