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,
|
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) => {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
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}`}
|
||||||
|
|||||||
Reference in New Issue
Block a user