feat(chart): candle build indicator as direction line #1
@@ -1,13 +1,18 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
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,
|
||||
@@ -28,6 +33,15 @@ type Props = {
|
||||
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;
|
||||
@@ -49,6 +63,13 @@ type Props = {
|
||||
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;
|
||||
}
|
||||
@@ -107,7 +128,361 @@ function toVolumeData(candles: Candle[]): HistogramData[] {
|
||||
|
||||
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 }));
|
||||
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({
|
||||
@@ -120,6 +495,7 @@ export default function TradingChart({
|
||||
showBuild,
|
||||
bucketSeconds,
|
||||
seriesKey,
|
||||
priceLines,
|
||||
fib,
|
||||
fibOpacity = 1,
|
||||
fibSelected = false,
|
||||
@@ -136,18 +512,23 @@ export default function TradingChart({
|
||||
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'>;
|
||||
build?: ISeriesApi<'Line'>;
|
||||
buildHover?: ISeriesApi<'Line'>;
|
||||
oracle?: ISeriesApi<'Line'>;
|
||||
sma20?: ISeriesApi<'Line'>;
|
||||
ema20?: ISeriesApi<'Line'>;
|
||||
@@ -198,6 +579,14 @@ export default function TradingChart({
|
||||
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;
|
||||
@@ -249,8 +638,13 @@ export default function TradingChart({
|
||||
scaleMargins: { top: 0.88, bottom: 0 },
|
||||
});
|
||||
|
||||
const buildSeries = chart.addSeries(LineSeries, {
|
||||
color: '#60a5fa',
|
||||
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',
|
||||
@@ -258,7 +652,7 @@ export default function TradingChart({
|
||||
priceLineVisible: false,
|
||||
crosshairMarkerVisible: false,
|
||||
});
|
||||
buildSeries.priceScale().applyOptions({
|
||||
buildHoverSeries.priceScale().applyOptions({
|
||||
scaleMargins: { top: 0.72, bottom: 0.12 },
|
||||
visible: false,
|
||||
borderVisible: false,
|
||||
@@ -285,7 +679,7 @@ export default function TradingChart({
|
||||
seriesRef.current = {
|
||||
candles: candleSeries,
|
||||
volume: volumeSeries,
|
||||
build: buildSeries,
|
||||
buildHover: buildHoverSeries,
|
||||
oracle: oracleSeries,
|
||||
sma20: smaSeries,
|
||||
ema20: emaSeries,
|
||||
@@ -335,7 +729,23 @@ export default function TradingChart({
|
||||
chart.subscribeClick(onClick);
|
||||
|
||||
const onCrosshairMove = (param: any) => {
|
||||
if (!param?.point) return;
|
||||
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);
|
||||
@@ -580,15 +990,75 @@ export default function TradingChart({
|
||||
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.build) return;
|
||||
if (!s.candles || !s.volume || !s.buildHover) return;
|
||||
s.candles.setData(candleData);
|
||||
s.volume.setData(volumeData);
|
||||
s.oracle?.setData(oracleData);
|
||||
@@ -598,34 +1068,18 @@ export default function TradingChart({
|
||||
s.bbLower?.setData(bbLower);
|
||||
s.bbMid?.setData(bbMid);
|
||||
|
||||
s.build.applyOptions({ visible: showBuild });
|
||||
if (!showBuild) {
|
||||
buildSamplesRef.current.clear();
|
||||
buildKeyRef.current = seriesKey;
|
||||
lastBuildCandleStartRef.current = null;
|
||||
s.build.setData([]);
|
||||
}
|
||||
|
||||
if (buildKeyRef.current !== seriesKey) {
|
||||
buildSamplesRef.current.clear();
|
||||
buildKeyRef.current = seriesKey;
|
||||
lastBuildCandleStartRef.current = null;
|
||||
}
|
||||
|
||||
if (!showBuild) {
|
||||
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 });
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -671,62 +1125,26 @@ export default function TradingChart({
|
||||
lastBuildCandleStartRef.current = start;
|
||||
}
|
||||
|
||||
const buildRaw: LinePoint[] = [];
|
||||
for (const c of candles) {
|
||||
const list = map.get(c.time);
|
||||
if (!list?.length) continue;
|
||||
const buildPrimitive = buildSlicesPrimitiveRef.current;
|
||||
buildPrimitive?.setData({ candles, bucketSeconds: bs, samples: map });
|
||||
buildPrimitive?.setEnabled(!showBuild);
|
||||
|
||||
const startT = c.time + eps;
|
||||
const endT = c.time + bs - eps;
|
||||
if (!(endT > startT)) continue;
|
||||
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)) : [];
|
||||
|
||||
buildRaw.push({ time: toTime(c.time) } as WhitespaceData);
|
||||
buildRaw.push({ time: toTime(startT), value: 0 } as LineData);
|
||||
|
||||
let lastT = startT;
|
||||
for (const p of list) {
|
||||
let t = p.t;
|
||||
if (t <= lastT + eps) t = lastT + eps;
|
||||
if (t >= endT) break;
|
||||
buildRaw.push({ time: toTime(t), value: p.v } as LineData);
|
||||
lastT = t;
|
||||
if (hoverData.length) {
|
||||
s.buildHover.applyOptions({ visible: true });
|
||||
s.buildHover.setData(hoverData);
|
||||
} else {
|
||||
s.buildHover.applyOptions({ visible: false });
|
||||
s.buildHover.setData([]);
|
||||
}
|
||||
|
||||
const finalDelta = c.close - c.open;
|
||||
if (endT > lastT + eps) {
|
||||
buildRaw.push({ time: toTime(endT), value: finalDelta } as LineData);
|
||||
} else if (buildRaw.length) {
|
||||
const prev = buildRaw[buildRaw.length - 1];
|
||||
if ('value' in prev) {
|
||||
buildRaw[buildRaw.length - 1] = { ...prev, value: finalDelta } as LineData;
|
||||
} else {
|
||||
s.buildHover.applyOptions({ visible: false });
|
||||
s.buildHover.setData([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buildColored: LinePoint[] = [];
|
||||
let lastLineIdx: number | null = null;
|
||||
let lastLineVal: number | null = null;
|
||||
for (const p of buildRaw) {
|
||||
if (!('value' in p)) {
|
||||
buildColored.push(p);
|
||||
lastLineIdx = null;
|
||||
lastLineVal = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lastLineIdx != null && lastLineVal != null) {
|
||||
const delta = p.value - lastLineVal;
|
||||
const color = delta > 0 ? '#22c55e' : delta < 0 ? '#ef4444' : '#60a5fa';
|
||||
const prev = buildColored[lastLineIdx] as LineData;
|
||||
buildColored[lastLineIdx] = { ...prev, color };
|
||||
}
|
||||
|
||||
buildColored.push({ time: p.time, value: p.value } as LineData);
|
||||
lastLineIdx = buildColored.length - 1;
|
||||
lastLineVal = p.value;
|
||||
}
|
||||
|
||||
s.build.setData(buildColored);
|
||||
|
||||
s.sma20?.applyOptions({ visible: showIndicators });
|
||||
s.ema20?.applyOptions({ visible: showIndicators });
|
||||
@@ -747,13 +1165,14 @@ export default function TradingChart({
|
||||
candles,
|
||||
bucketSeconds,
|
||||
seriesKey,
|
||||
hoverCandleTime,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const s = seriesRef.current;
|
||||
if (!s.candles) return;
|
||||
s.candles.applyOptions({ priceFormat });
|
||||
s.build?.applyOptions({ priceFormat });
|
||||
s.buildHover?.applyOptions({ priceFormat });
|
||||
s.oracle?.applyOptions({ priceFormat });
|
||||
s.sma20?.applyOptions({ priceFormat });
|
||||
s.ema20?.applyOptions({ priceFormat });
|
||||
|
||||
Reference in New Issue
Block a user