1199 lines
43 KiB
TypeScript
1199 lines
43 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useRef, useState, useImperativeHandle, forwardRef } from 'react';
|
|
import { createChart, IChartApi, ISeriesApi, CandlestickData, HistogramData, Time, SeriesMarker } from 'lightweight-charts';
|
|
import { useTheme } from 'next-themes';
|
|
import SpanAnnotationManager from './SpanAnnotationManager';
|
|
import { TrendLine } from '@/plugins/trend-line';
|
|
import { RectangleDrawingPrimitive, RectanglePoint } from '@/plugins/rectangle-drawing';
|
|
import type { PerCandlePrediction, PredictionSpan, ModelInfoResponse, PredictionSummary } from '@/types/predictions';
|
|
|
|
interface DrawingState {
|
|
tool: 'line' | 'rectangle';
|
|
firstPoint: { time: Time; price: number };
|
|
}
|
|
|
|
interface Point {
|
|
time: Time;
|
|
price: number;
|
|
}
|
|
|
|
interface Candle {
|
|
time: number;
|
|
open: number;
|
|
high: number;
|
|
low: number;
|
|
close: number;
|
|
}
|
|
|
|
interface Annotation {
|
|
id: number;
|
|
timestamp: number;
|
|
label_type: string;
|
|
geometry: {
|
|
startTime?: number;
|
|
startPrice?: number;
|
|
endTime?: number;
|
|
endPrice?: number;
|
|
} | null;
|
|
created_at: number;
|
|
}
|
|
|
|
type AnnotationType = {
|
|
id: number;
|
|
name: string;
|
|
display_name: string;
|
|
color: string;
|
|
category: string;
|
|
icon: string | null;
|
|
is_active: boolean;
|
|
};
|
|
|
|
interface SpanAnnotation {
|
|
id: number;
|
|
chart_id: number;
|
|
start_time: number;
|
|
end_time: number;
|
|
label: string;
|
|
confidence: number | null;
|
|
outcome: string | null;
|
|
notes: string | null;
|
|
sub_spans: any;
|
|
color: string;
|
|
created_at: number;
|
|
}
|
|
|
|
interface SpanLabelType {
|
|
id: number;
|
|
name: string;
|
|
display_name: string;
|
|
color: string;
|
|
hotkey: string | null;
|
|
is_active: boolean;
|
|
sort_order: number;
|
|
created_at: number;
|
|
}
|
|
|
|
interface CandleChartProps {
|
|
activeTool: string | null;
|
|
onAnnotationChange?: () => void;
|
|
selectedColor: string;
|
|
selectedLabelId?: number | null;
|
|
onLabelSelect?: (id: number) => void;
|
|
activeChartId?: number | null;
|
|
spanAnnotations?: SpanAnnotation[];
|
|
spanLabelTypes?: SpanLabelType[];
|
|
selectedSpanId?: number | null;
|
|
onSpanAnnotationsChange?: () => void;
|
|
onSelectedSpanChange?: (spanId: number | null) => void;
|
|
// Prediction rendering props
|
|
predictionVisible?: boolean;
|
|
perCandlePredictions?: PerCandlePrediction[];
|
|
predictionSpans?: PredictionSpan[];
|
|
confidenceThreshold?: number;
|
|
selectedLabels?: Set<string>;
|
|
modelInfo?: ModelInfoResponse | null;
|
|
predictionSummary?: PredictionSummary | null;
|
|
showOnlyDisagreements?: boolean;
|
|
onPredictionClick?: (span: PredictionSpan, disagreementType: string | null) => void;
|
|
onPredictionDismiss?: (span: PredictionSpan, disagreementType: string | null) => void;
|
|
}
|
|
|
|
export interface CandleChartHandle {
|
|
refreshData: () => Promise<void>;
|
|
getVisibleCandles: () => Candle[];
|
|
}
|
|
|
|
const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
|
({
|
|
activeTool,
|
|
onAnnotationChange,
|
|
selectedColor,
|
|
selectedLabelId,
|
|
onLabelSelect,
|
|
activeChartId,
|
|
spanAnnotations = [],
|
|
spanLabelTypes = [],
|
|
selectedSpanId = null,
|
|
onSpanAnnotationsChange,
|
|
onSelectedSpanChange,
|
|
predictionVisible = false,
|
|
perCandlePredictions = [],
|
|
predictionSpans = [],
|
|
confidenceThreshold = 0.5,
|
|
selectedLabels = new Set<string>(),
|
|
modelInfo = null,
|
|
predictionSummary = null,
|
|
showOnlyDisagreements = false,
|
|
onPredictionClick,
|
|
onPredictionDismiss,
|
|
}, ref) => {
|
|
const chartContainerRef = useRef<HTMLDivElement>(null);
|
|
const chartRef = useRef<IChartApi | null>(null);
|
|
const seriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null);
|
|
const histogramSeriesRef = useRef<ISeriesApi<'Histogram'> | null>(null);
|
|
const [candles, setCandles] = useState<Candle[]>([]);
|
|
const [annotations, setAnnotations] = useState<Annotation[]>([]);
|
|
const [annotationTypes, setAnnotationTypes] = useState<AnnotationType[]>([]);
|
|
const [isEmpty, setIsEmpty] = useState(true);
|
|
const { theme, resolvedTheme } = useTheme();
|
|
const [mounted, setMounted] = useState(false);
|
|
|
|
// Drawing state for line and rectangle tools
|
|
const [drawingState, setDrawingState] = useState<DrawingState | null>(null);
|
|
const previewPrimitiveRef = useRef<TrendLine | RectangleDrawingPrimitive | null>(null);
|
|
const linePrimitivesRef = useRef<Map<number, TrendLine>>(new Map());
|
|
const rectanglePrimitivesRef = useRef<Map<number, RectangleDrawingPrimitive>>(new Map());
|
|
const [selectedRectangleId, setSelectedRectangleId] = useState<number | null>(null);
|
|
|
|
// Line selection and dragging state
|
|
const [selectedLineId, setSelectedLineId] = useState<number | null>(null);
|
|
const [dragState, setDragState] = useState<{ lineId: number; endpoint: 'p1' | 'p2' } | null>(null);
|
|
|
|
// Track mounted state to avoid hydration mismatch
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
// Fetch candles from API
|
|
const fetchCandles = async () => {
|
|
try {
|
|
const url = activeChartId ? `/api/candles?chartId=${activeChartId}` : '/api/candles';
|
|
const response = await fetch(url);
|
|
const data = await response.json();
|
|
setCandles(data);
|
|
setIsEmpty(data.length === 0);
|
|
return data;
|
|
} catch (error) {
|
|
console.error('Failed to fetch candles:', error);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
// Fetch annotations from API
|
|
const fetchAnnotations = async () => {
|
|
try {
|
|
const url = activeChartId ? `/api/annotations?chartId=${activeChartId}` : '/api/annotations';
|
|
const response = await fetch(url);
|
|
const data = await response.json();
|
|
setAnnotations(data);
|
|
return data;
|
|
} catch (error) {
|
|
console.error('Failed to fetch annotations:', error);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
// Fetch annotation types from API
|
|
const fetchAnnotationTypes = async () => {
|
|
try {
|
|
const response = await fetch('/api/annotation-types');
|
|
const data = await response.json();
|
|
setAnnotationTypes(data.filter((t: AnnotationType) => t.is_active));
|
|
return data;
|
|
} catch (error) {
|
|
console.error('Failed to fetch annotation types:', error);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
// Expose methods to parent
|
|
useImperativeHandle(ref, () => ({
|
|
refreshData: async () => {
|
|
await fetchCandles();
|
|
await fetchAnnotations();
|
|
await fetchAnnotationTypes();
|
|
},
|
|
getVisibleCandles: () => {
|
|
if (!chartRef.current || candles.length === 0) return [];
|
|
const timeScale = chartRef.current.timeScale();
|
|
const visibleRange = timeScale.getVisibleLogicalRange();
|
|
if (!visibleRange) return candles;
|
|
const barsInfo = seriesRef.current?.barsInLogicalRange(visibleRange);
|
|
if (!barsInfo || !barsInfo.barsBefore && !barsInfo.barsAfter) return candles;
|
|
// Filter candles by visible time range
|
|
const from = barsInfo.from as number;
|
|
const to = barsInfo.to as number;
|
|
if (typeof from !== 'number' || typeof to !== 'number') return candles;
|
|
return candles.filter((c) => c.time >= from && c.time <= to);
|
|
},
|
|
}));
|
|
|
|
// Get theme-specific colors
|
|
const getChartColors = () => {
|
|
const isDark = resolvedTheme === 'dark';
|
|
|
|
if (isDark) {
|
|
return {
|
|
layout: {
|
|
background: { color: '#000000' },
|
|
textColor: '#00ff41',
|
|
},
|
|
grid: {
|
|
vertLines: { color: '#003311' },
|
|
horzLines: { color: '#003311' },
|
|
},
|
|
timeScale: {
|
|
borderColor: '#003311',
|
|
},
|
|
rightPriceScale: {
|
|
borderColor: '#003311',
|
|
},
|
|
crosshair: {
|
|
color: '#00ff41',
|
|
},
|
|
};
|
|
} else {
|
|
return {
|
|
layout: {
|
|
background: { color: '#ffffff' },
|
|
textColor: '#1a1a2e',
|
|
},
|
|
grid: {
|
|
vertLines: { color: '#d1d5db' },
|
|
horzLines: { color: '#d1d5db' },
|
|
},
|
|
timeScale: {
|
|
borderColor: '#d1d5db',
|
|
},
|
|
rightPriceScale: {
|
|
borderColor: '#d1d5db',
|
|
},
|
|
crosshair: {
|
|
color: '#16a34a',
|
|
},
|
|
};
|
|
}
|
|
};
|
|
|
|
// Initialize chart
|
|
useEffect(() => {
|
|
if (!chartContainerRef.current || isEmpty || !mounted) return;
|
|
|
|
const colors = getChartColors();
|
|
|
|
const chart = createChart(chartContainerRef.current, {
|
|
width: chartContainerRef.current.clientWidth,
|
|
height: chartContainerRef.current.clientHeight,
|
|
layout: colors.layout,
|
|
grid: colors.grid,
|
|
crosshair: {
|
|
mode: 1,
|
|
},
|
|
timeScale: {
|
|
timeVisible: true,
|
|
secondsVisible: false,
|
|
borderColor: colors.timeScale.borderColor,
|
|
},
|
|
rightPriceScale: {
|
|
borderColor: colors.rightPriceScale.borderColor,
|
|
},
|
|
});
|
|
|
|
const candlestickSeries = chart.addCandlestickSeries({
|
|
upColor: '#ffffff',
|
|
downColor: '#444444',
|
|
borderVisible: true,
|
|
wickUpColor: '#444444',
|
|
wickDownColor: '#444444',
|
|
borderUpColor: '#444444',
|
|
borderDownColor: '#444444',
|
|
});
|
|
|
|
// Add histogram series for prediction overlay (rendered behind candles)
|
|
const histogramSeries = chart.addHistogramSeries({
|
|
color: 'rgba(128, 128, 128, 0.15)',
|
|
priceFormat: { type: 'volume' },
|
|
priceScaleId: 'prediction_overlay',
|
|
});
|
|
histogramSeries.priceScale().applyOptions({
|
|
scaleMargins: { top: 0, bottom: 0 },
|
|
visible: false,
|
|
});
|
|
|
|
chartRef.current = chart;
|
|
seriesRef.current = candlestickSeries;
|
|
histogramSeriesRef.current = histogramSeries;
|
|
|
|
// Handle resize
|
|
const resizeObserver = new ResizeObserver((entries) => {
|
|
if (entries.length === 0 || !chartContainerRef.current) return;
|
|
const { width, height } = entries[0].contentRect;
|
|
chart.applyOptions({ width, height });
|
|
});
|
|
|
|
resizeObserver.observe(chartContainerRef.current);
|
|
|
|
return () => {
|
|
resizeObserver.disconnect();
|
|
histogramSeriesRef.current = null;
|
|
chart.remove();
|
|
};
|
|
}, [isEmpty, mounted, resolvedTheme]);
|
|
|
|
// Load candle data into chart
|
|
useEffect(() => {
|
|
if (!seriesRef.current || candles.length === 0) return;
|
|
|
|
const chartData: CandlestickData[] = candles
|
|
.map((candle) => ({
|
|
time: candle.time as Time,
|
|
open: candle.open,
|
|
high: candle.high,
|
|
low: candle.low,
|
|
close: candle.close,
|
|
}))
|
|
.sort((a, b) => (a.time as number) - (b.time as number));
|
|
|
|
seriesRef.current.setData(chartData);
|
|
}, [candles]);
|
|
|
|
// Update markers from annotations
|
|
useEffect(() => {
|
|
if (!seriesRef.current || annotationTypes.length === 0) return;
|
|
|
|
// Get marker type names
|
|
const markerTypeNames = annotationTypes
|
|
.filter((t) => t.category === 'marker')
|
|
.map((t) => t.name);
|
|
|
|
const markerAnnotations = annotations.filter((a) =>
|
|
markerTypeNames.includes(a.label_type)
|
|
);
|
|
|
|
const markers = markerAnnotations
|
|
.map((annotation) => {
|
|
const annotationType = annotationTypes.find((t) => t.name === annotation.label_type);
|
|
if (!annotationType) return null;
|
|
|
|
const isSelected = annotation.id === selectedLabelId;
|
|
|
|
// Determine marker shape and position based on icon
|
|
const isUpArrow = annotationType.icon === 'arrowUp';
|
|
|
|
return {
|
|
time: annotation.timestamp as Time,
|
|
position: isUpArrow ? ('belowBar' as const) : ('aboveBar' as const),
|
|
color: isSelected
|
|
? annotationType.color + 'CC' // Add slight transparency when selected
|
|
: annotationType.color,
|
|
shape: isUpArrow ? ('arrowUp' as const) : ('arrowDown' as const),
|
|
text: annotationType.display_name,
|
|
size: isSelected ? 2 : 1,
|
|
};
|
|
})
|
|
.filter((m) => m !== null)
|
|
.sort((a, b) => (a!.time as number) - (b!.time as number));
|
|
|
|
seriesRef.current.setMarkers(markers as any);
|
|
}, [annotations, selectedLabelId, annotationTypes]);
|
|
|
|
// Render prediction histogram overlay and span markers
|
|
useEffect(() => {
|
|
if (!histogramSeriesRef.current || !seriesRef.current || candles.length === 0) return;
|
|
|
|
// If predictions are not visible or no data, clear the histogram and markers
|
|
if (!predictionVisible || perCandlePredictions.length === 0) {
|
|
histogramSeriesRef.current.setData([]);
|
|
histogramSeriesRef.current.setMarkers([]);
|
|
return;
|
|
}
|
|
|
|
// Build a label-to-color map from modelInfo
|
|
// Generate colors for labels since backend no longer provides them
|
|
const labelColorMap: Record<string, string> = {};
|
|
if (modelInfo?.labels) {
|
|
const predefinedColors = [
|
|
'#3b82f6', // blue
|
|
'#ef4444', // red
|
|
'#10b981', // green
|
|
'#f59e0b', // amber
|
|
'#8b5cf6', // violet
|
|
'#ec4899', // pink
|
|
'#06b6d4', // cyan
|
|
'#f97316', // orange
|
|
];
|
|
modelInfo.labels.forEach((label, index) => {
|
|
labelColorMap[label] = predefinedColors[index % predefinedColors.length];
|
|
});
|
|
}
|
|
|
|
// Build disagreement lookup map for highlighting
|
|
// Map: predictionSpan start_time -> disagreement type
|
|
const disagreementMap = new Map<number, string>();
|
|
const disagreementSpanSet = new Set<string>(); // Set of "start-end" keys for filtering
|
|
|
|
if (predictionSummary?.disagreements) {
|
|
predictionSummary.disagreements.forEach((d) => {
|
|
if (d.predictionSpan) {
|
|
disagreementMap.set(d.predictionSpan.start_time, d.type);
|
|
disagreementSpanSet.add(`${d.predictionSpan.start_time}-${d.predictionSpan.end_time}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Helper to convert hex to rgba
|
|
const hexToRgba = (hex: string, alpha: number): string => {
|
|
hex = hex.replace('#', '');
|
|
const r = parseInt(hex.substring(0, 2), 16);
|
|
const g = parseInt(hex.substring(2, 4), 16);
|
|
const b = parseInt(hex.substring(4, 6), 16);
|
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
};
|
|
|
|
// Build candle price lookup for histogram values
|
|
const candleMap = new Map(candles.map((c) => [c.time, c]));
|
|
|
|
// Build a map from prediction time to its span for disagreement lookup
|
|
const predictionTimeToSpan = new Map<number, PredictionSpan>();
|
|
predictionSpans.forEach((span) => {
|
|
// Associate all times in the span with the span object
|
|
for (let t = span.start_time; t <= span.end_time; t += 60) { // Assuming 1-minute candles, adjust if needed
|
|
predictionTimeToSpan.set(t, span);
|
|
}
|
|
});
|
|
|
|
// Filter and map predictions to histogram data with disagreement highlighting
|
|
const histogramData: (HistogramData & { color?: string })[] = perCandlePredictions
|
|
.filter((p) => {
|
|
// Filter by confidence threshold
|
|
if (p.confidence < confidenceThreshold) return false;
|
|
// Filter by selected labels
|
|
if (!selectedLabels.has(p.label)) return false;
|
|
// Skip "O" (no-pattern) labels
|
|
if (p.label === 'O') return false;
|
|
|
|
// If showOnlyDisagreements is enabled, only show predictions that are part of disagreements
|
|
if (showOnlyDisagreements) {
|
|
const span = predictionTimeToSpan.get(p.time);
|
|
if (!span) return false;
|
|
const spanKey = `${span.start_time}-${span.end_time}`;
|
|
if (!disagreementSpanSet.has(spanKey)) return false;
|
|
}
|
|
|
|
return true;
|
|
})
|
|
.map((p) => {
|
|
const candle = candleMap.get(p.time);
|
|
// Use candle high as the histogram value so it overlays correctly
|
|
const value = candle ? candle.high : 0;
|
|
|
|
// Check if this prediction is part of a disagreement
|
|
const span = predictionTimeToSpan.get(p.time);
|
|
const disagreementType = span ? disagreementMap.get(span.start_time) : null;
|
|
|
|
let baseColor = labelColorMap[p.label] || '#888888';
|
|
let alpha = 0.15;
|
|
|
|
// Apply disagreement-specific colors and styling
|
|
if (disagreementType === 'missed_by_human') {
|
|
// Yellow highlight for predictions missed by humans
|
|
baseColor = '#eab308'; // yellow
|
|
alpha = 0.25;
|
|
} else if (disagreementType === 'label_mismatch') {
|
|
// Orange for label mismatches
|
|
baseColor = '#f97316'; // orange
|
|
alpha = 0.25;
|
|
}
|
|
|
|
return {
|
|
time: p.time as Time,
|
|
value,
|
|
color: hexToRgba(baseColor, alpha),
|
|
};
|
|
})
|
|
.sort((a, b) => (a.time as number) - (b.time as number));
|
|
|
|
histogramSeriesRef.current.setData(histogramData);
|
|
|
|
// Add markers for prediction span labels at span start times
|
|
const spanMarkers: SeriesMarker<Time>[] = predictionSpans
|
|
.filter((span) => {
|
|
if (span.avg_confidence < confidenceThreshold) return false;
|
|
if (!selectedLabels.has(span.label)) return false;
|
|
if (span.label === 'O') return false;
|
|
|
|
// If showOnlyDisagreements is enabled, only show spans that are disagreements
|
|
if (showOnlyDisagreements) {
|
|
const spanKey = `${span.start_time}-${span.end_time}`;
|
|
if (!disagreementSpanSet.has(spanKey)) return false;
|
|
}
|
|
|
|
return true;
|
|
})
|
|
.map((span) => {
|
|
const disagreementType = disagreementMap.get(span.start_time);
|
|
let baseColor = labelColorMap[span.label] || '#888888';
|
|
let labelText = span.label;
|
|
|
|
// Apply disagreement-specific styling to markers
|
|
if (disagreementType === 'missed_by_human') {
|
|
baseColor = '#eab308'; // yellow
|
|
labelText = `⚠ ${span.label}`;
|
|
} else if (disagreementType === 'label_mismatch') {
|
|
baseColor = '#f97316'; // orange
|
|
labelText = `⚠ ${span.label}`;
|
|
}
|
|
|
|
const confidencePct = Math.round(span.avg_confidence * 100);
|
|
return {
|
|
time: span.start_time as Time,
|
|
position: 'belowBar' as const,
|
|
color: baseColor,
|
|
shape: 'square' as const,
|
|
text: `${labelText} (${confidencePct}%)`,
|
|
size: 1,
|
|
};
|
|
})
|
|
.sort((a, b) => (a.time as number) - (b.time as number));
|
|
|
|
histogramSeriesRef.current.setMarkers(spanMarkers);
|
|
}, [predictionVisible, perCandlePredictions, predictionSpans, confidenceThreshold, selectedLabels, modelInfo, candles, predictionSummary, showOnlyDisagreements]);
|
|
|
|
// Handle chart clicks for annotation
|
|
useEffect(() => {
|
|
if (!chartRef.current || !seriesRef.current) return;
|
|
|
|
// Helper function to check if click is near a line endpoint
|
|
const isNearEndpoint = (clickX: number, clickY: number, endpointTime: Time, endpointPrice: number): boolean => {
|
|
const endpointX = chartRef.current!.timeScale().timeToCoordinate(endpointTime);
|
|
const endpointY = seriesRef.current!.priceToCoordinate(endpointPrice);
|
|
|
|
if (endpointX === null || endpointY === null) return false;
|
|
|
|
const distance = Math.sqrt(
|
|
Math.pow(clickX - endpointX, 2) + Math.pow(clickY - endpointY, 2)
|
|
);
|
|
|
|
return distance <= 8; // 8 pixel tolerance for endpoint handles
|
|
};
|
|
|
|
const handleClick = async (param: any) => {
|
|
if (!param.point) return;
|
|
|
|
const timeCoordinate = param.point.x;
|
|
const priceCoordinate = param.point.y;
|
|
|
|
const time = chartRef.current!.timeScale().coordinateToTime(timeCoordinate);
|
|
const price = seriesRef.current!.coordinateToPrice(priceCoordinate);
|
|
|
|
if (time === null || price === null) return;
|
|
|
|
// Check if clicking on a selected line's endpoint handle to start dragging
|
|
if (selectedLineId !== null && !activeTool) {
|
|
const linePrimitive = linePrimitivesRef.current.get(selectedLineId);
|
|
if (linePrimitive) {
|
|
const p1 = linePrimitive.getP1();
|
|
const p2 = linePrimitive.getP2();
|
|
|
|
if (isNearEndpoint(timeCoordinate, priceCoordinate, p1.time, p1.price)) {
|
|
setDragState({ lineId: selectedLineId, endpoint: 'p1' });
|
|
return;
|
|
}
|
|
|
|
if (isNearEndpoint(timeCoordinate, priceCoordinate, p2.time, p2.price)) {
|
|
setDragState({ lineId: selectedLineId, endpoint: 'p2' });
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If currently dragging, complete the drag operation
|
|
if (dragState) {
|
|
const linePrimitive = linePrimitivesRef.current.get(dragState.lineId);
|
|
if (linePrimitive) {
|
|
const annotation = annotations.find(a => a.id === dragState.lineId);
|
|
if (annotation) {
|
|
// Persist the updated geometry
|
|
const p1 = linePrimitive.getP1();
|
|
const p2 = linePrimitive.getP2();
|
|
|
|
try {
|
|
await fetch(`/api/annotations/${annotation.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
geometry: {
|
|
startTime: typeof p1.time === 'string' ? Date.parse(p1.time) / 1000 : p1.time,
|
|
startPrice: p1.price,
|
|
endTime: typeof p2.time === 'string' ? Date.parse(p2.time) / 1000 : p2.time,
|
|
endPrice: p2.price,
|
|
},
|
|
}),
|
|
});
|
|
await fetchAnnotations();
|
|
onAnnotationChange?.();
|
|
} catch (error) {
|
|
console.error('Failed to update line annotation:', error);
|
|
}
|
|
}
|
|
}
|
|
setDragState(null);
|
|
return;
|
|
}
|
|
|
|
// Handle line and rectangle drawing
|
|
if (activeTool === 'line' || activeTool === 'rectangle') {
|
|
if (!drawingState) {
|
|
// First click: record first point and show preview
|
|
const firstPoint = { time, price };
|
|
setDrawingState({ tool: activeTool, firstPoint });
|
|
|
|
// Create preview primitive
|
|
if (seriesRef.current && chartRef.current) {
|
|
const previewPoint = { time, price };
|
|
|
|
if (activeTool === 'line') {
|
|
const preview = new TrendLine(
|
|
chartRef.current,
|
|
seriesRef.current,
|
|
firstPoint as Point,
|
|
previewPoint as Point,
|
|
{
|
|
lineColor: selectedColor,
|
|
width: 2,
|
|
showLabels: false,
|
|
isPreview: true,
|
|
}
|
|
);
|
|
seriesRef.current.attachPrimitive(preview);
|
|
previewPrimitiveRef.current = preview;
|
|
} else {
|
|
const preview = new RectangleDrawingPrimitive({
|
|
p1: firstPoint as RectanglePoint,
|
|
p2: previewPoint as RectanglePoint,
|
|
color: selectedColor,
|
|
isPreview: true,
|
|
});
|
|
seriesRef.current.attachPrimitive(preview);
|
|
previewPrimitiveRef.current = preview;
|
|
}
|
|
}
|
|
} else {
|
|
// Second click: save annotation and create permanent primitive
|
|
const secondPoint = { time, price };
|
|
|
|
try {
|
|
const timestamp = typeof drawingState.firstPoint.time === 'string'
|
|
? Date.parse(drawingState.firstPoint.time) / 1000
|
|
: (drawingState.firstPoint.time as number);
|
|
|
|
const response = await fetch('/api/annotations', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
timestamp,
|
|
label_type: activeTool,
|
|
chart_id: activeChartId,
|
|
color: selectedColor,
|
|
geometry: {
|
|
startTime: typeof drawingState.firstPoint.time === 'string'
|
|
? Date.parse(drawingState.firstPoint.time) / 1000
|
|
: drawingState.firstPoint.time,
|
|
startPrice: drawingState.firstPoint.price,
|
|
endTime: typeof secondPoint.time === 'string'
|
|
? Date.parse(secondPoint.time) / 1000
|
|
: secondPoint.time,
|
|
endPrice: secondPoint.price,
|
|
},
|
|
}),
|
|
});
|
|
|
|
if (response.ok) {
|
|
// Detach preview
|
|
if (previewPrimitiveRef.current && seriesRef.current) {
|
|
seriesRef.current.detachPrimitive(previewPrimitiveRef.current);
|
|
previewPrimitiveRef.current = null;
|
|
}
|
|
|
|
// Clear drawing state
|
|
setDrawingState(null);
|
|
|
|
// Refresh annotations to load the permanent primitive
|
|
await fetchAnnotations();
|
|
onAnnotationChange?.();
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to create annotation:', error);
|
|
}
|
|
}
|
|
return; // Don't process other tools
|
|
}
|
|
|
|
if (!activeTool) return;
|
|
|
|
// Check if activeTool is a marker type
|
|
const markerType = annotationTypes.find(
|
|
(t) => t.category === 'marker' && t.name === activeTool
|
|
);
|
|
|
|
if (markerType) {
|
|
const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
|
|
|
|
// Find nearest candle
|
|
const nearestCandle = candles.reduce((prev, curr) => {
|
|
return Math.abs(curr.time - timestamp) < Math.abs(prev.time - timestamp) ? curr : prev;
|
|
}, candles[0]);
|
|
|
|
if (!nearestCandle) return;
|
|
|
|
// Create annotation
|
|
try {
|
|
const response = await fetch('/api/annotations', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
timestamp: nearestCandle.time,
|
|
label_type: activeTool,
|
|
chart_id: activeChartId,
|
|
}),
|
|
});
|
|
|
|
if (response.ok) {
|
|
await fetchAnnotations();
|
|
onAnnotationChange?.();
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to create annotation:', error);
|
|
}
|
|
}
|
|
|
|
// For delete tool, find and delete line, rectangle, or marker at clicked position
|
|
if (activeTool === 'delete') {
|
|
const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
|
|
|
|
// First, check for line hit using primitives' hitTest
|
|
const deleteLineHit = (() => {
|
|
let result: { id: number; primitive: TrendLine } | null = null;
|
|
linePrimitivesRef.current.forEach((primitive, id) => {
|
|
const hit = primitive.hitTest(timeCoordinate, priceCoordinate);
|
|
if (hit) {
|
|
result = { id, primitive };
|
|
}
|
|
});
|
|
return result as { id: number; primitive: TrendLine } | null;
|
|
})();
|
|
|
|
if (deleteLineHit) {
|
|
// Delete the clicked line
|
|
try {
|
|
const response = await fetch(`/api/annotations/${deleteLineHit.id}`, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
if (response.ok) {
|
|
seriesRef.current!.detachPrimitive(deleteLineHit.primitive);
|
|
linePrimitivesRef.current.delete(deleteLineHit.id);
|
|
if (selectedLineId === deleteLineHit.id) {
|
|
setSelectedLineId(null);
|
|
}
|
|
await fetchAnnotations();
|
|
onAnnotationChange?.();
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to delete line annotation:', error);
|
|
}
|
|
return; // Don't process rectangle/marker deletion
|
|
}
|
|
|
|
// Next, check for rectangle hit using primitives' hitTest
|
|
const rectangleHit = (() => {
|
|
let result: { id: number; primitive: RectangleDrawingPrimitive } | null = null;
|
|
rectanglePrimitivesRef.current.forEach((primitive, id) => {
|
|
const hit = primitive.hitTest(timeCoordinate, priceCoordinate);
|
|
if (hit) {
|
|
result = { id, primitive };
|
|
}
|
|
});
|
|
return result as { id: number; primitive: RectangleDrawingPrimitive } | null;
|
|
})();
|
|
|
|
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
|
|
.filter((t) => t.category === 'marker')
|
|
.map((t) => t.name);
|
|
|
|
// Find annotation at this timestamp (within tolerance)
|
|
const tolerance = 60; // 60 seconds tolerance
|
|
const annotation = annotations.find(
|
|
(a) =>
|
|
markerTypeNames.includes(a.label_type) &&
|
|
Math.abs(a.timestamp - timestamp) < tolerance
|
|
);
|
|
|
|
if (annotation) {
|
|
try {
|
|
const response = await fetch(`/api/annotations/${annotation.id}`, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
if (response.ok) {
|
|
await fetchAnnotations();
|
|
onAnnotationChange?.();
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to delete annotation:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle line selection when no tool is active or delete tool is active
|
|
if (!activeTool || activeTool === 'delete') {
|
|
// Check if a line was clicked
|
|
const lineHit = (() => {
|
|
let result: { id: number; primitive: TrendLine } | null = null;
|
|
linePrimitivesRef.current.forEach((primitive, id) => {
|
|
const hit = primitive.hitTest(timeCoordinate, priceCoordinate);
|
|
if (hit && activeTool !== 'delete') {
|
|
result = { id, primitive };
|
|
}
|
|
});
|
|
return result as { id: number; primitive: TrendLine } | null;
|
|
})();
|
|
|
|
if (lineHit && activeTool !== 'delete') {
|
|
// Toggle selection
|
|
const newSelectedId = selectedLineId === lineHit.id ? null : lineHit.id;
|
|
setSelectedLineId(newSelectedId);
|
|
|
|
// Update all lines' selection state
|
|
linePrimitivesRef.current.forEach((p, pid) => {
|
|
p.setSelected(pid === newSelectedId);
|
|
});
|
|
seriesRef.current!.applyOptions({});
|
|
return; // Don't process rectangle/marker selection
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
if (!activeTool && predictionVisible && predictionSpans.length > 0) {
|
|
const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
|
|
|
|
// Find if click is within any prediction span
|
|
const clickedSpan = predictionSpans.find(
|
|
(span) => timestamp >= span.start_time && timestamp <= span.end_time
|
|
);
|
|
|
|
if (clickedSpan) {
|
|
// Check if this span is a disagreement
|
|
const disagreementMap = new Map<number, string>();
|
|
if (predictionSummary?.disagreements) {
|
|
predictionSummary.disagreements.forEach((d) => {
|
|
if (d.predictionSpan) {
|
|
disagreementMap.set(d.predictionSpan.start_time, d.type);
|
|
}
|
|
});
|
|
}
|
|
|
|
const disagreementType = disagreementMap.get(clickedSpan.start_time) || null;
|
|
|
|
// Check if Alt or Ctrl key is pressed for dismiss action
|
|
if ((param.sourceEvent?.altKey || param.sourceEvent?.ctrlKey) && onPredictionDismiss) {
|
|
// Alt+Click or Ctrl+Click: Dismiss as "not a pattern"
|
|
onPredictionDismiss(clickedSpan, disagreementType);
|
|
} else if (onPredictionClick) {
|
|
// Normal click: Convert to annotation (only for disagreements)
|
|
if (disagreementType === 'missed_by_human' || disagreementType === 'label_mismatch') {
|
|
onPredictionClick(clickedSpan, disagreementType);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Select/deselect label markers by clicking them
|
|
const isMarkerTool = annotationTypes.find(
|
|
(t) => t.category === 'marker' && t.name === activeTool
|
|
);
|
|
|
|
if (!activeTool || isMarkerTool) {
|
|
const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
|
|
const markerTypeNames = annotationTypes
|
|
.filter((t) => t.category === 'marker')
|
|
.map((t) => t.name);
|
|
|
|
// Find annotation at this timestamp (within tolerance)
|
|
const tolerance = 60; // 60 seconds tolerance
|
|
const annotation = annotations.find(
|
|
(a) =>
|
|
markerTypeNames.includes(a.label_type) &&
|
|
Math.abs(a.timestamp - timestamp) < tolerance
|
|
);
|
|
|
|
if (annotation) {
|
|
onLabelSelect?.(annotation.id === selectedLabelId ? -1 : annotation.id);
|
|
}
|
|
}
|
|
};
|
|
|
|
chartRef.current.subscribeClick(handleClick);
|
|
|
|
return () => {
|
|
chartRef.current?.unsubscribeClick(handleClick);
|
|
};
|
|
}, [activeTool, candles, annotations, annotationTypes, onAnnotationChange, predictionVisible, predictionSpans, predictionSummary, onPredictionClick, onPredictionDismiss, drawingState, selectedColor]);
|
|
|
|
// Handle crosshair move to update preview during drawing and dragging
|
|
useEffect(() => {
|
|
if (!chartRef.current || !seriesRef.current) return;
|
|
if (!drawingState && !dragState) return;
|
|
if (drawingState && !previewPrimitiveRef.current) return;
|
|
|
|
let isUpdating = false;
|
|
const handleCrosshairMove = (param: any) => {
|
|
if (isUpdating) return;
|
|
if (!param.point) return;
|
|
|
|
const time = chartRef.current!.timeScale().coordinateToTime(param.point.x);
|
|
const price = seriesRef.current!.coordinateToPrice(param.point.y);
|
|
|
|
if (time === null || price === null) return;
|
|
|
|
const currentPoint = { time, price };
|
|
|
|
isUpdating = true;
|
|
try {
|
|
// Update preview primitive endpoint during drawing
|
|
if (drawingState && previewPrimitiveRef.current) {
|
|
if (previewPrimitiveRef.current instanceof TrendLine) {
|
|
previewPrimitiveRef.current.updatePoints(
|
|
drawingState.firstPoint as Point,
|
|
currentPoint as Point
|
|
);
|
|
} else if (previewPrimitiveRef.current instanceof RectangleDrawingPrimitive) {
|
|
previewPrimitiveRef.current.updatePoints(
|
|
drawingState.firstPoint as RectanglePoint,
|
|
currentPoint as RectanglePoint
|
|
);
|
|
}
|
|
seriesRef.current!.applyOptions({});
|
|
}
|
|
|
|
// Update line endpoint during dragging
|
|
if (dragState) {
|
|
const linePrimitive = linePrimitivesRef.current.get(dragState.lineId);
|
|
if (linePrimitive) {
|
|
const p1 = linePrimitive.getP1();
|
|
const p2 = linePrimitive.getP2();
|
|
|
|
if (dragState.endpoint === 'p1') {
|
|
linePrimitive.updatePoints(currentPoint as Point, p2);
|
|
} else {
|
|
linePrimitive.updatePoints(p1, currentPoint as Point);
|
|
}
|
|
seriesRef.current!.applyOptions({});
|
|
}
|
|
}
|
|
} finally {
|
|
isUpdating = false;
|
|
}
|
|
};
|
|
|
|
chartRef.current.subscribeCrosshairMove(handleCrosshairMove);
|
|
|
|
return () => {
|
|
chartRef.current?.unsubscribeCrosshairMove(handleCrosshairMove);
|
|
};
|
|
}, [drawingState, dragState]);
|
|
|
|
// Handle Escape key to cancel drawing
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && drawingState && previewPrimitiveRef.current) {
|
|
// Detach preview primitive
|
|
if (seriesRef.current) {
|
|
seriesRef.current.detachPrimitive(previewPrimitiveRef.current);
|
|
previewPrimitiveRef.current = null;
|
|
}
|
|
// Clear drawing state
|
|
setDrawingState(null);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
|
|
return () => {
|
|
window.removeEventListener('keydown', handleKeyDown);
|
|
};
|
|
}, [drawingState]);
|
|
|
|
// Manage TrendLine primitives for saved line annotations
|
|
useEffect(() => {
|
|
if (!chartRef.current || !seriesRef.current || annotations.length === 0) return;
|
|
|
|
// Filter line annotations
|
|
const lineAnnotations = annotations.filter((a) => a.label_type === 'line' && a.geometry);
|
|
|
|
// Detach old primitives that no longer exist
|
|
const currentIds = new Set(lineAnnotations.map((a) => a.id));
|
|
linePrimitivesRef.current.forEach((primitive, id) => {
|
|
if (!currentIds.has(id)) {
|
|
seriesRef.current!.detachPrimitive(primitive);
|
|
linePrimitivesRef.current.delete(id);
|
|
}
|
|
});
|
|
|
|
// Create/update primitives for line annotations
|
|
lineAnnotations.forEach((annotation) => {
|
|
const geometry = annotation.geometry!;
|
|
if (!geometry.startTime || !geometry.endTime) return;
|
|
|
|
// Check if primitive already exists
|
|
if (!linePrimitivesRef.current.has(annotation.id)) {
|
|
// Create new TrendLine primitive
|
|
const p1: Point = {
|
|
time: geometry.startTime as Time,
|
|
price: geometry.startPrice!,
|
|
};
|
|
const p2: Point = {
|
|
time: geometry.endTime as Time,
|
|
price: geometry.endPrice!,
|
|
};
|
|
|
|
const color = annotationTypes.find((t) => t.name === 'line')?.color || selectedColor;
|
|
|
|
const trendLine = new TrendLine(
|
|
chartRef.current!,
|
|
seriesRef.current!,
|
|
p1,
|
|
p2,
|
|
{
|
|
lineColor: color,
|
|
width: 2,
|
|
showLabels: false,
|
|
annotationId: String(annotation.id),
|
|
}
|
|
);
|
|
|
|
seriesRef.current!.attachPrimitive(trendLine);
|
|
linePrimitivesRef.current.set(annotation.id, trendLine);
|
|
}
|
|
});
|
|
}, [annotations, annotationTypes, selectedColor]);
|
|
|
|
// 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(() => {
|
|
fetchCandles();
|
|
fetchAnnotations();
|
|
fetchAnnotationTypes();
|
|
}, [activeChartId]);
|
|
|
|
if (isEmpty) {
|
|
return (
|
|
<div className="flex-1 flex items-center justify-center bg-background">
|
|
<div className="text-muted-foreground text-center">
|
|
<p className="text-lg">Upload a CSV file to view the candlestick chart</p>
|
|
<p className="text-sm mt-2">CSV format: time, open, high, low, close</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="w-full h-full relative">
|
|
<div ref={chartContainerRef} className="absolute inset-0" />
|
|
<SpanAnnotationManager
|
|
chart={chartRef.current}
|
|
series={seriesRef.current}
|
|
activeTool={activeTool}
|
|
candles={candles}
|
|
spanAnnotations={spanAnnotations}
|
|
spanLabelTypes={spanLabelTypes}
|
|
selectedSpanId={selectedSpanId}
|
|
onSpanAnnotationsChange={onSpanAnnotationsChange || (() => {})}
|
|
onSelectedSpanChange={onSelectedSpanChange || (() => {})}
|
|
activeChartId={activeChartId ?? null}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
CandleChart.displayName = 'CandleChart';
|
|
|
|
export default CandleChart;
|