feat(web): K线图库对比 —— TradingView Lightweight Charts 实现 + 双库切换
- 按确认方案「两套实现并存对比」:
- 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 通过
This commit is contained in:
@@ -18,7 +18,8 @@ import {
|
|||||||
Empty,
|
Empty,
|
||||||
SkeletonLines,
|
SkeletonLines,
|
||||||
} from "@/components/ui";
|
} from "@/components/ui";
|
||||||
import { CandleChart, type CandleDatum } from "@/components/StockChart/CandleChart";
|
import type { CandleDatum } from "@/components/StockChart/chartTypes";
|
||||||
|
import { StockChart, type ChartLibrary } from "@/components/StockChart/StockChart";
|
||||||
|
|
||||||
export default function StockResearchPage() {
|
export default function StockResearchPage() {
|
||||||
const params = useParams<{ symbol: string }>();
|
const params = useParams<{ symbol: string }>();
|
||||||
@@ -28,6 +29,7 @@ export default function StockResearchPage() {
|
|||||||
const [signalMarkers, setSignalMarkers] = useState<ChartMarker[]>([]);
|
const [signalMarkers, setSignalMarkers] = useState<ChartMarker[]>([]);
|
||||||
const [selectionMarkers, setSelectionMarkers] = useState<ChartMarker[]>([]);
|
const [selectionMarkers, setSelectionMarkers] = useState<ChartMarker[]>([]);
|
||||||
const [adjust, setAdjust] = useState<"none" | "qfq" | "hfq">("none");
|
const [adjust, setAdjust] = useState<"none" | "qfq" | "hfq">("none");
|
||||||
|
const [chartLib, setChartLib] = useState<ChartLibrary>("lightweight");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
@@ -80,6 +82,11 @@ export default function StockResearchPage() {
|
|||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<div className="row" style={{ gap: 6 }}>
|
<div className="row" style={{ gap: 6 }}>
|
||||||
|
{(["echarts", "lightweight"] as const).map((l) => (
|
||||||
|
<Btn key={l} variant={chartLib === l ? "primary" : undefined} onClick={() => setChartLib(l)}>
|
||||||
|
{l === "echarts" ? "ECharts" : "Lightweight"}
|
||||||
|
</Btn>
|
||||||
|
))}
|
||||||
{(["none", "qfq", "hfq"] as const).map((m) => (
|
{(["none", "qfq", "hfq"] as const).map((m) => (
|
||||||
<Btn
|
<Btn
|
||||||
key={m}
|
key={m}
|
||||||
@@ -113,7 +120,8 @@ export default function StockResearchPage() {
|
|||||||
{candles.length === 0 ? (
|
{candles.length === 0 ? (
|
||||||
<Empty icon="chartLine" title="无 K 线数据" hint="该区间/口径没有行情(可能停牌或未同步)。" />
|
<Empty icon="chartLine" title="无 K 线数据" hint="该区间/口径没有行情(可能停牌或未同步)。" />
|
||||||
) : (
|
) : (
|
||||||
<CandleChart
|
<StockChart
|
||||||
|
library={chartLib}
|
||||||
data={candles}
|
data={candles}
|
||||||
volume={chart?.volume ?? []}
|
volume={chart?.volume ?? []}
|
||||||
indicators={chart?.indicators ?? {}}
|
indicators={chart?.indicators ?? {}}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
* - signals(未成交意图):空心标记 —— signal_buy 红圈 ▲ / signal_sell 绿圈
|
* - signals(未成交意图):空心标记 —— signal_buy 红圈 ▲ / signal_sell 绿圈
|
||||||
* 组件只做展示,绝不自行计算(数据来自后端 Chart API)。
|
* 组件只做展示,绝不自行计算(数据来自后端 Chart API)。
|
||||||
*
|
*
|
||||||
* 基于 ECharts candlestick;组件层隔离,便于后续替换 TradingView Lightweight Charts。
|
* 基于 ECharts candlestick(ECharts 实现);另一套实现见 CandleChartLW.tsx(TradingView
|
||||||
|
* Lightweight Charts),两套共用同一 props 接口(chartTypes.ts)供对比。
|
||||||
*/
|
*/
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import * as echarts from "echarts/core";
|
import * as echarts from "echarts/core";
|
||||||
@@ -16,6 +17,8 @@ import { BarChart, CandlestickChart, LineChart, ScatterChart } from "echarts/cha
|
|||||||
import { GridComponent, TooltipComponent } from "echarts/components";
|
import { GridComponent, TooltipComponent } from "echarts/components";
|
||||||
import { CanvasRenderer } from "echarts/renderers";
|
import { CanvasRenderer } from "echarts/renderers";
|
||||||
import type { ChartMarker } from "@/lib/types";
|
import type { ChartMarker } from "@/lib/types";
|
||||||
|
import type { CandleChartProps } from "./chartTypes";
|
||||||
|
export type { CandleDatum } from "./chartTypes";
|
||||||
|
|
||||||
echarts.use([
|
echarts.use([
|
||||||
CandlestickChart,
|
CandlestickChart,
|
||||||
@@ -38,31 +41,15 @@ const C = {
|
|||||||
ma60: "#d4a72c",
|
ma60: "#d4a72c",
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface CandleDatum {
|
|
||||||
time: string;
|
|
||||||
open: number;
|
|
||||||
close: number;
|
|
||||||
low: number;
|
|
||||||
high: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CandleChart({
|
export function CandleChart({
|
||||||
data,
|
data,
|
||||||
volume,
|
volume = [],
|
||||||
indicators = {},
|
indicators = {},
|
||||||
fills = [],
|
fills = [],
|
||||||
signals = [],
|
signals = [],
|
||||||
height = 480,
|
height = 480,
|
||||||
name = "",
|
name = "",
|
||||||
}: {
|
}: CandleChartProps) {
|
||||||
data: CandleDatum[];
|
|
||||||
volume?: { time: string; value: number | null }[];
|
|
||||||
indicators?: Record<string, { time: string; value: number | null }[]>;
|
|
||||||
fills?: ChartMarker[];
|
|
||||||
signals?: ChartMarker[];
|
|
||||||
height?: number;
|
|
||||||
name?: string;
|
|
||||||
}) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* K 线图 —— TradingView Lightweight Charts 实现(v3 §21 推荐库)。
|
||||||
|
*
|
||||||
|
* 与 ECharts 版(CandleChart.tsx)共用同一 props 接口(chartTypes.ts),
|
||||||
|
* 供「两套实现并存对比」,组件层可整体替换。
|
||||||
|
* 标记语义(v3 §20.3):成交 fills = 实心箭头;未成交信号 = 空心圆点。
|
||||||
|
* 组件只展示,不重算。
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
createChart,
|
||||||
|
ColorType,
|
||||||
|
CrosshairMode,
|
||||||
|
type IChartApi,
|
||||||
|
type ISeriesApi,
|
||||||
|
type SeriesMarker,
|
||||||
|
type Time,
|
||||||
|
} from "lightweight-charts";
|
||||||
|
import type { CandleChartProps } from "./chartTypes";
|
||||||
|
|
||||||
|
const C = {
|
||||||
|
faint: "#8b98b2",
|
||||||
|
grid: "rgba(150,165,195,0.09)",
|
||||||
|
up: "#e5484d",
|
||||||
|
down: "#2fb36b",
|
||||||
|
ma20: "#3fb6ff",
|
||||||
|
ma60: "#d4a72c",
|
||||||
|
text: "#e9eef7",
|
||||||
|
markerBuy: "#3ddc97",
|
||||||
|
markerSell: "#ff5a5a",
|
||||||
|
markerSig: "#b98bff",
|
||||||
|
};
|
||||||
|
|
||||||
|
function toTime(t: string): Time {
|
||||||
|
return t as Time; // 'YYYY-MM-DD'(v4 支持字符串 business day)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CandleChartLW({
|
||||||
|
data,
|
||||||
|
volume = [],
|
||||||
|
indicators = {},
|
||||||
|
fills = [],
|
||||||
|
signals = [],
|
||||||
|
height = 480,
|
||||||
|
name = "",
|
||||||
|
}: CandleChartProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current || data.length === 0) return;
|
||||||
|
const chart: IChartApi = createChart(ref.current, {
|
||||||
|
autoSize: true,
|
||||||
|
height,
|
||||||
|
layout: {
|
||||||
|
background: { type: ColorType.Solid, color: "transparent" },
|
||||||
|
textColor: C.text,
|
||||||
|
fontSize: 11,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
vertLines: { color: C.grid },
|
||||||
|
horzLines: { color: C.grid },
|
||||||
|
},
|
||||||
|
rightPriceScale: { borderColor: C.grid },
|
||||||
|
timeScale: { borderColor: C.grid, timeVisible: false },
|
||||||
|
crosshair: { mode: CrosshairMode.Normal },
|
||||||
|
});
|
||||||
|
|
||||||
|
const candle: ISeriesApi<"Candlestick"> = chart.addCandlestickSeries({
|
||||||
|
upColor: C.up,
|
||||||
|
downColor: C.down,
|
||||||
|
borderUpColor: C.up,
|
||||||
|
borderDownColor: C.down,
|
||||||
|
wickUpColor: C.up,
|
||||||
|
wickDownColor: C.down,
|
||||||
|
});
|
||||||
|
candle.setData(
|
||||||
|
data.map((d) => ({
|
||||||
|
time: toTime(d.time),
|
||||||
|
open: d.open,
|
||||||
|
high: d.high,
|
||||||
|
low: d.low,
|
||||||
|
close: d.close,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// 成交量(独立价格刻度,置于底部 20%)
|
||||||
|
const vol: ISeriesApi<"Histogram"> = chart.addHistogramSeries({
|
||||||
|
priceFormat: { type: "volume" },
|
||||||
|
priceScaleId: "vol",
|
||||||
|
lastValueVisible: false,
|
||||||
|
priceLineVisible: false,
|
||||||
|
});
|
||||||
|
chart.priceScale("vol").applyOptions({ scaleMargins: { top: 0.8, bottom: 0 } });
|
||||||
|
vol.setData(
|
||||||
|
volume.map((v) => ({ time: toTime(v.time), value: v.value ?? 0, color: C.grid }))
|
||||||
|
);
|
||||||
|
|
||||||
|
// MA 指标线
|
||||||
|
for (const [name, pts] of Object.entries(indicators)) {
|
||||||
|
const line = chart.addLineSeries({
|
||||||
|
color: name === "ma60" ? C.ma60 : C.ma20,
|
||||||
|
lineWidth: 1,
|
||||||
|
priceLineVisible: false,
|
||||||
|
lastValueVisible: false,
|
||||||
|
crosshairMarkerVisible: false,
|
||||||
|
});
|
||||||
|
line.setData(
|
||||||
|
pts
|
||||||
|
.filter((p) => p.value != null)
|
||||||
|
.map((p) => ({ time: toTime(p.time), value: p.value as number }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件标记:成交=实心箭头;未成交信号=圆点
|
||||||
|
const markers: SeriesMarker<Time>[] = [];
|
||||||
|
for (const m of fills) {
|
||||||
|
const buy = m.kind.endsWith("buy");
|
||||||
|
markers.push({
|
||||||
|
time: toTime(m.time),
|
||||||
|
position: buy ? "belowBar" : "aboveBar",
|
||||||
|
color: buy ? C.markerBuy : C.markerSell,
|
||||||
|
shape: buy ? "arrowUp" : "arrowDown",
|
||||||
|
size: 1,
|
||||||
|
text: buy ? "B" : "S",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const m of signals) {
|
||||||
|
const buy = m.kind.endsWith("buy");
|
||||||
|
markers.push({
|
||||||
|
time: toTime(m.time),
|
||||||
|
position: buy ? "belowBar" : "aboveBar",
|
||||||
|
color: C.markerSig,
|
||||||
|
shape: "circle",
|
||||||
|
size: 1,
|
||||||
|
text: buy ? "sigB" : "sigS",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (markers.length) candle.setMarkers(markers);
|
||||||
|
if (data.length) chart.timeScale().fitContent();
|
||||||
|
|
||||||
|
const onResize = () => chart.applyOptions({ width: ref.current?.clientWidth });
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
chart.remove();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [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 线图(Lightweight)`} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/** K 线图统一入口:可切换 ECharts / TradingView Lightweight Charts 两套实现(对比期)。 */
|
||||||
|
import type { CandleChartProps } from "./chartTypes";
|
||||||
|
import { CandleChart } from "./CandleChart";
|
||||||
|
import { CandleChartLW } from "./CandleChartLW";
|
||||||
|
|
||||||
|
export type ChartLibrary = "echarts" | "lightweight";
|
||||||
|
|
||||||
|
export function StockChart({ library = "lightweight", ...props }: CandleChartProps & { library?: ChartLibrary }) {
|
||||||
|
return library === "echarts" ? <CandleChart {...props} /> : <CandleChartLW {...props} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/** K 线组件共享接口(两套实现共用:ECharts 版与 TradingView Lightweight 版)。 */
|
||||||
|
import type { ChartMarker, ChartSeriesPoint, ChartVolumePoint } from "@/lib/types";
|
||||||
|
|
||||||
|
export interface CandleDatum {
|
||||||
|
time: string;
|
||||||
|
open: number;
|
||||||
|
close: number;
|
||||||
|
low: number;
|
||||||
|
high: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CandleChartProps {
|
||||||
|
data: CandleDatum[];
|
||||||
|
volume?: ChartVolumePoint[];
|
||||||
|
indicators?: Record<string, ChartSeriesPoint[]>;
|
||||||
|
fills?: ChartMarker[];
|
||||||
|
signals?: ChartMarker[];
|
||||||
|
height?: number;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"echarts": "^5.5.1",
|
"echarts": "^5.5.1",
|
||||||
|
"lightweight-charts": "^4.2.3",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
|
|||||||
Generated
+15
@@ -11,6 +11,9 @@ importers:
|
|||||||
echarts:
|
echarts:
|
||||||
specifier: ^5.5.1
|
specifier: ^5.5.1
|
||||||
version: 5.6.0
|
version: 5.6.0
|
||||||
|
lightweight-charts:
|
||||||
|
specifier: ^4.2.3
|
||||||
|
version: 4.2.3
|
||||||
next:
|
next:
|
||||||
specifier: ^15.1.0
|
specifier: ^15.1.0
|
||||||
version: 15.5.25(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
version: 15.5.25(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||||
@@ -286,6 +289,12 @@ packages:
|
|||||||
echarts@5.6.0:
|
echarts@5.6.0:
|
||||||
resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==}
|
resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==}
|
||||||
|
|
||||||
|
fancy-canvas@2.1.0:
|
||||||
|
resolution: {integrity: sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==}
|
||||||
|
|
||||||
|
lightweight-charts@4.2.3:
|
||||||
|
resolution: {integrity: sha512-5kS/2hY3wNYNzhnS8Gb+GAS07DX8GPF2YVDnd2NMC85gJVQ6RLU6YrXNgNJ6eg0AnWPwCnvaGtYmGky3HiLQEw==}
|
||||||
|
|
||||||
nanoid@3.3.18:
|
nanoid@3.3.18:
|
||||||
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
|
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
@@ -549,6 +558,12 @@ snapshots:
|
|||||||
tslib: 2.3.0
|
tslib: 2.3.0
|
||||||
zrender: 5.6.1
|
zrender: 5.6.1
|
||||||
|
|
||||||
|
fancy-canvas@2.1.0: {}
|
||||||
|
|
||||||
|
lightweight-charts@4.2.3:
|
||||||
|
dependencies:
|
||||||
|
fancy-canvas: 2.1.0
|
||||||
|
|
||||||
nanoid@3.3.18: {}
|
nanoid@3.3.18: {}
|
||||||
|
|
||||||
next@15.5.25(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
next@15.5.25(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||||
|
|||||||
Reference in New Issue
Block a user