feat: K线图默认展示近3个月,拖动回看更早数据;自定义日期格式;主图加高成交量压低
This commit is contained in:
@@ -55,8 +55,9 @@
|
|||||||
- 生成分享链接时复用已有短链,避免每次生成新链接
|
- 生成分享链接时复用已有短链,避免每次生成新链接
|
||||||
- 全页面适配移动端:响应式字体、间距、布局断点(sm/md/lg)
|
- 全页面适配移动端:响应式字体、间距、布局断点(sm/md/lg)
|
||||||
- K线图历史数据获取失败时直接报错,禁止使用模拟数据降级(避免"刷新数据变化"问题)
|
- K线图历史数据获取失败时直接报错,禁止使用模拟数据降级(避免"刷新数据变化"问题)
|
||||||
- K线天数逻辑:默认3个月(90天),自选日期到现在超过3个月则从自选日期开始
|
- K线天数逻辑:日K一次拉取近365天(供拖动回看),默认视口只展示最近90个自然日(3个月),向左拖动查看更早数据;分钟K一次拉取320根,铺满展示
|
||||||
- K线图使用 recharts Brush 组件实现移动端缩放和滑动查看
|
- 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开头=北交(橙),主板不显示标签;标记位置:搜索候选、详情页标题、集合卡片股票列表、分享页股票卡片标题
|
- 板块标记: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编码}`(按名称搜索金十资讯)
|
- 详情页右上角外部跳转按钮:①"东方财富" `https://wap.eastmoney.com/quote/stock/{market}.{code}.html?appfenxiang=1`,market映射 688→6/60→1/其他→0;②"金十数据" `https://search.jin10.com/?keyword={股票名称URL编码}`(按名称搜索金十资讯)
|
||||||
- 详情页资金流向模块:展示近30日资金流向分析,包含:
|
- 详情页资金流向模块:展示近30日资金流向分析,包含:
|
||||||
|
|||||||
@@ -137,6 +137,22 @@ export function KLineCard({ code, addedAt, ready }: Props) {
|
|||||||
const displayData = chartPeriod === "d" ? dailyData : minuteData;
|
const displayData = chartPeriod === "d" ? dailyData : minuteData;
|
||||||
const currentLoading = chartPeriod === "d" ? chartLoading : minuteLoading;
|
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 (
|
return (
|
||||||
<Card className="shadow-lg">
|
<Card className="shadow-lg">
|
||||||
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
|
<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>
|
</CardHeader>
|
||||||
<CardContent className="px-2 md:px-6 pb-2 md:pb-6">
|
<CardContent className="px-2 md:px-6 pb-2 md:pb-6">
|
||||||
{currentLoading ? (
|
{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="flex items-center text-muted-foreground text-sm">
|
||||||
<div className="animate-pulse mr-2 h-2 w-2 rounded-full bg-primary"></div>
|
<div className="animate-pulse mr-2 h-2 w-2 rounded-full bg-primary"></div>
|
||||||
K线数据加载中...
|
K线数据加载中...
|
||||||
@@ -221,18 +237,11 @@ export function KLineCard({ code, addedAt, ready }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<KLineChart
|
<KLineChart
|
||||||
data={displayData.map((d) => ({
|
data={chartData}
|
||||||
time: d.dateMs,
|
|
||||||
open: d.open,
|
|
||||||
close: d.close,
|
|
||||||
high: d.high,
|
|
||||||
low: d.low,
|
|
||||||
volume: d.volume,
|
|
||||||
isAddedDate: d.isAddedDate,
|
|
||||||
}))}
|
|
||||||
mode={chartMode}
|
mode={chartMode}
|
||||||
hasAddedDate={ready}
|
hasAddedDate={ready}
|
||||||
indicators={indicators}
|
indicators={indicators}
|
||||||
|
defaultVisibleDays={chartPeriod === "d" ? 90 : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
ColorType,
|
ColorType,
|
||||||
CrosshairMode,
|
CrosshairMode,
|
||||||
LineStyle,
|
LineStyle,
|
||||||
|
TickMarkType,
|
||||||
createSeriesMarkers,
|
createSeriesMarkers,
|
||||||
type IChartApi,
|
type IChartApi,
|
||||||
type ISeriesApi,
|
type ISeriesApi,
|
||||||
@@ -41,6 +42,8 @@ interface Props {
|
|||||||
mode: "line" | "candle";
|
mode: "line" | "candle";
|
||||||
hasAddedDate?: boolean;
|
hasAddedDate?: boolean;
|
||||||
indicators: IndicatorToggles;
|
indicators: IndicatorToggles;
|
||||||
|
/** 默认只展示最近 N 个自然日(日线用),其余数据靠拖动查看;不传则铺满全部数据 */
|
||||||
|
defaultVisibleDays?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// lightweight-charts 无法解析 CSS 变量或 oklch() 颜色,直接用具体 hex 色。
|
// lightweight-charts 无法解析 CSS 变量或 oklch() 颜色,直接用具体 hex 色。
|
||||||
@@ -61,9 +64,41 @@ const MACD_DIF = "#3b82f6";
|
|||||||
const MACD_DEA = "#f59e0b";
|
const MACD_DEA = "#f59e0b";
|
||||||
const RSI_COLOR = "#a855f7";
|
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 containerRef = useRef<HTMLDivElement>(null);
|
||||||
const chartRef = useRef<IChartApi | null>(null);
|
const chartRef = useRef<IChartApi | null>(null);
|
||||||
|
// 记录上次填充的数据集:仅数据集变化(首次加载/切换周期)时调整视口,
|
||||||
|
// 指标开关/显示方式切换时保留用户拖动缩放后的位置
|
||||||
|
const lastDataRef = useRef<KLineItem[] | null>(null);
|
||||||
const priceSeriesRef = useRef<ISeriesApi<"Candlestick" | "Line"> | null>(null);
|
const priceSeriesRef = useRef<ISeriesApi<"Candlestick" | "Line"> | null>(null);
|
||||||
const volSeriesRef = useRef<ISeriesApi<"Histogram"> | null>(null);
|
const volSeriesRef = useRef<ISeriesApi<"Histogram"> | null>(null);
|
||||||
// 指标 series(重建用)
|
// 指标 series(重建用)
|
||||||
@@ -73,8 +108,15 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
|||||||
|
|
||||||
// 容器高度随指标 pane 数量增长,避免主图被压缩
|
// 容器高度随指标 pane 数量增长,避免主图被压缩
|
||||||
const extraPanes = [indicators.macd, indicators.rsi].filter(Boolean).length;
|
const extraPanes = [indicators.macd, indicators.rsi].filter(Boolean).length;
|
||||||
// 固定图表高度:主图+成交量 300,每个指标副图 +95
|
// 图表总高 = 各 pane 高度之和(主图 + 成交量 + 指标副图×N)
|
||||||
const chartHeight = 300 + extraPanes * 95;
|
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 成交量
|
// 创建图表(仅一次):pane0 主图 + pane1 成交量
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -94,7 +136,12 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
|||||||
horzLines: { color: GRID, style: LineStyle.Dashed, visible: true },
|
horzLines: { color: GRID, style: LineStyle.Dashed, visible: true },
|
||||||
},
|
},
|
||||||
rightPriceScale: { borderColor: GRID },
|
rightPriceScale: { borderColor: GRID },
|
||||||
timeScale: { borderColor: GRID, timeVisible: false },
|
localization: { timeFormatter: formatCrosshairTime },
|
||||||
|
timeScale: {
|
||||||
|
borderColor: GRID,
|
||||||
|
timeVisible: false,
|
||||||
|
tickMarkFormatter: formatTickMark,
|
||||||
|
},
|
||||||
crosshair: { mode: CrosshairMode.Normal },
|
crosshair: { mode: CrosshairMode.Normal },
|
||||||
});
|
});
|
||||||
chartRef.current = chart;
|
chartRef.current = chart;
|
||||||
@@ -109,6 +156,7 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
|||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
volSeriesRef.current = vol;
|
volSeriesRef.current = vol;
|
||||||
|
applyPaneHeights(chart);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
chart.remove();
|
chart.remove();
|
||||||
@@ -118,6 +166,8 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
|||||||
maSeriesRef.current = [];
|
maSeriesRef.current = [];
|
||||||
macdSeriesRef.current = [];
|
macdSeriesRef.current = [];
|
||||||
rsiSeriesRef.current = [];
|
rsiSeriesRef.current = [];
|
||||||
|
// 图表实例销毁后重置,重建时重新应用默认视口(StrictMode 重挂载同样生效)
|
||||||
|
lastDataRef.current = null;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -223,6 +273,8 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
|||||||
);
|
);
|
||||||
rsiSeriesRef.current.push(s);
|
rsiSeriesRef.current.push(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyPaneHeights(chart);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模式切换:重建价格 series + 指标
|
// 模式切换:重建价格 series + 指标
|
||||||
@@ -310,7 +362,23 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
|||||||
createSeriesMarkers(ps, []);
|
createSeriesMarkers(ps, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
chart.timeScale().fitContent();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 颜色图例:根据启用的指标生成
|
// 颜色图例:根据启用的指标生成
|
||||||
|
|||||||
Reference in New Issue
Block a user