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:
@@ -11,6 +11,7 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
import { useIsCoarsePointer } from '../../hooks/useMediaQuery'
|
||||
import type { ChartDayPoint } from '../../types/economics'
|
||||
|
||||
type Props = {
|
||||
@@ -93,6 +94,9 @@ function CustomTooltip({
|
||||
}
|
||||
|
||||
export function EconomicsChart({ points, hasGreenBonus }: Props) {
|
||||
// Touch zařízení: tooltip na tap (neblokuje scroll), ne hover.
|
||||
const isCoarse = useIsCoarsePointer()
|
||||
|
||||
if (points.length === 0) {
|
||||
return (
|
||||
<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 },
|
||||
}}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip hasGreenBonus={hasGreenBonus} />} />
|
||||
<Tooltip
|
||||
trigger={isCoarse ? 'click' : 'hover'}
|
||||
content={<CustomTooltip hasGreenBonus={hasGreenBonus} />}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: 11 }}
|
||||
formatter={(value: string) => {
|
||||
|
||||
@@ -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 type { ChartArea, TooltipItem } from 'chart.js'
|
||||
|
||||
import { useIsCoarsePointer } from '../../hooks/useMediaQuery'
|
||||
import type { SlotData } from '../../types/dashboard'
|
||||
import { CHART_LAYOUT_PADDING } from './chartConstants'
|
||||
import {
|
||||
@@ -64,9 +66,14 @@ type Props = {
|
||||
export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const chartRef = useRef<Chart | null>(null)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
const onChartAreaRef = useRef(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 negRangesRef = useRef<ReturnType<typeof computeNegWeekendRanges>>([])
|
||||
const nowIndexRef = useRef(0)
|
||||
@@ -200,6 +207,7 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }:
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
enabled: !isCoarse,
|
||||
callbacks: {
|
||||
title(items: TooltipItem<'line'>[]) {
|
||||
const i = items[0]?.dataIndex ?? 0
|
||||
@@ -265,8 +273,8 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }:
|
||||
chart.destroy()
|
||||
chartRef.current = null
|
||||
}
|
||||
// Jen při změně okna (první slot / počet); data dorovnává druhý effect.
|
||||
}, [windowKey, bgPlugin, nowPlugin])
|
||||
// Jen při změně okna (první slot / počet) nebo typu pointeru; data dorovnává druhý effect.
|
||||
}, [windowKey, bgPlugin, nowPlugin, isCoarse])
|
||||
|
||||
useEffect(() => {
|
||||
const ch = chartRef.current
|
||||
@@ -324,11 +332,87 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }:
|
||||
ch.update('none')
|
||||
}, [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 (
|
||||
<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">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onClick={handleCanvasTap}
|
||||
className="max-h-chart-sm w-full sm:max-h-chart-md lg:max-h-chart-lg"
|
||||
role="img"
|
||||
aria-label="Graf výkonů a cen"
|
||||
|
||||
@@ -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 type { TooltipItem } from 'chart.js'
|
||||
|
||||
import { useIsCoarsePointer } from '../../hooks/useMediaQuery'
|
||||
import type { SlotData } from '../../types/dashboard'
|
||||
import { CHART_LAYOUT_PADDING } from './chartConstants'
|
||||
import {
|
||||
@@ -20,6 +22,11 @@ type Props = {
|
||||
export function SocTuvChart({ slots, nowIndex, liveBatSoc = null }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(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 negRangesRef = useRef<ReturnType<typeof computeNegWeekendRanges>>([])
|
||||
@@ -149,6 +156,7 @@ export function SocTuvChart({ slots, nowIndex, liveBatSoc = null }: Props) {
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
enabled: !isCoarse,
|
||||
callbacks: {
|
||||
title(items: TooltipItem<'line'>[]) {
|
||||
const i = items[0]?.dataIndex ?? 0
|
||||
@@ -217,7 +225,7 @@ export function SocTuvChart({ slots, nowIndex, liveBatSoc = null }: Props) {
|
||||
chart.destroy()
|
||||
chartRef.current = null
|
||||
}
|
||||
}, [windowKey, bgPlugin, nowPlugin])
|
||||
}, [windowKey, bgPlugin, nowPlugin, isCoarse])
|
||||
|
||||
useEffect(() => {
|
||||
const ch = chartRef.current
|
||||
@@ -234,9 +242,74 @@ export function SocTuvChart({ slots, nowIndex, liveBatSoc = null }: Props) {
|
||||
ch.update('none')
|
||||
}, [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 (
|
||||
<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" />
|
||||
<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]">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onClick={handleCanvasTap}
|
||||
className="max-h-[80px] w-full sm:max-h-[100px]"
|
||||
role="img"
|
||||
aria-label="SoC a TUV"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
23
frontend/src/hooks/useMediaQuery.ts
Normal file
23
frontend/src/hooks/useMediaQuery.ts
Normal 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)')
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
postRunPlan,
|
||||
} from '../api/backend'
|
||||
import { floorSlotUtcMs, SLOT_MS } from '../components/charts/chartConstants'
|
||||
import { useIsCoarsePointer } from '../hooks/useMediaQuery'
|
||||
import { useSiteStatus } from '../hooks/useSiteStatus'
|
||||
import {
|
||||
maskForInterval,
|
||||
@@ -899,6 +900,8 @@ function HorizonToggle({
|
||||
export default function Planning() {
|
||||
const { site, ready: siteReady } = useSiteStatus()
|
||||
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 [compareData, setCompareData] = useState<PlanningCompareResponse | null>(null)
|
||||
@@ -1517,7 +1520,10 @@ export default function Planning() {
|
||||
offset: 10,
|
||||
}}
|
||||
/>
|
||||
<Tooltip content={<PlanTooltip nowMs={nowMs} solverSnap={solverSnap} />} />
|
||||
<Tooltip
|
||||
trigger={isCoarse ? 'click' : 'hover'}
|
||||
content={<PlanTooltip nowMs={nowMs} solverSnap={solverSnap} />}
|
||||
/>
|
||||
{chartChargeBands.map((b) => (
|
||||
<ReferenceArea
|
||||
key={`chg-${b.x1}-${b.x2}`}
|
||||
|
||||
Reference in New Issue
Block a user