mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
监控引擎 (backend): - MonitorRuleEngine 统一规则引擎,支持策略/信号/价格/行情四种类型 - JSONL 追加存储 (alert_store.py),支持分页 + 清理 - alert/monitor_rules CRUD API + seed 演示数据 - POST /api/kline/instruments/names 批量股票名称查询 - SSE strategy_alert 事件触发前端通知 前端通知体系: - AlertToast 自定义弹窗 (Framer Motion + AnimatePresence) - Web Audio API 合成音效 (12种预设,无需音频文件) - 侧边栏角标 (monitorBadge.ts) localStorage 持久化未读数 - pendingSeen 机制解决 markSeen/setCurrentTotal 竞态 - 系统设置页: 通知开关 + 最大条数 + 音效选择 - 菜单设置页: 角标数字开关 监控中心 (Monitor.tsx): - 双栏布局: 左侧实时告警列表 + 右侧规则管理 - RulesList 股票代码后显示中文名称 - RuleEditor 完整规则编辑器组件 - 告警支持查看详情 (StockPreviewDialog) Dashboard: - MonitorWidget: Top 10 实时告警卡片 Dev 页面: - 一键填充演示告警 + 规则 + 分钟探测 - 可视化 SSE 事件流查看 其他: - v0.1.19 → v0.1.28 (VERSION 从 pyproject.toml 读取) - dev.ps1/dev.sh 添加 --host 0.0.0.0 (局域网访问) - 修复 Screener.tsx presets.length 可选链 - README 截图表格更新 (6张) + 监控章节重写 - 删除 MinuteDataProbe 页面 (合并至 Dev)
1100 lines
36 KiB
TypeScript
1100 lines
36 KiB
TypeScript
import { useEffect, useRef, useCallback, useMemo } from 'react'
|
|
import * as echarts from 'echarts'
|
|
import type { ECharts, EChartsOption } from 'echarts'
|
|
|
|
export interface OHLC {
|
|
date: string
|
|
open: number
|
|
high: number
|
|
low: number
|
|
close: number
|
|
volume?: number
|
|
ma5?: number | null
|
|
ma10?: number | null
|
|
ma20?: number | null
|
|
ma60?: number | null
|
|
macd_dif?: number | null
|
|
macd_dea?: number | null
|
|
macd_hist?: number | null
|
|
rsi_6?: number | null
|
|
rsi_14?: number | null
|
|
rsi_24?: number | null
|
|
kdj_k?: number | null
|
|
kdj_d?: number | null
|
|
kdj_j?: number | null
|
|
boll_upper?: number | null
|
|
boll_lower?: number | null
|
|
}
|
|
|
|
export interface ChartMarker {
|
|
date: string
|
|
kind: 'buy' | 'sell' | 'neutral'
|
|
label?: string
|
|
/** 若为 true,标记放在蜡烛上方(如涨停连板标签)。 */
|
|
above?: boolean
|
|
/** 自定义标签颜色,覆盖默认的 kind 对应色。 */
|
|
color?: string
|
|
}
|
|
|
|
export interface ChartRange {
|
|
start: string
|
|
end: string
|
|
label?: string
|
|
color?: string
|
|
}
|
|
|
|
export interface ChartPriceLine {
|
|
value: number
|
|
label?: string
|
|
color?: string
|
|
start?: string
|
|
end?: string
|
|
}
|
|
|
|
export interface StockInfo {
|
|
name?: string
|
|
total_shares?: number
|
|
float_shares?: number
|
|
/** 扩展数据(key: configId__fieldName),来自 klineDaily 的 ext_columns */
|
|
ext?: Record<string, unknown>
|
|
}
|
|
|
|
/** 子图定义 */
|
|
export interface SubChartDef {
|
|
key: string
|
|
label: string
|
|
/** 子图固定高度 px */
|
|
height: number
|
|
/** 构建 series 数组 */
|
|
buildSeries: (data: OHLC[]) => any[]
|
|
/** 构建信息栏文字 (当前数据行 -> 显示内容) */
|
|
buildInfo: (d: OHLC | null) => { label: string; color: string; value: string }[]
|
|
/** Y 轴特殊配置 */
|
|
yAxisConfig?: Record<string, any>
|
|
}
|
|
|
|
// ===== 成交量 N 日均量 =====
|
|
function volMaN(data: OHLC[], n: number): (number | null)[] {
|
|
const result: (number | null)[] = []
|
|
for (let i = 0; i < data.length; i++) {
|
|
if (i < n - 1) { result.push(null); continue }
|
|
let sum = 0
|
|
for (let j = i - n + 1; j <= i; j++) sum += data[j].volume ?? 0
|
|
result.push(sum / n)
|
|
}
|
|
return result
|
|
}
|
|
|
|
function fmtVol(v: number | null | undefined): string {
|
|
if (v == null) return '—'
|
|
if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿'
|
|
if (v >= 1e4) return (v / 1e4).toFixed(0) + '万'
|
|
return v.toFixed(0)
|
|
}
|
|
|
|
export const SUB_CHARTS: SubChartDef[] = [
|
|
{
|
|
key: 'vol',
|
|
label: '成交量',
|
|
height: 84,
|
|
yAxisConfig: { min: 0 },
|
|
buildSeries: (data) => {
|
|
const ma5Data = volMaN(data, 5)
|
|
const ma10Data = volMaN(data, 10)
|
|
return [
|
|
{
|
|
name: '成交量',
|
|
type: 'bar',
|
|
data: data.map(d => ({
|
|
value: d.volume ?? 0,
|
|
itemStyle: {
|
|
color: d.close >= d.open ? 'rgba(240,68,56,0.6)' : 'rgba(18,183,106,0.6)',
|
|
},
|
|
})),
|
|
barWidth: '60%',
|
|
animation: false,
|
|
},
|
|
{
|
|
name: 'VOL5',
|
|
type: 'line',
|
|
data: ma5Data,
|
|
smooth: true, symbol: 'none', animation: false,
|
|
lineStyle: { width: 1, color: '#FACC15' },
|
|
itemStyle: { color: '#FACC15' },
|
|
},
|
|
{
|
|
name: 'VOL10',
|
|
type: 'line',
|
|
data: ma10Data,
|
|
smooth: true, symbol: 'none', animation: false,
|
|
lineStyle: { width: 1, color: '#8B5CF6' },
|
|
itemStyle: { color: '#8B5CF6' },
|
|
},
|
|
]
|
|
},
|
|
buildInfo: (d) => {
|
|
if (!d) return []
|
|
return [
|
|
{ label: '量', color: d.close >= d.open ? '#C74040' : '#2D9B65', value: fmtVol(d.volume) },
|
|
]
|
|
},
|
|
},
|
|
{
|
|
key: 'macd',
|
|
label: 'MACD',
|
|
height: 72,
|
|
buildSeries: (data) => [
|
|
{
|
|
name: 'DIF',
|
|
type: 'line',
|
|
data: data.map(d => d.macd_dif != null ? Number(d.macd_dif) : '-'),
|
|
smooth: true, symbol: 'none', animation: false,
|
|
lineStyle: { width: 1, color: '#FACC15' },
|
|
itemStyle: { color: '#FACC15' },
|
|
},
|
|
{
|
|
name: 'DEA',
|
|
type: 'line',
|
|
data: data.map(d => d.macd_dea != null ? Number(d.macd_dea) : '-'),
|
|
smooth: true, symbol: 'none', animation: false,
|
|
lineStyle: { width: 1, color: '#8B5CF6' },
|
|
itemStyle: { color: '#8B5CF6' },
|
|
},
|
|
{
|
|
name: 'MACD',
|
|
type: 'bar',
|
|
data: data.map(d => {
|
|
const v = d.macd_hist
|
|
if (v == null) return '-'
|
|
return {
|
|
value: Number(v),
|
|
itemStyle: { color: Number(v) >= 0 ? 'rgba(240,68,56,0.6)' : 'rgba(18,183,106,0.6)' },
|
|
}
|
|
}),
|
|
barWidth: '40%',
|
|
animation: false,
|
|
},
|
|
],
|
|
buildInfo: (d) => {
|
|
if (!d) return []
|
|
return [
|
|
{ label: 'DIF', color: '#FACC15', value: d.macd_dif != null ? d.macd_dif.toFixed(3) : '—' },
|
|
{ label: 'DEA', color: '#8B5CF6', value: d.macd_dea != null ? d.macd_dea.toFixed(3) : '—' },
|
|
{ label: 'MACD', color: d.macd_hist != null && d.macd_hist >= 0 ? '#C74040' : '#2D9B65', value: d.macd_hist != null ? d.macd_hist.toFixed(3) : '—' },
|
|
]
|
|
},
|
|
},
|
|
{
|
|
key: 'rsi',
|
|
label: 'RSI',
|
|
height: 72,
|
|
yAxisConfig: { min: 0, max: 100 },
|
|
buildSeries: (data) => [
|
|
{
|
|
name: 'RSI6',
|
|
type: 'line',
|
|
data: data.map(d => d.rsi_6 != null ? Number(d.rsi_6) : '-'),
|
|
smooth: true, symbol: 'none', animation: false,
|
|
lineStyle: { width: 1, color: '#FACC15' },
|
|
itemStyle: { color: '#FACC15' },
|
|
},
|
|
{
|
|
name: 'RSI14',
|
|
type: 'line',
|
|
data: data.map(d => d.rsi_14 != null ? Number(d.rsi_14) : '-'),
|
|
smooth: true, symbol: 'none', animation: false,
|
|
lineStyle: { width: 1, color: '#3B82F6' },
|
|
itemStyle: { color: '#3B82F6' },
|
|
},
|
|
{
|
|
name: 'RSI24',
|
|
type: 'line',
|
|
data: data.map(d => d.rsi_24 != null ? Number(d.rsi_24) : '-'),
|
|
smooth: true, symbol: 'none', animation: false,
|
|
lineStyle: { width: 1, color: '#8B5CF6' },
|
|
itemStyle: { color: '#8B5CF6' },
|
|
},
|
|
],
|
|
buildInfo: (d) => {
|
|
if (!d) return []
|
|
return [
|
|
{ label: 'RSI6', color: '#FACC15', value: d.rsi_6 != null ? d.rsi_6.toFixed(1) : '—' },
|
|
{ label: 'RSI14', color: '#3B82F6', value: d.rsi_14 != null ? d.rsi_14.toFixed(1) : '—' },
|
|
{ label: 'RSI24', color: '#8B5CF6', value: d.rsi_24 != null ? d.rsi_24.toFixed(1) : '—' },
|
|
]
|
|
},
|
|
},
|
|
{
|
|
key: 'kdj',
|
|
label: 'KDJ',
|
|
height: 72,
|
|
buildSeries: (data) => [
|
|
{
|
|
name: 'K',
|
|
type: 'line',
|
|
data: data.map(d => d.kdj_k != null ? Number(d.kdj_k) : '-'),
|
|
smooth: true, symbol: 'none', animation: false,
|
|
lineStyle: { width: 1, color: '#FACC15' },
|
|
itemStyle: { color: '#FACC15' },
|
|
},
|
|
{
|
|
name: 'D',
|
|
type: 'line',
|
|
data: data.map(d => d.kdj_d != null ? Number(d.kdj_d) : '-'),
|
|
smooth: true, symbol: 'none', animation: false,
|
|
lineStyle: { width: 1, color: '#3B82F6' },
|
|
itemStyle: { color: '#3B82F6' },
|
|
},
|
|
{
|
|
name: 'J',
|
|
type: 'line',
|
|
data: data.map(d => d.kdj_j != null ? Number(d.kdj_j) : '-'),
|
|
smooth: true, symbol: 'none', animation: false,
|
|
lineStyle: { width: 1, color: '#8B5CF6' },
|
|
itemStyle: { color: '#8B5CF6' },
|
|
},
|
|
],
|
|
buildInfo: (d) => {
|
|
if (!d) return []
|
|
return [
|
|
{ label: 'K', color: '#FACC15', value: d.kdj_k != null ? d.kdj_k.toFixed(1) : '—' },
|
|
{ label: 'D', color: '#3B82F6', value: d.kdj_d != null ? d.kdj_d.toFixed(1) : '—' },
|
|
{ label: 'J', color: '#8B5CF6', value: d.kdj_j != null ? d.kdj_j.toFixed(1) : '—' },
|
|
]
|
|
},
|
|
},
|
|
]
|
|
|
|
/** 向后兼容的 INDICATORS 导出 (不含 vol) */
|
|
export const INDICATORS = SUB_CHARTS.filter(s => s.key !== 'vol')
|
|
|
|
/** 主图叠加指标 (画在 K 线上方, 不占副图空间) */
|
|
export const OVERLAY_INDICATORS: { key: string; label: string }[] = [
|
|
{ key: 'boll', label: 'BOLL' },
|
|
]
|
|
|
|
interface Props {
|
|
data: OHLC[]
|
|
markers?: ChartMarker[]
|
|
ranges?: ChartRange[]
|
|
priceLines?: ChartPriceLine[]
|
|
height?: number
|
|
showMA?: boolean
|
|
showInfoBar?: boolean
|
|
showMarkers?: boolean
|
|
onToggleMarkers?: () => void
|
|
stockInfo?: StockInfo
|
|
symbol?: string
|
|
linkedPrice?: number | null
|
|
onDateClick?: (date: string) => void
|
|
/** 默认可见蜡烛根数, 默认 60 */
|
|
visibleBars?: number
|
|
/** 已激活的子图 key 列表 (含 vol, 按点击顺序) */
|
|
activeIndicators?: string[]
|
|
}
|
|
|
|
const THEME = {
|
|
bull: '#C74040',
|
|
bear: '#2D9B65',
|
|
bullAlpha: 'rgba(240,68,56,0.7)',
|
|
bearAlpha: 'rgba(18,183,106,0.7)',
|
|
ma5: '#A1A1AA',
|
|
ma10: '#3B82F6',
|
|
ma20: '#F97316',
|
|
ma60: '#8B5CF6',
|
|
text: '#A1A1AA',
|
|
grid: 'rgba(255,255,255,0.04)',
|
|
border: '#27272A',
|
|
bg: 'transparent',
|
|
}
|
|
|
|
/** 可见蜡烛超过此数量时,涨停/炸板标签切换为小圆点。 */
|
|
const COMPACT_THRESHOLD = 60
|
|
|
|
/** 子图上方信息栏高度 (px) */
|
|
const INFO_BAR_H = 16
|
|
/** 子图之间的间距 (px) */
|
|
const SUB_GAP_PX = 4
|
|
|
|
function buildSubInfoGraphics(
|
|
data: OHLC[],
|
|
infoIdx: number,
|
|
activeIndicators: string[],
|
|
subStartTop: number,
|
|
): any[] {
|
|
const d = infoIdx >= 0 && infoIdx < data.length ? data[infoIdx] : null
|
|
const graphics: any[] = []
|
|
let curTop = subStartTop
|
|
|
|
activeIndicators.forEach((key) => {
|
|
const def = SUB_CHARTS.find(s => s.key === key)
|
|
if (!def) return
|
|
|
|
const items = def.buildInfo(d)
|
|
if (def.key === 'vol' && d) {
|
|
const calcVolMa = (n: number) => {
|
|
if (infoIdx < n - 1) return null
|
|
let sum = 0
|
|
for (let j = infoIdx - n + 1; j <= infoIdx; j++) sum += data[j].volume ?? 0
|
|
return sum / n
|
|
}
|
|
const vol5 = calcVolMa(5)
|
|
const vol10 = calcVolMa(10)
|
|
items.push({ label: 'VOL5', color: '#FACC15', value: fmtVol(vol5) })
|
|
items.push({ label: 'VOL10', color: '#8B5CF6', value: fmtVol(vol10) })
|
|
}
|
|
|
|
// 每个元素加固定 id,确保 ECharts 增量更新时能正确匹配
|
|
graphics.push({
|
|
id: `sub-sep-${key}`,
|
|
type: 'line',
|
|
shape: { x1: 0, y1: curTop, x2: 2000, y2: curTop },
|
|
style: { stroke: 'rgba(255,255,255,0.08)', lineWidth: 1 },
|
|
silent: true, z: 0,
|
|
})
|
|
graphics.push({
|
|
id: `sub-label-${key}`,
|
|
type: 'text',
|
|
style: {
|
|
text: def.label,
|
|
x: 4, y: curTop + 4,
|
|
fill: '#8E8E96',
|
|
fontSize: 10, fontFamily: 'JetBrains Mono, monospace',
|
|
fontWeight: 'bold',
|
|
},
|
|
silent: true, z: 10,
|
|
})
|
|
|
|
const richTextParts: string[] = []
|
|
const rich: Record<string, any> = {}
|
|
items.forEach((item, idx) => {
|
|
const styleKey = `s${idx}`
|
|
richTextParts.push(`{${styleKey}|${item.label}:${item.value}}`)
|
|
rich[styleKey] = {
|
|
fill: item.color,
|
|
fontSize: 10,
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
}
|
|
})
|
|
graphics.push({
|
|
id: `sub-val-${key}`,
|
|
type: 'text',
|
|
right: 24,
|
|
style: {
|
|
text: richTextParts.join(`{gap| }`),
|
|
y: curTop + 3,
|
|
rich: {
|
|
gap: { fill: 'transparent', fontSize: 10 },
|
|
...rich,
|
|
},
|
|
fontSize: 10,
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
textAlign: 'right',
|
|
textVerticalAlign: 'top',
|
|
},
|
|
silent: true, z: 10,
|
|
})
|
|
|
|
curTop += INFO_BAR_H + def.height + SUB_GAP_PX
|
|
})
|
|
|
|
return graphics
|
|
}
|
|
|
|
function buildOption(
|
|
data: OHLC[],
|
|
dates: string[],
|
|
dateIndexMap: Map<string, number>,
|
|
markers: ChartMarker[] | undefined,
|
|
ranges: ChartRange[] | undefined,
|
|
priceLines: ChartPriceLine[] | undefined,
|
|
showMA: boolean,
|
|
compact: boolean,
|
|
activeIndicators: string[],
|
|
containerHeight: number,
|
|
infoIdx: number,
|
|
linkedPrice: number | null | undefined,
|
|
): EChartsOption {
|
|
const candleData = data.map(d => [d.open, d.close, d.low, d.high])
|
|
|
|
const hasMA = showMA && data.some(d => d.ma5 != null || d.ma10 != null || d.ma20 != null || d.ma60 != null)
|
|
|
|
const markPointData: any[] = []
|
|
if (markers && markers.length > 0) {
|
|
for (const m of markers) {
|
|
const idx = dateIndexMap.get(m.date)
|
|
if (idx == null) continue
|
|
const d = data[idx]
|
|
const isBuy = m.kind === 'buy'
|
|
const isSell = m.kind === 'sell'
|
|
|
|
if (m.above) {
|
|
const dotColor = m.color ?? (isBuy ? '#FACC15' : THEME.text)
|
|
if (compact) {
|
|
markPointData.push({
|
|
name: m.date, coord: [m.date, d.high],
|
|
symbol: 'circle', symbolSize: 4, symbolOffset: [0, -10],
|
|
itemStyle: { color: dotColor, cursor: 'pointer' },
|
|
label: { show: false }, z: 100, zlevel: 10,
|
|
})
|
|
} else {
|
|
markPointData.push({
|
|
name: m.date, coord: [m.date, d.high],
|
|
symbol: 'circle', symbolSize: 12, symbolOffset: [0, -2],
|
|
itemStyle: { color: 'transparent' },
|
|
label: {
|
|
show: true, formatter: m.label ?? '', position: 'top', distance: 0,
|
|
color: dotColor, fontSize: 10, fontWeight: 'normal',
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
},
|
|
z: 100, zlevel: 10,
|
|
})
|
|
}
|
|
} else {
|
|
markPointData.push({
|
|
name: m.label ?? '',
|
|
coord: [m.date, isBuy ? d.low : d.high],
|
|
symbol: 'arrow', symbolSize: 12,
|
|
symbolRotate: isBuy ? 0 : 180,
|
|
symbolOffset: isBuy ? [0, '60%'] : [0, '-60%'],
|
|
itemStyle: { color: isBuy ? THEME.bull : isSell ? THEME.bear : THEME.text },
|
|
label: {
|
|
show: !!m.label, formatter: m.label ?? '',
|
|
position: isBuy ? 'bottom' : 'top', distance: 8,
|
|
color: THEME.text, fontSize: 10,
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
},
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// ====== 布局计算 ======
|
|
const left = 60
|
|
const right = 20
|
|
const topPad = 8
|
|
const candleBottomPad = 22
|
|
|
|
let subTotalH = 0
|
|
const activeSubDefs: SubChartDef[] = []
|
|
activeIndicators.forEach(key => {
|
|
const def = SUB_CHARTS.find(s => s.key === key)
|
|
if (!def) return
|
|
activeSubDefs.push(def)
|
|
subTotalH += INFO_BAR_H + def.height
|
|
})
|
|
if (activeSubDefs.length > 0) subTotalH += activeSubDefs.length * SUB_GAP_PX
|
|
|
|
const candleAvail = Math.max(containerHeight - topPad - candleBottomPad - subTotalH, 100)
|
|
|
|
const grids: any[] = []
|
|
const xAxes: any[] = []
|
|
const yAxes: any[] = []
|
|
const series: any[] = []
|
|
const xAxisIndices: number[] = []
|
|
|
|
// ===== grid 0: K线主图 =====
|
|
grids.push({ left, right, top: topPad, height: candleAvail })
|
|
xAxes.push({
|
|
type: 'category', data: dates, boundaryGap: true,
|
|
axisLine: { lineStyle: { color: THEME.border } },
|
|
axisLabel: { color: THEME.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' },
|
|
axisTick: { show: false },
|
|
splitLine: { show: false },
|
|
})
|
|
yAxes.push({
|
|
scale: true,
|
|
// 上下各留 3% 边距: 防止最高/最低点的蜡烛贴边, 涨停/炸板标签被遮挡
|
|
boundaryGap: [0.03, 0.03],
|
|
splitArea: { show: false },
|
|
axisLine: { show: false }, axisTick: { show: false },
|
|
splitLine: { lineStyle: { color: THEME.grid } },
|
|
axisLabel: { color: THEME.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' },
|
|
})
|
|
xAxisIndices.push(0)
|
|
|
|
const markAreaData = (ranges ?? [])
|
|
.filter(r => dateIndexMap.has(r.start) && dateIndexMap.has(r.end))
|
|
.map(r => ([
|
|
{
|
|
name: r.label ?? '',
|
|
xAxis: r.start,
|
|
itemStyle: { color: r.color ?? 'rgba(59,130,246,0.08)' },
|
|
label: {
|
|
show: !!r.label,
|
|
position: 'insideTop',
|
|
distance: 8,
|
|
color: '#DBEAFE',
|
|
backgroundColor: 'rgba(15,23,42,0.72)',
|
|
borderColor: 'rgba(59,130,246,0.35)',
|
|
borderWidth: 1,
|
|
borderRadius: 4,
|
|
padding: [2, 6],
|
|
fontSize: 10,
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
},
|
|
},
|
|
{ xAxis: r.end },
|
|
]))
|
|
|
|
const markLineData: any[] = (priceLines ?? [])
|
|
.filter(line => Number.isFinite(line.value))
|
|
.map(line => {
|
|
const lineStyle = {
|
|
color: line.color ?? THEME.text,
|
|
type: 'dashed' as const,
|
|
width: 1,
|
|
opacity: 0.92,
|
|
}
|
|
const label = {
|
|
show: !!line.label,
|
|
formatter: line.label ?? '',
|
|
position: 'insideEndTop' as const,
|
|
color: line.color ?? THEME.text,
|
|
backgroundColor: 'rgba(15,23,42,0.72)',
|
|
borderRadius: 4,
|
|
padding: [2, 6],
|
|
fontSize: 10,
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
}
|
|
if (line.start && line.end && dateIndexMap.has(line.start) && dateIndexMap.has(line.end)) {
|
|
return [
|
|
{ xAxis: line.start, yAxis: line.value },
|
|
{ xAxis: line.end, yAxis: line.value, lineStyle, label, symbol: 'none' },
|
|
]
|
|
}
|
|
return { yAxis: line.value, lineStyle, label, symbol: 'none' }
|
|
})
|
|
|
|
if (linkedPrice != null) {
|
|
markLineData.push({
|
|
yAxis: linkedPrice,
|
|
lineStyle: { color: '#3B82F6', type: 'dashed', width: 1, opacity: 0.7 },
|
|
label: {
|
|
show: true,
|
|
formatter: linkedPrice.toFixed(2),
|
|
position: 'insideEndTop',
|
|
color: '#3B82F6',
|
|
fontSize: 10,
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
backgroundColor: 'rgba(24,24,27,0.85)',
|
|
borderColor: '#3B82F6',
|
|
borderWidth: 1,
|
|
padding: [1, 4],
|
|
borderRadius: 2,
|
|
},
|
|
symbol: 'none',
|
|
})
|
|
}
|
|
|
|
series.push({
|
|
name: 'K', type: 'candlestick', data: candleData,
|
|
animation: false,
|
|
itemStyle: {
|
|
color: THEME.bull, color0: THEME.bear,
|
|
borderColor: THEME.bull, borderColor0: THEME.bear,
|
|
cursor: 'pointer',
|
|
},
|
|
markPoint: markPointData.length > 0 ? { data: markPointData, animation: false } : undefined,
|
|
markArea: markAreaData.length > 0 ? { silent: true, data: markAreaData } : undefined,
|
|
markLine: markLineData.length > 0 ? { silent: true, symbol: 'none', data: markLineData, animation: false } : undefined,
|
|
})
|
|
|
|
if (hasMA) {
|
|
const maLine = (key: keyof OHLC, color: string, name: string) => ({
|
|
name, type: 'line',
|
|
data: data.map(d => (d[key] != null ? Number(d[key]) : '-')),
|
|
smooth: true, symbol: 'none', animation: false,
|
|
silent: true,
|
|
lineStyle: { width: 1, color }, itemStyle: { color },
|
|
})
|
|
series.push(maLine('ma5', THEME.ma5, 'MA5'))
|
|
series.push(maLine('ma10', THEME.ma10, 'MA10'))
|
|
series.push(maLine('ma20', THEME.ma20, 'MA20'))
|
|
series.push(maLine('ma60', THEME.ma60, 'MA60'))
|
|
}
|
|
|
|
// BOLL 布林带 — 需在 activeIndicators 中激活
|
|
const showBOLL = activeIndicators.includes('boll') && data.some(d => d.boll_upper != null || d.boll_lower != null)
|
|
if (showBOLL) {
|
|
const bollLine = (key: keyof OHLC, color: string, name: string) => ({
|
|
name, type: 'line',
|
|
data: data.map(d => (d[key] != null ? Number(d[key]) : '-')),
|
|
smooth: true, symbol: 'none', animation: false,
|
|
silent: true,
|
|
lineStyle: { width: 1, color, type: 'dashed' as const }, itemStyle: { color },
|
|
})
|
|
series.push(bollLine('boll_upper', '#E879F9', 'BOLL上'))
|
|
series.push(bollLine('boll_lower', '#E879F9', 'BOLL下'))
|
|
}
|
|
|
|
// ===== 子图区域 =====
|
|
let curTop = topPad + candleAvail + candleBottomPad
|
|
|
|
activeSubDefs.forEach((def, i) => {
|
|
const gridIdx = i + 1
|
|
const xAxisIdx = i + 1
|
|
const yAxisIdx = i + 1
|
|
|
|
const chartTop = curTop + INFO_BAR_H
|
|
grids.push({
|
|
left, right,
|
|
top: chartTop,
|
|
height: def.height,
|
|
show: true,
|
|
borderColor: 'rgba(255,255,255,0.06)',
|
|
borderWidth: 1,
|
|
})
|
|
|
|
xAxes.push({
|
|
type: 'category', gridIndex: gridIdx, data: dates, boundaryGap: true,
|
|
axisLine: { show: false }, axisLabel: { show: false },
|
|
axisTick: { show: false }, splitLine: { show: false },
|
|
axisPointer: { label: { show: false } },
|
|
})
|
|
|
|
const isFixedRange = !!def.yAxisConfig
|
|
yAxes.push({
|
|
scale: !isFixedRange,
|
|
...(isFixedRange ? def.yAxisConfig : {}),
|
|
gridIndex: gridIdx,
|
|
splitNumber: 2,
|
|
axisLine: { show: false }, axisTick: { show: false },
|
|
splitLine: { lineStyle: { color: THEME.grid } },
|
|
axisLabel: {
|
|
show: true, color: THEME.text, fontSize: 9,
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
},
|
|
})
|
|
|
|
xAxisIndices.push(xAxisIdx)
|
|
|
|
const subSeries = def.buildSeries(data)
|
|
subSeries.forEach((s: any) => {
|
|
series.push({ ...s, xAxisIndex: xAxisIdx, yAxisIndex: yAxisIdx })
|
|
})
|
|
|
|
curTop += INFO_BAR_H + def.height + SUB_GAP_PX
|
|
})
|
|
|
|
// 子图信息栏 graphic
|
|
const subStartTop = topPad + candleAvail + candleBottomPad
|
|
const infoGraphics = buildSubInfoGraphics(data, infoIdx, activeIndicators, subStartTop)
|
|
|
|
return {
|
|
animation: false,
|
|
backgroundColor: THEME.bg,
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
axisPointer: { type: 'cross', crossStyle: { color: '#555' } },
|
|
backgroundColor: 'transparent',
|
|
borderWidth: 0,
|
|
textStyle: { fontSize: 0 },
|
|
formatter: () => '',
|
|
},
|
|
axisPointer: {
|
|
link: [{ xAxisIndex: 'all' }],
|
|
label: {
|
|
backgroundColor: '#333',
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
fontSize: 10,
|
|
},
|
|
},
|
|
graphic: infoGraphics.length > 0 ? infoGraphics : undefined,
|
|
grid: grids,
|
|
xAxis: xAxes,
|
|
yAxis: yAxes,
|
|
dataZoom: [
|
|
{
|
|
type: 'inside',
|
|
xAxisIndex: xAxisIndices,
|
|
start: 0,
|
|
end: 100,
|
|
moveOnMouseMove: true,
|
|
zoomOnMouseWheel: true,
|
|
},
|
|
],
|
|
series,
|
|
}
|
|
}
|
|
|
|
|
|
export function EChartsCandlestick({
|
|
data,
|
|
markers,
|
|
ranges,
|
|
priceLines,
|
|
height = 480,
|
|
showMA = true,
|
|
showInfoBar = true,
|
|
showMarkers: showMarkersProp = true,
|
|
onToggleMarkers: _onToggleMarkers,
|
|
stockInfo,
|
|
symbol: _symbol,
|
|
linkedPrice,
|
|
onDateClick,
|
|
visibleBars = 60,
|
|
activeIndicators = [],
|
|
}: Props) {
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const chartRef = useRef<ECharts | null>(null)
|
|
const dataRef = useRef(data)
|
|
dataRef.current = data
|
|
const onDateClickRef = useRef(onDateClick)
|
|
onDateClickRef.current = onDateClick
|
|
|
|
// --- 全部用 ref,避免高频交互触发 React 重渲染 ---
|
|
const infoIdxRef = useRef<number>(data.length - 1)
|
|
const compactRef = useRef(false)
|
|
const userZoomRef = useRef<{ start: number; end: number } | null>(null)
|
|
|
|
// 需要在闭包中访问最新值的变量 — 先声明占位,后面赋值
|
|
const activeIndicatorsRef = useRef(activeIndicators)
|
|
activeIndicatorsRef.current = activeIndicators
|
|
const chartHeightRef = useRef(300)
|
|
const subTotalHRef = useRef(0)
|
|
const getInfoBarHTMLRef = useRef<() => string>(() => '')
|
|
|
|
// 强制刷新信息栏 DOM 的回调
|
|
const infoBarRef = useRef<HTMLDivElement>(null)
|
|
const triggerInfoBarUpdate = useRef(() => {
|
|
const idx = infoIdxRef.current
|
|
const curData = dataRef.current
|
|
const d = idx >= 0 && idx < curData.length ? curData[idx] : null
|
|
if (!d) return
|
|
const chart = chartRef.current
|
|
if (!chart) return
|
|
const subStartTop = chartHeightRef.current - subTotalHRef.current
|
|
const infoGraphics = buildSubInfoGraphics(curData, idx, activeIndicatorsRef.current, subStartTop)
|
|
if (infoGraphics.length > 0) {
|
|
chart.setOption({ graphic: infoGraphics }, { lazyUpdate: true })
|
|
}
|
|
}).current
|
|
|
|
// 计算子图总高度
|
|
const activeSubDefs = activeIndicators
|
|
.map(key => SUB_CHARTS.find(s => s.key === key))
|
|
.filter((d): d is SubChartDef => !!d)
|
|
|
|
let subTotalH = 0
|
|
activeSubDefs.forEach(def => { subTotalH += INFO_BAR_H + def.height })
|
|
if (activeSubDefs.length > 0) subTotalH += activeSubDefs.length * SUB_GAP_PX
|
|
|
|
const mainInfoBarH = showInfoBar ? 40 : 0
|
|
const minCandleH = 120
|
|
|
|
const chartHeight = Math.max(height - mainInfoBarH, 8 + minCandleH + 14 + subTotalH)
|
|
chartHeightRef.current = chartHeight
|
|
subTotalHRef.current = subTotalH
|
|
|
|
// 预计算 date→index Map (O(1) 查找)
|
|
const dates = useMemo(() => data.map(d => d.date), [data])
|
|
const dateIndexMap = useMemo(() => {
|
|
const m = new Map<string, number>()
|
|
dates.forEach((d, i) => m.set(d, i))
|
|
return m
|
|
}, [dates])
|
|
|
|
// 计算 dataZoom 初始范围
|
|
const initialZoom = useMemo(() => ({
|
|
start: Math.max(0, 100 - (visibleBars / Math.max(data.length, 1)) * 100),
|
|
end: 100,
|
|
}), [visibleBars, data.length])
|
|
|
|
// ===== 信息栏 HTML 内容 (基于 infoIdxRef.current) =====
|
|
const getInfoBarHTML = useCallback(() => {
|
|
let idx = infoIdxRef.current
|
|
let d = idx >= 0 && idx < data.length ? data[idx] : null
|
|
// fallback: 如果当前 idx 无数据,取最后一根 K 线
|
|
if (!d && data.length > 0) {
|
|
idx = data.length - 1
|
|
d = data[idx]
|
|
}
|
|
if (!d) return ''
|
|
const prev = idx > 0 ? data[idx - 1] : null
|
|
const chg = prev ? d.close - prev.close : 0
|
|
const isUp = chg >= 0
|
|
const clr = isUp ? THEME.bull : THEME.bear
|
|
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">`
|
|
html += `<span style="color:${THEME.text}">${d.date}</span>`
|
|
html += `<span style="color:${THEME.text}">开</span>`
|
|
html += `<span style="color:${d.open >= d.close ? THEME.bear : THEME.bull}">${d.open.toFixed(2)}</span>`
|
|
html += `<span style="color:${THEME.text}">高</span>`
|
|
html += `<span style="color:${THEME.bull}">${d.high.toFixed(2)}</span>`
|
|
html += `<span style="color:${THEME.text}">低</span>`
|
|
html += `<span style="color:${THEME.bear}">${d.low.toFixed(2)}</span>`
|
|
html += `<span style="color:${THEME.text}">收</span>`
|
|
html += `<span style="color:${clr};font-weight:600">${d.close.toFixed(2)}</span>`
|
|
// 涨跌幅 (收盘后, 换手前; 和收间隔一些距离)
|
|
if (prev) {
|
|
const chgPct = (chg / prev.close * 100)
|
|
html += `<span style="color:${clr};margin-left:8px">${isUp ? '+' : ''}${chgPct.toFixed(2)}%</span>`
|
|
}
|
|
if (turnoverRate != null) {
|
|
html += `<span style="color:${THEME.text}">换手</span>`
|
|
html += `<span style="color:${THEME.text}">${turnoverRate.toFixed(2)}%</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">`
|
|
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>`
|
|
if (d.ma60 != null) html += `<span style="color:${THEME.ma60}">MA60:${Number(d.ma60).toFixed(2)}</span>`
|
|
if (d.boll_upper != null && activeIndicators.includes('boll')) {
|
|
html += `<span style="color:#E879F9">BOLL:${Number(d.boll_upper).toFixed(2)}/${Number(d.ma20).toFixed(2)}/${Number(d.boll_lower).toFixed(2)}</span>`
|
|
}
|
|
html += `</div>`
|
|
}
|
|
|
|
return html
|
|
}, [data, stockInfo, showMA, activeIndicators])
|
|
getInfoBarHTMLRef.current = getInfoBarHTML
|
|
|
|
// data 变化时重置 infoIdx
|
|
useEffect(() => {
|
|
infoIdxRef.current = data.length - 1
|
|
compactRef.current = false
|
|
userZoomRef.current = null
|
|
}, [data.length])
|
|
|
|
// ===== 初始化 chart (只在 chartHeight 变化时重建) =====
|
|
useEffect(() => {
|
|
const el = containerRef.current
|
|
if (!el) return
|
|
|
|
const chart = echarts.init(el, undefined, { renderer: 'canvas' })
|
|
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
|
|
}
|
|
}
|
|
// 没有找到有效数据 — 不做任何操作,保持上次显示
|
|
})
|
|
|
|
chart.on('click', (params: any) => {
|
|
if (params.componentType === 'markPoint' && params.name) {
|
|
onDateClickRef.current?.(params.name)
|
|
return
|
|
}
|
|
if (params.seriesName !== 'K' || params.dataIndex == null) return
|
|
const d = dataRef.current
|
|
const idx = params.dataIndex
|
|
if (idx >= 0 && idx < d.length) {
|
|
onDateClickRef.current?.(d[idx].date)
|
|
}
|
|
})
|
|
|
|
// dataZoom → 只更新 ref,不触发 React re-render
|
|
// compact 变化时需要增量更新 markPoint
|
|
chart.on('dataZoom', () => {
|
|
const opt = chart.getOption() as any
|
|
const zoom = opt?.dataZoom?.[0]
|
|
if (!zoom) return
|
|
userZoomRef.current = { start: zoom.start, end: zoom.end }
|
|
|
|
const d = dataRef.current
|
|
const total = d.length
|
|
const visibleCount = Math.round(total * (zoom.end - zoom.start) / 100)
|
|
const newCompact = visibleCount > COMPACT_THRESHOLD
|
|
if (newCompact !== compactRef.current) {
|
|
compactRef.current = newCompact
|
|
// compact 变了需要更新 markPoint,但只更新 markPoint series
|
|
// 通过 dispatch 自定义事件来增量更新
|
|
updateMarkPoints()
|
|
}
|
|
})
|
|
|
|
const ro = new ResizeObserver(() => { chart.resize() })
|
|
ro.observe(el)
|
|
|
|
return () => {
|
|
chart.off('updateAxisPointer')
|
|
chart.off('click')
|
|
chart.off('dataZoom')
|
|
ro.disconnect()
|
|
chart.dispose()
|
|
chartRef.current = null
|
|
}
|
|
}, [chartHeight]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// 增量更新 markPoint (compact 切换时)
|
|
function updateMarkPoints() {
|
|
const chart = chartRef.current
|
|
if (!chart) return
|
|
const mkrs = showMarkersProp ? markers : undefined
|
|
if (!mkrs || mkrs.length === 0) return
|
|
const compact = compactRef.current
|
|
const markPointData: any[] = []
|
|
for (const m of mkrs) {
|
|
const idx = dateIndexMap.get(m.date)
|
|
if (idx == null) continue
|
|
const d = data[idx]
|
|
const isBuy = m.kind === 'buy'
|
|
const isSell = m.kind === 'sell'
|
|
if (m.above) {
|
|
const dotColor = m.color ?? (isBuy ? '#FACC15' : THEME.text)
|
|
if (compact) {
|
|
markPointData.push({
|
|
name: m.date, coord: [m.date, d.high],
|
|
symbol: 'circle', symbolSize: 4, symbolOffset: [0, -10],
|
|
itemStyle: { color: dotColor, cursor: 'pointer' },
|
|
label: { show: false }, z: 100, zlevel: 10,
|
|
})
|
|
} else {
|
|
markPointData.push({
|
|
name: m.date, coord: [m.date, d.high],
|
|
symbol: 'circle', symbolSize: 12, symbolOffset: [0, -2],
|
|
itemStyle: { color: 'transparent' },
|
|
label: {
|
|
show: true, formatter: m.label ?? '', position: 'top', distance: 0,
|
|
color: dotColor, fontSize: 10, fontWeight: 'normal',
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
},
|
|
z: 100, zlevel: 10,
|
|
})
|
|
}
|
|
} else {
|
|
markPointData.push({
|
|
name: m.label ?? '',
|
|
coord: [m.date, isBuy ? d.low : d.high],
|
|
symbol: 'arrow', symbolSize: 12,
|
|
symbolRotate: isBuy ? 0 : 180,
|
|
symbolOffset: isBuy ? [0, '60%'] : [0, '-60%'],
|
|
itemStyle: { color: isBuy ? THEME.bull : isSell ? THEME.bear : THEME.text },
|
|
label: {
|
|
show: !!m.label, formatter: m.label ?? '',
|
|
position: isBuy ? 'bottom' : 'top', distance: 8,
|
|
color: THEME.text, fontSize: 10,
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
},
|
|
})
|
|
}
|
|
}
|
|
chart.setOption({
|
|
series: [{
|
|
name: 'K',
|
|
markPoint: markPointData.length > 0 ? { data: markPointData, animation: false } : undefined,
|
|
}]
|
|
})
|
|
}
|
|
|
|
// ===== 核心: 仅在数据/配置变更时全量 setOption =====
|
|
useEffect(() => {
|
|
const chart = chartRef.current
|
|
if (!chart) return
|
|
|
|
const option = buildOption(
|
|
data, dates, dateIndexMap,
|
|
showMarkersProp ? markers : undefined,
|
|
ranges,
|
|
priceLines,
|
|
showMA, compactRef.current,
|
|
activeIndicators, chartHeight,
|
|
infoIdxRef.current,
|
|
linkedPrice,
|
|
)
|
|
|
|
chart.setOption(option, true)
|
|
|
|
// 恢复用户缩放位置
|
|
const zoom = userZoomRef.current
|
|
if (zoom) {
|
|
chart.dispatchAction({ type: 'dataZoom', start: zoom.start, end: zoom.end })
|
|
} else {
|
|
chart.dispatchAction({ type: 'dataZoom', start: initialZoom.start, end: initialZoom.end })
|
|
}
|
|
|
|
// 初始信息栏
|
|
const infoEl = infoBarRef.current
|
|
if (infoEl) {
|
|
infoEl.innerHTML = getInfoBarHTML()
|
|
}
|
|
}, [data, markers, ranges, priceLines, linkedPrice, showMA, showMarkersProp, activeIndicators, chartHeight, dates, dateIndexMap, initialZoom, getInfoBarHTML])
|
|
|
|
// 渲染信息栏容器 (内容由 JS 直接写入)
|
|
const initialHTML = useMemo(() => {
|
|
const idx = data.length - 1
|
|
const d = idx >= 0 && idx < data.length ? data[idx] : null
|
|
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">`
|
|
html += `<span style="color:${THEME.text}">${d.date}</span>`
|
|
html += `<span style="color:${THEME.text}">开</span>`
|
|
html += `<span style="color:${d.open >= d.close ? THEME.bear : THEME.bull}">${d.open.toFixed(2)}</span>`
|
|
html += `<span style="color:${THEME.text}">高</span>`
|
|
html += `<span style="color:${THEME.bull}">${d.high.toFixed(2)}</span>`
|
|
html += `<span style="color:${THEME.text}">低</span>`
|
|
html += `<span style="color:${THEME.bear}">${d.low.toFixed(2)}</span>`
|
|
html += `<span style="color:${THEME.text}">收</span>`
|
|
const prevClose0 = data[idx-1]?.close ?? d.close
|
|
const clr0 = d.close >= prevClose0 ? THEME.bull : THEME.bear
|
|
html += `<span style="color:${clr0};font-weight:600">${d.close.toFixed(2)}</span>`
|
|
// 涨跌幅 (收盘后, 换手前; 和收间隔一些距离)
|
|
if (idx > 0) {
|
|
const chgPct0 = ((d.close - prevClose0) / prevClose0 * 100)
|
|
html += `<span style="color:${clr0};margin-left:8px">${chgPct0 >= 0 ? '+' : ''}${chgPct0.toFixed(2)}%</span>`
|
|
}
|
|
if (turnoverRate != null) {
|
|
html += `<span style="color:${THEME.text}">换手</span>`
|
|
html += `<span style="color:${THEME.text}">${turnoverRate.toFixed(2)}%</span>`
|
|
}
|
|
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">`
|
|
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>`
|
|
if (d.ma60 != null) html += `<span style="color:${THEME.ma60}">MA60:${Number(d.ma60).toFixed(2)}</span>`
|
|
if (d.boll_upper != null && activeIndicators.includes('boll')) {
|
|
html += `<span style="color:#E879F9">BOLL:${Number(d.boll_upper).toFixed(2)}/${Number(d.ma20).toFixed(2)}/${Number(d.boll_lower).toFixed(2)}</span>`
|
|
}
|
|
html += `</div>`
|
|
}
|
|
return html
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
return (
|
|
<div className="w-full">
|
|
{/* 主图信息栏 — 内容由 JS 直接操作 innerHTML */}
|
|
{showInfoBar && (
|
|
<div ref={infoBarRef} style={{ backgroundColor: 'rgba(39,39,42,0.6)' }}
|
|
dangerouslySetInnerHTML={{ __html: initialHTML }} />
|
|
)}
|
|
|
|
{/* ECharts canvas */}
|
|
<div ref={containerRef} className="w-full" style={{ height: chartHeight }} />
|
|
</div>
|
|
)
|
|
}
|