feat: implement section 8 - span selection, editing, and deletion

This commit is contained in:
Marko Djordjevic 2026-02-14 10:11:51 +01:00
parent 586f02ed69
commit 2f05136f20
2 changed files with 202 additions and 33 deletions

View file

@ -54,12 +54,12 @@
## 8. Span Selection, Editing & Deletion ## 8. Span Selection, Editing & Deletion
- [ ] 8.1 Implement span click-to-select using hitTest: set selectedSpanId, highlight rectangle, scroll sidebar list to selected item - [x] 8.1 Implement span click-to-select using hitTest: set selectedSpanId, highlight rectangle, scroll sidebar list to selected item
- [ ] 8.2 Implement click-to-deselect (click selected span again or click outside any span) - [x] 8.2 Implement click-to-deselect (click selected span again or click outside any span)
- [ ] 8.3 Implement double-click / Enter to open edit popover pre-populated with current span data - [x] 8.3 Implement double-click / Enter to open edit popover pre-populated with current span data
- [ ] 8.4 Wire edit Save: PATCH to API, update primitive color/label, update state - [x] 8.4 Wire edit Save: PATCH to API, update primitive color/label, update state
- [ ] 8.5 Implement Delete/Backspace keyboard shortcut for selected span: DELETE API call, remove primitive, clear selection, update state - [x] 8.5 Implement Delete/Backspace keyboard shortcut for selected span: DELETE API call, remove primitive, clear selection, update state
- [ ] 8.6 Implement delete-tool click on span rectangle: same DELETE flow as keyboard shortcut - [x] 8.6 Implement delete-tool click on span rectangle: same DELETE flow as keyboard shortcut
## 9. Span Annotation Sidebar List ## 9. Span Annotation Sidebar List

View file

