feat(chart): render brick-stack flow bars

This commit is contained in:
u1
2026-01-10 22:54:22 +00:00
parent 912a78588d
commit 6904be4a51

View File

@@ -1,13 +1,18 @@
import { useEffect, useMemo, useRef } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { import {
CandlestickSeries, CandlestickSeries,
ColorType, ColorType,
CrosshairMode, CrosshairMode,
HistogramSeries, HistogramSeries,
type IPrimitivePaneRenderer,
type IPrimitivePaneView,
type IChartApi, type IChartApi,
type ISeriesApi, type ISeriesApi,
type ISeriesPrimitive,
LineStyle, LineStyle,
LineSeries, LineSeries,
type SeriesAttachedParameter,
type Time,
createChart, createChart,
type UTCTimestamp, type UTCTimestamp,
type CandlestickData, type CandlestickData,
@@ -28,6 +33,15 @@ type Props = {
showBuild: boolean; showBuild: boolean;
bucketSeconds: number; bucketSeconds: number;
seriesKey: string; seriesKey: string;
priceLines?: Array<{
id: string;
title: string;
price: number | null;
color: string;
lineWidth?: number;
lineStyle?: LineStyle;
axisLabelVisible?: boolean;
}>;
fib?: FibRetracement | null; fib?: FibRetracement | null;
fibOpacity?: number; fibOpacity?: number;
fibSelected?: boolean; fibSelected?: boolean;
@@ -49,6 +63,13 @@ type Props = {
type LinePoint = LineData | WhitespaceData; type LinePoint = LineData | WhitespaceData;
type BuildSample = { t: number; v: number }; 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 { function toTime(t: number): UTCTimestamp {
return t as UTCTimestamp; return t as UTCTimestamp;
} }
@@ -107,7 +128,361 @@ function toVolumeData(candles: Candle[]): HistogramData[] {
function toLineSeries(points: SeriesPoint[] | undefined): LinePoint[] { function toLineSeries(points: SeriesPoint[] | undefined): LinePoint[] {
if (!points?.length) return []; 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({ export default function TradingChart({
@@ -120,6 +495,7 @@ export default function TradingChart({
showBuild, showBuild,
bucketSeconds, bucketSeconds,
seriesKey, seriesKey,
priceLines,
fib, fib,
fibOpacity = 1, fibOpacity = 1,
fibSelected = false, fibSelected = false,
@@ -136,18 +512,23 @@ export default function TradingChart({
const fibOpacityRef = useRef<number>(fibOpacity); const fibOpacityRef = useRef<number>(fibOpacity);
const priceAutoScaleRef = useRef<boolean>(priceAutoScale); const priceAutoScaleRef = useRef<boolean>(priceAutoScale);
const prevPriceAutoScaleRef = useRef<boolean>(priceAutoScale); const prevPriceAutoScaleRef = useRef<boolean>(priceAutoScale);
const showBuildRef = useRef<boolean>(showBuild);
const onReadyRef = useRef<Props['onReady']>(onReady); const onReadyRef = useRef<Props['onReady']>(onReady);
const onChartClickRef = useRef<Props['onChartClick']>(onChartClick); const onChartClickRef = useRef<Props['onChartClick']>(onChartClick);
const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove); const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove);
const onPointerEventRef = useRef<Props['onPointerEvent']>(onPointerEvent); const onPointerEventRef = useRef<Props['onPointerEvent']>(onPointerEvent);
const capturedOverlayPointerRef = useRef<number | null>(null); const capturedOverlayPointerRef = useRef<number | null>(null);
const buildSlicesPrimitiveRef = useRef<BuildSlicesPrimitive | null>(null);
const buildSamplesRef = useRef<Map<number, BuildSample[]>>(new Map()); const buildSamplesRef = useRef<Map<number, BuildSample[]>>(new Map());
const buildKeyRef = useRef<string | null>(null); const buildKeyRef = useRef<string | null>(null);
const lastBuildCandleStartRef = useRef<number | 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<{ const seriesRef = useRef<{
candles?: ISeriesApi<'Candlestick'>; candles?: ISeriesApi<'Candlestick'>;
volume?: ISeriesApi<'Histogram'>; volume?: ISeriesApi<'Histogram'>;
build?: ISeriesApi<'Line'>; buildHover?: ISeriesApi<'Line'>;
oracle?: ISeriesApi<'Line'>; oracle?: ISeriesApi<'Line'>;
sma20?: ISeriesApi<'Line'>; sma20?: ISeriesApi<'Line'>;
ema20?: ISeriesApi<'Line'>; ema20?: ISeriesApi<'Line'>;
@@ -198,6 +579,14 @@ export default function TradingChart({
priceAutoScaleRef.current = priceAutoScale; priceAutoScaleRef.current = priceAutoScale;
}, [priceAutoScale]); }, [priceAutoScale]);
useEffect(() => {
showBuildRef.current = showBuild;
if (!showBuild && (hoverCandleTimeRef.current != null || hoverCandleTime != null)) {
hoverCandleTimeRef.current = null;
setHoverCandleTime(null);
}
}, [showBuild, hoverCandleTime]);
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
if (chartRef.current) return; if (chartRef.current) return;
@@ -249,8 +638,13 @@ export default function TradingChart({
scaleMargins: { top: 0.88, bottom: 0 }, scaleMargins: { top: 0.88, bottom: 0 },
}); });
const buildSeries = chart.addSeries(LineSeries, { const buildSlicesPrimitive = new BuildSlicesPrimitive();
color: '#60a5fa', volumeSeries.attachPrimitive(buildSlicesPrimitive);
buildSlicesPrimitiveRef.current = buildSlicesPrimitive;
buildSlicesPrimitive.setEnabled(!showBuildRef.current);
const buildHoverSeries = chart.addSeries(LineSeries, {
color: BUILD_FLAT_COLOR,
lineWidth: 2, lineWidth: 2,
priceFormat, priceFormat,
priceScaleId: 'build', priceScaleId: 'build',
@@ -258,7 +652,7 @@ export default function TradingChart({
priceLineVisible: false, priceLineVisible: false,
crosshairMarkerVisible: false, crosshairMarkerVisible: false,
}); });
buildSeries.priceScale().applyOptions({ buildHoverSeries.priceScale().applyOptions({
scaleMargins: { top: 0.72, bottom: 0.12 }, scaleMargins: { top: 0.72, bottom: 0.12 },
visible: false, visible: false,
borderVisible: false, borderVisible: false,
@@ -285,7 +679,7 @@ export default function TradingChart({
seriesRef.current = { seriesRef.current = {
candles: candleSeries, candles: candleSeries,
volume: volumeSeries, volume: volumeSeries,
build: buildSeries, buildHover: buildHoverSeries,
oracle: oracleSeries, oracle: oracleSeries,
sma20: smaSeries, sma20: smaSeries,
ema20: emaSeries, ema20: emaSeries,
@@ -335,7 +729,23 @@ export default function TradingChart({
chart.subscribeClick(onClick); chart.subscribeClick(onClick);
const onCrosshairMove = (param: any) => { 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); const logical = param.logical ?? chart.timeScale().coordinateToLogical(param.point.x);
if (logical == null) return; if (logical == null) return;
const price = candleSeries.coordinateToPrice(param.point.y); const price = candleSeries.coordinateToPrice(param.point.y);
@@ -580,15 +990,75 @@ export default function TradingChart({
candleSeries.detachPrimitive(fibPrimitiveRef.current); candleSeries.detachPrimitive(fibPrimitiveRef.current);
fibPrimitiveRef.current = null; 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(); chart.remove();
chartRef.current = null; chartRef.current = null;
seriesRef.current = {}; 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(() => { useEffect(() => {
const s = seriesRef.current; 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.candles.setData(candleData);
s.volume.setData(volumeData); s.volume.setData(volumeData);
s.oracle?.setData(oracleData); s.oracle?.setData(oracleData);
@@ -598,34 +1068,18 @@ export default function TradingChart({
s.bbLower?.setData(bbLower); s.bbLower?.setData(bbLower);
s.bbMid?.setData(bbMid); 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 bs = resolveBucketSeconds(bucketSeconds, candles);
const eps = 1e-3; const eps = 1e-3;
const maxPointsPerCandle = 600; const maxPointsPerCandle = 600;
const minStep = Math.max(0.5, bs / maxPointsPerCandle); const minStep = Math.max(0.5, bs / maxPointsPerCandle);
const map = buildSamplesRef.current; 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)); const visibleStarts = new Set(candles.map((c) => c.time));
for (const start of Array.from(map.keys())) { for (const start of Array.from(map.keys())) {
if (!visibleStarts.has(start)) map.delete(start); if (!visibleStarts.has(start)) map.delete(start);
@@ -671,63 +1125,27 @@ export default function TradingChart({
lastBuildCandleStartRef.current = start; lastBuildCandleStartRef.current = start;
} }
const buildRaw: LinePoint[] = []; const buildPrimitive = buildSlicesPrimitiveRef.current;
for (const c of candles) { buildPrimitive?.setData({ candles, bucketSeconds: bs, samples: map });
const list = map.get(c.time); buildPrimitive?.setEnabled(!showBuild);
if (!list?.length) continue;
const startT = c.time + eps; if (showBuild) {
const endT = c.time + bs - eps; const hoverTime = hoverCandleTime;
if (!(endT > startT)) continue; 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); if (hoverData.length) {
buildRaw.push({ time: toTime(startT), value: 0 } as LineData); s.buildHover.applyOptions({ visible: true });
s.buildHover.setData(hoverData);
let lastT = startT; } else {
for (const p of list) { s.buildHover.applyOptions({ visible: false });
let t = p.t; s.buildHover.setData([]);
if (t <= lastT + eps) t = lastT + eps;
if (t >= endT) break;
buildRaw.push({ time: toTime(t), value: p.v } as LineData);
lastT = t;
}
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.sma20?.applyOptions({ visible: showIndicators });
s.ema20?.applyOptions({ visible: showIndicators }); s.ema20?.applyOptions({ visible: showIndicators });
s.bbUpper?.applyOptions({ visible: showIndicators }); s.bbUpper?.applyOptions({ visible: showIndicators });
@@ -747,13 +1165,14 @@ export default function TradingChart({
candles, candles,
bucketSeconds, bucketSeconds,
seriesKey, seriesKey,
hoverCandleTime,
]); ]);
useEffect(() => { useEffect(() => {
const s = seriesRef.current; const s = seriesRef.current;
if (!s.candles) return; if (!s.candles) return;
s.candles.applyOptions({ priceFormat }); s.candles.applyOptions({ priceFormat });
s.build?.applyOptions({ priceFormat }); s.buildHover?.applyOptions({ priceFormat });
s.oracle?.applyOptions({ priceFormat }); s.oracle?.applyOptions({ priceFormat });
s.sma20?.applyOptions({ priceFormat }); s.sma20?.applyOptions({ priceFormat });
s.ema20?.applyOptions({ priceFormat }); s.ema20?.applyOptions({ priceFormat });