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
This commit is contained in:
Marko Djordjevic 2026-02-16 11:58:49 +01:00
parent 82fd5ce819
commit aea1791122
3 changed files with 117 additions and 9 deletions

View file

@ -145,6 +145,7 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
const previewPrimitiveRef = useRef<TrendLine | RectangleDrawingPrimitive | null>(null);
const linePrimitivesRef = useRef<Map<number, TrendLine>>(new Map());
const rectanglePrimitivesRef = useRef<Map<number, RectangleDrawingPrimitive>>(new Map());
const [selectedRectangleId, setSelectedRectangleId] = useState<number | null>(null);
// Track mounted state to avoid hydration mismatch
useEffect(() => {
@ -687,9 +688,39 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
}
}
// 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<CandleChartHandle, CandleChartProps>(
}
}
// 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<CandleChartHandle, CandleChartProps>(
});
}, [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 (