diff --git a/openspec/changes/line-rectangle-annotations/tasks.md b/openspec/changes/line-rectangle-annotations/tasks.md index 228e6a3..24925c9 100644 --- a/openspec/changes/line-rectangle-annotations/tasks.md +++ b/openspec/changes/line-rectangle-annotations/tasks.md @@ -17,11 +17,11 @@ ## 3. Wire Up Drawing Interaction in CandleChart -- [ ] 3.1 Add state for drawing mode: `drawingState: {tool: 'line'|'rectangle', firstPoint: {time, price}} | null` and `previewPrimitive: TrendLine | RectangleDrawingPrimitive | null` -- [ ] 3.2 Subscribe to `chart.subscribeClick()` — on first click when line/rectangle tool active, record first point and attach preview primitive; on second click, save annotation via API, detach preview, attach permanent primitive -- [ ] 3.3 Subscribe to `chart.subscribeCrosshairMove()` — when drawing in progress, update preview primitive's endpoint via `updatePoints()` or equivalent -- [ ] 3.4 Handle Escape key — detach preview primitive and clear drawing state -- [ ] 3.5 Manage TrendLine primitives for saved line annotations — create/attach on load, detach on delete, update on edit +- [x] 3.1 Add state for drawing mode: `drawingState: {tool: 'line'|'rectangle', firstPoint: {time, price}} | null` and `previewPrimitive: TrendLine | RectangleDrawingPrimitive | null` +- [x] 3.2 Subscribe to `chart.subscribeClick()` — on first click when line/rectangle tool active, record first point and attach preview primitive; on second click, save annotation via API, detach preview, attach permanent primitive +- [x] 3.3 Subscribe to `chart.subscribeCrosshairMove()` — when drawing in progress, update preview primitive's endpoint via `updatePoints()` or equivalent +- [x] 3.4 Handle Escape key — detach preview primitive and clear drawing state +- [x] 3.5 Manage TrendLine primitives for saved line annotations — create/attach on load, detach on delete, update on edit ## 4. Wire Up Rectangle Primitives in CandleChart diff --git a/src/components/CandleChart.tsx b/src/components/CandleChart.tsx index 94aeff5..244e1d7 100644 --- a/src/components/CandleChart.tsx +++ b/src/components/CandleChart.tsx @@ -5,8 +5,20 @@ import { createChart, IChartApi, ISeriesApi, CandlestickData, HistogramData, Tim import { useTheme } from 'next-themes'; import SvgOverlay from './SvgOverlay'; import SpanAnnotationManager from './SpanAnnotationManager'; +import { TrendLine } from '@/plugins/trend-line'; +import { RectangleDrawingPrimitive, RectanglePoint } from '@/plugins/rectangle-drawing'; import type { PerCandlePrediction, PredictionSpan, ModelInfoResponse, PredictionSummary } from '@/types/predictions'; +interface DrawingState { + tool: 'line' | 'rectangle'; + firstPoint: { time: Time; price: number }; +} + +interface Point { + time: Time; + price: number; +} + interface Candle { time: number; open: number; @@ -127,6 +139,12 @@ const CandleChart = forwardRef( const [isEmpty, setIsEmpty] = useState(true); const { theme, resolvedTheme } = useTheme(); const [mounted, setMounted] = useState(false); + + // Drawing state for line and rectangle tools + const [drawingState, setDrawingState] = useState(null); + const previewPrimitiveRef = useRef(null); + const linePrimitivesRef = useRef>(new Map()); + const rectanglePrimitivesRef = useRef>(new Map()); // Track mounted state to avoid hydration mismatch useEffect(() => { @@ -533,7 +551,7 @@ const CandleChart = forwardRef( if (!chartRef.current || !seriesRef.current) return; const handleClick = async (param: any) => { - if (!param.point || !activeTool) return; + if (!param.point) return; const timeCoordinate = param.point.x; const priceCoordinate = param.point.y; @@ -543,6 +561,96 @@ const CandleChart = forwardRef( if (time === null || price === null) return; + // Handle line and rectangle drawing + if (activeTool === 'line' || activeTool === 'rectangle') { + if (!drawingState) { + // First click: record first point and show preview + const firstPoint = { time, price }; + setDrawingState({ tool: activeTool, firstPoint }); + + // Create preview primitive + if (seriesRef.current && chartRef.current) { + const previewPoint = { time, price }; + + if (activeTool === 'line') { + const preview = new TrendLine( + chartRef.current, + seriesRef.current, + firstPoint as Point, + previewPoint as Point, + { + lineColor: selectedColor, + width: 2, + showLabels: false, + isPreview: true, + } + ); + seriesRef.current.attachPrimitive(preview); + previewPrimitiveRef.current = preview; + } else { + const preview = new RectangleDrawingPrimitive({ + p1: firstPoint as RectanglePoint, + p2: previewPoint as RectanglePoint, + color: selectedColor, + isPreview: true, + }); + seriesRef.current.attachPrimitive(preview); + previewPrimitiveRef.current = preview; + } + } + } else { + // Second click: save annotation and create permanent primitive + const secondPoint = { time, price }; + + try { + const timestamp = typeof drawingState.firstPoint.time === 'string' + ? Date.parse(drawingState.firstPoint.time) / 1000 + : (drawingState.firstPoint.time as number); + + const response = await fetch('/api/annotations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + timestamp, + label_type: activeTool, + chart_id: activeChartId, + color: selectedColor, + geometry: { + startTime: typeof drawingState.firstPoint.time === 'string' + ? Date.parse(drawingState.firstPoint.time) / 1000 + : drawingState.firstPoint.time, + startPrice: drawingState.firstPoint.price, + endTime: typeof secondPoint.time === 'string' + ? Date.parse(secondPoint.time) / 1000 + : secondPoint.time, + endPrice: secondPoint.price, + }, + }), + }); + + if (response.ok) { + // Detach preview + if (previewPrimitiveRef.current && seriesRef.current) { + seriesRef.current.detachPrimitive(previewPrimitiveRef.current); + previewPrimitiveRef.current = null; + } + + // Clear drawing state + setDrawingState(null); + + // Refresh annotations to load the permanent primitive + await fetchAnnotations(); + onAnnotationChange?.(); + } + } catch (error) { + console.error('Failed to create annotation:', error); + } + } + return; // Don't process other tools + } + + if (!activeTool) return; + // Check if activeTool is a marker type const markerType = annotationTypes.find( (t) => t.category === 'marker' && t.name === activeTool @@ -675,7 +783,120 @@ const CandleChart = forwardRef( return () => { chartRef.current?.unsubscribeClick(handleClick); }; - }, [activeTool, candles, annotations, annotationTypes, onAnnotationChange, predictionVisible, predictionSpans, predictionSummary, onPredictionClick, onPredictionDismiss]); + }, [activeTool, candles, annotations, annotationTypes, onAnnotationChange, predictionVisible, predictionSpans, predictionSummary, onPredictionClick, onPredictionDismiss, drawingState, selectedColor]); + + // Handle crosshair move to update preview during drawing + useEffect(() => { + if (!chartRef.current || !seriesRef.current || !drawingState || !previewPrimitiveRef.current) return; + + const handleCrosshairMove = (param: any) => { + if (!param.point) return; + + const time = chartRef.current!.timeScale().coordinateToTime(param.point.x); + const price = seriesRef.current!.coordinateToPrice(param.point.y); + + if (time === null || price === null) return; + + const currentPoint = { time, price }; + + // Update preview primitive endpoint + if (previewPrimitiveRef.current) { + if (previewPrimitiveRef.current instanceof TrendLine) { + previewPrimitiveRef.current.updatePoints( + drawingState.firstPoint as Point, + currentPoint as Point + ); + } else if (previewPrimitiveRef.current instanceof RectangleDrawingPrimitive) { + previewPrimitiveRef.current.updatePoints( + drawingState.firstPoint as RectanglePoint, + currentPoint as RectanglePoint + ); + } + seriesRef.current!.applyOptions({}); + } + }; + + chartRef.current.subscribeCrosshairMove(handleCrosshairMove); + + return () => { + chartRef.current?.unsubscribeCrosshairMove(handleCrosshairMove); + }; + }, [drawingState]); + + // Handle Escape key to cancel drawing + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && drawingState && previewPrimitiveRef.current) { + // Detach preview primitive + if (seriesRef.current) { + seriesRef.current.detachPrimitive(previewPrimitiveRef.current); + previewPrimitiveRef.current = null; + } + // Clear drawing state + setDrawingState(null); + } + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [drawingState]); + + // Manage TrendLine primitives for saved line annotations + useEffect(() => { + if (!chartRef.current || !seriesRef.current || annotations.length === 0) return; + + // Filter line annotations + const lineAnnotations = annotations.filter((a) => a.label_type === 'line' && a.geometry); + + // Detach old primitives that no longer exist + const currentIds = new Set(lineAnnotations.map((a) => a.id)); + linePrimitivesRef.current.forEach((primitive, id) => { + if (!currentIds.has(id)) { + seriesRef.current!.detachPrimitive(primitive); + linePrimitivesRef.current.delete(id); + } + }); + + // Create/update primitives for line annotations + lineAnnotations.forEach((annotation) => { + const geometry = annotation.geometry!; + if (!geometry.startTime || !geometry.endTime) return; + + // Check if primitive already exists + if (!linePrimitivesRef.current.has(annotation.id)) { + // Create new TrendLine primitive + const p1: Point = { + time: geometry.startTime as Time, + price: geometry.startPrice!, + }; + const p2: Point = { + time: geometry.endTime as Time, + price: geometry.endPrice!, + }; + + const color = annotationTypes.find((t) => t.name === 'line')?.color || selectedColor; + + const trendLine = new TrendLine( + chartRef.current!, + seriesRef.current!, + p1, + p2, + { + lineColor: color, + width: 2, + showLabels: false, + annotationId: String(annotation.id), + } + ); + + seriesRef.current!.attachPrimitive(trendLine); + linePrimitivesRef.current.set(annotation.id, trendLine); + } + }); + }, [annotations, annotationTypes, selectedColor]); // Fetch data on mount useEffect(() => {