@ -70,6 +70,7 @@ export default function SpanAnnotationManager({
const [endCandle, setEndCandle] = useState<Candle | null>(null); const [endCandle, setEndCandle] = useState<Candle | null>(null);
const [previewPrimitive, setPreviewPrimitive] = useState<SpanRectanglePrimitive | null>(null); const [previewPrimitive, setPreviewPrimitive] = useState<SpanRectanglePrimitive | null>(null);
const [popoverOpen, setPopoverOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false);
const [editingSpan, setEditingSpan] = useState<SpanAnnotation | null>(null);
const primitivesRef = useRef<Map<number, SpanRectanglePrimitive>>(new Map()); const primitivesRef = useRef<Map<number, SpanRectanglePrimitive>>(new Map());
// Find nearest candle to a timestamp // Find nearest candle to a timestamp
@ -139,32 +140,52 @@ export default function SpanAnnotationManager({
chart.timeScale().fitContent(); chart.timeScale().fitContent();
}, [spanAnnotations, selectedSpanId, series, chart, candles]); }, [spanAnnotations, selectedSpanId, series, chart, candles]);
// Handle clicks on chart for span tool // Handle clicks on chart for span tool and span selection
useEffect(() => { useEffect(() => {
if (!chart || !series || activeTool !== 'span') { if (!chart || !series) return;
// Clean up preview if tool changes
if (previewPrimitive && series) { // Clean up preview if tool changes away from span
if (activeTool !== 'span' && previewPrimitive) {
series.detachPrimitive(previewPrimitive); series.detachPrimitive(previewPrimitive);
setPreviewPrimitive(null); setPreviewPrimitive(null);
}
setInteractionState('idle'); setInteractionState('idle');
setStartCandle(null); setStartCandle(null);
setEndCandle(null); setEndCandle(null);
return;
} }
const handleClick = (param: any) => { const handleClick = (param: any) => {
if (!param.point) return; if (!param.point) return;
const time = chart.timeScale().coordinateToTime(param.point.x); const time = chart.timeScale().coordinateToTime(param.point.x);
if (!time) return; const price = series.coordinateToPrice(param.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 timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
const nearestCandle = findNearestCandle(timestamp); const nearestCandle = findNearestCandle(timestamp);
if (!nearestCandle) return; if (!nearestCandle) return;
if (interactionState === 'idle') { if (interactionState === 'idle') {
// Check if clicking on existing span for selection
let clickedSpanId: number | null = null;
primitivesRef.current.forEach((primitive, spanId) => {
if (primitive.hitTest(param.point.x, param.point.y)) {
clickedSpanId = spanId;
}
});
if (clickedSpanId !== null) {
// Select/deselect span
if (clickedSpanId === selectedSpanId) {
onSelectedSpanChange(null);
} else {
onSelectedSpanChange(clickedSpanId);
}
return;
}
// First click: set start candle // First click: set start candle
setStartCandle(nearestCandle); setStartCandle(nearestCandle);
setInteractionState('first-click-done'); setInteractionState('first-click-done');
@ -180,6 +201,38 @@ export default function SpanAnnotationManager({
setPreviewPrimitive(null); setPreviewPrimitive(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(param.point.x, param.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(param.point.x, param.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); chart.subscribeClick(handleClick);
@ -187,7 +240,7 @@ export default function SpanAnnotationManager({
return () => { return () => {
chart.unsubscribeClick(handleClick); chart.unsubscribeClick(handleClick);
}; };
}, [chart, series, activeTool, interactionState, candles, previewPrimitive]); }, [chart, series, activeTool, interactionState, candles, previewPrimitive, selectedSpanId, onSelectedSpanChange]);
// Handle mouse move for preview // Handle mouse move for preview
useEffect(() => { useEffect(() => {
@ -270,6 +323,26 @@ export default function SpanAnnotationManager({
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [activeTool, interactionState, previewPrimitive, series]); }, [activeTool, interactionState, previewPrimitive, series]);
// Handle span deletion
const handleDeleteSpan = async (spanId: number) => {
try {
const response = await fetch(`/api/span-annotations/${spanId}`, {
method: 'DELETE',
});
if (response.ok) {
onSpanAnnotationsChange();
if (selectedSpanId === spanId) {
onSelectedSpanChange(null);
}
} else {
console.error('Failed to delete span annotation');
}
} catch (error) {
console.error('Error deleting span annotation:', error);
}
};
// Handle popover save // Handle popover save
const handlePopoverSave = async (data: { const handlePopoverSave = async (data: {
label: string; label: string;
@ -277,6 +350,39 @@ export default function SpanAnnotationManager({
outcome: string | null; outcome: string | null;
notes: 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; if (!startCandle || !endCandle || !activeChartId) return;
// Swap if end < start // Swap if end < start
@ -325,15 +431,78 @@ export default function SpanAnnotationManager({
setInteractionState('idle'); setInteractionState('idle');
setStartCandle(null); setStartCandle(null);
setEndCandle(null); setEndCandle(null);
setEditingSpan(null);
}; };
// Handle keyboard shortcuts for deletion and editing
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Delete/Backspace: delete selected span
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedSpanId !== null) {
e.preventDefault();
handleDeleteSpan(selectedSpanId);
}
// Enter: open edit popover for selected span
if (e.key === 'Enter' && selectedSpanId !== null) {
const span = spanAnnotations.find((s) => s.id === selectedSpanId);
if (span) {
setEditingSpan(span);
setPopoverOpen(true);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedSpanId, spanAnnotations]);
// Handle double-click to edit span
useEffect(() => {
if (!chart) return;
const handleDblClick = (param: any) => {
if (!param.point) return;
let clickedSpanId: number | null = null;
primitivesRef.current.forEach((primitive, spanId) => {
if (primitive.hitTest(param.point.x, param.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 ( return (
<SpanPopover <SpanPopover
open={popoverOpen} open={popoverOpen}
onOpenChange={setPopoverOpen} onOpenChange={setPopoverOpen}
spanLabelTypes={spanLabelTypes} spanLabelTypes={spanLabelTypes}
initialData={ initialData={
startCandle && endCandle editingSpan
? {
start_time: editingSpan.start_time,
end_time: editingSpan.end_time,
label: editingSpan.label,
confidence: editingSpan.confidence,
outcome: editingSpan.outcome,
notes: editingSpan.notes,
}
: startCandle && endCandle
? { ? {
start_time: Math.min(startCandle.time, endCandle.time), start_time: Math.min(startCandle.time, endCandle.time),
end_time: Math.max(startCandle.time, endCandle.time), end_time: Math.max(startCandle.time, endCandle.time),