chore: initial import
This commit is contained in:
259
apps/visualizer/src/features/chart/TradingChart.tsx
Normal file
259
apps/visualizer/src/features/chart/TradingChart.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
CandlestickSeries,
|
||||
ColorType,
|
||||
CrosshairMode,
|
||||
HistogramSeries,
|
||||
type IChartApi,
|
||||
type ISeriesApi,
|
||||
LineStyle,
|
||||
LineSeries,
|
||||
createChart,
|
||||
type UTCTimestamp,
|
||||
type CandlestickData,
|
||||
type HistogramData,
|
||||
type LineData,
|
||||
type WhitespaceData,
|
||||
} from 'lightweight-charts';
|
||||
import type { Candle, SeriesPoint } from '../../lib/api';
|
||||
import { FibRetracementPrimitive, type FibAnchor, type FibRetracement } from './FibRetracementPrimitive';
|
||||
|
||||
type Props = {
|
||||
candles: Candle[];
|
||||
oracle?: SeriesPoint[];
|
||||
sma20?: SeriesPoint[];
|
||||
ema20?: SeriesPoint[];
|
||||
bb20?: { upper: SeriesPoint[]; lower: SeriesPoint[]; mid: SeriesPoint[] };
|
||||
showIndicators: boolean;
|
||||
fib?: FibRetracement | null;
|
||||
onReady?: (api: { chart: IChartApi; candles: ISeriesApi<'Candlestick', UTCTimestamp> }) => void;
|
||||
onChartClick?: (p: FibAnchor) => void;
|
||||
onChartCrosshairMove?: (p: FibAnchor) => void;
|
||||
};
|
||||
|
||||
type LinePoint = LineData | WhitespaceData;
|
||||
|
||||
function toTime(t: number): UTCTimestamp {
|
||||
return t as UTCTimestamp;
|
||||
}
|
||||
|
||||
function toCandleData(candles: Candle[]): CandlestickData[] {
|
||||
return candles.map((c) => ({
|
||||
time: toTime(c.time),
|
||||
open: c.open,
|
||||
high: c.high,
|
||||
low: c.low,
|
||||
close: c.close,
|
||||
}));
|
||||
}
|
||||
|
||||
function toVolumeData(candles: Candle[]): HistogramData[] {
|
||||
return candles.map((c) => {
|
||||
const up = c.close >= c.open;
|
||||
return {
|
||||
time: toTime(c.time),
|
||||
value: c.volume ?? 0,
|
||||
color: up ? 'rgba(34,197,94,0.35)' : 'rgba(239,68,68,0.35)',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function toLineSeries(points: SeriesPoint[] | undefined): LinePoint[] {
|
||||
if (!points?.length) return [];
|
||||
return points.map((p) => (p.value == null ? ({ time: toTime(p.time) } as WhitespaceData) : { time: toTime(p.time), value: p.value }));
|
||||
}
|
||||
|
||||
export default function TradingChart({
|
||||
candles,
|
||||
oracle,
|
||||
sma20,
|
||||
ema20,
|
||||
bb20,
|
||||
showIndicators,
|
||||
fib,
|
||||
onReady,
|
||||
onChartClick,
|
||||
onChartCrosshairMove,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
const fibPrimitiveRef = useRef<FibRetracementPrimitive | null>(null);
|
||||
const onReadyRef = useRef<Props['onReady']>(onReady);
|
||||
const onChartClickRef = useRef<Props['onChartClick']>(onChartClick);
|
||||
const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove);
|
||||
const seriesRef = useRef<{
|
||||
candles?: ISeriesApi<'Candlestick'>;
|
||||
volume?: ISeriesApi<'Histogram'>;
|
||||
oracle?: ISeriesApi<'Line'>;
|
||||
sma20?: ISeriesApi<'Line'>;
|
||||
ema20?: ISeriesApi<'Line'>;
|
||||
bbUpper?: ISeriesApi<'Line'>;
|
||||
bbLower?: ISeriesApi<'Line'>;
|
||||
bbMid?: ISeriesApi<'Line'>;
|
||||
}>({});
|
||||
|
||||
const candleData = useMemo(() => toCandleData(candles), [candles]);
|
||||
const volumeData = useMemo(() => toVolumeData(candles), [candles]);
|
||||
const oracleData = useMemo(() => toLineSeries(oracle), [oracle]);
|
||||
const smaData = useMemo(() => toLineSeries(sma20), [sma20]);
|
||||
const emaData = useMemo(() => toLineSeries(ema20), [ema20]);
|
||||
const bbUpper = useMemo(() => toLineSeries(bb20?.upper), [bb20?.upper]);
|
||||
const bbLower = useMemo(() => toLineSeries(bb20?.lower), [bb20?.lower]);
|
||||
const bbMid = useMemo(() => toLineSeries(bb20?.mid), [bb20?.mid]);
|
||||
|
||||
useEffect(() => {
|
||||
onReadyRef.current = onReady;
|
||||
}, [onReady]);
|
||||
|
||||
useEffect(() => {
|
||||
onChartClickRef.current = onChartClick;
|
||||
}, [onChartClick]);
|
||||
|
||||
useEffect(() => {
|
||||
onChartCrosshairMoveRef.current = onChartCrosshairMove;
|
||||
}, [onChartCrosshairMove]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
if (chartRef.current) return;
|
||||
|
||||
const chart = createChart(containerRef.current, {
|
||||
layout: {
|
||||
background: { type: ColorType.Solid, color: 'rgba(0,0,0,0)' },
|
||||
textColor: '#e6e9ef',
|
||||
fontFamily:
|
||||
'system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, sans-serif',
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: 'rgba(255,255,255,0.06)' },
|
||||
horzLines: { color: 'rgba(255,255,255,0.06)' },
|
||||
},
|
||||
crosshair: {
|
||||
mode: CrosshairMode.Normal,
|
||||
vertLine: { color: 'rgba(255,255,255,0.18)', style: LineStyle.Dashed },
|
||||
horzLine: { color: 'rgba(255,255,255,0.18)', style: LineStyle.Dashed },
|
||||
},
|
||||
rightPriceScale: { borderColor: 'rgba(255,255,255,0.08)' },
|
||||
timeScale: { borderColor: 'rgba(255,255,255,0.08)', timeVisible: true, secondsVisible: false },
|
||||
handleScale: { mouseWheel: true, pinch: true },
|
||||
handleScroll: { mouseWheel: true, pressedMouseMove: true, horzTouchDrag: true, vertTouchDrag: true },
|
||||
});
|
||||
chartRef.current = chart;
|
||||
|
||||
const candleSeries = chart.addSeries(CandlestickSeries, {
|
||||
upColor: '#22c55e',
|
||||
downColor: '#ef4444',
|
||||
borderVisible: false,
|
||||
wickUpColor: '#22c55e',
|
||||
wickDownColor: '#ef4444',
|
||||
});
|
||||
|
||||
const fibPrimitive = new FibRetracementPrimitive();
|
||||
candleSeries.attachPrimitive(fibPrimitive);
|
||||
fibPrimitiveRef.current = fibPrimitive;
|
||||
fibPrimitive.setFib(fib ?? null);
|
||||
|
||||
const volumeSeries = chart.addSeries(HistogramSeries, {
|
||||
priceFormat: { type: 'volume' },
|
||||
priceScaleId: '',
|
||||
color: 'rgba(255,255,255,0.15)',
|
||||
});
|
||||
volumeSeries.priceScale().applyOptions({
|
||||
scaleMargins: { top: 0.82, bottom: 0 },
|
||||
});
|
||||
|
||||
const oracleSeries = chart.addSeries(LineSeries, {
|
||||
color: 'rgba(251,146,60,0.9)',
|
||||
lineWidth: 1,
|
||||
lineStyle: LineStyle.Dotted,
|
||||
});
|
||||
|
||||
const smaSeries = chart.addSeries(LineSeries, { color: 'rgba(248,113,113,0.9)', lineWidth: 1 });
|
||||
const emaSeries = chart.addSeries(LineSeries, { color: 'rgba(52,211,153,0.9)', lineWidth: 1 });
|
||||
const bbUpperSeries = chart.addSeries(LineSeries, { color: 'rgba(250,204,21,0.6)', lineWidth: 1 });
|
||||
const bbLowerSeries = chart.addSeries(LineSeries, { color: 'rgba(163,163,163,0.6)', lineWidth: 1 });
|
||||
const bbMidSeries = chart.addSeries(LineSeries, {
|
||||
color: 'rgba(250,204,21,0.35)',
|
||||
lineWidth: 1,
|
||||
lineStyle: LineStyle.Dashed,
|
||||
});
|
||||
|
||||
seriesRef.current = {
|
||||
candles: candleSeries,
|
||||
volume: volumeSeries,
|
||||
oracle: oracleSeries,
|
||||
sma20: smaSeries,
|
||||
ema20: emaSeries,
|
||||
bbUpper: bbUpperSeries,
|
||||
bbLower: bbLowerSeries,
|
||||
bbMid: bbMidSeries,
|
||||
};
|
||||
|
||||
onReadyRef.current?.({ chart, candles: candleSeries as ISeriesApi<'Candlestick', UTCTimestamp> });
|
||||
|
||||
const onClick = (param: any) => {
|
||||
if (!param?.point) return;
|
||||
const logical = param.logical ?? chart.timeScale().coordinateToLogical(param.point.x);
|
||||
if (logical == null) return;
|
||||
const price = candleSeries.coordinateToPrice(param.point.y);
|
||||
if (price == null) return;
|
||||
onChartClickRef.current?.({ logical: Number(logical), price: Number(price) });
|
||||
};
|
||||
chart.subscribeClick(onClick);
|
||||
|
||||
const onCrosshairMove = (param: any) => {
|
||||
if (!param?.point) return;
|
||||
const logical = param.logical ?? chart.timeScale().coordinateToLogical(param.point.x);
|
||||
if (logical == null) return;
|
||||
const price = candleSeries.coordinateToPrice(param.point.y);
|
||||
if (price == null) return;
|
||||
onChartCrosshairMoveRef.current?.({ logical: Number(logical), price: Number(price) });
|
||||
};
|
||||
chart.subscribeCrosshairMove(onCrosshairMove);
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (!containerRef.current) return;
|
||||
const { width, height } = containerRef.current.getBoundingClientRect();
|
||||
chart.applyOptions({ width: Math.floor(width), height: Math.floor(height) });
|
||||
});
|
||||
ro.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
chart.unsubscribeClick(onClick);
|
||||
chart.unsubscribeCrosshairMove(onCrosshairMove);
|
||||
ro.disconnect();
|
||||
if (fibPrimitiveRef.current) {
|
||||
candleSeries.detachPrimitive(fibPrimitiveRef.current);
|
||||
fibPrimitiveRef.current = null;
|
||||
}
|
||||
chart.remove();
|
||||
chartRef.current = null;
|
||||
seriesRef.current = {};
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const s = seriesRef.current;
|
||||
if (!s.candles || !s.volume) return;
|
||||
s.candles.setData(candleData);
|
||||
s.volume.setData(volumeData);
|
||||
s.oracle?.setData(oracleData);
|
||||
s.sma20?.setData(smaData);
|
||||
s.ema20?.setData(emaData);
|
||||
s.bbUpper?.setData(bbUpper);
|
||||
s.bbLower?.setData(bbLower);
|
||||
s.bbMid?.setData(bbMid);
|
||||
|
||||
s.sma20?.applyOptions({ visible: showIndicators });
|
||||
s.ema20?.applyOptions({ visible: showIndicators });
|
||||
s.bbUpper?.applyOptions({ visible: showIndicators });
|
||||
s.bbLower?.applyOptions({ visible: showIndicators });
|
||||
s.bbMid?.applyOptions({ visible: showIndicators });
|
||||
}, [candleData, volumeData, oracleData, smaData, emaData, bbUpper, bbLower, bbMid, showIndicators]);
|
||||
|
||||
useEffect(() => {
|
||||
fibPrimitiveRef.current?.setFib(fib ?? null);
|
||||
}, [fib]);
|
||||
|
||||
return <div className="tradingChart" ref={containerRef} />;
|
||||
}
|
||||
Reference in New Issue
Block a user