diff --git a/src/api/cryptoChart.ts b/src/api/cryptoChart.ts index efd3967..21cdabb 100644 --- a/src/api/cryptoChart.ts +++ b/src/api/cryptoChart.ts @@ -278,11 +278,21 @@ interface BinanceAggTradeMsg { const CRYPTO_UPDATE_THROTTLE_MS = 80 +/** 加密货币分时折线图 X 轴 / 补点统一时间步长(ms),与图表 timeScale 一致 */ +export const CRYPTO_CHART_TIME_STEP_MS = 100 + +/** 将毫秒时间戳对齐到 {@link CRYPTO_CHART_TIME_STEP_MS} 网格(向下取整,单调递增友好) */ +export function alignCryptoChartTimeMs(tsMs: number): number { + const step = CRYPTO_CHART_TIME_STEP_MS + return Math.floor(tsMs / step) * step +} + /** * 订阅 Binance 归集交易 WebSocket(aggTrade),实现更丝滑的实时走势 * 参考:https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/web-socket-streams#归集交易 * - aggTrade 实时推送每笔成交,比 kline_1m(约 1s 一次)更细 * - 内部节流避免频繁 setOption 造成卡顿 + * - 每 {@link CRYPTO_CHART_TIME_STEP_MS}ms 若本周期未收到真实 aggTrade,则用最新成交价补一点(时间戳对齐到步长网格) */ export function subscribeCryptoRealtime( symbol: string, @@ -294,14 +304,23 @@ export function subscribeCryptoRealtime( const stream = `${binanceSymbol.toLowerCase()}@aggTrade` const ws = new WebSocket(`${BINANCE_WS}/${stream}`) + let disposed = false let lastUpdateTs = 0 let pendingPoint: CryptoChartPoint | null = null let rafId: number | null = null let throttleTimer: ReturnType | null = null + /** 本 hold 周期内是否收到过真实 aggTrade(用于持价补点) */ + let hadAggTradeSinceLastHoldTick = false + /** 最近一笔有效成交价,供无推送时重复输出 */ + let lastTradedPrice: number | null = null const flush = () => { rafId = null throttleTimer = null + if (disposed) { + pendingPoint = null + return + } if (pendingPoint) { onPoint(pendingPoint) pendingPoint = null @@ -323,7 +342,16 @@ export function subscribeCryptoRealtime( } } + const holdTimer = setInterval(() => { + if (disposed) return + if (!hadAggTradeSinceLastHoldTick && lastTradedPrice != null) { + onPoint([alignCryptoChartTimeMs(Date.now()), lastTradedPrice]) + } + hadAggTradeSinceLastHoldTick = false + }, CRYPTO_CHART_TIME_STEP_MS) + ws.onmessage = (event) => { + if (disposed) return try { const msg = JSON.parse((event as MessageEvent).data) as BinanceAggTradeMsg & { e?: string } if (msg.e === 'ping') { @@ -335,9 +363,11 @@ export function subscribeCryptoRealtime( if (p == null || T == null) return const price = parseFloat(p) if (!Number.isFinite(price)) return - const ts = T < 1e12 ? T * 1000 : T - const point: CryptoChartPoint = [ts, price] + const rawTs = T < 1e12 ? T * 1000 : T + const point: CryptoChartPoint = [alignCryptoChartTimeMs(rawTs), price] + hadAggTradeSinceLastHoldTick = true + lastTradedPrice = price pendingPoint = point const now = Date.now() if (now - lastUpdateTs >= CRYPTO_UPDATE_THROTTLE_MS) { @@ -352,6 +382,9 @@ export function subscribeCryptoRealtime( } return () => { + disposed = true + pendingPoint = null + clearInterval(holdTimer) if (rafId) cancelAnimationFrame(rafId) if (throttleTimer) clearTimeout(throttleTimer) ws.close() diff --git a/src/composables/inPanePriceScalePrimitive.ts b/src/composables/inPanePriceScalePrimitive.ts new file mode 100644 index 0000000..9d159fb --- /dev/null +++ b/src/composables/inPanePriceScalePrimitive.ts @@ -0,0 +1,160 @@ +/** + * 在主图区域内绘制 Y 轴刻度标签(不占用右侧独立价轴条带)。 + * 思路来自 lightweight-charts 官方 plugin-examples/overlay-price-scale。 + */ +import type { CanvasRenderingTarget2D } from 'fancy-canvas' +import type { + BarPrice, + IPrimitivePaneRenderer, + IPrimitivePaneView, + ISeriesPrimitive, + IPriceFormatter, + SeriesAttachedParameter, + Time, +} from 'lightweight-charts' + +export interface InPanePriceScaleOptions { + textColor: string + side: 'left' | 'right' + /** 相邻刻度最小间距(像素) */ + tickSpacingPx: number + fontSizePx: number + /** 与绘图区边缘的水平间距 */ + sideMarginPx: number +} + +const defaultOptions: InPanePriceScaleOptions = { + textColor: 'rgba(107, 114, 128, 0.65)', + side: 'right', + tickSpacingPx: 44, + fontSizePx: 9, + sideMarginPx: 8, +} + +interface Label { + label: string + y: number +} + +interface RendererData { + priceFormatter: IPriceFormatter + coordinateToPrice: (y: number) => BarPrice | null + options: InPanePriceScaleOptions +} + +function barPriceToNumber(p: BarPrice | null): number | null { + if (p == null) return null + const n = typeof p === 'number' ? p : Number(p) + return Number.isFinite(n) ? n : null +} + +class InPanePriceScaleRenderer implements IPrimitivePaneRenderer { + private _data: RendererData | null = null + + update(data: RendererData) { + this._data = data + } + + draw(target: CanvasRenderingTarget2D) { + target.useMediaCoordinateSpace((scope) => { + if (!this._data) return + const { options } = this._data + const labels = this._labelsForHeight(scope.mediaSize.height, this._data) + if (labels.length === 0) return + + const ctx = scope.context + ctx.font = `${options.fontSizePx}px inherit` + const isLeft = options.side === 'left' + + ctx.textAlign = isLeft ? 'left' : 'right' + ctx.textBaseline = 'middle' + + for (const item of labels) { + const textX = isLeft + ? options.sideMarginPx + : scope.mediaSize.width - options.sideMarginPx + ctx.fillStyle = options.textColor + ctx.fillText(item.label, textX, item.y) + } + }) + } + + private _labelsForHeight(height: number, data: RendererData): Label[] { + const { coordinateToPrice, priceFormatter, options } = data + const spacing = options.tickSpacingPx + const half = Math.max(4, Math.round(spacing / 4)) + const ys: number[] = [] + for (let y = half; y <= height - half; y += spacing) { + ys.push(y) + } + const out: Label[] = [] + for (const y of ys) { + const price = barPriceToNumber(coordinateToPrice(y)) + if (price == null) continue + out.push({ label: priceFormatter.format(price), y }) + } + return out + } +} + +class InPanePriceScalePaneView implements IPrimitivePaneView { + private readonly _renderer: InPanePriceScaleRenderer + + constructor() { + this._renderer = new InPanePriceScaleRenderer() + } + + zOrder(): 'top' { + return 'top' + } + + renderer(): IPrimitivePaneRenderer { + return this._renderer + } + + update(data: RendererData) { + this._renderer.update(data) + } +} + +export class InPanePriceScalePrimitive implements ISeriesPrimitive