"use client"; /** * 通用折线/面积/柱状图 —— TradingView Lightweight Charts 实现(唯一图表基座)。 * * 为什么自建而不直接用 LW 原语:LW 没有内置 tooltip 与图例,且硬性要求 * ① 标记时间必须存在于序列数据中,② 标记必须按时间升序。 * 本组件把这两条约束在内部处理掉(过滤 + 排序),避免每个页面各写一遍而踩坑 * —— 回测买卖点必须精确落在净值曲线日期上(v3 §20.3)。 * * 设计要点: * - 图表实例只在「结构签名」(序列个数/类型/高度)变化时重建;数据变化走 setData * + 数值校验和作为数据签名,避免每次渲染重建导致闪烁、丢失缩放位置。 * - 多序列共用价格轴(实验对比均为收益率 %,可直接叠加比较)。 * - 图例可点击隐藏/显示单条序列;tooltip 显示十字光标处全部序列数值。 */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ColorType, CrosshairMode, LineStyle, createChart, type HistogramData, type IChartApi, type ISeriesApi, type LineData, type MouseEventParams, type SeriesMarker, type Time, } from "lightweight-charts"; import { CHART, MARKER_COLORS } from "./theme"; import { isBuyMarker, prepareMarkers } from "./markers"; export interface LwPoint { time: string; value: number; } export interface LwSeries { key: string; label: string; data: LwPoint[]; color?: string; /** line=折线;area=带渐变面积;bar=柱状(月度收益等) */ type?: "line" | "area" | "bar"; lineWidth?: 1 | 2 | 3 | 4; dashed?: boolean; /** 是否在价格轴显示最新值标签(多序列对比时只留一条即可) */ lastValueVisible?: boolean; } export interface LwMarker { time: string; kind: keyof typeof MARKER_COLORS; label?: string; /** 覆盖默认文本(默认买 B / 卖 S) */ text?: string; } export interface LwChartProps { series: LwSeries[]; markers?: LwMarker[]; height?: number; /** 悬浮提示与价格轴的数值格式(客户端组件用;Server Component 请用 formatKey) */ valueFormat?: (v: number) => string; /** * 数值格式的**可序列化标识**。 * * 为什么需要它:Server Component 不能把函数传给 Client Component(`valueFormat` * 会直接 500:「Functions cannot be passed directly to Client Components」)。 * 因此服务端渲染的图表(归档详情、曲线放大页)用这个标识指定格式,由本组件在 * 客户端解析成函数 —— 两端看到的数字格式仍然只有一份定义。 */ formatKey?: LwFormatKey; /** 画一条 0 基准虚线(收益率曲线推荐开启) */ zeroLine?: boolean; legend?: boolean; ariaLabel?: string; emptyHint?: string; } type AnySeries = ISeriesApi<"Line"> | ISeriesApi<"Area"> | ISeriesApi<"Histogram">; /** 数值格式的可序列化标识(Server Component ↔ Client Component 的桥) */ export type LwFormatKey = | "num" | "num0" | "pct2" | "pct3" | "times3" | "auto4" /** 原值为小数、按百分数显示(0.15 → 15.00%) */ | "frac-pct"; export const FORMATTERS: Record string> = { num: (v) => v.toLocaleString("zh-CN", { maximumFractionDigits: 2 }), num0: (v) => v.toLocaleString("zh-CN", { maximumFractionDigits: 0 }), pct2: (v) => `${v.toFixed(2)}%`, pct3: (v) => `${v.toFixed(3)}%`, times3: (v) => `${v.toFixed(3)}×`, auto4: (v) => v.toFixed(4), "frac-pct": (v) => `${(v * 100).toFixed(2)}%`, }; function toTime(t: string): Time { return t as Time; } /** 结构签名:序列个数/类型/高度 —— 变化才重建图表实例 */ function structureKey(series: LwSeries[], height: number): string { return `${height}|${series.map((s) => `${s.key}:${s.type ?? "line"}`).join(",")}`; } /** * 数据签名:长度 + 首末时间 + **数值校验和**。 * 必须含数值校验和:只改成本/滑点后曲线数值变了但日期与长度不变, * 若签名只看长度就会漏更新(图表停留在上一次结果)。 */ function dataKey(series: LwSeries[], markers: LwMarker[]): string { const parts = series.map((s) => { let sum = 0; for (const p of s.data) sum += p.value; return `${s.key}:${s.data.length}:${s.data[0]?.time ?? ""}:${s.data.at(-1)?.time ?? ""}:${sum.toFixed(4)}`; }); const mk = markers.map((m) => `${m.time}${m.kind}`).join(","); return `${parts.join("|")}#${mk}`; } export function LwChart({ series, markers = [], height = 320, valueFormat, formatKey, zeroLine = false, legend = true, ariaLabel, emptyHint = "暂无数据", }: LwChartProps) { const wrapRef = useRef(null); const boxRef = useRef(null); const chartRef = useRef(null); const seriesRef = useRef>(new Map()); const zeroLineDrawn = useRef(false); /** 图例隐藏集合:tooltip 订阅里读取,用 ref 避免闭包过期 */ const hiddenRef = useRef>(new Set()); const resolvedFormat = valueFormat ?? (formatKey ? FORMATTERS[formatKey] : undefined); const fmtRef = useRef(resolvedFormat); fmtRef.current = resolvedFormat; const [hidden, setHidden] = useState>(new Set()); const [tip, setTip] = useState<{ x: number; y: number; time: string; rows: { label: string; color: string; text: string }[]; } | null>(null); hiddenRef.current = hidden; const struct = structureKey(series, height); const dkey = dataKey(series, markers); const fmt = useCallback( (v: number) => (fmtRef.current ? fmtRef.current(v) : v.toFixed(2)), [] ); /* ---------- 创建/销毁图表实例 ---------- */ useEffect(() => { const el = boxRef.current; if (!el) return; const chart = createChart(el, { autoSize: true, height, layout: { background: { type: ColorType.Solid, color: CHART.background }, textColor: CHART.text, fontSize: 11, }, grid: { vertLines: { color: CHART.grid }, horzLines: { color: CHART.grid }, }, rightPriceScale: { borderColor: CHART.border, scaleMargins: { top: 0.12, bottom: 0.12 }, }, timeScale: { borderColor: CHART.border, timeVisible: false, secondsVisible: false }, crosshair: { mode: CrosshairMode.Normal, vertLine: { color: CHART.crosshair, width: 1, style: LineStyle.Dashed, labelVisible: true }, horzLine: { color: CHART.crosshair, width: 1, style: LineStyle.Dashed, labelVisible: true }, }, }); chartRef.current = chart; zeroLineDrawn.current = false; const map = new Map(); series.forEach((s, i) => { const color = s.color ?? CHART.series[i % CHART.series.length]; let api: AnySeries; if (s.type === "bar") { api = chart.addHistogramSeries({ color, priceLineVisible: false, lastValueVisible: false, }); } else if (s.type === "area") { api = chart.addAreaSeries({ lineColor: color, topColor: `${color}55`, bottomColor: `${color}05`, lineWidth: s.lineWidth ?? 2, priceLineVisible: false, lastValueVisible: s.lastValueVisible ?? false, }); } else { api = chart.addLineSeries({ color, lineWidth: s.lineWidth ?? 2, lineStyle: s.dashed ? LineStyle.Dashed : LineStyle.Solid, priceLineVisible: false, lastValueVisible: s.lastValueVisible ?? series.length === 1, crosshairMarkerVisible: true, crosshairMarkerRadius: 4, }); } map.set(s.key, api); }); seriesRef.current = map; // tooltip:LW 无内置 tooltip,订阅十字光标自行渲染 const onMove = (param: MouseEventParams) => { if (!param.time || !param.point) { setTip(null); return; } const rows: { label: string; color: string; text: string }[] = []; series.forEach((s, i) => { const api = map.get(s.key); if (!api || hiddenRef.current.has(s.key)) return; const v = param.seriesData.get(api as unknown as ISeriesApi<"Line">) as | LineData | HistogramData | undefined; if (!v || typeof v.value !== "number") return; rows.push({ label: s.label, color: s.color ?? CHART.series[i % CHART.series.length], text: fmt(v.value), }); }); if (!rows.length) { setTip(null); return; } setTip({ x: param.point.x, y: param.point.y, time: String(param.time), rows }); }; chart.subscribeCrosshairMove(onMove); const onLeave = () => setTip(null); el.addEventListener("mouseleave", onLeave); return () => { el.removeEventListener("mouseleave", onLeave); chart.unsubscribeCrosshairMove(onMove); chart.remove(); chartRef.current = null; seriesRef.current = new Map(); }; // 仅结构变化才重建;数据/标记由下方 effect 推入 // eslint-disable-next-line react-hooks/exhaustive-deps }, [struct, fmt]); /* ---------- 推入数据与标记 ---------- */ useEffect(() => { const chart = chartRef.current; if (!chart) return; series.forEach((s) => { const api = seriesRef.current.get(s.key); if (!api) return; if (s.type === "bar") { api.setData( s.data.map((p) => ({ time: toTime(p.time), value: p.value })) as HistogramData[] ); } else { api.setData(s.data.map((p) => ({ time: toTime(p.time), value: p.value })) as LineData[]); } }); // 0 基准线:每个图表实例只画一次(重复调用会叠加多条价格线) if (zeroLine && !zeroLineDrawn.current) { const first = seriesRef.current.get(series[0]?.key ?? ""); first?.createPriceLine({ price: 0, color: CHART.faint, lineWidth: 1, lineStyle: LineStyle.Dotted, axisLabelVisible: false, title: "", }); zeroLineDrawn.current = true; } // 标记只能挂在某个序列上;统一挂第一条非柱状序列(净值/收益曲线) const primaryKey = series.find((s) => s.type !== "bar")?.key ?? series[0]?.key; const primary = primaryKey ? seriesRef.current.get(primaryKey) : undefined; if (primary && "setMarkers" in primary) { const validTimes = new Set( series.find((s) => s.key === primaryKey)?.data.map((p) => p.time) ?? [] ); // LW 硬约束:标记时间必须存在且升序 → 先过滤再排序,否则买卖点会丢或抛错。 // 传空数组同样重要:结果切换后必须清掉上一次的标记。 const ms: SeriesMarker