diff --git a/frontend/src/components/EChartsCandlestick.tsx b/frontend/src/components/EChartsCandlestick.tsx index 63035fd..595abbc 100644 --- a/frontend/src/components/EChartsCandlestick.tsx +++ b/frontend/src/components/EChartsCandlestick.tsx @@ -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(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 = `
` + let html = `
` html += `${d.date}` html += `` html += `${d.open.toFixed(2)}` @@ -930,11 +933,25 @@ export function EChartsCandlestick({ html += `换手` html += `${turnoverRate.toFixed(2)}%` } + // 至今: 仅当竖虚线(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 += `至今` + html += `${fmtPct(sinceRatio)}` + // 周期数: 从该K线(含)到最新一根K线共多少根; 悬停最后一根时为 1 + html += `周期 ${data.length - idx}` + } + } html += `
` // 第二行: MA + BOLL if (showMA) { - html += `
` + html += `
` if (d.ma5 != null) html += `MA5:${Number(d.ma5).toFixed(2)}` if (d.ma10 != null) html += `MA10:${Number(d.ma10).toFixed(2)}` if (d.ma20 != null) html += `MA20:${Number(d.ma20).toFixed(2)}` @@ -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 = `
` + let html = `
` html += `${d.date}` html += `` html += `${d.open.toFixed(2)}` @@ -1182,7 +1205,7 @@ export function EChartsCandlestick({ } html += `
` if (showMA) { - html += `
` + html += `
` if (d.ma5 != null) html += `MA5:${Number(d.ma5).toFixed(2)}` if (d.ma10 != null) html += `MA10:${Number(d.ma10).toFixed(2)}` if (d.ma20 != null) html += `MA20:${Number(d.ma20).toFixed(2)}`