feat: K线图默认展示近3个月,拖动回看更早数据;自定义日期格式;主图加高成交量压低
This commit is contained in:
@@ -55,8 +55,9 @@
|
||||
- 生成分享链接时复用已有短链,避免每次生成新链接
|
||||
- 全页面适配移动端:响应式字体、间距、布局断点(sm/md/lg)
|
||||
- K线图历史数据获取失败时直接报错,禁止使用模拟数据降级(避免"刷新数据变化"问题)
|
||||
- K线天数逻辑:默认3个月(90天),自选日期到现在超过3个月则从自选日期开始
|
||||
- K线图使用 recharts Brush 组件实现移动端缩放和滑动查看
|
||||
- K线天数逻辑:日K一次拉取近365天(供拖动回看),默认视口只展示最近90个自然日(3个月),向左拖动查看更早数据;分钟K一次拉取320根,铺满展示
|
||||
- K线图基于 TradingView Lightweight Charts v5 实现(蜡烛/折线 + MA/MACD/RSI 副图);拖动/缩放查看数据,切换指标或显示方式保留当前视口(仅在数据集变化时重置)
|
||||
- K线图时间格式自定义:时间轴刻度 年→`YYYY`、月→`YYYY-MM`、日→`MM-DD`,十字光标→`YYYY-MM-DD`(`tickMarkFormatter` + `localization.timeFormatter`);格式化必须用 UTC 取值(`getUTCFullYear` 等),因为时间戳按"北京时间墙钟视作 UTC"存储,用本地时区方法会错位一天
|
||||
- 板块标记:688开头=科创(红)、300/301开头=创业(紫)、920/8/4开头=北交(橙),主板不显示标签;标记位置:搜索候选、详情页标题、集合卡片股票列表、分享页股票卡片标题
|
||||
- 详情页右上角外部跳转按钮:①"东方财富" `https://wap.eastmoney.com/quote/stock/{market}.{code}.html?appfenxiang=1`,market映射 688→6/60→1/其他→0;②"金十数据" `https://search.jin10.com/?keyword={股票名称URL编码}`(按名称搜索金十资讯)
|
||||
- 详情页资金流向模块:展示近30日资金流向分析,包含:
|
||||
|
||||
@@ -137,6 +137,22 @@ export function KLineCard({ code, addedAt, ready }: Props) {
|
||||
const displayData = chartPeriod === "d" ? dailyData : minuteData;
|
||||
const currentLoading = chartPeriod === "d" ? chartLoading : minuteLoading;
|
||||
|
||||
// 传给图表的数据引用需稳定(仅随 displayData 变化),
|
||||
// 否则切换指标/显示方式时 KLineChart 会把新数组当成新数据集重置视口
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
displayData.map((d) => ({
|
||||
time: d.dateMs,
|
||||
open: d.open,
|
||||
close: d.close,
|
||||
high: d.high,
|
||||
low: d.low,
|
||||
volume: d.volume,
|
||||
isAddedDate: d.isAddedDate,
|
||||
})),
|
||||
[displayData],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
|
||||
@@ -213,7 +229,7 @@ export function KLineCard({ code, addedAt, ready }: Props) {
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 md:px-6 pb-2 md:pb-6">
|
||||
{currentLoading ? (
|
||||
<div className="h-[320px] sm:h-[380px] md:h-[440px] w-full flex items-center justify-center">
|
||||
<div className="h-[390px] w-full flex items-center justify-center">
|
||||
<div className="flex items-center text-muted-foreground text-sm">
|
||||
<div className="animate-pulse mr-2 h-2 w-2 rounded-full bg-primary"></div>
|
||||
K线数据加载中...
|
||||
@@ -221,18 +237,11 @@ export function KLineCard({ code, addedAt, ready }: Props) {
|
||||
</div>
|
||||
) : (
|
||||
<KLineChart
|
||||
data={displayData.map((d) => ({
|
||||
time: d.dateMs,
|
||||
open: d.open,
|
||||
close: d.close,
|
||||
high: d.high,
|
||||
low: d.low,
|
||||
volume: d.volume,
|
||||
isAddedDate: d.isAddedDate,
|
||||
}))}
|
||||
data={chartData}
|
||||
mode={chartMode}
|
||||
hasAddedDate={ready}
|
||||
indicators={indicators}
|
||||
defaultVisibleDays={chartPeriod === "d" ? 90 : undefined}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ColorType,
|
||||
CrosshairMode,
|
||||
LineStyle,
|
||||
TickMarkType,
|
||||
createSeriesMarkers,
|
||||
type IChartApi,
|
||||
type ISeriesApi,
|
||||
@@ -41,6 +42,8 @@ interface Props {
|
||||
mode: "line" | "candle";
|
||||
hasAddedDate?: boolean;
|
||||
indicators: IndicatorToggles;
|
||||
/** 默认只展示最近 N 个自然日(日线用),其余数据靠拖动查看;不传则铺满全部数据 */
|
||||
defaultVisibleDays?: number;
|
||||
}
|
||||
|
||||
// lightweight-charts 无法解析 CSS 变量或 oklch() 颜色,直接用具体 hex 色。
|
||||
@@ -61,9 +64,41 @@ const MACD_DIF = "#3b82f6";
|
||||
const MACD_DEA = "#f59e0b";
|
||||
const RSI_COLOR = "#a855f7";
|
||||
|
||||
export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
||||
// 时间统一按 UTC 取值格式化(kline-card 把北京时间墙钟视作 UTC 存储),
|
||||
// 用本地时区方法会导致日期错位一天
|
||||
function timeToDate(t: Time): Date {
|
||||
if (typeof t === "number") return new Date(t * 1000);
|
||||
if (typeof t === "string") return new Date(t.length === 10 ? `${t}T00:00:00Z` : t);
|
||||
return new Date(Date.UTC(t.year, t.month - 1, t.day));
|
||||
}
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
/** 十字光标处的完整日期:2026-09-01 */
|
||||
function formatCrosshairTime(t: Time): string {
|
||||
const d = timeToDate(t);
|
||||
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
/** 底部时间轴刻度:年→2026、月→2026-09、日→09-01(替代默认的 "01 9月 '26") */
|
||||
function formatTickMark(t: Time, tickMarkType: TickMarkType): string {
|
||||
const d = timeToDate(t);
|
||||
if (tickMarkType === TickMarkType.Year) return `${d.getUTCFullYear()}`;
|
||||
if (tickMarkType === TickMarkType.Month) return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}`;
|
||||
return `${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
// 各 pane 固定高度:主图加高、成交量压低,指标副图居中
|
||||
const MAIN_PANE_H = 320;
|
||||
const VOL_PANE_H = 70;
|
||||
const IND_PANE_H = 95;
|
||||
|
||||
export function KLineChart({ data, mode, hasAddedDate, indicators, defaultVisibleDays }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
// 记录上次填充的数据集:仅数据集变化(首次加载/切换周期)时调整视口,
|
||||
// 指标开关/显示方式切换时保留用户拖动缩放后的位置
|
||||
const lastDataRef = useRef<KLineItem[] | null>(null);
|
||||
const priceSeriesRef = useRef<ISeriesApi<"Candlestick" | "Line"> | null>(null);
|
||||
const volSeriesRef = useRef<ISeriesApi<"Histogram"> | null>(null);
|
||||
// 指标 series(重建用)
|
||||
@@ -73,8 +108,15 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
||||
|
||||
// 容器高度随指标 pane 数量增长,避免主图被压缩
|
||||
const extraPanes = [indicators.macd, indicators.rsi].filter(Boolean).length;
|
||||
// 固定图表高度:主图+成交量 300,每个指标副图 +95
|
||||
const chartHeight = 300 + extraPanes * 95;
|
||||
// 图表总高 = 各 pane 高度之和(主图 + 成交量 + 指标副图×N)
|
||||
const chartHeight = MAIN_PANE_H + VOL_PANE_H + extraPanes * IND_PANE_H;
|
||||
|
||||
// pane 顺序固定:0=主图 1=成交量 2+=指标副图;指标开关增删 pane 后重新应用高度
|
||||
function applyPaneHeights(chart: IChartApi) {
|
||||
chart.panes().forEach((pane, i) => {
|
||||
pane.setHeight(i === 0 ? MAIN_PANE_H : i === 1 ? VOL_PANE_H : IND_PANE_H);
|
||||
});
|
||||
}
|
||||
|
||||
// 创建图表(仅一次):pane0 主图 + pane1 成交量
|
||||
useEffect(() => {
|
||||
@@ -94,7 +136,12 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
||||
horzLines: { color: GRID, style: LineStyle.Dashed, visible: true },
|
||||
},
|
||||
rightPriceScale: { borderColor: GRID },
|
||||
timeScale: { borderColor: GRID, timeVisible: false },
|
||||
localization: { timeFormatter: formatCrosshairTime },
|
||||
timeScale: {
|
||||
borderColor: GRID,
|
||||
timeVisible: false,
|
||||
tickMarkFormatter: formatTickMark,
|
||||
},
|
||||
crosshair: { mode: CrosshairMode.Normal },
|
||||
});
|
||||
chartRef.current = chart;
|
||||
@@ -109,6 +156,7 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
||||
1,
|
||||
);
|
||||
volSeriesRef.current = vol;
|
||||
applyPaneHeights(chart);
|
||||
|
||||
return () => {
|
||||
chart.remove();
|
||||
@@ -118,6 +166,8 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
||||
maSeriesRef.current = [];
|
||||
macdSeriesRef.current = [];
|
||||
rsiSeriesRef.current = [];
|
||||
// 图表实例销毁后重置,重建时重新应用默认视口(StrictMode 重挂载同样生效)
|
||||
lastDataRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -223,6 +273,8 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
||||
);
|
||||
rsiSeriesRef.current.push(s);
|
||||
}
|
||||
|
||||
applyPaneHeights(chart);
|
||||
}
|
||||
|
||||
// 模式切换:重建价格 series + 指标
|
||||
@@ -310,8 +362,24 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
||||
createSeriesMarkers(ps, []);
|
||||
}
|
||||
|
||||
if (lastDataRef.current === data) return;
|
||||
lastDataRef.current = data;
|
||||
if (defaultVisibleDays) {
|
||||
// 默认视口只展示最近 N 个自然日,更早的数据靠向左拖动查看
|
||||
const toMs = (t: string | number) =>
|
||||
typeof t === "number" ? t * 1000 : new Date(t).getTime();
|
||||
const lastMs = toMs(data[data.length - 1].time);
|
||||
let fromIdx = data.findIndex(
|
||||
(d) => toMs(d.time) >= lastMs - defaultVisibleDays * 86400_000,
|
||||
);
|
||||
if (fromIdx < 0) fromIdx = 0;
|
||||
chart
|
||||
.timeScale()
|
||||
.setVisibleLogicalRange({ from: fromIdx, to: data.length - 1 + 2 });
|
||||
} else {
|
||||
chart.timeScale().fitContent();
|
||||
}
|
||||
}
|
||||
|
||||
// 颜色图例:根据启用的指标生成
|
||||
const legend: { color: string; label: string }[] = [];
|
||||
|
||||
Reference in New Issue
Block a user