Files
stock/frontend/src/components/DetailKLine.vue
2026-08-16 21:24:41 +08:00

892 lines
43 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import {
dispose, init, registerIndicator, registerOverlay,
type Chart, type KLineData, type OverlayCreate, type OverlayTemplate, type Point,
} from 'klinecharts';
// 官方画线扩展preview.klinecharts.com 同款工具集rect/circle 沿用 v10 内置版,不注册扩展的重名模板
import {
abcd, anyWaves, arrow, eightWaves, fibonacciCircle, fibonacciExtension, fibonacciSegment,
fibonacciSpeedResistanceFan, fibonacciSpiral, fiveWaves, gannBox, measure, parallelogram,
threeWaves, triangle, xabcd,
} from '@klinecharts/extension';
import { useSettingsStore, TOOLTIP_FIELDS, DEFAULT_TOOLTIP_FIELDS } from '@/stores/settings';
import type { Candle, TooltipField } from '@/api/types';
// 扩展画线模板一次性全局注册registerOverlay 是全局的,模块加载时执行一次即可)
for (const t of [
abcd, xabcd, threeWaves, fiveWaves, eightWaves, anyWaves, arrow, triangle, parallelogram,
fibonacciCircle, fibonacciExtension, fibonacciSegment, fibonacciSpeedResistanceFan,
fibonacciSpiral, gannBox, measure,
]) {
registerOverlay(t);
}
// ---------- 实盘买卖点标记(交割单导入) ----------
// v10 无 v9 的 simpleMarker须注册自定义模板字母种类/明细经 extendData 传入。
// A股惯例通达信/同花顺同款B 买贴 low 下方、S 卖贴 high 上方、T 当日买+卖做T贴 high 上方;
// 图上只显示单个字母徽章(色底白字,用户指定固定配色,不随涨跌设置),成交明细(数量/均价/费用)
// 悬停字母时由组件浮层展示——onMouseEnter/onMouseLeave 是创建项级回调OverlayCreate 未 Omit
// 事件键),闭包进组件状态即可(模板是模块级的,拿不到组件实例)。
interface TradeRow { label: string; text: string; tone: 'buy' | 'sell' | '' }
interface TradeMarkExt { kind: 'B' | 'S' | 'T'; rows: TradeRow[] }
const TRADE_COLORS: Record<'B' | 'S' | 'T', string> = { B: '#FE354B', S: '#3B7BBF', T: '#F9A504' };
const tradeMarkerTemplate: OverlayTemplate<TradeMarkExt> = {
name: 'tradeMarker',
totalStep: 2,
needDefaultPointFigure: false,
needDefaultXAxisFigure: false,
needDefaultYAxisFigure: false,
createPointFigures: ({ overlay, coordinates }) => {
const c = coordinates[0];
const ext = overlay.extendData;
if (!c || !ext) return [];
const ly = ext.kind === 'B' ? c.y + 22 : c.y - 22; // 字母中心与 bar 高低点的像素间距离K线远一点更清爽
return [
{
type: 'text',
attrs: { x: c.x, y: ly, text: ext.kind, align: 'center', baseline: 'middle' },
styles: {
color: '#FFFFFF', backgroundColor: TRADE_COLORS[ext.kind],
size: 12, weight: 'bold', borderRadius: 3,
paddingLeft: 3, paddingRight: 3, paddingTop: 1, paddingBottom: 1,
},
ignoreEvent: true,
},
{ // 透明命中区:把字母徽章的悬停判定兜成 r=9 的圆,指上去更容易。
// 必须排在 text 之后:库按数组顺序挂 children、倒序分发 mousemove
// circle 放最后才能最先接管事件——否则首个落点在徽章上时 enter 会被
// text 的 ignoreEvent 拦住、tooltip 出不来circle 全透明,压顶层无视觉影响)
type: 'circle',
attrs: { x: c.x, y: ly, r: 9 },
styles: { style: 'fill', color: 'rgba(0,0,0,0)', borderColor: 'rgba(0,0,0,0)' },
},
];
},
};
registerOverlay(tradeMarkerTemplate);
const props = defineProps<{
ticker: string;
candles: Candle[];
indicators: Record<string, Record<string, (number | null)[]>>;
/** 服务端在首屏窗口之前是否还有更早历史(决定左滑是否继续翻页) */
hasMore: boolean;
/** 向左翻页:取某日期(不含)之前 count 根历史,父组件保证口径一致;返回 null 表示无更多/已失效 */
loadOlder: (
end: string,
count: number,
) => Promise<{ candles: Candle[]; indicators: Record<string, Record<string, (number | null)[]>>; hasMore: boolean } | null>;
/** 副图指标及顺序('vol' 用内置;其余为后端序列) */
subPanes: string[];
/** 主图 MA 周期(可配置,随用户偏好持久化) */
maPeriods: number[];
/** 各副图高度 px可配置随用户偏好持久化 */
subHeights: Record<string, number>;
/** 主图是否叠加布林带 */
showBoll: boolean;
/** K线周期标签仅用于 MA 指标名缓存 key */
timeframe: string;
/** 浮层显示的指标(可选;缺省=目录全开,空数组=仅日期头) */
tooltipFields?: TooltipField[];
/** 日期跳转锚点本地零点时间戳build 完成后把该日 K 线滚动到可视区中央null=停在最新 */
centerTs?: number | null;
/** 实盘买卖点交割单导入按日聚合成标记B=当日只买 贴 low 下方、S=当日只卖 贴 high 上方、
* T=当日买+卖做T贴 high 上方rows 为悬停明细(数量/均价/费用)。
* 只画落在已渲染窗口内的(更早的等左滑翻页后自动补画) */
tradeMarkers?: { key: string; ts: number; kind: 'B' | 'S' | 'T'; rows: TradeRow[] }[];
}>();
const emit = defineEmits<{
/** 日期跳转锚点在本次数据窗口里找不到(早于上市/晚于最后一根):请父组件回退到最新行情并提示 */
(e: 'centerMiss', ts: number): void;
}>();
// A股语义色黑底高对比UP/DOWN 跟随设置中的涨跌配色
const settings = useSettingsStore();
let UP = '#FE354B';
let DOWN = '#1EBE72';
const MA_COLORS = ['#F5C518', '#4DA3FF', '#C77DFF', '#4DD0E1', '#FF8A3D', '#FF6E9C', '#A3E635', '#94A8FF'];
// ---------- 后端序列注入(单一事实源,与 allData 按索引对齐) ----------
// allData 会随向左翻页不断前插,图表只持有其中的后缀窗口——指标取值必须按时间戳
// 定位到 allData 索引,绝不能用图表相对索引(否则翻页后整体错位)。
let PV: Record<string, (number | null)[]> = {};
let allData: KLineData[] = [];
function idxOfTs(ts: number): number {
let lo = 0, hi = allData.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (allData[mid].timestamp === ts) return mid;
if (allData[mid].timestamp < ts) lo = mid + 1; else hi = mid - 1;
}
return -1;
}
const g = (k: string) => (ts: number) => {
const i = idxOfTs(ts);
return i >= 0 ? PV[k]?.[i] ?? undefined : undefined;
};
// 动态 MA按周期组合注册一次figures 的 key 必须静态,故按签名建缓存)
// 前端直接从收盘价计算 MA无需后端重拉数据切换均线周期即时响应
const _maReg = new Set<string>();
/** 滚动均值O(n) 滑动窗口min_periods=1与后端 pandas rolling 行为一致) */
function computeMA(closes: number[], period: number): (number | null)[] {
const n = closes.length;
const result: (number | null)[] = new Array(n);
let sum = 0;
for (let i = 0; i < n; i++) {
sum += closes[i];
if (i >= period) sum -= closes[i - period];
result[i] = sum / Math.min(i + 1, period);
}
return result;
}
function ensureMaIndicator(periods: number[]) {
const sig = [...periods].sort((a, b) => a - b).join('_');
if (_maReg.has(sig)) return `pv-ma-${sig}`;
registerIndicator({
name: `pv-ma-${sig}`,
shortName: 'MA',
figures: periods.map((p, i) => ({
key: `ma${p}`, title: `MA${p}`, type: 'line',
styles: () => ({ color: MA_COLORS[i % MA_COLORS.length] }),
})),
calc: (d: KLineData[]) => {
// 前端直接按收盘价计算 MA不从后端 PV 读取
const closes = d.map((b) => b.close);
const maValues: Record<string, (number | null)[]> = {};
for (const p of periods) maValues[`ma${p}`] = computeMA(closes, p);
return d.map((_, i) => {
const row: Record<string, number | undefined> = {};
for (const p of periods) {
const v = maValues[`ma${p}`][i];
if (v != null) row[`ma${p}`] = v;
}
return row;
});
},
});
_maReg.add(sig);
return `pv-ma-${sig}`;
}
registerIndicator({
name: 'pv-boll',
shortName: 'BOLL',
figures: [
{ key: 'upper', title: 'UP', type: 'line', styles: () => ({ color: '#C77DFF' }) },
{ key: 'mid', title: 'MB', type: 'line', styles: () => ({ color: '#F5C518' }) },
{ key: 'lower', title: 'DN', type: 'line', styles: () => ({ color: '#C77DFF' }) },
],
calc: (d: KLineData[]) => d.map((bar) => ({ upper: g('upper')(bar.timestamp), mid: g('mid')(bar.timestamp), lower: g('lower')(bar.timestamp) })),
});
registerIndicator({
name: 'pv-macd',
shortName: 'MACD',
figures: [
{ key: 'dif', title: 'DIF', type: 'line', styles: () => ({ color: '#4DA3FF' }) },
{ key: 'dea', title: 'DEA', type: 'line', styles: () => ({ color: '#F5C518' }) },
{
key: 'hist', title: 'HIST', type: 'bar', baseValue: 0, // 零轴柱,缺省会从面板底部画起
styles: (p) => {
const v = (p.data.current as { hist?: number } | null)?.hist ?? 0;
return { color: v >= 0 ? UP : DOWN };
},
},
],
calc: (d: KLineData[]) => d.map((bar) => ({ dif: g('dif')(bar.timestamp), dea: g('dea')(bar.timestamp), hist: g('hist')(bar.timestamp) })),
});
registerIndicator({
name: 'pv-kdj',
shortName: 'KDJ',
figures: [
{ key: 'k', title: 'K', type: 'line', styles: () => ({ color: '#4DA3FF' }) },
{ key: 'd', title: 'D', type: 'line', styles: () => ({ color: '#F5C518' }) },
{ key: 'j', title: 'J', type: 'line', styles: () => ({ color: '#FF6E9C' }) },
],
calc: (d: KLineData[]) => d.map((bar) => ({ k: g('k')(bar.timestamp), d: g('d')(bar.timestamp), j: g('j')(bar.timestamp) })),
});
registerIndicator({
name: 'pv-rsi',
shortName: 'RSI',
figures: [
{ key: 'rsi6', title: 'RSI6', type: 'line', styles: () => ({ color: '#4DA3FF' }) },
{ key: 'rsi12', title: 'RSI12', type: 'line', styles: () => ({ color: '#F5C518' }) },
{ key: 'rsi24', title: 'RSI24', type: 'line', styles: () => ({ color: '#C77DFF' }) },
],
calc: (d: KLineData[]) => d.map((bar) => ({ rsi6: g('rsi6')(bar.timestamp), rsi12: g('rsi12')(bar.timestamp), rsi24: g('rsi24')(bar.timestamp) })),
});
const container = ref<HTMLDivElement | null>(null);
let chart: Chart | null = null;
// ---------- 向左滚动按需加载(首屏 INIT_BARS 根,滚到左缘自动向前翻页 + 预取缓冲) ----------
const INIT_BARS = 240; // 初始展示根数(约一年日线)
const SERVE_BARS = 500; // 每次 backward 回调向图表吐出的根数
const FETCH_BARS = 800; // 每次网络翻页拉取的根数(后端含预热计算指标)
const PREFETCH_LEFT = 300; // 本地未吐出的剩余根数低于该值时提前预取下一页
let served = 0; // 已交给图表的 bar 数(从尾部计)
let hasMore = false; // 服务端可能还有更早历史
let fetching: Promise<void> | null = null; // 进行中的向前翻页请求
let epoch = 0; // 本轮 build 生命周期标记(重建后丢弃过期回调/数据)
let failCount = 0; // 连续翻页失败次数(超过 3 次才放弃,避免瞬时网络错误永久截断历史)
const canBack = () => allData.length > served || hasMore;
/** 用本地时区把 timestamp 格式化为 YYYY-MM-DDDB 里是 naive 日期toISOString 会因 UTC 偏移提前一天,导致翻页缺一根)。 */
const toDateStr = (ms: number) => {
const d = new Date(ms);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
/** 拉取下一页更早历史并前插到 allData / PV索引保持对齐。 */
async function ensureOlder(myEpoch: number): Promise<void> {
if (fetching) return fetching;
if (!hasMore || allData.length === 0) return Promise.resolve();
const end = toDateStr(allData[0].timestamp);
const p = (async () => {
try {
const page = await props.loadOlder(end, FETCH_BARS);
if (myEpoch !== epoch) return; // 期间已切股/切口径/重建,丢弃
if (!page || page.candles.length === 0) { hasMore = false; return; }
recordExtra(page.candles); // 额/换手随翻页累积EX 全局 Map重建时清
const firstTs = allData[0].timestamp;
const keep = page.candles.filter((c) => new Date(c.ts).getTime() < firstTs);
// 同戳边界K周/月K的窗口边界可能切出半周期用服务端完整聚合替换本端首根
const boundary = page.candles.find((c) => new Date(c.ts).getTime() === firstTs);
if (keep.length === 0 && !boundary) { hasMore = false; return; } // 防御:服务端窗口与本端重叠
const head = keep.map((k) => ({
timestamp: new Date(k.ts).getTime(),
open: k.open, high: k.high, low: k.low, close: k.close, volume: k.volume,
}));
allData = boundary
? [...head, { timestamp: firstTs, open: boundary.open, high: boundary.high, low: boundary.low, close: boundary.close, volume: boundary.volume }, ...allData.slice(1)]
: [...head, ...allData];
for (const series of Object.values(page.indicators)) {
for (const [key, arr] of Object.entries(series)) {
// keep 是 page.candles 的前缀(全页严格早于 firstTs指标按前缀对齐
const cut = arr.slice(0, keep.length);
if (boundary) {
const rep = arr[keep.length] ?? null; // 同戳边界K的指标值keep 之后紧邻一根)
PV[key] = [...cut, rep, ...(PV[key] ?? []).slice(1)];
} else {
PV[key] = [...cut, ...(PV[key] ?? [])];
}
}
}
// 注klinecharts v10 数据只经 dataLoader 流入、无 updateData
// 已渲染的半周期首K无法原地刷新替换 allData/PV 保证后续翻页与重建使用完整聚合。
hasMore = page.hasMore;
failCount = 0;
} catch {
// 网络失败:保留 hasMore下次左缘触发时重试连续 3 次失败才放弃
failCount += 1;
if (failCount >= 3) hasMore = false;
}
})();
fetching = p.finally(() => { fetching = null; });
return fetching;
}
/** 缓冲预取:本地剩余不足时提前拉下一页,用户滚到左缘时数据已就位、零等待。 */
function maybePrefetch(myEpoch: number) {
if (hasMore && !fetching && allData.length - served < PREFETCH_LEFT) void ensureOlder(myEpoch);
}
function darkStyles() {
return {
grid: { horizontal: { color: '#1C1E24' }, vertical: { color: '#1C1E24' } },
// 内建 VOL 等指标柱的涨跌色缺省是库默认绿涨红跌,与全站语义相反;
// bars[0] 与库默认项深合并init 时 merge 到 getDefaultStyles只覆盖三个颜色键
// UP/DOWN 在 build() 里随 settings 刷新,故切「绿涨红跌」重建后同样生效
indicator: { bars: [{ upColor: UP, downColor: DOWN, noChangeColor: '#7A818C' }] },
candle: {
bar: {
upColor: UP, downColor: DOWN,
upBorderColor: UP, downBorderColor: DOWN,
upWickColor: UP, downWickColor: DOWN,
},
priceMark: {
high: { color: '#9AA0AA' }, low: { color: '#9AA0AA' },
last: { upColor: UP, downColor: DOWN },
},
},
xAxis: { axisLine: { color: '#2A2D34' }, tickText: { color: '#9AA0AA', size: 12 }, tickLine: { color: '#2A2D34' } },
yAxis: { axisLine: { color: '#2A2D34' }, tickText: { color: '#9AA0AA', size: 12 }, tickLine: { color: '#2A2D34' } },
crosshair: {
horizontal: { text: { backgroundColor: '#333A45' } },
vertical: { text: { backgroundColor: '#333A45' } },
},
separator: {
color: '#23252B',
// 悬停/拖拽分隔条时的底色(库默认 8% 蓝在纯黑底上不可见,加重为可感知的拖拽提示)
activeBackgroundColor: 'rgba(37, 99, 235, 0.30)',
},
};
}
// 副图默认高度
const SUB_DEFAULT_HEIGHT: Record<string, number> = { vol: 64, macd: 100, kdj: 96, rsi: 84 };
const subH = (k: string) => Math.max(40, props.subHeights[k] ?? SUB_DEFAULT_HEIGHT[k] ?? 90);
// ---------- 分隔条拖拽调高(库原生 SeparatorWidget→ 持久化 ----------
// build 时记录 key→paneId拖动中库会高频触发 onPaneDrag防抖后读回各副图实际高度写入偏好
let paneIdByKey: Record<string, string> = {};
let subHTimer: ReturnType<typeof setTimeout> | null = null;
function persistSubHeights() {
if (!chart) return;
const next: Record<string, number> = {};
for (const key of props.subPanes) {
const pid = paneIdByKey[key];
const h = pid ? (chart.getPaneOptions(pid) as { height?: number } | null)?.height : undefined;
if (typeof h === 'number') next[key] = Math.max(40, Math.round(h));
}
if (Object.keys(next).length === 0) return;
settings.setChartLayout({ subHeights: { ...props.subHeights, ...next } });
}
// ---------- 鼠标跟随信息框(通达信式,浮层贴鼠标,每行一个指标) ----------
interface TipRow { key: string; label: string; text: string; tone: '' | 'up' | 'down' }
interface HoverInfo {
date: string; weekday: string;
rows: TipRow[];
}
const hover = ref<HoverInfo | null>(null);
// tooltip 展示的额/换手不在 KLineData 里,按时间戳从 Candle 源数据另存一份
const EX = new Map<number, { amount: number | null; turnover: number | null }>();
const recordExtra = (candles: Candle[]) => {
for (const c of candles) EX.set(new Date(c.ts).getTime(), { amount: c.amount ?? null, turnover: c.turnover ?? null });
};
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
function fmtVol(v: number): string {
if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿';
if (v >= 1e4) return (v / 1e4).toFixed(2) + '万';
return String(Math.round(v));
}
function fmtAmount(v: number): string {
if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿';
if (v >= 1e4) return (v / 1e4).toFixed(2) + '万';
return v.toFixed(0);
}
// 鼠标位置(相对图表容器),浮层跟着走并在右缘/下缘自动翻转
const mx = ref(0);
const my = ref(0);
function onMove(e: MouseEvent) {
const rect = container.value?.getBoundingClientRect();
mx.value = rect ? e.clientX - rect.left : e.clientX;
my.value = rect ? e.clientY - rect.top : e.clientY;
}
const hoverStyle = ref<Record<string, string>>({});
function placeHover(rowCount: number) {
const w = container.value?.clientWidth ?? 800;
const h = container.value?.clientHeight ?? 500;
// 空选时模板仍渲染一行“未选择指标”占位,按至少 1 行估高
const bw = 160, bh = 37 + Math.max(rowCount, 1) * 16, gap = 14;
const x = mx.value + gap + bw > w - 4 ? Math.max(4, mx.value - gap - bw) : mx.value + gap;
const y = my.value + gap + bh > h - 4 ? Math.max(4, my.value - gap - bh) : my.value + gap;
hoverStyle.value = { left: `${x}px`, top: `${y}px` };
}
function bindCrosshair(ch: Chart) {
ch.subscribeAction('onCrosshairChange', (payload) => {
// v10 分发的是裸 crosshair {x, y, paneId}(不含 kLineData
// 用公共 API 把 x 像素换算回 timestamp 再定位 bar
const x = (payload as { x?: number }).x;
if (typeof x !== 'number' || allData.length === 0) { hover.value = null; return; }
const pt = ch.convertFromPixel([{ x }]) as Array<Partial<Point>>;
const ts = pt?.[0]?.timestamp;
const idx = ts != null ? idxOfTs(ts) : -1; // 全量数组与指标序列按索引对齐
if (idx < 0) { hover.value = null; return; }
const k = allData[idx];
const prev = idx > 0 ? allData[idx - 1] : null;
const diff = prev ? k.close - prev.close : null;
const chg = prev ? (diff! / prev.close) * 100 : null;
const amp = prev ? ((k.high - k.low) / prev.close) * 100 : null;
const d = new Date(k.timestamp);
const ex = EX.get(k.timestamp);
// 每个指标一行配色跟随涨跌色调设置up/down 在 build() 里随 priceTone 刷新)。
// 平盘diff==0为中性色与全站 pctClass 及 klinecharts 的 noChangeColor 三态约定一致
const byDiff: '' | 'up' | 'down' = diff == null || diff === 0 ? '' : diff > 0 ? 'up' : 'down';
const values: Record<TooltipField, { text: string; tone: '' | 'up' | 'down' }> = {
open: { text: k.open.toFixed(2), tone: byDiff },
high: { text: k.high.toFixed(2), tone: 'up' },
low: { text: k.low.toFixed(2), tone: 'down' },
close: { text: k.close.toFixed(2), tone: byDiff },
diff: { text: diff == null ? '—' : (diff > 0 ? '+' : '') + diff.toFixed(2), tone: byDiff },
chg: { text: chg == null ? '—' : (chg > 0 ? '+' : '') + chg.toFixed(2) + '%', tone: byDiff },
amp: { text: amp == null ? '—' : amp.toFixed(2) + '%', tone: '' },
vol: { text: fmtVol(k.volume ?? 0), tone: '' },
amount: { text: ex?.amount != null ? fmtAmount(ex.amount) : '—', tone: '' },
turnover: { text: ex?.turnover == null ? '—' : ex.turnover.toFixed(2) + '%', tone: '' },
};
const selected = new Set(props.tooltipFields ?? DEFAULT_TOOLTIP_FIELDS);
const rows: TipRow[] = TOOLTIP_FIELDS
.filter((f) => selected.has(f.key)) // 目录顺序 = 设置弹层顺序 = 浮层行序
.map((f) => ({ key: f.key, label: f.label, ...values[f.key] }));
hover.value = {
date: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`,
weekday: `星期${WEEKDAYS[d.getDay()]}`,
rows,
};
placeHover(rows.length);
});
}
// ---------- 画图画线Pro 风格工具集v10 内置 + @klinecharts/extension ----------
interface Tool { key: string; label: string; title: string }
// 常用工具工具栏一行直出key 即 overlay 名,'' 表示光标模式)
const COMMON_TOOLS: Tool[] = [
{ key: '', label: '光标', title: '光标模式Esc 取消画线)' },
{ key: 'segment', label: '', title: '线段' },
{ key: 'rayLine', label: '→', title: '射线' },
{ key: 'horizontalStraightLine', label: '─', title: '水平线' },
{ key: 'rect', label: '▭', title: '矩形' },
{ key: 'priceChannelLine', label: '∥', title: '价格通道' },
{ key: 'fibonacciLine', label: 'fib', title: '斐波那契回撤' },
{ key: 'measure', label: '⇹', title: '测量(价差/幅度/K线数' },
];
// 更多工具(分组面板展开;均为已注册 overlay
const MORE_GROUPS: { group: string; items: Tool[] }[] = [
{ group: '线', items: [
{ key: 'straightLine', label: '直线', title: '无限延长直线' },
{ key: 'horizontalSegment', label: '水平线段', title: '两点水平线段' },
{ key: 'verticalStraightLine', label: '竖直线', title: '竖直直线' },
{ key: 'parallelStraightLine', label: '平行线', title: '平行直线' },
{ key: 'priceLine', label: '价格线', title: '单点价格线' },
] },
{ group: '斐波那契', items: [
{ key: 'fibonacciSegment', label: '线段fib', title: '斐波那契线段(周期)' },
{ key: 'fibonacciExtension', label: '扩展', title: '斐波那契扩展' },
{ key: 'fibonacciCircle', label: '圆形', title: '斐波那契圆形' },
{ key: 'fibonacciSpiral', label: '螺线', title: '斐波那契螺线' },
{ key: 'fibonacciSpeedResistanceFan', label: '阻力扇', title: '斐波那契速度阻力扇' },
] },
{ group: '波浪 · 江恩 · 谐波', items: [
{ key: 'threeWaves', label: '三浪', title: '三浪' },
{ key: 'fiveWaves', label: '五浪', title: '五浪' },
{ key: 'eightWaves', label: '八浪', title: '八浪' },
{ key: 'anyWaves', label: '自由浪', title: '自由波浪' },
{ key: 'gannBox', label: '江恩箱', title: '甘氏箱(江恩角度线)' },
{ key: 'abcd', label: 'AB=CD', title: 'AB=CD 谐波' },
{ key: 'xabcd', label: 'XABCD', title: 'XABCD 谐波' },
] },
{ group: '形状', items: [
{ key: 'triangle', label: '△', title: '三角形' },
{ key: 'parallelogram', label: '▱', title: '平行四边形' },
{ key: 'circle', label: '◯', title: '圆' },
{ key: 'arrow', label: '➤', title: '箭头' },
{ key: 'brush', label: '✎', title: '自由画笔' },
] },
];
const activeTool = ref('');
const showMore = ref(false);
/** 测量工具的气泡文案:价差 / 涨跌幅 / 两点间K线数Point.value 即价格) */
function measureTip(points: Partial<Point>[]): string[] {
const a = points[0], b = points[1];
if (!a?.value || !b?.value) return [];
const diff = b.value - a.value;
const pct = (diff / a.value) * 100;
const out = [
`价差 ${diff >= 0 ? '+' : ''}${diff.toFixed(2)}`,
`幅度 ${pct >= 0 ? '+' : ''}${pct.toFixed(2)}%`,
];
const ia = a.timestamp != null ? idxOfTs(a.timestamp) : -1;
const ib = b.timestamp != null ? idxOfTs(b.timestamp) : -1;
if (ia >= 0 && ib >= 0) out.push(`${Math.abs(ib - ia) + 1} 根K线`);
return out;
}
function pickTool(key: string) {
activeTool.value = key;
showMore.value = false;
if (chart && key) {
// measure 通过 extendData 注入气泡文案,其余直接按名创建
chart.createOverlay(key === 'measure' ? { name: 'measure', extendData: measureTip } : { name: key });
}
}
function clearOverlays() {
chart?.removeOverlay();
activeTool.value = '';
// removeOverlay() 无参清的是全部 overlay含交易点——交易点不是用户画线重画回来
renderTradeMarkers();
}
// ---------- 日期跳转居中 ----------
/** ts本地零点落在哪根K上取该时刻之前含同日最近一根的下标无则 -1停牌/非交易日自然落到前一根 */
function idxAtOrBefore(list: KLineData[], ts: number): number {
let lo = 0, hi = list.length - 1, ans = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (list[mid].timestamp <= ts) { ans = mid; lo = mid + 1; } else hi = mid - 1;
}
return ans;
}
/** 把已渲染的第 i 根K线滚动到可视区中央scrollToDataIndex 定位到右缘,补半个可视窗口即居中) */
function centerDataIndex(i: number) {
if (!chart) return;
const v = chart.getVisibleRange();
const vis = Math.max(2, Math.round(v.to - v.from) - 1); // from/to 含半个bar余量
chart.scrollToDataIndex(i + Math.floor(vis / 2) - 1, 350);
}
/** 对外:把某天滚动到可视区中央;目标不在当前已渲染窗口内时返回 false调用方走重拉窗口
* ts 晚于最后一根 10 天以上(未来日期/超出现有数据)同样算失败,避免 floor 搜索落到
* 最后一根、锚点却指向一个不存在交易的日期10 天容忍周末与春节黄金周这类停牌间隙。 */
const FUTURE_TOL_MS = 10 * 86400000;
function centerOn(ts: number): boolean {
if (!chart) return false;
const list = chart.getDataList();
if (list.length === 0) return false;
const i = idxAtOrBefore(list, ts);
if (i < 0) return false;
if (ts > list[list.length - 1].timestamp + FUTURE_TOL_MS) return false;
centerDataIndex(i);
return true;
}
defineExpose({ centerOn });
// ---------- 实盘买卖点标记渲染 ----------
const TRADE_GROUP = 'trades';
/** 交易点允许吸附到「晚于最后一根K时间戳」的窗口按周期放大周/月/年K的 bar 时间戳
* 是周期首日(周一/1日/1月1日当前周期内的成交如月中仍应贴到最后一根上。
* 日K严格为 0行情未同步到成交日时宁可先不画数据同步后重建图表自动补上
* 也不能把周一的成交错标到周五的K线上。 */
const TRADE_AHEAD_MS: Record<string, number> = {
'1d': 0,
'1w': 6 * 86400000,
'1M': 31 * 86400000,
'1y': 366 * 86400000,
};
/** 按 groupId 整组重建买卖点标记(先删后建,幂等)。交易日期按时间戳吸附到所在 bar
* B 贴 bar.low 下方、S/T 贴 bar.high 上方坐标随复权切换自动重算value 取自当前数据)。
* 早于已渲染窗口的交易先跳过——左滑翻页 serveOlder 吐出新数据后会重跑本函数补画。
* 列表为空(关闭显示/清空成交/切到无成交股票)也必须清组,否则旧标记残留。 */
function renderTradeMarkers() {
if (!chart) return;
tradeTip.value = null; // 组重建期间字母已换位,旧明细浮层不能留在原地
chart.removeOverlay({ groupId: TRADE_GROUP });
if (!props.tradeMarkers?.length) return;
const list = chart.getDataList();
if (list.length === 0) return;
const lastTs = list[list.length - 1].timestamp;
const aheadMs = TRADE_AHEAD_MS[props.timeframe] ?? 0;
const creates: OverlayCreate<unknown>[] = [];
for (const m of props.tradeMarkers) {
const i = idxAtOrBefore(list, m.ts);
if (i < 0 || m.ts > lastTs + aheadMs) continue; // 未翻到 / 行情尚未覆盖该周期
const bar = list[i];
creates.push({
id: `trade-${m.key}`,
groupId: TRADE_GROUP,
name: 'tradeMarker',
points: [{ timestamp: bar.timestamp, value: m.kind === 'B' ? bar.low : bar.high }],
extendData: { kind: m.kind, rows: m.rows },
onMouseEnter: (ev) => {
// pageX/pageY 是文档绝对坐标x/y 是相对各 pane 画布的,副图 pane 会带偏移),而
// getBoundingClientRect 是视口坐标——须再减 window.scrollX/Y 对齐基准:浮层是从滚过的
// 列表页打开的body 锁滚仍保留偏移),漏减会把 tip 整体顶出可视区、悬停像失灵
const rect = container.value?.getBoundingClientRect();
const px = (ev.pageX ?? 0) - (rect?.left ?? 0) - window.scrollX;
const py = (ev.pageY ?? 0) - (rect?.top ?? 0) - window.scrollY;
tradeTip.value = { ...placeTradeTip(px, py, m.rows.length), kind: m.kind, date: m.key, rows: m.rows };
},
onMouseLeave: () => { tradeTip.value = null; },
// v10 右键命中 figure 会默认 removeOverlaylock 只拦左键按下),标记被悄悄删掉——显式吞掉
onRightClick: (ev) => { ev.preventDefault?.(); },
lock: true,
});
}
if (creates.length) chart.createOverlay(creates);
}
// ---------- 交易点悬停明细(悬停 B/S/T 字母才显示,离开/滚动即隐) ----------
interface TradeTip { x: number; y: number; kind: 'B' | 'S' | 'T'; date: string; rows: TradeRow[] }
const tradeTip = ref<TradeTip | null>(null);
/** 贴鼠标定位并在右缘/下缘自动翻转(与十字线浮层 placeHover 同款策略,宽度略大) */
function placeTradeTip(px: number, py: number, rowCount: number): { x: number; y: number } {
const w = container.value?.clientWidth ?? 800;
const h = container.value?.clientHeight ?? 500;
const bw = 168, bh = 36 + rowCount * 17, gap = 12;
const x = px + gap + bw > w - 4 ? Math.max(4, px - gap - bw) : px + gap;
const y = py + gap + bh > h - 4 ? Math.max(4, py - gap - bh) : py + gap;
return { x, y };
}
function build() {
if (!container.value || props.candles.length === 0) return;
UP = settings.upHex;
DOWN = settings.downHex;
PV = {};
EX.clear();
recordExtra(props.candles);
for (const [group, series] of Object.entries(props.indicators)) {
for (const [key, arr] of Object.entries(series)) PV[key] = arr;
}
const ch = init(container.value, { styles: darkStyles() });
if (!ch) return;
chart = ch;
const myEpoch = ++epoch;
allData = props.candles.map((k) => ({
timestamp: new Date(k.ts).getTime(),
open: k.open, high: k.high, low: k.low, close: k.close, volume: k.volume,
}));
served = 0;
hasMore = props.hasMore;
fetching = null;
failCount = 0;
ch.setDataLoader({
getBars: ({ type, callback }) => {
if (myEpoch !== epoch) return; // 已重建(切股/切口径/改MA旧图表已 dispose无需应答
// klinecharts v10 契约(见 node_modules/klinecharts/dist/index.esm.js _addData
// 'init' → callback 数据整体替换
// 'forward' → 用户拖到左缘callback 数据【前插】为更旧历史more.forward=false 后左缘不再触发
// 'backward'→ 用户拖到右缘callback 数据【追加】为更新端数据;我们已持有最新一根,永远没有
// 每次 getBars 必须恰好应答一次 callback否则图表 _loading 卡死、后续不再加载
const serveOlder = (take: number) => {
const start = allData.length - served - take;
served += take;
callback(allData.slice(start, start + take), { forward: canBack(), backward: false });
renderTradeMarkers(); // 窗口左扩后补画此前跳过的更早交易点
};
const answerEmpty = () => callback([], { forward: false, backward: false });
if (type === 'init') {
// 首屏:最近 INIT_BARS 根;更早历史由左滑触发 'forward' 翻页。
// 有跳转锚点时把 serve 左扩到包含锚点(锚点落在窗口前 1/2 处),仍保持
// [n-served, n) 尾连续不变式——这样 serveOlder 的翻页切片不用变;
// BOLL/副图等同数据重建时锚点就不会掉出首屏窗口。
const n = allData.length;
const anchorIdx = props.centerTs != null ? idxAtOrBefore(allData, props.centerTs) : -1;
served = anchorIdx >= 0
? Math.min(n, Math.max(INIT_BARS, n - anchorIdx + (INIT_BARS >> 1)))
: Math.min(INIT_BARS, n);
callback(allData.slice(n - served), { forward: canBack(), backward: false });
maybePrefetch(myEpoch);
} else if (type === 'forward') {
// 左缘:优先吐本地未吐出的(首屏余量或已预取页),本地耗尽再向服务端翻一页更早历史
const step = async () => {
if (allData.length - served > 0) {
serveOlder(Math.min(SERVE_BARS, allData.length - served));
maybePrefetch(myEpoch);
return;
}
if (!hasMore) { answerEmpty(); return; }
await ensureOlder(myEpoch); // 若已在请求中则复用同一 promise
if (myEpoch !== epoch) return; // 期间已重建,由新图表应答
if (allData.length - served > 0) {
serveOlder(Math.min(SERVE_BARS, allData.length - served));
maybePrefetch(myEpoch);
} else {
answerEmpty();
}
};
void step();
} else {
// 'backward'(右缘更新端)与 'update'(单根刷新走 subscribeBar无更新端数据
// 绝不能把更旧历史从这里给出去——v10 会 concat 到最新一根右侧造成时间轴乱序
callback([], { forward: canBack(), backward: false });
}
},
});
// v10 要求 symbol+period+dataLoader 三者齐备才触发 'init' 加载,缺一则 getBars 永不调用、图表空白
ch.setSymbol({ ticker: props.ticker });
ch.setPeriod({ type: 'day', span: 1 });
// 主图MA周期可配置恒开BOLL 可选
ch.createIndicator({ name: ensureMaIndicator(props.maPeriods), paneId: 'candle_pane' });
if (props.showBoll) ch.createIndicator({ name: 'pv-boll', paneId: 'candle_pane' });
// 副图按用户顺序创建,并设置用户高度;主图吃剩余高度。
// minHeight 交给库在分隔条拖拽时强制执行(与 subH 的 40px 下限一致)
const subTotal = props.subPanes.reduce((s, k) => s + subH(k), 0);
const total = container.value.clientHeight || 560;
ch.setPaneOptions({ id: 'candle_pane', height: Math.max(200, total - subTotal - 24), minHeight: 200 });
paneIdByKey = {};
for (const key of props.subPanes) {
const name = key === 'vol' ? 'VOL' : `pv-${key}`;
ch.createIndicator(name);
const paneId = ch.getIndicators().find((i) => i.name === name)?.paneId;
if (paneId) {
paneIdByKey[key] = paneId;
ch.setPaneOptions({ id: paneId, height: subH(key), minHeight: 40 });
}
}
bindCrosshair(ch);
// 分隔条拖拽调高拖动结束250ms 无新事件)后把各副图实际高度持久化;
// 期间图表已重建epoch 变化)则丢弃,新图表按存档布局
ch.subscribeAction('onPaneDrag', () => {
if (myEpoch !== epoch) return;
if (subHTimer) clearTimeout(subHTimer);
subHTimer = setTimeout(() => { subHTimer = null; persistSubHeights(); }, 250);
});
// 缓冲预取:可视范围接近已加载左缘(<200 根)时提前翻下一页
ch.subscribeAction('onVisibleRangeChange', (payload) => {
if (myEpoch !== epoch) return;
tradeTip.value = null; // 滚动后字母随 bar 移位,悬停明细立即失效
const from = (payload as { data?: { from?: unknown } }).data?.from;
if (typeof from === 'number' && from < 200) maybePrefetch(myEpoch);
});
ch.setOffsetRightDistance(28);
ch.scrollToRealTime();
// 日期跳转build 尾部的 scrollToRealTime 会把视口重置到最新一根,居中必须放在它之后
//init 数据在 setPeriod 时已同步落入图表,这里可直接定位)。
// 居中失败(锚点早于上市首日/晚于最后一根)必须上报:否则锚点 chip 与统计口径
// 仍停留在「已定位」状态,视口却悄悄回到最新行情。
if (props.centerTs != null && !centerOn(props.centerTs)) emit('centerMiss', props.centerTs);
// init 数据在 setPeriod 时已同步落入图表,可直接画首屏窗口内的交易点
renderTradeMarkers();
}
function teardown() {
if (subHTimer) { clearTimeout(subHTimer); subHTimer = null; }
if (container.value) dispose(container.value);
chart = null;
hover.value = null;
tradeTip.value = null;
activeTool.value = '';
}
onMounted(build);
onBeforeUnmount(teardown);
watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll, props.maPeriods, props.timeframe], () => { teardown(); build(); }, { deep: true });
// 买卖点数据变化(导入/清空/开关显示):只重画标记,不重建图表(保留滚动位置与用户画线)
watch(() => props.tradeMarkers, renderTradeMarkers, { deep: true });
// 涨跌配色切换:重建图表以应用新颜色
watch(() => settings.priceTone, () => { teardown(); build(); });
// 副图高度变化:仅调 pane 高度,不重建(保留滚动/画线状态)
watch(() => props.subHeights, () => {
if (!chart) return;
const subTotal = props.subPanes.reduce((s, k) => s + subH(k), 0);
const total = container.value?.clientHeight || 560;
chart.setPaneOptions({ id: 'candle_pane', height: Math.max(200, total - subTotal - 24), minHeight: 200 });
for (const key of props.subPanes) {
const name = key === 'vol' ? 'VOL' : `pv-${key}`;
const paneId = chart.getIndicators().find((i) => i.name === name)?.paneId;
if (paneId) chart.setPaneOptions({ id: paneId, height: subH(key), minHeight: 40 });
}
}, { deep: true });
</script>
<template>
<!-- mousemove captureklinecharts 在内部容器上以冒泡阶段监听并同步触发
onCrosshairChangeplaceHovercapture 先于它更新 mx/my避免用到上一次的坐标 -->
<div class="relative h-full w-full" @mousemove.capture="onMove" @mouseleave="hover = null; tradeTip = null">
<div ref="container" class="h-full w-full"></div>
<!-- 鼠标跟随信息框贴鼠标/下缘自动翻转每行一个指标内容由浮层设置决定
悬停交易字母时让位给明细浮层两框几乎同点位叠加会呈现双层边框的重影 -->
<div
v-if="hover && !tradeTip"
class="pointer-events-none absolute z-10 w-40 rounded border border-[#33353D] bg-black/90 px-2.5 py-1.5 font-mono text-xs leading-4 text-[#E8EAED] shadow-lg"
:style="hoverStyle"
>
<div class="text-[#9BA3AE]">{{ hover.date }} <span class="text-[#A8AFB8]">{{ hover.weekday }}</span></div>
<div class="mt-1 border-t border-[#33353D]/60 pt-1">
<div v-for="r in hover.rows" :key="r.key" class="flex items-baseline justify-between">
<span class="text-[#A8AFB8]">{{ r.label }}</span>
<span
:style="r.tone ? { color: r.tone === 'up' ? UP : DOWN } : undefined"
:class="r.tone ? '' : 'text-[#E8EAED]'"
>{{ r.text }}</span>
</div>
<div v-if="hover.rows.length === 0" class="text-[#A8AFB8]">未选择指标</div>
</div>
</div>
<!-- 交易点悬停明细贴鼠标/下缘自动翻转日期 + 字母 + 当日买卖数量/均价/费用 -->
<div
v-if="tradeTip"
class="pointer-events-none absolute z-20 w-44 rounded border border-[#33353D] bg-black/90 px-2.5 py-1.5 font-mono text-xs leading-4 text-[#E8EAED] shadow-lg"
:style="{ left: `${tradeTip.x}px`, top: `${tradeTip.y}px` }"
>
<div class="flex items-baseline justify-between">
<span class="text-[#9BA3AE]">{{ tradeTip.date }}</span>
<span class="font-bold" :style="{ color: TRADE_COLORS[tradeTip.kind] }">{{ tradeTip.kind }}</span>
</div>
<div class="mt-1 border-t border-[#33353D]/60 pt-1">
<div v-for="(r, i) in tradeTip.rows" :key="i" class="flex items-baseline justify-between">
<span class="text-[#A8AFB8]">{{ r.label }}</span>
<span
:style="r.tone ? { color: r.tone === 'buy' ? TRADE_COLORS.B : TRADE_COLORS.S } : undefined"
:class="r.tone ? '' : 'text-[#E8EAED]'"
>{{ r.text }}</span>
</div>
</div>
</div>
<!-- 画图画线工具栏常用一行 + 更多分组面板
移入工具栏时 canvas 收不到后续 mousemoveonMouseLeave 不会触发须在此清掉交易明细浮层 -->
<div
class="absolute right-2 top-2 z-10 rounded-md border border-[#26272E] bg-[#101014] shadow-sm"
@mouseenter="tradeTip = null"
>
<div class="flex items-center gap-0.5 px-1 py-0.5">
<button
v-for="t in COMMON_TOOLS"
:key="t.key || 'cursor'"
type="button"
class="min-w-6 rounded px-1 py-0.5 text-xs transition-colors"
:class="activeTool === t.key ? 'bg-blue-600 text-white' : 'text-[#A8AFB8] hover:bg-[#1E2026] hover:text-[#E8EAED]'"
:title="t.title"
@click="pickTool(t.key)"
>{{ t.label }}</button>
<span class="mx-0.5 h-3 w-px bg-[#26272E]"></span>
<button
type="button"
class="rounded px-1 py-0.5 text-xs transition-colors"
:class="showMore ? 'bg-blue-500/15 text-blue-300' : 'text-[#A8AFB8] hover:bg-[#1E2026] hover:text-[#E8EAED]'"
title="更多画线工具"
@click="showMore = !showMore"
>更多</button>
<button
type="button"
class="rounded px-1 py-0.5 text-xs text-red-500 transition-colors hover:bg-red-500/15"
title="清除全部画线"
@click="clearOverlays"
>清除</button>
</div>
<div v-if="showMore" class="max-w-56 border-t border-[#26272E] px-1.5 py-1">
<div v-for="grp in MORE_GROUPS" :key="grp.group" class="mb-1.5 last:mb-0">
<div class="mb-0.5 text-xs leading-3 text-[#9BA3AE]">{{ grp.group }}</div>
<div class="flex flex-wrap gap-0.5">
<button
v-for="t in grp.items"
:key="t.key"
type="button"
class="rounded px-1.5 py-0.5 text-xs transition-colors"
:class="activeTool === t.key ? 'bg-blue-600 text-white' : 'text-[#A8AFB8] hover:bg-[#1E2026] hover:text-[#E8EAED]'"
:title="t.title"
@click="pickTool(t.key)"
>{{ t.label }}</button>
</div>
</div>
</div>
</div>
</div>
</template>