- 按确认方案「两套实现并存对比」:
- npm 依赖 lightweight-charts@4.2.3(v3 §21 推荐库)
- components/StockChart/chartTypes.ts:共享 props 接口
- CandleChart.tsx(ECharts 版,重构复用共享类型)+ CandleChartLW.tsx
(Lightweight 版:蜡烛+成交量副刻度+MA+成交实心箭头/未成交信号圆点 markers)
- StockChart.tsx wrapper;个股页顶部「ECharts / Lightweight」切换(默认 Lightweight)
- /stocks/600519.SH 冒烟 200;tsc --noEmit 通过
201 lines
6.2 KiB
TypeScript
201 lines
6.2 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* K 线图(v3 §20 Chart):蜡烛图 + 成交量 + MA 指标 + 事件标记。
|
||
*
|
||
* 标记语义(v3 §20.3 Signal↔Fill 严格区分):
|
||
* - fills(实际成交):实心标记 —— fill_buy 红色 ▲ / fill_sell 绿色 ▼
|
||
* - signals(未成交意图):空心标记 —— signal_buy 红圈 ▲ / signal_sell 绿圈
|
||
* 组件只做展示,绝不自行计算(数据来自后端 Chart API)。
|
||
*
|
||
* 基于 ECharts candlestick(ECharts 实现);另一套实现见 CandleChartLW.tsx(TradingView
|
||
* Lightweight Charts),两套共用同一 props 接口(chartTypes.ts)供对比。
|
||
*/
|
||
import { useEffect, useRef } from "react";
|
||
import * as echarts from "echarts/core";
|
||
import { BarChart, CandlestickChart, LineChart, ScatterChart } from "echarts/charts";
|
||
import { GridComponent, TooltipComponent } from "echarts/components";
|
||
import { CanvasRenderer } from "echarts/renderers";
|
||
import type { ChartMarker } from "@/lib/types";
|
||
import type { CandleChartProps } from "./chartTypes";
|
||
export type { CandleDatum } from "./chartTypes";
|
||
|
||
echarts.use([
|
||
CandlestickChart,
|
||
LineChart,
|
||
BarChart,
|
||
ScatterChart,
|
||
GridComponent,
|
||
TooltipComponent,
|
||
CanvasRenderer,
|
||
]);
|
||
|
||
const C = {
|
||
faint: "#8b98b2",
|
||
line: "rgba(150,165,195,0.13)",
|
||
tooltipBg: "#141d30",
|
||
tooltipBorder: "rgba(150,165,195,0.28)",
|
||
up: "#e5484d",
|
||
down: "#2fb36b",
|
||
ma20: "#3fb6ff",
|
||
ma60: "#d4a72c",
|
||
};
|
||
|
||
export function CandleChart({
|
||
data,
|
||
volume = [],
|
||
indicators = {},
|
||
fills = [],
|
||
signals = [],
|
||
height = 480,
|
||
name = "",
|
||
}: CandleChartProps) {
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (!ref.current || data.length === 0) return;
|
||
const chart = echarts.init(ref.current);
|
||
const dates = data.map((d) => d.time);
|
||
const vol = volume ?? [];
|
||
|
||
// echarts option 结构较宽,series 用宽松类型(组件层实现细节)
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const series: any[] = [
|
||
{
|
||
name: "K线",
|
||
type: "candlestick",
|
||
data: data.map((d) => [d.open, d.close, d.low, d.high]),
|
||
itemStyle: {
|
||
color: C.up, // 阳线(收盘>开盘)红涨
|
||
color0: C.down,
|
||
borderColor: C.up,
|
||
borderColor0: C.down,
|
||
},
|
||
},
|
||
];
|
||
for (const [name, pts] of Object.entries(indicators)) {
|
||
const vals = dates.map((t) => {
|
||
const p = pts.find((x) => x.time === t);
|
||
return p?.value ?? null;
|
||
});
|
||
series.push({
|
||
name,
|
||
type: "line",
|
||
data: vals,
|
||
smooth: false,
|
||
showSymbol: false,
|
||
lineStyle: { width: 1.2 },
|
||
itemStyle: { color: C[name as keyof typeof C] ?? "#b98bff" },
|
||
connectNulls: true,
|
||
});
|
||
}
|
||
// 成交标记(实心)与未成交信号(空心):以散点叠加在价格坐标
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const mk = (m: ChartMarker, filled: boolean): any => {
|
||
const buy = m.kind.endsWith("buy");
|
||
return {
|
||
value: [m.time, m.price ?? 0],
|
||
symbol: filled ? (buy ? "triangle" : "triangle") : buy ? "triangle" : "triangle",
|
||
symbolRotate: buy ? 0 : 180,
|
||
symbolSize: 13,
|
||
itemStyle: {
|
||
color: filled ? (buy ? C.up : C.down) : "transparent",
|
||
borderColor: buy ? C.up : C.down,
|
||
borderWidth: 1.6,
|
||
},
|
||
tooltip: { formatter: m.text.join("<br/>") },
|
||
};
|
||
};
|
||
series.push(
|
||
{ name: "成交", type: "scatter", data: fills.map((m) => mk(m, true)), z: 6 },
|
||
{ name: "信号(未成交)", type: "scatter", data: signals.map((m) => mk(m, false)), z: 5 }
|
||
);
|
||
|
||
const hasVol = vol.length > 0;
|
||
const mainGridBottom = hasVol ? 58 : 14;
|
||
series.push({
|
||
name: "成交量",
|
||
type: "bar",
|
||
xAxisIndex: 1,
|
||
yAxisIndex: 1,
|
||
data: dates.map((t) => {
|
||
const p = vol.find((x) => x.time === t);
|
||
return p?.value ?? 0;
|
||
}),
|
||
itemStyle: { color: "rgba(150,165,195,0.35)" },
|
||
});
|
||
|
||
chart.setOption({
|
||
backgroundColor: "transparent",
|
||
tooltip: {
|
||
trigger: "axis",
|
||
axisPointer: { type: "cross" },
|
||
backgroundColor: C.tooltipBg,
|
||
borderColor: C.tooltipBorder,
|
||
textStyle: { color: "#e9eef7", fontSize: 12 },
|
||
},
|
||
axisPointer: { link: [{ xAxisIndex: "all" }] },
|
||
grid: [
|
||
{ left: 8, right: 18, top: 16, height: hasVol ? "62%" : "82%", containLabel: true },
|
||
...(hasVol
|
||
? [{ left: 8, right: 18, top: hasVol ? "76%" : undefined, height: "14%", containLabel: true }]
|
||
: []),
|
||
],
|
||
xAxis: [
|
||
{
|
||
type: "category",
|
||
data: dates,
|
||
boundaryGap: true,
|
||
axisLine: { lineStyle: { color: C.line } },
|
||
axisTick: { show: false },
|
||
axisLabel: { color: C.faint, fontSize: 10 },
|
||
splitLine: { show: false },
|
||
},
|
||
...(hasVol
|
||
? [{
|
||
type: "category",
|
||
gridIndex: 1,
|
||
data: dates,
|
||
boundaryGap: true,
|
||
axisLine: { lineStyle: { color: C.line } },
|
||
axisTick: { show: false },
|
||
axisLabel: { show: false },
|
||
splitLine: { show: false },
|
||
}]
|
||
: []),
|
||
],
|
||
yAxis: [
|
||
{
|
||
type: "value",
|
||
scale: true,
|
||
splitNumber: 4,
|
||
axisLabel: { color: C.faint, fontSize: 10 },
|
||
splitLine: { lineStyle: { color: C.line } },
|
||
},
|
||
...(hasVol
|
||
? [{
|
||
type: "value",
|
||
gridIndex: 1,
|
||
scale: true,
|
||
axisLabel: { color: C.faint, fontSize: 9 },
|
||
splitLine: { show: false },
|
||
}]
|
||
: []),
|
||
],
|
||
series,
|
||
legend: {
|
||
show: false,
|
||
},
|
||
});
|
||
const onResize = () => chart.resize();
|
||
window.addEventListener("resize", onResize);
|
||
return () => {
|
||
window.removeEventListener("resize", onResize);
|
||
chart.dispose();
|
||
};
|
||
}, [data, volume, indicators, fills, signals, height]);
|
||
|
||
if (data.length === 0) return <div className="hint">暂无 K 线数据</div>;
|
||
return <div ref={ref} style={{ height, width: "100%" }} aria-label={`${name} K 线图`} />;
|
||
}
|