From aea1791122476478534000e50277461feaef458f Mon Sep 17 00:00:00 2001 From: Marko Djordjevic Date: Mon, 16 Feb 2026 11:58:49 +0100 Subject: [PATCH] feat: complete rectangle annotation tool (tasks 4.1-5.2) - Add rectangle primitive management in CandleChart - Handle chart switching with proper primitive cleanup - Implement rectangle selection via hitTest - Add rectangle deletion in delete tool - Add rectangle tool button to Toolbox - Wire rectangle tool with toggle behavior --- .../line-rectangle-annotations/tasks.md | 12 +- src/components/CandleChart.tsx | 104 +++++++++++++++++- src/components/Toolbox.tsx | 10 ++ 3 files changed, 117 insertions(+), 9 deletions(-) diff --git a/openspec/changes/line-rectangle-annotations/tasks.md b/openspec/changes/line-rectangle-annotations/tasks.md index 24925c9..41c4373 100644 --- a/openspec/changes/line-rectangle-annotations/tasks.md +++ b/openspec/changes/line-rectangle-annotations/tasks.md @@ -25,15 +25,15 @@ ## 4. Wire Up Rectangle Primitives in CandleChart -- [ ] 4.1 On annotation fetch, create `RectangleDrawingPrimitive` instances for `label_type: "rectangle"` annotations and attach to series -- [ ] 4.2 On chart switch, detach old rectangle primitives and create new ones for the new chart's annotations -- [ ] 4.3 Handle rectangle selection — on click hit detected via primitive `hitTest()`, call `setSelected()` and track selected annotation ID -- [ ] 4.4 Handle rectangle deletion — when delete tool active and hit detected, send DELETE API call, detach primitive, refresh annotations +- [x] 4.1 On annotation fetch, create `RectangleDrawingPrimitive` instances for `label_type: "rectangle"` annotations and attach to series +- [x] 4.2 On chart switch, detach old rectangle primitives and create new ones for the new chart's annotations +- [x] 4.3 Handle rectangle selection — on click hit detected via primitive `hitTest()`, call `setSelected()` and track selected annotation ID +- [x] 4.4 Handle rectangle deletion — when delete tool active and hit detected, send DELETE API call, detach primitive, refresh annotations ## 5. Update Toolbox -- [ ] 5.1 Add "rectangle" tool button to Toolbox (using existing `RectangleHorizontal` lucide icon import, which is already present) -- [ ] 5.2 Wire rectangle button to `onToolChange('rectangle')` with same toggle behavior as other tools +- [x] 5.1 Add "rectangle" tool button to Toolbox (using existing `RectangleHorizontal` lucide icon import, which is already present) +- [x] 5.2 Wire rectangle button to `onToolChange('rectangle')` with same toggle behavior as other tools ## 6. Remove SVG Overlay diff --git a/src/components/CandleChart.tsx b/src/components/CandleChart.tsx index 244e1d7..01b27a9 100644 --- a/src/components/CandleChart.tsx +++ b/src/components/CandleChart.tsx @@ -145,6 +145,7 @@ const CandleChart = forwardRef( const previewPrimitiveRef = useRef(null); const linePrimitivesRef = useRef>(new Map()); const rectanglePrimitivesRef = useRef>(new Map()); + const [selectedRectangleId, setSelectedRectangleId] = useState(null); // Track mounted state to avoid hydration mismatch useEffect(() => { @@ -687,9 +688,39 @@ const CandleChart = forwardRef( } } - // For delete tool, find and delete marker at clicked position + // For delete tool, find and delete marker or rectangle at clicked position if (activeTool === 'delete') { const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number); + + // First, check for rectangle hit using primitives' hitTest + let rectangleHit: { id: number; primitive: RectangleDrawingPrimitive } | null = null; + rectanglePrimitivesRef.current.forEach((primitive, id) => { + const hit = primitive.hitTest(timeCoordinate, priceCoordinate); + if (hit) { + rectangleHit = { id, primitive }; + } + }); + + if (rectangleHit) { + // Delete the clicked rectangle + try { + const response = await fetch(`/api/annotations/${rectangleHit.id}`, { + method: 'DELETE', + }); + + if (response.ok) { + seriesRef.current!.detachPrimitive(rectangleHit.primitive); + rectanglePrimitivesRef.current.delete(rectangleHit.id); + await fetchAnnotations(); + onAnnotationChange?.(); + } + } catch (error) { + console.error('Failed to delete rectangle annotation:', error); + } + return; // Don't process marker deletion + } + + // If no rectangle hit, check for marker annotations const markerTypeNames = annotationTypes .filter((t) => t.category === 'marker') .map((t) => t.name); @@ -718,6 +749,25 @@ const CandleChart = forwardRef( } } + // Handle rectangle selection when no tool is active or delete tool is active + if (!activeTool || activeTool === 'delete') { + // Check if a rectangle was clicked + rectanglePrimitivesRef.current.forEach((primitive, id) => { + const hit = primitive.hitTest(timeCoordinate, priceCoordinate); + if (hit && activeTool !== 'delete') { + // Toggle selection + const newSelectedId = selectedRectangleId === id ? null : id; + setSelectedRectangleId(newSelectedId); + + // Update all rectangles' selection state + rectanglePrimitivesRef.current.forEach((p, pid) => { + p.setSelected(pid === newSelectedId); + }); + seriesRef.current!.applyOptions({}); + } + }); + } + // Handle clicks on prediction spans (for converting to annotations or dismissing) if (!activeTool && predictionVisible && predictionSpans.length > 0) { const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number); @@ -898,12 +948,60 @@ const CandleChart = forwardRef( }); }, [annotations, annotationTypes, selectedColor]); - // Fetch data on mount + // Manage RectangleDrawingPrimitive for saved rectangle annotations + useEffect(() => { + if (!seriesRef.current || annotations.length === 0) return; + + // Filter rectangle annotations + const rectangleAnnotations = annotations.filter((a) => a.label_type === 'rectangle' && a.geometry); + + // Detach old primitives that no longer exist + const currentIds = new Set(rectangleAnnotations.map((a) => a.id)); + rectanglePrimitivesRef.current.forEach((primitive, id) => { + if (!currentIds.has(id)) { + seriesRef.current!.detachPrimitive(primitive); + rectanglePrimitivesRef.current.delete(id); + } + }); + + // Create/update primitives for rectangle annotations + rectangleAnnotations.forEach((annotation) => { + const geometry = annotation.geometry!; + if (!geometry.startTime || !geometry.endTime) return; + + // Check if primitive already exists + if (!rectanglePrimitivesRef.current.has(annotation.id)) { + // Create new RectangleDrawingPrimitive + const p1: RectanglePoint = { + time: geometry.startTime as Time, + price: geometry.startPrice!, + }; + const p2: RectanglePoint = { + time: geometry.endTime as Time, + price: geometry.endPrice!, + }; + + const color = annotationTypes.find((t) => t.name === 'rectangle')?.color || selectedColor; + + const rectangle = new RectangleDrawingPrimitive({ + p1, + p2, + color, + annotationId: String(annotation.id), + }); + + seriesRef.current!.attachPrimitive(rectangle); + rectanglePrimitivesRef.current.set(annotation.id, rectangle); + } + }); + }, [annotations, annotationTypes, selectedColor]); + + // Fetch data on mount and when chart switches useEffect(() => { fetchCandles(); fetchAnnotations(); fetchAnnotationTypes(); - }, []); + }, [activeChartId]); if (isEmpty) { return ( diff --git a/src/components/Toolbox.tsx b/src/components/Toolbox.tsx index 713fe79..52c08ea 100644 --- a/src/components/Toolbox.tsx +++ b/src/components/Toolbox.tsx @@ -204,6 +204,16 @@ export default function Toolbox({ ))} + {/* Rectangle tool button */} + + {/* Span tool button */}