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:
parent
82fd5ce819
commit
aea1791122
3 changed files with 117 additions and 9 deletions
|
|
@ -25,15 +25,15 @@
|
||||||
|
|
||||||
## 4. Wire Up Rectangle Primitives in CandleChart
|
## 4. Wire Up Rectangle Primitives in CandleChart
|
||||||
|
|
||||||
- [ ] 4.1 On annotation fetch, create `RectangleDrawingPrimitive` instances for `label_type: "rectangle"` annotations and attach to series
|
- [x] 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
|
- [x] 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
|
- [x] 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.4 Handle rectangle deletion — when delete tool active and hit detected, send DELETE API call, detach primitive, refresh annotations
|
||||||
|
|
||||||
## 5. Update Toolbox
|
## 5. Update Toolbox
|
||||||
|
|
||||||
- [ ] 5.1 Add "rectangle" tool button to Toolbox (using existing `RectangleHorizontal` lucide icon import, which is already present)
|
- [x] 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.2 Wire rectangle button to `onToolChange('rectangle')` with same toggle behavior as other tools
|
||||||
|
|
||||||
## 6. Remove SVG Overlay
|
## 6. Remove SVG Overlay
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,7 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
||||||
const previewPrimitiveRef = useRef<TrendLine | RectangleDrawingPrimitive | null>(null);
|
const previewPrimitiveRef = useRef<TrendLine | RectangleDrawingPrimitive | null>(null);
|
||||||
const linePrimitivesRef = useRef<Map<number, TrendLine>>(new Map());
|
const linePrimitivesRef = useRef<Map<number, TrendLine>>(new Map());
|
||||||
const rectanglePrimitivesRef = useRef<Map<number, RectangleDrawingPrimitive>>(new Map());
|
const rectanglePrimitivesRef = useRef<Map<number, RectangleDrawingPrimitive>>(new Map());
|
||||||
|
const [selectedRectangleId, setSelectedRectangleId] = useState<number | null>(null);
|
||||||
|
|
||||||
// Track mounted state to avoid hydration mismatch
|
// Track mounted state to avoid hydration mismatch
|
||||||
useEffect(() => {
|
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') {
|
if (activeTool === 'delete') {
|
||||||
const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
|
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
|
const markerTypeNames = annotationTypes
|
||||||
.filter((t) => t.category === 'marker')
|
.filter((t) => t.category === 'marker')
|
||||||
.map((t) => t.name);
|
.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)
|
// Handle clicks on prediction spans (for converting to annotations or dismissing)
|
||||||
if (!activeTool && predictionVisible && predictionSpans.length > 0) {
|
if (!activeTool && predictionVisible && predictionSpans.length > 0) {
|
||||||
const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
|
const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
|
||||||
|
|
@ -898,12 +948,60 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
||||||
});
|
});
|
||||||
}, [annotations, annotationTypes, selectedColor]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
fetchCandles();
|
fetchCandles();
|
||||||
fetchAnnotations();
|
fetchAnnotations();
|
||||||
fetchAnnotationTypes();
|
fetchAnnotationTypes();
|
||||||
}, []);
|
}, [activeChartId]);
|
||||||
|
|
||||||
if (isEmpty) {
|
if (isEmpty) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,16 @@ export default function Toolbox({
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Rectangle tool button */}
|
||||||
|
<Button
|
||||||
|
variant={activeTool === 'rectangle' ? 'default' : 'outline'}
|
||||||
|
className="justify-start gap-2"
|
||||||
|
onClick={() => handleToolClick('rectangle')}
|
||||||
|
>
|
||||||
|
<RectangleHorizontal className="w-5 h-5" />
|
||||||
|
Rectangle
|
||||||
|
</Button>
|
||||||
|
|
||||||
{/* Span tool button */}
|
{/* Span tool button */}
|
||||||
<Button
|
<Button
|
||||||
variant={activeTool === 'span' ? 'default' : 'outline'}
|
variant={activeTool === 'span' ? 'default' : 'outline'}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue