charts: cœur du moteur (panneaux, rendu bougies/volume/axes, crosshair, interactions, dessins, harnais, captures)
17 changed files +2,965 −5
added
hfmarketdata/web/dev/charts-harness.html
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +<!doctype html> | |
| 2 | +<html lang="en"> | |
| 3 | +<head> | |
| 4 | + <meta charset="utf-8" /> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> | |
| 6 | + <title>hfmarketdata · chart engine harness</title> | |
| 7 | + <style> | |
| 8 | + :root { --bg: #0a0c10; --bg-1: #10131a; --line: #232833; --fg: #e8ebf1; --fg-2: #8f98a8; --accent: #34d399; } | |
| 9 | + [data-theme="light"] { --bg: #ffffff; --bg-1: #ffffff; --line: #e2e5eb; --fg: #12161d; --fg-2: #4f5a6b; --accent: #0f766e; } | |
| 10 | + * { box-sizing: border-box; } | |
| 11 | + html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg); font: 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } | |
| 12 | + body { display: flex; flex-direction: column; overflow: hidden; } | |
| 13 | + header { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; padding: 6px 8px; border-bottom: 1px solid var(--line); background: var(--bg); } | |
| 14 | + header .group { display: flex; gap: 4px; align-items: center; padding-right: 8px; border-right: 1px solid var(--line); } | |
| 15 | + header .group:last-child { border-right: 0; } | |
| 16 | + button, select { font: inherit; color: var(--fg); background: var(--bg-1); border: 1px solid var(--line); border-radius: 6px; padding: 4px 8px; cursor: pointer; min-height: 28px; } | |
| 17 | + button:hover, select:hover { border-color: var(--fg-2); } | |
| 18 | + button.active { border-color: var(--accent); color: var(--accent); } | |
| 19 | + button.tool { min-width: 28px; } | |
| 20 | + #legend { position: absolute; left: 10px; top: 8px; z-index: 5; pointer-events: none; font-family: "JetBrains Mono", ui-monospace, Menlo, monospace; font-size: 12px; color: var(--fg); background: color-mix(in srgb, var(--bg) 70%, transparent); padding: 4px 8px; border-radius: 6px; display: flex; flex-direction: column; gap: 2px; } | |
| 21 | + #legend .row { display: flex; gap: 10px; align-items: baseline; } | |
| 22 | + #legend .sym { font-weight: 700; } | |
| 23 | + #legend .muted { color: var(--fg-2); } | |
| 24 | + #legend .up { color: #2fbf71; } #legend .down { color: #e5484d; } | |
| 25 | + #legend .k { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 4px; vertical-align: middle; } | |
| 26 | + main { position: relative; flex: 1; min-height: 0; } | |
| 27 | + #chart { position: absolute; inset: 0; } | |
| 28 | + #status { position: absolute; right: 8px; bottom: 32px; z-index: 5; font-size: 11px; color: var(--fg-2); background: color-mix(in srgb, var(--bg) 75%, transparent); padding: 2px 6px; border-radius: 4px; pointer-events: none; } | |
| 29 | + @media (max-width: 640px) { header { gap: 4px; padding: 4px; } header .group { padding-right: 4px; } button, select { padding: 4px 6px; } .desktop-only { display: none; } } | |
| 30 | + </style> | |
| 31 | +</head> | |
| 32 | +<body> | |
| 33 | + <header id="toolbar"></header> | |
| 34 | + <main> | |
| 35 | + <div id="legend"></div> | |
| 36 | + <div id="chart"></div> | |
| 37 | + <div id="status"></div> | |
| 38 | + </main> | |
| 39 | + <script type="module" src="./charts-harness.js"></script> | |
| 40 | +</body> | |
| 41 | +</html> | |
added
hfmarketdata/web/dev/charts-harness.js
+258 −0
@@ -0,0 +1,258 @@ | ||
| 1 | +// Visual harness for the chart engine: synthetic but realistic data (sessions, gaps), every series type, | |
| 2 | +// indicator, drawing tool and theme, plus a programmatic pan/zoom benchmark used by scripts/charts-shots.mjs. | |
| 3 | +// | |
| 4 | +// URL params: ?tf=1min|1day&n=50000&theme=dark|light&type=candles&ind=rsi,macd&mode=log|percent&compare=1 | |
| 5 | +// &drawings=1&volume=0&watermark=…&reduced=1 | |
| 6 | + | |
| 7 | +import { createChart, darkTheme, lightTheme, SERIES_TYPES, INDICATOR_TYPES, DRAWING_TOOLS } from '../src/charts/engine/index.js' | |
| 8 | + | |
| 9 | +/* ───────────── synthetic data ───────────── */ | |
| 10 | + | |
| 11 | +function mulberry32(seed) { | |
| 12 | + let a = seed >>> 0 | |
| 13 | + return () => { a = (a + 0x6D2B79F5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296 } | |
| 14 | +} | |
| 15 | +function gauss(rnd) { let u = 0, v = 0; while (u === 0) u = rnd(); while (v === 0) v = rnd(); return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v) } | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * Random-walk bars. 1min: sessions 09:30–16:00 ET (390 bars/day), weekdays only, overnight / weekend gaps with a | |
| 19 | + * jump at the open. 1day: weekdays only. Timestamps are wall-clock encoded with Date.UTC (contract). | |
| 20 | + */ | |
| 21 | +export function makeBars({ tf = '1min', count = 50_000, seed = 42, start = Date.UTC(2023, 0, 2), price = 187.5, vol = 0.00035 } = {}) { | |
| 22 | + const rnd = mulberry32(seed) | |
| 23 | + const bars = [] | |
| 24 | + let p = price | |
| 25 | + let sigma = vol | |
| 26 | + let t = start | |
| 27 | + const dayMs = 86_400_000 | |
| 28 | + const isWeekend = ts => { const d = new Date(ts).getUTCDay(); return d === 0 || d === 6 } | |
| 29 | + if (tf === '1day') { | |
| 30 | + let d = Math.floor(t / dayMs) * dayMs | |
| 31 | + while (bars.length < count) { | |
| 32 | + if (!isWeekend(d)) { | |
| 33 | + sigma = Math.max(0.006, Math.min(0.05, sigma * 0.97 + 0.015 * 0.03 + Math.abs(gauss(rnd)) * 0.002)) | |
| 34 | + const o = p * (1 + gauss(rnd) * sigma * 0.4) | |
| 35 | + let hi = o, lo = o, c = o | |
| 36 | + for (let k = 0; k < 8; k++) { c = c * (1 + gauss(rnd) * sigma * 0.35); hi = Math.max(hi, c); lo = Math.min(lo, c) } | |
| 37 | + const v = Math.round((4e6 + 3e6 * Math.abs(gauss(rnd))) * (1 + 6 * Math.abs(c - o) / o)) | |
| 38 | + bars.push({ t: d, o: r2(o), h: r2(hi), l: r2(lo), c: r2(c), v }) | |
| 39 | + p = c | |
| 40 | + } | |
| 41 | + d += dayMs | |
| 42 | + } | |
| 43 | + return bars | |
| 44 | + } | |
| 45 | + const stepMs = { '1min': 60_000, '5min': 300_000, '30min': 1_800_000, '1hour': 3_600_000 }[tf] || 60_000 | |
| 46 | + const perDay = Math.floor((6.5 * 3_600_000) / stepMs) | |
| 47 | + let day = Math.floor(t / dayMs) * dayMs | |
| 48 | + while (bars.length < count) { | |
| 49 | + if (isWeekend(day)) { day += dayMs; continue } | |
| 50 | + // Overnight gap. | |
| 51 | + p = p * (1 + gauss(rnd) * 0.006) | |
| 52 | + sigma = Math.max(0.00015, Math.min(0.002, sigma * 0.9 + vol * 0.1 + Math.abs(gauss(rnd)) * 0.00005)) | |
| 53 | + for (let k = 0; k < perDay && bars.length < count; k++) { | |
| 54 | + const ts = day + 9.5 * 3_600_000 + k * stepMs | |
| 55 | + // U-shaped intraday activity. | |
| 56 | + const u = k / perDay | |
| 57 | + const act = 0.6 + 1.6 * (Math.pow(u - 0.5, 2) * 4) | |
| 58 | + const s = sigma * act | |
| 59 | + const o = p | |
| 60 | + let hi = o, lo = o, c = o | |
| 61 | + for (let m = 0; m < 4; m++) { c = c * (1 + gauss(rnd) * s * 0.5); hi = Math.max(hi, c); lo = Math.min(lo, c) } | |
| 62 | + const v = Math.round((12_000 + 20_000 * act) * (0.5 + Math.abs(gauss(rnd)))) | |
| 63 | + bars.push({ t: ts, o: r2(o), h: r2(hi), l: r2(lo), c: r2(c), v }) | |
| 64 | + p = c | |
| 65 | + } | |
| 66 | + day += dayMs | |
| 67 | + } | |
| 68 | + return bars | |
| 69 | +} | |
| 70 | +const r2 = v => Math.round(v * 100) / 100 | |
| 71 | + | |
| 72 | +/* ───────────── page ───────────── */ | |
| 73 | + | |
| 74 | +const q = new URLSearchParams(location.search) | |
| 75 | +const state = { | |
| 76 | + tf: q.get('tf') || '1min', | |
| 77 | + n: Number(q.get('n')) || 50_000, | |
| 78 | + theme: q.get('theme') || 'dark', | |
| 79 | + type: q.get('type') || 'candles', | |
| 80 | + mode: q.get('mode') || 'linear', | |
| 81 | + volume: q.get('volume') !== '0', | |
| 82 | + crosshair: q.get('crosshair') || 'normal', | |
| 83 | + symbol: q.get('symbol') || 'AAPL', | |
| 84 | +} | |
| 85 | +document.documentElement.dataset.theme = state.theme | |
| 86 | + | |
| 87 | +const container = document.getElementById('chart') | |
| 88 | +const chart = createChart(container, { | |
| 89 | + theme: state.theme === 'light' ? lightTheme : darkTheme, | |
| 90 | + timeframe: state.tf, | |
| 91 | + sessionLabel: 'ET', | |
| 92 | + watermark: q.get('watermark') ?? `${state.symbol} · ${state.tf === '1day' ? '1D' : state.tf} · HF Market Data`, | |
| 93 | + reducedMotion: q.get('reduced') === '1', | |
| 94 | +}) | |
| 95 | +window.__chart = chart | |
| 96 | + | |
| 97 | +let bars = makeBars({ tf: state.tf, count: state.n, seed: 42 }) | |
| 98 | +// Keep a hidden older history so "needMoreLeft" can be served (prepend without jump). | |
| 99 | +const OLDER = 20_000 | |
| 100 | +let historyCursor = 0 | |
| 101 | +let all = bars | |
| 102 | +if (bars.length > OLDER + 500) { all = bars; bars = all.slice(OLDER); historyCursor = OLDER } | |
| 103 | +chart.setData(bars) | |
| 104 | +chart.setSeriesType(state.type) | |
| 105 | +chart.setVolume(state.volume) | |
| 106 | +chart.setPriceScale({ mode: state.mode }) | |
| 107 | +chart.setCrosshair({ mode: state.crosshair }) | |
| 108 | + | |
| 109 | +chart.on('visibleRangeChange', r => { | |
| 110 | + if (r.needMoreLeft && historyCursor > 0) { | |
| 111 | + const chunk = all.slice(Math.max(0, historyCursor - 5000), historyCursor) | |
| 112 | + historyCursor -= chunk.length | |
| 113 | + chart.prependData(chunk) | |
| 114 | + status(`prepended ${chunk.length} bars · ${chart.getData().length} loaded`) | |
| 115 | + } | |
| 116 | +}) | |
| 117 | + | |
| 118 | +/* ───────────── toolbar ───────────── */ | |
| 119 | + | |
| 120 | +const bar = document.getElementById('toolbar') | |
| 121 | +const group = () => { const g = document.createElement('div'); g.className = 'group'; bar.appendChild(g); return g } | |
| 122 | +const btn = (parent, label, onClick, cls = '') => { const b = document.createElement('button'); b.textContent = label; b.className = cls; b.addEventListener('click', onClick); parent.appendChild(b); return b } | |
| 123 | +const sel = (parent, options, value, onChange) => { const s = document.createElement('select'); for (const o of options) { const op = document.createElement('option'); op.value = o; op.textContent = o; s.appendChild(op) } s.value = value; s.addEventListener('change', () => onChange(s.value)); parent.appendChild(s); return s } | |
| 124 | + | |
| 125 | +let g = group() | |
| 126 | +sel(g, ['1min:50000', '1min:200000', '1day:10000', '5min:30000'], `${state.tf}:${state.n}`, v => { const [tf, n] = v.split(':'); location.search = `?tf=${tf}&n=${n}&theme=${state.theme}&type=${state.type}` }) | |
| 127 | +btn(g, state.theme === 'dark' ? 'Light' : 'Dark', () => { state.theme = state.theme === 'dark' ? 'light' : 'dark'; document.documentElement.dataset.theme = state.theme; chart.setTheme(state.theme === 'light' ? lightTheme : darkTheme); refreshActive() }) | |
| 128 | + | |
| 129 | +g = group() | |
| 130 | +sel(g, SERIES_TYPES, state.type, v => { state.type = v; chart.setSeriesType(v) }) | |
| 131 | +const volBtn = btn(g, 'Vol', () => { state.volume = !state.volume; chart.setVolume(state.volume); refreshActive() }) | |
| 132 | +sel(g, ['linear', 'log', 'percent'], state.mode, v => { state.mode = v; chart.setPriceScale({ mode: v }) }) | |
| 133 | +btn(g, 'Invert', () => chart.setPriceScale({ invert: !chart.getPriceScale().invert })) | |
| 134 | +sel(g, ['normal', 'magnet', 'hidden'], state.crosshair, v => chart.setCrosshair({ mode: v })) | |
| 135 | + | |
| 136 | +g = group() | |
| 137 | +const indSel = sel(g, ['+ indicator', ...INDICATOR_TYPES], '+ indicator', v => { if (v !== '+ indicator') { chart.addIndicator({ type: v }); indSel.value = '+ indicator'; updateIndicatorList() } }) | |
| 138 | +btn(g, 'Clear ind.', () => { for (const i of chart.getIndicators()) chart.removeIndicator(i.id); updateIndicatorList() }) | |
| 139 | +const cmpBtn = btn(g, 'Compare', () => { | |
| 140 | + if (chart._cmp) { chart.removeCompare('MSFT'); chart.removeCompare('SPY'); chart._cmp = false } | |
| 141 | + else { chart.addCompare('MSFT', 'MSFT', makeBars({ tf: state.tf, count: all.length, seed: 7, price: 402, vol: 0.0003 })); chart.addCompare('SPY', 'SPY', makeBars({ tf: state.tf, count: all.length, seed: 99, price: 512, vol: 0.00025 })); chart._cmp = true } | |
| 142 | + refreshActive() | |
| 143 | +}) | |
| 144 | + | |
| 145 | +g = group() | |
| 146 | +const toolButtons = {} | |
| 147 | +const glyph = { trendline: '╱', ray: '→', extended: '↔', hline: '─', vline: '│', rect: '▭', fib: 'Fib', measure: '📏', text: 'T', arrow: '➚', channel: '∥', brush: '✎' } | |
| 148 | +for (const t of DRAWING_TOOLS) toolButtons[t] = btn(g, glyph[t] || t, () => chart.setDrawingTool(chart.drawings.tool === t ? null : t), 'tool') | |
| 149 | +btn(g, 'Undo', () => chart.undo(), 'desktop-only'); btn(g, 'Redo', () => chart.redo(), 'desktop-only') | |
| 150 | +btn(g, 'Clear', () => chart.clearDrawings(), 'desktop-only') | |
| 151 | +chart.on('toolChange', t => { for (const k of Object.keys(toolButtons)) toolButtons[k].classList.toggle('active', k === t) }) | |
| 152 | + | |
| 153 | +g = group() | |
| 154 | +btn(g, 'Fit', () => chart.fitContent(true)); btn(g, 'Latest', () => chart.scrollToLatest(true)); btn(g, 'Reset', () => chart.resetView()) | |
| 155 | +btn(g, '+', () => chart.zoom(1.3)); btn(g, '−', () => chart.zoom(1 / 1.3)) | |
| 156 | +let live = 0 | |
| 157 | +const liveBtn = btn(g, 'Live', () => { if (live) { clearInterval(live); live = 0 } else live = setInterval(tick, 400); refreshActive() }, 'desktop-only') | |
| 158 | +btn(g, 'PNG', async () => { const blob = await chart.toPNG({ scale: 2, watermark: `${state.symbol} · ${state.tf}` }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'chart.png'; a.click() }, 'desktop-only') | |
| 159 | +btn(g, 'Bench', async () => status(`bench: ${JSON.stringify(await bench(100))}`), 'desktop-only') | |
| 160 | + | |
| 161 | +function refreshActive() { volBtn.classList.toggle('active', state.volume); cmpBtn.classList.toggle('active', !!chart._cmp); liveBtn.classList.toggle('active', !!live) } | |
| 162 | +refreshActive() | |
| 163 | + | |
| 164 | +let indicatorIds = [] | |
| 165 | +function updateIndicatorList() { indicatorIds = chart.getIndicators().map(i => i.id) } | |
| 166 | + | |
| 167 | +/* ───────────── live simulation ───────────── */ | |
| 168 | + | |
| 169 | +const rndLive = mulberry32(1234) | |
| 170 | +function tick() { | |
| 171 | + const data = chart.getData() | |
| 172 | + const last = data[data.length - 1] | |
| 173 | + const step = state.tf === '1day' ? 86_400_000 : 60_000 | |
| 174 | + const rollover = rndLive() < 0.15 | |
| 175 | + if (rollover) { | |
| 176 | + const o = last.c | |
| 177 | + const c = r2(o * (1 + gauss(rndLive) * 0.0006)) | |
| 178 | + chart.updateLast({ t: last.t + step, o, h: Math.max(o, c), l: Math.min(o, c), c, v: 8000 }) | |
| 179 | + } else { | |
| 180 | + const c = r2(last.c * (1 + gauss(rndLive) * 0.0005)) | |
| 181 | + chart.updateLast({ ...last, c, h: Math.max(last.h, c), l: Math.min(last.l, c), v: last.v + 2000 }) | |
| 182 | + } | |
| 183 | +} | |
| 184 | + | |
| 185 | +/* ───────────── legend (HTML, from crosshairMove) ───────────── */ | |
| 186 | + | |
| 187 | +const legend = document.getElementById('legend') | |
| 188 | +const fmt = v => (v == null ? '—' : Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })) | |
| 189 | +function renderLegend(info) { | |
| 190 | + const data = chart.getData() | |
| 191 | + const b = info?.bar || data[data.length - 1] | |
| 192 | + if (!b) { legend.innerHTML = ''; return } | |
| 193 | + const up = b.c >= b.o | |
| 194 | + const chg = ((b.c - b.o) / b.o) * 100 | |
| 195 | + const cls = up ? 'up' : 'down' | |
| 196 | + const d = new Date(b.t) | |
| 197 | + const when = state.tf === '1day' ? d.toISOString().slice(0, 10) : d.toISOString().slice(0, 16).replace('T', ' ') + ' ET' | |
| 198 | + let html = `<div class="row"><span class="sym">${state.symbol}</span><span class="muted">${state.tf}</span><span class="muted">${when}</span></div>` | |
| 199 | + html += `<div class="row"><span>O <b class="${cls}">${fmt(b.o)}</b></span><span>H <b class="${cls}">${fmt(b.h)}</b></span><span>L <b class="${cls}">${fmt(b.l)}</b></span><span>C <b class="${cls}">${fmt(b.c)}</b></span><span class="${cls}">${chg >= 0 ? '+' : ''}${chg.toFixed(2)}%</span><span class="muted">Vol ${b.v?.toLocaleString('en-US') ?? '—'}</span></div>` | |
| 200 | + for (const ind of chart.getIndicators()) { | |
| 201 | + const vals = info?.indicators?.[ind.id] || Object.fromEntries(Object.entries(ind.values).map(([k, arr]) => [k, arr[data.length - 1]])) | |
| 202 | + const parts = Object.entries(vals).filter(([k]) => k !== 'direction').map(([k, v], i) => `<span><i class="k" style="background:${ind.colors[i % ind.colors.length] || '#888'}"></i>${k} ${fmt(v)}</span>`) | |
| 203 | + html += `<div class="row"><span class="muted">${ind.title}</span>${parts.join('')}</div>` | |
| 204 | + } | |
| 205 | + if (info?.compares) for (const [id, v] of Object.entries(info.compares)) html += `<div class="row"><span class="muted">${id}</span><span>${v == null ? '—' : (v >= 0 ? '+' : '') + v.toFixed(2) + '%'}</span></div>` | |
| 206 | + legend.innerHTML = html | |
| 207 | +} | |
| 208 | +chart.on('crosshairMove', renderLegend) | |
| 209 | +renderLegend(null) | |
| 210 | + | |
| 211 | +const statusEl = document.getElementById('status') | |
| 212 | +function status(msg) { statusEl.textContent = msg } | |
| 213 | +status(`${bars.length.toLocaleString()} bars · ${state.tf}`) | |
| 214 | + | |
| 215 | +/* ───────────── URL-driven setup for screenshots ───────────── */ | |
| 216 | + | |
| 217 | +for (const t of (q.get('ind') || '').split(',').filter(Boolean)) chart.addIndicator({ type: t }) | |
| 218 | +if (q.get('compare') === '1') cmpBtn.click() | |
| 219 | +if (q.get('drawings') === '1') { | |
| 220 | + const data = chart.getData() | |
| 221 | + const n = data.length | |
| 222 | + const at = i => data[Math.max(0, Math.min(n - 1, i))] | |
| 223 | + const a = at(n - 120), b = at(n - 30), c = at(n - 80), d = at(n - 55) | |
| 224 | + chart.setDrawings([ | |
| 225 | + { id: 'tl', type: 'trendline', points: [{ t: a.t, price: a.l * 0.998 }, { t: b.t, price: b.l * 0.998 }] }, | |
| 226 | + { id: 'hl', type: 'hline', points: [{ t: b.t, price: b.h * 1.004 }], style: { dash: [4, 4] } }, | |
| 227 | + { id: 'rc', type: 'rect', points: [{ t: c.t, price: c.h * 1.002 }, { t: d.t, price: d.l * 0.998 }] }, | |
| 228 | + { id: 'fb', type: 'fib', points: [{ t: at(n - 100).t, price: at(n - 100).l }, { t: at(n - 60).t, price: at(n - 60).h }] }, | |
| 229 | + { id: 'ms', type: 'measure', points: [{ t: at(n - 50).t, price: at(n - 50).c }, { t: at(n - 15).t, price: at(n - 15).c }] }, | |
| 230 | + { id: 'tx', type: 'text', points: [{ t: at(n - 20).t, price: at(n - 20).h * 1.006 }], text: 'Breakout zone' }, | |
| 231 | + { id: 'ar', type: 'arrow', points: [{ t: at(n - 140).t, price: at(n - 140).h * 1.006 }, { t: at(n - 125).t, price: at(n - 125).h * 1.001 }] }, | |
| 232 | + ]) | |
| 233 | + chart.drawings.selectedId = 'tl' | |
| 234 | + chart.invalidate('overlay') | |
| 235 | +} | |
| 236 | +if (q.get('live') === '1') liveBtn.click() | |
| 237 | + | |
| 238 | +/* ───────────── benchmark ───────────── */ | |
| 239 | + | |
| 240 | +/** Runs `n` programmatic pan/zoom steps, forcing a synchronous frame each time. Returns timings in ms. */ | |
| 241 | +export async function bench(n = 100) { | |
| 242 | + const c = chart | |
| 243 | + const times = [] | |
| 244 | + await new Promise(r => requestAnimationFrame(r)) | |
| 245 | + for (let i = 0; i < n; i++) { | |
| 246 | + const t0 = performance.now() | |
| 247 | + if (i % 4 === 0) c.ts.zoomAt(i % 8 === 0 ? 0.85 : 1.18, c.plotWidth * 0.6) | |
| 248 | + else c.ts.scrollPx(i % 2 ? 37 : -23) | |
| 249 | + c.dirty.data = true | |
| 250 | + c._frame(performance.now()) | |
| 251 | + times.push(performance.now() - t0) | |
| 252 | + } | |
| 253 | + times.sort((a, b) => a - b) | |
| 254 | + const sum = times.reduce((a, b) => a + b, 0) | |
| 255 | + const res = { frames: n, bars: c.getData().length, visible: c.ts.visibleRange().to - c.ts.visibleRange().from + 1, avgMs: +(sum / n).toFixed(2), p50Ms: +times[Math.floor(n / 2)].toFixed(2), p95Ms: +times[Math.floor(n * 0.95)].toFixed(2), maxMs: +times[n - 1].toFixed(2) } | |
| 256 | + return res | |
| 257 | +} | |
| 258 | +window.__harness = { bench, makeBars, chart, state } | |
modified
hfmarketdata/web/package.json
+3 −1
@@ -14,7 +14,9 @@ | ||
| 14 | 14 | "sample:aapl-pe": "node scripts/make-aapl-pe-sample.mjs", |
| 15 | 15 | "bench:formats": "node scripts/bench-formats.mjs", |
| 16 | 16 | "test:e2e": "playwright test", |
| 17 | − "test:e2e:ui": "playwright test --ui" | |
| 17 | + "test:e2e:ui": "playwright test --ui", | |
| 18 | + "test:charts": "node --test src/charts", | |
| 19 | + "charts:shots": "node scripts/charts-shots.mjs" | |
| 18 | 20 | }, |
| 19 | 21 | "dependencies": { |
| 20 | 22 | "@mdx-js/react": "^3.1.1", |
added
hfmarketdata/web/scripts/charts-shots.mjs
+106 −0
@@ -0,0 +1,106 @@ | ||
| 1 | +#!/usr/bin/env node | |
| 2 | +// Visual QA for the chart engine: starts Vite on the harness, captures desktop (1440×900) and mobile (390×844) | |
| 3 | +// screenshots into /tmp/hfmd-charts/, and reports the render time of 100 programmatic pan/zoom frames. | |
| 4 | +// | |
| 5 | +// Usage: node scripts/charts-shots.mjs [--only=name,name] [--out=/tmp/hfmd-charts] [--port=4180] | |
| 6 | + | |
| 7 | +import { spawn } from 'node:child_process' | |
| 8 | +import { mkdirSync } from 'node:fs' | |
| 9 | +import { resolve, dirname } from 'node:path' | |
| 10 | +import { fileURLToPath } from 'node:url' | |
| 11 | +import { chromium } from '@playwright/test' | |
| 12 | + | |
| 13 | +const here = dirname(fileURLToPath(import.meta.url)) | |
| 14 | +const webDir = resolve(here, '..') | |
| 15 | +const args = Object.fromEntries(process.argv.slice(2).filter(a => a.startsWith('--')).map(a => { const [k, v] = a.slice(2).split('='); return [k, v ?? '1'] })) | |
| 16 | +const PORT = Number(args.port || 4180) | |
| 17 | +const OUT = args.out || '/tmp/hfmd-charts' | |
| 18 | +const ONLY = args.only ? new Set(args.only.split(',')) : null | |
| 19 | +mkdirSync(OUT, { recursive: true }) | |
| 20 | + | |
| 21 | +const HARNESS = `http://localhost:${PORT}/dev/charts-harness.html` | |
| 22 | + | |
| 23 | +const SHOTS = [ | |
| 24 | + { name: 'desktop-1min-candles', q: 'tf=1min&n=50000' }, | |
| 25 | + { name: 'desktop-1min-light', q: 'tf=1min&n=50000&theme=light' }, | |
| 26 | + { name: 'desktop-1day-indicators', q: 'tf=1day&n=10000&ind=bollinger,rsi,macd' }, | |
| 27 | + { name: 'desktop-1day-ichimoku-volume', q: 'tf=1day&n=10000&ind=ichimoku,volume-ma,adx' }, | |
| 28 | + { name: 'desktop-line-log', q: 'tf=1day&n=10000&type=area&mode=log' }, | |
| 29 | + { name: 'desktop-compare-percent', q: 'tf=1day&n=10000&type=line&compare=1' }, | |
| 30 | + { name: 'desktop-hollow-drawings', q: 'tf=1min&n=50000&type=hollow&drawings=1&crosshair=magnet' }, | |
| 31 | + { name: 'desktop-ohlc-supertrend-light', q: 'tf=1min&n=50000&type=ohlc&ind=supertrend,vwap,stoch&theme=light' }, | |
| 32 | + { name: 'desktop-baseline-heikin', q: 'tf=1day&n=10000&type=heikin&ind=ema,sma' }, | |
| 33 | + { name: 'desktop-200k-zoomed-out', q: 'tf=1min&n=200000', zoomOut: 40 }, | |
| 34 | + { name: 'mobile-1min-candles', q: 'tf=1min&n=50000&ind=rsi', mobile: true }, | |
| 35 | + { name: 'mobile-1day-light', q: 'tf=1day&n=10000&theme=light&type=area', mobile: true }, | |
| 36 | +] | |
| 37 | + | |
| 38 | +function startVite() { | |
| 39 | + const child = spawn('npx', ['vite', '--port', String(PORT), '--strictPort', '--clearScreen', 'false'], { cwd: webDir, stdio: ['ignore', 'pipe', 'pipe'] }) | |
| 40 | + child.stdout.on('data', d => { if (process.env.DEBUG) process.stdout.write(d) }) | |
| 41 | + child.stderr.on('data', d => process.stderr.write(d)) | |
| 42 | + return child | |
| 43 | +} | |
| 44 | + | |
| 45 | +async function waitFor(url, ms = 30_000) { | |
| 46 | + const t0 = Date.now() | |
| 47 | + while (Date.now() - t0 < ms) { | |
| 48 | + try { const r = await fetch(url); if (r.ok) return } catch { /* not yet */ } | |
| 49 | + await new Promise(r => setTimeout(r, 250)) | |
| 50 | + } | |
| 51 | + throw new Error(`Vite did not start on ${url}`) | |
| 52 | +} | |
| 53 | + | |
| 54 | +async function main() { | |
| 55 | + const vite = startVite() | |
| 56 | + const stop = () => { try { vite.kill('SIGTERM') } catch { /* ignore */ } } | |
| 57 | + process.on('exit', stop); process.on('SIGINT', () => { stop(); process.exit(130) }) | |
| 58 | + try { | |
| 59 | + await waitFor(HARNESS) | |
| 60 | + const browser = await chromium.launch() | |
| 61 | + const results = [] | |
| 62 | + for (const s of SHOTS) { | |
| 63 | + if (ONLY && !ONLY.has(s.name)) continue | |
| 64 | + const ctx = await browser.newContext(s.mobile | |
| 65 | + ? { viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true } | |
| 66 | + : { viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 }) | |
| 67 | + const page = await ctx.newPage() | |
| 68 | + const errors = [] | |
| 69 | + page.on('pageerror', e => errors.push(String(e))) | |
| 70 | + page.on('console', m => { if (m.type() === 'error') errors.push(m.text()) }) | |
| 71 | + await page.goto(`${HARNESS}?${s.q}&reduced=1`, { waitUntil: 'networkidle' }) | |
| 72 | + await page.waitForFunction(() => window.__chart && window.__chart.getData().length > 0) | |
| 73 | + if (s.zoomOut) { | |
| 74 | + await page.evaluate(k => { const c = window.__chart; for (let i = 0; i < k; i++) c.ts.zoomAt(0.8, c.plotWidth / 2); c.invalidate('data') }, s.zoomOut) | |
| 75 | + } | |
| 76 | + // Hover to show the crosshair on desktop shots. | |
| 77 | + if (!s.mobile) await page.mouse.move(s.hoverX || 900, s.hoverY || 380) | |
| 78 | + await page.waitForTimeout(350) | |
| 79 | + const file = `${OUT}/${s.name}.png` | |
| 80 | + await page.screenshot({ path: file }) | |
| 81 | + console.log(`shot ${s.name.padEnd(36)} → ${file}${errors.length ? ` ⚠ ${errors.length} console error(s): ${errors[0]}` : ''}`) | |
| 82 | + results.push({ name: s.name, errors }) | |
| 83 | + await ctx.close() | |
| 84 | + } | |
| 85 | + // Benchmark: 100 programmatic pan/zoom frames on 200 000 bars and on 50 000 bars. | |
| 86 | + for (const [label, qs] of [['200k 1min', 'tf=1min&n=200000'], ['50k 1min + 3 indicators', 'tf=1min&n=50000&ind=bollinger,rsi,macd']]) { | |
| 87 | + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 }) | |
| 88 | + const page = await ctx.newPage() | |
| 89 | + await page.goto(`${HARNESS}?${qs}&reduced=1`, { waitUntil: 'networkidle' }) | |
| 90 | + await page.waitForFunction(() => window.__harness && window.__chart.getData().length > 0) | |
| 91 | + const r = await page.evaluate(() => window.__harness.bench(100)) | |
| 92 | + console.log(`bench ${label.padEnd(30)} ${JSON.stringify(r)}`) | |
| 93 | + // Zoomed-out (everything visible) worst case. | |
| 94 | + const r2 = await page.evaluate(async () => { const c = window.__chart; for (let i = 0; i < 40; i++) c.ts.zoomAt(0.8, c.plotWidth / 2); return window.__harness.bench(100) }) | |
| 95 | + console.log(`bench ${(label + ' (zoomed out)').padEnd(30)} ${JSON.stringify(r2)}`) | |
| 96 | + await ctx.close() | |
| 97 | + } | |
| 98 | + await browser.close() | |
| 99 | + const failed = results.filter(r => r.errors.length) | |
| 100 | + if (failed.length) { console.error(`\n${failed.length} page(s) with console errors`); process.exitCode = 1 } | |
| 101 | + } finally { | |
| 102 | + stop() | |
| 103 | + } | |
| 104 | +} | |
| 105 | + | |
| 106 | +main().catch(e => { console.error(e); process.exit(1) }) | |
added
hfmarketdata/web/src/charts/engine/core/chart.js
+957 −0
@@ -0,0 +1,957 @@ | ||
| 1 | +// Chart core: DOM/layout, frame scheduling, data + scales orchestration, rendering of every layer, public API. | |
| 2 | + | |
| 3 | +import { Emitter } from './emitter.js' | |
| 4 | +import { Animator } from './animation.js' | |
| 5 | +import { BarStore } from '../data/store.js' | |
| 6 | +import { TimeScale } from '../scales/time-scale.js' | |
| 7 | +import { timeTicks as buildTimeTicks } from '../scales/ticks.js' | |
| 8 | +import { Pane, COLLAPSED_H, MIN_PANE_H } from '../panes/pane.js' | |
| 9 | +import { createLayer, crisp, withAlpha } from '../render/canvas.js' | |
| 10 | +import { font, measure, FONT_SIZE } from '../render/text.js' | |
| 11 | +import { drawSeries, drawVolume } from '../render/series.js' | |
| 12 | +import { drawGrid, drawSessionBreaks, drawPriceAxis, drawTimeAxis, measureAxisWidth, TIME_AXIS_H, MIN_AXIS_W } from '../render/axes.js' | |
| 13 | +import { drawPlots, valuePath, plotColor } from '../render/plots.js' | |
| 14 | +import { drawCrosshair, drawAxisLabel, drawTimeLabel, drawLastPrice, drawHiLoMarkers, drawWatermark, drawPaneHeader } from '../render/overlay.js' | |
| 15 | +import { normalizeTheme, darkTheme } from '../theme.js' | |
| 16 | +import { autoDecimals, formatPrice, formatCompact, formatPercent, clamp } from '../format/number.js' | |
| 17 | +import { fmtFull, isIntraday, isDayChange } from '../format/time.js' | |
| 18 | +import { indicatorSpec, indicatorParams, computeIndicator } from '../../indicators/index.js' | |
| 19 | +import { heikinAshi } from '../../indicators/heikin-ashi.js' | |
| 20 | +import { DrawingManager } from '../drawings/manager.js' | |
| 21 | +import { attachInteractions } from '../interactions/pointer.js' | |
| 22 | +import { exportPNG } from '../export/png.js' | |
| 23 | +import { pill } from '../render/text.js' | |
| 24 | + | |
| 25 | +const DEFAULTS = { | |
| 26 | + theme: darkTheme, | |
| 27 | + timeframe: '1day', | |
| 28 | + priceFormat: { decimals: 'auto', minMove: undefined }, | |
| 29 | + locale: 'en-US', | |
| 30 | + sessionLabel: '', | |
| 31 | + watermark: '', | |
| 32 | + rightOffsetBars: 8, | |
| 33 | + barSpacing: 8, | |
| 34 | + minBarSpacing: 0.5, | |
| 35 | + maxBars: 500_000, | |
| 36 | + reducedMotion: false, | |
| 37 | + baselineValue: null, // baseline series: reference price (null = first visible open) | |
| 38 | + volumeFraction: 0.2, | |
| 39 | + fitBars: 150, // bars shown by fitContent() | |
| 40 | + onPaneClose: null, // (paneInfo) => void — when set, the close button delegates to the caller | |
| 41 | +} | |
| 42 | + | |
| 43 | +const PULSE_MS = 650 | |
| 44 | +const SERIES_TYPES = new Set(['candles', 'hollow', 'ohlc', 'line', 'area', 'baseline', 'heikin', 'columns', 'hlc']) | |
| 45 | +let idSeq = 0 | |
| 46 | + | |
| 47 | +export class Chart { | |
| 48 | + constructor(container, options = {}) { | |
| 49 | + this.container = container | |
| 50 | + this.opts = { ...DEFAULTS, ...options, priceFormat: { ...DEFAULTS.priceFormat, ...(options.priceFormat || {}) } } | |
| 51 | + this.theme = normalizeTheme(this.opts.theme) | |
| 52 | + this.emitter = new Emitter() | |
| 53 | + this.animator = new Animator() | |
| 54 | + this.store = new BarStore(this.opts.maxBars) | |
| 55 | + this.renderStore = this.store | |
| 56 | + this._haVersion = -1 | |
| 57 | + this.ts = new TimeScale({ barSpacing: this.opts.barSpacing, minBarSpacing: this.opts.minBarSpacing, rightOffsetBars: this.opts.rightOffsetBars }) | |
| 58 | + this.seriesType = 'candles' | |
| 59 | + this.volumeVisible = true | |
| 60 | + this.crosshairOpts = { mode: 'normal', showLabels: true } | |
| 61 | + this.pointer = null // { x, y, pane, region } in chart-local px, null when outside | |
| 62 | + this.indicators = new Map() | |
| 63 | + this.compares = new Map() | |
| 64 | + this._modeBeforeCompare = null | |
| 65 | + this.decimals = 2 | |
| 66 | + this.axisWidth = MIN_AXIS_W | |
| 67 | + this.width = 0; this.height = 0; this.dpr = 1 | |
| 68 | + this.dirty = { layout: true, data: true, overlay: true } | |
| 69 | + this._raf = 0 | |
| 70 | + this._last = 0 | |
| 71 | + this._pulseAt = -Infinity | |
| 72 | + this._lastClose = NaN | |
| 73 | + this._emittedRange = null | |
| 74 | + this._indicatorVersion = -1 | |
| 75 | + this._destroyed = false | |
| 76 | + this.kinetic = null // { vx } px/ms while inertia is running | |
| 77 | + this.dragging = false | |
| 78 | + | |
| 79 | + this._buildDom() | |
| 80 | + this.mainPane = new Pane(this.el, { kind: 'main', weight: 1 }) | |
| 81 | + this.panes = [this.mainPane] | |
| 82 | + this.drawings = new DrawingManager(this) | |
| 83 | + this.interactions = attachInteractions(this) | |
| 84 | + if (typeof ResizeObserver !== 'undefined') { | |
| 85 | + this._ro = new ResizeObserver(() => this.resize()) | |
| 86 | + this._ro.observe(container) | |
| 87 | + } | |
| 88 | + this.resize() | |
| 89 | + } | |
| 90 | + | |
| 91 | + /* ───────────────────────── DOM & layout ───────────────────────── */ | |
| 92 | + | |
| 93 | + _buildDom() { | |
| 94 | + const el = document.createElement('div') | |
| 95 | + el.className = 'hfmd-chart' | |
| 96 | + el.tabIndex = 0 | |
| 97 | + el.setAttribute('role', 'img') | |
| 98 | + el.setAttribute('aria-label', 'Financial chart') | |
| 99 | + el.style.cssText = `position:relative;width:100%;height:100%;overflow:hidden;outline:none;user-select:none;-webkit-user-select:none;touch-action:none;background:${this.theme.bg};cursor:crosshair;` | |
| 100 | + this.container.appendChild(el) | |
| 101 | + this.el = el | |
| 102 | + const ta = document.createElement('div') | |
| 103 | + ta.className = 'hfmd-time-axis' | |
| 104 | + ta.style.cssText = 'position:absolute;left:0;overflow:hidden;' | |
| 105 | + el.appendChild(ta) | |
| 106 | + this.timeAxis = { el: ta, main: createLayer(ta, { zIndex: 1 }), overlay: createLayer(ta, { zIndex: 2 }), top: 0, height: TIME_AXIS_H } | |
| 107 | + } | |
| 108 | + | |
| 109 | + resize() { | |
| 110 | + if (this._destroyed) return | |
| 111 | + const w = this.container.clientWidth || 600 | |
| 112 | + const h = this.container.clientHeight || 400 | |
| 113 | + this.width = w; this.height = h | |
| 114 | + this.dpr = Math.max(1, Math.min(3, (typeof window !== 'undefined' && window.devicePixelRatio) || 1)) | |
| 115 | + this.dirty.layout = true | |
| 116 | + this.invalidate('data') | |
| 117 | + } | |
| 118 | + | |
| 119 | + _layout() { | |
| 120 | + const total = this.height - TIME_AXIS_H | |
| 121 | + let free = total | |
| 122 | + let weight = 0 | |
| 123 | + for (const p of this.panes) { if (p.collapsed) free -= COLLAPSED_H; else weight += p.weight } | |
| 124 | + let y = 0 | |
| 125 | + for (const p of this.panes) { | |
| 126 | + const h = p.collapsed ? COLLAPSED_H : Math.max(MIN_PANE_H, Math.round((free * p.weight) / (weight || 1))) | |
| 127 | + p.layout(y, h, this.width, this.dpr) | |
| 128 | + p.scale.height = h | |
| 129 | + y += h | |
| 130 | + } | |
| 131 | + // Rounding slack goes to the main pane so the time axis always sits at the bottom. | |
| 132 | + const slack = total - y | |
| 133 | + if (slack !== 0) { | |
| 134 | + const p = this.mainPane | |
| 135 | + p.layout(p.top, p.height + slack, this.width, this.dpr) | |
| 136 | + p.scale.height = p.height | |
| 137 | + let yy = p.top + p.height | |
| 138 | + for (const q of this.panes) { if (q.top > p.top) { q.layout(yy, q.height, this.width, this.dpr); yy += q.height } } | |
| 139 | + } | |
| 140 | + this.timeAxis.top = total | |
| 141 | + this.timeAxis.el.style.top = total + 'px' | |
| 142 | + this.timeAxis.el.style.width = this.width + 'px' | |
| 143 | + this.timeAxis.el.style.height = TIME_AXIS_H + 'px' | |
| 144 | + this.timeAxis.main.resize(this.width, TIME_AXIS_H, this.dpr) | |
| 145 | + this.timeAxis.overlay.resize(this.width, TIME_AXIS_H, this.dpr) | |
| 146 | + this.dirty.layout = false | |
| 147 | + } | |
| 148 | + | |
| 149 | + get plotWidth() { return Math.max(10, this.width - this.axisWidth) } | |
| 150 | + | |
| 151 | + /** Which part of the chart is under (x, y) in chart-local pixels. */ | |
| 152 | + hitRegion(x, y) { | |
| 153 | + if (x < 0 || y < 0 || x > this.width || y > this.height) return { region: 'outside' } | |
| 154 | + if (y >= this.timeAxis.top) return { region: x >= this.plotWidth ? 'corner' : 'timeAxis', x, y: y - this.timeAxis.top } | |
| 155 | + for (let i = 0; i < this.panes.length; i++) { | |
| 156 | + const p = this.panes[i] | |
| 157 | + if (y >= p.top && y < p.top + p.height) { | |
| 158 | + const ly = y - p.top | |
| 159 | + if (i > 0 && ly <= 4) return { region: 'separator', pane: p, index: i, x, y: ly } | |
| 160 | + if (i < this.panes.length - 1 && ly >= p.height - 4) return { region: 'separator', pane: this.panes[i + 1], index: i + 1, x, y: ly } | |
| 161 | + if (p.kind !== 'main' && p.headerBoxes.length) { | |
| 162 | + for (const b of p.headerBoxes) if (x >= b.x && x <= b.x + b.w && ly >= b.y && ly <= b.y + b.h) return { region: 'paneButton', pane: p, button: b.id, x, y: ly } | |
| 163 | + } | |
| 164 | + if (x >= this.plotWidth) return { region: 'priceAxis', pane: p, x, y: ly } | |
| 165 | + return { region: 'plot', pane: p, x, y: ly } | |
| 166 | + } | |
| 167 | + } | |
| 168 | + return { region: 'outside' } | |
| 169 | + } | |
| 170 | + | |
| 171 | + /* ───────────────────────── scheduling ───────────────────────── */ | |
| 172 | + | |
| 173 | + invalidate(kind = 'all') { | |
| 174 | + if (this._destroyed) return | |
| 175 | + if (kind === 'all' || kind === 'data') { this.dirty.data = true; this.dirty.overlay = true } | |
| 176 | + if (kind === 'overlay') this.dirty.overlay = true | |
| 177 | + if (kind === 'layout') { this.dirty.layout = true; this.dirty.data = true; this.dirty.overlay = true } | |
| 178 | + if (!this._raf) this._raf = requestAnimationFrame(this._frame) | |
| 179 | + } | |
| 180 | + | |
| 181 | + _frame = (now) => { | |
| 182 | + this._raf = 0 | |
| 183 | + if (this._destroyed) return | |
| 184 | + const dt = Math.min(50, Math.max(1, now - (this._last || now - 16))) | |
| 185 | + this._last = now | |
| 186 | + let animating = this.animator.tick(now) | |
| 187 | + if (this.kinetic) animating = this._stepKinetic(dt) || animating | |
| 188 | + if (this.dirty.layout) this._layout() | |
| 189 | + this._syncData() | |
| 190 | + this.ts.width = this.plotWidth | |
| 191 | + this.ts.count = this.store.length | |
| 192 | + const vr = this.ts.visibleRange() | |
| 193 | + this._updateBases(vr) | |
| 194 | + // Scales: auto ranges (snap while dragging so the chart never lags the hand). | |
| 195 | + for (const p of this.panes) { | |
| 196 | + if (p.collapsed) continue | |
| 197 | + if (p.scale.auto) this._autoRange(p, vr) | |
| 198 | + const moving = p.scale.step(this.dragging ? 1e9 : dt, this.opts.reducedMotion || this.dragging) | |
| 199 | + if (moving) animating = true | |
| 200 | + } | |
| 201 | + const needData = this.dirty.data || animating | |
| 202 | + if (needData) this._timeTicks = this._computeTimeTicks(vr) | |
| 203 | + if (needData) this._updateAxisWidth() | |
| 204 | + if (needData) { | |
| 205 | + for (const p of this.panes) this._drawPane(p, vr) | |
| 206 | + this._drawTimeAxis() | |
| 207 | + this.dirty.data = false | |
| 208 | + this.dirty.overlay = true | |
| 209 | + } | |
| 210 | + const pulse = now - this._pulseAt < PULSE_MS | |
| 211 | + if (this.dirty.overlay || pulse) { | |
| 212 | + for (const p of this.panes) this._drawOverlay(p, vr, now) | |
| 213 | + this._drawTimeOverlay() | |
| 214 | + this.dirty.overlay = false | |
| 215 | + } | |
| 216 | + this._emitRange(vr) | |
| 217 | + if (animating || pulse) this._raf = requestAnimationFrame(this._frame) | |
| 218 | + } | |
| 219 | + | |
| 220 | + _stepKinetic(dt) { | |
| 221 | + const k = this.kinetic | |
| 222 | + if (!k) return false | |
| 223 | + const dx = k.vx * dt | |
| 224 | + this.ts.scrollPx(dx) | |
| 225 | + k.vx *= Math.exp(-dt / 325) | |
| 226 | + this.dirty.data = true | |
| 227 | + if (Math.abs(k.vx) < 0.02) { this.kinetic = null; return false } | |
| 228 | + return true | |
| 229 | + } | |
| 230 | + | |
| 231 | + /* ───────────────────────── data sync ───────────────────────── */ | |
| 232 | + | |
| 233 | + _syncData() { | |
| 234 | + if (this.seriesType === 'heikin') { | |
| 235 | + if (this._haVersion !== this.store.version) { | |
| 236 | + const ha = new BarStore(this.opts.maxBars) | |
| 237 | + ha.set(heikinAshi(this.store.bars)) | |
| 238 | + this.renderStore = ha | |
| 239 | + this._haVersion = this.store.version | |
| 240 | + } | |
| 241 | + } else this.renderStore = this.store | |
| 242 | + if (this._indicatorVersion !== this.store.version) { | |
| 243 | + for (const ind of this.indicators.values()) this._computeIndicator(ind) | |
| 244 | + for (const c of this.compares.values()) this._alignCompare(c) | |
| 245 | + this._indicatorVersion = this.store.version | |
| 246 | + } | |
| 247 | + } | |
| 248 | + | |
| 249 | + _updateBases(vr) { | |
| 250 | + const ps = this.mainPane.scale | |
| 251 | + if (ps.mode !== 'percent') return | |
| 252 | + const n = this.store.length | |
| 253 | + if (!n) return | |
| 254 | + const i = clamp(vr.from, 0, n - 1) | |
| 255 | + ps.base = this.store.c[i] || this.store.o[i] || 1 | |
| 256 | + for (const c of this.compares.values()) { | |
| 257 | + const v = c.aligned[i] | |
| 258 | + c.base = v > 0 ? v : NaN | |
| 259 | + } | |
| 260 | + } | |
| 261 | + | |
| 262 | + _autoRange(p, vr) { | |
| 263 | + if (vr.to < vr.from) return | |
| 264 | + let lo = Infinity, hi = -Infinity | |
| 265 | + const ps = p.scale | |
| 266 | + if (p.kind === 'main') { | |
| 267 | + const rs = this.renderStore | |
| 268 | + const closeOnly = this.seriesType === 'line' || this.seriesType === 'area' || this.seriesType === 'baseline' || this.seriesType === 'columns' | |
| 269 | + if (closeOnly) { | |
| 270 | + const C = rs.c | |
| 271 | + for (let i = vr.from; i <= vr.to; i++) { const c = C[i]; if (c < lo) lo = c; if (c > hi) hi = c } | |
| 272 | + } else { const mm = rs.minMax(vr.from, vr.to); lo = mm[0]; hi = mm[1] } | |
| 273 | + if (this.seriesType === 'baseline') { const b = this._baselineValue(vr); if (b < lo) lo = b; if (b > hi) hi = b } | |
| 274 | + lo = ps.toInternal(lo); hi = ps.toInternal(hi) | |
| 275 | + for (const c of this.compares.values()) { | |
| 276 | + if (!(c.base > 0)) continue | |
| 277 | + const A = c.aligned | |
| 278 | + for (let i = vr.from; i <= vr.to; i++) { const v = A[i]; if (!(v > 0)) continue; const pct = (v / c.base - 1) * 100; if (pct < lo) lo = pct; if (pct > hi) hi = pct } | |
| 279 | + } | |
| 280 | + } | |
| 281 | + for (const id of p.indicators) { | |
| 282 | + const ind = this.indicators.get(id) | |
| 283 | + if (!ind || !ind.values) continue | |
| 284 | + for (const plot of ind.spec.plots) { | |
| 285 | + const keys = plot.kind === 'band' ? [plot.upper, plot.lower] : plot.kind === 'cloud' ? [plot.a, plot.b] : [plot.key] | |
| 286 | + for (const key of keys) { | |
| 287 | + const arr = ind.values[key] | |
| 288 | + if (!arr) continue | |
| 289 | + const end = Math.min(vr.to, arr.length - 1) | |
| 290 | + for (let i = vr.from; i <= end; i++) { | |
| 291 | + const v = arr[i] | |
| 292 | + if (v == null || Number.isNaN(v)) continue | |
| 293 | + const iv = p.kind === 'main' ? ps.toInternal(v) : v | |
| 294 | + if (iv < lo) lo = iv; if (iv > hi) hi = iv | |
| 295 | + } | |
| 296 | + } | |
| 297 | + } | |
| 298 | + if (p.kind !== 'main' && ind.spec.levels) for (const l of ind.spec.levels) { if (l < lo) lo = l; if (l > hi) hi = l } | |
| 299 | + } | |
| 300 | + if (!Number.isFinite(lo) || !Number.isFinite(hi)) return | |
| 301 | + if (p.kind === 'main') ps.setAutoRangeInternal(lo, hi) | |
| 302 | + else ps.setAutoRange(lo, hi) | |
| 303 | + } | |
| 304 | + | |
| 305 | + _baselineValue(vr) { | |
| 306 | + if (this.opts.baselineValue != null) return this.opts.baselineValue | |
| 307 | + const rs = this.renderStore | |
| 308 | + if (!rs.length) return 0 | |
| 309 | + const i = clamp(vr.from, 0, rs.length - 1) | |
| 310 | + return rs.o[i] | |
| 311 | + } | |
| 312 | + | |
| 313 | + _computeTimeTicks(vr) { | |
| 314 | + if (vr.to < vr.from) return [] | |
| 315 | + const ctx = this.timeAxis.main.ctx | |
| 316 | + const regular = font(this.theme, { mono: true, size: FONT_SIZE }) | |
| 317 | + const strong = font(this.theme, { mono: true, size: FONT_SIZE, weight: 700 }) | |
| 318 | + return buildTimeTicks({ | |
| 319 | + times: this.store.t, from: vr.from, to: vr.to, tf: this.opts.timeframe, xOf: i => this.ts.x(i), width: this.plotWidth, | |
| 320 | + measure: (text, isStrong) => measure(ctx, text, isStrong ? strong : regular), | |
| 321 | + }) | |
| 322 | + } | |
| 323 | + | |
| 324 | + _updateAxisWidth() { | |
| 325 | + const ctx = this.mainPane.main.ctx | |
| 326 | + let w = MIN_AXIS_W | |
| 327 | + for (const p of this.panes) { | |
| 328 | + if (p.collapsed) continue | |
| 329 | + const r = this._paneTicks(p) | |
| 330 | + p._ticks = r.ticks | |
| 331 | + p.decimals = r.decimals | |
| 332 | + const need = measureAxisWidth(ctx, r.ticks, this.theme) | |
| 333 | + if (need > w) w = need | |
| 334 | + } | |
| 335 | + // Also make room for the last-price label (bold) and the crosshair label. | |
| 336 | + if (this.store.length) { | |
| 337 | + const text = this._formatMain(this.store.c[this.store.length - 1]) | |
| 338 | + const need = Math.ceil(measure(ctx, text, font(this.theme, { mono: true, size: FONT_SIZE, weight: 600 }))) + 16 | |
| 339 | + if (need > w) w = need | |
| 340 | + } | |
| 341 | + w = Math.ceil(w / 4) * 4 | |
| 342 | + if (w > this.axisWidth || w < this.axisWidth - 12) { | |
| 343 | + this.axisWidth = w | |
| 344 | + this.ts.width = this.plotWidth | |
| 345 | + } | |
| 346 | + } | |
| 347 | + | |
| 348 | + _paneTicks(p) { | |
| 349 | + const fmt = this._paneFormatter(p) | |
| 350 | + return p.scale.ticks(p.height, p.kind === 'main' ? this.decimals : 0, this.opts.locale, 44, fmt) | |
| 351 | + } | |
| 352 | + | |
| 353 | + _paneFormatter(p) { | |
| 354 | + if (p.kind === 'main') return null | |
| 355 | + const ind = this.indicators.get(p.indicators[0]) | |
| 356 | + if (ind && ind.spec.format === 'compact') return v => formatCompact(v, this.opts.locale) | |
| 357 | + return null | |
| 358 | + } | |
| 359 | + | |
| 360 | + _formatMain(price) { return this.mainPane.scale.format(price, this.decimals, this.opts.locale) } | |
| 361 | + | |
| 362 | + _formatPane(p, value) { | |
| 363 | + if (p.kind === 'main') return this._formatMain(value) | |
| 364 | + const fmt = this._paneFormatter(p) | |
| 365 | + if (fmt) return fmt(value) | |
| 366 | + return formatPrice(value, Math.max(p.decimals || 0, 2), this.opts.locale) | |
| 367 | + } | |
| 368 | + | |
| 369 | + /* ───────────────────────── drawing: data layer ───────────────────────── */ | |
| 370 | + | |
| 371 | + _drawPane(p, vr) { | |
| 372 | + const layer = p.main | |
| 373 | + const ctx = layer.ctx | |
| 374 | + layer.clear() | |
| 375 | + const W = this.plotWidth, H = p.height, theme = this.theme | |
| 376 | + ctx.fillStyle = theme.bg | |
| 377 | + ctx.fillRect(0, 0, this.width, H) | |
| 378 | + if (p.collapsed) { | |
| 379 | + this._drawPriceAxisFor(p, []) | |
| 380 | + this._drawPaneBorder(p) | |
| 381 | + return | |
| 382 | + } | |
| 383 | + const ticks = p._ticks || [] | |
| 384 | + ctx.save() | |
| 385 | + ctx.beginPath(); ctx.rect(0, 0, W, H); ctx.clip() | |
| 386 | + drawGrid(ctx, { width: W, height: H, priceTicks: ticks, timeTicks: this._timeTicks || [], theme }) | |
| 387 | + if (p.kind === 'main') { | |
| 388 | + if (isIntraday(this.opts.timeframe)) this._drawSessionBreaks(ctx, vr, H) | |
| 389 | + drawWatermark(ctx, { text: this.opts.watermark, width: W, height: H, theme }) | |
| 390 | + const g = { ctx, store: this.renderStore, from: vr.from, to: vr.to, ts: this.ts, ps: p.scale, theme, width: W, height: H, type: this.seriesType, baseline: this._baselineValue(vr) } | |
| 391 | + if (this.volumeVisible) drawVolume(g, this.opts.volumeFraction) | |
| 392 | + drawSeries(g) | |
| 393 | + this._drawIndicators(ctx, p, vr, W, H) | |
| 394 | + this._drawCompares(ctx, p, vr, W, H) | |
| 395 | + if (vr.to >= vr.from && !this.ts.isCompressed) this._drawHiLo(ctx, vr, W) | |
| 396 | + } else { | |
| 397 | + this._drawLevels(ctx, p, W) | |
| 398 | + this._drawIndicators(ctx, p, vr, W, H) | |
| 399 | + } | |
| 400 | + ctx.restore() | |
| 401 | + this._drawPriceAxisFor(p, ticks) | |
| 402 | + this._drawPaneBorder(p) | |
| 403 | + } | |
| 404 | + | |
| 405 | + _drawPaneBorder(p) { | |
| 406 | + const ctx = p.main.ctx | |
| 407 | + if (p !== this.panes[0]) { | |
| 408 | + ctx.strokeStyle = this.theme.paneBorder | |
| 409 | + ctx.lineWidth = 1 | |
| 410 | + ctx.beginPath(); ctx.moveTo(0, 0.5); ctx.lineTo(this.width, 0.5); ctx.stroke() | |
| 411 | + } | |
| 412 | + } | |
| 413 | + | |
| 414 | + _drawPriceAxisFor(p, ticks) { | |
| 415 | + drawPriceAxis(p.main.ctx, { x0: this.plotWidth, width: this.axisWidth, height: p.height, ticks, theme: this.theme }) | |
| 416 | + } | |
| 417 | + | |
| 418 | + _drawSessionBreaks(ctx, vr, H) { | |
| 419 | + if (vr.to < vr.from) return | |
| 420 | + const T = this.store.t | |
| 421 | + const xs = [] | |
| 422 | + for (let i = Math.max(1, vr.from); i <= vr.to; i++) { | |
| 423 | + if (isDayChange(T[i], T[i - 1])) { xs.push(this.ts.x(i) - this.ts.barSpacing / 2); if (xs.length > 80) return } | |
| 424 | + } | |
| 425 | + drawSessionBreaks(ctx, { xs, height: H, theme: this.theme }) | |
| 426 | + } | |
| 427 | + | |
| 428 | + _drawLevels(ctx, p, W) { | |
| 429 | + const ind = this.indicators.get(p.indicators[0]) | |
| 430 | + if (!ind || !ind.spec.levels) return | |
| 431 | + ctx.strokeStyle = this.theme.gridStrong | |
| 432 | + ctx.lineWidth = 1 | |
| 433 | + ctx.setLineDash([4, 4]) | |
| 434 | + ctx.beginPath() | |
| 435 | + for (const l of ind.spec.levels) { const y = crisp(p.scale.y(l)); ctx.moveTo(0, y); ctx.lineTo(W, y) } | |
| 436 | + ctx.stroke() | |
| 437 | + ctx.setLineDash([]) | |
| 438 | + } | |
| 439 | + | |
| 440 | + _drawIndicators(ctx, p, vr, W, H) { | |
| 441 | + const ps = p.scale | |
| 442 | + // Indicator arrays may extend past the data (Ichimoku forward cloud): draw up to the right edge. | |
| 443 | + const to = Math.max(vr.to, Math.ceil(this.ts.indexAt(W))) | |
| 444 | + for (const id of p.indicators) { | |
| 445 | + const ind = this.indicators.get(id) | |
| 446 | + if (!ind || !ind.values) continue | |
| 447 | + drawPlots({ ctx, plots: ind.spec.plots, values: ind.values, colors: ind.colors, from: vr.from, to, ts: this.ts, yOf: v => ps.y(v), theme: this.theme, height: H, store: this.store }) | |
| 448 | + } | |
| 449 | + } | |
| 450 | + | |
| 451 | + _drawCompares(ctx, p, vr, W, H) { | |
| 452 | + if (!this.compares.size || vr.to < vr.from) return | |
| 453 | + const ps = p.scale | |
| 454 | + let k = 0 | |
| 455 | + for (const c of this.compares.values()) { | |
| 456 | + const color = c.color || this.theme.series[(k + 1) % this.theme.series.length] | |
| 457 | + k++ | |
| 458 | + if (!(c.base > 0)) continue | |
| 459 | + const A = c.aligned | |
| 460 | + const pct = new Array(vr.to + 1) | |
| 461 | + for (let i = vr.from; i <= vr.to; i++) pct[i] = A[i] > 0 ? (A[i] / c.base - 1) * 100 : null | |
| 462 | + ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.lineJoin = 'round' | |
| 463 | + ctx.stroke(valuePath(pct, vr.from, vr.to, this.ts, v => ps.yInternal(v))) | |
| 464 | + // Label at the last visible point. | |
| 465 | + let last = vr.to | |
| 466 | + while (last >= vr.from && pct[last] == null) last-- | |
| 467 | + if (last >= vr.from) { | |
| 468 | + const y = ps.yInternal(pct[last]) | |
| 469 | + const x = Math.min(this.ts.x(last) + 6, W - 4) | |
| 470 | + pill(ctx, `${c.label} ${formatPercent(pct[last], 2, this.opts.locale)}`, x, y, { bg: color, fontStr: font(this.theme, { mono: true, size: 10, weight: 600 }), h: 16, padX: 4, align: x > W - 120 ? 'right' : 'left', clampTo: { x0: 0, x1: W, y0: 0, y1: H } }) | |
| 471 | + } | |
| 472 | + } | |
| 473 | + } | |
| 474 | + | |
| 475 | + _drawHiLo(ctx, vr, W) { | |
| 476 | + const ex = this.renderStore.extremes(vr.from, vr.to) | |
| 477 | + if (ex.iHi < 0) return | |
| 478 | + const ps = this.mainPane.scale | |
| 479 | + drawHiLoMarkers(ctx, { | |
| 480 | + hi: { x: this.ts.x(ex.iHi), y: ps.y(ex.hi), text: `H ${this._formatMain(ex.hi)}` }, | |
| 481 | + lo: { x: this.ts.x(ex.iLo), y: ps.y(ex.lo), text: `L ${this._formatMain(ex.lo)}` }, | |
| 482 | + plotWidth: W, theme: this.theme, | |
| 483 | + }) | |
| 484 | + } | |
| 485 | + | |
| 486 | + _drawTimeAxis() { | |
| 487 | + const layer = this.timeAxis.main | |
| 488 | + layer.clear() | |
| 489 | + drawTimeAxis(layer.ctx, { width: this.width, plotWidth: this.plotWidth, height: TIME_AXIS_H, ticks: this._timeTicks || [], theme: this.theme }) | |
| 490 | + } | |
| 491 | + | |
| 492 | + /* ───────────────────────── drawing: overlay layer ───────────────────────── */ | |
| 493 | + | |
| 494 | + _drawOverlay(p, vr, now) { | |
| 495 | + const layer = p.overlay | |
| 496 | + const ctx = layer.ctx | |
| 497 | + layer.clear() | |
| 498 | + const W = this.plotWidth, H = p.height, theme = this.theme | |
| 499 | + if (p.collapsed) { | |
| 500 | + if (p.kind !== 'main') p.headerBoxes = drawPaneHeader(ctx, { title: p.title, plotWidth: W, theme, hover: this._headerHover(p), collapsed: true }) | |
| 501 | + return | |
| 502 | + } | |
| 503 | + const ptr = this.pointer | |
| 504 | + const hovered = ptr && ptr.pane === p | |
| 505 | + // Main pane: last price, drawings. | |
| 506 | + if (p.kind === 'main' && this.store.length) { | |
| 507 | + const n = this.store.length | |
| 508 | + const c = this.store.c[n - 1], o = this.store.o[n - 1] | |
| 509 | + const y = p.scale.y(c) | |
| 510 | + const age = now - this._pulseAt | |
| 511 | + const pulse = age < PULSE_MS ? 1 - age / PULSE_MS : 0 | |
| 512 | + ctx.save(); ctx.beginPath(); ctx.rect(0, 0, this.width, H); ctx.clip() | |
| 513 | + drawLastPrice(ctx, { y, text: this._formatMain(c), plotWidth: W, axisWidth: this.axisWidth, height: H, up: c >= o, theme, pulse }) | |
| 514 | + ctx.restore() | |
| 515 | + ctx.save(); ctx.beginPath(); ctx.rect(0, 0, W, H); ctx.clip() | |
| 516 | + this.drawings.draw(ctx, { width: W, height: H, ts: this.ts, ps: p.scale, store: this.store, theme, pointer: hovered ? ptr : null }) | |
| 517 | + ctx.restore() | |
| 518 | + } | |
| 519 | + // Crosshair. | |
| 520 | + if (ptr && this.crosshairOpts.mode !== 'hidden' && ptr.region === 'plot') { | |
| 521 | + const x = this._crosshairX() | |
| 522 | + const y = hovered ? this._crosshairY(p) : null | |
| 523 | + ctx.save(); ctx.beginPath(); ctx.rect(0, 0, W, H); ctx.clip() | |
| 524 | + drawCrosshair(ctx, { x, y, width: W, height: H, theme }) | |
| 525 | + ctx.restore() | |
| 526 | + if (hovered && this.crosshairOpts.showLabels && y != null) { | |
| 527 | + const price = p.scale.priceAt(y) | |
| 528 | + drawAxisLabel(ctx, { text: this._formatPane(p, price), x0: W, y, width: this.axisWidth, height: H, bg: theme.crosshairLabelBg, color: theme.crosshairLabelText, theme }) | |
| 529 | + } | |
| 530 | + } | |
| 531 | + // Indicator values on the axis (last value pills) for indicator panes. | |
| 532 | + if (p.kind !== 'main') { | |
| 533 | + this._drawIndicatorLastValues(ctx, p, W, H) | |
| 534 | + p.headerBoxes = drawPaneHeader(ctx, { title: p.title, plotWidth: W, theme, hover: this._headerHover(p), collapsed: false }) | |
| 535 | + } | |
| 536 | + } | |
| 537 | + | |
| 538 | + _headerHover(p) { | |
| 539 | + const ptr = this.pointer | |
| 540 | + if (!ptr || ptr.pane !== p) return null | |
| 541 | + return { x: ptr.x, y: ptr.y } | |
| 542 | + } | |
| 543 | + | |
| 544 | + _drawIndicatorLastValues(ctx, p, W, H) { | |
| 545 | + const n = this.store.length | |
| 546 | + if (!n) return | |
| 547 | + for (const id of p.indicators) { | |
| 548 | + const ind = this.indicators.get(id) | |
| 549 | + if (!ind || !ind.values) continue | |
| 550 | + for (const plot of ind.spec.plots) { | |
| 551 | + if (plot.kind !== 'line') continue | |
| 552 | + const arr = ind.values[plot.key] | |
| 553 | + if (!arr) continue | |
| 554 | + let i = Math.min(n - 1, arr.length - 1) | |
| 555 | + while (i >= 0 && (arr[i] == null || Number.isNaN(arr[i]))) i-- | |
| 556 | + if (i < 0) continue | |
| 557 | + const y = p.scale.y(arr[i]) | |
| 558 | + if (y < 0 || y > H) continue | |
| 559 | + const color = plotColor(plot.color, this.theme, ind.colors) | |
| 560 | + drawAxisLabel(ctx, { text: this._formatPane(p, arr[i]), x0: W, y, width: this.axisWidth, height: H, bg: color, theme: this.theme }) | |
| 561 | + } | |
| 562 | + } | |
| 563 | + } | |
| 564 | + | |
| 565 | + _drawTimeOverlay() { | |
| 566 | + const layer = this.timeAxis.overlay | |
| 567 | + layer.clear() | |
| 568 | + const ptr = this.pointer | |
| 569 | + if (!ptr || this.crosshairOpts.mode === 'hidden' || ptr.region !== 'plot' || !this.crosshairOpts.showLabels) return | |
| 570 | + const idx = this._pointerIndex() | |
| 571 | + const t = this.store.timeAtIndex(idx) | |
| 572 | + if (t == null) return | |
| 573 | + drawTimeLabel(layer.ctx, { text: fmtFull(t, this.opts.timeframe, this.opts.sessionLabel), x: this._crosshairX(), plotWidth: this.plotWidth, height: TIME_AXIS_H, theme: this.theme }) | |
| 574 | + } | |
| 575 | + | |
| 576 | + /** Bar index under the pointer (rounded, may exceed the data in the right offset area). */ | |
| 577 | + _pointerIndex() { | |
| 578 | + const ptr = this.pointer | |
| 579 | + return Math.round(this.ts.indexAt(ptr.x)) | |
| 580 | + } | |
| 581 | + | |
| 582 | + _crosshairX() { | |
| 583 | + const i = this._pointerIndex() | |
| 584 | + return Math.floor(this.ts.x(i)) + 0.5 | |
| 585 | + } | |
| 586 | + | |
| 587 | + _crosshairY(p) { | |
| 588 | + const ptr = this.pointer | |
| 589 | + if (this.crosshairOpts.mode !== 'magnet' || p.kind !== 'main') return ptr.y | |
| 590 | + const i = this._pointerIndex() | |
| 591 | + if (i < 0 || i >= this.store.length) return ptr.y | |
| 592 | + const rs = this.renderStore | |
| 593 | + const ps = p.scale | |
| 594 | + const cands = [rs.o[i], rs.h[i], rs.l[i], rs.c[i]] | |
| 595 | + let best = ptr.y, bestD = Infinity | |
| 596 | + for (const v of cands) { const y = ps.y(v); const d = Math.abs(y - ptr.y); if (d < bestD) { bestD = d; best = y } } | |
| 597 | + return best | |
| 598 | + } | |
| 599 | + | |
| 600 | + /** Magnet-snapped price for a pointer position in the main pane. */ | |
| 601 | + snapPrice(x, y) { | |
| 602 | + const ps = this.mainPane.scale | |
| 603 | + if (this.crosshairOpts.mode !== 'magnet') return ps.priceAt(y) | |
| 604 | + const i = Math.round(this.ts.indexAt(x)) | |
| 605 | + if (i < 0 || i >= this.store.length) return ps.priceAt(y) | |
| 606 | + const rs = this.renderStore | |
| 607 | + let best = ps.priceAt(y), bestD = 12 | |
| 608 | + for (const v of [rs.o[i], rs.h[i], rs.l[i], rs.c[i]]) { const d = Math.abs(ps.y(v) - y); if (d < bestD) { bestD = d; best = v } } | |
| 609 | + return best | |
| 610 | + } | |
| 611 | + | |
| 612 | + /* ───────────────────────── pointer / crosshair state ───────────────────────── */ | |
| 613 | + | |
| 614 | + setPointer(pos) { | |
| 615 | + const prev = this.pointer | |
| 616 | + this.pointer = pos | |
| 617 | + this.invalidate('overlay') | |
| 618 | + if (this.emitter.has('crosshairMove')) { | |
| 619 | + if (!pos || pos.region !== 'plot') { if (prev) this.emitter.emit('crosshairMove', null); return } | |
| 620 | + this.emitter.emit('crosshairMove', this._crosshairInfo(pos)) | |
| 621 | + } | |
| 622 | + } | |
| 623 | + | |
| 624 | + _crosshairInfo(pos) { | |
| 625 | + const n = this.store.length | |
| 626 | + const raw = Math.round(this.ts.indexAt(pos.x)) | |
| 627 | + const index = n ? clamp(raw, 0, n - 1) : -1 | |
| 628 | + const bar = index >= 0 ? this.store.bars[index] : null | |
| 629 | + const p = pos.pane | |
| 630 | + const y = this._crosshairY(p) | |
| 631 | + const price = p.scale.priceAt(y) | |
| 632 | + const indicators = {} | |
| 633 | + for (const ind of this.indicators.values()) { | |
| 634 | + const vals = {} | |
| 635 | + if (ind.values) for (const key of Object.keys(ind.values)) { const arr = ind.values[key]; vals[key] = index >= 0 && index < arr.length && arr[index] != null && !Number.isNaN(arr[index]) ? arr[index] : null } | |
| 636 | + indicators[ind.id] = vals | |
| 637 | + } | |
| 638 | + const compares = {} | |
| 639 | + for (const c of this.compares.values()) { | |
| 640 | + const v = index >= 0 ? c.aligned[index] : NaN | |
| 641 | + compares[c.id] = v > 0 && c.base > 0 ? (v / c.base - 1) * 100 : null | |
| 642 | + } | |
| 643 | + return { index, bar, x: pos.x, y: pos.y, price, pane: p.kind === 'main' ? 'main' : p.id, paneId: p.id, indicators, compares } | |
| 644 | + } | |
| 645 | + | |
| 646 | + /* ───────────────────────── range events ───────────────────────── */ | |
| 647 | + | |
| 648 | + _emitRange(vr) { | |
| 649 | + const key = `${vr.from}|${vr.to}|${this.store.length}|${this.ts.barSpacing.toFixed(3)}|${this.ts.leftIndex.toFixed(3)}` | |
| 650 | + if (key === this._emittedRange) return | |
| 651 | + this._emittedRange = key | |
| 652 | + if (!this.emitter.has('visibleRangeChange')) return | |
| 653 | + this.emitter.emit('visibleRangeChange', { fromIndex: vr.from, toIndex: vr.to, barsLeftOfViewport: vr.from, needMoreLeft: this.store.length > 0 && vr.from < 200 }) | |
| 654 | + } | |
| 655 | + | |
| 656 | + /* ═══════════════════════════ PUBLIC API ═══════════════════════════ */ | |
| 657 | + | |
| 658 | + setData(bars) { | |
| 659 | + this.store.set(bars || []) | |
| 660 | + this.ts.count = this.store.length | |
| 661 | + this.ts.width = this.plotWidth | |
| 662 | + this._refreshDecimals() | |
| 663 | + for (const p of this.panes) { p.scale.hasRange = false; p.scale.auto = true } | |
| 664 | + this._pulseAt = -Infinity | |
| 665 | + this._lastClose = this.store.length ? this.store.c[this.store.length - 1] : NaN | |
| 666 | + this.ts.apply(this.ts.fitView(this.opts.fitBars)) | |
| 667 | + this.ts.stickToRight = true | |
| 668 | + this.drawings.onDataChange() | |
| 669 | + this.invalidate('data') | |
| 670 | + } | |
| 671 | + | |
| 672 | + prependData(older) { | |
| 673 | + const n = this.store.prepend(older || []) | |
| 674 | + if (n) { this.ts.onPrepend(n); this._refreshDecimals(); this.drawings.onDataChange(); this.invalidate('data') } | |
| 675 | + } | |
| 676 | + | |
| 677 | + appendData(newer) { | |
| 678 | + const n = this.store.append(newer || []) | |
| 679 | + if (!n) return | |
| 680 | + this.ts.count = this.store.length | |
| 681 | + if (this.ts.stickToRight) this.ts.scrollToLatest() | |
| 682 | + this._lastClose = this.store.c[this.store.length - 1] | |
| 683 | + this.invalidate('data') | |
| 684 | + } | |
| 685 | + | |
| 686 | + updateLast(bar) { | |
| 687 | + const r = this.store.updateLast(bar) | |
| 688 | + if (r === 'ignored') return | |
| 689 | + this.ts.count = this.store.length | |
| 690 | + if (r === 'append' && this.ts.stickToRight) this.ts.scrollToLatest() | |
| 691 | + if (bar.c !== this._lastClose) { this._pulseAt = performance.now(); this._lastClose = bar.c } | |
| 692 | + this.invalidate('data') | |
| 693 | + } | |
| 694 | + | |
| 695 | + getData() { return this.store.bars.slice() } | |
| 696 | + | |
| 697 | + setTimeframe(tf) { this.opts.timeframe = tf; this.invalidate('data') } | |
| 698 | + | |
| 699 | + _refreshDecimals() { | |
| 700 | + const pf = this.opts.priceFormat | |
| 701 | + this.decimals = pf.decimals === 'auto' || pf.decimals == null ? autoDecimals(this.store.bars, pf.minMove) : pf.decimals | |
| 702 | + } | |
| 703 | + | |
| 704 | + setSeriesType(type) { | |
| 705 | + if (!SERIES_TYPES.has(type)) throw new Error(`Unknown series type: ${type}`) | |
| 706 | + this.seriesType = type | |
| 707 | + this.invalidate('data') | |
| 708 | + } | |
| 709 | + | |
| 710 | + setPriceScale({ mode, auto, invert } = {}) { | |
| 711 | + const ps = this.mainPane.scale | |
| 712 | + if (mode && ps.mode !== mode) { | |
| 713 | + if (this.compares.size && mode !== 'percent') this._modeBeforeCompare = mode | |
| 714 | + else ps.setMode(mode) | |
| 715 | + } | |
| 716 | + if (auto != null) { ps.auto = !!auto; if (ps.auto) ps._forceAnim = true } | |
| 717 | + if (invert != null) ps.invert = !!invert | |
| 718 | + this.emitter.emit('priceScaleChange', this.getPriceScale()) | |
| 719 | + this.invalidate('data') | |
| 720 | + } | |
| 721 | + | |
| 722 | + getPriceScale() { const ps = this.mainPane.scale; return { mode: ps.mode, auto: ps.auto, invert: ps.invert } } | |
| 723 | + | |
| 724 | + setVolume(visible) { | |
| 725 | + this.volumeVisible = !!visible | |
| 726 | + this.mainPane.scale.marginBottom = this.volumeVisible ? 0.08 + this.opts.volumeFraction * 0.7 : 0.08 | |
| 727 | + this.invalidate('data') | |
| 728 | + } | |
| 729 | + | |
| 730 | + setTheme(theme) { | |
| 731 | + this.theme = normalizeTheme(theme) | |
| 732 | + this.opts.theme = theme | |
| 733 | + this.el.style.background = this.theme.bg | |
| 734 | + this.invalidate('data') | |
| 735 | + } | |
| 736 | + | |
| 737 | + setCrosshair({ mode, showLabels } = {}) { | |
| 738 | + if (mode) this.crosshairOpts.mode = mode | |
| 739 | + if (showLabels != null) this.crosshairOpts.showLabels = !!showLabels | |
| 740 | + this.invalidate('overlay') | |
| 741 | + } | |
| 742 | + | |
| 743 | + setOptions(partial = {}) { | |
| 744 | + const prev = this.opts | |
| 745 | + this.opts = { ...prev, ...partial, priceFormat: { ...prev.priceFormat, ...(partial.priceFormat || {}) } } | |
| 746 | + if (partial.theme) this.setTheme(partial.theme) | |
| 747 | + if (partial.rightOffsetBars != null) this.ts.rightOffsetBars = partial.rightOffsetBars | |
| 748 | + if (partial.minBarSpacing != null) this.ts.minBarSpacing = partial.minBarSpacing | |
| 749 | + if (partial.barSpacing != null) this.ts.defaultBarSpacing = partial.barSpacing | |
| 750 | + if (partial.maxBars != null) this.store.maxBars = partial.maxBars | |
| 751 | + if (partial.priceFormat) this._refreshDecimals() | |
| 752 | + this.invalidate('data') | |
| 753 | + } | |
| 754 | + | |
| 755 | + /* ─── indicators ─── */ | |
| 756 | + | |
| 757 | + addIndicator({ type, params, pane, colors, id } = {}) { | |
| 758 | + const spec = indicatorSpec(type) | |
| 759 | + const finalId = id || `${type}-${++idSeq}` | |
| 760 | + if (this.indicators.has(finalId)) this.removeIndicator(finalId) | |
| 761 | + const where = pane || spec.pane | |
| 762 | + const ind = { id: finalId, type, params: indicatorParams(type, params), spec, colors: colors || null, values: null, paneId: null } | |
| 763 | + let target | |
| 764 | + if (where === 'main') target = this.mainPane | |
| 765 | + else { | |
| 766 | + target = new Pane(this.el, { kind: 'indicator', weight: 0.28 }) | |
| 767 | + this.panes.push(target) | |
| 768 | + this.dirty.layout = true | |
| 769 | + } | |
| 770 | + target.indicators.push(finalId) | |
| 771 | + ind.paneId = target.id | |
| 772 | + if (target.kind !== 'main') { target.title = spec.title(ind.params); if (spec.range) target.scale.fixedRange = { ...spec.range } } | |
| 773 | + this.indicators.set(finalId, ind) | |
| 774 | + this._computeIndicator(ind) | |
| 775 | + this.invalidate('layout') | |
| 776 | + return finalId | |
| 777 | + } | |
| 778 | + | |
| 779 | + _computeIndicator(ind) { | |
| 780 | + ind.values = this.store.length ? computeIndicator(ind.type, this.store.bars, ind.params) : null | |
| 781 | + } | |
| 782 | + | |
| 783 | + updateIndicator(id, params) { | |
| 784 | + const ind = this.indicators.get(id) | |
| 785 | + if (!ind) return | |
| 786 | + ind.params = indicatorParams(ind.type, { ...ind.params, ...(params || {}) }) | |
| 787 | + const p = this.panes.find(p => p.id === ind.paneId) | |
| 788 | + if (p && p.kind !== 'main') p.title = ind.spec.title(ind.params) | |
| 789 | + this._computeIndicator(ind) | |
| 790 | + this.invalidate('data') | |
| 791 | + } | |
| 792 | + | |
| 793 | + removeIndicator(id) { | |
| 794 | + const ind = this.indicators.get(id) | |
| 795 | + if (!ind) return | |
| 796 | + this.indicators.delete(id) | |
| 797 | + const p = this.panes.find(p => p.id === ind.paneId) | |
| 798 | + if (p) { | |
| 799 | + p.indicators = p.indicators.filter(x => x !== id) | |
| 800 | + if (p.kind !== 'main' && p.indicators.length === 0) this._removePane(p) | |
| 801 | + } | |
| 802 | + this.invalidate('layout') | |
| 803 | + } | |
| 804 | + | |
| 805 | + _removePane(p) { | |
| 806 | + this.panes = this.panes.filter(x => x !== p) | |
| 807 | + p.destroy() | |
| 808 | + if (this.pointer && this.pointer.pane === p) this.pointer = null | |
| 809 | + this.dirty.layout = true | |
| 810 | + } | |
| 811 | + | |
| 812 | + /** Close button of an indicator pane. */ | |
| 813 | + closePane(p) { | |
| 814 | + if (this.opts.onPaneClose) { this.opts.onPaneClose({ id: p.id, indicators: p.indicators.slice() }); return } | |
| 815 | + for (const id of p.indicators.slice()) this.removeIndicator(id) | |
| 816 | + } | |
| 817 | + | |
| 818 | + togglePane(p) { p.collapsed = !p.collapsed; this.invalidate('layout') } | |
| 819 | + | |
| 820 | + getIndicators() { | |
| 821 | + return Array.from(this.indicators.values()).map(ind => ({ | |
| 822 | + id: ind.id, type: ind.type, params: { ...ind.params }, pane: ind.paneId === this.mainPane.id ? 'main' : ind.paneId, | |
| 823 | + colors: ind.colors || ind.spec.plots.filter(p => p.key).map(p => plotColor(p.color, this.theme, null)), | |
| 824 | + title: ind.spec.title(ind.params), values: ind.values || {}, | |
| 825 | + })) | |
| 826 | + } | |
| 827 | + | |
| 828 | + /* ─── compares ─── */ | |
| 829 | + | |
| 830 | + addCompare(id, label, bars, color) { | |
| 831 | + const c = { id, label, bars: bars || [], color: color || null, aligned: new Float64Array(0), base: NaN } | |
| 832 | + this._alignCompare(c) | |
| 833 | + this.compares.set(id, c) | |
| 834 | + const ps = this.mainPane.scale | |
| 835 | + if (ps.mode !== 'percent') { this._modeBeforeCompare = ps.mode; ps.setMode('percent') } | |
| 836 | + this.invalidate('data') | |
| 837 | + } | |
| 838 | + | |
| 839 | + removeCompare(id) { | |
| 840 | + if (!this.compares.delete(id)) return | |
| 841 | + if (this.compares.size === 0 && this._modeBeforeCompare) { | |
| 842 | + this.mainPane.scale.setMode(this._modeBeforeCompare) | |
| 843 | + this._modeBeforeCompare = null | |
| 844 | + this.emitter.emit('priceScaleChange', this.getPriceScale()) | |
| 845 | + } | |
| 846 | + this.invalidate('data') | |
| 847 | + } | |
| 848 | + | |
| 849 | + /** aligned[i] = close of the compare series at (or just before) the main bar i's time; NaN when none. */ | |
| 850 | + _alignCompare(c) { | |
| 851 | + const n = this.store.length | |
| 852 | + const out = new Float64Array(n).fill(NaN) | |
| 853 | + const src = c.bars | |
| 854 | + let j = 0 | |
| 855 | + for (let i = 0; i < n; i++) { | |
| 856 | + const t = this.store.t[i] | |
| 857 | + while (j + 1 < src.length && src[j + 1].t <= t) j++ | |
| 858 | + if (src.length && src[j].t <= t) out[i] = src[j].c | |
| 859 | + } | |
| 860 | + c.aligned = out | |
| 861 | + } | |
| 862 | + | |
| 863 | + /* ─── drawings (delegated) ─── */ | |
| 864 | + | |
| 865 | + setDrawingTool(tool) { this.drawings.setTool(tool) } | |
| 866 | + getDrawings() { return this.drawings.toJSON() } | |
| 867 | + setDrawings(list) { this.drawings.fromJSON(list) } | |
| 868 | + clearDrawings() { this.drawings.clear() } | |
| 869 | + deleteSelectedDrawing() { this.drawings.deleteSelected() } | |
| 870 | + undo() { this.drawings.undo() } | |
| 871 | + redo() { this.drawings.redo() } | |
| 872 | + | |
| 873 | + /* ─── navigation ─── */ | |
| 874 | + | |
| 875 | + _animateView(target, animate) { | |
| 876 | + const from = { barSpacing: this.ts.barSpacing, leftIndex: this.ts.leftIndex } | |
| 877 | + this.kinetic = null | |
| 878 | + if (!animate || this.opts.reducedMotion) { this.ts.apply(target); this.ts.stickToRight = this.ts.isAtLatest(); this.invalidate('data'); return } | |
| 879 | + this.animator.tween({ | |
| 880 | + from, to: target, duration: 200, tag: 'view', | |
| 881 | + apply: v => { this.ts.barSpacing = v.barSpacing; this.ts.leftIndex = v.leftIndex; this.dirty.data = true }, | |
| 882 | + done: () => { this.ts.apply(target); this.ts.stickToRight = this.ts.isAtLatest(); this.dirty.data = true }, | |
| 883 | + }) | |
| 884 | + this.invalidate('data') | |
| 885 | + } | |
| 886 | + | |
| 887 | + setVisibleRange(r, animate = true) { | |
| 888 | + if (!r) return | |
| 889 | + let from, to | |
| 890 | + if (r.fromIndex != null) { from = r.fromIndex; to = r.toIndex } | |
| 891 | + else { from = this.store.indexOfTime(r.fromT); to = this.store.indexOfTime(r.toT) } | |
| 892 | + if (!(to > from)) return | |
| 893 | + this._animateView(this.ts.rangeToView(from, to, { withOffset: false }), animate) | |
| 894 | + } | |
| 895 | + | |
| 896 | + getVisibleRange() { | |
| 897 | + const vr = this.ts.visibleRange() | |
| 898 | + return { fromIndex: vr.from, toIndex: vr.to, fromT: vr.to >= vr.from ? this.store.t[vr.from] : null, toT: vr.to >= vr.from ? this.store.t[vr.to] : null } | |
| 899 | + } | |
| 900 | + | |
| 901 | + fitContent(animate = true) { | |
| 902 | + this._animateView(this.ts.fitView(this.opts.fitBars), animate) | |
| 903 | + } | |
| 904 | + | |
| 905 | + scrollToLatest(animate = true) { | |
| 906 | + const right = this.ts.latestRightIndex() | |
| 907 | + const bs = this.ts.barSpacing | |
| 908 | + const leftIndex = right - (this.ts.width - bs / 2) / bs | |
| 909 | + this._animateView({ barSpacing: bs, leftIndex }, animate) | |
| 910 | + } | |
| 911 | + | |
| 912 | + zoom(factor, anchorX) { | |
| 913 | + const ts = this.ts | |
| 914 | + const ax = anchorX == null ? ts.width : anchorX | |
| 915 | + const before = { barSpacing: ts.barSpacing, leftIndex: ts.leftIndex } | |
| 916 | + if (!ts.zoomAt(factor, ax)) return | |
| 917 | + const target = { barSpacing: ts.barSpacing, leftIndex: ts.leftIndex } | |
| 918 | + ts.barSpacing = before.barSpacing; ts.leftIndex = before.leftIndex | |
| 919 | + this._animateView(target, true) | |
| 920 | + } | |
| 921 | + | |
| 922 | + resetView() { | |
| 923 | + const ps = this.mainPane.scale | |
| 924 | + ps.auto = true; ps.invert = false; ps._forceAnim = true | |
| 925 | + for (const p of this.panes) { p.scale.auto = true; p.scale._forceAnim = true } | |
| 926 | + this.fitContent(true) | |
| 927 | + this.emitter.emit('priceScaleChange', this.getPriceScale()) | |
| 928 | + } | |
| 929 | + | |
| 930 | + toPNG(o) { | |
| 931 | + // Make sure the layers reflect the latest state before composing. | |
| 932 | + if (this._raf) { cancelAnimationFrame(this._raf); this._raf = 0 } | |
| 933 | + this.dirty.data = true | |
| 934 | + this._frame(performance.now()) | |
| 935 | + return exportPNG(this, o || {}) | |
| 936 | + } | |
| 937 | + | |
| 938 | + on(event, fn) { return this.emitter.on(event, fn) } | |
| 939 | + | |
| 940 | + destroy() { | |
| 941 | + if (this._destroyed) return | |
| 942 | + this._destroyed = true | |
| 943 | + if (this._raf) cancelAnimationFrame(this._raf) | |
| 944 | + this._raf = 0 | |
| 945 | + if (this._ro) this._ro.disconnect() | |
| 946 | + this.interactions.destroy() | |
| 947 | + this.drawings.destroy() | |
| 948 | + this.animator.clear() | |
| 949 | + for (const p of this.panes) p.destroy() | |
| 950 | + this.timeAxis.main.destroy(); this.timeAxis.overlay.destroy(); this.timeAxis.el.remove() | |
| 951 | + this.el.remove() | |
| 952 | + this.emitter.clear() | |
| 953 | + this.panes = [] | |
| 954 | + } | |
| 955 | +} | |
| 956 | + | |
| 957 | +export { withAlpha } | |
added
hfmarketdata/web/src/charts/engine/drawings/geometry.js
+115 −0
@@ -0,0 +1,115 @@ | ||
| 1 | +// Pixel-space geometry for drawings: distances and hit-testing. Pure functions (unit-tested). | |
| 2 | + | |
| 3 | +export const HANDLE_R = 6 | |
| 4 | +export const HIT_TOLERANCE = 6 | |
| 5 | + | |
| 6 | +export function distToSegment(px, py, x1, y1, x2, y2) { | |
| 7 | + const dx = x2 - x1, dy = y2 - y1 | |
| 8 | + const len2 = dx * dx + dy * dy | |
| 9 | + let t = len2 === 0 ? 0 : ((px - x1) * dx + (py - y1) * dy) / len2 | |
| 10 | + t = Math.max(0, Math.min(1, t)) | |
| 11 | + return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy)) | |
| 12 | +} | |
| 13 | + | |
| 14 | +/** Distance to the infinite line through (x1,y1)-(x2,y2). */ | |
| 15 | +export function distToLine(px, py, x1, y1, x2, y2) { | |
| 16 | + const dx = x2 - x1, dy = y2 - y1 | |
| 17 | + const len = Math.hypot(dx, dy) | |
| 18 | + if (len === 0) return Math.hypot(px - x1, py - y1) | |
| 19 | + return Math.abs(dy * px - dx * py + x2 * y1 - y2 * x1) / len | |
| 20 | +} | |
| 21 | + | |
| 22 | +/** Distance to the ray starting at (x1,y1) towards (x2,y2). */ | |
| 23 | +export function distToRay(px, py, x1, y1, x2, y2) { | |
| 24 | + const dx = x2 - x1, dy = y2 - y1 | |
| 25 | + const len2 = dx * dx + dy * dy | |
| 26 | + if (len2 === 0) return Math.hypot(px - x1, py - y1) | |
| 27 | + const t = ((px - x1) * dx + (py - y1) * dy) / len2 | |
| 28 | + if (t < 0) return Math.hypot(px - x1, py - y1) | |
| 29 | + return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy)) | |
| 30 | +} | |
| 31 | + | |
| 32 | +/** Extend a line through two points to the borders of a [0,w]×[0,h] box. `mode`: 'ray' | 'extended' | 'segment'. */ | |
| 33 | +export function extendLine(x1, y1, x2, y2, w, h, mode) { | |
| 34 | + if (mode === 'segment') return [x1, y1, x2, y2] | |
| 35 | + const dx = x2 - x1, dy = y2 - y1 | |
| 36 | + if (dx === 0 && dy === 0) return [x1, y1, x2, y2] | |
| 37 | + const far = (w + h) * 4 | |
| 38 | + const len = Math.hypot(dx, dy) | |
| 39 | + const ux = dx / len, uy = dy / len | |
| 40 | + const ex = x1 + ux * far, ey = y1 + uy * far | |
| 41 | + if (mode === 'ray') return [x1, y1, ex, ey] | |
| 42 | + return [x1 - ux * far, y1 - uy * far, ex, ey] | |
| 43 | +} | |
| 44 | + | |
| 45 | +export function pointInRect(px, py, x1, y1, x2, y2, pad = 0) { | |
| 46 | + const l = Math.min(x1, x2) - pad, r = Math.max(x1, x2) + pad | |
| 47 | + const t = Math.min(y1, y2) - pad, b = Math.max(y1, y2) + pad | |
| 48 | + return px >= l && px <= r && py >= t && py <= b | |
| 49 | +} | |
| 50 | + | |
| 51 | +/** Distance to the border of a rectangle. */ | |
| 52 | +export function distToRectBorder(px, py, x1, y1, x2, y2) { | |
| 53 | + return Math.min( | |
| 54 | + distToSegment(px, py, x1, y1, x2, y1), distToSegment(px, py, x2, y1, x2, y2), | |
| 55 | + distToSegment(px, py, x2, y2, x1, y2), distToSegment(px, py, x1, y2, x1, y1), | |
| 56 | + ) | |
| 57 | +} | |
| 58 | + | |
| 59 | +/** | |
| 60 | + * Hit-test a drawing given its pixel anchor points. Returns { part: 'handle', index } when a handle is hit, | |
| 61 | + * { part: 'body' } for the shape itself, or null. `box` = { w, h } of the plot. | |
| 62 | + */ | |
| 63 | +export function hitDrawing(type, pts, px, py, box, tol = HIT_TOLERANCE) { | |
| 64 | + for (let i = 0; i < pts.length; i++) { | |
| 65 | + if (type === 'brush') break | |
| 66 | + if (Math.hypot(px - pts[i].x, py - pts[i].y) <= HANDLE_R + 2) return { part: 'handle', index: i } | |
| 67 | + } | |
| 68 | + const [a, b, c] = pts | |
| 69 | + switch (type) { | |
| 70 | + case 'trendline': case 'arrow': case 'measure': | |
| 71 | + return distToSegment(px, py, a.x, a.y, b.x, b.y) <= tol ? { part: 'body' } : null | |
| 72 | + case 'ray': | |
| 73 | + return distToRay(px, py, a.x, a.y, b.x, b.y) <= tol ? { part: 'body' } : null | |
| 74 | + case 'extended': | |
| 75 | + return distToLine(px, py, a.x, a.y, b.x, b.y) <= tol ? { part: 'body' } : null | |
| 76 | + case 'hline': | |
| 77 | + return Math.abs(py - a.y) <= tol ? { part: 'body' } : null | |
| 78 | + case 'vline': | |
| 79 | + return Math.abs(px - a.x) <= tol ? { part: 'body' } : null | |
| 80 | + case 'rect': case 'fib': | |
| 81 | + if (type === 'fib') { | |
| 82 | + const l = Math.min(a.x, b.x), r = Math.max(a.x, b.x) | |
| 83 | + if (px < l - tol || px > r + tol) return null | |
| 84 | + const lo = Math.min(a.y, b.y), hi = Math.max(a.y, b.y) | |
| 85 | + return py >= lo - tol && py <= hi + tol ? { part: 'body' } : null | |
| 86 | + } | |
| 87 | + return distToRectBorder(px, py, a.x, a.y, b.x, b.y) <= tol || pointInRect(px, py, a.x, a.y, b.x, b.y) ? { part: 'body' } : null | |
| 88 | + case 'text': | |
| 89 | + return pointInRect(px, py, a.x - 4, a.y - 10, a.x + Math.max(40, (box.textW || 40)), a.y + 10) ? { part: 'body' } : null | |
| 90 | + case 'channel': { | |
| 91 | + if (distToSegment(px, py, a.x, a.y, b.x, b.y) <= tol) return { part: 'body' } | |
| 92 | + if (!c) return null | |
| 93 | + const ox = c.x - a.x, oy = c.y - a.y | |
| 94 | + if (distToSegment(px, py, a.x + ox, a.y + oy, b.x + ox, b.y + oy) <= tol) return { part: 'body' } | |
| 95 | + // Inside the parallelogram. | |
| 96 | + const inside = pointInPoly(px, py, [[a.x, a.y], [b.x, b.y], [b.x + ox, b.y + oy], [a.x + ox, a.y + oy]]) | |
| 97 | + return inside ? { part: 'body' } : null | |
| 98 | + } | |
| 99 | + case 'brush': { | |
| 100 | + for (let i = 1; i < pts.length; i++) if (distToSegment(px, py, pts[i - 1].x, pts[i - 1].y, pts[i].x, pts[i].y) <= tol) return { part: 'body' } | |
| 101 | + return null | |
| 102 | + } | |
| 103 | + default: | |
| 104 | + return null | |
| 105 | + } | |
| 106 | +} | |
| 107 | + | |
| 108 | +export function pointInPoly(px, py, poly) { | |
| 109 | + let inside = false | |
| 110 | + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { | |
| 111 | + const [xi, yi] = poly[i], [xj, yj] = poly[j] | |
| 112 | + if ((yi > py) !== (yj > py) && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) inside = !inside | |
| 113 | + } | |
| 114 | + return inside | |
| 115 | +} | |
added
hfmarketdata/web/src/charts/engine/drawings/manager.js
+444 −0
@@ -0,0 +1,444 @@ | ||
| 1 | +// Drawing manager: tool state machine (click-click or drag to create), selection, handles, move/resize, | |
| 2 | +// undo/redo (JSON snapshots), rendering on the main pane overlay. | |
| 3 | + | |
| 4 | +import { TOOLS, POINT_COUNT, FIB_LEVELS, newDrawing, serialize, deserialize } from './model.js' | |
| 5 | +import { hitDrawing, extendLine, HANDLE_R } from './geometry.js' | |
| 6 | +import { crisp, withAlpha, roundRect } from '../render/canvas.js' | |
| 7 | +import { font, pill, measure, inkFor } from '../render/text.js' | |
| 8 | +import { formatPercent } from '../format/number.js' | |
| 9 | +import { fmtDuration, fmtFull } from '../format/time.js' | |
| 10 | + | |
| 11 | +const MAX_HISTORY = 100 | |
| 12 | + | |
| 13 | +export class DrawingManager { | |
| 14 | + constructor(chart) { | |
| 15 | + this.chart = chart | |
| 16 | + this.list = [] | |
| 17 | + this.tool = null | |
| 18 | + this.creating = null // { type, points: [{t, price}], preview: {t, price} | null } | |
| 19 | + this.selectedId = null | |
| 20 | + this.hoverId = null | |
| 21 | + this.drag = null // { id, part, index, startX, startY, orig } | |
| 22 | + this.undoStack = [] | |
| 23 | + this.redoStack = [] | |
| 24 | + this._textW = 40 | |
| 25 | + } | |
| 26 | + | |
| 27 | + /* ─── coordinates ─── */ | |
| 28 | + | |
| 29 | + toPixel(p) { | |
| 30 | + const c = this.chart | |
| 31 | + const i = c.store.indexOfTime(p.t) | |
| 32 | + return { x: c.ts.x(i), y: c.mainPane.scale.y(p.price) } | |
| 33 | + } | |
| 34 | + | |
| 35 | + fromPixel(x, y, snap = true) { | |
| 36 | + const c = this.chart | |
| 37 | + const idx = c.ts.indexAt(x) | |
| 38 | + const t = c.store.timeAtIndex(idx) | |
| 39 | + const price = snap ? c.snapPrice(x, y) : c.mainPane.scale.priceAt(y) | |
| 40 | + return { t: t == null ? 0 : t, price } | |
| 41 | + } | |
| 42 | + | |
| 43 | + /* ─── public API ─── */ | |
| 44 | + | |
| 45 | + setTool(tool) { | |
| 46 | + if (tool != null && !TOOLS.includes(tool)) throw new Error(`Unknown drawing tool: ${tool}`) | |
| 47 | + this.tool = tool | |
| 48 | + this.creating = null | |
| 49 | + if (tool) this.selectedId = null | |
| 50 | + this.chart.emitter.emit('toolChange', tool) | |
| 51 | + this.chart.invalidate('overlay') | |
| 52 | + } | |
| 53 | + | |
| 54 | + toJSON() { return serialize(this.list) } | |
| 55 | + | |
| 56 | + fromJSON(list) { | |
| 57 | + this._push() | |
| 58 | + this.list = deserialize(list) | |
| 59 | + this.selectedId = null | |
| 60 | + this._changed() | |
| 61 | + } | |
| 62 | + | |
| 63 | + clear() { | |
| 64 | + if (!this.list.length) return | |
| 65 | + this._push() | |
| 66 | + this.list = [] | |
| 67 | + this.selectedId = null | |
| 68 | + this._changed() | |
| 69 | + } | |
| 70 | + | |
| 71 | + deleteSelected() { | |
| 72 | + if (!this.selectedId) return | |
| 73 | + const d = this.list.find(x => x.id === this.selectedId) | |
| 74 | + if (!d || d.locked) return | |
| 75 | + this._push() | |
| 76 | + this.list = this.list.filter(x => x.id !== this.selectedId) | |
| 77 | + this.selectedId = null | |
| 78 | + this._changed() | |
| 79 | + } | |
| 80 | + | |
| 81 | + undo() { | |
| 82 | + if (!this.undoStack.length) return | |
| 83 | + this.redoStack.push(JSON.stringify(serialize(this.list))) | |
| 84 | + this.list = deserialize(JSON.parse(this.undoStack.pop())) | |
| 85 | + this.selectedId = null | |
| 86 | + this._changed() | |
| 87 | + } | |
| 88 | + | |
| 89 | + redo() { | |
| 90 | + if (!this.redoStack.length) return | |
| 91 | + this.undoStack.push(JSON.stringify(serialize(this.list))) | |
| 92 | + this.list = deserialize(JSON.parse(this.redoStack.pop())) | |
| 93 | + this.selectedId = null | |
| 94 | + this._changed() | |
| 95 | + } | |
| 96 | + | |
| 97 | + escape() { | |
| 98 | + if (this.creating) { this.creating = null; this.chart.invalidate('overlay'); return } | |
| 99 | + if (this.tool) { this.setTool(null); return } | |
| 100 | + if (this.selectedId) { this.selectedId = null; this.chart.invalidate('overlay') } | |
| 101 | + } | |
| 102 | + | |
| 103 | + onDataChange() { this.chart.invalidate('overlay') } | |
| 104 | + destroy() { this.list = []; this.undoStack = []; this.redoStack = [] } | |
| 105 | + | |
| 106 | + _push() { | |
| 107 | + this.undoStack.push(JSON.stringify(serialize(this.list))) | |
| 108 | + if (this.undoStack.length > MAX_HISTORY) this.undoStack.shift() | |
| 109 | + this.redoStack = [] | |
| 110 | + } | |
| 111 | + | |
| 112 | + _changed() { | |
| 113 | + this.chart.emitter.emit('drawingsChange', this.toJSON()) | |
| 114 | + this.chart.invalidate('overlay') | |
| 115 | + } | |
| 116 | + | |
| 117 | + /* ─── hit testing ─── */ | |
| 118 | + | |
| 119 | + _pixels(d) { return d.points.map(p => this.toPixel(p)) } | |
| 120 | + | |
| 121 | + hitTest(x, y) { | |
| 122 | + const box = { w: this.chart.plotWidth, h: this.chart.mainPane.height, textW: this._textW } | |
| 123 | + // Selected drawing first (its handles take priority), then top-most. | |
| 124 | + const order = this.list.slice().sort((a, b) => (a.id === this.selectedId ? -1 : b.id === this.selectedId ? 1 : 0)) | |
| 125 | + for (const d of order) { | |
| 126 | + const hit = hitDrawing(d.type, this._pixels(d), x, y, box) | |
| 127 | + if (hit) { if (hit.part === 'handle' && d.id !== this.selectedId) return { id: d.id, part: 'body' }; return { id: d.id, ...hit } } | |
| 128 | + } | |
| 129 | + return null | |
| 130 | + } | |
| 131 | + | |
| 132 | + cursorAt(x, y) { | |
| 133 | + if (this.drag) return this.drag.part === 'handle' ? 'grabbing' : 'move' | |
| 134 | + if (this.tool) return 'crosshair' | |
| 135 | + const h = this.hitTest(x, y) | |
| 136 | + if (!h) return null | |
| 137 | + return h.part === 'handle' ? 'grab' : 'pointer' | |
| 138 | + } | |
| 139 | + | |
| 140 | + /* ─── pointer state machine (coordinates relative to the main pane plot) ─── */ | |
| 141 | + | |
| 142 | + pointerDown(x, y, ev) { | |
| 143 | + if (this.tool) { | |
| 144 | + const p = this.fromPixel(x, y) | |
| 145 | + if (!this.creating) { | |
| 146 | + this.creating = { type: this.tool, points: [p], preview: null, downX: x, downY: y, dragging: true } | |
| 147 | + if (POINT_COUNT[this.tool] === 1) { this._commitCreating(); return true } | |
| 148 | + return true | |
| 149 | + } | |
| 150 | + // Second / third click. | |
| 151 | + this.creating.points.push(p) | |
| 152 | + this.creating.dragging = true | |
| 153 | + this.creating.downX = x; this.creating.downY = y | |
| 154 | + if (this.creating.points.length >= POINT_COUNT[this.creating.type]) this._commitCreating() | |
| 155 | + return true | |
| 156 | + } | |
| 157 | + const hit = this.hitTest(x, y) | |
| 158 | + if (!hit) { | |
| 159 | + if (this.selectedId) { this.selectedId = null; this.chart.invalidate('overlay') } | |
| 160 | + return false | |
| 161 | + } | |
| 162 | + const d = this.list.find(z => z.id === hit.id) | |
| 163 | + this.selectedId = hit.id | |
| 164 | + if (!d.locked) this.drag = { id: hit.id, part: hit.part, index: hit.index, startX: x, startY: y, orig: d.points.map(p => ({ ...p })), moved: false, snapshot: JSON.stringify(serialize(this.list)) } | |
| 165 | + this.chart.invalidate('overlay') | |
| 166 | + void ev | |
| 167 | + return true | |
| 168 | + } | |
| 169 | + | |
| 170 | + pointerMove(x, y, ev) { | |
| 171 | + if (this.creating) { | |
| 172 | + const p = this.fromPixel(x, y) | |
| 173 | + const cr = this.creating | |
| 174 | + if (cr.type === 'brush') { if (cr.dragging) cr.points.push(p) } | |
| 175 | + else cr.preview = p | |
| 176 | + this.chart.invalidate('overlay') | |
| 177 | + return true | |
| 178 | + } | |
| 179 | + if (this.drag) { | |
| 180 | + const dg = this.drag | |
| 181 | + const d = this.list.find(z => z.id === dg.id) | |
| 182 | + if (!d) { this.drag = null; return false } | |
| 183 | + if (!dg.moved && Math.hypot(x - dg.startX, y - dg.startY) < 2) return true | |
| 184 | + dg.moved = true | |
| 185 | + if (dg.part === 'handle') { | |
| 186 | + const p = this.fromPixel(x, y) | |
| 187 | + d.points[dg.index] = p | |
| 188 | + } else { | |
| 189 | + // Move: translate every point by the pixel delta (in index/price space). | |
| 190 | + const c = this.chart | |
| 191 | + const di = (x - dg.startX) / c.ts.barSpacing | |
| 192 | + const ps = c.mainPane.scale | |
| 193 | + const dPix = y - dg.startY | |
| 194 | + d.points = dg.orig.map(o => { | |
| 195 | + const i0 = c.store.indexOfTime(o.t) | |
| 196 | + const t = c.store.timeAtIndex(i0 + di) | |
| 197 | + const yy = ps.y(o.price) + dPix | |
| 198 | + return { t: t == null ? o.t : t, price: ps.priceAt(yy) } | |
| 199 | + }) | |
| 200 | + } | |
| 201 | + this.chart.invalidate('overlay') | |
| 202 | + return true | |
| 203 | + } | |
| 204 | + const h = this.hitTest(x, y) | |
| 205 | + const id = h ? h.id : null | |
| 206 | + if (id !== this.hoverId) { this.hoverId = id; this.chart.invalidate('overlay') } | |
| 207 | + void ev | |
| 208 | + return false | |
| 209 | + } | |
| 210 | + | |
| 211 | + pointerUp(x, y) { | |
| 212 | + if (this.creating) { | |
| 213 | + const cr = this.creating | |
| 214 | + if (cr.type === 'brush') { cr.dragging = false; if (cr.points.length >= 2) this._commitCreating(); else this.creating = null; return true } | |
| 215 | + const moved = Math.hypot(x - cr.downX, y - cr.downY) > 4 | |
| 216 | + if (moved && cr.dragging) { | |
| 217 | + cr.points.push(this.fromPixel(x, y)) | |
| 218 | + if (cr.points.length >= POINT_COUNT[cr.type]) this._commitCreating() | |
| 219 | + else cr.preview = null | |
| 220 | + } | |
| 221 | + cr.dragging = false | |
| 222 | + return true | |
| 223 | + } | |
| 224 | + if (this.drag) { | |
| 225 | + const dg = this.drag | |
| 226 | + this.drag = null | |
| 227 | + if (dg.moved) { | |
| 228 | + this.undoStack.push(dg.snapshot) | |
| 229 | + if (this.undoStack.length > MAX_HISTORY) this.undoStack.shift() | |
| 230 | + this.redoStack = [] | |
| 231 | + this._changed() | |
| 232 | + } | |
| 233 | + return true | |
| 234 | + } | |
| 235 | + return false | |
| 236 | + } | |
| 237 | + | |
| 238 | + _commitCreating() { | |
| 239 | + const cr = this.creating | |
| 240 | + this.creating = null | |
| 241 | + if (!cr) return | |
| 242 | + const d = newDrawing(cr.type, cr.points, { text: cr.type === 'text' ? 'Text' : undefined }) | |
| 243 | + this._push() | |
| 244 | + this.list.push(d) | |
| 245 | + this.selectedId = cr.type === 'measure' ? null : d.id | |
| 246 | + // Measure is transient: it is removed as soon as the tool is used again or deselected — keep it as a | |
| 247 | + // normal drawing here (callers may filter type === 'measure' before persisting). | |
| 248 | + this.setToolAfterCreate() | |
| 249 | + this._changed() | |
| 250 | + } | |
| 251 | + | |
| 252 | + setToolAfterCreate() { | |
| 253 | + // One shape per activation, like most terminals; brush stays active. | |
| 254 | + if (this.tool !== 'brush') { this.tool = null; this.chart.emitter.emit('toolChange', null) } | |
| 255 | + } | |
| 256 | + | |
| 257 | + /* ─── rendering ─── */ | |
| 258 | + | |
| 259 | + draw(ctx, g) { | |
| 260 | + const { theme } = g | |
| 261 | + for (const d of this.list) this._drawOne(ctx, g, d, d.id === this.selectedId, d.id === this.hoverId) | |
| 262 | + if (this.creating) { | |
| 263 | + const cr = this.creating | |
| 264 | + const pts = cr.points.slice() | |
| 265 | + if (cr.preview && cr.type !== 'brush') pts.push(cr.preview) | |
| 266 | + if (pts.length >= 1) { | |
| 267 | + const need = POINT_COUNT[cr.type] | |
| 268 | + const tmp = { type: cr.type, points: pts, style: { width: 1 }, text: cr.type === 'text' ? 'Text' : undefined } | |
| 269 | + if (need === Infinity || pts.length >= Math.min(need, 2) || need === 1) this._drawOne(ctx, g, tmp, true, false, true) | |
| 270 | + else this._drawHandles(ctx, pts.map(p => this.toPixel(p)), theme) | |
| 271 | + } | |
| 272 | + } | |
| 273 | + } | |
| 274 | + | |
| 275 | + _drawOne(ctx, g, d, selected, hovered, preview = false) { | |
| 276 | + const { theme, width: W, height: H } = g | |
| 277 | + const pts = this._pixels(d) | |
| 278 | + const color = (d.style && d.style.color) || theme.drawing | |
| 279 | + const lw = (d.style && d.style.width) || 1 | |
| 280 | + ctx.save() | |
| 281 | + ctx.strokeStyle = color | |
| 282 | + ctx.fillStyle = color | |
| 283 | + ctx.lineWidth = hovered && !selected ? lw + 1 : lw | |
| 284 | + ctx.lineJoin = 'round'; ctx.lineCap = 'round' | |
| 285 | + if (d.style && d.style.dash) ctx.setLineDash(d.style.dash) | |
| 286 | + if (preview) ctx.globalAlpha = 0.85 | |
| 287 | + const [a, b, c] = pts | |
| 288 | + switch (d.type) { | |
| 289 | + case 'trendline': case 'ray': case 'extended': { | |
| 290 | + const mode = d.type === 'trendline' ? 'segment' : d.type | |
| 291 | + const [x1, y1, x2, y2] = extendLine(a.x, a.y, b.x, b.y, W, H, mode) | |
| 292 | + line(ctx, x1, y1, x2, y2) | |
| 293 | + break | |
| 294 | + } | |
| 295 | + case 'arrow': { | |
| 296 | + line(ctx, a.x, a.y, b.x, b.y) | |
| 297 | + arrowHead(ctx, a.x, a.y, b.x, b.y, 9 + lw * 2) | |
| 298 | + break | |
| 299 | + } | |
| 300 | + case 'hline': { | |
| 301 | + ctx.setLineDash(d.style && d.style.dash ? d.style.dash : []) | |
| 302 | + line(ctx, 0, crisp(a.y), W, crisp(a.y)) | |
| 303 | + pill(ctx, this.chart._formatMain(d.points[0].price), W + 4, a.y, { bg: color, color: inkFor(color), fontStr: font(theme, { mono: true, size: 11 }), h: 18, padX: 4, clampTo: { x0: W + 2, x1: W + this.chart.axisWidth - 1, y0: 0, y1: H } }) | |
| 304 | + break | |
| 305 | + } | |
| 306 | + case 'vline': { | |
| 307 | + line(ctx, crisp(a.x), 0, crisp(a.x), H) | |
| 308 | + const t = d.points[0].t | |
| 309 | + pill(ctx, fmtFull(t, this.chart.opts.timeframe, this.chart.opts.sessionLabel), a.x + 6, 12, { bg: withAlpha(color, 0.9), color: inkFor(color), fontStr: font(theme, { mono: true, size: 10 }), h: 16, padX: 4, clampTo: { x0: 0, x1: W, y0: 0, y1: H } }) | |
| 310 | + break | |
| 311 | + } | |
| 312 | + case 'rect': { | |
| 313 | + const l = Math.min(a.x, b.x), t = Math.min(a.y, b.y), w = Math.abs(b.x - a.x), h = Math.abs(b.y - a.y) | |
| 314 | + ctx.fillStyle = withAlpha(color, 0.12); ctx.fillRect(l, t, w, h) | |
| 315 | + ctx.strokeRect(crisp(l), crisp(t), Math.round(w), Math.round(h)) | |
| 316 | + break | |
| 317 | + } | |
| 318 | + case 'channel': { | |
| 319 | + line(ctx, a.x, a.y, b.x, b.y) | |
| 320 | + if (c) { | |
| 321 | + const ox = c.x - a.x, oy = c.y - a.y | |
| 322 | + line(ctx, a.x + ox, a.y + oy, b.x + ox, b.y + oy) | |
| 323 | + ctx.fillStyle = withAlpha(color, 0.10) | |
| 324 | + ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(b.x + ox, b.y + oy); ctx.lineTo(a.x + ox, a.y + oy); ctx.closePath(); ctx.fill() | |
| 325 | + ctx.setLineDash([3, 3]); ctx.globalAlpha *= 0.6 | |
| 326 | + line(ctx, a.x + ox / 2, a.y + oy / 2, b.x + ox / 2, b.y + oy / 2) | |
| 327 | + ctx.setLineDash([]); ctx.globalAlpha = preview ? 0.85 : 1 | |
| 328 | + } | |
| 329 | + break | |
| 330 | + } | |
| 331 | + case 'brush': { | |
| 332 | + ctx.lineWidth = Math.max(1.5, lw) | |
| 333 | + ctx.beginPath() | |
| 334 | + pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y))) | |
| 335 | + ctx.stroke() | |
| 336 | + break | |
| 337 | + } | |
| 338 | + case 'fib': this._drawFib(ctx, g, d, pts, color); break | |
| 339 | + case 'measure': this._drawMeasure(ctx, g, d, pts, theme); break | |
| 340 | + case 'text': { | |
| 341 | + const text = d.text || 'Text' | |
| 342 | + const f = font(theme, { size: 12, weight: 500 }) | |
| 343 | + this._textW = measure(ctx, text, f) + 8 | |
| 344 | + ctx.font = f | |
| 345 | + ctx.fillStyle = withAlpha(theme.bg, 0.75) | |
| 346 | + roundRect(ctx, a.x - 4, a.y - 10, this._textW, 20, 3); ctx.fill() | |
| 347 | + ctx.fillStyle = color | |
| 348 | + ctx.textBaseline = 'middle'; ctx.textAlign = 'left' | |
| 349 | + ctx.fillText(text, a.x, a.y + 0.5) | |
| 350 | + break | |
| 351 | + } | |
| 352 | + default: break | |
| 353 | + } | |
| 354 | + ctx.restore() | |
| 355 | + if (selected && d.type !== 'brush') this._drawHandles(ctx, pts, theme, d.locked) | |
| 356 | + else if (selected && d.type === 'brush') { ctx.save(); ctx.strokeStyle = theme.selection; ctx.lineWidth = 6; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.beginPath(); pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y))); ctx.stroke(); ctx.restore() } | |
| 357 | + } | |
| 358 | + | |
| 359 | + _drawHandles(ctx, pts, theme, locked = false) { | |
| 360 | + for (const p of pts) { | |
| 361 | + ctx.beginPath(); ctx.arc(p.x, p.y, HANDLE_R + 4, 0, Math.PI * 2) | |
| 362 | + ctx.fillStyle = theme.selection; ctx.fill() | |
| 363 | + ctx.beginPath(); ctx.arc(p.x, p.y, HANDLE_R / 2 + 0.5, 0, Math.PI * 2) | |
| 364 | + ctx.fillStyle = locked ? theme.textMuted : theme.drawingHandle; ctx.fill() | |
| 365 | + ctx.lineWidth = 1.5; ctx.strokeStyle = theme.drawing; ctx.stroke() | |
| 366 | + } | |
| 367 | + } | |
| 368 | + | |
| 369 | + _drawFib(ctx, g, d, pts, color) { | |
| 370 | + const { theme, width: W } = g | |
| 371 | + const [a, b] = pts | |
| 372 | + const p0 = d.points[0].price, p1 = d.points[1].price | |
| 373 | + const l = Math.min(a.x, b.x), r = Math.max(a.x, b.x) | |
| 374 | + const ps = this.chart.mainPane.scale | |
| 375 | + const f = font(theme, { mono: true, size: 10 }) | |
| 376 | + ctx.font = f; ctx.textBaseline = 'middle' | |
| 377 | + const levels = d.style && d.style.extended ? [...FIB_LEVELS, 1.618] : FIB_LEVELS | |
| 378 | + let prevY = null | |
| 379 | + levels.forEach((lv, i) => { | |
| 380 | + const price = p0 + (p1 - p0) * lv | |
| 381 | + const y = ps.y(price) | |
| 382 | + const c = theme.series[i % theme.series.length] | |
| 383 | + if (prevY != null) { ctx.fillStyle = withAlpha(c, 0.06); ctx.fillRect(l, Math.min(prevY, y), r - l, Math.abs(y - prevY)) } | |
| 384 | + ctx.strokeStyle = withAlpha(c, 0.9); ctx.lineWidth = 1 | |
| 385 | + line(ctx, l, crisp(y), r, crisp(y)) | |
| 386 | + const label = `${lv.toFixed(3).replace(/0+$/, '').replace(/\.$/, '')} ${this.chart._formatMain(price)}` | |
| 387 | + ctx.fillStyle = c | |
| 388 | + ctx.textAlign = 'left' | |
| 389 | + ctx.fillText(label, Math.min(r + 6, W - 120), y) | |
| 390 | + prevY = y | |
| 391 | + }) | |
| 392 | + ctx.strokeStyle = withAlpha(color, 0.5); ctx.setLineDash([3, 3]) | |
| 393 | + line(ctx, a.x, a.y, b.x, b.y) | |
| 394 | + ctx.setLineDash([]) | |
| 395 | + } | |
| 396 | + | |
| 397 | + _drawMeasure(ctx, g, d, pts, theme) { | |
| 398 | + const { width: W, height: H } = g | |
| 399 | + const [a, b] = pts | |
| 400 | + const up = d.points[1].price >= d.points[0].price | |
| 401 | + const color = up ? theme.up : theme.down | |
| 402 | + const l = Math.min(a.x, b.x), t = Math.min(a.y, b.y), w = Math.abs(b.x - a.x), h = Math.abs(b.y - a.y) | |
| 403 | + ctx.fillStyle = withAlpha(color, 0.12); ctx.fillRect(l, t, w, h) | |
| 404 | + ctx.strokeStyle = withAlpha(color, 0.8); ctx.lineWidth = 1; ctx.setLineDash([3, 3]) | |
| 405 | + ctx.strokeRect(crisp(l), crisp(t), Math.round(w), Math.round(h)) | |
| 406 | + ctx.setLineDash([]) | |
| 407 | + // Arrow along the vertical move. | |
| 408 | + const mx = l + w / 2 | |
| 409 | + ctx.strokeStyle = color; ctx.lineWidth = 1.25 | |
| 410 | + line(ctx, mx, a.y, mx, b.y); arrowHead(ctx, mx, a.y, mx, b.y, 8) | |
| 411 | + const c = this.chart | |
| 412 | + const dp = d.points[1].price - d.points[0].price | |
| 413 | + const pct = d.points[0].price ? (dp / d.points[0].price) * 100 : 0 | |
| 414 | + const i0 = c.store.indexOfTime(d.points[0].t), i1 = c.store.indexOfTime(d.points[1].t) | |
| 415 | + const bars = Math.round(i1 - i0) | |
| 416 | + const lines = [ | |
| 417 | + `${dp >= 0 ? '+' : '−'}${c._formatMain(Math.abs(dp))} (${formatPercent(pct, 2, c.opts.locale)})`, | |
| 418 | + `${bars} bars · ${fmtDuration(d.points[1].t - d.points[0].t)}`, | |
| 419 | + ] | |
| 420 | + const f = font(theme, { mono: true, size: 11, weight: 500 }) | |
| 421 | + ctx.font = f | |
| 422 | + const bw = Math.max(...lines.map(s => measure(ctx, s, f))) + 16 | |
| 423 | + const bh = 18 * lines.length + 6 | |
| 424 | + let bx = mx - bw / 2, by = up ? t - bh - 8 : t + h + 8 | |
| 425 | + bx = Math.max(2, Math.min(W - bw - 2, bx)); by = Math.max(2, Math.min(H - bh - 2, by)) | |
| 426 | + ctx.fillStyle = color | |
| 427 | + roundRect(ctx, bx, by, bw, bh, 4); ctx.fill() | |
| 428 | + ctx.fillStyle = inkFor(color); ctx.textBaseline = 'middle'; ctx.textAlign = 'center' | |
| 429 | + lines.forEach((s, i) => ctx.fillText(s, bx + bw / 2, by + 3 + 9 + i * 18)) | |
| 430 | + ctx.textAlign = 'left' | |
| 431 | + } | |
| 432 | +} | |
| 433 | + | |
| 434 | +function line(ctx, x1, y1, x2, y2) { ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke() } | |
| 435 | + | |
| 436 | +function arrowHead(ctx, x1, y1, x2, y2, size) { | |
| 437 | + const ang = Math.atan2(y2 - y1, x2 - x1) | |
| 438 | + ctx.beginPath() | |
| 439 | + ctx.moveTo(x2, y2) | |
| 440 | + ctx.lineTo(x2 - size * Math.cos(ang - Math.PI / 7), y2 - size * Math.sin(ang - Math.PI / 7)) | |
| 441 | + ctx.lineTo(x2 - size * Math.cos(ang + Math.PI / 7), y2 - size * Math.sin(ang + Math.PI / 7)) | |
| 442 | + ctx.closePath() | |
| 443 | + ctx.fill() | |
| 444 | +} | |
added
hfmarketdata/web/src/charts/engine/drawings/model.js
+57 −0
@@ -0,0 +1,57 @@ | ||
| 1 | +// Drawing model: tool definitions, JSON (de)serialization and validation. Coordinates are { t, price }. | |
| 2 | + | |
| 3 | +export const TOOLS = ['trendline', 'ray', 'extended', 'hline', 'vline', 'rect', 'fib', 'measure', 'text', 'arrow', 'channel', 'brush'] | |
| 4 | + | |
| 5 | +/** Number of anchor points each tool needs (brush = free-hand, ends on pointer up). */ | |
| 6 | +export const POINT_COUNT = { trendline: 2, ray: 2, extended: 2, hline: 1, vline: 1, rect: 2, fib: 2, measure: 2, text: 1, arrow: 2, channel: 3, brush: Infinity } | |
| 7 | + | |
| 8 | +export const FIB_LEVELS = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1] | |
| 9 | +export const FIB_EXTENDED = [...FIB_LEVELS, 1.618] | |
| 10 | + | |
| 11 | +let seq = 0 | |
| 12 | +export const newId = () => `d${Date.now().toString(36)}${(++seq).toString(36)}` | |
| 13 | + | |
| 14 | +export function newDrawing(type, points, { style, text, locked } = {}) { | |
| 15 | + return { | |
| 16 | + id: newId(), type, points: points.map(p => ({ t: p.t, price: p.price })), | |
| 17 | + style: { color: undefined, width: 1, dash: null, ...(style || {}) }, | |
| 18 | + text: text || undefined, locked: !!locked, | |
| 19 | + } | |
| 20 | +} | |
| 21 | + | |
| 22 | +/** Normalize an untrusted JSON drawing; returns null when unusable. */ | |
| 23 | +export function validateDrawing(d) { | |
| 24 | + if (!d || typeof d !== 'object') return null | |
| 25 | + if (!TOOLS.includes(d.type)) return null | |
| 26 | + if (!Array.isArray(d.points) || d.points.length === 0) return null | |
| 27 | + const need = POINT_COUNT[d.type] | |
| 28 | + const points = d.points.filter(p => p && Number.isFinite(p.t) && Number.isFinite(p.price)).map(p => ({ t: p.t, price: p.price })) | |
| 29 | + if (need !== Infinity && points.length < need) return null | |
| 30 | + if (need === Infinity && points.length < 2) return null | |
| 31 | + const style = { width: 1, dash: null, ...(d.style && typeof d.style === 'object' ? d.style : {}) } | |
| 32 | + if (!(style.width > 0)) style.width = 1 | |
| 33 | + if (style.dash != null && !Array.isArray(style.dash)) style.dash = null | |
| 34 | + return { | |
| 35 | + id: typeof d.id === 'string' && d.id ? d.id : newId(), type: d.type, | |
| 36 | + points: need === Infinity ? points : points.slice(0, need), | |
| 37 | + style, text: typeof d.text === 'string' ? d.text : undefined, locked: !!d.locked, | |
| 38 | + } | |
| 39 | +} | |
| 40 | + | |
| 41 | +/** Plain JSON copy of a list of drawings (strips undefined). */ | |
| 42 | +export function serialize(list) { | |
| 43 | + return list.map(d => { | |
| 44 | + const o = { id: d.id, type: d.type, points: d.points.map(p => ({ t: p.t, price: p.price })) } | |
| 45 | + const style = {} | |
| 46 | + if (d.style) for (const k of ['color', 'width', 'dash']) if (d.style[k] != null) style[k] = d.style[k] | |
| 47 | + if (Object.keys(style).length) o.style = style | |
| 48 | + if (d.text != null) o.text = d.text | |
| 49 | + if (d.locked) o.locked = true | |
| 50 | + return o | |
| 51 | + }) | |
| 52 | +} | |
| 53 | + | |
| 54 | +export function deserialize(list) { | |
| 55 | + if (!Array.isArray(list)) return [] | |
| 56 | + return list.map(validateDrawing).filter(Boolean) | |
| 57 | +} | |
added
hfmarketdata/web/src/charts/engine/export/png.js
+52 −0
@@ -0,0 +1,52 @@ | ||
| 1 | +// PNG export: composes every pane layer and the time axis onto one canvas, adds the attribution, returns a Blob. | |
| 2 | + | |
| 3 | +import { font } from '../render/text.js' | |
| 4 | +import { withAlpha } from '../render/canvas.js' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * @param chart internal chart (uses chart.panes, chart.timeAxis, chart.width/height, chart.theme) | |
| 8 | + * @param o { scale?: number, watermark?: string, background?: string } | |
| 9 | + */ | |
| 10 | +export async function exportPNG(chart, { scale = 2, watermark, background } = {}) { | |
| 11 | + const w = chart.width, h = chart.height | |
| 12 | + const out = document.createElement('canvas') | |
| 13 | + out.width = Math.round(w * scale); out.height = Math.round(h * scale) | |
| 14 | + const ctx = out.getContext('2d') | |
| 15 | + ctx.scale(scale, scale) | |
| 16 | + ctx.fillStyle = background || chart.theme.bg | |
| 17 | + ctx.fillRect(0, 0, w, h) | |
| 18 | + const blit = (layer, x, y) => { | |
| 19 | + if (!layer.canvas.width || !layer.canvas.height) return | |
| 20 | + ctx.drawImage(layer.canvas, 0, 0, layer.canvas.width, layer.canvas.height, x, y, layer.width, layer.height) | |
| 21 | + } | |
| 22 | + for (const p of chart.panes) { blit(p.main, 0, p.top); blit(p.overlay, 0, p.top) } | |
| 23 | + blit(chart.timeAxis.main, 0, chart.timeAxis.top) | |
| 24 | + const theme = chart.theme | |
| 25 | + if (watermark) { | |
| 26 | + ctx.font = font(theme, { size: 13, weight: 600 }) | |
| 27 | + ctx.fillStyle = withAlpha(theme.text, 0.55) | |
| 28 | + ctx.textBaseline = 'top'; ctx.textAlign = 'left' | |
| 29 | + ctx.fillText(watermark, 10, 8) | |
| 30 | + } | |
| 31 | + // Attribution (export only). | |
| 32 | + const attr = 'hfmarketdata.io' | |
| 33 | + ctx.font = font(theme, { size: 11, weight: 500 }) | |
| 34 | + ctx.textBaseline = 'bottom'; ctx.textAlign = 'left' | |
| 35 | + const tw = ctx.measureText(attr).width | |
| 36 | + ctx.fillStyle = withAlpha(theme.bg, 0.8) | |
| 37 | + ctx.fillRect(6, h - chart.timeAxis.height - 22, tw + 12, 18) | |
| 38 | + ctx.fillStyle = withAlpha(theme.text, 0.7) | |
| 39 | + ctx.fillText(attr, 12, h - chart.timeAxis.height - 7) | |
| 40 | + return new Promise((resolve, reject) => { | |
| 41 | + if (out.toBlob) out.toBlob(b => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png') | |
| 42 | + else { | |
| 43 | + try { | |
| 44 | + const url = out.toDataURL('image/png') | |
| 45 | + const bin = atob(url.split(',')[1]) | |
| 46 | + const arr = new Uint8Array(bin.length) | |
| 47 | + for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i) | |
| 48 | + resolve(new Blob([arr], { type: 'image/png' })) | |
| 49 | + } catch (e) { reject(e) } | |
| 50 | + } | |
| 51 | + }) | |
| 52 | +} | |
added
hfmarketdata/web/src/charts/engine/index.js
+20 −0
@@ -0,0 +1,20 @@ | ||
| 1 | +// hfmarketdata chart engine — public entry point. See ../CONTRACT.md for the API contract. | |
| 2 | + | |
| 3 | +import { Chart } from './core/chart.js' | |
| 4 | + | |
| 5 | +export { darkTheme, lightTheme, normalizeTheme } from './theme.js' | |
| 6 | +export { TOOLS as DRAWING_TOOLS } from './drawings/model.js' | |
| 7 | +export { INDICATOR_TYPES, REGISTRY as INDICATORS, computeIndicator, indicatorParams } from '../indicators/index.js' | |
| 8 | + | |
| 9 | +export const SERIES_TYPES = ['candles', 'hollow', 'ohlc', 'line', 'area', 'baseline', 'heikin', 'columns', 'hlc'] | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * Create a chart inside `container` (the element must have a size: the chart fills it and follows it with a | |
| 13 | + * ResizeObserver). Returns the Chart API described in CONTRACT.md. | |
| 14 | + */ | |
| 15 | +export function createChart(container, options = {}) { | |
| 16 | + if (!container || typeof container.appendChild !== 'function') throw new Error('createChart: container element required') | |
| 17 | + return new Chart(container, options) | |
| 18 | +} | |
| 19 | + | |
| 20 | +export default createChart | |
added
hfmarketdata/web/src/charts/engine/interactions/pointer.js
+308 −0
@@ -0,0 +1,308 @@ | ||
| 1 | +// Pointer / wheel / touch / keyboard interactions. Everything goes through Pointer Events (mouse, pen, touch). | |
| 2 | + | |
| 3 | +const CLICK_PX = 3 | |
| 4 | +const LONG_PRESS_MS = 450 | |
| 5 | +const KINETIC_MIN = 0.05 // px/ms | |
| 6 | + | |
| 7 | +export function attachInteractions(chart) { | |
| 8 | + const el = chart.el | |
| 9 | + const pointers = new Map() | |
| 10 | + let mode = null // 'pan' | 'priceAxis' | 'timeAxis' | 'separator' | 'drawing' | 'pinch' | 'touchCrosshair' | |
| 11 | + let st = null // per-mode state | |
| 12 | + let longPress = 0 | |
| 13 | + let downInfo = null | |
| 14 | + | |
| 15 | + const local = ev => { const r = el.getBoundingClientRect(); return { x: ev.clientX - r.left, y: ev.clientY - r.top } } | |
| 16 | + | |
| 17 | + const setCursor = c => { if (el.style.cursor !== c) el.style.cursor = c } | |
| 18 | + | |
| 19 | + const hoverCursor = (hit, x, y) => { | |
| 20 | + switch (hit.region) { | |
| 21 | + case 'priceAxis': return 'ns-resize' | |
| 22 | + case 'timeAxis': return 'ew-resize' | |
| 23 | + case 'separator': return 'row-resize' | |
| 24 | + case 'paneButton': return 'pointer' | |
| 25 | + case 'plot': { | |
| 26 | + if (hit.pane.kind === 'main') { | |
| 27 | + const c = chart.drawings.cursorAt(x, hit.y) | |
| 28 | + if (c) return c | |
| 29 | + } | |
| 30 | + return chart.drawings.tool ? 'crosshair' : 'crosshair' | |
| 31 | + } | |
| 32 | + default: return 'default' | |
| 33 | + } | |
| 34 | + } | |
| 35 | + | |
| 36 | + const pointerInfo = (hit, p) => (hit.region === 'plot' ? { x: p.x, y: hit.y, pane: hit.pane, region: 'plot' } : { x: p.x, y: hit.y, pane: hit.pane || null, region: hit.region }) | |
| 37 | + | |
| 38 | + function onPointerDown(ev) { | |
| 39 | + if (ev.button !== 0 && ev.pointerType === 'mouse') return | |
| 40 | + const p = local(ev) | |
| 41 | + pointers.set(ev.pointerId, p) | |
| 42 | + try { el.setPointerCapture(ev.pointerId) } catch { /* ignore */ } | |
| 43 | + if (el !== document.activeElement) el.focus({ preventScroll: true }) | |
| 44 | + if (pointers.size === 2) { startPinch(); return } | |
| 45 | + const hit = chart.hitRegion(p.x, p.y) | |
| 46 | + downInfo = { x: p.x, y: p.y, hit, t: performance.now(), moved: false } | |
| 47 | + clearTimeout(longPress) | |
| 48 | + switch (hit.region) { | |
| 49 | + case 'paneButton': | |
| 50 | + mode = 'button'; st = { hit } | |
| 51 | + return | |
| 52 | + case 'separator': | |
| 53 | + mode = 'separator' | |
| 54 | + st = { index: hit.index, lastY: p.y } | |
| 55 | + for (const q of chart.panes) if (!q.collapsed) q.weight = q.height | |
| 56 | + setCursor('row-resize') | |
| 57 | + return | |
| 58 | + case 'priceAxis': | |
| 59 | + mode = 'priceAxis'; st = { pane: hit.pane, lastY: p.y } | |
| 60 | + return | |
| 61 | + case 'timeAxis': | |
| 62 | + mode = 'timeAxis'; st = { lastX: p.x } | |
| 63 | + return | |
| 64 | + case 'plot': { | |
| 65 | + chart.setPointer(pointerInfo(hit, p)) | |
| 66 | + if (hit.pane.kind === 'main' && chart.drawings.pointerDown(p.x, hit.y, ev)) { mode = 'drawing'; st = { pane: hit.pane }; return } | |
| 67 | + mode = 'pan' | |
| 68 | + st = { lastX: p.x, lastT: performance.now(), vx: 0 } | |
| 69 | + chart.dragging = true | |
| 70 | + chart.kinetic = null | |
| 71 | + chart.animator.cancel('view') | |
| 72 | + if (ev.pointerType === 'touch') { | |
| 73 | + longPress = setTimeout(() => { | |
| 74 | + if (mode === 'pan' && downInfo && !downInfo.moved) { mode = 'touchCrosshair'; chart.dragging = false; chart.setPointer(pointerInfo(hit, p)) } | |
| 75 | + }, LONG_PRESS_MS) | |
| 76 | + } else setCursor('grabbing') | |
| 77 | + return | |
| 78 | + } | |
| 79 | + default: | |
| 80 | + return | |
| 81 | + } | |
| 82 | + } | |
| 83 | + | |
| 84 | + function startPinch() { | |
| 85 | + clearTimeout(longPress) | |
| 86 | + const [a, b] = Array.from(pointers.values()) | |
| 87 | + mode = 'pinch' | |
| 88 | + chart.dragging = true | |
| 89 | + chart.kinetic = null | |
| 90 | + st = { dist: Math.max(1, Math.abs(a.x - b.x)), mid: (a.x + b.x) / 2 } | |
| 91 | + chart.setPointer(null) | |
| 92 | + } | |
| 93 | + | |
| 94 | + function onPointerMove(ev) { | |
| 95 | + const p = local(ev) | |
| 96 | + if (pointers.has(ev.pointerId)) pointers.set(ev.pointerId, p) | |
| 97 | + if (downInfo && !downInfo.moved && Math.hypot(p.x - downInfo.x, p.y - downInfo.y) > CLICK_PX) downInfo.moved = true | |
| 98 | + if (mode === 'pinch' && pointers.size >= 2) { | |
| 99 | + const [a, b] = Array.from(pointers.values()) | |
| 100 | + const dist = Math.max(1, Math.abs(a.x - b.x)) | |
| 101 | + const mid = (a.x + b.x) / 2 | |
| 102 | + chart.ts.zoomAt(dist / st.dist, mid) | |
| 103 | + chart.ts.scrollPx(mid - st.mid) | |
| 104 | + st.dist = dist; st.mid = mid | |
| 105 | + chart.invalidate('data') | |
| 106 | + return | |
| 107 | + } | |
| 108 | + const now = performance.now() | |
| 109 | + switch (mode) { | |
| 110 | + case 'pan': { | |
| 111 | + const dx = p.x - st.lastX | |
| 112 | + const dt = Math.max(1, now - st.lastT) | |
| 113 | + chart.ts.scrollPx(dx) | |
| 114 | + st.vx = st.vx * 0.7 + (dx / dt) * 0.3 | |
| 115 | + st.lastX = p.x; st.lastT = now | |
| 116 | + const hit = chart.hitRegion(p.x, p.y) | |
| 117 | + if (hit.region === 'plot') chart.pointer = pointerInfo(hit, p) | |
| 118 | + chart.invalidate('data') | |
| 119 | + return | |
| 120 | + } | |
| 121 | + case 'touchCrosshair': { | |
| 122 | + const hit = chart.hitRegion(p.x, p.y) | |
| 123 | + if (hit.region === 'plot') chart.setPointer(pointerInfo(hit, p)) | |
| 124 | + return | |
| 125 | + } | |
| 126 | + case 'priceAxis': { | |
| 127 | + const dy = p.y - st.lastY | |
| 128 | + st.lastY = p.y | |
| 129 | + st.pane.scale.stretch(Math.exp(-dy / 250), st.pane.height / 2) | |
| 130 | + chart.emitter.emit('priceScaleChange', chart.getPriceScale()) | |
| 131 | + chart.invalidate('data') | |
| 132 | + return | |
| 133 | + } | |
| 134 | + case 'timeAxis': { | |
| 135 | + const dx = p.x - st.lastX | |
| 136 | + st.lastX = p.x | |
| 137 | + chart.ts.zoomAt(Math.exp(dx / 200), chart.plotWidth) | |
| 138 | + chart.invalidate('data') | |
| 139 | + return | |
| 140 | + } | |
| 141 | + case 'separator': { | |
| 142 | + const dy = p.y - st.lastY | |
| 143 | + const a = chart.panes[st.index - 1], b = chart.panes[st.index] | |
| 144 | + if (a && b && !a.collapsed && !b.collapsed) { | |
| 145 | + const min = 48 | |
| 146 | + const move = Math.max(-(a.weight - min), Math.min(b.weight - min, dy)) | |
| 147 | + a.weight += move; b.weight -= move | |
| 148 | + st.lastY += move | |
| 149 | + chart.invalidate('layout') | |
| 150 | + } | |
| 151 | + return | |
| 152 | + } | |
| 153 | + case 'drawing': { | |
| 154 | + const hit = chart.hitRegion(p.x, p.y) | |
| 155 | + const y = p.y - st.pane.top | |
| 156 | + chart.pointer = { x: p.x, y, pane: st.pane, region: 'plot' } | |
| 157 | + chart.drawings.pointerMove(p.x, y, ev) | |
| 158 | + chart.invalidate('overlay') | |
| 159 | + void hit | |
| 160 | + return | |
| 161 | + } | |
| 162 | + case 'button': | |
| 163 | + return | |
| 164 | + default: { | |
| 165 | + // Hover: crosshair + cursor. | |
| 166 | + const hit = chart.hitRegion(p.x, p.y) | |
| 167 | + if (hit.region === 'outside') { chart.setPointer(null); setCursor('default'); return } | |
| 168 | + chart.setPointer(pointerInfo(hit, p)) | |
| 169 | + if (hit.region === 'plot' && hit.pane.kind === 'main') chart.drawings.pointerMove(p.x, hit.y, ev) | |
| 170 | + setCursor(hoverCursor(hit, p.x, hit.y)) | |
| 171 | + } | |
| 172 | + } | |
| 173 | + } | |
| 174 | + | |
| 175 | + function onPointerUp(ev) { | |
| 176 | + const p = local(ev) | |
| 177 | + pointers.delete(ev.pointerId) | |
| 178 | + clearTimeout(longPress) | |
| 179 | + try { el.releasePointerCapture(ev.pointerId) } catch { /* ignore */ } | |
| 180 | + if (mode === 'pinch') { | |
| 181 | + if (pointers.size < 2) { mode = null; st = null; chart.dragging = false } | |
| 182 | + return | |
| 183 | + } | |
| 184 | + const wasClick = downInfo && !downInfo.moved | |
| 185 | + switch (mode) { | |
| 186 | + case 'pan': | |
| 187 | + chart.dragging = false | |
| 188 | + if (Math.abs(st.vx) > KINETIC_MIN && !chart.opts.reducedMotion) { chart.kinetic = { vx: st.vx }; chart.invalidate('data') } | |
| 189 | + if (wasClick) emitClick(p, downInfo.hit) | |
| 190 | + if (ev.pointerType === 'touch') chart.setPointer(null) | |
| 191 | + break | |
| 192 | + case 'touchCrosshair': | |
| 193 | + chart.setPointer(null) | |
| 194 | + break | |
| 195 | + case 'drawing': | |
| 196 | + chart.drawings.pointerUp(p.x, p.y - st.pane.top, ev) | |
| 197 | + chart.invalidate('overlay') | |
| 198 | + break | |
| 199 | + case 'button': { | |
| 200 | + const hit = chart.hitRegion(p.x, p.y) | |
| 201 | + if (hit.region === 'paneButton' && hit.pane === st.hit.pane && hit.button === st.hit.button) { | |
| 202 | + if (hit.button === 'close') chart.closePane(hit.pane) | |
| 203 | + else chart.togglePane(hit.pane) | |
| 204 | + } | |
| 205 | + break | |
| 206 | + } | |
| 207 | + case 'priceAxis': | |
| 208 | + case 'timeAxis': | |
| 209 | + case 'separator': | |
| 210 | + break | |
| 211 | + default: | |
| 212 | + break | |
| 213 | + } | |
| 214 | + mode = null; st = null; downInfo = null | |
| 215 | + const hit = chart.hitRegion(p.x, p.y) | |
| 216 | + setCursor(hit.region === 'outside' ? 'default' : hoverCursor(hit, p.x, hit.y)) | |
| 217 | + } | |
| 218 | + | |
| 219 | + function emitClick(p, hit) { | |
| 220 | + if (!hit || hit.region !== 'plot' || !chart.emitter.has('click')) return | |
| 221 | + const n = chart.store.length | |
| 222 | + const raw = Math.round(chart.ts.indexAt(p.x)) | |
| 223 | + const index = n ? Math.max(0, Math.min(n - 1, raw)) : -1 | |
| 224 | + chart.emitter.emit('click', { index, bar: index >= 0 ? chart.store.bars[index] : null, price: hit.pane.scale.priceAt(hit.y), pane: hit.pane.kind === 'main' ? 'main' : hit.pane.id }) | |
| 225 | + } | |
| 226 | + | |
| 227 | + function onPointerLeave() { | |
| 228 | + if (mode) return | |
| 229 | + chart.setPointer(null) | |
| 230 | + } | |
| 231 | + | |
| 232 | + function onWheel(ev) { | |
| 233 | + const p = local(ev) | |
| 234 | + const hit = chart.hitRegion(p.x, p.y) | |
| 235 | + if (hit.region === 'outside') return | |
| 236 | + ev.preventDefault() | |
| 237 | + const scale = ev.deltaMode === 1 ? 16 : ev.deltaMode === 2 ? 100 : 1 | |
| 238 | + const dy = ev.deltaY * scale, dx = ev.deltaX * scale | |
| 239 | + chart.kinetic = null | |
| 240 | + chart.animator.cancel('view') | |
| 241 | + if (hit.region === 'priceAxis') { | |
| 242 | + hit.pane.scale.stretch(Math.exp(-dy * 0.002), hit.y) | |
| 243 | + chart.emitter.emit('priceScaleChange', chart.getPriceScale()) | |
| 244 | + chart.invalidate('data') | |
| 245 | + return | |
| 246 | + } | |
| 247 | + if (ev.ctrlKey || ev.metaKey || Math.abs(dy) >= Math.abs(dx)) { | |
| 248 | + const amount = Math.max(-60, Math.min(60, dy)) | |
| 249 | + chart.ts.zoomAt(Math.exp(-amount * 0.004), p.x) | |
| 250 | + } else { | |
| 251 | + chart.ts.scrollPx(-dx) | |
| 252 | + } | |
| 253 | + if (hit.region === 'plot') chart.pointer = pointerInfo(hit, p) | |
| 254 | + chart.invalidate('data') | |
| 255 | + } | |
| 256 | + | |
| 257 | + function onDblClick(ev) { | |
| 258 | + const p = local(ev) | |
| 259 | + const hit = chart.hitRegion(p.x, p.y) | |
| 260 | + if (hit.region === 'priceAxis') { hit.pane.scale.auto = true; hit.pane.scale._forceAnim = true; chart.emitter.emit('priceScaleChange', chart.getPriceScale()); chart.invalidate('data'); return } | |
| 261 | + if (hit.region === 'plot' || hit.region === 'timeAxis') { | |
| 262 | + if (hit.region === 'plot' && hit.pane.kind === 'main' && chart.drawings.hitTest(p.x, hit.y)) return | |
| 263 | + chart.fitContent(true) | |
| 264 | + } | |
| 265 | + } | |
| 266 | + | |
| 267 | + function onKeyDown(ev) { | |
| 268 | + const meta = ev.metaKey || ev.ctrlKey | |
| 269 | + const step = chart.plotWidth * 0.1 | |
| 270 | + switch (ev.key) { | |
| 271 | + case 'ArrowLeft': chart.ts.scrollPx(ev.shiftKey ? chart.ts.barSpacing : step); chart.invalidate('data'); break | |
| 272 | + case 'ArrowRight': chart.ts.scrollPx(-(ev.shiftKey ? chart.ts.barSpacing : step)); chart.invalidate('data'); break | |
| 273 | + case '+': case '=': chart.zoom(1.25, chart.pointer ? chart.pointer.x : undefined); break | |
| 274 | + case '-': case '_': chart.zoom(0.8, chart.pointer ? chart.pointer.x : undefined); break | |
| 275 | + case 'Home': chart.setVisibleRange({ fromIndex: 0, toIndex: Math.max(1, Math.min(chart.store.length - 1, Math.round(chart.ts.visibleBars))) }, true); break | |
| 276 | + case 'End': chart.scrollToLatest(true); break | |
| 277 | + case 'Escape': chart.drawings.escape(); break | |
| 278 | + case 'Delete': case 'Backspace': if (chart.drawings.selectedId) { chart.drawings.deleteSelected() } else return; break | |
| 279 | + case 'z': case 'Z': if (meta) { if (ev.shiftKey) chart.redo(); else chart.undo() } else return; break | |
| 280 | + case 'y': case 'Y': if (meta) chart.redo(); else return; break | |
| 281 | + default: return | |
| 282 | + } | |
| 283 | + ev.preventDefault() | |
| 284 | + } | |
| 285 | + | |
| 286 | + el.addEventListener('pointerdown', onPointerDown) | |
| 287 | + el.addEventListener('pointermove', onPointerMove) | |
| 288 | + el.addEventListener('pointerup', onPointerUp) | |
| 289 | + el.addEventListener('pointercancel', onPointerUp) | |
| 290 | + el.addEventListener('pointerleave', onPointerLeave) | |
| 291 | + el.addEventListener('wheel', onWheel, { passive: false }) | |
| 292 | + el.addEventListener('dblclick', onDblClick) | |
| 293 | + el.addEventListener('keydown', onKeyDown) | |
| 294 | + | |
| 295 | + return { | |
| 296 | + destroy() { | |
| 297 | + clearTimeout(longPress) | |
| 298 | + el.removeEventListener('pointerdown', onPointerDown) | |
| 299 | + el.removeEventListener('pointermove', onPointerMove) | |
| 300 | + el.removeEventListener('pointerup', onPointerUp) | |
| 301 | + el.removeEventListener('pointercancel', onPointerUp) | |
| 302 | + el.removeEventListener('pointerleave', onPointerLeave) | |
| 303 | + el.removeEventListener('wheel', onWheel) | |
| 304 | + el.removeEventListener('dblclick', onDblClick) | |
| 305 | + el.removeEventListener('keydown', onKeyDown) | |
| 306 | + }, | |
| 307 | + } | |
| 308 | +} | |
added
hfmarketdata/web/src/charts/engine/panes/pane.js
+49 −0
@@ -0,0 +1,49 @@ | ||
| 1 | +// A pane = a stacked plot area with its own price scale, two canvas layers (data + overlay) and a list of indicators. | |
| 2 | + | |
| 3 | +import { createLayer } from '../render/canvas.js' | |
| 4 | +import { PriceScale } from '../scales/price-scale.js' | |
| 5 | + | |
| 6 | +export const COLLAPSED_H = 22 | |
| 7 | +export const MIN_PANE_H = 48 | |
| 8 | + | |
| 9 | +let seq = 0 | |
| 10 | + | |
| 11 | +export class Pane { | |
| 12 | + constructor(parent, { kind = 'indicator', weight = 0.25 } = {}) { | |
| 13 | + this.id = `pane-${++seq}` | |
| 14 | + this.kind = kind | |
| 15 | + this.weight = weight | |
| 16 | + this.collapsed = false | |
| 17 | + this.el = document.createElement('div') | |
| 18 | + this.el.className = 'hfmd-pane' | |
| 19 | + this.el.style.cssText = 'position:absolute;left:0;overflow:hidden;' | |
| 20 | + parent.appendChild(this.el) | |
| 21 | + this.main = createLayer(this.el, { zIndex: 1 }) | |
| 22 | + this.overlay = createLayer(this.el, { zIndex: 2 }) | |
| 23 | + this.scale = new PriceScale() | |
| 24 | + this.indicators = [] // indicator ids drawn in this pane | |
| 25 | + this.top = 0 | |
| 26 | + this.height = 0 | |
| 27 | + this.width = 0 | |
| 28 | + this.headerBoxes = [] // hit boxes of header buttons (indicator panes) | |
| 29 | + this.hoverHeader = null | |
| 30 | + } | |
| 31 | + | |
| 32 | + /** Title shown in the header of indicator panes (first indicator's title). */ | |
| 33 | + get title() { return this._title || '' } | |
| 34 | + set title(v) { this._title = v } | |
| 35 | + | |
| 36 | + layout(top, height, width, dpr) { | |
| 37 | + this.top = top; this.height = height; this.width = width | |
| 38 | + this.el.style.top = top + 'px' | |
| 39 | + this.el.style.height = height + 'px' | |
| 40 | + this.el.style.width = width + 'px' | |
| 41 | + const a = this.main.resize(width, height, dpr) | |
| 42 | + const b = this.overlay.resize(width, height, dpr) | |
| 43 | + return a || b | |
| 44 | + } | |
| 45 | + | |
| 46 | + destroy() { | |
| 47 | + this.main.destroy(); this.overlay.destroy(); this.el.remove() | |
| 48 | + } | |
| 49 | +} | |
added
hfmarketdata/web/src/charts/engine/render/axes.js
+100 −0
@@ -0,0 +1,100 @@ | ||
| 1 | +// Grid, price axis and time axis renderers. | |
| 2 | + | |
| 3 | +import { crisp, hline, vline, withAlpha } from './canvas.js' | |
| 4 | +import { font, measure, FONT_SIZE } from './text.js' | |
| 5 | + | |
| 6 | +export const TIME_AXIS_H = 26 | |
| 7 | +export const AXIS_PAD = 8 | |
| 8 | +export const MIN_AXIS_W = 52 | |
| 9 | + | |
| 10 | +/** Horizontal grid lines at price ticks, vertical at time ticks (day/month/year ticks are stronger). */ | |
| 11 | +export function drawGrid(ctx, { width, height, priceTicks, timeTicks, theme }) { | |
| 12 | + ctx.lineWidth = 1 | |
| 13 | + ctx.strokeStyle = theme.grid | |
| 14 | + ctx.beginPath() | |
| 15 | + for (const t of priceTicks) { | |
| 16 | + if (t.y < 0 || t.y > height) continue | |
| 17 | + ctx.moveTo(0, crisp(t.y)); ctx.lineTo(width, crisp(t.y)) | |
| 18 | + } | |
| 19 | + for (const t of timeTicks) { | |
| 20 | + if (t.level >= 4) continue | |
| 21 | + ctx.moveTo(crisp(t.x), 0); ctx.lineTo(crisp(t.x), height) | |
| 22 | + } | |
| 23 | + ctx.stroke() | |
| 24 | + ctx.strokeStyle = theme.gridStrong | |
| 25 | + ctx.beginPath() | |
| 26 | + for (const t of timeTicks) { | |
| 27 | + if (t.level < 4) continue | |
| 28 | + ctx.moveTo(crisp(t.x), 0); ctx.lineTo(crisp(t.x), height) | |
| 29 | + } | |
| 30 | + ctx.stroke() | |
| 31 | +} | |
| 32 | + | |
| 33 | +/** Very light vertical lines where a new calendar day starts (intraday only). */ | |
| 34 | +export function drawSessionBreaks(ctx, { xs, height, theme }) { | |
| 35 | + if (!xs.length) return | |
| 36 | + ctx.strokeStyle = withAlpha(theme.axisText, 0.18) | |
| 37 | + ctx.lineWidth = 1 | |
| 38 | + ctx.setLineDash([2, 4]) | |
| 39 | + ctx.beginPath() | |
| 40 | + for (const x of xs) { ctx.moveTo(crisp(x), 0); ctx.lineTo(crisp(x), height) } | |
| 41 | + ctx.stroke() | |
| 42 | + ctx.setLineDash([]) | |
| 43 | +} | |
| 44 | + | |
| 45 | +/** Price axis: background, axis line, tick marks and labels. `x0` = left edge of the axis area. */ | |
| 46 | +export function drawPriceAxis(ctx, { x0, width, height, ticks, theme, mono = true, levels = [] }) { | |
| 47 | + ctx.fillStyle = theme.bg | |
| 48 | + ctx.fillRect(x0, 0, width, height) | |
| 49 | + ctx.strokeStyle = theme.axisLine | |
| 50 | + ctx.lineWidth = 1 | |
| 51 | + vline(ctx, x0, 0, height) | |
| 52 | + const f = font(theme, { mono, size: FONT_SIZE }) | |
| 53 | + ctx.font = f | |
| 54 | + ctx.fillStyle = theme.axisText | |
| 55 | + ctx.textBaseline = 'middle' | |
| 56 | + ctx.textAlign = 'left' | |
| 57 | + ctx.strokeStyle = theme.axisLine | |
| 58 | + for (const t of ticks) { | |
| 59 | + if (t.y < 4 || t.y > height - 4) continue | |
| 60 | + const y = crisp(t.y) | |
| 61 | + ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x0 + 4, y); ctx.stroke() | |
| 62 | + ctx.fillText(t.text, x0 + AXIS_PAD, t.y + 0.5) | |
| 63 | + } | |
| 64 | + void levels | |
| 65 | +} | |
| 66 | + | |
| 67 | +/** Width needed by the widest label of a set of ticks (plus padding), never below MIN_AXIS_W. */ | |
| 68 | +export function measureAxisWidth(ctx, ticks, theme, mono = true) { | |
| 69 | + const f = font(theme, { mono, size: FONT_SIZE }) | |
| 70 | + let w = 0 | |
| 71 | + for (const t of ticks) { const tw = measure(ctx, t.text, f); if (tw > w) w = tw } | |
| 72 | + return Math.max(MIN_AXIS_W, Math.ceil(w) + AXIS_PAD * 2 + 4) | |
| 73 | +} | |
| 74 | + | |
| 75 | +/** Time axis strip: top line, tick marks and hierarchical labels (strong = day/month/year). */ | |
| 76 | +export function drawTimeAxis(ctx, { width, plotWidth, height, ticks, theme, mono = true }) { | |
| 77 | + ctx.fillStyle = theme.bg | |
| 78 | + ctx.fillRect(0, 0, width, height) | |
| 79 | + ctx.strokeStyle = theme.axisLine | |
| 80 | + ctx.lineWidth = 1 | |
| 81 | + hline(ctx, 0, width, 0) | |
| 82 | + vline(ctx, plotWidth, 0, height) | |
| 83 | + ctx.textBaseline = 'middle' | |
| 84 | + ctx.textAlign = 'center' | |
| 85 | + const regular = font(theme, { mono, size: FONT_SIZE }) | |
| 86 | + const strong = font(theme, { mono, size: FONT_SIZE, weight: 700 }) | |
| 87 | + for (const t of ticks) { | |
| 88 | + if (t.x < 0 || t.x > plotWidth) continue | |
| 89 | + ctx.strokeStyle = theme.axisLine | |
| 90 | + ctx.beginPath(); ctx.moveTo(crisp(t.x), 0); ctx.lineTo(crisp(t.x), 4); ctx.stroke() | |
| 91 | + ctx.font = t.strong ? strong : regular | |
| 92 | + ctx.fillStyle = t.strong ? theme.text : theme.axisText | |
| 93 | + // Keep labels inside the plot: shift edge labels inward. | |
| 94 | + const w = measure(ctx, t.text, ctx.font) | |
| 95 | + let x = t.x | |
| 96 | + if (x - w / 2 < 2) x = 2 + w / 2 | |
| 97 | + if (x + w / 2 > plotWidth - 2) x = plotWidth - 2 - w / 2 | |
| 98 | + ctx.fillText(t.text, x, height / 2 + 2.5) | |
| 99 | + } | |
| 100 | +} | |
added
hfmarketdata/web/src/charts/engine/render/overlay.js
+123 −0
@@ -0,0 +1,123 @@ | ||
| 1 | +// Overlay-layer renderers: crosshair, last price marker, visible High/Low markers, watermark, pane header buttons. | |
| 2 | + | |
| 3 | +import { crisp, roundRect, withAlpha } from './canvas.js' | |
| 4 | +import { font, pill, measure, FONT_SIZE, inkFor } from './text.js' | |
| 5 | + | |
| 6 | +export const LABEL_H = 18 | |
| 7 | + | |
| 8 | +/** Crosshair lines for one pane. `y` is null in panes other than the hovered one. */ | |
| 9 | +export function drawCrosshair(ctx, { x, y, width, height, theme }) { | |
| 10 | + ctx.strokeStyle = theme.crosshair | |
| 11 | + ctx.lineWidth = 1 | |
| 12 | + ctx.setLineDash([3, 3]) | |
| 13 | + ctx.beginPath() | |
| 14 | + if (x != null) { ctx.moveTo(crisp(x), 0); ctx.lineTo(crisp(x), height) } | |
| 15 | + if (y != null) { ctx.moveTo(0, crisp(y)); ctx.lineTo(width, crisp(y)) } | |
| 16 | + ctx.stroke() | |
| 17 | + ctx.setLineDash([]) | |
| 18 | +} | |
| 19 | + | |
| 20 | +/** Price label on the axis (crosshair or any marker). */ | |
| 21 | +export function drawAxisLabel(ctx, { text, x0, y, width, height, bg, color, theme }) { | |
| 22 | + return pill(ctx, text, x0 + 4, y, { | |
| 23 | + bg, color, fontStr: font(theme, { mono: true, size: FONT_SIZE }), h: LABEL_H, padX: 4, | |
| 24 | + clampTo: { x0: x0 + 2, x1: x0 + width - 1, y0: 0, y1: height }, | |
| 25 | + }) | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** Time label centered under the crosshair in the time axis. */ | |
| 29 | +export function drawTimeLabel(ctx, { text, x, plotWidth, height, theme }) { | |
| 30 | + return pill(ctx, text, x, height / 2 + 1, { | |
| 31 | + bg: theme.crosshairLabelBg, color: theme.crosshairLabelText, fontStr: font(theme, { mono: true, size: FONT_SIZE }), | |
| 32 | + h: LABEL_H, padX: 6, align: 'center', clampTo: { x0: 1, x1: plotWidth - 1, y0: 0, y1: height }, | |
| 33 | + }) | |
| 34 | +} | |
| 35 | + | |
| 36 | +/** | |
| 37 | + * Last price: dashed line across the plot + colored label on the axis. `pulse` ∈ [0, 1) animates a soft halo | |
| 38 | + * around the label right after an update (1 = just updated → fades to 0). | |
| 39 | + */ | |
| 40 | +export function drawLastPrice(ctx, { y, text, plotWidth, axisWidth, height, up, theme, pulse = 0, line = true }) { | |
| 41 | + if (y < -LABEL_H || y > height + LABEL_H) return | |
| 42 | + const color = up ? theme.lastPriceUp : theme.lastPriceDown | |
| 43 | + if (line) { | |
| 44 | + ctx.strokeStyle = withAlpha(color, 0.9) | |
| 45 | + ctx.lineWidth = 1 | |
| 46 | + ctx.setLineDash([2, 3]) | |
| 47 | + ctx.beginPath(); ctx.moveTo(0, crisp(y)); ctx.lineTo(plotWidth, crisp(y)); ctx.stroke() | |
| 48 | + ctx.setLineDash([]) | |
| 49 | + } | |
| 50 | + const box = pill(ctx, text, plotWidth + 4, y, { | |
| 51 | + bg: color, color: inkFor(color), fontStr: font(theme, { mono: true, size: FONT_SIZE, weight: 600 }), h: LABEL_H, padX: 4, | |
| 52 | + clampTo: { x0: plotWidth + 2, x1: plotWidth + axisWidth - 1, y0: 0, y1: height }, | |
| 53 | + }) | |
| 54 | + if (pulse > 0) { | |
| 55 | + const grow = (1 - pulse) * 5 | |
| 56 | + ctx.strokeStyle = withAlpha(color, 0.7 * pulse) | |
| 57 | + ctx.lineWidth = 1.5 | |
| 58 | + roundRect(ctx, box.x - grow, box.y - grow, box.w + grow * 2, box.h + grow * 2, 3 + grow) | |
| 59 | + ctx.stroke() | |
| 60 | + } | |
| 61 | + return box | |
| 62 | +} | |
| 63 | + | |
| 64 | +/** Small "H 123.45" / "L 120.00" markers at the visible extremes. */ | |
| 65 | +export function drawHiLoMarkers(ctx, { hi, lo, plotWidth, theme }) { | |
| 66 | + const f = font(theme, { mono: true, size: 10 }) | |
| 67 | + ctx.font = f | |
| 68 | + ctx.textBaseline = 'middle' | |
| 69 | + const place = (m, above) => { | |
| 70 | + if (!m) return | |
| 71 | + const w = measure(ctx, m.text, f) | |
| 72 | + let x = m.x - w / 2 | |
| 73 | + x = Math.max(2, Math.min(plotWidth - w - 2, x)) | |
| 74 | + const y = above ? m.y - 9 : m.y + 9 | |
| 75 | + ctx.fillStyle = withAlpha(theme.bg, 0.75) | |
| 76 | + ctx.fillRect(Math.round(x) - 2, Math.round(y) - 7, Math.ceil(w) + 4, 14) | |
| 77 | + ctx.fillStyle = theme.axisText | |
| 78 | + ctx.textAlign = 'left' | |
| 79 | + ctx.fillText(m.text, Math.round(x), y + 0.5) | |
| 80 | + } | |
| 81 | + place(hi, true) | |
| 82 | + place(lo, false) | |
| 83 | +} | |
| 84 | + | |
| 85 | +/** Discreet centered watermark (symbol · timeframe). */ | |
| 86 | +export function drawWatermark(ctx, { text, width, height, theme, alpha = 0.07 }) { | |
| 87 | + if (!text) return | |
| 88 | + const size = Math.max(16, Math.min(40, Math.floor(width / 18))) | |
| 89 | + ctx.font = font(theme, { size, weight: 700 }) | |
| 90 | + ctx.fillStyle = withAlpha(theme.text, alpha) | |
| 91 | + ctx.textAlign = 'center' | |
| 92 | + ctx.textBaseline = 'middle' | |
| 93 | + ctx.fillText(text, width / 2, height / 2) | |
| 94 | + ctx.textAlign = 'left' | |
| 95 | +} | |
| 96 | + | |
| 97 | +/** Header of an indicator pane: title (left) and collapse / close buttons (right). Returns button hit boxes. */ | |
| 98 | +export function drawPaneHeader(ctx, { title, plotWidth, theme, hover, collapsed }) { | |
| 99 | + const f = font(theme, { size: FONT_SIZE }) | |
| 100 | + ctx.font = f | |
| 101 | + ctx.textBaseline = 'middle' | |
| 102 | + ctx.textAlign = 'left' | |
| 103 | + ctx.fillStyle = theme.textMuted | |
| 104 | + if (title) ctx.fillText(title, 8, 11) | |
| 105 | + const size = 16, gap = 4 | |
| 106 | + const boxes = [] | |
| 107 | + const buttons = [{ id: 'close', glyph: '×' }, { id: 'collapse', glyph: collapsed ? '+' : '−' }] | |
| 108 | + let x = plotWidth - 6 - size | |
| 109 | + for (const b of buttons) { | |
| 110 | + const box = { id: b.id, x, y: 3, w: size, h: size } | |
| 111 | + const isHover = hover && hover.x >= box.x && hover.x <= box.x + box.w && hover.y >= box.y && hover.y <= box.y + box.h | |
| 112 | + ctx.fillStyle = isHover ? withAlpha(theme.text, 0.14) : withAlpha(theme.text, 0.05) | |
| 113 | + roundRect(ctx, box.x, box.y, box.w, box.h, 3); ctx.fill() | |
| 114 | + ctx.fillStyle = isHover ? theme.text : theme.textMuted | |
| 115 | + ctx.textAlign = 'center' | |
| 116 | + ctx.font = font(theme, { size: 13, weight: 500 }) | |
| 117 | + ctx.fillText(b.glyph, box.x + size / 2, box.y + size / 2 + 0.5) | |
| 118 | + boxes.push(box) | |
| 119 | + x -= size + gap | |
| 120 | + } | |
| 121 | + ctx.textAlign = 'left' | |
| 122 | + return boxes | |
| 123 | +} | |
added
hfmarketdata/web/src/charts/engine/render/plots.js
+148 −0
@@ -0,0 +1,148 @@ | ||
| 1 | +// Indicator plot renderers (line / histogram / band / cloud) and comparison overlays. | |
| 2 | + | |
| 3 | +import { withAlpha } from './canvas.js' | |
| 4 | + | |
| 5 | +/** Resolve a plot color spec (series index or theme role) to a CSS color. */ | |
| 6 | +export function plotColor(spec, theme, colors) { | |
| 7 | + if (spec === 'up') return theme.up | |
| 8 | + if (spec === 'down') return theme.down | |
| 9 | + if (typeof spec === 'number') return (colors && colors[spec]) || theme.series[spec % theme.series.length] | |
| 10 | + return spec || theme.series[0] | |
| 11 | +} | |
| 12 | + | |
| 13 | +/** Path of non-null values over [from, to]; y via `yOf(value)`; x via time scale. Gaps break the path. */ | |
| 14 | +export function valuePath(values, from, to, ts, yOf) { | |
| 15 | + const path = new Path2D() | |
| 16 | + let pen = false | |
| 17 | + const n = values.length | |
| 18 | + const end = Math.min(to, n - 1) | |
| 19 | + for (let i = Math.max(0, from); i <= end; i++) { | |
| 20 | + const v = values[i] | |
| 21 | + if (v == null || Number.isNaN(v)) { pen = false; continue } | |
| 22 | + const x = ts.x(i), y = yOf(v) | |
| 23 | + if (!pen) { path.moveTo(x, y); pen = true } else path.lineTo(x, y) | |
| 24 | + } | |
| 25 | + return path | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** | |
| 29 | + * Draw the plots of one indicator instance. | |
| 30 | + * @param g { ctx, plots, values, colors, from, to, ts, yOf, theme, height, store } | |
| 31 | + */ | |
| 32 | +export function drawPlots(g) { | |
| 33 | + const { ctx, plots, values, colors, from, to, ts, yOf, theme, height, store } = g | |
| 34 | + for (const p of plots) { | |
| 35 | + if (p.kind === 'band') { | |
| 36 | + const up = values[p.upper], lo = values[p.lower] | |
| 37 | + if (!up || !lo) continue | |
| 38 | + fillBetween(ctx, up, lo, from, to, ts, yOf, withAlpha(plotColor(p.color, theme, colors), p.alpha ?? 0.08), null) | |
| 39 | + } else if (p.kind === 'cloud') { | |
| 40 | + const a = values[p.a], b = values[p.b] | |
| 41 | + if (!a || !b) continue | |
| 42 | + fillBetween(ctx, a, b, from, to, ts, yOf, withAlpha(plotColor(p.colorA, theme, colors), p.alpha ?? 0.12), withAlpha(plotColor(p.colorB, theme, colors), p.alpha ?? 0.12)) | |
| 43 | + } else if (p.kind === 'histogram') { | |
| 44 | + const v = values[p.key] | |
| 45 | + if (!v) continue | |
| 46 | + drawHistogram(ctx, v, from, to, ts, yOf, height, p, theme, colors, store) | |
| 47 | + } else { | |
| 48 | + const v = values[p.key] | |
| 49 | + if (!v) continue | |
| 50 | + ctx.strokeStyle = plotColor(p.color, theme, colors) | |
| 51 | + ctx.lineWidth = p.width ?? (ts.isCompressed ? 1 : 1.25) | |
| 52 | + ctx.lineJoin = 'round'; ctx.lineCap = 'round' | |
| 53 | + if (p.dash) ctx.setLineDash(p.dash) | |
| 54 | + ctx.stroke(valuePath(v, from, to, ts, yOf)) | |
| 55 | + if (p.dash) ctx.setLineDash([]) | |
| 56 | + } | |
| 57 | + } | |
| 58 | +} | |
| 59 | + | |
| 60 | +/** Fill the region between two series. With `colorB`, segments where b > a use colorB (cloud). */ | |
| 61 | +function fillBetween(ctx, a, b, from, to, ts, yOf, colorA, colorB) { | |
| 62 | + const n = Math.min(a.length, b.length) | |
| 63 | + const end = Math.min(to, n - 1) | |
| 64 | + const start = Math.max(0, from) | |
| 65 | + if (!colorB) { | |
| 66 | + const path = new Path2D() | |
| 67 | + let pen = false | |
| 68 | + const back = [] | |
| 69 | + for (let i = start; i <= end; i++) { | |
| 70 | + const va = a[i], vb = b[i] | |
| 71 | + if (va == null || vb == null) { if (pen) { closeRegion(path, back); back.length = 0; pen = false } continue } | |
| 72 | + const x = ts.x(i) | |
| 73 | + if (!pen) { path.moveTo(x, yOf(va)); pen = true } else path.lineTo(x, yOf(va)) | |
| 74 | + back.push(x, yOf(vb)) | |
| 75 | + } | |
| 76 | + if (pen) closeRegion(path, back) | |
| 77 | + ctx.fillStyle = colorA; ctx.fill(path) | |
| 78 | + return | |
| 79 | + } | |
| 80 | + // Cloud: split at crossings so each region takes the color of the series on top. | |
| 81 | + const pathA = new Path2D(), pathB = new Path2D() | |
| 82 | + let cur = null, back = [], path = null | |
| 83 | + const flush = () => { if (path && back.length) closeRegion(path, back); back = []; path = null; cur = null } | |
| 84 | + for (let i = start; i <= end; i++) { | |
| 85 | + const va = a[i], vb = b[i] | |
| 86 | + if (va == null || vb == null) { flush(); continue } | |
| 87 | + const side = va >= vb ? 'A' : 'B' | |
| 88 | + const x = ts.x(i) | |
| 89 | + if (side !== cur) { | |
| 90 | + // Crossing: end the previous region at the intersection point and start the next one there. | |
| 91 | + if (cur !== null && i > start && a[i - 1] != null && b[i - 1] != null) { | |
| 92 | + const pa = a[i - 1], pb = b[i - 1] | |
| 93 | + const d0 = pa - pb, d1 = va - vb | |
| 94 | + const k = d0 === d1 ? 0.5 : d0 / (d0 - d1) | |
| 95 | + const xi = ts.x(i - 1 + k), yi = yOf(pa + (va - pa) * k) | |
| 96 | + path.lineTo(xi, yi); back.push(xi, yi) | |
| 97 | + flush() | |
| 98 | + path = side === 'A' ? pathA : pathB | |
| 99 | + path.moveTo(xi, yi) | |
| 100 | + cur = side | |
| 101 | + } else { | |
| 102 | + flush() | |
| 103 | + path = side === 'A' ? pathA : pathB | |
| 104 | + path.moveTo(x, yOf(va)) | |
| 105 | + cur = side | |
| 106 | + } | |
| 107 | + } | |
| 108 | + path.lineTo(x, yOf(va)) | |
| 109 | + back.push(x, yOf(vb)) | |
| 110 | + } | |
| 111 | + flush() | |
| 112 | + ctx.fillStyle = colorA; ctx.fill(pathA) | |
| 113 | + ctx.fillStyle = colorB; ctx.fill(pathB) | |
| 114 | +} | |
| 115 | + | |
| 116 | +function closeRegion(path, back) { | |
| 117 | + for (let j = back.length - 2; j >= 0; j -= 2) path.lineTo(back[j], back[j + 1]) | |
| 118 | + path.closePath() | |
| 119 | +} | |
| 120 | + | |
| 121 | +function drawHistogram(ctx, v, from, to, ts, yOf, height, p, theme, colors, store) { | |
| 122 | + const w = ts.isCompressed ? 1 : Math.max(1, ts.bodyWidth()) | |
| 123 | + const half = (w - 1) / 2 | |
| 124 | + const pos = new Path2D(), neg = new Path2D() | |
| 125 | + const zero = p.color === 'volume' ? height : Math.round(yOf(0)) | |
| 126 | + const end = Math.min(to, v.length - 1) | |
| 127 | + for (let i = Math.max(0, from); i <= end; i++) { | |
| 128 | + const val = v[i] | |
| 129 | + if (val == null || Number.isNaN(val)) continue | |
| 130 | + const xc = Math.floor(ts.x(i)) + 0.5 | |
| 131 | + const y = Math.round(yOf(val)) | |
| 132 | + let bucket | |
| 133 | + if (p.color === 'volume') bucket = store && store.c[i] < store.o[i] ? neg : pos | |
| 134 | + else if (p.color === 'updown') bucket = val >= 0 ? pos : neg | |
| 135 | + else bucket = pos | |
| 136 | + const top = Math.min(y, zero), h = Math.max(1, Math.abs(zero - y)) | |
| 137 | + bucket.rect(xc - half - 0.5, top, w, h) | |
| 138 | + } | |
| 139 | + if (p.color === 'volume') { | |
| 140 | + ctx.fillStyle = theme.volumeUp; ctx.fill(pos) | |
| 141 | + ctx.fillStyle = theme.volumeDown; ctx.fill(neg) | |
| 142 | + } else if (p.color === 'updown') { | |
| 143 | + ctx.fillStyle = withAlpha(theme.up, 0.65); ctx.fill(pos) | |
| 144 | + ctx.fillStyle = withAlpha(theme.down, 0.65); ctx.fill(neg) | |
| 145 | + } else { | |
| 146 | + ctx.fillStyle = withAlpha(plotColor(p.color, theme, colors), 0.7); ctx.fill(pos) | |
| 147 | + } | |
| 148 | +} | |
added
hfmarketdata/web/src/charts/engine/render/series.js
+175 −0
@@ -0,0 +1,175 @@ | ||
| 1 | +// Series renderers. All work on the store's typed columns, cull to [from, to] and batch by color with Path2D | |
| 2 | +// (no per-bar objects). Coordinates are CSS pixels; the context is already scaled for the DPR. | |
| 3 | + | |
| 4 | +import { withAlpha } from './canvas.js' | |
| 5 | + | |
| 6 | +/** Pixel-center x for bar `i` (odd body widths center on a pixel). */ | |
| 7 | +const centerX = (ts, i) => Math.floor(ts.x(i)) + 0.5 | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * @param g { ctx, store, from, to, ts, ps, theme, width, height, type, baseline, lineWidth } | |
| 11 | + */ | |
| 12 | +export function drawSeries(g) { | |
| 13 | + const { type } = g | |
| 14 | + if (g.to < g.from) return | |
| 15 | + if (g.ts.isCompressed && type !== 'area' && type !== 'baseline' && type !== 'columns') return drawLine(g, g.theme.neutral, 1) | |
| 16 | + switch (type) { | |
| 17 | + case 'candles': return drawCandles(g, false) | |
| 18 | + case 'hollow': return drawCandles(g, true) | |
| 19 | + case 'heikin': return drawCandles(g, false) | |
| 20 | + case 'ohlc': return drawBars(g, true) | |
| 21 | + case 'hlc': return drawBars(g, false) | |
| 22 | + case 'line': return drawLine(g, g.theme.series[0], g.lineWidth || 1.5) | |
| 23 | + case 'area': return drawArea(g) | |
| 24 | + case 'baseline': return drawBaseline(g) | |
| 25 | + case 'columns': return drawColumns(g) | |
| 26 | + default: return drawCandles(g, false) | |
| 27 | + } | |
| 28 | +} | |
| 29 | + | |
| 30 | +function drawCandles(g, hollow) { | |
| 31 | + const { ctx, store, from, to, ts, ps, theme } = g | |
| 32 | + const O = store.o, H = store.h, L = store.l, C = store.c | |
| 33 | + const bodyW = ts.bodyWidth() | |
| 34 | + const half = (bodyW - 1) / 2 | |
| 35 | + const upBody = new Path2D(), downBody = new Path2D(), upWick = new Path2D(), downWick = new Path2D() | |
| 36 | + const upHollow = hollow ? new Path2D() : null | |
| 37 | + for (let i = from; i <= to; i++) { | |
| 38 | + const o = O[i], c = C[i] | |
| 39 | + const up = c >= o | |
| 40 | + const xc = centerX(ts, i) | |
| 41 | + const yO = ps.y(o), yC = ps.y(c) | |
| 42 | + let top = Math.round(Math.min(yO, yC)), bot = Math.round(Math.max(yO, yC)) | |
| 43 | + if (bot - top < 1) bot = top + 1 | |
| 44 | + const yH = Math.round(ps.y(H[i])), yL = Math.round(ps.y(L[i])) | |
| 45 | + const wick = up ? upWick : downWick | |
| 46 | + // Wicks are 1 px wide, centered on the body, drawn only outside the body. | |
| 47 | + if (yH < top) wick.rect(xc - 0.5, yH, 1, top - yH) | |
| 48 | + if (yL > bot) wick.rect(xc - 0.5, bot, 1, yL - bot) | |
| 49 | + if (bodyW === 1) { wick.rect(xc - 0.5, top, 1, bot - top); continue } | |
| 50 | + const left = xc - half - 0.5 | |
| 51 | + if (hollow && up) upHollow.rect(left + 0.5, top + 0.5, bodyW - 1, Math.max(1, bot - top - 1)) | |
| 52 | + else (up ? upBody : downBody).rect(left, top, bodyW, bot - top) | |
| 53 | + } | |
| 54 | + ctx.fillStyle = theme.upWick; ctx.fill(upWick) | |
| 55 | + ctx.fillStyle = theme.downWick; ctx.fill(downWick) | |
| 56 | + ctx.fillStyle = theme.up; ctx.fill(upBody) | |
| 57 | + ctx.fillStyle = theme.down; ctx.fill(downBody) | |
| 58 | + if (hollow) { | |
| 59 | + ctx.strokeStyle = theme.up; ctx.lineWidth = 1; ctx.stroke(upHollow) | |
| 60 | + } | |
| 61 | +} | |
| 62 | + | |
| 63 | +function drawBars(g, withOpen) { | |
| 64 | + const { ctx, store, from, to, ts, ps, theme } = g | |
| 65 | + const O = store.o, H = store.h, L = store.l, C = store.c | |
| 66 | + const bodyW = ts.bodyWidth() | |
| 67 | + const tick = Math.max(1, Math.floor(bodyW / 2)) | |
| 68 | + const up = new Path2D(), down = new Path2D() | |
| 69 | + for (let i = from; i <= to; i++) { | |
| 70 | + const o = O[i], c = C[i] | |
| 71 | + const p = c >= o ? up : down | |
| 72 | + const xc = centerX(ts, i) | |
| 73 | + const yH = Math.round(ps.y(H[i])), yL = Math.round(ps.y(L[i])) | |
| 74 | + p.rect(xc - 0.5, yH, 1, Math.max(1, yL - yH)) | |
| 75 | + const yC = Math.round(ps.y(c)) | |
| 76 | + p.rect(xc - 0.5, yC, tick + 0.5, 1) | |
| 77 | + if (withOpen) { const yO = Math.round(ps.y(o)); p.rect(xc - tick, yO, tick + 0.5, 1) } | |
| 78 | + } | |
| 79 | + ctx.fillStyle = theme.up; ctx.fill(up) | |
| 80 | + ctx.fillStyle = theme.down; ctx.fill(down) | |
| 81 | +} | |
| 82 | + | |
| 83 | +function closePath(g) { | |
| 84 | + const { store, from, to, ts, ps } = g | |
| 85 | + const C = store.c | |
| 86 | + const path = new Path2D() | |
| 87 | + let first = true | |
| 88 | + for (let i = from; i <= to; i++) { | |
| 89 | + const x = ts.x(i), y = ps.y(C[i]) | |
| 90 | + if (first) { path.moveTo(x, y); first = false } else path.lineTo(x, y) | |
| 91 | + } | |
| 92 | + return path | |
| 93 | +} | |
| 94 | + | |
| 95 | +function drawLine(g, color, width) { | |
| 96 | + const { ctx } = g | |
| 97 | + ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineJoin = 'round'; ctx.lineCap = 'round' | |
| 98 | + ctx.stroke(closePath(g)) | |
| 99 | +} | |
| 100 | + | |
| 101 | +function drawArea(g) { | |
| 102 | + const { ctx, store, from, to, ts, ps, theme, height } = g | |
| 103 | + const color = theme.series[0] | |
| 104 | + const line = closePath(g) | |
| 105 | + const fill = new Path2D(line) | |
| 106 | + fill.lineTo(ts.x(to), height); fill.lineTo(ts.x(from), height); fill.closePath() | |
| 107 | + const grad = ctx.createLinearGradient(0, 0, 0, height) | |
| 108 | + grad.addColorStop(0, withAlpha(color, 0.35)); grad.addColorStop(1, withAlpha(color, 0.02)) | |
| 109 | + ctx.fillStyle = grad; ctx.fill(fill) | |
| 110 | + ctx.strokeStyle = color; ctx.lineWidth = g.ts.isCompressed ? 1 : 1.5; ctx.lineJoin = 'round'; ctx.stroke(line) | |
| 111 | + void store | |
| 112 | +} | |
| 113 | + | |
| 114 | +function drawBaseline(g) { | |
| 115 | + const { ctx, from, to, ts, ps, theme, height, width } = g | |
| 116 | + const base = g.baseline | |
| 117 | + const yB = Math.max(0, Math.min(height, ps.y(base))) | |
| 118 | + const line = closePath(g) | |
| 119 | + const fill = new Path2D(line) | |
| 120 | + fill.lineTo(ts.x(to), yB); fill.lineTo(ts.x(from), yB); fill.closePath() | |
| 121 | + // Above the baseline: up color; below: down color — two clipped fills of the same polygon. | |
| 122 | + ctx.save(); ctx.beginPath(); ctx.rect(0, 0, width, yB); ctx.clip() | |
| 123 | + let grad = ctx.createLinearGradient(0, 0, 0, yB) | |
| 124 | + grad.addColorStop(0, withAlpha(theme.up, 0.35)); grad.addColorStop(1, withAlpha(theme.up, 0.03)) | |
| 125 | + ctx.fillStyle = grad; ctx.fill(fill) | |
| 126 | + ctx.strokeStyle = theme.up; ctx.lineWidth = 1.5; ctx.stroke(line) | |
| 127 | + ctx.restore() | |
| 128 | + ctx.save(); ctx.beginPath(); ctx.rect(0, yB, width, height - yB); ctx.clip() | |
| 129 | + grad = ctx.createLinearGradient(0, yB, 0, height) | |
| 130 | + grad.addColorStop(0, withAlpha(theme.down, 0.03)); grad.addColorStop(1, withAlpha(theme.down, 0.35)) | |
| 131 | + ctx.fillStyle = grad; ctx.fill(fill) | |
| 132 | + ctx.strokeStyle = theme.down; ctx.lineWidth = 1.5; ctx.stroke(line) | |
| 133 | + ctx.restore() | |
| 134 | + ctx.strokeStyle = withAlpha(theme.textMuted, 0.6); ctx.lineWidth = 1; ctx.setLineDash([3, 3]) | |
| 135 | + ctx.beginPath(); ctx.moveTo(0, Math.round(yB) + 0.5); ctx.lineTo(width, Math.round(yB) + 0.5); ctx.stroke(); ctx.setLineDash([]) | |
| 136 | +} | |
| 137 | + | |
| 138 | +function drawColumns(g) { | |
| 139 | + const { ctx, store, from, to, ts, ps, theme, height } = g | |
| 140 | + const O = store.o, C = store.c | |
| 141 | + const w = Math.max(1, ts.isCompressed ? 1 : ts.bodyWidth()) | |
| 142 | + const half = (w - 1) / 2 | |
| 143 | + const up = new Path2D(), down = new Path2D() | |
| 144 | + for (let i = from; i <= to; i++) { | |
| 145 | + const c = C[i] | |
| 146 | + const xc = centerX(ts, i) | |
| 147 | + const y = Math.round(ps.y(c)) | |
| 148 | + ;(c >= O[i] ? up : down).rect(xc - half - 0.5, y, w, Math.max(1, height - y)) | |
| 149 | + } | |
| 150 | + ctx.fillStyle = withAlpha(theme.up, 0.8); ctx.fill(up) | |
| 151 | + ctx.fillStyle = withAlpha(theme.down, 0.8); ctx.fill(down) | |
| 152 | +} | |
| 153 | + | |
| 154 | +/** Volume histogram in the bottom `fraction` of the pane. */ | |
| 155 | +export function drawVolume(g, fraction = 0.2) { | |
| 156 | + const { ctx, store, from, to, ts, theme, height } = g | |
| 157 | + if (to < from) return | |
| 158 | + const V = store.v, O = store.o, C = store.c | |
| 159 | + let max = 0 | |
| 160 | + for (let i = from; i <= to; i++) if (V[i] > max) max = V[i] | |
| 161 | + if (!(max > 0)) return | |
| 162 | + const areaH = height * fraction | |
| 163 | + const w = ts.isCompressed ? 1 : Math.max(1, ts.bodyWidth()) | |
| 164 | + const half = (w - 1) / 2 | |
| 165 | + const up = new Path2D(), down = new Path2D() | |
| 166 | + for (let i = from; i <= to; i++) { | |
| 167 | + const v = V[i] | |
| 168 | + if (!(v > 0)) continue | |
| 169 | + const h = Math.max(1, Math.round((v / max) * areaH)) | |
| 170 | + const xc = centerX(ts, i) | |
| 171 | + ;(C[i] >= O[i] ? up : down).rect(xc - half - 0.5, height - h, w, h) | |
| 172 | + } | |
| 173 | + ctx.fillStyle = theme.volumeUp; ctx.fill(up) | |
| 174 | + ctx.fillStyle = theme.volumeDown; ctx.fill(down) | |
| 175 | +} | |
modified
hfmarketdata/web/src/charts/engine/scales/price-scale.js
+9 −4
@@ -70,7 +70,12 @@ export class PriceScale { | ||
| 70 | 70 | setAutoRange(min, max) { |
| 71 | 71 | if (!Number.isFinite(min) || !Number.isFinite(max)) return |
| 72 | 72 | if (this.fixedRange) { min = this.fixedRange.lo; max = this.fixedRange.hi } |
| 73 | − let a = this.toInternal(min), b = this.toInternal(max) | |
| 73 | + this.setAutoRangeInternal(this.toInternal(min), this.toInternal(max)) | |
| 74 | + } | |
| 75 | + | |
| 76 | + /** Same as setAutoRange but with bounds already expressed in internal units. */ | |
| 77 | + setAutoRangeInternal(a, b) { | |
| 78 | + if (!Number.isFinite(a) || !Number.isFinite(b)) return | |
| 74 | 79 | if (a > b) [a, b] = [b, a] |
| 75 | 80 | if (b - a < 1e-12) { |
| 76 | 81 | const pad = Math.abs(a) * 0.01 || 1 |
@@ -118,13 +123,13 @@ export class PriceScale { | ||
| 118 | 123 | } |
| 119 | 124 | |
| 120 | 125 | /** Ticks for the axis: [{ price, y, text }], plus the decimals used. */ |
| 121 | − ticks(height, decimals, locale, minPx = 44) { | |
| 126 | + ticks(height, decimals, locale, minPx = 44, fmt = null) { | |
| 122 | 127 | const out = [] |
| 123 | 128 | if (!(height > 0) || !(this.hi > this.lo)) return { ticks: out, decimals } |
| 124 | 129 | if (this.mode === 'log') { |
| 125 | 130 | const r = logTicks(this.lo, this.hi, height, minPx) |
| 126 | 131 | const d = Math.max(decimals, r.decimals) |
| 127 | − for (const p of r.ticks) out.push({ price: p, y: this.y(p), text: formatPrice(p, Math.min(d, 8), locale) }) | |
| 132 | + for (const p of r.ticks) out.push({ price: p, y: this.y(p), text: fmt ? fmt(p, d) : formatPrice(p, Math.min(d, 8), locale) }) | |
| 128 | 133 | return { ticks: out, decimals: d } |
| 129 | 134 | } |
| 130 | 135 | if (this.mode === 'percent') { |
@@ -134,7 +139,7 @@ export class PriceScale { | ||
| 134 | 139 | } |
| 135 | 140 | const r = linearTicks(this.lo, this.hi, height, minPx) |
| 136 | 141 | const d = Math.min(Math.max(decimals, r.decimals), 8) |
| 137 | − for (const v of r.ticks) out.push({ price: v, y: this.yInternal(v), text: formatPrice(v, d, locale) }) | |
| 142 | + for (const v of r.ticks) out.push({ price: v, y: this.yInternal(v), text: fmt ? fmt(v, d) : formatPrice(v, d, locale) }) | |
| 138 | 143 | return { ticks: out, decimals: d } |
| 139 | 144 | } |
| 140 | 145 | |
| 141 | 146 | |