candle-annotator/src/components/CandleChart.tsx
Marko Djordjevic 82fd5ce819 feat: wire up drawing interaction for line and rectangle tools (tasks 3.1-3.5)
- Add drawing state and preview primitive refs
- Implement two-click drawing flow via subscribeClick
- Add crosshair move handler to update preview in real-time
- Add Escape key handler to cancel drawing
- Manage TrendLine primitives for saved line annotations
2026-02-16 11:55:06 +01:00

949 lines
33 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 SvgOverlay from './SvgOverlay';
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: number;
};
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: number;
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());
// 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 === 1));
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;
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;
// 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 marker at clicked position
if (activeTool === 'delete') {
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) {
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 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
useEffect(() => {
if (!chartRef.current || !seriesRef.current || !drawingState || !previewPrimitiveRef.current) return;
const handleCrosshairMove = (param: any) => {
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 };
// Update preview primitive endpoint
if (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({});
}
};
chartRef.current.subscribeCrosshairMove(handleCrosshairMove);
return () => {
chartRef.current?.unsubscribeCrosshairMove(handleCrosshairMove);
};
}, [drawingState]);
// 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]);
// Fetch data on mount
useEffect(() => {
fetchCandles();
fetchAnnotations();
fetchAnnotationTypes();
}, []);
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" />
<SvgOverlay
chart={chartRef.current}
series={seriesRef.current}
activeTool={activeTool}
onAnnotationChange={onAnnotationChange}
selectedColor={selectedColor}
activeChartId={activeChartId}
/>
<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;