SPB Git

spb/hfmarketdata

Public

Open high-frequency market data platform — FirstRate full-history downloader, DuckDB/Parquet lake, open REST API and React docs platform (www.hfmarketdata.io)

charts: cœur du moteur (panneaux, rendu bougies/volume/axes, crosshair, interactions, dessins, harnais, captures)

Simon-Pierre Boucher committed (Sep 7, 2026) parent 068b577 Browse files

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 "sample:aapl-pe": "node scripts/make-aapl-pe-sample.mjs", 14 "sample:aapl-pe": "node scripts/make-aapl-pe-sample.mjs",
15 "bench:formats": "node scripts/bench-formats.mjs", 15 "bench:formats": "node scripts/bench-formats.mjs",
16 "test:e2e": "playwright test", 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 "dependencies": { 21 "dependencies": {
20 "@mdx-js/react": "^3.1.1", 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
Large diff (57 lines) is not loaded by default to keep the page fast.Load diff
added hfmarketdata/web/src/charts/engine/export/png.js +52 −0
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
added hfmarketdata/web/src/charts/engine/index.js +20 −0
Large diff (20 lines) is not loaded by default to keep the page fast.Load diff
added hfmarketdata/web/src/charts/engine/interactions/pointer.js +308 −0
Large diff (308 lines) is not loaded by default to keep the page fast.Load diff
added hfmarketdata/web/src/charts/engine/panes/pane.js +49 −0
Large diff (49 lines) is not loaded by default to keep the page fast.Load diff
added hfmarketdata/web/src/charts/engine/render/axes.js +100 −0
Large diff (100 lines) is not loaded by default to keep the page fast.Load diff
added hfmarketdata/web/src/charts/engine/render/overlay.js +123 −0
Large diff (123 lines) is not loaded by default to keep the page fast.Load diff
added hfmarketdata/web/src/charts/engine/render/plots.js +148 −0
Large diff (148 lines) is not loaded by default to keep the page fast.Load diff
added hfmarketdata/web/src/charts/engine/render/series.js +175 −0
Large diff (175 lines) is not loaded by default to keep the page fast.Load diff
modified hfmarketdata/web/src/charts/engine/scales/price-scale.js +9 −4
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme