'use client'; import { useEffect, useRef, useState, useImperativeHandle, forwardRef } from 'react'; import { createChart, IChartApi, ISeriesApi, CandlestickData, Time } from 'lightweight-charts'; import SvgOverlay from './SvgOverlay'; 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; } interface CandleChartProps { activeTool: string | null; onAnnotationChange?: () => void; } export interface CandleChartHandle { refreshData: () => Promise; } const CandleChart = forwardRef( ({ activeTool, onAnnotationChange }, ref) => { const chartContainerRef = useRef(null); const chartRef = useRef(null); const seriesRef = useRef | null>(null); const [candles, setCandles] = useState([]); const [annotations, setAnnotations] = useState([]); const [isEmpty, setIsEmpty] = useState(true); // Fetch candles from API const fetchCandles = async () => { try { const response = await fetch('/api/candles'); 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 response = await fetch('/api/annotations'); const data = await response.json(); setAnnotations(data); return data; } catch (error) { console.error('Failed to fetch annotations:', error); return []; } }; // Expose refresh method to parent useImperativeHandle(ref, () => ({ refreshData: async () => { await fetchCandles(); await fetchAnnotations(); }, })); // Initialize chart useEffect(() => { if (!chartContainerRef.current || isEmpty) return; const chart = createChart(chartContainerRef.current, { width: chartContainerRef.current.clientWidth, height: chartContainerRef.current.clientHeight, layout: { background: { color: '#0f172a' }, // Slate-900 textColor: '#e2e8f0', // Slate-200 }, grid: { vertLines: { color: '#1e293b' }, // Subtle grid horzLines: { color: '#1e293b' }, }, crosshair: { mode: 1, }, timeScale: { timeVisible: true, secondsVisible: false, borderColor: '#334155', }, rightPriceScale: { borderColor: '#334155', }, }); const candlestickSeries = chart.addCandlestickSeries({ upColor: '#22c55e', // Green downColor: '#ef4444', // Red borderVisible: false, wickUpColor: '#22c55e', wickDownColor: '#ef4444', }); chartRef.current = chart; seriesRef.current = candlestickSeries; // 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(); chart.remove(); }; }, [isEmpty]); // 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) return; const markerAnnotations = annotations.filter( (a) => a.label_type === 'break_up' || a.label_type === 'break_down' ); const markers = markerAnnotations .map((annotation) => ({ time: annotation.timestamp as Time, position: annotation.label_type === 'break_up' ? ('belowBar' as const) : ('aboveBar' as const), color: annotation.label_type === 'break_up' ? '#22c55e' : '#ef4444', shape: annotation.label_type === 'break_up' ? ('arrowUp' as const) : ('arrowDown' as const), text: annotation.label_type === 'break_up' ? 'Break Up' : 'Break Down', })) .sort((a, b) => (a.time as number) - (b.time as number)); seriesRef.current.setMarkers(markers); }, [annotations]); // Handle chart clicks for annotation useEffect(() => { if (!chartRef.current || !seriesRef.current) return; const handleClick = async (param: any) => { if (!param.point || !activeTool) 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; // For break_up and break_down, snap to nearest candle if (activeTool === 'break_up' || activeTool === 'break_down') { 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, }), }); 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); // Find annotation at this timestamp (within tolerance) const tolerance = 60; // 60 seconds tolerance const annotation = annotations.find( (a) => (a.label_type === 'break_up' || a.label_type === 'break_down') && 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); } } } }; chartRef.current.subscribeClick(handleClick); return () => { chartRef.current?.unsubscribeClick(handleClick); }; }, [activeTool, candles, annotations, onAnnotationChange]); // Fetch data on mount useEffect(() => { fetchCandles(); fetchAnnotations(); }, []); if (isEmpty) { return (

Upload a CSV file to view the candlestick chart

CSV format: time, open, high, low, close

); } return (
); } ); CandleChart.displayName = 'CandleChart'; export default CandleChart;