491 lines
20 KiB
HTML
491 lines
20 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>静态股票图表 (离线版)</title>
|
||
<style>
|
||
html, body {
|
||
height: 100%;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
#chart-container, #tv-chart {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="chart-container">
|
||
<div id="tv-chart"></div>
|
||
<div class="loading-overlay" id="loading-overlay">
|
||
<div class="spinner"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let isFrist = true;
|
||
let shapesDrawn = new Set();
|
||
const symbol = "{{ symbol }}";
|
||
const interval = "{{ interval }}";
|
||
const chan_config = {{ chan_config | tojson }};
|
||
const STATIC_DATA = {{ static_data | tojson }};
|
||
const STATIC_SHAPES = {{ static_shapes | tojson }};
|
||
|
||
function createShape(shape) {
|
||
const chart = window._tvWidget?.activeChart();
|
||
if (!chart) return;
|
||
const shapePoints = shape.points.map(p => ({time: p.time, price: p.price}));
|
||
const options = {
|
||
shape: shape.shapeType, lock: false, disableSelection: false,
|
||
disableSave: false, disableUndo: true, overrides: shape.overrides || {}, zOrder: 'top',
|
||
};
|
||
try {
|
||
const result = chart.createMultipointShape(shapePoints, options);
|
||
if (!result) return;
|
||
if (typeof result === 'object' && typeof result.then === 'function') {
|
||
result.then(id => console.log('图形创建成功, ID:', id));
|
||
} else {
|
||
console.log('图形创建成功, ID:', result);
|
||
}
|
||
} catch (e) {
|
||
console.warn('创建图形失败:', e);
|
||
}
|
||
}
|
||
|
||
function shapeReady(shape, latestTimeMs) {
|
||
if (!shape.points || shape.points.length === 0) return false;
|
||
const maxTimeSec = Math.max(...shape.points.map(p => p.time));
|
||
return maxTimeSec * 1000 <= latestTimeMs;
|
||
}
|
||
|
||
function drawShapesUpTo(latestTime) {
|
||
STATIC_SHAPES.forEach((shape, idx) => {
|
||
if (!shapesDrawn.has(idx) && shapeReady(shape, latestTime)) {
|
||
createShape(shape);
|
||
shapesDrawn.add(idx);
|
||
}
|
||
});
|
||
}
|
||
|
||
class StaticDatafeed {
|
||
constructor() {
|
||
this._subscribers = {};
|
||
this._allBars = STATIC_DATA.bars;
|
||
this._pushIndex = 0;
|
||
}
|
||
|
||
onReady(callback) {
|
||
setTimeout(() => callback({
|
||
supports_search: false, supports_group_request: false,
|
||
supports_marks: false, supports_timescale_marks: true,
|
||
supports_time: true, supported_resolutions: [interval],
|
||
}), 0);
|
||
}
|
||
|
||
resolveSymbol(symbolName, onResolve, onError) {
|
||
const sym = symbolName.toUpperCase();
|
||
const symbolInfo = {
|
||
exchange: "exchange", ticker: symbol, name: symbol,
|
||
description: symbol, type: "Unknown", session: "24x7",
|
||
timezone: "Asia/Shanghai", minmov: 1, pricescale: 100,
|
||
visible_plots_set: "ohlcv", has_no_volume: false,
|
||
has_weekly_and_monthly: false, supported_resolutions: [interval],
|
||
volume_precision: 1, has_intraday: true, has_daily: true,
|
||
monthly_multipliers: [], weekly_multipliers: [], format: "price",
|
||
};
|
||
setTimeout(() => onResolve(symbolInfo), 0);
|
||
}
|
||
|
||
searchSymbols(userInput, exchange, symbolType, onResult) {
|
||
}
|
||
|
||
getBars(symbolInfo, resolution, periodParams, onResult, onError) {
|
||
console.log("[Datafeed.getBars]", symbolInfo, resolution, periodParams);
|
||
if (isFrist) {
|
||
const initialCount = Math.min(10, this._allBars.length);
|
||
const bars = this._allBars.slice(0, initialCount).map(([t, o, h, l, c, v]) => ({
|
||
time: t * 1000, open: o, high: h, low: l, close: c, volume: v
|
||
}));
|
||
this._pushIndex = initialCount;
|
||
isFrist = false;
|
||
setTimeout(() => onResult(bars, {noData: false}), 0);
|
||
} else {
|
||
onResult([], {noData: true});
|
||
}
|
||
}
|
||
|
||
subscribeBars(symbolInfo, resolution, onTick, listenerGuid, onResetCacheNeededCallback) {
|
||
this.unsubscribeBars(listenerGuid);
|
||
const bars = this._allBars;
|
||
let idx = this._pushIndex;
|
||
if (idx > 0) {
|
||
const lastBar = bars[idx - 1]; // 最后一条已加载的K线
|
||
drawShapesUpTo(lastBar[0] * 1000); // 转为毫秒
|
||
}
|
||
const sub = {};
|
||
sub.timerId = setInterval(() => {
|
||
if (idx >= bars.length) {
|
||
clearInterval(sub.timerId);
|
||
return;
|
||
}
|
||
const [t, o, h, l, c, v] = bars[idx];
|
||
const bar = {time: t * 1000, open: o, high: h, low: l, close: c, volume: v};
|
||
onTick(bar);
|
||
drawShapesUpTo(bar.time);
|
||
idx++;
|
||
}, 20);
|
||
this._subscribers[listenerGuid] = sub;
|
||
}
|
||
|
||
unsubscribeBars(listenerGuid) {
|
||
const sub = this._subscribers[listenerGuid];
|
||
if (sub && sub.timerId) clearInterval(sub.timerId);
|
||
delete this._subscribers[listenerGuid];
|
||
}
|
||
|
||
getServerTime(callback) {
|
||
setTimeout(() => callback(Math.floor(Date.now() / 1000)), 0);
|
||
}
|
||
}
|
||
|
||
// 创建静态图形的函数
|
||
function createStaticShapes(chart) {
|
||
STATIC_SHAPES.forEach(shape => {
|
||
const shapePoints = shape.points.map(p => ({
|
||
time: p.time,
|
||
price: p.price
|
||
}));
|
||
const options = {
|
||
shape: shape.shapeType,
|
||
lock: false,
|
||
disableSelection: false,
|
||
disableSave: false,
|
||
disableUndo: true,
|
||
overrides: shape.overrides || {},
|
||
zOrder: 'top',
|
||
};
|
||
try {
|
||
const result = chart.createMultipointShape(shapePoints, options);
|
||
if (result === null) return;
|
||
if (typeof result === 'object' && typeof result.then === 'function') {
|
||
result.then(id => console.log('图形创建成功, ID:', id));
|
||
} else {
|
||
console.log('图形创建成功, ID:', result);
|
||
}
|
||
} catch (e) {
|
||
console.warn('创建图形失败:', e);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ====================== 图形控制面板 ======================
|
||
function formatPeriod(seconds) {
|
||
const map = {
|
||
60: '1分钟', 180: '3分钟', 300: '5分钟', 900: '15分钟', 1800: '30分钟',
|
||
3600: '1小时', 7200: '2小时', 14400: '4小时', 21600: '6小时',
|
||
43200: '12小时', 86400: '日线', 259200: '3日线'
|
||
};
|
||
return map[seconds] || `${seconds}秒`;
|
||
}
|
||
|
||
function setShapesVisibleByPeriod(rawType, periodSeconds, visible) {
|
||
if (!window._tvWidget || !window._tvWidget.activeChart()) return;
|
||
const allShapes = window._tvWidget.activeChart().getAllShapes();
|
||
allShapes.forEach(({id}) => {
|
||
const shape = window._tvWidget.activeChart().getShapeById(id);
|
||
if (!shape) return;
|
||
const props = shape.getProperties();
|
||
const title = props.title || '';
|
||
const parts = title.split(':');
|
||
if (parts.length < 3) return;
|
||
const [, periodStr, typeFromTitle] = parts;
|
||
const periodFromTitle = Number(periodStr);
|
||
if (typeFromTitle === rawType && periodFromTitle === periodSeconds) {
|
||
shape.setProperties({visible});
|
||
}
|
||
});
|
||
}
|
||
|
||
function isPeriodVisible(rawType, periodSeconds) {
|
||
if (!window._tvWidget || !window._tvWidget.activeChart()) return true;
|
||
const allShapes = window._tvWidget.activeChart().getAllShapes();
|
||
let hasAny = false;
|
||
for (const {id} of allShapes) {
|
||
const shape = window._tvWidget.activeChart().getShapeById(id);
|
||
if (!shape) continue;
|
||
const props = shape.getProperties();
|
||
const title = props.title || '';
|
||
const parts = title.split(':');
|
||
if (parts.length < 3) continue;
|
||
const [, periodStr, typeFromTitle] = parts;
|
||
const periodFromTitle = Number(periodStr);
|
||
if (typeFromTitle === rawType && periodFromTitle === periodSeconds) {
|
||
hasAny = true;
|
||
if (props.visible === false) return false;
|
||
}
|
||
}
|
||
return hasAny;
|
||
}
|
||
|
||
function setupCustomDropdown(triggerElement, widget) {
|
||
const panel = document.createElement('div');
|
||
panel.style.cssText = `
|
||
position: absolute; z-index: 10000;
|
||
background: ${widget.getTheme() === 'dark' ? '#1e2532' : '#ffffff'};
|
||
border-radius: 8px; box-shadow: 0 6px 20px rgba(0,0,0,0.3);
|
||
padding: 8px 0; min-width: 240px; max-height: 70vh; overflow-y: auto;
|
||
border: 1px solid ${widget.getTheme() === 'dark' ? '#3a4a6a' : '#d1d4dc'};
|
||
color: ${widget.getTheme() === 'dark' ? '#d1d9e6' : '#131722'};
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Trebuchet MS', Roboto, sans-serif;
|
||
display: none;
|
||
backdrop-filter: blur(4px);
|
||
`;
|
||
document.body.appendChild(panel);
|
||
|
||
const refreshMenu = () => {
|
||
panel.innerHTML = '';
|
||
if (!window._tvWidget || !window._tvWidget.activeChart()) return;
|
||
const allShapes = window._tvWidget.activeChart().getAllShapes();
|
||
|
||
const typePeriods = {}; // { rawType -> Set of periods }
|
||
|
||
allShapes.forEach(({id}) => {
|
||
const shape = window._tvWidget.activeChart().getShapeById(id);
|
||
if (!shape) return;
|
||
const props = shape.getProperties();
|
||
const title = props.title || '';
|
||
const parts = title.split(':');
|
||
if (parts.length < 3) return;
|
||
const period = Number(parts[1]);
|
||
const rawType = parts[2]; // 直接使用第三段
|
||
if (!typePeriods[rawType]) typePeriods[rawType] = new Set();
|
||
typePeriods[rawType].add(period);
|
||
});
|
||
|
||
if (Object.keys(typePeriods).length === 0) {
|
||
const empty = document.createElement('div');
|
||
empty.textContent = '暂无图形数据';
|
||
empty.style.padding = '12px 16px';
|
||
empty.style.opacity = '0.7';
|
||
panel.appendChild(empty);
|
||
return;
|
||
}
|
||
|
||
// 为每个基础类型创建折叠组
|
||
Object.keys(typePeriods).sort().forEach(baseType => {
|
||
const section = document.createElement('div');
|
||
const header = document.createElement('div');
|
||
header.style.cssText = `
|
||
padding: 10px 16px; cursor: pointer; display: flex; align-items: center;
|
||
justify-content: space-between; user-select: none;
|
||
background: ${widget.getTheme() === 'dark' ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.02)'};
|
||
border-bottom: 1px solid rgba(128,128,128,0.2);
|
||
`;
|
||
header.innerHTML = `<span style="font-weight:600;">${baseType}</span><span class="toggle-arrow" style="transition: transform 0.2s;">▼</span>`;
|
||
const content = document.createElement('div');
|
||
content.style.display = 'block';
|
||
|
||
header.addEventListener('click', () => {
|
||
const arrow = header.querySelector('.toggle-arrow');
|
||
if (content.style.display === 'none') {
|
||
content.style.display = 'block';
|
||
arrow.style.transform = 'rotate(0deg)';
|
||
} else {
|
||
content.style.display = 'none';
|
||
arrow.style.transform = 'rotate(-90deg)';
|
||
}
|
||
});
|
||
|
||
const periods = Array.from(typePeriods[baseType]).sort((a, b) => a - b);
|
||
periods.forEach(period => {
|
||
const row = document.createElement('div');
|
||
row.style.cssText = `
|
||
padding: 6px 16px 6px 28px; display: flex; align-items: center;
|
||
cursor: pointer; transition: background 0.15s;
|
||
`;
|
||
row.addEventListener('mouseenter', () => row.style.background = widget.getTheme() === 'dark' ? '#2a374b' : '#f0f3fa');
|
||
row.addEventListener('mouseleave', () => row.style.background = 'transparent');
|
||
|
||
const checkbox = document.createElement('input');
|
||
checkbox.type = 'checkbox';
|
||
checkbox.checked = isPeriodVisible(baseType, period);
|
||
checkbox.style.cssText = `
|
||
margin-right: 12px; accent-color: #2962ff;
|
||
width: 16px; height: 16px; cursor: pointer;
|
||
`;
|
||
|
||
const label = document.createElement('span');
|
||
label.textContent = formatPeriod(period);
|
||
label.style.flex = '1';
|
||
|
||
row.appendChild(checkbox);
|
||
row.appendChild(label);
|
||
|
||
row.addEventListener('click', (e) => {
|
||
if (e.target !== checkbox) {
|
||
checkbox.checked = !checkbox.checked;
|
||
}
|
||
setShapesVisibleByPeriod(baseType, period, checkbox.checked);
|
||
});
|
||
|
||
content.appendChild(row);
|
||
});
|
||
|
||
section.appendChild(header);
|
||
section.appendChild(content);
|
||
panel.appendChild(section);
|
||
});
|
||
|
||
// 底部全局控制按钮
|
||
const footer = document.createElement('div');
|
||
footer.style.cssText = `
|
||
border-top: 1px solid ${widget.getTheme() === 'dark' ? '#3a4a6a' : '#d1d4dc'};
|
||
padding: 8px 16px; display: flex; gap: 8px;
|
||
`;
|
||
const showAllBtn = document.createElement('button');
|
||
showAllBtn.textContent = '全部显示';
|
||
showAllBtn.style.cssText = `flex:1; padding: 4px; background: #2563eb; color:white; border:none; border-radius: 4px; cursor:pointer;`;
|
||
showAllBtn.addEventListener('click', () => {
|
||
Object.keys(typePeriods).forEach(baseType => {
|
||
typePeriods[baseType].forEach(period => {
|
||
setShapesVisibleByPeriod(baseType, period, true);
|
||
});
|
||
});
|
||
refreshMenu();
|
||
});
|
||
const hideAllBtn = document.createElement('button');
|
||
hideAllBtn.textContent = '全部隐藏';
|
||
hideAllBtn.style.cssText = `flex:1; padding: 4px; background: #ef4444; color:white; border:none; border-radius: 4px; cursor:pointer;`;
|
||
hideAllBtn.addEventListener('click', () => {
|
||
Object.keys(typePeriods).forEach(baseType => {
|
||
typePeriods[baseType].forEach(period => {
|
||
setShapesVisibleByPeriod(baseType, period, false);
|
||
});
|
||
});
|
||
refreshMenu();
|
||
});
|
||
footer.appendChild(showAllBtn);
|
||
footer.appendChild(hideAllBtn);
|
||
panel.appendChild(footer);
|
||
};
|
||
|
||
const showPanel = () => {
|
||
refreshMenu();
|
||
panel.style.display = 'block';
|
||
const rect = triggerElement.getBoundingClientRect();
|
||
// 等待 layout 完成以获取正确的 panel 宽度
|
||
requestAnimationFrame(() => {
|
||
const panelWidth = panel.offsetWidth;
|
||
let left = rect.right - panelWidth;
|
||
if (left < 0) left = 5;
|
||
panel.style.top = `${rect.bottom + 4}px`;
|
||
panel.style.left = `${left}px`;
|
||
});
|
||
};
|
||
const hidePanel = () => {
|
||
panel.style.display = 'none';
|
||
};
|
||
|
||
triggerElement.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
panel.style.display === 'block' ? hidePanel() : showPanel();
|
||
});
|
||
document.addEventListener('click', (e) => {
|
||
if (!panel.contains(e.target) && e.target !== triggerElement) hidePanel();
|
||
});
|
||
window.addEventListener('resize', () => {
|
||
if (panel.style.display === 'block') showPanel();
|
||
});
|
||
|
||
// 保存引用以便外部强制刷新
|
||
this.refreshDropdown = refreshMenu;
|
||
}
|
||
|
||
function createButton() {
|
||
// 创建自定义按钮(注意:useTradingViewStyle 省略或设为 false)
|
||
const customBtn = window._tvWidget.createButton({
|
||
align: 'right' // 可选 'left' 或 'right'
|
||
});
|
||
|
||
// 设置按钮内容与样式
|
||
customBtn.innerHTML = `⚙️ 图表工具`;
|
||
customBtn.style.cssText = `
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 32px;
|
||
padding: 0 12px;
|
||
margin: 0 4px;
|
||
border-radius: 4px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: ${window._tvWidget.getTheme() === 'dark' ? '#d1d4dc' : '#131722'};
|
||
background: transparent;
|
||
border: none;
|
||
cursor: pointer;
|
||
transition: background 0.2s, color 0.2s;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Trebuchet MS', Roboto, Ubuntu, sans-serif;
|
||
user-select: none;
|
||
`;
|
||
|
||
customBtn.addEventListener('mouseenter', () => {
|
||
customBtn.style.background = window._tvWidget.getTheme() === 'dark'
|
||
? 'rgba(255, 255, 255, 0.08)'
|
||
: 'rgba(0, 0, 0, 0.05)';
|
||
});
|
||
customBtn.addEventListener('mouseleave', () => {
|
||
customBtn.style.background = 'transparent';
|
||
});
|
||
|
||
// 现在你可以安全地使用 customBtn 作为触发器
|
||
this.setupCustomDropdown(customBtn, window._tvWidget);
|
||
}
|
||
|
||
function initChart() {
|
||
if (typeof TradingView === 'undefined') {
|
||
document.getElementById('tv-chart').innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#888;">请将 charting_library 放在同级目录</div>';
|
||
document.getElementById('loading-overlay').classList.add('hidden');
|
||
return;
|
||
}
|
||
|
||
const widget = new TradingView.widget({
|
||
container: 'tv-chart', library_path: 'charting_library/', locale: 'zh',
|
||
datafeed: new StaticDatafeed(), symbol, interval,
|
||
favorites: {drawingTools: ["LineToolPath", "LineToolRectangle", "LineToolTrendLine"]},
|
||
disabled_features: [
|
||
"use_localstorage_for_settings", "header_symbol_search",
|
||
"header_undo_redo", "header_screenshot", "header_compare",
|
||
"header_chart_type", "go_to_date",
|
||
],
|
||
theme: "light",
|
||
fullscreen: false, autosize: true,
|
||
//toolbar_bg: '#2a2a3e',
|
||
overrides: {
|
||
//'paneProperties.background': '#1e1e2e',
|
||
//'mainSeriesProperties.candleStyle.upColor': '#00c853',
|
||
//'mainSeriesProperties.candleStyle.downColor': '#ff1744',
|
||
}
|
||
});
|
||
window._tvWidget = widget;
|
||
|
||
widget.onChartReady(() => {
|
||
document.getElementById('loading-overlay').classList.add('hidden');
|
||
console.log('✅ 开始实时回放');
|
||
});
|
||
|
||
widget.headerReady().then(() => {
|
||
widget.activeChart().createStudy("MACD", false, false, {
|
||
in_0: 13, in_1: 31, in_2: 11, in_3: "close"
|
||
});
|
||
createButton();
|
||
});
|
||
}
|
||
|
||
window.addEventListener('DOMContentLoaded', initChart);
|
||
</script>
|
||
<script src="charting_library/charting_library.js"></script>
|
||
</body>
|
||
</html> |