feat(visualizer): improve chart interactions
- Rename Drift → Trade in UI\n- Dynamic price precision for low prices\n- Fib retracement: select + move in X+Y\n- Ctrl+wheel vertical zoom, Ctrl+drag vertical pan\n- Auto Scale toggle for price scale
This commit is contained in:
@@ -31,9 +31,12 @@ export default function ChartPanel({
|
||||
const [fibStart, setFibStart] = useState<FibAnchor | null>(null);
|
||||
const [fib, setFib] = useState<FibRetracement | null>(null);
|
||||
const [fibDraft, setFibDraft] = useState<FibRetracement | null>(null);
|
||||
const [fibMove, setFibMove] = useState<{ start: FibAnchor; origin: FibRetracement } | null>(null);
|
||||
const [priceAutoScale, setPriceAutoScale] = useState(true);
|
||||
const chartApiRef = useRef<IChartApi | null>(null);
|
||||
const activeToolRef = useRef(activeTool);
|
||||
const fibStartRef = useRef<FibAnchor | null>(fibStart);
|
||||
const fibMoveRef = useRef<{ start: FibAnchor; origin: FibRetracement } | null>(fibMove);
|
||||
const pendingMoveRef = useRef<FibAnchor | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
@@ -57,6 +60,7 @@ export default function ChartPanel({
|
||||
|
||||
useEffect(() => {
|
||||
activeToolRef.current = activeTool;
|
||||
if (activeTool === 'fib-retracement') setFibMove(null);
|
||||
if (activeTool !== 'fib-retracement') {
|
||||
setFibStart(null);
|
||||
setFibDraft(null);
|
||||
@@ -67,9 +71,20 @@ export default function ChartPanel({
|
||||
fibStartRef.current = fibStart;
|
||||
}, [fibStart]);
|
||||
|
||||
useEffect(() => {
|
||||
fibMoveRef.current = fibMove;
|
||||
}, [fibMove]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
|
||||
if (fibMoveRef.current) {
|
||||
setFibMove(null);
|
||||
setFibDraft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeToolRef.current !== 'fib-retracement') return;
|
||||
setFibStart(null);
|
||||
setFibDraft(null);
|
||||
@@ -108,6 +123,8 @@ export default function ChartPanel({
|
||||
onTimeframeChange={onTimeframeChange}
|
||||
showIndicators={showIndicators}
|
||||
onToggleIndicators={onToggleIndicators}
|
||||
priceAutoScale={priceAutoScale}
|
||||
onTogglePriceAutoScale={() => setPriceAutoScale((v) => !v)}
|
||||
seriesLabel={seriesLabel}
|
||||
isFullscreen={isFullscreen}
|
||||
onToggleFullscreen={() => setIsFullscreen((v) => !v)}
|
||||
@@ -126,6 +143,7 @@ export default function ChartPanel({
|
||||
setFib(null);
|
||||
setFibStart(null);
|
||||
setFibDraft(null);
|
||||
setFibMove(null);
|
||||
}}
|
||||
/>
|
||||
<div className="chartCard__chart">
|
||||
@@ -137,35 +155,68 @@ export default function ChartPanel({
|
||||
bb20={indicators.bb20}
|
||||
showIndicators={showIndicators}
|
||||
fib={fibDraft ?? fib}
|
||||
fibSelected={fibMove != null}
|
||||
priceAutoScale={priceAutoScale}
|
||||
onReady={({ chart }) => {
|
||||
chartApiRef.current = chart;
|
||||
}}
|
||||
onChartClick={(p) => {
|
||||
if (activeTool !== 'fib-retracement') return;
|
||||
if (!fibStartRef.current) {
|
||||
fibStartRef.current = p;
|
||||
setFibStart(p);
|
||||
setFibDraft({ a: p, b: p });
|
||||
if (activeTool === 'fib-retracement') {
|
||||
if (!fibStartRef.current) {
|
||||
fibStartRef.current = p;
|
||||
setFibStart(p);
|
||||
setFibDraft({ a: p, b: p });
|
||||
return;
|
||||
}
|
||||
setFib({ a: fibStartRef.current, b: p });
|
||||
setFibStart(null);
|
||||
fibStartRef.current = null;
|
||||
setFibDraft(null);
|
||||
setActiveTool('cursor');
|
||||
return;
|
||||
}
|
||||
setFib({ a: fibStartRef.current, b: p });
|
||||
setFibStart(null);
|
||||
fibStartRef.current = null;
|
||||
setFibDraft(null);
|
||||
setActiveTool('cursor');
|
||||
|
||||
const move = fibMoveRef.current;
|
||||
if (move) {
|
||||
const deltaLogical = p.logical - move.start.logical;
|
||||
const deltaPrice = p.price - move.start.price;
|
||||
setFib({
|
||||
a: { logical: move.origin.a.logical + deltaLogical, price: move.origin.a.price + deltaPrice },
|
||||
b: { logical: move.origin.b.logical + deltaLogical, price: move.origin.b.price + deltaPrice },
|
||||
});
|
||||
setFibMove(null);
|
||||
setFibDraft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (p.target === 'fib' && fib) {
|
||||
setFibMove({ start: p, origin: fib });
|
||||
setFibDraft(fib);
|
||||
}
|
||||
}}
|
||||
onChartCrosshairMove={(p) => {
|
||||
if (activeToolRef.current !== 'fib-retracement') return;
|
||||
const start = fibStartRef.current;
|
||||
if (!start) return;
|
||||
pendingMoveRef.current = p;
|
||||
if (rafRef.current != null) return;
|
||||
rafRef.current = window.requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
const move = pendingMoveRef.current;
|
||||
const pointer = pendingMoveRef.current;
|
||||
if (!pointer) return;
|
||||
|
||||
const move = fibMoveRef.current;
|
||||
if (move) {
|
||||
const deltaLogical = pointer.logical - move.start.logical;
|
||||
const deltaPrice = pointer.price - move.start.price;
|
||||
setFibDraft({
|
||||
a: { logical: move.origin.a.logical + deltaLogical, price: move.origin.a.price + deltaPrice },
|
||||
b: { logical: move.origin.b.logical + deltaLogical, price: move.origin.b.price + deltaPrice },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeToolRef.current !== 'fib-retracement') return;
|
||||
const start2 = fibStartRef.current;
|
||||
if (!move || !start2) return;
|
||||
setFibDraft({ a: start2, b: move });
|
||||
if (!start2) return;
|
||||
setFibDraft({ a: start2, b: pointer });
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,8 @@ type Props = {
|
||||
onTimeframeChange: (tf: string) => void;
|
||||
showIndicators: boolean;
|
||||
onToggleIndicators: () => void;
|
||||
priceAutoScale: boolean;
|
||||
onTogglePriceAutoScale: () => void;
|
||||
seriesLabel: string;
|
||||
isFullscreen: boolean;
|
||||
onToggleFullscreen: () => void;
|
||||
@@ -17,6 +19,8 @@ export default function ChartToolbar({
|
||||
onTimeframeChange,
|
||||
showIndicators,
|
||||
onToggleIndicators,
|
||||
priceAutoScale,
|
||||
onTogglePriceAutoScale,
|
||||
seriesLabel,
|
||||
isFullscreen,
|
||||
onToggleFullscreen,
|
||||
@@ -41,6 +45,9 @@ export default function ChartToolbar({
|
||||
<Button size="sm" variant={showIndicators ? 'primary' : 'ghost'} onClick={onToggleIndicators} type="button">
|
||||
Indicators
|
||||
</Button>
|
||||
<Button size="sm" variant={priceAutoScale ? 'primary' : 'ghost'} onClick={onTogglePriceAutoScale} type="button">
|
||||
Auto Scale
|
||||
</Button>
|
||||
<Button size="sm" variant={isFullscreen ? 'primary' : 'ghost'} onClick={onToggleFullscreen} type="button">
|
||||
{isFullscreen ? 'Exit' : 'Fullscreen'}
|
||||
</Button>
|
||||
|
||||
@@ -54,6 +54,7 @@ type State = {
|
||||
fib: FibRetracement | null;
|
||||
series: ISeriesApi<'Candlestick', Time> | null;
|
||||
chart: SeriesAttachedParameter<Time>['chart'] | null;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
class FibPaneRenderer implements IPrimitivePaneRenderer {
|
||||
@@ -64,7 +65,7 @@ class FibPaneRenderer implements IPrimitivePaneRenderer {
|
||||
}
|
||||
|
||||
draw(target: any) {
|
||||
const { fib, series, chart } = this._getState();
|
||||
const { fib, series, chart, selected } = this._getState();
|
||||
if (!fib || !series || !chart) return;
|
||||
|
||||
const x1 = chart.timeScale().logicalToCoordinate(fib.a.logical as any);
|
||||
@@ -132,7 +133,7 @@ class FibPaneRenderer implements IPrimitivePaneRenderer {
|
||||
const bx = Math.round(x2 * horizontalPixelRatio);
|
||||
const by = Math.round(y1 * verticalPixelRatio);
|
||||
|
||||
context.strokeStyle = 'rgba(226,232,240,0.55)';
|
||||
context.strokeStyle = selected ? 'rgba(250,204,21,0.65)' : 'rgba(226,232,240,0.55)';
|
||||
context.lineWidth = Math.max(1, Math.round(1 * horizontalPixelRatio));
|
||||
context.setLineDash([Math.max(2, Math.round(5 * horizontalPixelRatio)), Math.max(2, Math.round(5 * horizontalPixelRatio))]);
|
||||
context.beginPath();
|
||||
@@ -141,14 +142,20 @@ class FibPaneRenderer implements IPrimitivePaneRenderer {
|
||||
context.stroke();
|
||||
context.setLineDash([]);
|
||||
|
||||
const r = Math.max(2, Math.round(3 * horizontalPixelRatio));
|
||||
context.fillStyle = 'rgba(147,197,253,0.95)';
|
||||
const r = Math.max(2, Math.round((selected ? 4 : 3) * horizontalPixelRatio));
|
||||
context.fillStyle = selected ? 'rgba(250,204,21,0.95)' : 'rgba(147,197,253,0.95)';
|
||||
if (selected) {
|
||||
context.strokeStyle = 'rgba(15,23,42,0.85)';
|
||||
context.lineWidth = Math.max(1, Math.round(1 * horizontalPixelRatio));
|
||||
}
|
||||
context.beginPath();
|
||||
context.arc(ax, ay, r, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
if (selected) context.stroke();
|
||||
context.beginPath();
|
||||
context.arc(bx, by, r, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
if (selected) context.stroke();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -170,11 +177,17 @@ export class FibRetracementPrimitive implements ISeriesPrimitive<Time> {
|
||||
private _param: SeriesAttachedParameter<Time> | null = null;
|
||||
private _series: ISeriesApi<'Candlestick', Time> | null = null;
|
||||
private _fib: FibRetracement | null = null;
|
||||
private _selected = false;
|
||||
private readonly _paneView: FibPaneView;
|
||||
private readonly _paneViews: readonly IPrimitivePaneView[];
|
||||
|
||||
constructor() {
|
||||
this._paneView = new FibPaneView(() => ({ fib: this._fib, series: this._series, chart: this._param?.chart ?? null }));
|
||||
this._paneView = new FibPaneView(() => ({
|
||||
fib: this._fib,
|
||||
series: this._series,
|
||||
chart: this._param?.chart ?? null,
|
||||
selected: this._selected,
|
||||
}));
|
||||
this._paneViews = [this._paneView];
|
||||
}
|
||||
|
||||
@@ -196,4 +209,9 @@ export class FibRetracementPrimitive implements ISeriesPrimitive<Time> {
|
||||
this._fib = next;
|
||||
this._param?.requestUpdate();
|
||||
}
|
||||
|
||||
setSelected(next: boolean) {
|
||||
this._selected = next;
|
||||
this._param?.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,10 @@ type Props = {
|
||||
bb20?: { upper: SeriesPoint[]; lower: SeriesPoint[]; mid: SeriesPoint[] };
|
||||
showIndicators: boolean;
|
||||
fib?: FibRetracement | null;
|
||||
fibSelected?: boolean;
|
||||
priceAutoScale?: boolean;
|
||||
onReady?: (api: { chart: IChartApi; candles: ISeriesApi<'Candlestick', UTCTimestamp> }) => void;
|
||||
onChartClick?: (p: FibAnchor) => void;
|
||||
onChartClick?: (p: FibAnchor & { target: 'chart' | 'fib' }) => void;
|
||||
onChartCrosshairMove?: (p: FibAnchor) => void;
|
||||
};
|
||||
|
||||
@@ -37,6 +39,27 @@ function toTime(t: number): UTCTimestamp {
|
||||
return t as UTCTimestamp;
|
||||
}
|
||||
|
||||
function samplePriceFromCandles(candles: Candle[]): number | null {
|
||||
for (let i = candles.length - 1; i >= 0; i -= 1) {
|
||||
const close = candles[i]?.close;
|
||||
if (typeof close !== 'number') continue;
|
||||
if (!Number.isFinite(close)) continue;
|
||||
if (close === 0) continue;
|
||||
return close;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function priceFormatForSample(price: number) {
|
||||
const abs = Math.abs(price);
|
||||
if (!Number.isFinite(abs) || abs === 0) return { type: 'price' as const, precision: 2, minMove: 0.01 };
|
||||
if (abs >= 1000) return { type: 'price' as const, precision: 0, minMove: 1 };
|
||||
if (abs >= 1) return { type: 'price' as const, precision: 2, minMove: 0.01 };
|
||||
const exponent = Math.floor(Math.log10(abs)); // negative for abs < 1
|
||||
const precision = Math.min(8, Math.max(4, -exponent + 3));
|
||||
return { type: 'price' as const, precision, minMove: Math.pow(10, -precision) };
|
||||
}
|
||||
|
||||
function toCandleData(candles: Candle[]): CandlestickData[] {
|
||||
return candles.map((c) => ({
|
||||
time: toTime(c.time),
|
||||
@@ -71,6 +94,8 @@ export default function TradingChart({
|
||||
bb20,
|
||||
showIndicators,
|
||||
fib,
|
||||
fibSelected = false,
|
||||
priceAutoScale = true,
|
||||
onReady,
|
||||
onChartClick,
|
||||
onChartCrosshairMove,
|
||||
@@ -78,6 +103,9 @@ export default function TradingChart({
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
const fibPrimitiveRef = useRef<FibRetracementPrimitive | null>(null);
|
||||
const fibRef = useRef<FibRetracement | null>(fib ?? null);
|
||||
const priceAutoScaleRef = useRef<boolean>(priceAutoScale);
|
||||
const prevPriceAutoScaleRef = useRef<boolean>(priceAutoScale);
|
||||
const onReadyRef = useRef<Props['onReady']>(onReady);
|
||||
const onChartClickRef = useRef<Props['onChartClick']>(onChartClick);
|
||||
const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove);
|
||||
@@ -100,6 +128,10 @@ export default function TradingChart({
|
||||
const bbUpper = useMemo(() => toLineSeries(bb20?.upper), [bb20?.upper]);
|
||||
const bbLower = useMemo(() => toLineSeries(bb20?.lower), [bb20?.lower]);
|
||||
const bbMid = useMemo(() => toLineSeries(bb20?.mid), [bb20?.mid]);
|
||||
const priceFormat = useMemo(() => {
|
||||
const sample = samplePriceFromCandles(candles);
|
||||
return sample == null ? { type: 'price' as const, precision: 2, minMove: 0.01 } : priceFormatForSample(sample);
|
||||
}, [candles]);
|
||||
|
||||
useEffect(() => {
|
||||
onReadyRef.current = onReady;
|
||||
@@ -113,6 +145,14 @@ export default function TradingChart({
|
||||
onChartCrosshairMoveRef.current = onChartCrosshairMove;
|
||||
}, [onChartCrosshairMove]);
|
||||
|
||||
useEffect(() => {
|
||||
fibRef.current = fib ?? null;
|
||||
}, [fib]);
|
||||
|
||||
useEffect(() => {
|
||||
priceAutoScaleRef.current = priceAutoScale;
|
||||
}, [priceAutoScale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
if (chartRef.current) return;
|
||||
@@ -146,6 +186,7 @@ export default function TradingChart({
|
||||
borderVisible: false,
|
||||
wickUpColor: '#22c55e',
|
||||
wickDownColor: '#ef4444',
|
||||
priceFormat,
|
||||
});
|
||||
|
||||
const fibPrimitive = new FibRetracementPrimitive();
|
||||
@@ -166,16 +207,18 @@ export default function TradingChart({
|
||||
color: 'rgba(251,146,60,0.9)',
|
||||
lineWidth: 1,
|
||||
lineStyle: LineStyle.Dotted,
|
||||
priceFormat,
|
||||
});
|
||||
|
||||
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 smaSeries = chart.addSeries(LineSeries, { color: 'rgba(248,113,113,0.9)', lineWidth: 1, priceFormat });
|
||||
const emaSeries = chart.addSeries(LineSeries, { color: 'rgba(52,211,153,0.9)', lineWidth: 1, priceFormat });
|
||||
const bbUpperSeries = chart.addSeries(LineSeries, { color: 'rgba(250,204,21,0.6)', lineWidth: 1, priceFormat });
|
||||
const bbLowerSeries = chart.addSeries(LineSeries, { color: 'rgba(163,163,163,0.6)', lineWidth: 1, priceFormat });
|
||||
const bbMidSeries = chart.addSeries(LineSeries, {
|
||||
color: 'rgba(250,204,21,0.35)',
|
||||
lineWidth: 1,
|
||||
lineStyle: LineStyle.Dashed,
|
||||
priceFormat,
|
||||
});
|
||||
|
||||
seriesRef.current = {
|
||||
@@ -197,7 +240,35 @@ export default function TradingChart({
|
||||
if (logical == null) return;
|
||||
const price = candleSeries.coordinateToPrice(param.point.y);
|
||||
if (price == null) return;
|
||||
onChartClickRef.current?.({ logical: Number(logical), price: Number(price) });
|
||||
|
||||
const currentFib = fibRef.current;
|
||||
let target: 'chart' | 'fib' = 'chart';
|
||||
if (currentFib) {
|
||||
const x1 = chart.timeScale().logicalToCoordinate(currentFib.a.logical as any);
|
||||
const x2 = chart.timeScale().logicalToCoordinate(currentFib.b.logical as any);
|
||||
if (x1 != null && x2 != null) {
|
||||
const tol = 6;
|
||||
const left = Math.min(x1, x2) - tol;
|
||||
const right = Math.max(x1, x2) + tol;
|
||||
const p0 = currentFib.a.price;
|
||||
const delta = currentFib.b.price - p0;
|
||||
const yRatioMin = 0;
|
||||
const yRatioMax = 4.236;
|
||||
const pMin = Math.min(p0 + delta * yRatioMin, p0 + delta * yRatioMax);
|
||||
const pMax = Math.max(p0 + delta * yRatioMin, p0 + delta * yRatioMax);
|
||||
const y1 = candleSeries.priceToCoordinate(pMin);
|
||||
const y2 = candleSeries.priceToCoordinate(pMax);
|
||||
if (y1 != null && y2 != null) {
|
||||
const top = Math.min(y1, y2) - tol;
|
||||
const bottom = Math.max(y1, y2) + tol;
|
||||
if (param.point.x >= left && param.point.x <= right && param.point.y >= top && param.point.y <= bottom) {
|
||||
target = 'fib';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onChartClickRef.current?.({ logical: Number(logical), price: Number(price), target });
|
||||
};
|
||||
chart.subscribeClick(onClick);
|
||||
|
||||
@@ -211,6 +282,127 @@ export default function TradingChart({
|
||||
};
|
||||
chart.subscribeCrosshairMove(onCrosshairMove);
|
||||
|
||||
const container = containerRef.current;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (!e.ctrlKey) return;
|
||||
if (!containerRef.current) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (priceAutoScaleRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const y = e.clientY - rect.top;
|
||||
const pivotPrice = candleSeries.coordinateToPrice(y);
|
||||
if (pivotPrice == null) return;
|
||||
|
||||
const ps = candleSeries.priceScale();
|
||||
const range = ps.getVisibleRange();
|
||||
let low: number;
|
||||
let high: number;
|
||||
if (range) {
|
||||
low = Math.min(range.from, range.to);
|
||||
high = Math.max(range.from, range.to);
|
||||
} else {
|
||||
const top = candleSeries.coordinateToPrice(0);
|
||||
const bottom = candleSeries.coordinateToPrice(rect.height);
|
||||
if (top == null || bottom == null) return;
|
||||
low = Math.min(top, bottom);
|
||||
high = Math.max(top, bottom);
|
||||
}
|
||||
const span = high - low;
|
||||
if (!(span > 0)) return;
|
||||
|
||||
const zoomFactor = Math.exp(e.deltaY * 0.002);
|
||||
if (!Number.isFinite(zoomFactor) || zoomFactor <= 0) return;
|
||||
const t = Math.min(1, Math.max(0, (pivotPrice - low) / span));
|
||||
const minSpan = span * 1e-6;
|
||||
const maxSpan = span * 1e6;
|
||||
const nextSpan = Math.min(maxSpan, Math.max(minSpan, span * zoomFactor));
|
||||
const nextLow = pivotPrice - t * nextSpan;
|
||||
const nextHigh = nextLow + nextSpan;
|
||||
|
||||
ps.setAutoScale(false);
|
||||
ps.setVisibleRange({ from: nextLow, to: nextHigh });
|
||||
};
|
||||
container?.addEventListener('wheel', onWheel, { passive: false, capture: true });
|
||||
|
||||
let pan: { pointerId: number; startPrice: number; startRange: { from: number; to: number } } | null = null;
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
if (!e.ctrlKey) return;
|
||||
if (priceAutoScaleRef.current) return;
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const y = e.clientY - rect.top;
|
||||
const startPrice = candleSeries.coordinateToPrice(y);
|
||||
if (startPrice == null) return;
|
||||
|
||||
const ps = candleSeries.priceScale();
|
||||
const range = ps.getVisibleRange();
|
||||
let from: number;
|
||||
let to: number;
|
||||
if (range) {
|
||||
from = range.from;
|
||||
to = range.to;
|
||||
} else {
|
||||
const top = candleSeries.coordinateToPrice(0);
|
||||
const bottom = candleSeries.coordinateToPrice(rect.height);
|
||||
if (top == null || bottom == null) return;
|
||||
from = Math.min(top, bottom);
|
||||
to = Math.max(top, bottom);
|
||||
}
|
||||
if (!(to > from)) return;
|
||||
|
||||
pan = { pointerId: e.pointerId, startPrice, startRange: { from, to } };
|
||||
try {
|
||||
containerRef.current.setPointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!pan) return;
|
||||
if (pan.pointerId !== e.pointerId) return;
|
||||
if (!containerRef.current) return;
|
||||
if (!e.ctrlKey || priceAutoScaleRef.current) {
|
||||
pan = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const y = e.clientY - rect.top;
|
||||
const currentPrice = candleSeries.coordinateToPrice(y);
|
||||
if (currentPrice == null) return;
|
||||
const delta = pan.startPrice - currentPrice;
|
||||
|
||||
const ps = candleSeries.priceScale();
|
||||
ps.setAutoScale(false);
|
||||
ps.setVisibleRange({ from: pan.startRange.from + delta, to: pan.startRange.to + delta });
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const stopPan = (e: PointerEvent) => {
|
||||
if (!pan) return;
|
||||
if (pan.pointerId !== e.pointerId) return;
|
||||
pan = null;
|
||||
try {
|
||||
containerRef.current?.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
container?.addEventListener('pointerdown', onPointerDown, { capture: true });
|
||||
container?.addEventListener('pointermove', onPointerMove, { capture: true });
|
||||
container?.addEventListener('pointerup', stopPan, { capture: true });
|
||||
container?.addEventListener('pointercancel', stopPan, { capture: true });
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (!containerRef.current) return;
|
||||
const { width, height } = containerRef.current.getBoundingClientRect();
|
||||
@@ -221,6 +413,11 @@ export default function TradingChart({
|
||||
return () => {
|
||||
chart.unsubscribeClick(onClick);
|
||||
chart.unsubscribeCrosshairMove(onCrosshairMove);
|
||||
container?.removeEventListener('wheel', onWheel, { capture: true });
|
||||
container?.removeEventListener('pointerdown', onPointerDown, { capture: true });
|
||||
container?.removeEventListener('pointermove', onPointerMove, { capture: true });
|
||||
container?.removeEventListener('pointerup', stopPan, { capture: true });
|
||||
container?.removeEventListener('pointercancel', stopPan, { capture: true });
|
||||
ro.disconnect();
|
||||
if (fibPrimitiveRef.current) {
|
||||
candleSeries.detachPrimitive(fibPrimitiveRef.current);
|
||||
@@ -251,9 +448,51 @@ export default function TradingChart({
|
||||
s.bbMid?.applyOptions({ visible: showIndicators });
|
||||
}, [candleData, volumeData, oracleData, smaData, emaData, bbUpper, bbLower, bbMid, showIndicators]);
|
||||
|
||||
useEffect(() => {
|
||||
const s = seriesRef.current;
|
||||
if (!s.candles) return;
|
||||
s.candles.applyOptions({ priceFormat });
|
||||
s.oracle?.applyOptions({ priceFormat });
|
||||
s.sma20?.applyOptions({ priceFormat });
|
||||
s.ema20?.applyOptions({ priceFormat });
|
||||
s.bbUpper?.applyOptions({ priceFormat });
|
||||
s.bbLower?.applyOptions({ priceFormat });
|
||||
s.bbMid?.applyOptions({ priceFormat });
|
||||
}, [priceFormat]);
|
||||
|
||||
useEffect(() => {
|
||||
fibPrimitiveRef.current?.setFib(fib ?? null);
|
||||
}, [fib]);
|
||||
|
||||
useEffect(() => {
|
||||
fibPrimitiveRef.current?.setSelected(Boolean(fibSelected));
|
||||
}, [fibSelected]);
|
||||
|
||||
useEffect(() => {
|
||||
const candlesSeries = seriesRef.current.candles;
|
||||
if (!candlesSeries) return;
|
||||
const ps = candlesSeries.priceScale();
|
||||
const prev = prevPriceAutoScaleRef.current;
|
||||
prevPriceAutoScaleRef.current = priceAutoScale;
|
||||
|
||||
if (priceAutoScale) {
|
||||
ps.setAutoScale(true);
|
||||
return;
|
||||
}
|
||||
|
||||
ps.setAutoScale(false);
|
||||
if (!prev) return;
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const top = candlesSeries.coordinateToPrice(0);
|
||||
const bottom = candlesSeries.coordinateToPrice(rect.height);
|
||||
if (top == null || bottom == null) return;
|
||||
const from = Math.min(top, bottom);
|
||||
const to = Math.max(top, bottom);
|
||||
if (!(to > from)) return;
|
||||
ps.setVisibleRange({ from, to });
|
||||
}, [priceAutoScale]);
|
||||
|
||||
return <div className="tradingChart" ref={containerRef} />;
|
||||
}
|
||||
|
||||
@@ -16,16 +16,15 @@ type Props = {
|
||||
active?: NavId;
|
||||
onSelect?: (id: NavId) => void;
|
||||
rightSlot?: ReactNode;
|
||||
rightEndSlot?: ReactNode;
|
||||
};
|
||||
|
||||
export default function TopNav({ active = 'trade', onSelect, rightSlot, rightEndSlot }: Props) {
|
||||
export default function TopNav({ active = 'trade', onSelect, rightSlot }: Props) {
|
||||
return (
|
||||
<header className="topNav">
|
||||
<div className="topNav__left">
|
||||
<div className="topNav__brand" aria-label="Drift">
|
||||
<div className="topNav__brand" aria-label="Trade">
|
||||
<div className="topNav__brandMark" aria-hidden="true" />
|
||||
<div className="topNav__brandName">Drift</div>
|
||||
<div className="topNav__brandName">Trade</div>
|
||||
</div>
|
||||
<nav className="topNav__menu" aria-label="Primary">
|
||||
{navItems.map((it) => (
|
||||
@@ -56,7 +55,6 @@ export default function TopNav({ active = 'trade', onSelect, rightSlot, rightEnd
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{rightEndSlot}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user