diff --git a/frontend/src/components/charts/EconomicsChart.tsx b/frontend/src/components/charts/EconomicsChart.tsx index f33afea..d03151b 100644 --- a/frontend/src/components/charts/EconomicsChart.tsx +++ b/frontend/src/components/charts/EconomicsChart.tsx @@ -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 (
@@ -134,7 +138,10 @@ export function EconomicsChart({ points, hasGreenBonus }: Props) { style: { fontSize: 11, fill: BLUE }, }} /> - } /> + } + /> { diff --git a/frontend/src/components/charts/EnergyChart.tsx b/frontend/src/components/charts/EnergyChart.tsx index b6e4656..b0d51ad 100644 --- a/frontend/src/components/charts/EnergyChart.tsx +++ b/frontend/src/components/charts/EnergyChart.tsx @@ -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(null) const chartRef = useRef(null) + const wrapRef = useRef(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(null) + const slotsRef = useRef([]) const negRangesRef = useRef>([]) 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) => { + 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 ( -
+
+ {touchInfo ? ( +
+
+ {touchInfo.title} + +
+
+ {touchInfo.rows.map((r) => ( + + {r.label}: {r.text} + + ))} +
+
+ ) : null}
(null) const chartRef = useRef(null) + const wrapRef = useRef(null) + + // Touch zařízení: hover tooltip vypnutý; tap vybere slot do panelu nad grafem. + const isCoarse = useIsCoarsePointer() + const [touchIdx, setTouchIdx] = useState(null) const slotsRef = useRef([]) const negRangesRef = useRef>([]) @@ -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) => { + 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 ( -
- +
+ {touchInfo ? ( +
+
+ {touchInfo.title} + +
+
+ {touchInfo.rows.map((r) => ( + + {r.label}: {r.text} + + ))} +
+
+ ) : null} +
+ +
) } diff --git a/frontend/src/hooks/useMediaQuery.ts b/frontend/src/hooks/useMediaQuery.ts new file mode 100644 index 0000000..afe0644 --- /dev/null +++ b/frontend/src/hooks/useMediaQuery.ts @@ -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(() => + 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)') +} diff --git a/frontend/src/pages/Planning.tsx b/frontend/src/pages/Planning.tsx index 81484ba..9839161 100644 --- a/frontend/src/pages/Planning.tsx +++ b/frontend/src/pages/Planning.tsx @@ -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(null) const [compareData, setCompareData] = useState(null) @@ -1517,7 +1520,10 @@ export default function Planning() { offset: 10, }} /> - } /> + } + /> {chartChargeBands.map((b) => (