feat: implement section 10 - hotkey label assignment for span annotations

This commit is contained in:
Marko Djordjevic 2026-02-14 10:14:17 +01:00
parent 4089aab77c
commit b5e4d6573e
2 changed files with 70 additions and 12 deletions

View file

@ -73,9 +73,9 @@
## 10. Hotkey Label Assignment
- [ ] 10.1 Add keydown listener for span label hotkeys (active only when span tool is active and span range is selected)
- [ ] 10.2 On hotkey press: save span with mapped label (default confidence/outcome/notes), render rectangle, update sidebar — skip popover
- [ ] 10.3 Ignore hotkeys when span tool is inactive or no span range selected
- [x] 10.1 Add keydown listener for span label hotkeys (active only when span tool is active and span range is selected)
- [x] 10.2 On hotkey press: save span with mapped label (default confidence/outcome/notes), render rectangle, update sidebar — skip popover
- [x] 10.3 Ignore hotkeys when span tool is inactive or no span range selected
## 11. Export Endpoints

View file

@ -68,6 +68,7 @@ export default function SpanAnnotationManager({
const [interactionState, setInteractionState] = useState<InteractionState>('idle');
const [startCandle, setStartCandle] = useState<Candle | null>(null);
const [endCandle, setEndCandle] = useState<Candle | null>(null);
const [cursorCandle, setCursorCandle] = useState<Candle | null>(null);
const [previewPrimitive, setPreviewPrimitive] = useState<SpanRectanglePrimitive | null>(null);
const [popoverOpen, setPopoverOpen] = useState(false);
const [editingSpan, setEditingSpan] = useState<SpanAnnotation | null>(null);
@ -255,18 +256,21 @@ export default function SpanAnnotationManager({
if (!time) return;
const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
const cursorCandle = findNearestCandle(timestamp);
const nearestCandle = findNearestCandle(timestamp);
if (!cursorCandle) return;
if (!nearestCandle) return;
// Update cursor candle for hotkey usage
setCursorCandle(nearestCandle);
// Calculate price range for preview
const { max_high, min_low } = calculatePriceRange(startCandle, cursorCandle);
const { max_high, min_low } = calculatePriceRange(startCandle, nearestCandle);
// Swap if end < start
const [start_time, end_time] =
startCandle.time <= cursorCandle.time
? [startCandle.time, cursorCandle.time]
: [cursorCandle.time, startCandle.time];
startCandle.time <= nearestCandle.time
? [startCandle.time, nearestCandle.time]
: [nearestCandle.time, startCandle.time];
const previewData: SpanData = {
id: -1, // Preview ID
@ -434,13 +438,14 @@ export default function SpanAnnotationManager({
setEditingSpan(null);
};
// Handle keyboard shortcuts for deletion and editing
// Handle keyboard shortcuts for deletion, editing, and hotkey label assignment
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const handleKeyDown = async (e: KeyboardEvent) => {
// Delete/Backspace: delete selected span
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedSpanId !== null) {
e.preventDefault();
handleDeleteSpan(selectedSpanId);
return;
}
// Enter: open edit popover for selected span
@ -450,12 +455,65 @@ export default function SpanAnnotationManager({
setEditingSpan(span);
setPopoverOpen(true);
}
return;
}
// Hotkey label assignment: only when span tool is active and a span range is selected (after first click)
if (activeTool === 'span' && interactionState === 'first-click-done' && startCandle) {
// Check if key matches any label hotkey
const matchingLabel = spanLabelTypes.find((lt) => lt.hotkey === e.key && lt.is_active === 1);
if (matchingLabel && activeChartId) {
e.preventDefault();
// Use cursor candle if available, otherwise use start candle
const endCandleForHotkey = cursorCandle || startCandle;
// Swap if end < start
const [start_time, end_time] =
startCandle.time <= endCandleForHotkey.time
? [startCandle.time, endCandleForHotkey.time]
: [endCandleForHotkey.time, startCandle.time];
// Create span with hotkey label (default confidence/outcome/notes)
try {
const response = await fetch('/api/span-annotations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chart_id: activeChartId,
start_time,
end_time,
label: matchingLabel.name,
confidence: 3, // Default confidence
outcome: null,
notes: null,
color: matchingLabel.color,
}),
});
if (response.ok) {
onSpanAnnotationsChange();
// Clean up preview and reset state
if (previewPrimitive && series) {
series.detachPrimitive(previewPrimitive);
setPreviewPrimitive(null);
}
setInteractionState('idle');
setStartCandle(null);
setEndCandle(null);
}
} catch (error) {
console.error('Error creating span with hotkey:', error);
}
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedSpanId, spanAnnotations]);
}, [selectedSpanId, spanAnnotations, activeTool, interactionState, startCandle, cursorCandle, spanLabelTypes, activeChartId, previewPrimitive, series, onSpanAnnotationsChange]);
// Handle double-click to edit span
useEffect(() => {