diff --git a/frontend/src/components/StreamMetricsChart.tsx b/frontend/src/components/StreamMetricsChart.tsx index 621d5b1..e4afb82 100644 --- a/frontend/src/components/StreamMetricsChart.tsx +++ b/frontend/src/components/StreamMetricsChart.tsx @@ -7,7 +7,9 @@ import { Tooltip, XAxis, YAxis, + ReferenceArea, } from "recharts"; +import { useState, useRef, useMemo, useCallback } from "react"; import { MetricsSnapshot } from "../hooks/useMetricsHistory"; interface StreamMetricsChartProps { @@ -20,6 +22,129 @@ function formatTime(timestamp: number): string { } export function StreamMetricsChart({ data }: StreamMetricsChartProps) { + const [zoomRange, setZoomRange] = useState<[number, number] | null>(null); + const [interactionMode, setInteractionMode] = useState<"pan" | "zoom">("pan"); + const [refAreaLeft, setRefAreaLeft] = useState(null); + const [refAreaRight, setRefAreaRight] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const containerRef = useRef(null); + const dragRef = useRef<{ startX: number; startRange: [number, number]; active: boolean } | null>(null); + const pinchRef = useRef<{ startDist: number; startRange: [number, number] } | null>(null); + + const maxIndex = Math.max(0, data.length - 1); + const currentRange = zoomRange || [0, maxIndex]; + + const currentData = useMemo(() => { + return data.slice(Math.max(0, currentRange[0]), Math.min(data.length, currentRange[1] + 1)); + }, [data, currentRange]); + + const visibleMinutes = useMemo(() => { + if (currentData.length < 2) return 1; + const diffMs = currentData[currentData.length - 1].timestamp - currentData[0].timestamp; + return Math.max(1, Math.round(diffMs / 60000)); + }, [currentData]); + + const zoomInOut = useCallback((factor: number) => { + setZoomRange((prev) => { + const range = prev || [0, maxIndex]; + const length = range[1] - range[0]; + if (length <= 1 && factor > 1) return range; + + const newLength = Math.max(1, Math.round(length / factor)); + const center = range[0] + length / 2; + + let newStart = Math.round(center - newLength / 2); + let newEnd = Math.round(center + newLength / 2); + + return [Math.max(0, newStart), Math.min(maxIndex, newEnd)]; + }); + }, [maxIndex]); + + const handlePointerDown = (e: React.PointerEvent) => { + if (interactionMode !== "pan") return; + dragRef.current = { + startX: e.clientX, + startRange: zoomRange || [0, maxIndex], + active: true + }; + setIsDragging(true); + e.currentTarget.setPointerCapture(e.pointerId); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (dragRef.current?.active) { + if (!containerRef.current) return; + const width = containerRef.current.clientWidth || 800; + const { startX, startRange } = dragRef.current; + const deltaX = e.clientX - startX; + + const length = startRange[1] - startRange[0]; + const shiftPoints = Math.round((deltaX / width) * length); + + let newStart = startRange[0] - shiftPoints; + let newEnd = startRange[1] - shiftPoints; + + if (newStart < 0) { + newEnd -= newStart; + newStart = 0; + } + if (newEnd > maxIndex) { + newStart -= (newEnd - maxIndex); + newEnd = maxIndex; + } + setZoomRange([Math.max(0, newStart), Math.min(maxIndex, newEnd)]); + } + }; + + const handlePointerUp = (e: React.PointerEvent) => { + if (dragRef.current) dragRef.current.active = false; + setIsDragging(false); + e.currentTarget.releasePointerCapture(e.pointerId); + }; + + const handleWheel = (e: React.WheelEvent) => { + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + zoomInOut(e.deltaY > 0 ? 0.9 : 1.1); + } + }; + + const handleTouchStart = (e: React.TouchEvent) => { + if (e.touches.length === 2) { + const dist = Math.hypot( + e.touches[0].clientX - e.touches[1].clientX, + e.touches[0].clientY - e.touches[1].clientY + ); + pinchRef.current = { startDist: dist, startRange: zoomRange || [0, maxIndex] }; + } + }; + + const handleTouchMove = (e: React.TouchEvent) => { + if (e.touches.length === 2 && pinchRef.current) { + const dist = Math.hypot( + e.touches[0].clientX - e.touches[1].clientX, + e.touches[0].clientY - e.touches[1].clientY + ); + const { startDist, startRange } = pinchRef.current; + const factor = dist / startDist; + + const length = startRange[1] - startRange[0]; + if (length <= 1 && factor > 1) return; + + const newLength = Math.max(1, Math.round(length / factor)); + const center = startRange[0] + length / 2; + + let newStart = Math.round(center - newLength / 2); + let newEnd = Math.round(center + newLength / 2); + + setZoomRange([Math.max(0, newStart), Math.min(maxIndex, newEnd)]); + } + }; + + const handleTouchEnd = () => { + pinchRef.current = null; + }; + if (data.length === 0) { return (
@@ -32,7 +157,7 @@ export function StreamMetricsChart({ data }: StreamMetricsChartProps) { ); } - const chartData = data.map((snapshot) => ({ + const chartData = currentData.map((snapshot) => ({ time: formatTime(snapshot.timestamp), Active: snapshot.active, Completed: snapshot.completed, @@ -40,12 +165,126 @@ export function StreamMetricsChart({ data }: StreamMetricsChartProps) { })); return ( -
- - +
+
+ Showing last {visibleMinutes} minutes +
+
+ + + + +
+
+ +
+ + { + if (interactionMode === "zoom" && e?.activeLabel) setRefAreaLeft(e.activeLabel); + }} + onMouseMove={(e: any) => { + if (interactionMode === "zoom" && refAreaLeft && e?.activeLabel) setRefAreaRight(e.activeLabel); + }} + onMouseUp={() => { + if (interactionMode === "zoom" && refAreaLeft && refAreaRight) { + const startIndex = currentData.findIndex(d => formatTime(d.timestamp) === refAreaLeft); + const endIndex = currentData.findIndex(d => formatTime(d.timestamp) === refAreaRight); + if (startIndex !== -1 && endIndex !== -1) { + const start = Math.min(startIndex, endIndex); + const end = Math.max(startIndex, endIndex); + const currentStart = currentRange[0]; + setZoomRange([Math.max(0, currentStart + start), Math.min(maxIndex, currentStart + end)]); + } + setRefAreaLeft(null); + setRefAreaRight(null); + } else if (interactionMode === "zoom") { + setRefAreaLeft(null); + setRefAreaRight(null); + } + }} > + {refAreaLeft && refAreaRight && ( + + )} @@ -111,6 +350,7 @@ export function StreamMetricsChart({ data }: StreamMetricsChartProps) { /> +
); }