import React, { useRef, useEffect, useState } from "react"; interface Trace { data: number[]; color?: string; // e.g. "#ffe066", "#ff00ff", "#00ffe7" label?: string; } interface Marker { x: number; // index in data y: number; // value label?: string; color?: string; } interface Overlay { text: string; color?: string; x?: number; y?: number; fontSize?: number; } interface NeonAnalyzerChartProps { traces: Trace[]; width?: number; height?: number; overlays?: Overlay[]; markers?: Marker[]; gridColor?: string; bgColor?: string; showLegend?: boolean; yMin?: number; yMax?: number; jobName?: string; } const defaultColors = ["#ffe066", "#ff00ff", "#00ffe7", "#39ff14", "#ffffff"]; // --- Trace color presets --- const colorPresets = [ ["#ffe066", "#ff00ff", "#00ffe7", "#39ff14", "#ffffff"], // Neon ["#ffb300", "#1e88e5", "#43a047", "#e53935", "#8e24aa"], // Pro VNA ["#00e5ff", "#ff4081", "#ffd600", "#00c853", "#d500f9"], // Modern ]; export default function NeonAnalyzerChart({ traces, width = 800, height = 400, overlays = [], markers = [], gridColor = "#00ffe7", bgColor = "#0a0f1c", showLegend = true, yMin = 0, yMax = 100, jobName, }: NeonAnalyzerChartProps) { const canvasRef = useRef(null); const [zoom, setZoom] = useState(1); const [pan, setPan] = useState(0); const [isPanning, setIsPanning] = useState(false); const [panStartX, setPanStartX] = useState(null); const [panVelocity, setPanVelocity] = useState(0); const [lastPanTime, setLastPanTime] = useState(null); const [userPanned, setUserPanned] = useState(false); const [draggedMarker, setDraggedMarker] = useState(null); const [measureMarkers, setMeasureMarkers] = useState<{ x: number; y: number }[]>([]); // Auto-pan to latest data if not manually panned useEffect(() => { if (!userPanned && traces[0]?.data.length > 0) { setPan(1); } }, [traces, userPanned]); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; // Enable shadow for glow effects ctx.shadowBlur = 5; ctx.shadowColor = '#00ffe7'; // Clear canvas with dark background ctx.fillStyle = bgColor; ctx.fillRect(0, 0, width, height); // Draw grid const drawGrid = () => { ctx.strokeStyle = '#00ffe733'; ctx.lineWidth = 0.5; // Vertical grid lines for (let x = 50; x < width - 20; x += 50) { ctx.beginPath(); ctx.moveTo(x, 20); ctx.lineTo(x, height - 50); ctx.stroke(); } // Horizontal grid lines for (let y = 20; y < height - 50; y += 50) { ctx.beginPath(); ctx.moveTo(50, y); ctx.lineTo(width - 20, y); ctx.stroke(); } }; // Draw glowing axes const drawAxes = () => { ctx.strokeStyle = '#00ffe7'; ctx.lineWidth = 2; ctx.shadowBlur = 10; ctx.beginPath(); ctx.moveTo(50, 20); ctx.lineTo(50, height - 50); ctx.lineTo(width - 20, height - 50); ctx.stroke(); ctx.shadowBlur = 5; }; // Draw y-axis labels with cyberpunk style const drawYLabels = () => { ctx.fillStyle = '#00ffe7'; ctx.font = '12px "Share Tech Mono", monospace'; ctx.textAlign = 'right'; const step = (yMax - yMin) / 5; for (let i = 0; i <= 5; i++) { const y = height - 50 - (i / 5) * (height - 70); const value = yMin + i * step; ctx.fillText(value.toFixed(1), 45, y + 4); } }; // Draw x-axis labels const drawXLabels = () => { ctx.fillStyle = '#00ffe7'; ctx.font = '12px "Share Tech Mono", monospace'; ctx.textAlign = 'center'; const maxEpochs = Math.max(...traces.map(t => t.data.length)); const step = Math.ceil(maxEpochs / 10); for (let i = 0; i <= maxEpochs; i += step) { const x = 50 + (i / maxEpochs) * (width - 70); ctx.fillText(i.toString(), x, height - 35); } }; // Draw traces with glow effect const drawTraces = () => { traces.forEach(trace => { if (!trace.data.length) return; ctx.strokeStyle = trace.color || defaultColors[Math.floor(Math.random() * defaultColors.length)]; ctx.lineWidth = 2; ctx.shadowColor = trace.color || defaultColors[Math.floor(Math.random() * defaultColors.length)]; ctx.shadowBlur = 10; ctx.beginPath(); const xScale = (width - 70) / (trace.data.length - 1); const yScale = (height - 70) / (yMax - yMin); trace.data.forEach((value, i) => { const x = 50 + i * xScale; const y = height - 50 - (value - yMin) * yScale; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.stroke(); }); }; // Draw legend with cyberpunk style const drawLegend = () => { if (!showLegend) return; const legendX = width - 150; const legendY = 30; const lineLength = 20; traces.forEach((trace, i) => { const y = legendY + i * 25; // Draw line with glow ctx.strokeStyle = trace.color || defaultColors[Math.floor(Math.random() * defaultColors.length)]; ctx.shadowColor = trace.color || defaultColors[Math.floor(Math.random() * defaultColors.length)]; ctx.shadowBlur = 8; ctx.beginPath(); ctx.moveTo(legendX, y); ctx.lineTo(legendX + lineLength, y); ctx.stroke(); // Draw label ctx.shadowBlur = 0; ctx.fillStyle = trace.color || defaultColors[Math.floor(Math.random() * defaultColors.length)]; ctx.font = '14px "Share Tech Mono", monospace'; ctx.textAlign = 'left'; ctx.fillText(trace.label || `Trace ${i + 1}`, legendX + lineLength + 10, y + 4); }); }; // Draw overlays with glow effect const drawOverlays = () => { overlays.forEach(overlay => { ctx.fillStyle = overlay.color || '#ff0000'; ctx.shadowColor = overlay.color || '#ff0000'; ctx.shadowBlur = 8; ctx.font = `${overlay.fontSize || 24}px "Share Tech Mono", monospace`; ctx.fillText(overlay.text, overlay.x || 0, overlay.y || 0); }); }; // Draw markers [...markers, ...measureMarkers].forEach(marker => { ctx.fillStyle = marker.color || '#ff0000'; ctx.beginPath(); ctx.arc(marker.x, marker.y, 4, 0, Math.PI * 2); ctx.fill(); }); // Execute all drawing functions drawGrid(); drawAxes(); drawYLabels(); drawXLabels(); drawTraces(); drawLegend(); drawOverlays(); }, [traces, width, height, overlays, markers, showLegend, yMin, yMax]); const handleMouseDown = (e: React.MouseEvent) => { const rect = (e.target as HTMLCanvasElement).getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; // Check if clicking near any existing markers const n = traces[0]?.data.length || 0; const windowSize = Math.floor(n / zoom); const startIdx = Math.floor((n - windowSize) * pan); const xScale = (width - 70) / (windowSize - 1); for (let i = 0; i < measureMarkers.length; i++) { const marker = measureMarkers[i]; const markerScreenX = 50 + (marker.x - startIdx) * xScale; if (Math.abs(x - markerScreenX) < 10) { setDraggedMarker(i); return; } } // Otherwise start panning setIsPanning(true); setPanStartX(e.clientX); setUserPanned(true); }; const handleMouseUp = () => { setIsPanning(false); setPanStartX(null); setPanVelocity(0); setDraggedMarker(null); }; const handleMouseMove = (e: React.MouseEvent) => { if (draggedMarker !== null) { // Drag marker horizontally const rect = (e.target as HTMLCanvasElement).getBoundingClientRect(); const x = e.clientX - rect.left; const n = traces[0]?.data.length || 0; const windowSize = Math.floor(n / zoom); const startIdx = Math.floor((n - windowSize) * pan); let epoch = Math.round(((x - 50) / (width - 70)) * (windowSize - 1)) + startIdx; epoch = Math.max(startIdx, Math.min(startIdx + windowSize - 1, epoch)); const y = traces[0]?.data[epoch] ?? 0; setMeasureMarkers(prev => prev.map((m, i) => i === draggedMarker ? { x: epoch, y } : m)); setUserPanned(true); return; } if (!isPanning || panStartX === null) return; const dx = e.clientX - panStartX; setPan(prev => { let next = Math.max(0, Math.min(1, prev - dx / width / zoom)); setPanVelocity(dx / ((Date.now() - (lastPanTime || Date.now())) || 1)); setLastPanTime(Date.now()); setUserPanned(true); return next; }); setPanStartX(e.clientX); }; // Inertial scrolling useEffect(() => { if (!isPanning && Math.abs(panVelocity) > 0.001) { const raf = requestAnimationFrame(() => { setPan(prev => { let next = Math.max(0, Math.min(1, prev - panVelocity * 0.05)); if (next === 0 || next === 1) setPanVelocity(0); else setPanVelocity(panVelocity * 0.92); return next; }); }); return () => cancelAnimationFrame(raf); } }, [isPanning, panVelocity]); return ( ); }