feat(kline): show since-prev-close change and period count on hover

悬停某根K线时, 图表自带信息栏额外显示「至今」(该K线昨收到最新收盘的涨跌幅)
与「周期 N」(到最新K线共多少根); 鼠标移出图表区仅隐藏该字段, 其余保持。

- updateAxisPointer 重写: 区分 悬停K线变化 与 竖虚线显隐, 分别控制信息栏重绘与副图 graphic
- 信息栏行高 height → min-height, 容纳「至今」字段不被裁剪

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
richard
2026-09-02 13:12:42 +08:00
co-authored by Claude
parent ef66877529
commit 0f7c1dfe39
+49 -26
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useCallback, useMemo } from 'react'
import { chartTheme, getTheme, useTheme } from '@/lib/theme'
import { fmtPct } from '@/lib/format'
import * as echarts from 'echarts'
import type { ECharts, EChartsOption } from 'echarts'
@@ -832,6 +833,8 @@ export function EChartsCandlestick({
const infoIdxRef = useRef<number>(data.length - 1)
const compactRef = useRef(false)
const userZoomRef = useRef<{ start: number; end: number } | null>(null)
// 竖虚线(crosshair)是否可见: 控制信息栏「至今」字段的显隐。鼠标移出图表区即 false。
const hoverActiveRef = useRef(false)
// 需要在闭包中访问最新值的变量 — 先声明占位,后面赋值
const activeIndicatorsRef = useRef(activeIndicators)
@@ -911,7 +914,7 @@ export function EChartsCandlestick({
const floatShares = stockInfo?.float_shares
const turnoverRate = floatShares && d.volume ? (d.volume * 100 / floatShares * 100) : null
let html = `<div style="display:flex;align-items:center;gap:6px;padding:0 8px;font:11px 'JetBrains Mono',monospace;select:none;height:20px;flex-wrap:wrap">`
let html = `<div style="display:flex;align-items:center;gap:6px;padding:0 8px;font:11px 'JetBrains Mono',monospace;select:none;min-height:20px;flex-wrap:wrap">`
html += `<span style="color:${CT().text}">${d.date}</span>`
html += `<span style="color:${CT().text}">开</span>`
html += `<span style="color:${d.open >= d.close ? THEME.bear : THEME.bull}">${d.open.toFixed(2)}</span>`
@@ -930,11 +933,25 @@ export function EChartsCandlestick({
html += `<span style="color:${CT().text}">换手</span>`
html += `<span style="color:${CT().text}">${turnoverRate.toFixed(2)}%</span>`
}
// 至今: 仅当竖虚线(crosshair)在图上且鼠标悬停某根 K 线时显示。
// 最新价取最后一根K线收盘 (后端 _maybe_inject_live_candle 盘中注入实时价, 收盘后即最近收盘)。
// 基准取该K线昨收(前一日收盘), 与同花顺及全市场涨幅口径一致; 数据第一根K线无昨收则跳过。
if (hoverActiveRef.current && prev && Number.isFinite(prev.close) && prev.close > 0) {
const latestPrice = data[data.length - 1].close
if (Number.isFinite(latestPrice)) {
const sinceRatio = (latestPrice - prev.close) / prev.close
const sinceClr = sinceRatio >= 0 ? THEME.bull : THEME.bear
html += `<span style="color:${CT().text}">至今</span>`
html += `<span style="color:${sinceClr}">${fmtPct(sinceRatio)}</span>`
// 周期数: 从该K线(含)到最新一根K线共多少根; 悬停最后一根时为 1
html += `<span style="color:${CT().text}">周期 ${data.length - idx}</span>`
}
}
html += `</div>`
// 第二行: MA + BOLL
if (showMA) {
html += `<div style="display:flex;align-items:center;gap:10px;padding:0 8px;font:11px 'JetBrains Mono',monospace;select:none;height:20px;flex-wrap:wrap">`
html += `<div style="display:flex;align-items:center;gap:10px;padding:0 8px;font:11px 'JetBrains Mono',monospace;select:none;min-height:20px;flex-wrap:wrap">`
if (d.ma5 != null) html += `<span style="color:${THEME.ma5}">MA5:${Number(d.ma5).toFixed(2)}</span>`
if (d.ma10 != null) html += `<span style="color:${THEME.ma10}">MA10:${Number(d.ma10).toFixed(2)}</span>`
if (d.ma20 != null) html += `<span style="color:${THEME.ma20}">MA20:${Number(d.ma20).toFixed(2)}</span>`
@@ -954,6 +971,8 @@ export function EChartsCandlestick({
infoIdxRef.current = data.length - 1
compactRef.current = false
userZoomRef.current = null
// 新数据无悬停上下文, 隐藏「至今」; 下次鼠标移动时由 updateAxisPointer 重新置位
hoverActiveRef.current = false
}, [data.length])
// ===== 初始化 chart (只在 chartHeight 变化时重建) =====
@@ -965,32 +984,36 @@ export function EChartsCandlestick({
chartRef.current = chart
// 鼠标移动 → 只更新 ref + DOM,不触发 React re-render
// 设计原则: 找不到有效数据时保持上次显示,永远不清空信息栏
// 设计原则: 找不到有效数据时保持上次显示,永远不清空信息栏; 鼠标移出时仅隐藏「至今」。
chart.on('updateAxisPointer', (event: any) => {
const axesInfo = event.axesInfo
if (!axesInfo) return // 鼠标移出图表区域,保持当前显示
for (const info of Object.values(axesInfo)) {
const val = (info as any)?.value
if (val == null) continue
const d = dataRef.current
const idx = typeof val === 'number' ? val : d.findIndex(x => x.date === val)
if (idx >= 0 && idx < d.length) {
if (infoIdxRef.current === idx) return
infoIdxRef.current = idx
// 直接更新信息栏 DOM (通过 ref 读取最新的生成函数)
const infoEl = infoBarRef.current
if (infoEl) {
const html = getInfoBarHTMLRef.current()
if (html) infoEl.innerHTML = html // 只在有内容时更新
}
// 更新子图 graphic
triggerInfoBarUpdate()
return
const d = dataRef.current
// 竖虚线是否正落在某根有效 K 线上 (鼠标在图表数据区内)
let foundIdx = -1
if (axesInfo) {
for (const info of Object.values(axesInfo)) {
const val = (info as any)?.value
if (val == null) continue
const idx = typeof val === 'number' ? val : d.findIndex(x => x.date === val)
if (idx >= 0 && idx < d.length) { foundIdx = idx; break }
}
}
// 没有找到有效数据 — 不做任何操作,保持上次显示
const active = foundIdx >= 0
const idxChanged = foundIdx >= 0 && infoIdxRef.current !== foundIdx
const visChanged = active !== hoverActiveRef.current
hoverActiveRef.current = active
if (idxChanged) infoIdxRef.current = foundIdx
// 竖虚线显隐或悬停 K 线变化 → 重绘一次信息栏 (控制「至今」字段显隐 + 当前 K 线数据)
if (visChanged || idxChanged) {
const infoEl = infoBarRef.current
if (infoEl) {
const html = getInfoBarHTMLRef.current()
if (html) infoEl.innerHTML = html // 只在有内容时更新
}
}
if (foundIdx < 0) return
// 更新子图 graphic (仅悬停 K 线变化时; 纯显隐切换不影响副图)
if (idxChanged) triggerInfoBarUpdate()
})
chart.on('click', (params: any) => {
@@ -1159,7 +1182,7 @@ export function EChartsCandlestick({
if (!d) return ''
const floatShares = stockInfo?.float_shares
const turnoverRate = floatShares && d.volume ? (d.volume * 100 / floatShares * 100) : null
let html = `<div style="display:flex;align-items:center;gap:6px;padding:0 8px;font:11px 'JetBrains Mono',monospace;height:20px;flex-wrap:wrap">`
let html = `<div style="display:flex;align-items:center;gap:6px;padding:0 8px;font:11px 'JetBrains Mono',monospace;min-height:20px;flex-wrap:wrap">`
html += `<span style="color:${CT().text}">${d.date}</span>`
html += `<span style="color:${CT().text}">开</span>`
html += `<span style="color:${d.open >= d.close ? THEME.bear : THEME.bull}">${d.open.toFixed(2)}</span>`
@@ -1182,7 +1205,7 @@ export function EChartsCandlestick({
}
html += `</div>`
if (showMA) {
html += `<div style="display:flex;align-items:center;gap:10px;padding:0 8px;font:11px 'JetBrains Mono',monospace;height:20px;flex-wrap:wrap">`
html += `<div style="display:flex;align-items:center;gap:10px;padding:0 8px;font:11px 'JetBrains Mono',monospace;min-height:20px;flex-wrap:wrap">`
if (d.ma5 != null) html += `<span style="color:${THEME.ma5}">MA5:${Number(d.ma5).toFixed(2)}</span>`
if (d.ma10 != null) html += `<span style="color:${THEME.ma10}">MA10:${Number(d.ma10).toFixed(2)}</span>`
if (d.ma20 != null) html += `<span style="color:${THEME.ma20}">MA20:${Number(d.ma20).toFixed(2)}</span>`