Files

491 lines
20 KiB
HTML
Raw Permalink Normal View History

2026-05-26 19:06:28 +08:00
<!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>