1220 lines
41 KiB
TypeScript
1220 lines
41 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
CandlestickSeries,
|
|
ColorType,
|
|
CrosshairMode,
|
|
HistogramSeries,
|
|
type IPrimitivePaneRenderer,
|
|
type IPrimitivePaneView,
|
|
type IChartApi,
|
|
type ISeriesApi,
|
|
type ISeriesPrimitive,
|
|
LineStyle,
|
|
LineSeries,
|
|
type SeriesAttachedParameter,
|
|
type Time,
|
|
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;
|
|
showBuild: boolean;
|
|
bucketSeconds: number;
|
|
seriesKey: string;
|
|
priceLines?: Array<{
|
|
id: string;
|
|
title: string;
|
|
price: number | null;
|
|
color: string;
|
|
lineWidth?: number;
|
|
lineStyle?: LineStyle;
|
|
axisLabelVisible?: boolean;
|
|
}>;
|
|
fib?: FibRetracement | null;
|
|
fibOpacity?: number;
|
|
fibSelected?: boolean;
|
|
priceAutoScale?: boolean;
|
|
onReady?: (api: { chart: IChartApi; candles: ISeriesApi<'Candlestick', UTCTimestamp> }) => void;
|
|
onChartClick?: (p: FibAnchor & { target: 'chart' | 'fib' }) => void;
|
|
onChartCrosshairMove?: (p: FibAnchor) => void;
|
|
onPointerEvent?: (p: {
|
|
type: 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel';
|
|
logical: number;
|
|
price: number;
|
|
x: number;
|
|
y: number;
|
|
target: 'chart' | 'fib';
|
|
event: PointerEvent;
|
|
}) => { consume?: boolean; capturePointer?: boolean } | void;
|
|
};
|
|
|
|
type LinePoint = LineData | WhitespaceData;
|
|
type BuildSample = { t: number; v: number };
|
|
|
|
const BUILD_UP_COLOR = '#22c55e';
|
|
const BUILD_DOWN_COLOR = '#ef4444';
|
|
const BUILD_FLAT_COLOR = '#60a5fa';
|
|
const BUILD_UP_SLICE = 'rgba(34,197,94,0.70)';
|
|
const BUILD_DOWN_SLICE = 'rgba(239,68,68,0.70)';
|
|
const BUILD_FLAT_SLICE = 'rgba(96,165,250,0.70)';
|
|
|
|
function toTime(t: number): UTCTimestamp {
|
|
return t as UTCTimestamp;
|
|
}
|
|
|
|
function resolveBucketSeconds(bucketSeconds: number, candles: Candle[]): number {
|
|
if (Number.isFinite(bucketSeconds) && bucketSeconds > 0) return bucketSeconds;
|
|
if (candles.length >= 2) {
|
|
const last = candles[candles.length - 1]?.time;
|
|
const prev = candles[candles.length - 2]?.time;
|
|
const delta = typeof last === 'number' && typeof prev === 'number' ? last - prev : 0;
|
|
if (Number.isFinite(delta) && delta > 0) return delta;
|
|
}
|
|
return 60;
|
|
}
|
|
|
|
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),
|
|
open: c.open,
|
|
high: c.high,
|
|
low: c.low,
|
|
close: c.close,
|
|
}));
|
|
}
|
|
|
|
function toVolumeData(candles: Candle[]): HistogramData[] {
|
|
return candles.map((c) => {
|
|
return {
|
|
time: toTime(c.time),
|
|
value: c.volume ?? 0,
|
|
color: 'rgba(148,163,184,0.22)',
|
|
};
|
|
});
|
|
}
|
|
|
|
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 }
|
|
);
|
|
}
|
|
|
|
function colorForDelta(delta: number): string {
|
|
if (delta > 0) return BUILD_UP_COLOR;
|
|
if (delta < 0) return BUILD_DOWN_COLOR;
|
|
return BUILD_FLAT_COLOR;
|
|
}
|
|
|
|
function sliceColorForDelta(delta: number): string {
|
|
if (delta > 0) return BUILD_UP_SLICE;
|
|
if (delta < 0) return BUILD_DOWN_SLICE;
|
|
return BUILD_FLAT_SLICE;
|
|
}
|
|
|
|
type SliceDir = -1 | 0 | 1;
|
|
|
|
function dirForDelta(delta: number): SliceDir {
|
|
if (delta > 0) return 1;
|
|
if (delta < 0) return -1;
|
|
return 0;
|
|
}
|
|
|
|
function buildDeltaSeriesForCandle(candle: Candle, bs: number, samples: BuildSample[] | undefined): LinePoint[] {
|
|
const eps = 1e-3;
|
|
const startT = candle.time + eps;
|
|
const endT = candle.time + bs - eps;
|
|
if (!(endT > startT)) return [];
|
|
|
|
const points: BuildSample[] = [{ t: startT, v: 0 }];
|
|
let lastT = startT;
|
|
for (const p of samples || []) {
|
|
let t = p.t;
|
|
if (t <= lastT + eps) t = lastT + eps;
|
|
if (t >= endT) break;
|
|
points.push({ t, v: p.v });
|
|
lastT = t;
|
|
}
|
|
|
|
const finalDelta = candle.close - candle.open;
|
|
if (endT > lastT + eps) {
|
|
points.push({ t: endT, v: finalDelta });
|
|
} else if (points.length) {
|
|
points[points.length - 1] = { ...points[points.length - 1]!, v: finalDelta };
|
|
}
|
|
|
|
const out: LinePoint[] = [{ time: toTime(candle.time) } as WhitespaceData];
|
|
out.push({ time: toTime(points[0]!.t), value: points[0]!.v } as LineData);
|
|
|
|
let lastLineIdx = out.length - 1;
|
|
let lastVal = points[0]!.v;
|
|
for (let i = 1; i < points.length; i += 1) {
|
|
const v = points[i]!.v;
|
|
const prev = out[lastLineIdx] as LineData;
|
|
out[lastLineIdx] = { ...prev, color: colorForDelta(v - lastVal) } as LineData;
|
|
out.push({ time: toTime(points[i]!.t), value: v } as LineData);
|
|
lastLineIdx = out.length - 1;
|
|
lastVal = v;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
type BuildSlicesState = {
|
|
enabled: boolean;
|
|
candles: Candle[];
|
|
bucketSeconds: number;
|
|
samples: Map<number, BuildSample[]>;
|
|
series: ISeriesApi<'Histogram', Time> | null;
|
|
chart: SeriesAttachedParameter<Time>['chart'] | null;
|
|
};
|
|
|
|
class BuildSlicesPaneRenderer implements IPrimitivePaneRenderer {
|
|
private readonly _getState: () => BuildSlicesState;
|
|
|
|
constructor(getState: () => BuildSlicesState) {
|
|
this._getState = getState;
|
|
}
|
|
|
|
draw(target: any) {
|
|
const { enabled, candles, bucketSeconds, samples, series, chart } = this._getState();
|
|
if (!enabled) return;
|
|
if (!candles.length || !series || !chart) return;
|
|
|
|
const bs = resolveBucketSeconds(bucketSeconds, candles);
|
|
const lastCandleTime = candles[candles.length - 1]?.time ?? null;
|
|
|
|
const yBase = series.priceToCoordinate(0);
|
|
if (yBase == null) return;
|
|
|
|
const xs = candles.map((c) => chart.timeScale().timeToCoordinate(toTime(c.time)));
|
|
|
|
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }: any) => {
|
|
const yBottomPx = Math.round(yBase * verticalPixelRatio);
|
|
const lastIdx = xs.length - 1;
|
|
|
|
for (let i = 0; i < candles.length; i += 1) {
|
|
const x = xs[i];
|
|
if (x == null) continue;
|
|
if (!Number.isFinite(x)) continue;
|
|
|
|
const c = candles[i]!;
|
|
|
|
const start = c.time;
|
|
const end = start + bs;
|
|
const isCurrent = lastCandleTime != null && c.time === lastCandleTime;
|
|
const now = Date.now() / 1000;
|
|
const progressT = isCurrent ? Math.min(end, Math.max(start, now)) : end;
|
|
|
|
let spacing = 0;
|
|
const prevX = i > 0 ? xs[i - 1] : null;
|
|
const nextX = i < lastIdx ? xs[i + 1] : null;
|
|
if (prevX != null && Number.isFinite(prevX)) spacing = x - prevX;
|
|
if (nextX != null && Number.isFinite(nextX)) {
|
|
const s2 = nextX - x;
|
|
spacing = spacing > 0 ? Math.min(spacing, s2) : s2;
|
|
}
|
|
if (!(spacing > 0)) spacing = 6;
|
|
|
|
const barWidthCss = Math.max(1, spacing * 0.9);
|
|
const barWidthPx = Math.max(1, Math.round(barWidthCss * horizontalPixelRatio));
|
|
const xCenterPx = Math.round(x * horizontalPixelRatio);
|
|
const xLeftPx = Math.round(xCenterPx - barWidthPx / 2);
|
|
|
|
const volumeValue = typeof c.volume === 'number' && Number.isFinite(c.volume) ? c.volume : 0;
|
|
if (!(volumeValue > 0)) continue;
|
|
const yTop = series.priceToCoordinate(volumeValue);
|
|
if (yTop == null) continue;
|
|
const yTopPx = Math.round(yTop * verticalPixelRatio);
|
|
if (!(yBottomPx > yTopPx)) continue;
|
|
const barHeightPx = yBottomPx - yTopPx;
|
|
|
|
const x0 = Math.max(0, Math.min(bitmapSize.width, xLeftPx));
|
|
const x1 = Math.max(0, Math.min(bitmapSize.width, xLeftPx + barWidthPx));
|
|
const w = x1 - x0;
|
|
if (!(w > 0)) continue;
|
|
|
|
// Prefer server-provided `flowRows`: brick-by-brick direction per time slice inside the candle.
|
|
// Fallback to `flow` (3 shares) or net candle direction.
|
|
const rowsFromApi = Array.isArray((c as any).flowRows) ? ((c as any).flowRows as any[]) : null;
|
|
const rowDirs = rowsFromApi?.length ? rowsFromApi : null;
|
|
|
|
if (rowDirs) {
|
|
const rows = Math.max(1, rowDirs.length);
|
|
const progressRows = Math.max(0, Math.min(rows, Math.ceil(((progressT - start) / bs) * rows)));
|
|
const movesFromApi = Array.isArray((c as any).flowMoves) ? ((c as any).flowMoves as any[]) : null;
|
|
const rowMoves = movesFromApi && movesFromApi.length === rows ? movesFromApi : null;
|
|
let maxMove = 0;
|
|
if (rowMoves) {
|
|
for (let r = 0; r < progressRows; r += 1) {
|
|
const v = Number(rowMoves[r]);
|
|
if (Number.isFinite(v) && v > maxMove) maxMove = v;
|
|
}
|
|
}
|
|
|
|
const sepPx = 1; // black separator line between steps
|
|
const sepColor = 'rgba(0,0,0,0.75)';
|
|
|
|
// Blue (flat) bricks have a constant pixel height (unit).
|
|
// Up/down bricks are scaled by |Δ| in their dt.
|
|
const minNonFlatPx = 1;
|
|
let unitFlatPx = 2;
|
|
|
|
let flatCount = 0;
|
|
let nonFlatCount = 0;
|
|
for (let r = 0; r < progressRows; r += 1) {
|
|
const dirRaw = rowDirs[r];
|
|
const dir = dirRaw > 0 ? 1 : dirRaw < 0 ? -1 : 0;
|
|
if (dir === 0) flatCount += 1;
|
|
else nonFlatCount += 1;
|
|
}
|
|
|
|
const sepTotal = Math.max(0, progressRows - 1) * sepPx;
|
|
if (flatCount > 0) {
|
|
const maxUnit = Math.floor((barHeightPx - sepTotal - nonFlatCount * minNonFlatPx) / flatCount);
|
|
unitFlatPx = Math.max(1, Math.min(unitFlatPx, maxUnit));
|
|
} else {
|
|
unitFlatPx = 0;
|
|
}
|
|
|
|
let nonFlatAvailable = barHeightPx - sepTotal - flatCount * unitFlatPx;
|
|
if (!Number.isFinite(nonFlatAvailable) || nonFlatAvailable < 0) nonFlatAvailable = 0;
|
|
|
|
const baseNonFlat = nonFlatCount * minNonFlatPx;
|
|
let extra = nonFlatAvailable - baseNonFlat;
|
|
if (!Number.isFinite(extra) || extra < 0) extra = 0;
|
|
|
|
let sumMove = 0;
|
|
if (nonFlatCount > 0) {
|
|
for (let r = 0; r < progressRows; r += 1) {
|
|
const dirRaw = rowDirs[r];
|
|
const dir = dirRaw > 0 ? 1 : dirRaw < 0 ? -1 : 0;
|
|
if (dir === 0) continue;
|
|
const mvRaw = rowMoves ? Number(rowMoves[r]) : 0;
|
|
const mv = Number.isFinite(mvRaw) ? Math.max(0, mvRaw) : 0;
|
|
sumMove += mv;
|
|
}
|
|
}
|
|
|
|
if (!(flatCount + nonFlatCount > 0) || barHeightPx <= sepTotal) {
|
|
continue;
|
|
}
|
|
|
|
// Stack bricks bottom-up (earliest slices at the bottom).
|
|
let usedExtra = 0;
|
|
let nonFlatSeen = 0;
|
|
let y = yBottomPx;
|
|
for (let r = 0; r < progressRows; r += 1) {
|
|
const dirRaw = rowDirs[r];
|
|
const dir = dirRaw > 0 ? 1 : dirRaw < 0 ? -1 : 0;
|
|
const isLast = r === progressRows - 1;
|
|
|
|
let h = 0;
|
|
if (dir === 0) {
|
|
h = unitFlatPx;
|
|
} else {
|
|
nonFlatSeen += 1;
|
|
const mvRaw = rowMoves ? Number(rowMoves[r]) : 0;
|
|
const mv = Number.isFinite(mvRaw) ? Math.max(0, mvRaw) : 0;
|
|
const share = sumMove > 0 ? mv / sumMove : 1 / nonFlatCount;
|
|
const wantExtra = Math.floor(extra * share);
|
|
const isLastNonFlat = nonFlatSeen === nonFlatCount;
|
|
const add = isLastNonFlat ? Math.max(0, extra - usedExtra) : Math.max(0, wantExtra);
|
|
usedExtra += add;
|
|
h = minNonFlatPx + add;
|
|
}
|
|
|
|
if (h <= 0) continue;
|
|
y -= h;
|
|
context.fillStyle = sliceColorForDelta(dir);
|
|
context.fillRect(x0, y, w, h);
|
|
|
|
if (!isLast) {
|
|
y -= sepPx;
|
|
context.fillStyle = sepColor;
|
|
context.fillRect(x0, y, w, sepPx);
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const f: any = (c as any).flow;
|
|
let upShare = typeof f?.up === 'number' ? f.up : Number(f?.up);
|
|
let downShare = typeof f?.down === 'number' ? f.down : Number(f?.down);
|
|
let flatShare = typeof f?.flat === 'number' ? f.flat : Number(f?.flat);
|
|
|
|
if (!Number.isFinite(upShare)) upShare = 0;
|
|
if (!Number.isFinite(downShare)) downShare = 0;
|
|
if (!Number.isFinite(flatShare)) flatShare = 0;
|
|
upShare = Math.max(0, upShare);
|
|
downShare = Math.max(0, downShare);
|
|
flatShare = Math.max(0, flatShare);
|
|
|
|
let sum = upShare + downShare + flatShare;
|
|
if (!(sum > 0)) {
|
|
const overallDir = dirForDelta(c.close - c.open);
|
|
upShare = overallDir > 0 ? 1 : 0;
|
|
downShare = overallDir < 0 ? 1 : 0;
|
|
flatShare = overallDir === 0 ? 1 : 0;
|
|
sum = 1;
|
|
}
|
|
|
|
upShare /= sum;
|
|
downShare /= sum;
|
|
flatShare /= sum;
|
|
|
|
const downH = Math.floor(barHeightPx * downShare);
|
|
const flatH = Math.floor(barHeightPx * flatShare);
|
|
const upH = Math.max(0, barHeightPx - downH - flatH);
|
|
|
|
let y = yBottomPx;
|
|
|
|
if (downH > 0) {
|
|
context.fillStyle = sliceColorForDelta(-1);
|
|
context.fillRect(x0, y - downH, w, downH);
|
|
y -= downH;
|
|
}
|
|
if (flatH > 0) {
|
|
context.fillStyle = sliceColorForDelta(0);
|
|
context.fillRect(x0, y - flatH, w, flatH);
|
|
y -= flatH;
|
|
}
|
|
if (upH > 0) {
|
|
context.fillStyle = sliceColorForDelta(1);
|
|
context.fillRect(x0, y - upH, w, upH);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
class BuildSlicesPaneView implements IPrimitivePaneView {
|
|
private readonly _renderer: BuildSlicesPaneRenderer;
|
|
|
|
constructor(getState: () => BuildSlicesState) {
|
|
this._renderer = new BuildSlicesPaneRenderer(getState);
|
|
}
|
|
|
|
zOrder() {
|
|
return 'top';
|
|
}
|
|
|
|
renderer() {
|
|
return this._renderer;
|
|
}
|
|
}
|
|
|
|
class BuildSlicesPrimitive implements ISeriesPrimitive<Time> {
|
|
private _param: SeriesAttachedParameter<Time> | null = null;
|
|
private _series: ISeriesApi<'Histogram', Time> | null = null;
|
|
private _enabled = true;
|
|
private _candles: Candle[] = [];
|
|
private _bucketSeconds = 0;
|
|
private _samples: Map<number, BuildSample[]> = new Map();
|
|
private readonly _paneView: BuildSlicesPaneView;
|
|
private readonly _paneViews: readonly IPrimitivePaneView[];
|
|
|
|
constructor() {
|
|
this._paneView = new BuildSlicesPaneView(() => ({
|
|
enabled: this._enabled,
|
|
candles: this._candles,
|
|
bucketSeconds: this._bucketSeconds,
|
|
samples: this._samples,
|
|
series: this._series,
|
|
chart: this._param?.chart ?? null,
|
|
}));
|
|
this._paneViews = [this._paneView];
|
|
}
|
|
|
|
attached(param: SeriesAttachedParameter<Time>) {
|
|
this._param = param;
|
|
this._series = param.series as ISeriesApi<'Histogram', Time>;
|
|
}
|
|
|
|
detached() {
|
|
this._param = null;
|
|
this._series = null;
|
|
}
|
|
|
|
paneViews() {
|
|
return this._paneViews;
|
|
}
|
|
|
|
setEnabled(next: boolean) {
|
|
this._enabled = Boolean(next);
|
|
this._param?.requestUpdate();
|
|
}
|
|
|
|
setData(next: { candles: Candle[]; bucketSeconds: number; samples: Map<number, BuildSample[]> }) {
|
|
this._candles = Array.isArray(next.candles) ? next.candles : [];
|
|
this._bucketSeconds = Number.isFinite(next.bucketSeconds) ? next.bucketSeconds : 0;
|
|
this._samples = next.samples;
|
|
this._param?.requestUpdate();
|
|
}
|
|
}
|
|
|
|
export default function TradingChart({
|
|
candles,
|
|
oracle,
|
|
sma20,
|
|
ema20,
|
|
bb20,
|
|
showIndicators,
|
|
showBuild,
|
|
bucketSeconds,
|
|
seriesKey,
|
|
priceLines,
|
|
fib,
|
|
fibOpacity = 1,
|
|
fibSelected = false,
|
|
priceAutoScale = true,
|
|
onReady,
|
|
onChartClick,
|
|
onChartCrosshairMove,
|
|
onPointerEvent,
|
|
}: Props) {
|
|
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 fibOpacityRef = useRef<number>(fibOpacity);
|
|
const priceAutoScaleRef = useRef<boolean>(priceAutoScale);
|
|
const prevPriceAutoScaleRef = useRef<boolean>(priceAutoScale);
|
|
const showBuildRef = useRef<boolean>(showBuild);
|
|
const onReadyRef = useRef<Props['onReady']>(onReady);
|
|
const onChartClickRef = useRef<Props['onChartClick']>(onChartClick);
|
|
const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove);
|
|
const onPointerEventRef = useRef<Props['onPointerEvent']>(onPointerEvent);
|
|
const capturedOverlayPointerRef = useRef<number | null>(null);
|
|
const buildSlicesPrimitiveRef = useRef<BuildSlicesPrimitive | null>(null);
|
|
const buildSamplesRef = useRef<Map<number, BuildSample[]>>(new Map());
|
|
const buildKeyRef = useRef<string | null>(null);
|
|
const lastBuildCandleStartRef = useRef<number | null>(null);
|
|
const hoverCandleTimeRef = useRef<number | null>(null);
|
|
const [hoverCandleTime, setHoverCandleTime] = useState<number | null>(null);
|
|
const priceLinesRef = useRef<Map<string, any>>(new Map());
|
|
const seriesRef = useRef<{
|
|
candles?: ISeriesApi<'Candlestick'>;
|
|
volume?: ISeriesApi<'Histogram'>;
|
|
buildHover?: ISeriesApi<'Line'>;
|
|
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]);
|
|
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;
|
|
}, [onReady]);
|
|
|
|
useEffect(() => {
|
|
onChartClickRef.current = onChartClick;
|
|
}, [onChartClick]);
|
|
|
|
useEffect(() => {
|
|
onChartCrosshairMoveRef.current = onChartCrosshairMove;
|
|
}, [onChartCrosshairMove]);
|
|
|
|
useEffect(() => {
|
|
onPointerEventRef.current = onPointerEvent;
|
|
}, [onPointerEvent]);
|
|
|
|
useEffect(() => {
|
|
fibRef.current = fib ?? null;
|
|
}, [fib]);
|
|
|
|
useEffect(() => {
|
|
fibOpacityRef.current = fibOpacity;
|
|
fibPrimitiveRef.current?.setOpacity(fibOpacity);
|
|
}, [fibOpacity]);
|
|
|
|
useEffect(() => {
|
|
priceAutoScaleRef.current = priceAutoScale;
|
|
}, [priceAutoScale]);
|
|
|
|
useEffect(() => {
|
|
showBuildRef.current = showBuild;
|
|
if (!showBuild && (hoverCandleTimeRef.current != null || hoverCandleTime != null)) {
|
|
hoverCandleTimeRef.current = null;
|
|
setHoverCandleTime(null);
|
|
}
|
|
}, [showBuild, hoverCandleTime]);
|
|
|
|
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',
|
|
priceFormat,
|
|
});
|
|
|
|
const fibPrimitive = new FibRetracementPrimitive();
|
|
candleSeries.attachPrimitive(fibPrimitive);
|
|
fibPrimitiveRef.current = fibPrimitive;
|
|
fibPrimitive.setFib(fib ?? null);
|
|
fibPrimitive.setOpacity(fibOpacityRef.current);
|
|
|
|
const volumeSeries = chart.addSeries(HistogramSeries, {
|
|
priceFormat: { type: 'volume' },
|
|
priceScaleId: '',
|
|
color: 'rgba(255,255,255,0.15)',
|
|
});
|
|
volumeSeries.priceScale().applyOptions({
|
|
scaleMargins: { top: 0.88, bottom: 0 },
|
|
});
|
|
|
|
const buildSlicesPrimitive = new BuildSlicesPrimitive();
|
|
volumeSeries.attachPrimitive(buildSlicesPrimitive);
|
|
buildSlicesPrimitiveRef.current = buildSlicesPrimitive;
|
|
buildSlicesPrimitive.setEnabled(showBuildRef.current);
|
|
|
|
const buildHoverSeries = chart.addSeries(LineSeries, {
|
|
color: BUILD_FLAT_COLOR,
|
|
lineWidth: 2,
|
|
priceFormat,
|
|
priceScaleId: 'build',
|
|
lastValueVisible: false,
|
|
priceLineVisible: false,
|
|
crosshairMarkerVisible: false,
|
|
});
|
|
buildHoverSeries.priceScale().applyOptions({
|
|
scaleMargins: { top: 0.72, bottom: 0.12 },
|
|
visible: false,
|
|
borderVisible: false,
|
|
});
|
|
|
|
const oracleSeries = chart.addSeries(LineSeries, {
|
|
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, 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 = {
|
|
candles: candleSeries,
|
|
volume: volumeSeries,
|
|
buildHover: buildHoverSeries,
|
|
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;
|
|
|
|
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);
|
|
|
|
const onCrosshairMove = (param: any) => {
|
|
if (!param?.point) {
|
|
if (showBuildRef.current && hoverCandleTimeRef.current != null) {
|
|
hoverCandleTimeRef.current = null;
|
|
setHoverCandleTime(null);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (showBuildRef.current) {
|
|
const t = typeof param?.time === 'number' ? Number(param.time) : null;
|
|
const next = t != null && Number.isFinite(t) ? t : null;
|
|
if (hoverCandleTimeRef.current !== next) {
|
|
hoverCandleTimeRef.current = next;
|
|
setHoverCandleTime(next);
|
|
}
|
|
}
|
|
|
|
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 container = containerRef.current;
|
|
const toAnchorFromPoint = (x: number, y: number): FibAnchor | null => {
|
|
const logical = chart.timeScale().coordinateToLogical(x);
|
|
if (logical == null) return null;
|
|
const price = candleSeries.coordinateToPrice(y);
|
|
if (price == null) return null;
|
|
return { logical: Number(logical), price: Number(price) };
|
|
};
|
|
|
|
const isOverFib = (x: number, y: number, currentFib: FibRetracement): boolean => {
|
|
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) return false;
|
|
|
|
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) return false;
|
|
|
|
const top = Math.min(y1, y2) - tol;
|
|
const bottom = Math.max(y1, y2) + tol;
|
|
return x >= left && x <= right && y >= top && y <= bottom;
|
|
};
|
|
|
|
const onOverlayPointer = (e: PointerEvent) => {
|
|
if (!containerRef.current) return;
|
|
const rect = containerRef.current.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
const anchor = toAnchorFromPoint(x, y);
|
|
if (!anchor) return;
|
|
|
|
const currentFib = fibRef.current;
|
|
const target: 'chart' | 'fib' = currentFib && isOverFib(x, y, currentFib) ? 'fib' : 'chart';
|
|
const type =
|
|
e.type === 'pointerdown'
|
|
? ('pointerdown' as const)
|
|
: e.type === 'pointermove'
|
|
? ('pointermove' as const)
|
|
: e.type === 'pointerup'
|
|
? ('pointerup' as const)
|
|
: ('pointercancel' as const);
|
|
|
|
const decision = onPointerEventRef.current?.({
|
|
type,
|
|
logical: anchor.logical,
|
|
price: anchor.price,
|
|
x,
|
|
y,
|
|
target,
|
|
event: e,
|
|
});
|
|
|
|
if (decision?.capturePointer) {
|
|
capturedOverlayPointerRef.current = e.pointerId;
|
|
try {
|
|
containerRef.current.setPointerCapture(e.pointerId);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
if (decision?.consume) {
|
|
e.preventDefault();
|
|
e.stopImmediatePropagation();
|
|
}
|
|
|
|
if (type === 'pointerup' || type === 'pointercancel') {
|
|
if (capturedOverlayPointerRef.current === e.pointerId) {
|
|
capturedOverlayPointerRef.current = null;
|
|
try {
|
|
containerRef.current.releasePointerCapture(e.pointerId);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
};
|
|
container?.addEventListener('pointerdown', onOverlayPointer, { capture: true });
|
|
container?.addEventListener('pointermove', onOverlayPointer, { capture: true });
|
|
container?.addEventListener('pointerup', onOverlayPointer, { capture: true });
|
|
container?.addEventListener('pointercancel', onOverlayPointer, { capture: true });
|
|
|
|
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();
|
|
chart.applyOptions({ width: Math.floor(width), height: Math.floor(height) });
|
|
});
|
|
ro.observe(containerRef.current);
|
|
|
|
return () => {
|
|
chart.unsubscribeClick(onClick);
|
|
chart.unsubscribeCrosshairMove(onCrosshairMove);
|
|
container?.removeEventListener('pointerdown', onOverlayPointer, { capture: true });
|
|
container?.removeEventListener('pointermove', onOverlayPointer, { capture: true });
|
|
container?.removeEventListener('pointerup', onOverlayPointer, { capture: true });
|
|
container?.removeEventListener('pointercancel', onOverlayPointer, { capture: true });
|
|
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);
|
|
fibPrimitiveRef.current = null;
|
|
}
|
|
if (buildSlicesPrimitiveRef.current) {
|
|
volumeSeries.detachPrimitive(buildSlicesPrimitiveRef.current);
|
|
buildSlicesPrimitiveRef.current = null;
|
|
}
|
|
const lines = priceLinesRef.current;
|
|
for (const line of Array.from(lines.values())) {
|
|
try {
|
|
candleSeries.removePriceLine(line);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
lines.clear();
|
|
chart.remove();
|
|
chartRef.current = null;
|
|
seriesRef.current = {};
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const candlesSeries = seriesRef.current.candles;
|
|
if (!candlesSeries) return;
|
|
|
|
const desired = (priceLines || []).filter((l) => l.price != null && Number.isFinite(l.price));
|
|
const desiredIds = new Set(desired.map((l) => l.id));
|
|
|
|
const map = priceLinesRef.current;
|
|
for (const [id, line] of Array.from(map.entries())) {
|
|
if (desiredIds.has(id)) continue;
|
|
try {
|
|
candlesSeries.removePriceLine(line);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
map.delete(id);
|
|
}
|
|
|
|
for (const spec of desired) {
|
|
const opts: any = {
|
|
price: spec.price,
|
|
color: spec.color,
|
|
title: spec.title,
|
|
lineWidth: spec.lineWidth ?? 1,
|
|
lineStyle: spec.lineStyle ?? LineStyle.Dotted,
|
|
axisLabelVisible: spec.axisLabelVisible ?? true,
|
|
};
|
|
|
|
const existing = map.get(spec.id);
|
|
if (!existing) {
|
|
try {
|
|
const created = candlesSeries.createPriceLine(opts);
|
|
map.set(spec.id, created);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
existing.applyOptions(opts);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}, [priceLines]);
|
|
|
|
useEffect(() => {
|
|
const s = seriesRef.current;
|
|
if (!s.candles || !s.volume || !s.buildHover) 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);
|
|
|
|
const bs = resolveBucketSeconds(bucketSeconds, candles);
|
|
const eps = 1e-3;
|
|
const maxPointsPerCandle = 600;
|
|
const minStep = Math.max(0.5, bs / maxPointsPerCandle);
|
|
const map = buildSamplesRef.current;
|
|
|
|
if (buildKeyRef.current !== seriesKey) {
|
|
map.clear();
|
|
buildKeyRef.current = seriesKey;
|
|
lastBuildCandleStartRef.current = null;
|
|
}
|
|
|
|
const visibleStarts = new Set(candles.map((c) => c.time));
|
|
for (const start of Array.from(map.keys())) {
|
|
if (!visibleStarts.has(start)) map.delete(start);
|
|
}
|
|
|
|
const last = candles[candles.length - 1];
|
|
if (last) {
|
|
const prevStart = lastBuildCandleStartRef.current;
|
|
if (prevStart != null && prevStart !== last.time) {
|
|
const prevCandle = candles.find((c) => c.time === prevStart);
|
|
if (prevCandle) {
|
|
const endT = prevStart + bs - eps;
|
|
const finalDelta = prevCandle.close - prevCandle.open;
|
|
const list = map.get(prevStart) ?? [];
|
|
const lastT = list.length ? list[list.length - 1]!.t : -Infinity;
|
|
if (endT > lastT + eps) {
|
|
list.push({ t: endT, v: finalDelta });
|
|
} else if (list.length) {
|
|
list[list.length - 1] = { t: lastT, v: finalDelta };
|
|
}
|
|
map.set(prevStart, list);
|
|
}
|
|
}
|
|
|
|
const start = last.time;
|
|
const endT = start + bs - eps;
|
|
const delta = last.close - last.open;
|
|
const nowT = Date.now() / 1000;
|
|
const tClamped = Math.min(endT, Math.max(start + eps, nowT));
|
|
const list = map.get(start) ?? [];
|
|
if (list.length) {
|
|
const lastPt = list[list.length - 1]!;
|
|
if (tClamped - lastPt.t < minStep) {
|
|
list[list.length - 1] = { t: tClamped, v: delta };
|
|
} else {
|
|
const t = Math.min(endT, Math.max(lastPt.t + eps, tClamped));
|
|
if (t > lastPt.t) list.push({ t, v: delta });
|
|
}
|
|
} else {
|
|
list.push({ t: tClamped, v: delta });
|
|
}
|
|
map.set(start, list);
|
|
lastBuildCandleStartRef.current = start;
|
|
}
|
|
|
|
const buildPrimitive = buildSlicesPrimitiveRef.current;
|
|
buildPrimitive?.setData({ candles, bucketSeconds: bs, samples: map });
|
|
buildPrimitive?.setEnabled(showBuild);
|
|
|
|
if (showBuild) {
|
|
const hoverTime = hoverCandleTime;
|
|
const hoverCandle = hoverTime == null ? null : candles.find((c) => c.time === hoverTime);
|
|
const hoverData = hoverCandle ? buildDeltaSeriesForCandle(hoverCandle, bs, map.get(hoverCandle.time)) : [];
|
|
|
|
if (hoverData.length) {
|
|
s.buildHover.applyOptions({ visible: true });
|
|
s.buildHover.setData(hoverData);
|
|
} else {
|
|
s.buildHover.applyOptions({ visible: false });
|
|
s.buildHover.setData([]);
|
|
}
|
|
} else {
|
|
s.buildHover.applyOptions({ visible: false });
|
|
s.buildHover.setData([]);
|
|
}
|
|
|
|
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,
|
|
showBuild,
|
|
candles,
|
|
bucketSeconds,
|
|
seriesKey,
|
|
hoverCandleTime,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const s = seriesRef.current;
|
|
if (!s.candles) return;
|
|
s.candles.applyOptions({ priceFormat });
|
|
s.buildHover?.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} />;
|
|
}
|