'use client'; import { useEffect, useState, useRef, useCallback } from 'react'; import { IChartApi, ISeriesApi, Time, MouseEventParams } from 'lightweight-charts'; import { SpanRectanglePrimitive, SpanData } from './SpanRectanglePrimitive'; import SpanPopover from './SpanPopover'; import type { Candle, SpanAnnotation, SpanLabelType } from '@/types'; // Candle, SpanAnnotation, SpanLabelType are imported from @/types above. interface SpanAnnotationManagerProps { chart: IChartApi | null; series: ISeriesApi<'Candlestick'> | null; activeTool: string | null; candles: Candle[]; spanAnnotations: SpanAnnotation[]; spanLabelTypes: SpanLabelType[]; selectedSpanId: number | null; onSpanAnnotationsChange: () => void; onSelectedSpanChange: (spanId: number | null) => void; activeChartId: number | null; } type InteractionState = 'idle' | 'first-click-done' | 'popover-open'; export default function SpanAnnotationManager({ chart, series, activeTool, candles, spanAnnotations, spanLabelTypes, selectedSpanId, onSpanAnnotationsChange, onSelectedSpanChange, activeChartId, }: SpanAnnotationManagerProps) { const [interactionState, setInteractionState] = useState('idle'); const [startCandle, setStartCandle] = useState(null); const [endCandle, setEndCandle] = useState(null); const [cursorCandle, setCursorCandle] = useState(null); const previewPrimitiveRef = useRef(null); const [popoverOpen, setPopoverOpen] = useState(false); const [editingSpan, setEditingSpan] = useState(null); const primitivesRef = useRef>(new Map()); const selectedSpanIdRef = useRef(selectedSpanId); const hasInitializedRef = useRef(false); // Keep selectedSpanIdRef in sync with prop useEffect(() => { selectedSpanIdRef.current = selectedSpanId; }, [selectedSpanId]); // Cleanup preview primitive on unmount useEffect(() => { return () => { if (previewPrimitiveRef.current && series) { try { series.detachPrimitive(previewPrimitiveRef.current); } catch { // series may already be disposed } previewPrimitiveRef.current = null; } }; }, [series]); // Find nearest candle to a timestamp const findNearestCandle = (timestamp: number): Candle | null => { if (candles.length === 0) return null; return candles.reduce((prev, curr) => { return Math.abs(curr.time - timestamp) < Math.abs(prev.time - timestamp) ? curr : prev; }); }; // Calculate price range for candles in a span const calculatePriceRange = (start: Candle, end: Candle): { max_high: number; min_low: number } => { const startIdx = candles.findIndex((c) => c.time === start.time); const endIdx = candles.findIndex((c) => c.time === end.time); if (startIdx === -1 || endIdx === -1) { return { max_high: Math.max(start.high, end.high), min_low: Math.min(start.low, end.low) }; } const [minIdx, maxIdx] = [Math.min(startIdx, endIdx), Math.max(startIdx, endIdx)]; const spanCandles = candles.slice(minIdx, maxIdx + 1); const max_high = Math.max(...spanCandles.map((c) => c.high)); const min_low = Math.min(...spanCandles.map((c) => c.low)); return { max_high, min_low }; }; // Full reconciliation: rebuild all primitives when annotation list changes useEffect(() => { if (!series || !chart) return; // Clear existing primitives primitivesRef.current.forEach((primitive) => { series.detachPrimitive(primitive); }); primitivesRef.current.clear(); // Create primitives for each span annotation spanAnnotations.forEach((span) => { // Find candles within span time range for price calculation const spanCandles = candles.filter( (c) => c.time >= span.start_time && c.time <= span.end_time ); let max_high: number; let min_low: number; if (spanCandles.length > 0) { max_high = Math.max(...spanCandles.map((c) => c.high)); min_low = Math.min(...spanCandles.map((c) => c.low)); } else { // Fallback: find nearest candles to span boundaries const nearest = candles.reduce( (acc, c) => { const distStart = Math.abs(c.time - span.start_time); const distEnd = Math.abs(c.time - span.end_time); if (distStart < acc.startDist) acc = { ...acc, startCandle: c, startDist: distStart }; if (distEnd < acc.endDist) acc = { ...acc, endCandle: c, endDist: distEnd }; return acc; }, { startCandle: candles[0], startDist: Infinity, endCandle: candles[0], endDist: Infinity } ); max_high = Math.max(nearest.startCandle?.high ?? 0, nearest.endCandle?.high ?? 0); min_low = Math.min(nearest.startCandle?.low ?? 0, nearest.endCandle?.low ?? 0); } const spanData: SpanData = { id: span.id, start_time: span.start_time, end_time: span.end_time, label: span.label, color: span.color, max_high: max_high, min_low: min_low, }; const primitive = new SpanRectanglePrimitive({ data: spanData, isSelected: span.id === selectedSpanIdRef.current, }); series.attachPrimitive(primitive); primitivesRef.current.set(span.id, primitive); }); }, [spanAnnotations, series, chart, candles]); // Selection-only effect: update visual state without rebuilding primitives useEffect(() => { primitivesRef.current.forEach((primitive, spanId) => { primitive.setSelected(spanId === selectedSpanId); }); }, [selectedSpanId]); // Fit chart content only on initial load when chart and series become available useEffect(() => { if (!chart || !series) return; if (hasInitializedRef.current) return; hasInitializedRef.current = true; chart.timeScale().fitContent(); }, [chart, series]); // Handle clicks on chart for span tool and span selection useEffect(() => { if (!chart || !series) return; // Clean up preview if tool changes away from span if (activeTool !== 'span' && previewPrimitiveRef.current) { series.detachPrimitive(previewPrimitiveRef.current); previewPrimitiveRef.current = null; setInteractionState('idle'); setStartCandle(null); setEndCandle(null); } const handleClick = (param: MouseEventParams) => { if (!param.point) return; const point = param.point; const time = chart.timeScale().coordinateToTime(point.x); const price = series.coordinateToPrice(point.y); if (!time || !price) return; // Handle span tool two-click interaction if (activeTool === 'span') { const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number); const nearestCandle = findNearestCandle(timestamp); if (!nearestCandle) return; if (interactionState === 'idle') { // Check if clicking on existing span for selection let clickedSpanId: number | null = null; primitivesRef.current.forEach((primitive, spanId) => { if (primitive.hitTest(point.x, point.y)) { clickedSpanId = spanId; } }); if (clickedSpanId !== null) { // Select/deselect span if (clickedSpanId === selectedSpanId) { onSelectedSpanChange(null); } else { onSelectedSpanChange(clickedSpanId); } return; } // First click: set start candle setStartCandle(nearestCandle); setInteractionState('first-click-done'); } else if (interactionState === 'first-click-done') { // Second click: set end candle and open popover setEndCandle(nearestCandle); setInteractionState('popover-open'); setPopoverOpen(true); // Clean up preview primitive if (previewPrimitiveRef.current) { series.detachPrimitive(previewPrimitiveRef.current); previewPrimitiveRef.current = null; } } } else if (activeTool === 'delete') { // Delete span on click with delete tool let clickedSpanId: number | null = null; primitivesRef.current.forEach((primitive, spanId) => { if (primitive.hitTest(point.x, point.y)) { clickedSpanId = spanId; } }); if (clickedSpanId !== null) { handleDeleteSpan(clickedSpanId); } } else if (!activeTool) { // Click to select span when no tool is active let clickedSpanId: number | null = null; primitivesRef.current.forEach((primitive, spanId) => { if (primitive.hitTest(point.x, point.y)) { clickedSpanId = spanId; } }); if (clickedSpanId !== null) { if (clickedSpanId === selectedSpanId) { onSelectedSpanChange(null); } else { onSelectedSpanChange(clickedSpanId); } } else { // Deselect if clicking outside any span onSelectedSpanChange(null); } } }; chart.subscribeClick(handleClick); return () => { chart.unsubscribeClick(handleClick); }; }, [chart, series, activeTool, interactionState, candles, selectedSpanId, onSelectedSpanChange]); // Handle mouse move for preview useEffect(() => { if (!chart || !series || activeTool !== 'span' || interactionState !== 'first-click-done' || !startCandle) { return; } const handleMouseMove = (param: MouseEventParams) => { if (!param.point) return; const time = chart.timeScale().coordinateToTime(param.point.x); if (!time) return; const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number); const nearestCandle = findNearestCandle(timestamp); if (!nearestCandle) return; // Update cursor candle for hotkey usage setCursorCandle(nearestCandle); // Calculate price range for preview const { max_high, min_low } = calculatePriceRange(startCandle, nearestCandle); // Swap if end < start const [start_time, end_time] = startCandle.time <= nearestCandle.time ? [startCandle.time, nearestCandle.time] : [nearestCandle.time, startCandle.time]; const previewData: SpanData = { id: -1, // Preview ID start_time, end_time, label: 'PREVIEW', color: '#888888', max_high, min_low, }; // Remove old preview primitive if (previewPrimitiveRef.current) { series.detachPrimitive(previewPrimitiveRef.current); } // Create new preview primitive const newPreview = new SpanRectanglePrimitive({ data: previewData, isSelected: false, }); series.attachPrimitive(newPreview); previewPrimitiveRef.current = newPreview; }; chart.subscribeCrosshairMove(handleMouseMove); return () => { chart.unsubscribeCrosshairMove(handleMouseMove); }; }, [chart, series, activeTool, interactionState, startCandle, candles]); // Handle Escape key to cancel span selection useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && activeTool === 'span') { if (interactionState === 'first-click-done') { // Cancel span selection setInteractionState('idle'); setStartCandle(null); setEndCandle(null); // Clean up preview if (previewPrimitiveRef.current && series) { series.detachPrimitive(previewPrimitiveRef.current); previewPrimitiveRef.current = null; } } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [activeTool, interactionState, series]); // Handle span deletion const handleDeleteSpan = useCallback(async (spanId: number) => { try { const response = await fetch(`/api/span-annotations/${spanId}`, { method: 'DELETE', }); if (response.ok) { onSpanAnnotationsChange(); if (selectedSpanIdRef.current === spanId) { onSelectedSpanChange(null); } } else { console.error('Failed to delete span annotation'); } } catch (error) { console.error('Error deleting span annotation:', error); } }, [onSpanAnnotationsChange, onSelectedSpanChange]); // Handle popover save const handlePopoverSave = async (data: { label: string; confidence: number | null; outcome: string | null; notes: string | null; }) => { // If editing existing span if (editingSpan) { // Find label type color const labelType = spanLabelTypes.find((t) => t.name === data.label); const color = labelType?.color || editingSpan.color; try { const response = await fetch(`/api/span-annotations/${editingSpan.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label: data.label, confidence: data.confidence, outcome: data.outcome, notes: data.notes, color, }), }); if (response.ok) { onSpanAnnotationsChange(); setPopoverOpen(false); setEditingSpan(null); } else { console.error('Failed to update span annotation'); } } catch (error) { console.error('Error updating span annotation:', error); } return; } // Creating new span if (!startCandle || !endCandle || !activeChartId) return; // Swap if end < start const [start_time, end_time] = startCandle.time <= endCandle.time ? [startCandle.time, endCandle.time] : [endCandle.time, startCandle.time]; // Find label type color const labelType = spanLabelTypes.find((t) => t.name === data.label); const color = labelType?.color || '#2196F3'; try { const response = await fetch('/api/span-annotations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chart_id: activeChartId, start_time, end_time, label: data.label, confidence: data.confidence, outcome: data.outcome, notes: data.notes, color, }), }); if (response.ok) { onSpanAnnotationsChange(); setPopoverOpen(false); setInteractionState('idle'); setStartCandle(null); setEndCandle(null); } else { console.error('Failed to create span annotation'); } } catch (error) { console.error('Error creating span annotation:', error); } }; // Handle popover cancel const handlePopoverCancel = () => { setPopoverOpen(false); setInteractionState('idle'); setStartCandle(null); setEndCandle(null); setEditingSpan(null); }; // Handle keyboard shortcuts for deletion, editing, and hotkey label assignment useEffect(() => { const handleKeyDown = async (e: KeyboardEvent) => { // Delete/Backspace: delete selected span const currentSelectedSpanId = selectedSpanIdRef.current; if ((e.key === 'Delete' || e.key === 'Backspace') && currentSelectedSpanId !== null) { e.preventDefault(); handleDeleteSpan(currentSelectedSpanId); return; } // Enter: open edit popover for selected span if (e.key === 'Enter' && currentSelectedSpanId !== null) { const span = spanAnnotations.find((s) => s.id === currentSelectedSpanId); if (span) { setEditingSpan(span); setPopoverOpen(true); } return; } // Hotkey label assignment: only when span tool is active and a span range is selected (after first click) if (activeTool === 'span' && interactionState === 'first-click-done' && startCandle) { // Check if key matches any label hotkey const matchingLabel = spanLabelTypes.find((lt) => lt.hotkey === e.key && lt.is_active); if (matchingLabel && activeChartId) { e.preventDefault(); // Use cursor candle if available, otherwise use start candle const endCandleForHotkey = cursorCandle || startCandle; // Swap if end < start const [start_time, end_time] = startCandle.time <= endCandleForHotkey.time ? [startCandle.time, endCandleForHotkey.time] : [endCandleForHotkey.time, startCandle.time]; // Create span with hotkey label (default confidence/outcome/notes) try { const response = await fetch('/api/span-annotations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chart_id: activeChartId, start_time, end_time, label: matchingLabel.name, confidence: 3, // Default confidence outcome: null, notes: null, color: matchingLabel.color, }), }); if (response.ok) { onSpanAnnotationsChange(); // Clean up preview and reset state if (previewPrimitiveRef.current && series) { series.detachPrimitive(previewPrimitiveRef.current); previewPrimitiveRef.current = null; } setInteractionState('idle'); setStartCandle(null); setEndCandle(null); } } catch (error) { console.error('Error creating span with hotkey:', error); } } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [handleDeleteSpan, spanAnnotations, activeTool, interactionState, startCandle, cursorCandle, spanLabelTypes, activeChartId, series, onSpanAnnotationsChange]); // Handle double-click to edit span useEffect(() => { if (!chart) return; const handleDblClick = (param: MouseEventParams) => { if (!param.point) return; const point = param.point; let clickedSpanId: number | null = null; primitivesRef.current.forEach((primitive, spanId) => { if (primitive.hitTest(point.x, point.y)) { clickedSpanId = spanId; } }); if (clickedSpanId !== null) { const span = spanAnnotations.find((s) => s.id === clickedSpanId); if (span) { setEditingSpan(span); setPopoverOpen(true); } } }; chart.subscribeDblClick(handleDblClick); return () => { chart.unsubscribeDblClick(handleDblClick); }; }, [chart, spanAnnotations]); return ( ); }