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
@@ -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 setAutoRange(min, max) { 70 setAutoRange(min, max) {
71 if (!Number.isFinite(min) || !Number.isFinite(max)) return 71 if (!Number.isFinite(min) || !Number.isFinite(max)) return
72 if (this.fixedRange) { min = this.fixedRange.lo; max = this.fixedRange.hi } 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 if (a > b) [a, b] = [b, a] 79 if (a > b) [a, b] = [b, a]
75 if (b - a < 1e-12) { 80 if (b - a < 1e-12) {
76 const pad = Math.abs(a) * 0.01 || 1 81 const pad = Math.abs(a) * 0.01 || 1
@@ -118,13 +123,13 @@ export class PriceScale {
118 } 123 }
119 124
120 /** Ticks for the axis: [{ price, y, text }], plus the decimals used. */ 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 const out = [] 127 const out = []
123 if (!(height > 0) || !(this.hi > this.lo)) return { ticks: out, decimals } 128 if (!(height > 0) || !(this.hi > this.lo)) return { ticks: out, decimals }
124 if (this.mode === 'log') { 129 if (this.mode === 'log') {
125 const r = logTicks(this.lo, this.hi, height, minPx) 130 const r = logTicks(this.lo, this.hi, height, minPx)
126 const d = Math.max(decimals, r.decimals) 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 return { ticks: out, decimals: d } 133 return { ticks: out, decimals: d }
129 } 134 }
130 if (this.mode === 'percent') { 135 if (this.mode === 'percent') {
@@ -134,7 +139,7 @@ export class PriceScale {
134 } 139 }
135 const r = linearTicks(this.lo, this.hi, height, minPx) 140 const r = linearTicks(this.lo, this.hi, height, minPx)
136 const d = Math.min(Math.max(decimals, r.decimals), 8) 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 return { ticks: out, decimals: d } 143 return { ticks: out, decimals: d }
139 } 144 }
140 145
141 146
Menu

Theme