'use client'; import { useEffect, useState, useRef } from 'react'; import { IChartApi, ISeriesApi } from 'lightweight-charts'; interface Annotation { id: number; timestamp: number; label_type: string; geometry: { startTime?: number; startPrice?: number; endTime?: number; endPrice?: number; } | null; color?: string; created_at: number; } interface SvgOverlayProps { chart: IChartApi | null; series: ISeriesApi<'Candlestick'> | null; activeTool: string | null; onAnnotationChange?: () => void; selectedColor: string; } interface Point { time: number; price: number; } export default function SvgOverlay({ chart, series, activeTool, onAnnotationChange, selectedColor, }: SvgOverlayProps) { const svgRef = useRef(null); const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); const [annotations, setAnnotations] = useState([]); const [drawingLine, setDrawingLine] = useState<{ start: Point; current: Point } | null>(null); const [mousePosition, setMousePosition] = useState<{ x: number; y: number } | null>(null); // Fetch annotations const fetchAnnotations = async () => { try { const response = await fetch('/api/annotations'); const data = await response.json(); setAnnotations(data); } catch (error) { console.error('Failed to fetch annotations:', error); } }; // Update dimensions when SVG resizes useEffect(() => { if (!svgRef.current) return; const updateDimensions = () => { if (svgRef.current) { const rect = svgRef.current.getBoundingClientRect(); setDimensions({ width: rect.width, height: rect.height, }); } }; updateDimensions(); const resizeObserver = new ResizeObserver(updateDimensions); resizeObserver.observe(svgRef.current); return () => { resizeObserver.disconnect(); }; }, []); // Subscribe to visible range changes (zoom/pan) useEffect(() => { if (!chart) return; const handleVisibleRangeChange = () => { // Force re-render by updating state setAnnotations((prev) => [...prev]); }; chart.timeScale().subscribeVisibleTimeRangeChange(handleVisibleRangeChange); return () => { chart.timeScale().unsubscribeVisibleTimeRangeChange(handleVisibleRangeChange); }; }, [chart]); // Fetch annotations on mount and when notified useEffect(() => { fetchAnnotations(); }, [onAnnotationChange]); // Convert data coordinates to pixel coordinates const dataToPixel = (time: number, price: number): { x: number; y: number } | null => { if (!chart || !series) return null; const x = chart.timeScale().timeToCoordinate(time as any); const y = series.priceToCoordinate(price); if (x === null || y === null) return null; return { x, y }; }; // Convert pixel coordinates to data coordinates const pixelToData = (x: number, y: number): Point | null => { if (!chart || !series) return null; const time = chart.timeScale().coordinateToTime(x); const price = series.coordinateToPrice(y); if (time === null || price === null) return null; return { time: typeof time === 'string' ? Date.parse(time) / 1000 : (time as number), price, }; }; // Handle mouse move const handleMouseMove = (e: React.MouseEvent) => { if (!svgRef.current) return; const rect = svgRef.current.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; setMousePosition({ x, y }); if (drawingLine && activeTool === 'line') { const dataPoint = pixelToData(x, y); if (dataPoint) { setDrawingLine((prev) => (prev ? { ...prev, current: dataPoint } : null)); } } }; // Handle click const handleClick = async (e: React.MouseEvent) => { if (!svgRef.current || !chart || !series) return; const rect = svgRef.current.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const dataPoint = pixelToData(x, y); if (!dataPoint) return; // Line drawing mode if (activeTool === 'line') { if (!drawingLine) { // First click - start line setDrawingLine({ start: dataPoint, current: dataPoint, }); } else { // Second click - save line try { const response = await fetch('/api/annotations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ timestamp: drawingLine.start.time, label_type: 'line', color: selectedColor, geometry: { startTime: drawingLine.start.time, startPrice: drawingLine.start.price, endTime: dataPoint.time, endPrice: dataPoint.price, }, }), }); if (response.ok) { await fetchAnnotations(); onAnnotationChange?.(); } } catch (error) { console.error('Failed to create line annotation:', error); } setDrawingLine(null); } } // Delete mode - delete line if (activeTool === 'delete') { // Find line annotation near click point const lineAnnotations = annotations.filter((a) => a.label_type === 'line' && a.geometry); for (const annotation of lineAnnotations) { if (!annotation.geometry) continue; const start = dataToPixel( annotation.geometry.startTime!, annotation.geometry.startPrice! ); const end = dataToPixel(annotation.geometry.endTime!, annotation.geometry.endPrice!); if (!start || !end) continue; // Calculate distance from point to line segment const distance = distanceToLineSegment({ x, y }, start, end); if (distance < 10) { // Within 10 pixels try { const response = await fetch(`/api/annotations/${annotation.id}`, { method: 'DELETE', }); if (response.ok) { await fetchAnnotations(); onAnnotationChange?.(); } } catch (error) { console.error('Failed to delete annotation:', error); } break; } } } }; // Calculate distance from point to line segment const distanceToLineSegment = ( point: { x: number; y: number }, lineStart: { x: number; y: number }, lineEnd: { x: number; y: number } ): number => { const A = point.x - lineStart.x; const B = point.y - lineStart.y; const C = lineEnd.x - lineStart.x; const D = lineEnd.y - lineStart.y; const dot = A * C + B * D; const lenSq = C * C + D * D; let param = -1; if (lenSq !== 0) param = dot / lenSq; let xx, yy; if (param < 0) { xx = lineStart.x; yy = lineStart.y; } else if (param > 1) { xx = lineEnd.x; yy = lineEnd.y; } else { xx = lineStart.x + param * C; yy = lineStart.y + param * D; } const dx = point.x - xx; const dy = point.y - yy; return Math.sqrt(dx * dx + dy * dy); }; // Handle Escape key to cancel line drawing useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && drawingLine) { setDrawingLine(null); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [drawingLine]); // Render line annotations const renderLines = () => { const lineAnnotations = annotations.filter((a) => a.label_type === 'line' && a.geometry); return lineAnnotations.map((annotation) => { if (!annotation.geometry) return null; const start = dataToPixel( annotation.geometry.startTime!, annotation.geometry.startPrice! ); const end = dataToPixel(annotation.geometry.endTime!, annotation.geometry.endPrice!); if (!start || !end) return null; return ( ); }); }; // Render preview line while drawing const renderPreviewLine = () => { if (!drawingLine) return null; const start = dataToPixel(drawingLine.start.time, drawingLine.start.price); const end = dataToPixel(drawingLine.current.time, drawingLine.current.price); if (!start || !end) return null; return ( ); }; // Render cursor circle during line drawing const renderCursorCircle = () => { if (!drawingLine || !mousePosition) return null; return ( ); }; if (!chart || !series) return null; return ( {renderLines()} {renderPreviewLine()} {renderCursorCircle()} ); }