feat(rotation): add position weight to detail JSON and viewer

- Record position_weights in daily_records during backtest run
- Export weight field per held asset in detail JSON
- Display weight percentage in backtest_viewer holdings cards
- Force-add backtest_viewer.html (previously ignored by *.html rule)
This commit is contained in:
2026-06-06 22:39:23 +08:00
parent 4973a9a2a5
commit eb3c82f05b
2 changed files with 718 additions and 0 deletions

View File

@@ -0,0 +1,715 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ETF轮动策略回测回放器</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, 'SF Mono', 'Menlo', monospace; background: #0d1117; color: #c9d1d9; font-size: 13px; }
.loader { display: flex; align-items: center; justify-content: center; height: 100vh; flex-direction: column; gap: 16px; }
.loader input[type="file"] { display: none; }
.loader label { padding: 16px 32px; background: #238636; color: #fff; border-radius: 8px; cursor: pointer; font-size: 16px; }
.loader label:hover { background: #2ea043; }
.loader .hint { color: #8b949e; }
.app { display: none; height: 100vh; flex-direction: column; overflow: hidden; }
.app.active { display: flex; }
/* Header */
.header { display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: #161b22; border-bottom: 1px solid #30363d; flex-shrink: 0; }
.header .date-display { font-size: 18px; font-weight: 600; color: #58a6ff; min-width: 120px; }
.header .nav-val { font-size: 16px; font-weight: 600; min-width: 100px; }
.header .daily-ret { font-size: 14px; min-width: 80px; font-weight: 600; }
.header .rebal-badge { background: #da3633; color: #fff; padding: 2px 8px; border-radius: 4px; font-size: 11px; }
/* Controls */
.controls { display: flex; align-items: center; gap: 8px; padding: 6px 16px; background: #161b22; border-bottom: 1px solid #30363d; flex-shrink: 0; }
.controls button { background: #21262d; border: 1px solid #30363d; color: #c9d1d9; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; }
.controls button:hover { background: #30363d; }
.controls button:active { background: #484f58; }
.controls input[type="date"] { background: #21262d; border: 1px solid #30363d; color: #c9d1d9; padding: 4px 8px; border-radius: 4px; font-size: 12px; }
.controls input[type="range"] { width: 80px; }
.controls .speed-label { color: #8b949e; font-size: 11px; }
.controls .day-counter { color: #8b949e; font-size: 11px; margin-left: auto; }
/* Nav Chart */
.chart-container { height: 180px; padding: 8px 16px; background: #0d1117; flex-shrink: 0; border-bottom: 1px solid #30363d; position: relative; }
.chart-container canvas { width: 100%; height: 100%; }
/* Stats panel */
.stats-panel { display: flex; gap: 16px; padding: 8px 16px; background: #161b22; border-bottom: 1px solid #30363d; flex-shrink: 0; flex-wrap: wrap; }
.stat-item { display: flex; flex-direction: column; min-width: 90px; }
.stat-item .stat-label { color: #8b949e; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
.stat-item .stat-value { font-size: 14px; font-weight: 600; margin-top: 1px; }
/* Main content */
.main { display: grid; grid-template-columns: 280px 1fr; flex: 1; overflow: hidden; }
/* Holdings panel */
.holdings-panel { padding: 12px; border-right: 1px solid #30363d; overflow-y: auto; background: #161b22; }
.holdings-panel h3 { color: #8b949e; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
.holding-card { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 10px; margin-bottom: 8px; }
.holding-card.positive { border-left: 3px solid #3fb950; }
.holding-card.negative { border-left: 3px solid #da3633; }
.holding-card .code { font-weight: 600; color: #58a6ff; font-size: 14px; }
.holding-card .name { color: #8b949e; font-size: 11px; }
.holding-card .details { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; margin-top: 6px; font-size: 11px; }
.holding-card .details .label { color: #8b949e; }
.holding-card .details .value { text-align: right; }
/* Ranking table */
.ranking-panel { display: flex; flex-direction: column; overflow: hidden; }
.ranking-table-wrap { flex: 1; overflow-y: auto; padding: 0 12px 12px; }
.ranking-panel h3 { color: #8b949e; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; padding: 12px 12px 8px; flex-shrink: 0; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
thead { position: sticky; top: 0; background: #0d1117; z-index: 1; }
th { text-align: left; padding: 6px 8px; color: #8b949e; border-bottom: 1px solid #30363d; font-weight: 500; }
td { padding: 5px 8px; border-bottom: 1px solid #21262d; }
tr.held { background: rgba(56, 139, 253, 0.1); }
tr.held td:first-child { border-left: 3px solid #58a6ff; }
tr.below-threshold { color: #484f58; }
.positive { color: #3fb950; }
.negative { color: #da3633; }
.neutral { color: #8b949e; }
/* Rebalance bar */
.rebalance-bar { padding: 8px 16px; background: #1c1206; border-top: 1px solid #5a3e00; flex-shrink: 0; display: none; font-size: 12px; }
.rebalance-bar.active { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
.rebalance-bar .tag-in { background: #238636; color: #fff; padding: 1px 6px; border-radius: 3px; font-size: 11px; }
.rebalance-bar .tag-out { background: #da3633; color: #fff; padding: 1px 6px; border-radius: 3px; font-size: 11px; }
</style>
</head>
<body>
<div class="loader" id="loader">
<div style="font-size: 20px; font-weight: 600;">ETF 轮动策略回测回放器</div>
<div class="hint">加载 backtest_detail.json 开始回放</div>
<label for="file-input">选择 JSON 文件</label>
<input type="file" id="file-input" accept=".json">
<div class="hint" id="load-status"></div>
</div>
<div class="app" id="app">
<div class="header">
<span class="date-display" id="date-display"></span>
<span class="nav-val" id="nav-display"></span>
<span class="daily-ret" id="ret-display"></span>
<span class="rebal-badge" id="rebal-badge" style="display:none">调仓</span>
</div>
<div class="controls">
<button id="btn-prev" title="上一天 (←)">&#9664; 前一天</button>
<button id="btn-next" title="下一天 (→)">后一天 &#9654;</button>
<button id="btn-prev-rebal" title="上一调仓日">&#9664;&#9664; 上一调仓</button>
<button id="btn-next-rebal" title="下一调仓日">下一调仓 &#9654;&#9654;</button>
<input type="date" id="date-picker">
<button id="btn-play" title="播放/暂停 (Space)">&#9654; 播放</button>
<span class="speed-label">速度:</span>
<input type="range" id="speed-slider" min="1" max="10" value="5">
<span class="day-counter" id="day-counter"></span>
</div>
<div class="chart-container">
<canvas id="nav-chart"></canvas>
</div>
<div class="stats-panel" id="stats-panel"></div>
<div class="main">
<div class="holdings-panel">
<h3>当日持仓</h3>
<div id="holdings-cards"></div>
</div>
<div class="ranking-panel">
<h3>全部标的排名 (按动量排序)</h3>
<div class="ranking-table-wrap">
<table>
<thead>
<tr>
<th>#</th>
<th>代码</th>
<th>名称</th>
<th>大类</th>
<th>动量</th>
<th>阈值</th>
<th>指数价</th>
<th>ETF价</th>
<th>指数收益</th>
<th>ETF收益</th>
<th>溢价率</th>
<th>持仓</th>
</tr>
</thead>
<tbody id="ranking-body"></tbody>
</table>
</div>
</div>
</div>
<div class="rebalance-bar" id="rebalance-bar"></div>
</div>
<script>
let DATA = null;
let currentIdx = 0;
let playing = false;
let playTimer = null;
let rebalanceDays = [];
const $ = id => document.getElementById(id);
// File loading
$('file-input').addEventListener('change', e => {
const file = e.target.files[0];
if (!file) return;
$('load-status').textContent = '加载中...';
const reader = new FileReader();
reader.onload = ev => {
try {
DATA = JSON.parse(ev.target.result);
initApp();
} catch (err) {
$('load-status').textContent = '解析失败: ' + err.message;
}
};
reader.readAsText(file);
});
function initApp() {
$('loader').style.display = 'none';
$('app').classList.add('active');
// Build rebalance day index
rebalanceDays = [];
DATA.days.forEach((d, i) => { if (d.is_rebalance) rebalanceDays.push(i); });
// Set date picker range
$('date-picker').min = DATA.days[0].date;
$('date-picker').max = DATA.days[DATA.days.length - 1].date;
drawChart();
render(0);
}
// Navigation
$('btn-prev').addEventListener('click', () => navigate(-1));
$('btn-next').addEventListener('click', () => navigate(1));
$('btn-prev-rebal').addEventListener('click', () => navigateRebalance(-1));
$('btn-next-rebal').addEventListener('click', () => navigateRebalance(1));
$('date-picker').addEventListener('change', e => {
const target = e.target.value;
const idx = DATA.days.findIndex(d => d.date === target);
if (idx >= 0) { currentIdx = idx; render(currentIdx); drawChartMarker(); }
});
$('btn-play').addEventListener('click', togglePlay);
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT') return;
if (e.key === 'ArrowLeft') { e.preventDefault(); navigate(-1); }
if (e.key === 'ArrowRight') { e.preventDefault(); navigate(1); }
if (e.key === ' ') { e.preventDefault(); togglePlay(); }
});
function navigate(delta) {
const next = currentIdx + delta;
if (next >= 0 && next < DATA.days.length) {
currentIdx = next;
render(currentIdx);
drawChartMarker();
}
}
function navigateRebalance(dir) {
if (dir > 0) {
const next = rebalanceDays.find(i => i > currentIdx);
if (next !== undefined) { currentIdx = next; render(currentIdx); drawChartMarker(); }
} else {
const prev = [...rebalanceDays].reverse().find(i => i < currentIdx);
if (prev !== undefined) { currentIdx = prev; render(currentIdx); drawChartMarker(); }
}
}
function togglePlay() {
playing = !playing;
$('btn-play').innerHTML = playing ? '&#9646;&#9646; 暂停' : '&#9654; 播放';
if (playing) {
playTick();
} else {
clearTimeout(playTimer);
}
}
function playTick() {
if (!playing) return;
if (currentIdx < DATA.days.length - 1) {
currentIdx++;
render(currentIdx);
drawChartMarker();
const speed = parseInt($('speed-slider').value);
const delay = 1100 - speed * 100; // 1000ms ~ 100ms
playTimer = setTimeout(playTick, delay);
} else {
playing = false;
$('btn-play').innerHTML = '&#9654; 播放';
}
}
function fmtPct(v, decimals) {
if (v === null || v === undefined) return '-';
return (v * 100).toFixed(decimals !== undefined ? decimals : 2) + '%';
}
function fmtNum(v, decimals) {
if (v === null || v === undefined) return '-';
return Number(v).toFixed(decimals !== undefined ? decimals : 2);
}
function retClass(v) {
if (v === null || v === undefined) return 'neutral';
return v > 0 ? 'positive' : v < 0 ? 'negative' : 'neutral';
}
function render(idx) {
const day = DATA.days[idx];
const meta = DATA.meta;
// Header
$('date-display').textContent = day.date;
$('nav-display').textContent = '净值 ' + fmtNum(day.nav, 4);
$('nav-display').className = 'nav-val';
const retEl = $('ret-display');
retEl.textContent = (day.daily_return >= 0 ? '+' : '') + fmtPct(day.daily_return);
retEl.className = 'daily-ret ' + retClass(day.daily_return);
const badge = $('rebal-badge');
badge.style.display = day.is_rebalance ? 'inline' : 'none';
$('day-counter').textContent = `${idx + 1} / ${DATA.days.length} 天 | 调仓 ${rebalanceDays.filter(i => i <= idx).length}`;
$('date-picker').value = day.date;
// Stats panel
renderStats(idx);
// Holdings cards
renderHoldings(day, meta);
// Ranking table
renderRanking(day, meta);
// Rebalance bar
renderRebalanceBar(day, meta);
}
function renderStats(idx) {
const panel = $('stats-panel');
const days = DATA.days;
// Slice up to current day
const navs = [];
for (let i = 0; i <= idx; i++) {
if (days[i].nav !== null) navs.push(days[i].nav);
}
if (navs.length < 2) {
panel.innerHTML = '<span class="stat-item"><span class="stat-label">等待数据...</span></span>';
return;
}
const startNav = navs[0];
const curNav = navs[navs.length - 1];
const totalReturn = curNav / startNav - 1;
const tradingDays = navs.length;
const years = tradingDays / 252; // 统一使用 252 天A股标准与 rotation.py 一致
const cagr = years > 0 ? Math.pow(curNav / startNav, 1 / years) - 1 : 0;
// Max drawdown
let maxDD = 0;
let peak = navs[0];
for (let i = 1; i < navs.length; i++) {
if (navs[i] > peak) peak = navs[i];
const dd = navs[i] / peak - 1;
if (dd < maxDD) maxDD = dd;
}
// Volatility & Sharpe使用 252 天,与 rotation.py 一致)
const rets = [];
for (let i = 1; i < navs.length; i++) {
rets.push(navs[i] / navs[i-1] - 1);
}
const meanRet = rets.reduce((a, b) => a + b, 0) / rets.length;
const variance = rets.reduce((a, b) => a + (b - meanRet) ** 2, 0) / rets.length;
const vol = Math.sqrt(variance) * Math.sqrt(252);
const sharpe = vol > 0 ? (meanRet * 252) / vol : 0;
const calmar = maxDD !== 0 ? cagr / Math.abs(maxDD) : 0;
// Rebalance count & avg holding days
const rebalCount = rebalanceDays.filter(i => i <= idx).length;
let avgHoldDays = '-';
if (rebalCount > 1) {
let totalHold = 0;
let prevRebal = rebalanceDays[0];
let cnt = 0;
for (const ri of rebalanceDays) {
if (ri > idx) break;
if (cnt > 0) totalHold += ri - prevRebal;
prevRebal = ri;
cnt++;
}
if (cnt > 1) avgHoldDays = (totalHold / (cnt - 1)).toFixed(1);
}
// Win rate (daily)
const winDays = rets.filter(r => r > 0).length;
const winRate = rets.length > 0 ? winDays / rets.length : 0;
// Year-to-date return
const curYear = days[idx].date.substring(0, 4);
let ytdReturn = null;
for (let i = idx; i >= 0; i--) {
if (days[i].date.substring(0, 4) !== curYear) {
const prevYearEndNav = days[i].nav;
if (prevYearEndNav && curNav) ytdReturn = curNav / prevYearEndNav - 1;
break;
}
}
const items = [
{ label: 'CAGR', value: fmtPct(cagr), cls: retClass(cagr) },
{ label: '累计收益', value: fmtPct(totalReturn), cls: retClass(totalReturn) },
{ label: '最大回撤', value: fmtPct(maxDD), cls: 'negative' },
{ label: '年化波动', value: fmtPct(vol), cls: 'neutral' },
{ label: '夏普', value: fmtNum(sharpe, 3), cls: retClass(sharpe) },
{ label: 'Calmar', value: fmtNum(calmar, 3), cls: retClass(calmar) },
{ label: '胜率', value: fmtPct(winRate), cls: 'neutral' },
{ label: '调仓次数', value: String(rebalCount), cls: 'neutral' },
{ label: '平均持仓', value: avgHoldDays + '天', cls: 'neutral' },
{ label: '本年收益', value: ytdReturn !== null ? fmtPct(ytdReturn) : '-', cls: retClass(ytdReturn) },
{ label: '交易天数', value: String(tradingDays), cls: 'neutral' },
];
panel.innerHTML = items.map(it =>
`<div class="stat-item"><span class="stat-label">${it.label}</span><span class="stat-value ${it.cls}">${it.value}</span></div>`
).join('');
}
function renderHoldings(day, meta) {
const container = $('holdings-cards');
if (!day.holdings || day.holdings.length === 0) {
container.innerHTML = '<div style="color:#8b949e;padding:16px;text-align:center;">无持仓</div>';
return;
}
let html = '';
for (const code of day.holdings) {
const a = day.assets[code];
if (!a) continue;
const info = meta.codes[code] || {};
const cumEtf = a.cum_return_etf;
const cumIdx = a.cum_return_idx;
const mainCum = cumEtf !== null && cumEtf !== undefined ? cumEtf : cumIdx;
const cls = mainCum !== null && mainCum !== undefined ? (mainCum >= 0 ? 'positive' : 'negative') : '';
html += `<div class="holding-card ${cls}">
<span class="code">${code}</span> <span class="name">${info.name || ''}</span>
${info.etf ? `<span class="name"> (${info.etf})</span>` : ''}
<div class="details">
<span class="label">入场日</span><span class="value">${a.entry_date || '-'}</span>
<span class="label">持有天数</span><span class="value">${a.holding_days || 0}</span>
<span class="label">仓位占比</span><span class="value" style="color:#f0c000;font-weight:600">${a.weight !== null && a.weight !== undefined ? fmtPct(a.weight) : '-'}</span>
<span class="label">大类</span><span class="value">${info.market || '-'}</span>
<span class="label">ETF累计</span><span class="value ${retClass(cumEtf)}">${fmtPct(cumEtf)}</span>
<span class="label">指数累计</span><span class="value ${retClass(cumIdx)}">${fmtPct(cumIdx)}</span>
<span class="label">今日ETF</span><span class="value ${retClass(a.etf_return_ctc)}">${fmtPct(a.etf_return_ctc)}</span>
<span class="label">今日指数</span><span class="value ${retClass(a.index_return)}">${fmtPct(a.index_return)}</span>
<span class="label">动量</span><span class="value">${fmtNum(a.momentum, 4)}</span>
</div>
</div>`;
}
container.innerHTML = html;
}
function renderRanking(day, meta) {
const tbody = $('ranking-body');
const assets = day.assets;
const codes = Object.keys(assets);
// Sort by momentum descending, nulls last
codes.sort((a, b) => {
const ma = assets[a].momentum;
const mb = assets[b].momentum;
if (ma === null && mb === null) return 0;
if (ma === null) return 1;
if (mb === null) return -1;
return mb - ma;
});
const threshold = codes.length > 0 && assets[codes[0]].threshold !== null
? assets[codes[0]].threshold : 0;
let html = '';
let thresholdDrawn = false;
for (let i = 0; i < codes.length; i++) {
const code = codes[i];
const a = assets[code];
const info = meta.codes[code] || {};
const isHeld = a.is_held;
const belowThreshold = a.momentum !== null && a.momentum < threshold;
// Insert threshold separator row before first below-threshold item
if (!thresholdDrawn && belowThreshold) {
html += `<tr><td colspan="12" style="color:#da3633;font-size:11px;text-align:center;border-bottom:2px dashed #da3633;padding:2px;">--- 动态阈值: ${fmtNum(threshold, 4)} ---</td></tr>`;
thresholdDrawn = true;
}
let rowClass = '';
if (isHeld) rowClass += ' held';
if (belowThreshold) rowClass += ' below-threshold';
// Use rank from data, but show sequential for display
const displayRank = a.rank !== null ? a.rank : '-';
html += `<tr class="${rowClass}">
<td>${displayRank}</td>
<td>${code}</td>
<td>${info.name || ''}</td>
<td>${info.market || ''}</td>
<td>${fmtNum(a.momentum, 4)}</td>
<td>${fmtNum(threshold, 4)}</td>
<td>${fmtNum(a.index_close, 2)}</td>
<td>${a.etf_close !== null ? fmtNum(a.etf_close, 3) : '-'}</td>
<td class="${retClass(a.index_return)}">${fmtPct(a.index_return)}</td>
<td class="${retClass(a.etf_return_ctc)}">${fmtPct(a.etf_return_ctc)}</td>
<td class="${a.premium > 0.05 ? 'negative' : a.premium < -0.02 ? 'positive' : 'neutral'}">${a.premium !== null && a.premium !== undefined ? fmtPct(a.premium) : '-'}</td>
<td>${isHeld ? '<span style="color:#58a6ff">&#9679;</span>' : ''}</td>
</tr>`;
}
// If threshold line not drawn yet (all above), add it at the bottom
if (!thresholdDrawn && threshold > 0) {
html += `<tr class="threshold-line"><td colspan="12" style="color:#da3633;font-size:11px;text-align:center;">--- 动态阈值: ${fmtNum(threshold, 4)} ---</td></tr>`;
}
tbody.innerHTML = html;
}
function renderRebalanceBar(day, meta) {
const bar = $('rebalance-bar');
if (!day.is_rebalance) {
bar.classList.remove('active');
return;
}
bar.classList.add('active');
let html = '<strong>调仓:</strong>';
if (day.added.length > 0) {
html += ' 调入: ';
html += day.added.map(c => {
const name = (meta.codes[c] || {}).name || '';
return `<span class="tag-in">${c} (${name})</span>`;
}).join(' ');
}
if (day.removed.length > 0) {
html += ' 调出: ';
html += day.removed.map(c => {
const name = (meta.codes[c] || {}).name || '';
return `<span class="tag-out">${c} (${name})</span>`;
}).join(' ');
}
// Calculate turnover
const prevHoldings = currentIdx > 0 ? DATA.days[currentIdx - 1].holdings : [];
const swapped = day.removed.length;
const total = Math.max(prevHoldings.length, 1);
const turnover = swapped / total;
const cost = turnover * meta.trade_cost;
html += `<span style="color:#8b949e;margin-left:12px;">换手 ${fmtPct(turnover, 0)} | 成本 ${fmtPct(cost, 2)}</span>`;
bar.innerHTML = html;
}
// ==================== Chart ====================
let chartCanvas, chartCtx;
let chartData = { navs: [], dates: [], rebalIdx: [] };
function drawChart() {
chartCanvas = $('nav-chart');
chartCtx = chartCanvas.getContext('2d');
// Retina support
const rect = chartCanvas.parentElement.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
chartCanvas.width = rect.width * dpr;
chartCanvas.height = rect.height * dpr;
chartCtx.scale(dpr, dpr);
chartCanvas.style.width = rect.width + 'px';
chartCanvas.style.height = rect.height + 'px';
chartData.navs = DATA.days.map(d => d.nav);
chartData.dates = DATA.days.map(d => d.date);
chartData.rebalIdx = rebalanceDays;
drawChartFull();
drawChartMarker();
}
function drawChartFull() {
const ctx = chartCtx;
const W = chartCanvas.width / (window.devicePixelRatio || 1);
const H = chartCanvas.height / (window.devicePixelRatio || 1);
const pad = { top: 20, right: 60, bottom: 20, left: 10 };
const cw = W - pad.left - pad.right;
const ch = H - pad.top - pad.bottom;
const navs = chartData.navs;
const minNav = Math.min(...navs.filter(v => v !== null)) * 0.98;
const maxNav = Math.max(...navs.filter(v => v !== null)) * 1.02;
const n = navs.length;
ctx.clearRect(0, 0, W, H);
// Grid lines
ctx.strokeStyle = '#21262d';
ctx.lineWidth = 0.5;
for (let i = 0; i <= 4; i++) {
const y = pad.top + ch * i / 4;
ctx.beginPath();
ctx.moveTo(pad.left, y);
ctx.lineTo(W - pad.right, y);
ctx.stroke();
const val = maxNav - (maxNav - minNav) * i / 4;
ctx.fillStyle = '#484f58';
ctx.font = '10px monospace';
ctx.textAlign = 'left';
ctx.fillText(val.toFixed(2), W - pad.right + 4, y + 3);
}
// Nav line
ctx.strokeStyle = '#58a6ff';
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let i = 0; i < n; i++) {
const x = pad.left + (i / (n - 1)) * cw;
const y = pad.top + (1 - (navs[i] - minNav) / (maxNav - minNav)) * ch;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Rebalance dots
ctx.fillStyle = 'rgba(218, 54, 51, 0.6)';
for (const ri of chartData.rebalIdx) {
const x = pad.left + (ri / (n - 1)) * cw;
const y = pad.top + (1 - (navs[ri] - minNav) / (maxNav - minNav)) * ch;
ctx.beginPath();
ctx.arc(x, y, 2, 0, Math.PI * 2);
ctx.fill();
}
// Store layout for marker
chartData.layout = { pad, cw, ch, minNav, maxNav, n, W, H };
}
function drawChartMarker() {
if (!chartData.layout) return;
const { pad, cw, ch, minNav, maxNav, n, W, H } = chartData.layout;
// Redraw full chart then add marker
drawChartFull();
const ctx = chartCtx;
const x = pad.left + (currentIdx / (n - 1)) * cw;
// Vertical line
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(x, pad.top);
ctx.lineTo(x, pad.top + ch);
ctx.stroke();
ctx.setLineDash([]);
// Dot
const nav = chartData.navs[currentIdx];
const y = pad.top + (1 - (nav - minNav) / (maxNav - minNav)) * ch;
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(x, y, 4, 0, Math.PI * 2);
ctx.fill();
// Label
ctx.fillStyle = '#fff';
ctx.font = '11px monospace';
ctx.textAlign = x > W / 2 ? 'right' : 'left';
const labelX = x > W / 2 ? x - 8 : x + 8;
ctx.fillText(fmtNum(nav, 4), labelX, y - 8);
}
// ==================== Chart interaction (click & drag) ====================
let isDragging = false;
function xToIndex(clientX) {
if (!chartData.layout) return -1;
const rect = chartCanvas.getBoundingClientRect();
const { pad, cw, n } = chartData.layout;
const x = clientX - rect.left - pad.left;
const ratio = Math.max(0, Math.min(1, x / cw));
return Math.round(ratio * (n - 1));
}
function chartNavigateTo(clientX) {
const idx = xToIndex(clientX);
if (idx >= 0 && idx < DATA.days.length && idx !== currentIdx) {
currentIdx = idx;
render(currentIdx);
drawChartMarker();
}
}
$('nav-chart').addEventListener('mousedown', e => {
if (!DATA) return;
isDragging = true;
chartNavigateTo(e.clientX);
});
window.addEventListener('mousemove', e => {
if (!isDragging) return;
chartNavigateTo(e.clientX);
});
window.addEventListener('mouseup', () => {
isDragging = false;
});
// Touch support
$('nav-chart').addEventListener('touchstart', e => {
if (!DATA) return;
isDragging = true;
chartNavigateTo(e.touches[0].clientX);
}, { passive: true });
window.addEventListener('touchmove', e => {
if (!isDragging) return;
chartNavigateTo(e.touches[0].clientX);
}, { passive: true });
window.addEventListener('touchend', () => {
isDragging = false;
});
// Cursor style
$('nav-chart').style.cursor = 'crosshair';
// Resize handler
window.addEventListener('resize', () => {
if (DATA) { drawChart(); }
});
</script>
</body>
</html>