mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
618 lines
22 KiB
TypeScript
618 lines
22 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import * as echarts from 'echarts'
|
|
import type { ECharts, EChartsOption } from 'echarts'
|
|
import type { MinuteKlineRow, PriceLimitInfo } from '@/lib/api'
|
|
import { computeIntradayAverage, formatMinuteTime, FULL_DAY_TIMES } from '@/lib/intraday-chart'
|
|
import { useChartTheme, type ChartTheme } from '@/lib/theme'
|
|
|
|
type YMode = 'adaptive' | 'limit'
|
|
|
|
// 序列颜色 (双主题通用); 画布轴/网格/十字线等主题相关色走 ChartTheme
|
|
const THEME = {
|
|
line: '#3B82F6',
|
|
areaFill: 'rgba(59,130,246,0.40)',
|
|
avgLine: '#F59E0B',
|
|
volUp: 'rgba(240,68,56,0.6)',
|
|
volDown: 'rgba(18,183,106,0.6)',
|
|
}
|
|
|
|
interface Props {
|
|
data: MinuteKlineRow[]
|
|
height?: number
|
|
prevClose?: number
|
|
date?: string
|
|
priceLimit?: PriceLimitInfo
|
|
onPriceHover?: (price: number | null) => void
|
|
onPriceDoubleClick?: (price: number, currentPrice: number) => void
|
|
currentPrice?: number
|
|
priceLines?: { value: number; label?: string; color?: string }[]
|
|
showLimitLines?: boolean
|
|
showAvgLine?: boolean
|
|
}
|
|
|
|
function fmtAmt(v: number | null | undefined): string {
|
|
if (v == null || !Number.isFinite(v)) return '—'
|
|
if (v >= 1_000_000_000) return `${(v / 1_000_000_000).toFixed(2)}亿`
|
|
if (v >= 10_000) return `${(v / 10_000).toFixed(0)}万`
|
|
return v.toFixed(0)
|
|
}
|
|
|
|
function isValidPrice(v: number | null | undefined): v is number {
|
|
return typeof v === 'number' && Number.isFinite(v) && v > 0
|
|
}
|
|
|
|
/** 计算实际涨跌停价 (四舍五入到2位小数) 和实际涨跌停幅度 */
|
|
function getLimitPrices(prevClose: number, priceLimit?: PriceLimitInfo): {
|
|
limitUp: number // 涨停价 (四舍五入)
|
|
limitDown: number // 跌停价 (四舍五入)
|
|
upPct: number // 实际涨停幅度 (如 9.97)
|
|
downPct: number // 实际跌停幅度 (如 -9.97)
|
|
} {
|
|
const pct = priceLimit && Number.isFinite(priceLimit.rate) ? priceLimit.rate : 0.10
|
|
const rawUp = prevClose * (1 + pct)
|
|
const rawDown = prevClose * (1 - pct)
|
|
// A股涨跌停价四舍五入到分 (2位小数)
|
|
const limitUp = isValidPrice(priceLimit?.limit_up)
|
|
? priceLimit.limit_up
|
|
: Math.round(rawUp * 100) / 100
|
|
const limitDown = isValidPrice(priceLimit?.limit_down)
|
|
? priceLimit.limit_down
|
|
: Math.round(rawDown * 100) / 100
|
|
const upPct = (limitUp - prevClose) / prevClose * 100
|
|
const downPct = (limitDown - prevClose) / prevClose * 100
|
|
return { limitUp, limitDown, upPct, downPct }
|
|
}
|
|
|
|
function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgPrices: (number | null)[], lineColor: string, areaColor: string, yMode: YMode, ct: ChartTheme, priceLimit?: PriceLimitInfo, showLimitLines = true, showAvgLine = true, priceLines: Props['priceLines'] = []): EChartsOption {
|
|
// 将数据映射到全天时间轴上的正确位置
|
|
const timeIndexMap = new Map(FULL_DAY_TIMES.map((t, i) => [t, i]))
|
|
const closes = new Array(FULL_DAY_TIMES.length).fill(null) as (number | null)[]
|
|
const highs = new Array(FULL_DAY_TIMES.length).fill(null) as (number | null)[]
|
|
const lows = new Array(FULL_DAY_TIMES.length).fill(null) as (number | null)[]
|
|
const avgData = new Array(FULL_DAY_TIMES.length).fill(null) as (number | null)[]
|
|
const volumes = new Array(FULL_DAY_TIMES.length).fill(null) as (any | null)[]
|
|
|
|
const volNeutral = 'rgba(161,161,170,0.5)'
|
|
// 量柱着色基准: 前一分钟 close; 第一根用昨收。
|
|
// 不用 row.open — stock-sdk 历史日无真实分钟 open(为 null), close-vs-open 会全偏。
|
|
let prevRef: number | null = prevClose ?? null
|
|
for (let i = 0; i < data.length; i++) {
|
|
const timeKey = formatMinuteTime(data[i].datetime)
|
|
const idx = timeIndexMap.get(timeKey)
|
|
if (idx !== undefined) {
|
|
closes[idx] = data[i].close
|
|
highs[idx] = data[i].high
|
|
lows[idx] = data[i].low
|
|
avgData[idx] = avgPrices[i]
|
|
volumes[idx] = {
|
|
value: data[i].volume,
|
|
itemStyle: {
|
|
color: prevRef == null
|
|
? volNeutral
|
|
: data[i].close > prevRef
|
|
? THEME.volUp
|
|
: data[i].close < prevRef
|
|
? THEME.volDown
|
|
: volNeutral,
|
|
},
|
|
}
|
|
prevRef = data[i].close
|
|
}
|
|
}
|
|
|
|
const areaStyle: any = {
|
|
color: {
|
|
type: 'linear',
|
|
x: 0, y: 0, x2: 0, y2: 1,
|
|
colorStops: [
|
|
{ offset: 0, color: areaColor },
|
|
{ offset: 1, color: 'rgba(0,0,0,0)' },
|
|
],
|
|
},
|
|
}
|
|
|
|
const markLineData: any[] = []
|
|
if (prevClose != null) {
|
|
markLineData.push({
|
|
yAxis: prevClose,
|
|
lineStyle: { color: ct.crosshair, type: 'dashed', width: 1 },
|
|
label: { show: false },
|
|
symbol: 'none',
|
|
})
|
|
}
|
|
for (const line of priceLines) {
|
|
if (!Number.isFinite(line.value) || line.value <= 0) continue
|
|
markLineData.push({
|
|
yAxis: line.value,
|
|
lineStyle: { color: line.color ?? ct.text, type: 'dashed', width: 1, opacity: 0.92 },
|
|
label: {
|
|
show: !!line.label,
|
|
formatter: line.label ?? '',
|
|
position: 'insideEndTop',
|
|
color: line.color ?? ct.text,
|
|
backgroundColor: ct.tooltipBg,
|
|
borderRadius: 4,
|
|
padding: [2, 6],
|
|
fontSize: 10,
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
},
|
|
symbol: 'none',
|
|
})
|
|
}
|
|
|
|
let yMin: number | undefined
|
|
let yMax: number | undefined
|
|
let maxDiff = 0
|
|
if (isValidPrice(prevClose) && data.length > 0) {
|
|
const priceArrays = showAvgLine ? [closes, highs, lows, avgData] : [closes, highs, lows]
|
|
for (const arr of priceArrays) {
|
|
for (const v of arr) {
|
|
if (!isValidPrice(v)) continue
|
|
const diff = Math.abs(v - prevClose)
|
|
if (diff > maxDiff) maxDiff = diff
|
|
}
|
|
}
|
|
|
|
const monitoredDiff = priceLines.reduce((largest, line) => (
|
|
Number.isFinite(line.value) && line.value > 0
|
|
? Math.max(largest, Math.abs(line.value - prevClose))
|
|
: largest
|
|
), 0) * 1.05
|
|
|
|
if (showLimitLines && yMode === 'limit') {
|
|
const { limitUp, limitDown } = getLimitPrices(prevClose, priceLimit)
|
|
const limitDiffUp = limitUp - prevClose
|
|
const limitDiffDown = prevClose - limitDown
|
|
const limitDiff = Math.max(limitDiffUp, limitDiffDown)
|
|
// 涨跌停模式: Y 轴按实际涨跌停价
|
|
maxDiff = Math.max(limitDiff, monitoredDiff)
|
|
yMin = prevClose - maxDiff
|
|
yMax = prevClose + maxDiff
|
|
// 加 markLine 标注涨停价和跌停价 (仅虚线, 不显示文字)
|
|
markLineData.push(
|
|
{
|
|
yAxis: limitUp,
|
|
lineStyle: { color: 'rgba(199,64,64,0.4)', type: 'dashed', width: 1 },
|
|
label: { show: false },
|
|
symbol: 'none',
|
|
},
|
|
{
|
|
yAxis: limitDown,
|
|
lineStyle: { color: 'rgba(45,155,101,0.4)', type: 'dashed', width: 1 },
|
|
label: { show: false },
|
|
symbol: 'none',
|
|
},
|
|
)
|
|
} else {
|
|
// 自适应模式: Y 轴按实际涨跌幅对称, 但不超出实际涨跌停范围
|
|
if (showLimitLines) {
|
|
const { limitUp, limitDown } = getLimitPrices(prevClose, priceLimit)
|
|
const limitDiff = Math.max(limitUp - prevClose, prevClose - limitDown)
|
|
maxDiff = Math.min(maxDiff, limitDiff)
|
|
}
|
|
if (!showLimitLines && maxDiff > 0) {
|
|
maxDiff *= 1.1
|
|
}
|
|
// 至少保证一个可视范围 (防止数据平时 maxDiff=0)。指数不使用涨跌停范围,最小范围要更紧,否则低波动指数会被压成横线。
|
|
const minDiff = showLimitLines ? prevClose * 0.01 : prevClose * 0.001
|
|
if (maxDiff < minDiff) maxDiff = minDiff
|
|
maxDiff = Math.max(maxDiff, monitoredDiff)
|
|
yMin = prevClose - maxDiff
|
|
yMax = prevClose + maxDiff
|
|
}
|
|
}
|
|
|
|
// x 轴标签: 9:30, 10:30, 11:30/13:00, 14:00, 15:00
|
|
// 11:30(idx 120) 和 13:00(idx 121) 相邻会重叠, 合并为一个标签
|
|
const xAxisLabelMap: Record<number, string> = {
|
|
0: '9:30',
|
|
60: '10:30',
|
|
120: '11:30/13:00',
|
|
181: '14:00',
|
|
241: '15:00',
|
|
}
|
|
const xAxisLabelFormatter = (_value: string, idx: number) => {
|
|
return xAxisLabelMap[idx] ?? ''
|
|
}
|
|
|
|
return {
|
|
animation: false,
|
|
backgroundColor: 'transparent',
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
backgroundColor: 'transparent',
|
|
borderWidth: 0,
|
|
textStyle: { fontSize: 0 },
|
|
formatter: () => '',
|
|
axisPointer: {
|
|
type: 'cross',
|
|
label: {
|
|
show: true,
|
|
backgroundColor: ct.tooltipBg,
|
|
borderColor: ct.tooltipBorder,
|
|
borderWidth: 1,
|
|
padding: [2, 5],
|
|
color: ct.tooltipText,
|
|
fontSize: 10,
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
},
|
|
crossStyle: { color: ct.crosshair, type: 'dashed', width: 1 },
|
|
lineStyle: { color: ct.crosshair, type: 'dashed', width: 1 },
|
|
},
|
|
},
|
|
axisPointer: {
|
|
link: [{ xAxisIndex: 'all' }],
|
|
},
|
|
grid: [
|
|
{ left: 60, right: 55, top: 24, bottom: '34%' },
|
|
{ left: 60, right: 55, top: '69%', bottom: 20 },
|
|
],
|
|
xAxis: [
|
|
{
|
|
type: 'category',
|
|
data: FULL_DAY_TIMES,
|
|
boundaryGap: false,
|
|
axisPointer: {
|
|
show: true,
|
|
lineStyle: { color: ct.crosshair, type: 'dashed', width: 1 },
|
|
label: {
|
|
show: true,
|
|
backgroundColor: ct.tooltipBg,
|
|
borderColor: ct.tooltipBorder,
|
|
borderWidth: 1,
|
|
padding: [2, 4],
|
|
color: ct.tooltipText,
|
|
fontSize: 10,
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
formatter: (params: any) => {
|
|
return params.value ?? ''
|
|
},
|
|
},
|
|
},
|
|
axisLine: { show: false },
|
|
axisLabel: {
|
|
color: ct.text,
|
|
fontSize: 10,
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
formatter: xAxisLabelFormatter,
|
|
interval: 0,
|
|
},
|
|
axisTick: { show: false },
|
|
splitLine: {
|
|
show: true,
|
|
lineStyle: { color: ct.grid },
|
|
},
|
|
},
|
|
{
|
|
type: 'category',
|
|
gridIndex: 1,
|
|
data: FULL_DAY_TIMES,
|
|
boundaryGap: false,
|
|
axisLine: { show: false },
|
|
axisLabel: { show: false },
|
|
axisTick: { show: false },
|
|
splitLine: { show: false },
|
|
},
|
|
],
|
|
yAxis: [
|
|
{
|
|
type: 'value',
|
|
min: yMin,
|
|
max: yMax,
|
|
interval: maxDiff || undefined,
|
|
splitArea: { show: false },
|
|
axisLine: { show: false },
|
|
axisTick: { show: false },
|
|
splitLine: { lineStyle: { color: ct.grid } },
|
|
axisPointer: {
|
|
label: {
|
|
formatter: (params: any) => {
|
|
const v = params.value
|
|
return typeof v === 'number' ? v.toFixed(2) : ''
|
|
},
|
|
},
|
|
},
|
|
axisLabel: {
|
|
color: ct.text,
|
|
fontSize: 10,
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
formatter: (v: number) => v.toFixed(2),
|
|
},
|
|
},
|
|
{
|
|
scale: true,
|
|
gridIndex: 1,
|
|
splitNumber: 2,
|
|
axisLine: { show: false },
|
|
axisTick: { show: false },
|
|
splitLine: { show: false },
|
|
axisLabel: { show: false },
|
|
},
|
|
...(isValidPrice(prevClose) && yMin != null && yMax != null ? [{
|
|
type: 'value' as const,
|
|
position: 'right' as const,
|
|
gridIndex: 0,
|
|
min: yMin,
|
|
max: yMax,
|
|
interval: maxDiff || undefined,
|
|
splitArea: { show: false },
|
|
axisLine: { show: false },
|
|
axisTick: { show: false },
|
|
splitLine: { show: false },
|
|
axisPointer: {
|
|
label: {
|
|
formatter: (params: any) => {
|
|
const v = params.value
|
|
if (typeof v !== 'number') return ''
|
|
const pct = (v - prevClose) / prevClose * 100
|
|
if (Math.abs(pct) < 0.01) return '0.00%'
|
|
return (pct > 0 ? '+' : '') + pct.toFixed(2) + '%'
|
|
},
|
|
},
|
|
},
|
|
axisLabel: {
|
|
color: ct.text,
|
|
fontSize: 10,
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
formatter: (v: number) => {
|
|
const pct = (v - prevClose) / prevClose * 100
|
|
if (Math.abs(pct) < 0.01) return '0.00%'
|
|
return (pct > 0 ? '+' : '') + pct.toFixed(2) + '%'
|
|
},
|
|
},
|
|
}] : []),
|
|
],
|
|
series: [
|
|
{
|
|
name: '价格',
|
|
type: 'line',
|
|
data: closes,
|
|
smooth: false,
|
|
symbol: 'none',
|
|
cursor: 'crosshair',
|
|
lineStyle: { width: 1.2, color: lineColor },
|
|
areaStyle,
|
|
connectNulls: true,
|
|
markLine: markLineData.length > 0 ? { symbol: 'none', data: markLineData, animation: false, silent: true } : undefined,
|
|
},
|
|
...(showAvgLine ? [{
|
|
name: '均价',
|
|
type: 'line' as const,
|
|
data: avgData,
|
|
smooth: false,
|
|
symbol: 'none',
|
|
cursor: 'crosshair',
|
|
lineStyle: { width: 1, color: THEME.avgLine },
|
|
connectNulls: true,
|
|
}] : []),
|
|
{
|
|
name: '成交量',
|
|
type: 'bar',
|
|
data: volumes,
|
|
xAxisIndex: 1,
|
|
yAxisIndex: 1,
|
|
cursor: 'crosshair',
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
export function EChartsIntraday({
|
|
data,
|
|
height = 320,
|
|
prevClose,
|
|
date,
|
|
priceLimit,
|
|
onPriceHover,
|
|
onPriceDoubleClick,
|
|
currentPrice,
|
|
priceLines,
|
|
showLimitLines = true,
|
|
showAvgLine = true,
|
|
}: Props) {
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const chartRef = useRef<ECharts | null>(null)
|
|
const roRef = useRef<ResizeObserver | null>(null)
|
|
const moRef = useRef<MutationObserver | null>(null)
|
|
const priceDoubleClickHandlerRef = useRef<((event: { offsetX: number; offsetY: number }) => void) | null>(null)
|
|
const dataRef = useRef(data)
|
|
dataRef.current = data
|
|
const currentPriceRef = useRef(currentPrice)
|
|
currentPriceRef.current = currentPrice
|
|
const onPriceHoverRef = useRef(onPriceHover)
|
|
onPriceHoverRef.current = onPriceHover
|
|
const onPriceDoubleClickRef = useRef(onPriceDoubleClick)
|
|
onPriceDoubleClickRef.current = onPriceDoubleClick
|
|
// 全日索引 → 数据数组索引 的映射 (ref 避免重建 chart)
|
|
const fullDayToDataIdx = useRef<Map<number, number>>(new Map())
|
|
|
|
const [infoIdx, setInfoIdx] = useState(data.length - 1)
|
|
const [yMode, setYMode] = useState<YMode>('adaptive')
|
|
const ct = useChartTheme()
|
|
const avgPrices = useMemo(() => computeIntradayAverage(data), [data])
|
|
|
|
// 分时线颜色:基于最新价 vs 昨收
|
|
const lastClose = data.length > 0 ? data[data.length - 1].close : null
|
|
const lineIsUp = lastClose != null && prevClose != null ? lastClose > prevClose : true
|
|
const lineIsFlat = lastClose != null && prevClose != null ? lastClose === prevClose : false
|
|
const lineColor = lineIsFlat ? '#A1A1AA' : lineIsUp ? '#C74040' : '#2D9B65'
|
|
const areaFill = lineIsFlat ? 'rgba(180,180,190,0.40)' : lineIsUp ? 'rgba(199,64,64,0.40)' : 'rgba(34,197,94,0.40)'
|
|
|
|
useEffect(() => {
|
|
setInfoIdx(data.length - 1)
|
|
}, [data.length])
|
|
|
|
useEffect(() => {
|
|
const el = containerRef.current
|
|
if (!el) return
|
|
|
|
let chart = chartRef.current
|
|
if (!chart) {
|
|
chart = echarts.init(el, undefined, { renderer: 'canvas' })
|
|
chartRef.current = chart
|
|
// 强制 canvas 使用十字光标,覆盖 ECharts 默认的 pointer
|
|
const forceCursor = () => {
|
|
const canvases = el.querySelectorAll('canvas')
|
|
canvases.forEach(c => { c.style.setProperty('cursor', 'crosshair', 'important') })
|
|
}
|
|
forceCursor()
|
|
// MutationObserver: ECharts 内部可能重建/修改 canvas 属性,持续强制 cursor
|
|
const mo = new MutationObserver(forceCursor)
|
|
mo.observe(el, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] })
|
|
moRef.current = mo
|
|
roRef.current = new ResizeObserver(() => {
|
|
chart!.resize()
|
|
forceCursor()
|
|
})
|
|
roRef.current.observe(el)
|
|
|
|
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 fullDayIdx = typeof val === 'number' ? val : -1
|
|
if (fullDayIdx >= 0) {
|
|
const dataIdx = fullDayToDataIdx.current.get(fullDayIdx) ?? -1
|
|
setInfoIdx(dataIdx)
|
|
const d = dataRef.current
|
|
if (dataIdx >= 0 && dataIdx < d.length) {
|
|
onPriceHoverRef.current?.(d[dataIdx].close)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
})
|
|
|
|
chart.on('globalout', () => {
|
|
onPriceHoverRef.current?.(null)
|
|
})
|
|
|
|
const handlePriceDoubleClick = (event: { offsetX: number; offsetY: number }) => {
|
|
const pixel: [number, number] = [event.offsetX, event.offsetY]
|
|
if (!chart!.containPixel({ gridIndex: 0 }, pixel)) return
|
|
const coordinate = chart!.convertFromPixel({ xAxisIndex: 0, yAxisIndex: 0 }, pixel)
|
|
const clickedPrice = Array.isArray(coordinate) ? Number(coordinate[1]) : NaN
|
|
const latestPrice = currentPriceRef.current ?? dataRef.current[dataRef.current.length - 1]?.close
|
|
if (Number.isFinite(clickedPrice) && clickedPrice > 0 && Number.isFinite(latestPrice) && latestPrice > 0) {
|
|
onPriceDoubleClickRef.current?.(clickedPrice, latestPrice)
|
|
}
|
|
}
|
|
priceDoubleClickHandlerRef.current = handlePriceDoubleClick
|
|
chart.getZr().on('dblclick', handlePriceDoubleClick)
|
|
}
|
|
|
|
if (data.length > 0) {
|
|
// 构建全日索引 → 数据索引 的映射
|
|
const timeIndexMap = new Map(FULL_DAY_TIMES.map((t, i) => [t, i]))
|
|
const mapping = new Map<number, number>()
|
|
for (let i = 0; i < data.length; i++) {
|
|
const timeKey = formatMinuteTime(data[i].datetime)
|
|
const fullDayIdx = timeIndexMap.get(timeKey)
|
|
if (fullDayIdx !== undefined) {
|
|
mapping.set(fullDayIdx, i)
|
|
}
|
|
}
|
|
fullDayToDataIdx.current = mapping
|
|
|
|
chart.setOption(buildOption(data, prevClose, avgPrices, lineColor, areaFill, yMode, ct, priceLimit, showLimitLines, showAvgLine, priceLines), true)
|
|
} else {
|
|
chart.clear()
|
|
}
|
|
}, [data, prevClose, height, lineColor, areaFill, yMode, ct, priceLimit, showLimitLines, showAvgLine, priceLines])
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
chartRef.current?.off('updateAxisPointer')
|
|
chartRef.current?.off('globalout')
|
|
if (priceDoubleClickHandlerRef.current) {
|
|
chartRef.current?.getZr().off('dblclick', priceDoubleClickHandlerRef.current)
|
|
}
|
|
moRef.current?.disconnect()
|
|
roRef.current?.disconnect()
|
|
chartRef.current?.dispose()
|
|
chartRef.current = null
|
|
moRef.current = null
|
|
roRef.current = null
|
|
}
|
|
}, [])
|
|
|
|
const d = infoIdx >= 0 && infoIdx < data.length ? data[infoIdx] : null
|
|
const avg = d != null ? avgPrices[infoIdx] : null
|
|
const chg = d && prevClose != null ? d.close - prevClose : null
|
|
const isUp = chg != null ? chg > 0 : true
|
|
const isFlat = chg != null ? chg === 0 : false
|
|
const priceClr = isFlat ? '#A1A1AA' : isUp ? '#C74040' : '#2D9B65'
|
|
|
|
return (
|
|
<div className="w-full">
|
|
{/* 按钮行: 切换式按钮组, 居右 */}
|
|
{showLimitLines && <div className="flex items-center justify-end px-1 pb-0.5">
|
|
<div className="inline-flex items-center rounded bg-elevated overflow-hidden">
|
|
<button
|
|
onClick={() => setYMode('adaptive')}
|
|
className={`px-2.5 py-0.5 text-[10px] font-mono cursor-pointer transition-colors ${
|
|
yMode === 'adaptive'
|
|
? 'bg-accent/20 text-accent'
|
|
: 'text-muted hover:text-secondary'
|
|
}`}
|
|
>
|
|
自适应
|
|
</button>
|
|
<div className="w-px h-3 bg-border/40" />
|
|
<button
|
|
onClick={() => setYMode('limit')}
|
|
className={`px-2.5 py-0.5 text-[10px] font-mono cursor-pointer transition-colors ${
|
|
yMode === 'limit'
|
|
? 'bg-accent/20 text-accent'
|
|
: 'text-muted hover:text-secondary'
|
|
}`}
|
|
>
|
|
涨跌停
|
|
</button>
|
|
</div>
|
|
</div>}
|
|
<div style={{ backgroundColor: ct.infoBarBg }}>
|
|
{/* 第一行: 日期 + OHLC */}
|
|
<div className="flex items-center gap-x-2 px-2 font-mono text-[11px] select-none flex-wrap" style={{ height: 20 }}>
|
|
{!d && <span className="text-muted">—</span>}
|
|
{d && (
|
|
<>
|
|
{date && <span className="text-muted">{date}</span>}
|
|
<span className="text-muted">开</span>
|
|
<span style={{ color: priceClr }}>{d.open != null ? d.open.toFixed(2) : '—'}</span>
|
|
<span className="text-muted">高</span>
|
|
<span style={{ color: priceClr }}>{d.high.toFixed(2)}</span>
|
|
<span className="text-muted">低</span>
|
|
<span style={{ color: priceClr }}>{d.low.toFixed(2)}</span>
|
|
<span className="text-muted">收</span>
|
|
<span style={{ color: priceClr }} className="font-semibold">{d.close.toFixed(2)}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
{/* 第二行: 价格+均价+量+额 */}
|
|
<div className="flex items-center gap-x-4 px-2 font-mono text-[11px] select-none" style={{ height: 20 }}>
|
|
{d && (
|
|
<>
|
|
<span className="flex items-center gap-x-1">
|
|
<span style={{ display: 'inline-block', width: 14, height: 2, background: priceClr }} />
|
|
<span style={{ color: priceClr }}>{d.close.toFixed(2)}</span>
|
|
</span>
|
|
{showAvgLine && <span className="flex items-center gap-x-1">
|
|
<span style={{ display: 'inline-block', width: 14, height: 2, background: THEME.avgLine }} />
|
|
<span style={{ color: THEME.avgLine }}>{avg != null ? avg.toFixed(2) : '—'}</span>
|
|
</span>}
|
|
<span className="text-muted">量</span>
|
|
<span className="text-secondary">{d.volume.toFixed(0)}</span>
|
|
<span className="text-muted">额</span>
|
|
<span className="text-secondary">{fmtAmt(d.amount)}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div ref={containerRef} className="w-full" style={{ height: height - 42, cursor: 'crosshair' }} />
|
|
</div>
|
|
)
|
|
}
|