599 lines
20 KiB
TypeScript
599 lines
20 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
|
import { IChartApi, ISeriesApi, Time, MouseEventParams } from 'lightweight-charts';
|
|
import { SpanRectanglePrimitive, SpanData } from './SpanRectanglePrimitive';
|
|
import SpanPopover from './SpanPopover';
|
|
import type { Candle, SpanAnnotation, SpanLabelType } from '@/types';
|
|
|
|
// Candle, SpanAnnotation, SpanLabelType are imported from @/types above.
|
|
|
|
interface SpanAnnotationManagerProps {
|
|
chart: IChartApi | null;
|
|
series: ISeriesApi<'Candlestick'> | null;
|
|
activeTool: string | null;
|
|
candles: Candle[];
|
|
spanAnnotations: SpanAnnotation[];
|
|
spanLabelTypes: SpanLabelType[];
|
|
selectedSpanId: number | null;
|
|
onSpanAnnotationsChange: () => void;
|
|
onSelectedSpanChange: (spanId: number | null) => void;
|
|
activeChartId: number | null;
|
|
}
|
|
|
|
type InteractionState = 'idle' | 'first-click-done' | 'popover-open';
|
|
|
|
export default function SpanAnnotationManager({
|
|
chart,
|
|
series,
|
|
activeTool,
|
|
candles,
|
|
spanAnnotations,
|
|
spanLabelTypes,
|
|
selectedSpanId,
|
|
onSpanAnnotationsChange,
|
|
onSelectedSpanChange,
|
|
activeChartId,
|
|
}: SpanAnnotationManagerProps) {
|
|
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 previewPrimitiveRef = useRef<SpanRectanglePrimitive | null>(null);
|
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
|
const [editingSpan, setEditingSpan] = useState<SpanAnnotation | null>(null);
|
|
const primitivesRef = useRef<Map<number, SpanRectanglePrimitive>>(new Map());
|
|
const selectedSpanIdRef = useRef<number | null>(selectedSpanId);
|
|
const hasInitializedRef = useRef(false);
|
|
|
|
// Keep selectedSpanIdRef in sync with prop
|
|
useEffect(() => {
|
|
selectedSpanIdRef.current = selectedSpanId;
|
|
}, [selectedSpanId]);
|
|
|
|
// Cleanup preview primitive on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (previewPrimitiveRef.current && series) {
|
|
try {
|
|
series.detachPrimitive(previewPrimitiveRef.current);
|
|
} catch {
|
|
// series may already be disposed
|
|
}
|
|
previewPrimitiveRef.current = null;
|
|
}
|
|
};
|
|
}, [series]);
|
|
|
|
// Find nearest candle to a timestamp
|
|
const findNearestCandle = (timestamp: number): Candle | null => {
|
|
if (candles.length === 0) return null;
|
|
return candles.reduce((prev, curr) => {
|
|
return Math.abs(curr.time - timestamp) < Math.abs(prev.time - timestamp) ? curr : prev;
|
|
});
|
|
};
|
|
|
|
// Calculate price range for candles in a span
|
|
const calculatePriceRange = (start: Candle, end: Candle): { max_high: number; min_low: number } => {
|
|
const startIdx = candles.findIndex((c) => c.time === start.time);
|
|
const endIdx = candles.findIndex((c) => c.time === end.time);
|
|
|
|
if (startIdx === -1 || endIdx === -1) {
|
|
return { max_high: Math.max(start.high, end.high), min_low: Math.min(start.low, end.low) };
|
|
}
|
|
|
|
const [minIdx, maxIdx] = [Math.min(startIdx, endIdx), Math.max(startIdx, endIdx)];
|
|
const spanCandles = candles.slice(minIdx, maxIdx + 1);
|
|
|
|
const max_high = Math.max(...spanCandles.map((c) => c.high));
|
|
const min_low = Math.min(...spanCandles.map((c) => c.low));
|
|
|
|
return { max_high, min_low };
|
|
};
|
|
|
|
// Full reconciliation: rebuild all primitives when annotation list changes
|
|
useEffect(() => {
|
|
if (!series || !chart) return;
|
|
|
|
// Clear existing primitives
|
|
primitivesRef.current.forEach((primitive) => {
|
|
series.detachPrimitive(primitive);
|
|
});
|
|
primitivesRef.current.clear();
|
|
|
|
// Create primitives for each span annotation
|
|
spanAnnotations.forEach((span) => {
|
|
// Find candles within span time range for price calculation
|
|
const spanCandles = candles.filter(
|
|
(c) => c.time >= span.start_time && c.time <= span.end_time
|
|
);
|
|
let max_high: number;
|
|
let min_low: number;
|
|
if (spanCandles.length > 0) {
|
|
max_high = Math.max(...spanCandles.map((c) => c.high));
|
|
min_low = Math.min(...spanCandles.map((c) => c.low));
|
|
} else {
|
|
// Fallback: find nearest candles to span boundaries
|
|
const nearest = candles.reduce(
|
|
(acc, c) => {
|
|
const distStart = Math.abs(c.time - span.start_time);
|
|
const distEnd = Math.abs(c.time - span.end_time);
|
|
if (distStart < acc.startDist) acc = { ...acc, startCandle: c, startDist: distStart };
|
|
if (distEnd < acc.endDist) acc = { ...acc, endCandle: c, endDist: distEnd };
|
|
return acc;
|
|
},
|
|
{ startCandle: candles[0], startDist: Infinity, endCandle: candles[0], endDist: Infinity }
|
|
);
|
|
max_high = Math.max(nearest.startCandle?.high ?? 0, nearest.endCandle?.high ?? 0);
|
|
min_low = Math.min(nearest.startCandle?.low ?? 0, nearest.endCandle?.low ?? 0);
|
|
}
|
|
|
|
const spanData: SpanData = {
|
|
id: span.id,
|
|
start_time: span.start_time,
|
|
end_time: span.end_time,
|
|
label: span.label,
|
|
color: span.color,
|
|
max_high: max_high,
|
|
min_low: min_low,
|
|
};
|
|
|
|
const primitive = new SpanRectanglePrimitive({
|
|
data: spanData,
|
|
isSelected: span.id === selectedSpanIdRef.current,
|
|
});
|
|
|
|
series.attachPrimitive(primitive);
|
|
primitivesRef.current.set(span.id, primitive);
|
|
});
|
|
}, [spanAnnotations, series, chart, candles]);
|
|
|
|
// Selection-only effect: update visual state without rebuilding primitives
|
|
useEffect(() => {
|
|
primitivesRef.current.forEach((primitive, spanId) => {
|
|
primitive.setSelected(spanId === selectedSpanId);
|
|
});
|
|
}, [selectedSpanId]);
|
|
|
|
// Fit chart content only on initial load when chart and series become available
|
|
useEffect(() => {
|
|
if (!chart || !series) return;
|
|
if (hasInitializedRef.current) return;
|
|
hasInitializedRef.current = true;
|
|
chart.timeScale().fitContent();
|
|
}, [chart, series]);
|
|
|
|
// Handle clicks on chart for span tool and span selection
|
|
useEffect(() => {
|
|
if (!chart || !series) return;
|
|
|
|
// Clean up preview if tool changes away from span
|
|
if (activeTool !== 'span' && previewPrimitiveRef.current) {
|
|
series.detachPrimitive(previewPrimitiveRef.current);
|
|
previewPrimitiveRef.current = null;
|
|
setInteractionState('idle');
|
|
setStartCandle(null);
|
|
setEndCandle(null);
|
|
}
|
|
|
|
const handleClick = (param: MouseEventParams) => {
|
|
if (!param.point) return;
|
|
const point = param.point;
|
|
|
|
const time = chart.timeScale().coordinateToTime(point.x);
|
|
const price = series.coordinateToPrice(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 nearestCandle = findNearestCandle(timestamp);
|
|
|
|
if (!nearestCandle) return;
|
|
|
|
if (interactionState === 'idle') {
|
|
// Check if clicking on existing span for selection
|
|
let clickedSpanId: number | null = null;
|
|
primitivesRef.current.forEach((primitive, spanId) => {
|
|
if (primitive.hitTest(point.x, point.y)) {
|
|
clickedSpanId = spanId;
|
|
}
|
|
});
|
|
|
|
if (clickedSpanId !== null) {
|
|
// Select/deselect span
|
|
if (clickedSpanId === selectedSpanId) {
|
|
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 (previewPrimitiveRef.current) {
|
|
series.detachPrimitive(previewPrimitiveRef.current);
|
|
previewPrimitiveRef.current = 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(point.x, 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(point.x, 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);
|
|
|
|
return () => {
|
|
chart.unsubscribeClick(handleClick);
|
|
};
|
|
}, [chart, series, activeTool, interactionState, candles, selectedSpanId, onSelectedSpanChange]);
|
|
|
|
// Handle mouse move for preview
|
|
useEffect(() => {
|
|
if (!chart || !series || activeTool !== 'span' || interactionState !== 'first-click-done' || !startCandle) {
|
|
return;
|
|
}
|
|
|
|
const handleMouseMove = (param: MouseEventParams) => {
|
|
if (!param.point) return;
|
|
|
|
const time = chart.timeScale().coordinateToTime(param.point.x);
|
|
if (!time) return;
|
|
|
|
const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
|
|
const nearestCandle = findNearestCandle(timestamp);
|
|
|
|
if (!nearestCandle) return;
|
|
|
|
// Update cursor candle for hotkey usage
|
|
setCursorCandle(nearestCandle);
|
|
|
|
// Calculate price range for preview
|
|
const { max_high, min_low } = calculatePriceRange(startCandle, nearestCandle);
|
|
|
|
// Swap if end < start
|
|
const [start_time, end_time] =
|
|
startCandle.time <= nearestCandle.time
|
|
? [startCandle.time, nearestCandle.time]
|
|
: [nearestCandle.time, startCandle.time];
|
|
|
|
const previewData: SpanData = {
|
|
id: -1, // Preview ID
|
|
start_time,
|
|
end_time,
|
|
label: 'PREVIEW',
|
|
color: '#888888',
|
|
max_high,
|
|
min_low,
|
|
};
|
|
|
|
// Remove old preview primitive
|
|
if (previewPrimitiveRef.current) {
|
|
series.detachPrimitive(previewPrimitiveRef.current);
|
|
}
|
|
|
|
// Create new preview primitive
|
|
const newPreview = new SpanRectanglePrimitive({
|
|
data: previewData,
|
|
isSelected: false,
|
|
});
|
|
|
|
series.attachPrimitive(newPreview);
|
|
previewPrimitiveRef.current = newPreview;
|
|
};
|
|
|
|
chart.subscribeCrosshairMove(handleMouseMove);
|
|
|
|
return () => {
|
|
chart.unsubscribeCrosshairMove(handleMouseMove);
|
|
};
|
|
}, [chart, series, activeTool, interactionState, startCandle, candles]);
|
|
|
|
// Handle Escape key to cancel span selection
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && activeTool === 'span') {
|
|
if (interactionState === 'first-click-done') {
|
|
// Cancel span selection
|
|
setInteractionState('idle');
|
|
setStartCandle(null);
|
|
setEndCandle(null);
|
|
|
|
// Clean up preview
|
|
if (previewPrimitiveRef.current && series) {
|
|
series.detachPrimitive(previewPrimitiveRef.current);
|
|
previewPrimitiveRef.current = null;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [activeTool, interactionState, series]);
|
|
|
|
// Handle span deletion
|
|
const handleDeleteSpan = useCallback(async (spanId: number) => {
|
|
try {
|
|
const response = await fetch(`/api/span-annotations/${spanId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
if (response.ok) {
|
|
onSpanAnnotationsChange();
|
|
if (selectedSpanIdRef.current === spanId) {
|
|
onSelectedSpanChange(null);
|
|
}
|
|
} else {
|
|
console.error('Failed to delete span annotation');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error deleting span annotation:', error);
|
|
}
|
|
}, [onSpanAnnotationsChange, onSelectedSpanChange]);
|
|
|
|
// Handle popover save
|
|
const handlePopoverSave = async (data: {
|
|
label: string;
|
|
confidence: number | null;
|
|
outcome: 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;
|
|
|
|
// Swap if end < start
|
|
const [start_time, end_time] =
|
|
startCandle.time <= endCandle.time
|
|
? [startCandle.time, endCandle.time]
|
|
: [endCandle.time, startCandle.time];
|
|
|
|
// Find label type color
|
|
const labelType = spanLabelTypes.find((t) => t.name === data.label);
|
|
const color = labelType?.color || '#2196F3';
|
|
|
|
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: data.label,
|
|
confidence: data.confidence,
|
|
outcome: data.outcome,
|
|
notes: data.notes,
|
|
color,
|
|
}),
|
|
});
|
|
|
|
if (response.ok) {
|
|
onSpanAnnotationsChange();
|
|
setPopoverOpen(false);
|
|
setInteractionState('idle');
|
|
setStartCandle(null);
|
|
setEndCandle(null);
|
|
} else {
|
|
console.error('Failed to create span annotation');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error creating span annotation:', error);
|
|
}
|
|
};
|
|
|
|
// Handle popover cancel
|
|
const handlePopoverCancel = () => {
|
|
setPopoverOpen(false);
|
|
setInteractionState('idle');
|
|
setStartCandle(null);
|
|
setEndCandle(null);
|
|
setEditingSpan(null);
|
|
};
|
|
|
|
// Handle keyboard shortcuts for deletion, editing, and hotkey label assignment
|
|
useEffect(() => {
|
|
const handleKeyDown = async (e: KeyboardEvent) => {
|
|
// Delete/Backspace: delete selected span
|
|
const currentSelectedSpanId = selectedSpanIdRef.current;
|
|
if ((e.key === 'Delete' || e.key === 'Backspace') && currentSelectedSpanId !== null) {
|
|
e.preventDefault();
|
|
handleDeleteSpan(currentSelectedSpanId);
|
|
return;
|
|
}
|
|
|
|
// Enter: open edit popover for selected span
|
|
if (e.key === 'Enter' && currentSelectedSpanId !== null) {
|
|
const span = spanAnnotations.find((s) => s.id === currentSelectedSpanId);
|
|
if (span) {
|
|
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);
|
|
|
|
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 (previewPrimitiveRef.current && series) {
|
|
series.detachPrimitive(previewPrimitiveRef.current);
|
|
previewPrimitiveRef.current = 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);
|
|
}, [handleDeleteSpan, spanAnnotations, activeTool, interactionState, startCandle, cursorCandle, spanLabelTypes, activeChartId, series, onSpanAnnotationsChange]);
|
|
|
|
// Handle double-click to edit span
|
|
useEffect(() => {
|
|
if (!chart) return;
|
|
|
|
const handleDblClick = (param: MouseEventParams) => {
|
|
if (!param.point) return;
|
|
const point = param.point;
|
|
|
|
let clickedSpanId: number | null = null;
|
|
primitivesRef.current.forEach((primitive, spanId) => {
|
|
if (primitive.hitTest(point.x, 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 (
|
|
<SpanPopover
|
|
open={popoverOpen}
|
|
onOpenChange={setPopoverOpen}
|
|
spanLabelTypes={spanLabelTypes}
|
|
initialData={
|
|
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),
|
|
end_time: Math.max(startCandle.time, endCandle.time),
|
|
}
|
|
: undefined
|
|
}
|
|
onSave={handlePopoverSave}
|
|
onCancel={handlePopoverCancel}
|
|
/>
|
|
);
|
|
}
|