功能更新
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { dispose, init, registerIndicator, type Chart, type KLineData } from 'klinecharts';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import type { Candle } from '@/api/types';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -9,41 +10,55 @@ const props = defineProps<{
|
||||
indicators: Record<string, Record<string, (number | null)[]>>;
|
||||
/** 副图指标及顺序('vol' 用内置;其余为后端序列) */
|
||||
subPanes: string[];
|
||||
/** 主图 MA 周期(可配置,随用户偏好持久化) */
|
||||
maPeriods: number[];
|
||||
/** 各副图高度 px(可配置,随用户偏好持久化) */
|
||||
subHeights: Record<string, number>;
|
||||
/** 主图是否叠加布林带 */
|
||||
showBoll: boolean;
|
||||
/** K线周期标签(仅用于 MA 指标名缓存 key) */
|
||||
timeframe: string;
|
||||
}>();
|
||||
|
||||
// A股语义色(浅色)
|
||||
const UP = '#dc2626';
|
||||
const DOWN = '#16a34a';
|
||||
const C1 = '#2563eb'; // 蓝
|
||||
const C2 = '#f59e0b'; // 橙
|
||||
const C3 = '#a855f7'; // 紫
|
||||
const C4 = '#10b981'; // 绿青
|
||||
// A股语义色(浅色);UP/DOWN 跟随设置中的涨跌配色
|
||||
const settings = useSettingsStore();
|
||||
let UP = '#dc2626';
|
||||
let DOWN = '#16a34a';
|
||||
const MA_COLORS = ['#2563eb', '#f59e0b', '#a855f7', '#10b981', '#ec4899', '#0ea5e9', '#84cc16', '#f97316'];
|
||||
|
||||
// ---------- 后端序列注入(单一事实源,按索引对齐) ----------
|
||||
let PV: Record<string, (number | null)[]> = {};
|
||||
const g = (k: string) => (i: number) => PV[k]?.[i] ?? undefined;
|
||||
|
||||
registerIndicator({
|
||||
name: 'pv-ma',
|
||||
shortName: 'MA',
|
||||
figures: [
|
||||
{ key: 'ma5', title: 'MA5', type: 'line', styles: () => ({ color: C1 }) },
|
||||
{ key: 'ma10', title: 'MA10', type: 'line', styles: () => ({ color: C2 }) },
|
||||
{ key: 'ma20', title: 'MA20', type: 'line', styles: () => ({ color: C3 }) },
|
||||
{ key: 'ma60', title: 'MA60', type: 'line', styles: () => ({ color: C4 }) },
|
||||
],
|
||||
calc: (d: KLineData[]) => d.map((_, i) => ({ ma5: g('ma5')(i), ma10: g('ma10')(i), ma20: g('ma20')(i), ma60: g('ma60')(i) })),
|
||||
});
|
||||
// 动态 MA:按周期组合注册一次(figures 的 key 必须静态,故按签名建缓存)
|
||||
const _maReg = new Set<string>();
|
||||
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[]) => d.map((_, i) => {
|
||||
const row: Record<string, number | undefined> = {};
|
||||
for (const p of periods) row[`ma${p}`] = g(`ma${p}`)(i);
|
||||
return row;
|
||||
}),
|
||||
});
|
||||
_maReg.add(sig);
|
||||
return `pv-ma-${sig}`;
|
||||
}
|
||||
|
||||
registerIndicator({
|
||||
name: 'pv-boll',
|
||||
shortName: 'BOLL',
|
||||
figures: [
|
||||
{ key: 'upper', title: 'UP', type: 'line', styles: () => ({ color: C3 }) },
|
||||
{ key: 'mid', title: 'MB', type: 'line', styles: () => ({ color: C2 }) },
|
||||
{ key: 'lower', title: 'DN', type: 'line', styles: () => ({ color: C3 }) },
|
||||
{ key: 'upper', title: 'UP', type: 'line', styles: () => ({ color: '#a855f7' }) },
|
||||
{ key: 'mid', title: 'MB', type: 'line', styles: () => ({ color: '#f59e0b' }) },
|
||||
{ key: 'lower', title: 'DN', type: 'line', styles: () => ({ color: '#a855f7' }) },
|
||||
],
|
||||
calc: (d: KLineData[]) => d.map((_, i) => ({ upper: g('upper')(i), mid: g('mid')(i), lower: g('lower')(i) })),
|
||||
});
|
||||
@@ -52,8 +67,8 @@ registerIndicator({
|
||||
name: 'pv-macd',
|
||||
shortName: 'MACD',
|
||||
figures: [
|
||||
{ key: 'dif', title: 'DIF', type: 'line', styles: () => ({ color: C1 }) },
|
||||
{ key: 'dea', title: 'DEA', type: 'line', styles: () => ({ color: C2 }) },
|
||||
{ key: 'dif', title: 'DIF', type: 'line', styles: () => ({ color: '#2563eb' }) },
|
||||
{ key: 'dea', title: 'DEA', type: 'line', styles: () => ({ color: '#f59e0b' }) },
|
||||
{
|
||||
key: 'hist', title: 'HIST', type: 'bar', baseValue: 0, // 零轴柱,缺省会从面板底部画起
|
||||
styles: (p) => {
|
||||
@@ -69,9 +84,9 @@ registerIndicator({
|
||||
name: 'pv-kdj',
|
||||
shortName: 'KDJ',
|
||||
figures: [
|
||||
{ key: 'k', title: 'K', type: 'line', styles: () => ({ color: C1 }) },
|
||||
{ key: 'd', title: 'D', type: 'line', styles: () => ({ color: C2 }) },
|
||||
{ key: 'j', title: 'J', type: 'line', styles: () => ({ color: UP }) },
|
||||
{ key: 'k', title: 'K', type: 'line', styles: () => ({ color: '#2563eb' }) },
|
||||
{ key: 'd', title: 'D', type: 'line', styles: () => ({ color: '#f59e0b' }) },
|
||||
{ key: 'j', title: 'J', type: 'line', styles: () => ({ color: '#dc2626' }) },
|
||||
],
|
||||
calc: (d: KLineData[]) => d.map((_, i) => ({ k: g('k')(i), d: g('d')(i), j: g('j')(i) })),
|
||||
});
|
||||
@@ -80,63 +95,145 @@ registerIndicator({
|
||||
name: 'pv-rsi',
|
||||
shortName: 'RSI',
|
||||
figures: [
|
||||
{ key: 'rsi6', title: 'RSI6', type: 'line', styles: () => ({ color: C1 }) },
|
||||
{ key: 'rsi12', title: 'RSI12', type: 'line', styles: () => ({ color: C2 }) },
|
||||
{ key: 'rsi24', title: 'RSI24', type: 'line', styles: () => ({ color: C3 }) },
|
||||
{ key: 'rsi6', title: 'RSI6', type: 'line', styles: () => ({ color: '#2563eb' }) },
|
||||
{ key: 'rsi12', title: 'RSI12', type: 'line', styles: () => ({ color: '#f59e0b' }) },
|
||||
{ key: 'rsi24', title: 'RSI24', type: 'line', styles: () => ({ color: '#a855f7' }) },
|
||||
],
|
||||
calc: (d: KLineData[]) => d.map((_, i) => ({ rsi6: g('rsi6')(i), rsi12: g('rsi12')(i), rsi24: g('rsi24')(i) })),
|
||||
});
|
||||
|
||||
const container = ref<HTMLDivElement | null>(null);
|
||||
let chart: Chart | null = null;
|
||||
let allData: KLineData[] = [];
|
||||
let served = 0; // 已交给图表的 bar 数(从尾部计),backward 分页用
|
||||
|
||||
const LIGHT_STYLES = {
|
||||
grid: { horizontal: { color: '#eef2f7' }, vertical: { color: '#eef2f7' } },
|
||||
candle: {
|
||||
bar: {
|
||||
upColor: UP, downColor: DOWN,
|
||||
upBorderColor: UP, downBorderColor: DOWN,
|
||||
upWickColor: UP, downWickColor: DOWN,
|
||||
const INIT_BARS = 240; // 初始展示根数(约一年日线)
|
||||
const PAGE_BARS = 500; // 每次向左滚动追加的历史根数
|
||||
|
||||
function lightStyles() {
|
||||
return {
|
||||
grid: { horizontal: { color: '#eef2f7' }, vertical: { color: '#eef2f7' } },
|
||||
candle: {
|
||||
bar: {
|
||||
upColor: UP, downColor: DOWN,
|
||||
upBorderColor: UP, downBorderColor: DOWN,
|
||||
upWickColor: UP, downWickColor: DOWN,
|
||||
},
|
||||
priceMark: {
|
||||
high: { color: '#94a3b8' }, low: { color: '#94a3b8' },
|
||||
last: { upColor: UP, downColor: DOWN },
|
||||
},
|
||||
},
|
||||
priceMark: {
|
||||
high: { color: '#94a3b8' }, low: { color: '#94a3b8' },
|
||||
last: { upColor: UP, downColor: DOWN },
|
||||
xAxis: { axisLine: { color: '#e2e8f0' }, tickText: { color: '#64748b' }, tickLine: { color: '#e2e8f0' } },
|
||||
yAxis: { axisLine: { color: '#e2e8f0' }, tickText: { color: '#64748b' }, tickLine: { color: '#e2e8f0' } },
|
||||
crosshair: {
|
||||
horizontal: { text: { backgroundColor: '#1e293b' } },
|
||||
vertical: { text: { backgroundColor: '#1e293b' } },
|
||||
},
|
||||
},
|
||||
xAxis: { axisLine: { color: '#e2e8f0' }, tickText: { color: '#64748b' }, tickLine: { color: '#e2e8f0' } },
|
||||
yAxis: { axisLine: { color: '#e2e8f0' }, tickText: { color: '#64748b' }, tickLine: { color: '#e2e8f0' } },
|
||||
crosshair: {
|
||||
horizontal: { text: { backgroundColor: '#1e293b' } },
|
||||
vertical: { text: { backgroundColor: '#1e293b' } },
|
||||
},
|
||||
separator: { color: '#e2e8f0' },
|
||||
};
|
||||
separator: { color: '#e2e8f0' },
|
||||
};
|
||||
}
|
||||
|
||||
// 副图默认高度
|
||||
const SUB_HEIGHT: Record<string, number> = { vol: 64, macd: 100, kdj: 96, rsi: 84 };
|
||||
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);
|
||||
|
||||
// ---------- 鼠标跟随信息框(通达信式) ----------
|
||||
interface HoverInfo {
|
||||
date: string; open: number; high: number; low: number; close: number;
|
||||
chg: number | null; amp: number | null; vol: string; amount: string | null;
|
||||
mas: { label: string; value: number | null; color: string }[];
|
||||
}
|
||||
const hover = ref<HoverInfo | null>(null);
|
||||
|
||||
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 bindCrosshair(ch: Chart) {
|
||||
ch.subscribeAction('onCrosshairChange', (payload) => {
|
||||
const k = (payload as { data?: { kLineData?: KLineData } }).data?.kLineData;
|
||||
if (!k || !allData.length) { hover.value = null; return; }
|
||||
// 二分定位索引(全量数组与指标序列按索引对齐)
|
||||
let lo = 0, hi = allData.length - 1, idx = -1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (allData[mid].timestamp === k.timestamp) { idx = mid; break; }
|
||||
if (allData[mid].timestamp < k.timestamp) lo = mid + 1; else hi = mid - 1;
|
||||
}
|
||||
if (idx < 0) { hover.value = null; return; }
|
||||
const prev = idx > 0 ? allData[idx - 1] : null;
|
||||
const chg = prev ? ((k.close - prev.close) / prev.close) * 100 : null;
|
||||
const amp = prev ? ((k.high - k.low) / prev.close) * 100 : null;
|
||||
hover.value = {
|
||||
date: new Date(k.timestamp).toLocaleDateString('zh-CN'),
|
||||
open: k.open, high: k.high, low: k.low, close: k.close,
|
||||
chg, amp, vol: fmtVol(k.volume ?? 0), amount: null,
|
||||
mas: props.maPeriods.map((p, i) => ({
|
||||
label: `MA${p}`,
|
||||
value: PV[`ma${p}`]?.[idx] ?? null,
|
||||
color: MA_COLORS[i % MA_COLORS.length],
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 画图画线(通达信式工具栏) ----------
|
||||
const TOOLS: { key: string; label: string; title: string }[] = [
|
||||
{ key: '', label: '光标', title: '光标模式(Esc 取消画线)' },
|
||||
{ key: 'segment', label: '╱', title: '线段' },
|
||||
{ key: 'ray', label: '→', title: '射线' },
|
||||
{ key: 'horizontalLine', label: '─', title: '水平线' },
|
||||
{ key: 'rect', label: '▭', title: '矩形' },
|
||||
{ key: 'priceChannelLine', label: '∥', title: '价格通道' },
|
||||
{ key: 'fibLine', label: 'fib', title: '斐波那契回撤' },
|
||||
];
|
||||
const activeTool = ref('');
|
||||
|
||||
function pickTool(key: string) {
|
||||
activeTool.value = key;
|
||||
if (chart && key) chart.createOverlay({ name: key });
|
||||
}
|
||||
|
||||
function clearOverlays() {
|
||||
chart?.removeOverlay();
|
||||
activeTool.value = '';
|
||||
}
|
||||
|
||||
function build() {
|
||||
if (!container.value || props.candles.length === 0) return;
|
||||
UP = settings.upHex;
|
||||
DOWN = settings.downHex;
|
||||
PV = {};
|
||||
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: LIGHT_STYLES });
|
||||
const ch = init(container.value, { styles: lightStyles() });
|
||||
if (!ch) return;
|
||||
chart = ch;
|
||||
|
||||
const data: KLineData[] = props.candles.map((k) => ({
|
||||
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;
|
||||
ch.setDataLoader({
|
||||
getBars: ({ type, callback }) => {
|
||||
if (type === 'update') {
|
||||
const last = data[data.length - 1];
|
||||
callback(last ? [last] : [], { backward: false, forward: false });
|
||||
const last = allData[allData.length - 1];
|
||||
callback(last ? [last] : [], { backward: served < allData.length, forward: false });
|
||||
} else if (type === 'init') {
|
||||
callback(data, { backward: false, forward: false });
|
||||
// 全量已拉到本地:先给最近 INIT_BARS 根,向左滚动时按页吐更早历史
|
||||
served = Math.min(INIT_BARS, allData.length);
|
||||
callback(allData.slice(allData.length - served), { backward: served < allData.length, forward: false });
|
||||
} else if (type === 'backward') {
|
||||
const remain = allData.length - served;
|
||||
const take = Math.min(PAGE_BARS, remain);
|
||||
const start = allData.length - served - take;
|
||||
served += take;
|
||||
callback(allData.slice(start, start + take), { backward: served < allData.length, forward: false });
|
||||
} else {
|
||||
callback([], { backward: false, forward: false });
|
||||
}
|
||||
@@ -146,21 +243,22 @@ function build() {
|
||||
ch.setSymbol({ ticker: props.ticker });
|
||||
ch.setPeriod({ type: 'day', span: 1 });
|
||||
|
||||
// 主图:MA 恒开,BOLL 可选
|
||||
ch.createIndicator({ name: 'pv-ma', paneId: 'candle_pane' });
|
||||
// 主图:MA(周期可配置)恒开,BOLL 可选
|
||||
ch.createIndicator({ name: ensureMaIndicator(props.maPeriods), paneId: 'candle_pane' });
|
||||
if (props.showBoll) ch.createIndicator({ name: 'pv-boll', paneId: 'candle_pane' });
|
||||
|
||||
// 副图按用户顺序创建,并压矮;主图吃剩余高度
|
||||
const subHeights = props.subPanes.reduce((s, k) => s + (SUB_HEIGHT[k] ?? 90), 0);
|
||||
// 副图按用户顺序创建,并设置用户高度;主图吃剩余高度
|
||||
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(220, total - subHeights - 24) });
|
||||
ch.setPaneOptions({ id: 'candle_pane', height: Math.max(200, total - subTotal - 24) });
|
||||
for (const key of props.subPanes) {
|
||||
if (key === 'vol') ch.createIndicator('VOL');
|
||||
else ch.createIndicator(`pv-${key}`);
|
||||
const paneId = ch.getIndicators().find((i) => i.name === (key === 'vol' ? 'VOL' : `pv-${key}`))?.paneId;
|
||||
if (paneId) ch.setPaneOptions({ id: paneId, height: SUB_HEIGHT[key] ?? 90 });
|
||||
const name = key === 'vol' ? 'VOL' : `pv-${key}`;
|
||||
ch.createIndicator(name);
|
||||
const paneId = ch.getIndicators().find((i) => i.name === name)?.paneId;
|
||||
if (paneId) ch.setPaneOptions({ id: paneId, height: subH(key) });
|
||||
}
|
||||
|
||||
bindCrosshair(ch);
|
||||
ch.setOffsetRightDistance(28);
|
||||
ch.scrollToRealTime();
|
||||
}
|
||||
@@ -168,13 +266,71 @@ function build() {
|
||||
function teardown() {
|
||||
if (container.value) dispose(container.value);
|
||||
chart = null;
|
||||
hover.value = null;
|
||||
activeTool.value = '';
|
||||
}
|
||||
|
||||
onMounted(build);
|
||||
onBeforeUnmount(teardown);
|
||||
watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll], () => { teardown(); build(); }, { deep: true });
|
||||
watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll, props.maPeriods, props.timeframe], () => { teardown(); build(); }, { 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) });
|
||||
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) });
|
||||
}
|
||||
}, { deep: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="h-full w-full"></div>
|
||||
<div class="relative h-full w-full">
|
||||
<div ref="container" class="h-full w-full"></div>
|
||||
|
||||
<!-- 鼠标跟随信息框(通达信式小方块) -->
|
||||
<div
|
||||
v-if="hover"
|
||||
class="pointer-events-none absolute left-2 top-2 z-10 rounded border border-slate-700 bg-slate-900/90 px-2.5 py-1.5 font-mono text-[11px] leading-4 text-slate-200 shadow-lg"
|
||||
>
|
||||
<div class="text-slate-400">{{ hover.date }}</div>
|
||||
<div>开 <span :class="hover.chg != null && hover.chg >= 0 ? 'text-red-400' : 'text-emerald-400'">{{ hover.open.toFixed(2) }}</span>
|
||||
高 <span class="text-red-400">{{ hover.high.toFixed(2) }}</span>
|
||||
低 <span class="text-emerald-400">{{ hover.low.toFixed(2) }}</span>
|
||||
收 <span :class="hover.chg != null && hover.chg >= 0 ? 'text-red-400' : 'text-emerald-400'">{{ hover.close.toFixed(2) }}</span></div>
|
||||
<div>幅 <span :class="hover.chg != null && hover.chg >= 0 ? 'text-red-400' : 'text-emerald-400'">{{ hover.chg == null ? '—' : (hover.chg > 0 ? '+' : '') + hover.chg.toFixed(2) + '%' }}</span>
|
||||
振 <span class="text-slate-100">{{ hover.amp == null ? '—' : hover.amp.toFixed(2) + '%' }}</span>
|
||||
量 <span class="text-slate-100">{{ hover.vol }}</span></div>
|
||||
<div v-if="hover.mas.length" class="mt-0.5">
|
||||
<span v-for="(m, i) in hover.mas" :key="m.label" class="mr-2" :style="{ color: m.color }">
|
||||
{{ m.label }} {{ m.value == null ? '—' : m.value.toFixed(2) }}<span v-if="i < hover.mas.length - 1" class="invisible">,</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 画图画线工具栏 -->
|
||||
<div class="absolute right-2 top-2 z-10 flex items-center gap-0.5 rounded-md border border-slate-200 bg-white/95 px-1 py-0.5 shadow-sm">
|
||||
<button
|
||||
v-for="t in TOOLS"
|
||||
:key="t.key || 'cursor'"
|
||||
type="button"
|
||||
class="min-w-6 rounded px-1 py-0.5 text-[11px] transition-colors"
|
||||
:class="activeTool === t.key ? 'bg-blue-600 text-white' : 'text-slate-500 hover:bg-slate-100 hover:text-slate-900'"
|
||||
:title="t.title"
|
||||
@click="pickTool(t.key)"
|
||||
>{{ t.label }}</button>
|
||||
<span class="mx-0.5 h-3 w-px bg-slate-200"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-1 py-0.5 text-[11px] text-red-500 transition-colors hover:bg-red-50"
|
||||
title="清除全部画线"
|
||||
@click="clearOverlays"
|
||||
>清除</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user