feat: implement section 8 - span selection, editing, and deletion
This commit is contained in:
parent
586f02ed69
commit
2f05136f20
2 changed files with 202 additions and 33 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,45 +140,97 @@ 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
|
||||||
series.detachPrimitive(previewPrimitive);
|
if (activeTool !== 'span' && previewPrimitive) {
|
||||||
setPreviewPrimitive(null);
|
series.detachPrimitive(previewPrimitive);
|
||||||
}
|
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;
|
||||||
|
|
||||||
const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
|
// Handle span tool two-click interaction
|
||||||
const nearestCandle = findNearestCandle(timestamp);
|
if (activeTool === 'span') {
|
||||||
|
const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
|
||||||
|
const nearestCandle = findNearestCandle(timestamp);
|
||||||
|
|
||||||
if (!nearestCandle) return;
|
if (!nearestCandle) return;
|
||||||
|
|
||||||
if (interactionState === 'idle') {
|
if (interactionState === 'idle') {
|
||||||
// First click: set start candle
|
// Check if clicking on existing span for selection
|
||||||
setStartCandle(nearestCandle);
|
let clickedSpanId: number | null = null;
|
||||||
setInteractionState('first-click-done');
|
primitivesRef.current.forEach((primitive, spanId) => {
|
||||||
} else if (interactionState === 'first-click-done') {
|
if (primitive.hitTest(param.point.x, param.point.y)) {
|
||||||
// Second click: set end candle and open popover
|
clickedSpanId = spanId;
|
||||||
setEndCandle(nearestCandle);
|
}
|
||||||
setInteractionState('popover-open');
|
});
|
||||||
setPopoverOpen(true);
|
|
||||||
|
|
||||||
// Clean up preview primitive
|
if (clickedSpanId !== null) {
|
||||||
if (previewPrimitive) {
|
// Select/deselect span
|
||||||
series.detachPrimitive(previewPrimitive);
|
if (clickedSpanId === selectedSpanId) {
|
||||||
setPreviewPrimitive(null);
|
onSelectedSpanChange(null);
|
||||||
|
} else {
|
||||||
|
onSelectedSpanChange(clickedSpanId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First click: set start candle
|
||||||
|
setStartCandle(nearestCandle);
|
||||||
|
setInteractionState('first-click-done');
|
||||||
|
} else if (interactionState === 'first-click-done') {
|
||||||
|
// Second click: set end candle and open popover
|
||||||
|
setEndCandle(nearestCandle);
|
||||||
|
setInteractionState('popover-open');
|
||||||
|
setPopoverOpen(true);
|
||||||
|
|
||||||
|
// Clean up preview primitive
|
||||||
|
if (previewPrimitive) {
|
||||||
|
series.detachPrimitive(previewPrimitive);
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue