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 14 "sample:aapl-pe": "node scripts/make-aapl-pe-sample.mjs",
15 15 "bench:formats": "node scripts/bench-formats.mjs",
16 16 "test:e2e": "playwright test",
17 − "test:e2e:ui": "playwright test --ui"
17 + "test:e2e:ui": "playwright test --ui",
18 + "test:charts": "node --test src/charts",
19 + "charts:shots": "node scripts/charts-shots.mjs"
18 20 },
19 21 "dependencies": {
20 22 "@mdx-js/react": "^3.1.1",
added hfmarketdata/web/scripts/charts-shots.mjs +106 −0
@@ -0,0 +1,106 @@
1 +#!/usr/bin/env node
2 +// Visual QA for the chart engine: starts Vite on the harness, captures desktop (1440×900) and mobile (390×844)
3 +// screenshots into /tmp/hfmd-charts/, and reports the render time of 100 programmatic pan/zoom frames.
4 +//
5 +// Usage: node scripts/charts-shots.mjs [--only=name,name] [--out=/tmp/hfmd-charts] [--port=4180]
6 +
7 +import { spawn } from 'node:child_process'
8 +import { mkdirSync } from 'node:fs'
9 +import { resolve, dirname } from 'node:path'
10 +import { fileURLToPath } from 'node:url'
11 +import { chromium } from '@playwright/test'
12 +
13 +const here = dirname(fileURLToPath(import.meta.url))
14 +const webDir = resolve(here, '..')
15 +const args = Object.fromEntries(process.argv.slice(2).filter(a => a.startsWith('--')).map(a => { const [k, v] = a.slice(2).split('='); return [k, v ?? '1'] }))
16 +const PORT = Number(args.port || 4180)
17 +const OUT = args.out || '/tmp/hfmd-charts'
18 +const ONLY = args.only ? new Set(args.only.split(',')) : null
19 +mkdirSync(OUT, { recursive: true })
20 +
21 +const HARNESS = `http://localhost:${PORT}/dev/charts-harness.html`
22 +
23 +const SHOTS = [
24 + { name: 'desktop-1min-candles', q: 'tf=1min&n=50000' },
25 + { name: 'desktop-1min-light', q: 'tf=1min&n=50000&theme=light' },
26 + { name: 'desktop-1day-indicators', q: 'tf=1day&n=10000&ind=bollinger,rsi,macd' },
27 + { name: 'desktop-1day-ichimoku-volume', q: 'tf=1day&n=10000&ind=ichimoku,volume-ma,adx' },
28 + { name: 'desktop-line-log', q: 'tf=1day&n=10000&type=area&mode=log' },
29 + { name: 'desktop-compare-percent', q: 'tf=1day&n=10000&type=line&compare=1' },
30 + { name: 'desktop-hollow-drawings', q: 'tf=1min&n=50000&type=hollow&drawings=1&crosshair=magnet' },
31 + { name: 'desktop-ohlc-supertrend-light', q: 'tf=1min&n=50000&type=ohlc&ind=supertrend,vwap,stoch&theme=light' },
32 + { name: 'desktop-baseline-heikin', q: 'tf=1day&n=10000&type=heikin&ind=ema,sma' },
33 + { name: 'desktop-200k-zoomed-out', q: 'tf=1min&n=200000', zoomOut: 40 },
34 + { name: 'mobile-1min-candles', q: 'tf=1min&n=50000&ind=rsi', mobile: true },
35 + { name: 'mobile-1day-light', q: 'tf=1day&n=10000&theme=light&type=area', mobile: true },
36 +]
37 +
38 +function startVite() {
39 + const child = spawn('npx', ['vite', '--port', String(PORT), '--strictPort', '--clearScreen', 'false'], { cwd: webDir, stdio: ['ignore', 'pipe', 'pipe'] })
40 + child.stdout.on('data', d => { if (process.env.DEBUG) process.stdout.write(d) })
41 + child.stderr.on('data', d => process.stderr.write(d))
42 + return child
43 +}
44 +
45 +async function waitFor(url, ms = 30_000) {
46 + const t0 = Date.now()
47 + while (Date.now() - t0 < ms) {
48 + try { const r = await fetch(url); if (r.ok) return } catch { /* not yet */ }
49 + await new Promise(r => setTimeout(r, 250))
50 + }
51 + throw new Error(`Vite did not start on ${url}`)
52 +}
53 +
54 +async function main() {
55 + const vite = startVite()
56 + const stop = () => { try { vite.kill('SIGTERM') } catch { /* ignore */ } }
57 + process.on('exit', stop); process.on('SIGINT', () => { stop(); process.exit(130) })
58 + try {
59 + await waitFor(HARNESS)
60 + const browser = await chromium.launch()
61 + const results = []
62 + for (const s of SHOTS) {
63 + if (ONLY && !ONLY.has(s.name)) continue
64 + const ctx = await browser.newContext(s.mobile
65 + ? { viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true }
66 + : { viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 })
67 + const page = await ctx.newPage()
68 + const errors = []
69 + page.on('pageerror', e => errors.push(String(e)))
70 + page.on('console', m => { if (m.type() === 'error') errors.push(m.text()) })
71 + await page.goto(`${HARNESS}?${s.q}&reduced=1`, { waitUntil: 'networkidle' })
72 + await page.waitForFunction(() => window.__chart && window.__chart.getData().length > 0)
73 + if (s.zoomOut) {
74 + await page.evaluate(k => { const c = window.__chart; for (let i = 0; i < k; i++) c.ts.zoomAt(0.8, c.plotWidth / 2); c.invalidate('data') }, s.zoomOut)
75 + }
76 + // Hover to show the crosshair on desktop shots.
77 + if (!s.mobile) await page.mouse.move(s.hoverX || 900, s.hoverY || 380)
78 + await page.waitForTimeout(350)
79 + const file = `${OUT}/${s.name}.png`
80 + await page.screenshot({ path: file })
81 + console.log(`shot ${s.name.padEnd(36)} → ${file}${errors.length ? ` ⚠ ${errors.length} console error(s): ${errors[0]}` : ''}`)
82 + results.push({ name: s.name, errors })
83 + await ctx.close()
84 + }
85 + // Benchmark: 100 programmatic pan/zoom frames on 200 000 bars and on 50 000 bars.
86 + for (const [label, qs] of [['200k 1min', 'tf=1min&n=200000'], ['50k 1min + 3 indicators', 'tf=1min&n=50000&ind=bollinger,rsi,macd']]) {
87 + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 })
88 + const page = await ctx.newPage()
89 + await page.goto(`${HARNESS}?${qs}&reduced=1`, { waitUntil: 'networkidle' })
90 + await page.waitForFunction(() => window.__harness && window.__chart.getData().length > 0)
91 + const r = await page.evaluate(() => window.__harness.bench(100))
92 + console.log(`bench ${label.padEnd(30)} ${JSON.stringify(r)}`)
93 + // Zoomed-out (everything visible) worst case.
94 + const r2 = await page.evaluate(async () => { const c = window.__chart; for (let i = 0; i < 40; i++) c.ts.zoomAt(0.8, c.plotWidth / 2); return window.__harness.bench(100) })
95 + console.log(`bench ${(label + ' (zoomed out)').padEnd(30)} ${JSON.stringify(r2)}`)
96 + await ctx.close()
97 + }
98 + await browser.close()
99 + const failed = results.filter(r => r.errors.length)
100 + if (failed.length) { console.error(`\n${failed.length} page(s) with console errors`); process.exitCode = 1 }
101 + } finally {
102 + stop()
103 + }
104 +}
105 +
106 +main().catch(e => { console.error(e); process.exit(1) })
added hfmarketdata/web/src/charts/engine/core/chart.js +957 −0
@@ -0,0 +1,957 @@
1 +// Chart core: DOM/layout, frame scheduling, data + scales orchestration, rendering of every layer, public API.
2 +
3 +import { Emitter } from './emitter.js'
4 +import { Animator } from './animation.js'
5 +import { BarStore } from '../data/store.js'
6 +import { TimeScale } from '../scales/time-scale.js'
7 +import { timeTicks as buildTimeTicks } from '../scales/ticks.js'
8 +import { Pane, COLLAPSED_H, MIN_PANE_H } from '../panes/pane.js'
9 +import { createLayer, crisp, withAlpha } from '../render/canvas.js'
10 +import { font, measure, FONT_SIZE } from '../render/text.js'
11 +import { drawSeries, drawVolume } from '../render/series.js'
12 +import { drawGrid, drawSessionBreaks, drawPriceAxis, drawTimeAxis, measureAxisWidth, TIME_AXIS_H, MIN_AXIS_W } from '../render/axes.js'
13 +import { drawPlots, valuePath, plotColor } from '../render/plots.js'
14 +import { drawCrosshair, drawAxisLabel, drawTimeLabel, drawLastPrice, drawHiLoMarkers, drawWatermark, drawPaneHeader } from '../render/overlay.js'
15 +import { normalizeTheme, darkTheme } from '../theme.js'
16 +import { autoDecimals, formatPrice, formatCompact, formatPercent, clamp } from '../format/number.js'
17 +import { fmtFull, isIntraday, isDayChange } from '../format/time.js'
18 +import { indicatorSpec, indicatorParams, computeIndicator } from '../../indicators/index.js'
19 +import { heikinAshi } from '../../indicators/heikin-ashi.js'
20 +import { DrawingManager } from '../drawings/manager.js'
21 +import { attachInteractions } from '../interactions/pointer.js'
22 +import { exportPNG } from '../export/png.js'
23 +import { pill } from '../render/text.js'
24 +
25 +const DEFAULTS = {
26 + theme: darkTheme,
27 + timeframe: '1day',
28 + priceFormat: { decimals: 'auto', minMove: undefined },
29 + locale: 'en-US',
30 + sessionLabel: '',
31 + watermark: '',
32 + rightOffsetBars: 8,
33 + barSpacing: 8,
34 + minBarSpacing: 0.5,
35 + maxBars: 500_000,
36 + reducedMotion: false,
37 + baselineValue: null, // baseline series: reference price (null = first visible open)
38 + volumeFraction: 0.2,
39 + fitBars: 150, // bars shown by fitContent()
40 + onPaneClose: null, // (paneInfo) => void — when set, the close button delegates to the caller
41 +}
42 +
43 +const PULSE_MS = 650
44 +const SERIES_TYPES = new Set(['candles', 'hollow', 'ohlc', 'line', 'area', 'baseline', 'heikin', 'columns', 'hlc'])
45 +let idSeq = 0
46 +
47 +export class Chart {
48 + constructor(container, options = {}) {
49 + this.container = container
50 + this.opts = { ...DEFAULTS, ...options, priceFormat: { ...DEFAULTS.priceFormat, ...(options.priceFormat || {}) } }
51 + this.theme = normalizeTheme(this.opts.theme)
52 + this.emitter = new Emitter()
53 + this.animator = new Animator()
54 + this.store = new BarStore(this.opts.maxBars)
55 + this.renderStore = this.store
56 + this._haVersion = -1
57 + this.ts = new TimeScale({ barSpacing: this.opts.barSpacing, minBarSpacing: this.opts.minBarSpacing, rightOffsetBars: this.opts.rightOffsetBars })
58 + this.seriesType = 'candles'
59 + this.volumeVisible = true
60 + this.crosshairOpts = { mode: 'normal', showLabels: true }
61 + this.pointer = null // { x, y, pane, region } in chart-local px, null when outside
62 + this.indicators = new Map()
63 + this.compares = new Map()
64 + this._modeBeforeCompare = null
65 + this.decimals = 2
66 + this.axisWidth = MIN_AXIS_W
67 + this.width = 0; this.height = 0; this.dpr = 1
68 + this.dirty = { layout: true, data: true, overlay: true }
69 + this._raf = 0
70 + this._last = 0
71 + this._pulseAt = -Infinity
72 + this._lastClose = NaN
73 + this._emittedRange = null
74 + this._indicatorVersion = -1
75 + this._destroyed = false
76 + this.kinetic = null // { vx } px/ms while inertia is running
77 + this.dragging = false
78 +
79 + this._buildDom()
80 + this.mainPane = new Pane(this.el, { kind: 'main', weight: 1 })
81 + this.panes = [this.mainPane]
82 + this.drawings = new DrawingManager(this)
83 + this.interactions = attachInteractions(this)
84 + if (typeof ResizeObserver !== 'undefined') {
85 + this._ro = new ResizeObserver(() => this.resize())
86 + this._ro.observe(container)
87 + }
88 + this.resize()
89 + }
90 +
91 + /* ───────────────────────── DOM & layout ───────────────────────── */
92 +
93 + _buildDom() {
94 + const el = document.createElement('div')
95 + el.className = 'hfmd-chart'
96 + el.tabIndex = 0
97 + el.setAttribute('role', 'img')
98 + el.setAttribute('aria-label', 'Financial chart')
99 + el.style.cssText = `position:relative;width:100%;height:100%;overflow:hidden;outline:none;user-select:none;-webkit-user-select:none;touch-action:none;background:${this.theme.bg};cursor:crosshair;`
100 + this.container.appendChild(el)
101 + this.el = el
102 + const ta = document.createElement('div')
103 + ta.className = 'hfmd-time-axis'
104 + ta.style.cssText = 'position:absolute;left:0;overflow:hidden;'
105 + el.appendChild(ta)
106 + this.timeAxis = { el: ta, main: createLayer(ta, { zIndex: 1 }), overlay: createLayer(ta, { zIndex: 2 }), top: 0, height: TIME_AXIS_H }
107 + }
108 +
109 + resize() {
110 + if (this._destroyed) return
111 + const w = this.container.clientWidth || 600
112 + const h = this.container.clientHeight || 400
113 + this.width = w; this.height = h
114 + this.dpr = Math.max(1, Math.min(3, (typeof window !== 'undefined' && window.devicePixelRatio) || 1))
115 + this.dirty.layout = true
116 + this.invalidate('data')
117 + }
118 +
119 + _layout() {
120 + const total = this.height - TIME_AXIS_H
121 + let free = total
122 + let weight = 0
123 + for (const p of this.panes) { if (p.collapsed) free -= COLLAPSED_H; else weight += p.weight }
124 + let y = 0
125 + for (const p of this.panes) {
126 + const h = p.collapsed ? COLLAPSED_H : Math.max(MIN_PANE_H, Math.round((free * p.weight) / (weight || 1)))
127 + p.layout(y, h, this.width, this.dpr)
128 + p.scale.height = h
129 + y += h
130 + }
131 + // Rounding slack goes to the main pane so the time axis always sits at the bottom.
132 + const slack = total - y
133 + if (slack !== 0) {
134 + const p = this.mainPane
135 + p.layout(p.top, p.height + slack, this.width, this.dpr)
136 + p.scale.height = p.height
137 + let yy = p.top + p.height
138 + for (const q of this.panes) { if (q.top > p.top) { q.layout(yy, q.height, this.width, this.dpr); yy += q.height } }
139 + }
140 + this.timeAxis.top = total
141 + this.timeAxis.el.style.top = total + 'px'
142 + this.timeAxis.el.style.width = this.width + 'px'
143 + this.timeAxis.el.style.height = TIME_AXIS_H + 'px'
144 + this.timeAxis.main.resize(this.width, TIME_AXIS_H, this.dpr)
145 + this.timeAxis.overlay.resize(this.width, TIME_AXIS_H, this.dpr)
146 + this.dirty.layout = false
147 + }
148 +
149 + get plotWidth() { return Math.max(10, this.width - this.axisWidth) }
150 +
151 + /** Which part of the chart is under (x, y) in chart-local pixels. */
152 + hitRegion(x, y) {
153 + if (x < 0 || y < 0 || x > this.width || y > this.height) return { region: 'outside' }
154 + if (y >= this.timeAxis.top) return { region: x >= this.plotWidth ? 'corner' : 'timeAxis', x, y: y - this.timeAxis.top }
155 + for (let i = 0; i < this.panes.length; i++) {
156 + const p = this.panes[i]
157 + if (y >= p.top && y < p.top + p.height) {
158 + const ly = y - p.top
159 + if (i > 0 && ly <= 4) return { region: 'separator', pane: p, index: i, x, y: ly }
160 + if (i < this.panes.length - 1 && ly >= p.height - 4) return { region: 'separator', pane: this.panes[i + 1], index: i + 1, x, y: ly }
161 + if (p.kind !== 'main' && p.headerBoxes.length) {
162 + for (const b of p.headerBoxes) if (x >= b.x && x <= b.x + b.w && ly >= b.y && ly <= b.y + b.h) return { region: 'paneButton', pane: p, button: b.id, x, y: ly }
163 + }
164 + if (x >= this.plotWidth) return { region: 'priceAxis', pane: p, x, y: ly }
165 + return { region: 'plot', pane: p, x, y: ly }
166 + }
167 + }
168 + return { region: 'outside' }
169 + }
170 +
171 + /* ───────────────────────── scheduling ───────────────────────── */
172 +
173 + invalidate(kind = 'all') {
174 + if (this._destroyed) return
175 + if (kind === 'all' || kind === 'data') { this.dirty.data = true; this.dirty.overlay = true }
176 + if (kind === 'overlay') this.dirty.overlay = true
177 + if (kind === 'layout') { this.dirty.layout = true; this.dirty.data = true; this.dirty.overlay = true }
178 + if (!this._raf) this._raf = requestAnimationFrame(this._frame)
179 + }
180 +
181 + _frame = (now) => {
182 + this._raf = 0
183 + if (this._destroyed) return
184 + const dt = Math.min(50, Math.max(1, now - (this._last || now - 16)))
185 + this._last = now
186 + let animating = this.animator.tick(now)
187 + if (this.kinetic) animating = this._stepKinetic(dt) || animating
188 + if (this.dirty.layout) this._layout()
189 + this._syncData()
190 + this.ts.width = this.plotWidth
191 + this.ts.count = this.store.length
192 + const vr = this.ts.visibleRange()
193 + this._updateBases(vr)
194 + // Scales: auto ranges (snap while dragging so the chart never lags the hand).
195 + for (const p of this.panes) {
196 + if (p.collapsed) continue
197 + if (p.scale.auto) this._autoRange(p, vr)
198 + const moving = p.scale.step(this.dragging ? 1e9 : dt, this.opts.reducedMotion || this.dragging)
199 + if (moving) animating = true
200 + }
201 + const needData = this.dirty.data || animating
202 + if (needData) this._timeTicks = this._computeTimeTicks(vr)
203 + if (needData) this._updateAxisWidth()
204 + if (needData) {
205 + for (const p of this.panes) this._drawPane(p, vr)
206 + this._drawTimeAxis()
207 + this.dirty.data = false
208 + this.dirty.overlay = true
209 + }
210 + const pulse = now - this._pulseAt < PULSE_MS
211 + if (this.dirty.overlay || pulse) {
212 + for (const p of this.panes) this._drawOverlay(p, vr, now)
213 + this._drawTimeOverlay()
214 + this.dirty.overlay = false
215 + }
216 + this._emitRange(vr)
217 + if (animating || pulse) this._raf = requestAnimationFrame(this._frame)
218 + }
219 +
220 + _stepKinetic(dt) {
221 + const k = this.kinetic
222 + if (!k) return false
223 + const dx = k.vx * dt
224 + this.ts.scrollPx(dx)
225 + k.vx *= Math.exp(-dt / 325)
226 + this.dirty.data = true
227 + if (Math.abs(k.vx) < 0.02) { this.kinetic = null; return false }
228 + return true
229 + }
230 +
231 + /* ───────────────────────── data sync ───────────────────────── */
232 +
233 + _syncData() {
234 + if (this.seriesType === 'heikin') {
235 + if (this._haVersion !== this.store.version) {
236 + const ha = new BarStore(this.opts.maxBars)
237 + ha.set(heikinAshi(this.store.bars))
238 + this.renderStore = ha
239 + this._haVersion = this.store.version
240 + }
241 + } else this.renderStore = this.store
242 + if (this._indicatorVersion !== this.store.version) {
243 + for (const ind of this.indicators.values()) this._computeIndicator(ind)
244 + for (const c of this.compares.values()) this._alignCompare(c)
245 + this._indicatorVersion = this.store.version
246 + }
247 + }
248 +
249 + _updateBases(vr) {
250 + const ps = this.mainPane.scale
251 + if (ps.mode !== 'percent') return
252 + const n = this.store.length
253 + if (!n) return
254 + const i = clamp(vr.from, 0, n - 1)
255 + ps.base = this.store.c[i] || this.store.o[i] || 1
256 + for (const c of this.compares.values()) {
257 + const v = c.aligned[i]
258 + c.base = v > 0 ? v : NaN
259 + }
260 + }
261 +
262 + _autoRange(p, vr) {
263 + if (vr.to < vr.from) return
264 + let lo = Infinity, hi = -Infinity
265 + const ps = p.scale
266 + if (p.kind === 'main') {
267 + const rs = this.renderStore
268 + const closeOnly = this.seriesType === 'line' || this.seriesType === 'area' || this.seriesType === 'baseline' || this.seriesType === 'columns'
269 + if (closeOnly) {
270 + const C = rs.c
271 + for (let i = vr.from; i <= vr.to; i++) { const c = C[i]; if (c < lo) lo = c; if (c > hi) hi = c }
272 + } else { const mm = rs.minMax(vr.from, vr.to); lo = mm[0]; hi = mm[1] }
273 + if (this.seriesType === 'baseline') { const b = this._baselineValue(vr); if (b < lo) lo = b; if (b > hi) hi = b }
274 + lo = ps.toInternal(lo); hi = ps.toInternal(hi)
275 + for (const c of this.compares.values()) {
276 + if (!(c.base > 0)) continue
277 + const A = c.aligned
278 + for (let i = vr.from; i <= vr.to; i++) { const v = A[i]; if (!(v > 0)) continue; const pct = (v / c.base - 1) * 100; if (pct < lo) lo = pct; if (pct > hi) hi = pct }
279 + }
280 + }
281 + for (const id of p.indicators) {
282 + const ind = this.indicators.get(id)
283 + if (!ind || !ind.values) continue
284 + for (const plot of ind.spec.plots) {
285 + const keys = plot.kind === 'band' ? [plot.upper, plot.lower] : plot.kind === 'cloud' ? [plot.a, plot.b] : [plot.key]
286 + for (const key of keys) {
287 + const arr = ind.values[key]
288 + if (!arr) continue
289 + const end = Math.min(vr.to, arr.length - 1)
290 + for (let i = vr.from; i <= end; i++) {
291 + const v = arr[i]
292 + if (v == null || Number.isNaN(v)) continue
293 + const iv = p.kind === 'main' ? ps.toInternal(v) : v
294 + if (iv < lo) lo = iv; if (iv > hi) hi = iv
295 + }
296 + }
297 + }
298 + if (p.kind !== 'main' && ind.spec.levels) for (const l of ind.spec.levels) { if (l < lo) lo = l; if (l > hi) hi = l }
299 + }
300 + if (!Number.isFinite(lo) || !Number.isFinite(hi)) return
301 + if (p.kind === 'main') ps.setAutoRangeInternal(lo, hi)
302 + else ps.setAutoRange(lo, hi)
303 + }
304 +
305 + _baselineValue(vr) {
306 + if (this.opts.baselineValue != null) return this.opts.baselineValue
307 + const rs = this.renderStore
308 + if (!rs.length) return 0
309 + const i = clamp(vr.from, 0, rs.length - 1)
310 + return rs.o[i]
311 + }
312 +
313 + _computeTimeTicks(vr) {
314 + if (vr.to < vr.from) return []
315 + const ctx = this.timeAxis.main.ctx
316 + const regular = font(this.theme, { mono: true, size: FONT_SIZE })
317 + const strong = font(this.theme, { mono: true, size: FONT_SIZE, weight: 700 })
318 + return buildTimeTicks({
319 + times: this.store.t, from: vr.from, to: vr.to, tf: this.opts.timeframe, xOf: i => this.ts.x(i), width: this.plotWidth,
320 + measure: (text, isStrong) => measure(ctx, text, isStrong ? strong : regular),
321 + })
322 + }
323 +
324 + _updateAxisWidth() {
325 + const ctx = this.mainPane.main.ctx
326 + let w = MIN_AXIS_W
327 + for (const p of this.panes) {
328 + if (p.collapsed) continue
329 + const r = this._paneTicks(p)
330 + p._ticks = r.ticks
331 + p.decimals = r.decimals
332 + const need = measureAxisWidth(ctx, r.ticks, this.theme)
333 + if (need > w) w = need
334 + }
335 + // Also make room for the last-price label (bold) and the crosshair label.
336 + if (this.store.length) {
337 + const text = this._formatMain(this.store.c[this.store.length - 1])
338 + const need = Math.ceil(measure(ctx, text, font(this.theme, { mono: true, size: FONT_SIZE, weight: 600 }))) + 16
339 + if (need > w) w = need
340 + }
341 + w = Math.ceil(w / 4) * 4
342 + if (w > this.axisWidth || w < this.axisWidth - 12) {
343 + this.axisWidth = w
344 + this.ts.width = this.plotWidth
345 + }
346 + }
347 +
348 + _paneTicks(p) {
349 + const fmt = this._paneFormatter(p)
350 + return p.scale.ticks(p.height, p.kind === 'main' ? this.decimals : 0, this.opts.locale, 44, fmt)
351 + }
352 +
353 + _paneFormatter(p) {
354 + if (p.kind === 'main') return null
355 + const ind = this.indicators.get(p.indicators[0])
356 + if (ind && ind.spec.format === 'compact') return v => formatCompact(v, this.opts.locale)
357 + return null
358 + }
359 +
360 + _formatMain(price) { return this.mainPane.scale.format(price, this.decimals, this.opts.locale) }
361 +
362 + _formatPane(p, value) {
363 + if (p.kind === 'main') return this._formatMain(value)
364 + const fmt = this._paneFormatter(p)
365 + if (fmt) return fmt(value)
366 + return formatPrice(value, Math.max(p.decimals || 0, 2), this.opts.locale)
367 + }
368 +
369 + /* ───────────────────────── drawing: data layer ───────────────────────── */
370 +
371 + _drawPane(p, vr) {
372 + const layer = p.main
373 + const ctx = layer.ctx
374 + layer.clear()
375 + const W = this.plotWidth, H = p.height, theme = this.theme
376 + ctx.fillStyle = theme.bg
377 + ctx.fillRect(0, 0, this.width, H)
378 + if (p.collapsed) {
379 + this._drawPriceAxisFor(p, [])
380 + this._drawPaneBorder(p)
381 + return
382 + }
383 + const ticks = p._ticks || []
384 + ctx.save()
385 + ctx.beginPath(); ctx.rect(0, 0, W, H); ctx.clip()
386 + drawGrid(ctx, { width: W, height: H, priceTicks: ticks, timeTicks: this._timeTicks || [], theme })
387 + if (p.kind === 'main') {
388 + if (isIntraday(this.opts.timeframe)) this._drawSessionBreaks(ctx, vr, H)
389 + drawWatermark(ctx, { text: this.opts.watermark, width: W, height: H, theme })
390 + const g = { ctx, store: this.renderStore, from: vr.from, to: vr.to, ts: this.ts, ps: p.scale, theme, width: W, height: H, type: this.seriesType, baseline: this._baselineValue(vr) }
391 + if (this.volumeVisible) drawVolume(g, this.opts.volumeFraction)
392 + drawSeries(g)
393 + this._drawIndicators(ctx, p, vr, W, H)
394 + this._drawCompares(ctx, p, vr, W, H)
395 + if (vr.to >= vr.from && !this.ts.isCompressed) this._drawHiLo(ctx, vr, W)
396 + } else {
397 + this._drawLevels(ctx, p, W)
398 + this._drawIndicators(ctx, p, vr, W, H)
399 + }
400 + ctx.restore()
401 + this._drawPriceAxisFor(p, ticks)
402 + this._drawPaneBorder(p)
403 + }
404 +
405 + _drawPaneBorder(p) {
406 + const ctx = p.main.ctx
407 + if (p !== this.panes[0]) {
408 + ctx.strokeStyle = this.theme.paneBorder
409 + ctx.lineWidth = 1
410 + ctx.beginPath(); ctx.moveTo(0, 0.5); ctx.lineTo(this.width, 0.5); ctx.stroke()
411 + }
412 + }
413 +
414 + _drawPriceAxisFor(p, ticks) {
415 + drawPriceAxis(p.main.ctx, { x0: this.plotWidth, width: this.axisWidth, height: p.height, ticks, theme: this.theme })
416 + }
417 +
418 + _drawSessionBreaks(ctx, vr, H) {
419 + if (vr.to < vr.from) return
420 + const T = this.store.t
421 + const xs = []
422 + for (let i = Math.max(1, vr.from); i <= vr.to; i++) {
423 + if (isDayChange(T[i], T[i - 1])) { xs.push(this.ts.x(i) - this.ts.barSpacing / 2); if (xs.length > 80) return }
424 + }
425 + drawSessionBreaks(ctx, { xs, height: H, theme: this.theme })
426 + }
427 +
428 + _drawLevels(ctx, p, W) {
429 + const ind = this.indicators.get(p.indicators[0])
430 + if (!ind || !ind.spec.levels) return
431 + ctx.strokeStyle = this.theme.gridStrong
432 + ctx.lineWidth = 1
433 + ctx.setLineDash([4, 4])
434 + ctx.beginPath()
435 + for (const l of ind.spec.levels) { const y = crisp(p.scale.y(l)); ctx.moveTo(0, y); ctx.lineTo(W, y) }
436 + ctx.stroke()
437 + ctx.setLineDash([])
438 + }
439 +
440 + _drawIndicators(ctx, p, vr, W, H) {
441 + const ps = p.scale
442 + // Indicator arrays may extend past the data (Ichimoku forward cloud): draw up to the right edge.
443 + const to = Math.max(vr.to, Math.ceil(this.ts.indexAt(W)))
444 + for (const id of p.indicators) {
445 + const ind = this.indicators.get(id)
446 + if (!ind || !ind.values) continue
447 + drawPlots({ ctx, plots: ind.spec.plots, values: ind.values, colors: ind.colors, from: vr.from, to, ts: this.ts, yOf: v => ps.y(v), theme: this.theme, height: H, store: this.store })
448 + }
449 + }
450 +
451 + _drawCompares(ctx, p, vr, W, H) {
452 + if (!this.compares.size || vr.to < vr.from) return
453 + const ps = p.scale
454 + let k = 0
455 + for (const c of this.compares.values()) {
456 + const color = c.color || this.theme.series[(k + 1) % this.theme.series.length]
457 + k++
458 + if (!(c.base > 0)) continue
459 + const A = c.aligned
460 + const pct = new Array(vr.to + 1)
461 + for (let i = vr.from; i <= vr.to; i++) pct[i] = A[i] > 0 ? (A[i] / c.base - 1) * 100 : null
462 + ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'
463 + ctx.stroke(valuePath(pct, vr.from, vr.to, this.ts, v => ps.yInternal(v)))
464 + // Label at the last visible point.
465 + let last = vr.to
466 + while (last >= vr.from && pct[last] == null) last--
467 + if (last >= vr.from) {
468 + const y = ps.yInternal(pct[last])
469 + const x = Math.min(this.ts.x(last) + 6, W - 4)
470 + pill(ctx, `${c.label} ${formatPercent(pct[last], 2, this.opts.locale)}`, x, y, { bg: color, fontStr: font(this.theme, { mono: true, size: 10, weight: 600 }), h: 16, padX: 4, align: x > W - 120 ? 'right' : 'left', clampTo: { x0: 0, x1: W, y0: 0, y1: H } })
471 + }
472 + }
473 + }
474 +
475 + _drawHiLo(ctx, vr, W) {
476 + const ex = this.renderStore.extremes(vr.from, vr.to)
477 + if (ex.iHi < 0) return
478 + const ps = this.mainPane.scale
479 + drawHiLoMarkers(ctx, {
480 + hi: { x: this.ts.x(ex.iHi), y: ps.y(ex.hi), text: `H ${this._formatMain(ex.hi)}` },
481 + lo: { x: this.ts.x(ex.iLo), y: ps.y(ex.lo), text: `L ${this._formatMain(ex.lo)}` },
482 + plotWidth: W, theme: this.theme,
483 + })
484 + }
485 +
486 + _drawTimeAxis() {
487 + const layer = this.timeAxis.main
488 + layer.clear()
489 + drawTimeAxis(layer.ctx, { width: this.width, plotWidth: this.plotWidth, height: TIME_AXIS_H, ticks: this._timeTicks || [], theme: this.theme })
490 + }
491 +
492 + /* ───────────────────────── drawing: overlay layer ───────────────────────── */
493 +
494 + _drawOverlay(p, vr, now) {
495 + const layer = p.overlay
496 + const ctx = layer.ctx
497 + layer.clear()
498 + const W = this.plotWidth, H = p.height, theme = this.theme
499 + if (p.collapsed) {
500 + if (p.kind !== 'main') p.headerBoxes = drawPaneHeader(ctx, { title: p.title, plotWidth: W, theme, hover: this._headerHover(p), collapsed: true })
501 + return
502 + }
503 + const ptr = this.pointer
504 + const hovered = ptr && ptr.pane === p
505 + // Main pane: last price, drawings.
506 + if (p.kind === 'main' && this.store.length) {
507 + const n = this.store.length
508 + const c = this.store.c[n - 1], o = this.store.o[n - 1]
509 + const y = p.scale.y(c)
510 + const age = now - this._pulseAt
511 + const pulse = age < PULSE_MS ? 1 - age / PULSE_MS : 0
512 + ctx.save(); ctx.beginPath(); ctx.rect(0, 0, this.width, H); ctx.clip()
513 + drawLastPrice(ctx, { y, text: this._formatMain(c), plotWidth: W, axisWidth: this.axisWidth, height: H, up: c >= o, theme, pulse })
514 + ctx.restore()
515 + ctx.save(); ctx.beginPath(); ctx.rect(0, 0, W, H); ctx.clip()
516 + this.drawings.draw(ctx, { width: W, height: H, ts: this.ts, ps: p.scale, store: this.store, theme, pointer: hovered ? ptr : null })
517 + ctx.restore()
518 + }
519 + // Crosshair.
520 + if (ptr && this.crosshairOpts.mode !== 'hidden' && ptr.region === 'plot') {
521 + const x = this._crosshairX()
522 + const y = hovered ? this._crosshairY(p) : null
523 + ctx.save(); ctx.beginPath(); ctx.rect(0, 0, W, H); ctx.clip()
524 + drawCrosshair(ctx, { x, y, width: W, height: H, theme })
525 + ctx.restore()
526 + if (hovered && this.crosshairOpts.showLabels && y != null) {
527 + const price = p.scale.priceAt(y)
528 + drawAxisLabel(ctx, { text: this._formatPane(p, price), x0: W, y, width: this.axisWidth, height: H, bg: theme.crosshairLabelBg, color: theme.crosshairLabelText, theme })
529 + }
530 + }
531 + // Indicator values on the axis (last value pills) for indicator panes.
532 + if (p.kind !== 'main') {
533 + this._drawIndicatorLastValues(ctx, p, W, H)
534 + p.headerBoxes = drawPaneHeader(ctx, { title: p.title, plotWidth: W, theme, hover: this._headerHover(p), collapsed: false })
535 + }
536 + }
537 +
538 + _headerHover(p) {
539 + const ptr = this.pointer
540 + if (!ptr || ptr.pane !== p) return null
541 + return { x: ptr.x, y: ptr.y }
542 + }
543 +
544 + _drawIndicatorLastValues(ctx, p, W, H) {
545 + const n = this.store.length
546 + if (!n) return
547 + for (const id of p.indicators) {
548 + const ind = this.indicators.get(id)
549 + if (!ind || !ind.values) continue
550 + for (const plot of ind.spec.plots) {
551 + if (plot.kind !== 'line') continue
552 + const arr = ind.values[plot.key]
553 + if (!arr) continue
554 + let i = Math.min(n - 1, arr.length - 1)
555 + while (i >= 0 && (arr[i] == null || Number.isNaN(arr[i]))) i--
556 + if (i < 0) continue
557 + const y = p.scale.y(arr[i])
558 + if (y < 0 || y > H) continue
559 + const color = plotColor(plot.color, this.theme, ind.colors)
560 + drawAxisLabel(ctx, { text: this._formatPane(p, arr[i]), x0: W, y, width: this.axisWidth, height: H, bg: color, theme: this.theme })
561 + }
562 + }
563 + }
564 +
565 + _drawTimeOverlay() {
566 + const layer = this.timeAxis.overlay
567 + layer.clear()
568 + const ptr = this.pointer
569 + if (!ptr || this.crosshairOpts.mode === 'hidden' || ptr.region !== 'plot' || !this.crosshairOpts.showLabels) return
570 + const idx = this._pointerIndex()
571 + const t = this.store.timeAtIndex(idx)
572 + if (t == null) return
573 + drawTimeLabel(layer.ctx, { text: fmtFull(t, this.opts.timeframe, this.opts.sessionLabel), x: this._crosshairX(), plotWidth: this.plotWidth, height: TIME_AXIS_H, theme: this.theme })
574 + }
575 +
576 + /** Bar index under the pointer (rounded, may exceed the data in the right offset area). */
577 + _pointerIndex() {
578 + const ptr = this.pointer
579 + return Math.round(this.ts.indexAt(ptr.x))
580 + }
581 +
582 + _crosshairX() {
583 + const i = this._pointerIndex()
584 + return Math.floor(this.ts.x(i)) + 0.5
585 + }
586 +
587 + _crosshairY(p) {
588 + const ptr = this.pointer
589 + if (this.crosshairOpts.mode !== 'magnet' || p.kind !== 'main') return ptr.y
590 + const i = this._pointerIndex()
591 + if (i < 0 || i >= this.store.length) return ptr.y
592 + const rs = this.renderStore
593 + const ps = p.scale
594 + const cands = [rs.o[i], rs.h[i], rs.l[i], rs.c[i]]
595 + let best = ptr.y, bestD = Infinity
596 + for (const v of cands) { const y = ps.y(v); const d = Math.abs(y - ptr.y); if (d < bestD) { bestD = d; best = y } }
597 + return best
598 + }
599 +
600 + /** Magnet-snapped price for a pointer position in the main pane. */
601 + snapPrice(x, y) {
602 + const ps = this.mainPane.scale
603 + if (this.crosshairOpts.mode !== 'magnet') return ps.priceAt(y)
604 + const i = Math.round(this.ts.indexAt(x))
605 + if (i < 0 || i >= this.store.length) return ps.priceAt(y)
606 + const rs = this.renderStore
607 + let best = ps.priceAt(y), bestD = 12
608 + for (const v of [rs.o[i], rs.h[i], rs.l[i], rs.c[i]]) { const d = Math.abs(ps.y(v) - y); if (d < bestD) { bestD = d; best = v } }
609 + return best
610 + }
611 +
612 + /* ───────────────────────── pointer / crosshair state ───────────────────────── */
613 +
614 + setPointer(pos) {
615 + const prev = this.pointer
616 + this.pointer = pos
617 + this.invalidate('overlay')
618 + if (this.emitter.has('crosshairMove')) {
619 + if (!pos || pos.region !== 'plot') { if (prev) this.emitter.emit('crosshairMove', null); return }
620 + this.emitter.emit('crosshairMove', this._crosshairInfo(pos))
621 + }
622 + }
623 +
624 + _crosshairInfo(pos) {
625 + const n = this.store.length
626 + const raw = Math.round(this.ts.indexAt(pos.x))
627 + const index = n ? clamp(raw, 0, n - 1) : -1
628 + const bar = index >= 0 ? this.store.bars[index] : null
629 + const p = pos.pane
630 + const y = this._crosshairY(p)
631 + const price = p.scale.priceAt(y)
632 + const indicators = {}
633 + for (const ind of this.indicators.values()) {
634 + const vals = {}
635 + if (ind.values) for (const key of Object.keys(ind.values)) { const arr = ind.values[key]; vals[key] = index >= 0 && index < arr.length && arr[index] != null && !Number.isNaN(arr[index]) ? arr[index] : null }
636 + indicators[ind.id] = vals
637 + }
638 + const compares = {}
639 + for (const c of this.compares.values()) {
640 + const v = index >= 0 ? c.aligned[index] : NaN
641 + compares[c.id] = v > 0 && c.base > 0 ? (v / c.base - 1) * 100 : null
642 + }
643 + return { index, bar, x: pos.x, y: pos.y, price, pane: p.kind === 'main' ? 'main' : p.id, paneId: p.id, indicators, compares }
644 + }
645 +
646 + /* ───────────────────────── range events ───────────────────────── */
647 +
648 + _emitRange(vr) {
649 + const key = `${vr.from}|${vr.to}|${this.store.length}|${this.ts.barSpacing.toFixed(3)}|${this.ts.leftIndex.toFixed(3)}`
650 + if (key === this._emittedRange) return
651 + this._emittedRange = key
652 + if (!this.emitter.has('visibleRangeChange')) return
653 + this.emitter.emit('visibleRangeChange', { fromIndex: vr.from, toIndex: vr.to, barsLeftOfViewport: vr.from, needMoreLeft: this.store.length > 0 && vr.from < 200 })
654 + }
655 +
656 + /* ═══════════════════════════ PUBLIC API ═══════════════════════════ */
657 +
658 + setData(bars) {
659 + this.store.set(bars || [])
660 + this.ts.count = this.store.length
661 + this.ts.width = this.plotWidth
662 + this._refreshDecimals()
663 + for (const p of this.panes) { p.scale.hasRange = false; p.scale.auto = true }
664 + this._pulseAt = -Infinity
665 + this._lastClose = this.store.length ? this.store.c[this.store.length - 1] : NaN
666 + this.ts.apply(this.ts.fitView(this.opts.fitBars))
667 + this.ts.stickToRight = true
668 + this.drawings.onDataChange()
669 + this.invalidate('data')
670 + }
671 +
672 + prependData(older) {
673 + const n = this.store.prepend(older || [])
674 + if (n) { this.ts.onPrepend(n); this._refreshDecimals(); this.drawings.onDataChange(); this.invalidate('data') }
675 + }
676 +
677 + appendData(newer) {
678 + const n = this.store.append(newer || [])
679 + if (!n) return
680 + this.ts.count = this.store.length
681 + if (this.ts.stickToRight) this.ts.scrollToLatest()
682 + this._lastClose = this.store.c[this.store.length - 1]
683 + this.invalidate('data')
684 + }
685 +
686 + updateLast(bar) {
687 + const r = this.store.updateLast(bar)
688 + if (r === 'ignored') return
689 + this.ts.count = this.store.length
690 + if (r === 'append' && this.ts.stickToRight) this.ts.scrollToLatest()
691 + if (bar.c !== this._lastClose) { this._pulseAt = performance.now(); this._lastClose = bar.c }
692 + this.invalidate('data')
693 + }
694 +
695 + getData() { return this.store.bars.slice() }
696 +
697 + setTimeframe(tf) { this.opts.timeframe = tf; this.invalidate('data') }
698 +
699 + _refreshDecimals() {
700 + const pf = this.opts.priceFormat
701 + this.decimals = pf.decimals === 'auto' || pf.decimals == null ? autoDecimals(this.store.bars, pf.minMove) : pf.decimals
702 + }
703 +
704 + setSeriesType(type) {
705 + if (!SERIES_TYPES.has(type)) throw new Error(`Unknown series type: ${type}`)
706 + this.seriesType = type
707 + this.invalidate('data')
708 + }
709 +
710 + setPriceScale({ mode, auto, invert } = {}) {
711 + const ps = this.mainPane.scale
712 + if (mode && ps.mode !== mode) {
713 + if (this.compares.size && mode !== 'percent') this._modeBeforeCompare = mode
714 + else ps.setMode(mode)
715 + }
716 + if (auto != null) { ps.auto = !!auto; if (ps.auto) ps._forceAnim = true }
717 + if (invert != null) ps.invert = !!invert
718 + this.emitter.emit('priceScaleChange', this.getPriceScale())
719 + this.invalidate('data')
720 + }
721 +
722 + getPriceScale() { const ps = this.mainPane.scale; return { mode: ps.mode, auto: ps.auto, invert: ps.invert } }
723 +
724 + setVolume(visible) {
725 + this.volumeVisible = !!visible
726 + this.mainPane.scale.marginBottom = this.volumeVisible ? 0.08 + this.opts.volumeFraction * 0.7 : 0.08
727 + this.invalidate('data')
728 + }
729 +
730 + setTheme(theme) {
731 + this.theme = normalizeTheme(theme)
732 + this.opts.theme = theme
733 + this.el.style.background = this.theme.bg
734 + this.invalidate('data')
735 + }
736 +
737 + setCrosshair({ mode, showLabels } = {}) {
738 + if (mode) this.crosshairOpts.mode = mode
739 + if (showLabels != null) this.crosshairOpts.showLabels = !!showLabels
740 + this.invalidate('overlay')
741 + }
742 +
743 + setOptions(partial = {}) {
744 + const prev = this.opts
745 + this.opts = { ...prev, ...partial, priceFormat: { ...prev.priceFormat, ...(partial.priceFormat || {}) } }
746 + if (partial.theme) this.setTheme(partial.theme)
747 + if (partial.rightOffsetBars != null) this.ts.rightOffsetBars = partial.rightOffsetBars
748 + if (partial.minBarSpacing != null) this.ts.minBarSpacing = partial.minBarSpacing
749 + if (partial.barSpacing != null) this.ts.defaultBarSpacing = partial.barSpacing
750 + if (partial.maxBars != null) this.store.maxBars = partial.maxBars
751 + if (partial.priceFormat) this._refreshDecimals()
752 + this.invalidate('data')
753 + }
754 +
755 + /* ─── indicators ─── */
756 +
757 + addIndicator({ type, params, pane, colors, id } = {}) {
758 + const spec = indicatorSpec(type)
759 + const finalId = id || `${type}-${++idSeq}`
760 + if (this.indicators.has(finalId)) this.removeIndicator(finalId)
761 + const where = pane || spec.pane
762 + const ind = { id: finalId, type, params: indicatorParams(type, params), spec, colors: colors || null, values: null, paneId: null }
763 + let target
764 + if (where === 'main') target = this.mainPane
765 + else {
766 + target = new Pane(this.el, { kind: 'indicator', weight: 0.28 })
767 + this.panes.push(target)
768 + this.dirty.layout = true
769 + }
770 + target.indicators.push(finalId)
771 + ind.paneId = target.id
772 + if (target.kind !== 'main') { target.title = spec.title(ind.params); if (spec.range) target.scale.fixedRange = { ...spec.range } }
773 + this.indicators.set(finalId, ind)
774 + this._computeIndicator(ind)
775 + this.invalidate('layout')
776 + return finalId
777 + }
778 +
779 + _computeIndicator(ind) {
780 + ind.values = this.store.length ? computeIndicator(ind.type, this.store.bars, ind.params) : null
781 + }
782 +
783 + updateIndicator(id, params) {
784 + const ind = this.indicators.get(id)
785 + if (!ind) return
786 + ind.params = indicatorParams(ind.type, { ...ind.params, ...(params || {}) })
787 + const p = this.panes.find(p => p.id === ind.paneId)
788 + if (p && p.kind !== 'main') p.title = ind.spec.title(ind.params)
789 + this._computeIndicator(ind)
790 + this.invalidate('data')
791 + }
792 +
793 + removeIndicator(id) {
794 + const ind = this.indicators.get(id)
795 + if (!ind) return
796 + this.indicators.delete(id)
797 + const p = this.panes.find(p => p.id === ind.paneId)
798 + if (p) {
799 + p.indicators = p.indicators.filter(x => x !== id)
800 + if (p.kind !== 'main' && p.indicators.length === 0) this._removePane(p)
801 + }
802 + this.invalidate('layout')
803 + }
804 +
805 + _removePane(p) {
806 + this.panes = this.panes.filter(x => x !== p)
807 + p.destroy()
808 + if (this.pointer && this.pointer.pane === p) this.pointer = null
809 + this.dirty.layout = true
810 + }
811 +
812 + /** Close button of an indicator pane. */
813 + closePane(p) {
814 + if (this.opts.onPaneClose) { this.opts.onPaneClose({ id: p.id, indicators: p.indicators.slice() }); return }
815 + for (const id of p.indicators.slice()) this.removeIndicator(id)
816 + }
817 +
818 + togglePane(p) { p.collapsed = !p.collapsed; this.invalidate('layout') }
819 +
820 + getIndicators() {
821 + return Array.from(this.indicators.values()).map(ind => ({
822 + id: ind.id, type: ind.type, params: { ...ind.params }, pane: ind.paneId === this.mainPane.id ? 'main' : ind.paneId,
823 + colors: ind.colors || ind.spec.plots.filter(p => p.key).map(p => plotColor(p.color, this.theme, null)),
824 + title: ind.spec.title(ind.params), values: ind.values || {},
825 + }))
826 + }
827 +
828 + /* ─── compares ─── */
829 +
830 + addCompare(id, label, bars, color) {
831 + const c = { id, label, bars: bars || [], color: color || null, aligned: new Float64Array(0), base: NaN }
832 + this._alignCompare(c)
833 + this.compares.set(id, c)
834 + const ps = this.mainPane.scale
835 + if (ps.mode !== 'percent') { this._modeBeforeCompare = ps.mode; ps.setMode('percent') }
836 + this.invalidate('data')
837 + }
838 +
839 + removeCompare(id) {
840 + if (!this.compares.delete(id)) return
841 + if (this.compares.size === 0 && this._modeBeforeCompare) {
842 + this.mainPane.scale.setMode(this._modeBeforeCompare)
843 + this._modeBeforeCompare = null
844 + this.emitter.emit('priceScaleChange', this.getPriceScale())
845 + }
846 + this.invalidate('data')
847 + }
848 +
849 + /** aligned[i] = close of the compare series at (or just before) the main bar i's time; NaN when none. */
850 + _alignCompare(c) {
851 + const n = this.store.length
852 + const out = new Float64Array(n).fill(NaN)
853 + const src = c.bars
854 + let j = 0
855 + for (let i = 0; i < n; i++) {
856 + const t = this.store.t[i]
857 + while (j + 1 < src.length && src[j + 1].t <= t) j++
858 + if (src.length && src[j].t <= t) out[i] = src[j].c
859 + }
860 + c.aligned = out
861 + }
862 +
863 + /* ─── drawings (delegated) ─── */
864 +
865 + setDrawingTool(tool) { this.drawings.setTool(tool) }
866 + getDrawings() { return this.drawings.toJSON() }
867 + setDrawings(list) { this.drawings.fromJSON(list) }
868 + clearDrawings() { this.drawings.clear() }
869 + deleteSelectedDrawing() { this.drawings.deleteSelected() }
870 + undo() { this.drawings.undo() }
871 + redo() { this.drawings.redo() }
872 +
873 + /* ─── navigation ─── */
874 +
875 + _animateView(target, animate) {
876 + const from = { barSpacing: this.ts.barSpacing, leftIndex: this.ts.leftIndex }
877 + this.kinetic = null
878 + if (!animate || this.opts.reducedMotion) { this.ts.apply(target); this.ts.stickToRight = this.ts.isAtLatest(); this.invalidate('data'); return }
879 + this.animator.tween({
880 + from, to: target, duration: 200, tag: 'view',
881 + apply: v => { this.ts.barSpacing = v.barSpacing; this.ts.leftIndex = v.leftIndex; this.dirty.data = true },
882 + done: () => { this.ts.apply(target); this.ts.stickToRight = this.ts.isAtLatest(); this.dirty.data = true },
883 + })
884 + this.invalidate('data')
885 + }
886 +
887 + setVisibleRange(r, animate = true) {
888 + if (!r) return
889 + let from, to
890 + if (r.fromIndex != null) { from = r.fromIndex; to = r.toIndex }
891 + else { from = this.store.indexOfTime(r.fromT); to = this.store.indexOfTime(r.toT) }
892 + if (!(to > from)) return
893 + this._animateView(this.ts.rangeToView(from, to, { withOffset: false }), animate)
894 + }
895 +
896 + getVisibleRange() {
897 + const vr = this.ts.visibleRange()
898 + return { fromIndex: vr.from, toIndex: vr.to, fromT: vr.to >= vr.from ? this.store.t[vr.from] : null, toT: vr.to >= vr.from ? this.store.t[vr.to] : null }
899 + }
900 +
901 + fitContent(animate = true) {
902 + this._animateView(this.ts.fitView(this.opts.fitBars), animate)
903 + }
904 +
905 + scrollToLatest(animate = true) {
906 + const right = this.ts.latestRightIndex()
907 + const bs = this.ts.barSpacing
908 + const leftIndex = right - (this.ts.width - bs / 2) / bs
909 + this._animateView({ barSpacing: bs, leftIndex }, animate)
910 + }
911 +
912 + zoom(factor, anchorX) {
913 + const ts = this.ts
914 + const ax = anchorX == null ? ts.width : anchorX
915 + const before = { barSpacing: ts.barSpacing, leftIndex: ts.leftIndex }
916 + if (!ts.zoomAt(factor, ax)) return
917 + const target = { barSpacing: ts.barSpacing, leftIndex: ts.leftIndex }
918 + ts.barSpacing = before.barSpacing; ts.leftIndex = before.leftIndex
919 + this._animateView(target, true)
920 + }
921 +
922 + resetView() {
923 + const ps = this.mainPane.scale
924 + ps.auto = true; ps.invert = false; ps._forceAnim = true
925 + for (const p of this.panes) { p.scale.auto = true; p.scale._forceAnim = true }
926 + this.fitContent(true)
927 + this.emitter.emit('priceScaleChange', this.getPriceScale())
928 + }
929 +
930 + toPNG(o) {
931 + // Make sure the layers reflect the latest state before composing.
932 + if (this._raf) { cancelAnimationFrame(this._raf); this._raf = 0 }
933 + this.dirty.data = true
934 + this._frame(performance.now())
935 + return exportPNG(this, o || {})
936 + }
937 +
938 + on(event, fn) { return this.emitter.on(event, fn) }
939 +
940 + destroy() {
941 + if (this._destroyed) return
942 + this._destroyed = true
943 + if (this._raf) cancelAnimationFrame(this._raf)
944 + this._raf = 0
945 + if (this._ro) this._ro.disconnect()
946 + this.interactions.destroy()
947 + this.drawings.destroy()
948 + this.animator.clear()
949 + for (const p of this.panes) p.destroy()
950 + this.timeAxis.main.destroy(); this.timeAxis.overlay.destroy(); this.timeAxis.el.remove()
951 + this.el.remove()
952 + this.emitter.clear()
953 + this.panes = []
954 + }
955 +}
956 +
957 +export { withAlpha }
added hfmarketdata/web/src/charts/engine/drawings/geometry.js +115 −0
@@ -0,0 +1,115 @@
1 +// Pixel-space geometry for drawings: distances and hit-testing. Pure functions (unit-tested).
2 +
3 +export const HANDLE_R = 6
4 +export const HIT_TOLERANCE = 6
5 +
6 +export function distToSegment(px, py, x1, y1, x2, y2) {
7 + const dx = x2 - x1, dy = y2 - y1
8 + const len2 = dx * dx + dy * dy
9 + let t = len2 === 0 ? 0 : ((px - x1) * dx + (py - y1) * dy) / len2
10 + t = Math.max(0, Math.min(1, t))
11 + return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy))
12 +}
13 +
14 +/** Distance to the infinite line through (x1,y1)-(x2,y2). */
15 +export function distToLine(px, py, x1, y1, x2, y2) {
16 + const dx = x2 - x1, dy = y2 - y1
17 + const len = Math.hypot(dx, dy)
18 + if (len === 0) return Math.hypot(px - x1, py - y1)
19 + return Math.abs(dy * px - dx * py + x2 * y1 - y2 * x1) / len
20 +}
21 +
22 +/** Distance to the ray starting at (x1,y1) towards (x2,y2). */
23 +export function distToRay(px, py, x1, y1, x2, y2) {
24 + const dx = x2 - x1, dy = y2 - y1
25 + const len2 = dx * dx + dy * dy
26 + if (len2 === 0) return Math.hypot(px - x1, py - y1)
27 + const t = ((px - x1) * dx + (py - y1) * dy) / len2
28 + if (t < 0) return Math.hypot(px - x1, py - y1)
29 + return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy))
30 +}
31 +
32 +/** Extend a line through two points to the borders of a [0,w]×[0,h] box. `mode`: 'ray' | 'extended' | 'segment'. */
33 +export function extendLine(x1, y1, x2, y2, w, h, mode) {
34 + if (mode === 'segment') return [x1, y1, x2, y2]
35 + const dx = x2 - x1, dy = y2 - y1
36 + if (dx === 0 && dy === 0) return [x1, y1, x2, y2]
37 + const far = (w + h) * 4
38 + const len = Math.hypot(dx, dy)
39 + const ux = dx / len, uy = dy / len
40 + const ex = x1 + ux * far, ey = y1 + uy * far
41 + if (mode === 'ray') return [x1, y1, ex, ey]
42 + return [x1 - ux * far, y1 - uy * far, ex, ey]
43 +}
44 +
45 +export function pointInRect(px, py, x1, y1, x2, y2, pad = 0) {
46 + const l = Math.min(x1, x2) - pad, r = Math.max(x1, x2) + pad
47 + const t = Math.min(y1, y2) - pad, b = Math.max(y1, y2) + pad
48 + return px >= l && px <= r && py >= t && py <= b
49 +}
50 +
51 +/** Distance to the border of a rectangle. */
52 +export function distToRectBorder(px, py, x1, y1, x2, y2) {
53 + return Math.min(
54 + distToSegment(px, py, x1, y1, x2, y1), distToSegment(px, py, x2, y1, x2, y2),
55 + distToSegment(px, py, x2, y2, x1, y2), distToSegment(px, py, x1, y2, x1, y1),
56 + )
57 +}
58 +
59 +/**
60 + * Hit-test a drawing given its pixel anchor points. Returns { part: 'handle', index } when a handle is hit,
61 + * { part: 'body' } for the shape itself, or null. `box` = { w, h } of the plot.
62 + */
63 +export function hitDrawing(type, pts, px, py, box, tol = HIT_TOLERANCE) {
64 + for (let i = 0; i < pts.length; i++) {
65 + if (type === 'brush') break
66 + if (Math.hypot(px - pts[i].x, py - pts[i].y) <= HANDLE_R + 2) return { part: 'handle', index: i }
67 + }
68 + const [a, b, c] = pts
69 + switch (type) {
70 + case 'trendline': case 'arrow': case 'measure':
71 + return distToSegment(px, py, a.x, a.y, b.x, b.y) <= tol ? { part: 'body' } : null
72 + case 'ray':
73 + return distToRay(px, py, a.x, a.y, b.x, b.y) <= tol ? { part: 'body' } : null
74 + case 'extended':
75 + return distToLine(px, py, a.x, a.y, b.x, b.y) <= tol ? { part: 'body' } : null
76 + case 'hline':
77 + return Math.abs(py - a.y) <= tol ? { part: 'body' } : null
78 + case 'vline':
79 + return Math.abs(px - a.x) <= tol ? { part: 'body' } : null
80 + case 'rect': case 'fib':
81 + if (type === 'fib') {
82 + const l = Math.min(a.x, b.x), r = Math.max(a.x, b.x)
83 + if (px < l - tol || px > r + tol) return null
84 + const lo = Math.min(a.y, b.y), hi = Math.max(a.y, b.y)
85 + return py >= lo - tol && py <= hi + tol ? { part: 'body' } : null
86 + }
87 + return distToRectBorder(px, py, a.x, a.y, b.x, b.y) <= tol || pointInRect(px, py, a.x, a.y, b.x, b.y) ? { part: 'body' } : null
88 + case 'text':
89 + return pointInRect(px, py, a.x - 4, a.y - 10, a.x + Math.max(40, (box.textW || 40)), a.y + 10) ? { part: 'body' } : null
90 + case 'channel': {
91 + if (distToSegment(px, py, a.x, a.y, b.x, b.y) <= tol) return { part: 'body' }
92 + if (!c) return null
93 + const ox = c.x - a.x, oy = c.y - a.y
94 + if (distToSegment(px, py, a.x + ox, a.y + oy, b.x + ox, b.y + oy) <= tol) return { part: 'body' }
95 + // Inside the parallelogram.
96 + const inside = pointInPoly(px, py, [[a.x, a.y], [b.x, b.y], [b.x + ox, b.y + oy], [a.x + ox, a.y + oy]])
97 + return inside ? { part: 'body' } : null
98 + }
99 + case 'brush': {
100 + for (let i = 1; i < pts.length; i++) if (distToSegment(px, py, pts[i - 1].x, pts[i - 1].y, pts[i].x, pts[i].y) <= tol) return { part: 'body' }
101 + return null
102 + }
103 + default:
104 + return null
105 + }
106 +}
107 +
108 +export function pointInPoly(px, py, poly) {
109 + let inside = false
110 + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
111 + const [xi, yi] = poly[i], [xj, yj] = poly[j]
112 + if ((yi > py) !== (yj > py) && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) inside = !inside
113 + }
114 + return inside
115 +}
added hfmarketdata/web/src/charts/engine/drawings/manager.js +444 −0
@@ -0,0 +1,444 @@
1 +// Drawing manager: tool state machine (click-click or drag to create), selection, handles, move/resize,
2 +// undo/redo (JSON snapshots), rendering on the main pane overlay.
3 +
4 +import { TOOLS, POINT_COUNT, FIB_LEVELS, newDrawing, serialize, deserialize } from './model.js'
5 +import { hitDrawing, extendLine, HANDLE_R } from './geometry.js'
6 +import { crisp, withAlpha, roundRect } from '../render/canvas.js'
7 +import { font, pill, measure, inkFor } from '../render/text.js'
8 +import { formatPercent } from '../format/number.js'
9 +import { fmtDuration, fmtFull } from '../format/time.js'
10 +
11 +const MAX_HISTORY = 100
12 +
13 +export class DrawingManager {
14 + constructor(chart) {
15 + this.chart = chart
16 + this.list = []
17 + this.tool = null
18 + this.creating = null // { type, points: [{t, price}], preview: {t, price} | null }
19 + this.selectedId = null
20 + this.hoverId = null
21 + this.drag = null // { id, part, index, startX, startY, orig }
22 + this.undoStack = []
23 + this.redoStack = []
24 + this._textW = 40
25 + }
26 +
27 + /* ─── coordinates ─── */
28 +
29 + toPixel(p) {
30 + const c = this.chart
31 + const i = c.store.indexOfTime(p.t)
32 + return { x: c.ts.x(i), y: c.mainPane.scale.y(p.price) }
33 + }
34 +
35 + fromPixel(x, y, snap = true) {
36 + const c = this.chart
37 + const idx = c.ts.indexAt(x)
38 + const t = c.store.timeAtIndex(idx)
39 + const price = snap ? c.snapPrice(x, y) : c.mainPane.scale.priceAt(y)
40 + return { t: t == null ? 0 : t, price }
41 + }
42 +
43 + /* ─── public API ─── */
44 +
45 + setTool(tool) {
46 + if (tool != null && !TOOLS.includes(tool)) throw new Error(`Unknown drawing tool: ${tool}`)
47 + this.tool = tool
48 + this.creating = null
49 + if (tool) this.selectedId = null
50 + this.chart.emitter.emit('toolChange', tool)
51 + this.chart.invalidate('overlay')
52 + }
53 +
54 + toJSON() { return serialize(this.list) }
55 +
56 + fromJSON(list) {
57 + this._push()
58 + this.list = deserialize(list)
59 + this.selectedId = null
60 + this._changed()
61 + }
62 +
63 + clear() {
64 + if (!this.list.length) return
65 + this._push()
66 + this.list = []
67 + this.selectedId = null
68 + this._changed()
69 + }
70 +
71 + deleteSelected() {
72 + if (!this.selectedId) return
73 + const d = this.list.find(x => x.id === this.selectedId)
74 + if (!d || d.locked) return
75 + this._push()
76 + this.list = this.list.filter(x => x.id !== this.selectedId)
77 + this.selectedId = null
78 + this._changed()
79 + }
80 +
81 + undo() {
82 + if (!this.undoStack.length) return
83 + this.redoStack.push(JSON.stringify(serialize(this.list)))
84 + this.list = deserialize(JSON.parse(this.undoStack.pop()))
85 + this.selectedId = null
86 + this._changed()
87 + }
88 +
89 + redo() {
90 + if (!this.redoStack.length) return
91 + this.undoStack.push(JSON.stringify(serialize(this.list)))
92 + this.list = deserialize(JSON.parse(this.redoStack.pop()))
93 + this.selectedId = null
94 + this._changed()
95 + }
96 +
97 + escape() {
98 + if (this.creating) { this.creating = null; this.chart.invalidate('overlay'); return }
99 + if (this.tool) { this.setTool(null); return }
100 + if (this.selectedId) { this.selectedId = null; this.chart.invalidate('overlay') }
101 + }
102 +
103 + onDataChange() { this.chart.invalidate('overlay') }
104 + destroy() { this.list = []; this.undoStack = []; this.redoStack = [] }
105 +
106 + _push() {
107 + this.undoStack.push(JSON.stringify(serialize(this.list)))
108 + if (this.undoStack.length > MAX_HISTORY) this.undoStack.shift()
109 + this.redoStack = []
110 + }
111 +
112 + _changed() {
113 + this.chart.emitter.emit('drawingsChange', this.toJSON())
114 + this.chart.invalidate('overlay')
115 + }
116 +
117 + /* ─── hit testing ─── */
118 +
119 + _pixels(d) { return d.points.map(p => this.toPixel(p)) }
120 +
121 + hitTest(x, y) {
122 + const box = { w: this.chart.plotWidth, h: this.chart.mainPane.height, textW: this._textW }
123 + // Selected drawing first (its handles take priority), then top-most.
124 + const order = this.list.slice().sort((a, b) => (a.id === this.selectedId ? -1 : b.id === this.selectedId ? 1 : 0))
125 + for (const d of order) {
126 + const hit = hitDrawing(d.type, this._pixels(d), x, y, box)
127 + if (hit) { if (hit.part === 'handle' && d.id !== this.selectedId) return { id: d.id, part: 'body' }; return { id: d.id, ...hit } }
128 + }
129 + return null
130 + }
131 +
132 + cursorAt(x, y) {
133 + if (this.drag) return this.drag.part === 'handle' ? 'grabbing' : 'move'
134 + if (this.tool) return 'crosshair'
135 + const h = this.hitTest(x, y)
136 + if (!h) return null
137 + return h.part === 'handle' ? 'grab' : 'pointer'
138 + }
139 +
140 + /* ─── pointer state machine (coordinates relative to the main pane plot) ─── */
141 +
142 + pointerDown(x, y, ev) {
143 + if (this.tool) {
144 + const p = this.fromPixel(x, y)
145 + if (!this.creating) {
146 + this.creating = { type: this.tool, points: [p], preview: null, downX: x, downY: y, dragging: true }
147 + if (POINT_COUNT[this.tool] === 1) { this._commitCreating(); return true }
148 + return true
149 + }
150 + // Second / third click.
151 + this.creating.points.push(p)
152 + this.creating.dragging = true
153 + this.creating.downX = x; this.creating.downY = y
154 + if (this.creating.points.length >= POINT_COUNT[this.creating.type]) this._commitCreating()
155 + return true
156 + }
157 + const hit = this.hitTest(x, y)
158 + if (!hit) {
159 + if (this.selectedId) { this.selectedId = null; this.chart.invalidate('overlay') }
160 + return false
161 + }
162 + const d = this.list.find(z => z.id === hit.id)
163 + this.selectedId = hit.id
164 + if (!d.locked) this.drag = { id: hit.id, part: hit.part, index: hit.index, startX: x, startY: y, orig: d.points.map(p => ({ ...p })), moved: false, snapshot: JSON.stringify(serialize(this.list)) }
165 + this.chart.invalidate('overlay')
166 + void ev
167 + return true
168 + }
169 +
170 + pointerMove(x, y, ev) {
171 + if (this.creating) {
172 + const p = this.fromPixel(x, y)
173 + const cr = this.creating
174 + if (cr.type === 'brush') { if (cr.dragging) cr.points.push(p) }
175 + else cr.preview = p
176 + this.chart.invalidate('overlay')
177 + return true
178 + }
179 + if (this.drag) {
180 + const dg = this.drag
181 + const d = this.list.find(z => z.id === dg.id)
182 + if (!d) { this.drag = null; return false }
183 + if (!dg.moved && Math.hypot(x - dg.startX, y - dg.startY) < 2) return true
184 + dg.moved = true
185 + if (dg.part === 'handle') {
186 + const p = this.fromPixel(x, y)
187 + d.points[dg.index] = p
188 + } else {
189 + // Move: translate every point by the pixel delta (in index/price space).
190 + const c = this.chart
191 + const di = (x - dg.startX) / c.ts.barSpacing
192 + const ps = c.mainPane.scale
193 + const dPix = y - dg.startY
194 + d.points = dg.orig.map(o => {
195 + const i0 = c.store.indexOfTime(o.t)
196 + const t = c.store.timeAtIndex(i0 + di)
197 + const yy = ps.y(o.price) + dPix
198 + return { t: t == null ? o.t : t, price: ps.priceAt(yy) }
199 + })
200 + }
201 + this.chart.invalidate('overlay')
202 + return true
203 + }
204 + const h = this.hitTest(x, y)
205 + const id = h ? h.id : null
206 + if (id !== this.hoverId) { this.hoverId = id; this.chart.invalidate('overlay') }
207 + void ev
208 + return false
209 + }
210 +
211 + pointerUp(x, y) {
212 + if (this.creating) {
213 + const cr = this.creating
214 + if (cr.type === 'brush') { cr.dragging = false; if (cr.points.length >= 2) this._commitCreating(); else this.creating = null; return true }
215 + const moved = Math.hypot(x - cr.downX, y - cr.downY) > 4
216 + if (moved && cr.dragging) {
217 + cr.points.push(this.fromPixel(x, y))
218 + if (cr.points.length >= POINT_COUNT[cr.type]) this._commitCreating()
219 + else cr.preview = null
220 + }
221 + cr.dragging = false
222 + return true
223 + }
224 + if (this.drag) {
225 + const dg = this.drag
226 + this.drag = null
227 + if (dg.moved) {
228 + this.undoStack.push(dg.snapshot)
229 + if (this.undoStack.length > MAX_HISTORY) this.undoStack.shift()
230 + this.redoStack = []
231 + this._changed()
232 + }
233 + return true
234 + }
235 + return false
236 + }
237 +
238 + _commitCreating() {
239 + const cr = this.creating
240 + this.creating = null
241 + if (!cr) return
242 + const d = newDrawing(cr.type, cr.points, { text: cr.type === 'text' ? 'Text' : undefined })
243 + this._push()
244 + this.list.push(d)
245 + this.selectedId = cr.type === 'measure' ? null : d.id
246 + // Measure is transient: it is removed as soon as the tool is used again or deselected — keep it as a
247 + // normal drawing here (callers may filter type === 'measure' before persisting).
248 + this.setToolAfterCreate()
249 + this._changed()
250 + }
251 +
252 + setToolAfterCreate() {
253 + // One shape per activation, like most terminals; brush stays active.
254 + if (this.tool !== 'brush') { this.tool = null; this.chart.emitter.emit('toolChange', null) }
255 + }
256 +
257 + /* ─── rendering ─── */
258 +
259 + draw(ctx, g) {
260 + const { theme } = g
261 + for (const d of this.list) this._drawOne(ctx, g, d, d.id === this.selectedId, d.id === this.hoverId)
262 + if (this.creating) {
263 + const cr = this.creating
264 + const pts = cr.points.slice()
265 + if (cr.preview && cr.type !== 'brush') pts.push(cr.preview)
266 + if (pts.length >= 1) {
267 + const need = POINT_COUNT[cr.type]
268 + const tmp = { type: cr.type, points: pts, style: { width: 1 }, text: cr.type === 'text' ? 'Text' : undefined }
269 + if (need === Infinity || pts.length >= Math.min(need, 2) || need === 1) this._drawOne(ctx, g, tmp, true, false, true)
270 + else this._drawHandles(ctx, pts.map(p => this.toPixel(p)), theme)
271 + }
272 + }
273 + }
274 +
275 + _drawOne(ctx, g, d, selected, hovered, preview = false) {
276 + const { theme, width: W, height: H } = g
277 + const pts = this._pixels(d)
278 + const color = (d.style && d.style.color) || theme.drawing
279 + const lw = (d.style && d.style.width) || 1
280 + ctx.save()
281 + ctx.strokeStyle = color
282 + ctx.fillStyle = color
283 + ctx.lineWidth = hovered && !selected ? lw + 1 : lw
284 + ctx.lineJoin = 'round'; ctx.lineCap = 'round'
285 + if (d.style && d.style.dash) ctx.setLineDash(d.style.dash)
286 + if (preview) ctx.globalAlpha = 0.85
287 + const [a, b, c] = pts
288 + switch (d.type) {
289 + case 'trendline': case 'ray': case 'extended': {
290 + const mode = d.type === 'trendline' ? 'segment' : d.type
291 + const [x1, y1, x2, y2] = extendLine(a.x, a.y, b.x, b.y, W, H, mode)
292 + line(ctx, x1, y1, x2, y2)
293 + break
294 + }
295 + case 'arrow': {
296 + line(ctx, a.x, a.y, b.x, b.y)
297 + arrowHead(ctx, a.x, a.y, b.x, b.y, 9 + lw * 2)
298 + break
299 + }
300 + case 'hline': {
301 + ctx.setLineDash(d.style && d.style.dash ? d.style.dash : [])
302 + line(ctx, 0, crisp(a.y), W, crisp(a.y))
303 + pill(ctx, this.chart._formatMain(d.points[0].price), W + 4, a.y, { bg: color, color: inkFor(color), fontStr: font(theme, { mono: true, size: 11 }), h: 18, padX: 4, clampTo: { x0: W + 2, x1: W + this.chart.axisWidth - 1, y0: 0, y1: H } })
304 + break
305 + }
306 + case 'vline': {
307 + line(ctx, crisp(a.x), 0, crisp(a.x), H)
308 + const t = d.points[0].t
309 + pill(ctx, fmtFull(t, this.chart.opts.timeframe, this.chart.opts.sessionLabel), a.x + 6, 12, { bg: withAlpha(color, 0.9), color: inkFor(color), fontStr: font(theme, { mono: true, size: 10 }), h: 16, padX: 4, clampTo: { x0: 0, x1: W, y0: 0, y1: H } })
310 + break
311 + }
312 + case 'rect': {
313 + const l = Math.min(a.x, b.x), t = Math.min(a.y, b.y), w = Math.abs(b.x - a.x), h = Math.abs(b.y - a.y)
314 + ctx.fillStyle = withAlpha(color, 0.12); ctx.fillRect(l, t, w, h)
315 + ctx.strokeRect(crisp(l), crisp(t), Math.round(w), Math.round(h))
316 + break
317 + }
318 + case 'channel': {
319 + line(ctx, a.x, a.y, b.x, b.y)
320 + if (c) {
321 + const ox = c.x - a.x, oy = c.y - a.y
322 + line(ctx, a.x + ox, a.y + oy, b.x + ox, b.y + oy)
323 + ctx.fillStyle = withAlpha(color, 0.10)
324 + ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(b.x + ox, b.y + oy); ctx.lineTo(a.x + ox, a.y + oy); ctx.closePath(); ctx.fill()
325 + ctx.setLineDash([3, 3]); ctx.globalAlpha *= 0.6
326 + line(ctx, a.x + ox / 2, a.y + oy / 2, b.x + ox / 2, b.y + oy / 2)
327 + ctx.setLineDash([]); ctx.globalAlpha = preview ? 0.85 : 1
328 + }
329 + break
330 + }
331 + case 'brush': {
332 + ctx.lineWidth = Math.max(1.5, lw)
333 + ctx.beginPath()
334 + pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)))
335 + ctx.stroke()
336 + break
337 + }
338 + case 'fib': this._drawFib(ctx, g, d, pts, color); break
339 + case 'measure': this._drawMeasure(ctx, g, d, pts, theme); break
340 + case 'text': {
341 + const text = d.text || 'Text'
342 + const f = font(theme, { size: 12, weight: 500 })
343 + this._textW = measure(ctx, text, f) + 8
344 + ctx.font = f
345 + ctx.fillStyle = withAlpha(theme.bg, 0.75)
346 + roundRect(ctx, a.x - 4, a.y - 10, this._textW, 20, 3); ctx.fill()
347 + ctx.fillStyle = color
348 + ctx.textBaseline = 'middle'; ctx.textAlign = 'left'
349 + ctx.fillText(text, a.x, a.y + 0.5)
350 + break
351 + }
352 + default: break
353 + }
354 + ctx.restore()
355 + if (selected && d.type !== 'brush') this._drawHandles(ctx, pts, theme, d.locked)
356 + else if (selected && d.type === 'brush') { ctx.save(); ctx.strokeStyle = theme.selection; ctx.lineWidth = 6; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.beginPath(); pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y))); ctx.stroke(); ctx.restore() }
357 + }
358 +
359 + _drawHandles(ctx, pts, theme, locked = false) {
360 + for (const p of pts) {
361 + ctx.beginPath(); ctx.arc(p.x, p.y, HANDLE_R + 4, 0, Math.PI * 2)
362 + ctx.fillStyle = theme.selection; ctx.fill()
363 + ctx.beginPath(); ctx.arc(p.x, p.y, HANDLE_R / 2 + 0.5, 0, Math.PI * 2)
364 + ctx.fillStyle = locked ? theme.textMuted : theme.drawingHandle; ctx.fill()
365 + ctx.lineWidth = 1.5; ctx.strokeStyle = theme.drawing; ctx.stroke()
366 + }
367 + }
368 +
369 + _drawFib(ctx, g, d, pts, color) {
370 + const { theme, width: W } = g
371 + const [a, b] = pts
372 + const p0 = d.points[0].price, p1 = d.points[1].price
373 + const l = Math.min(a.x, b.x), r = Math.max(a.x, b.x)
374 + const ps = this.chart.mainPane.scale
375 + const f = font(theme, { mono: true, size: 10 })
376 + ctx.font = f; ctx.textBaseline = 'middle'
377 + const levels = d.style && d.style.extended ? [...FIB_LEVELS, 1.618] : FIB_LEVELS
378 + let prevY = null
379 + levels.forEach((lv, i) => {
380 + const price = p0 + (p1 - p0) * lv
381 + const y = ps.y(price)
382 + const c = theme.series[i % theme.series.length]
383 + if (prevY != null) { ctx.fillStyle = withAlpha(c, 0.06); ctx.fillRect(l, Math.min(prevY, y), r - l, Math.abs(y - prevY)) }
384 + ctx.strokeStyle = withAlpha(c, 0.9); ctx.lineWidth = 1
385 + line(ctx, l, crisp(y), r, crisp(y))
386 + const label = `${lv.toFixed(3).replace(/0+$/, '').replace(/\.$/, '')} ${this.chart._formatMain(price)}`
387 + ctx.fillStyle = c
388 + ctx.textAlign = 'left'
389 + ctx.fillText(label, Math.min(r + 6, W - 120), y)
390 + prevY = y
391 + })
392 + ctx.strokeStyle = withAlpha(color, 0.5); ctx.setLineDash([3, 3])
393 + line(ctx, a.x, a.y, b.x, b.y)
394 + ctx.setLineDash([])
395 + }
396 +
397 + _drawMeasure(ctx, g, d, pts, theme) {
398 + const { width: W, height: H } = g
399 + const [a, b] = pts
400 + const up = d.points[1].price >= d.points[0].price
401 + const color = up ? theme.up : theme.down
402 + const l = Math.min(a.x, b.x), t = Math.min(a.y, b.y), w = Math.abs(b.x - a.x), h = Math.abs(b.y - a.y)
403 + ctx.fillStyle = withAlpha(color, 0.12); ctx.fillRect(l, t, w, h)
404 + ctx.strokeStyle = withAlpha(color, 0.8); ctx.lineWidth = 1; ctx.setLineDash([3, 3])
405 + ctx.strokeRect(crisp(l), crisp(t), Math.round(w), Math.round(h))
406 + ctx.setLineDash([])
407 + // Arrow along the vertical move.
408 + const mx = l + w / 2
409 + ctx.strokeStyle = color; ctx.lineWidth = 1.25
410 + line(ctx, mx, a.y, mx, b.y); arrowHead(ctx, mx, a.y, mx, b.y, 8)
411 + const c = this.chart
412 + const dp = d.points[1].price - d.points[0].price
413 + const pct = d.points[0].price ? (dp / d.points[0].price) * 100 : 0
414 + const i0 = c.store.indexOfTime(d.points[0].t), i1 = c.store.indexOfTime(d.points[1].t)
415 + const bars = Math.round(i1 - i0)
416 + const lines = [
417 + `${dp >= 0 ? '+' : '−'}${c._formatMain(Math.abs(dp))} (${formatPercent(pct, 2, c.opts.locale)})`,
418 + `${bars} bars · ${fmtDuration(d.points[1].t - d.points[0].t)}`,
419 + ]
420 + const f = font(theme, { mono: true, size: 11, weight: 500 })
421 + ctx.font = f
422 + const bw = Math.max(...lines.map(s => measure(ctx, s, f))) + 16
423 + const bh = 18 * lines.length + 6
424 + let bx = mx - bw / 2, by = up ? t - bh - 8 : t + h + 8
425 + bx = Math.max(2, Math.min(W - bw - 2, bx)); by = Math.max(2, Math.min(H - bh - 2, by))
426 + ctx.fillStyle = color
427 + roundRect(ctx, bx, by, bw, bh, 4); ctx.fill()
428 + ctx.fillStyle = inkFor(color); ctx.textBaseline = 'middle'; ctx.textAlign = 'center'
429 + lines.forEach((s, i) => ctx.fillText(s, bx + bw / 2, by + 3 + 9 + i * 18))
430 + ctx.textAlign = 'left'
431 + }
432 +}
433 +
434 +function line(ctx, x1, y1, x2, y2) { ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke() }
435 +
436 +function arrowHead(ctx, x1, y1, x2, y2, size) {
437 + const ang = Math.atan2(y2 - y1, x2 - x1)
438 + ctx.beginPath()
439 + ctx.moveTo(x2, y2)
440 + ctx.lineTo(x2 - size * Math.cos(ang - Math.PI / 7), y2 - size * Math.sin(ang - Math.PI / 7))
441 + ctx.lineTo(x2 - size * Math.cos(ang + Math.PI / 7), y2 - size * Math.sin(ang + Math.PI / 7))
442 + ctx.closePath()
443 + ctx.fill()
444 +}
added hfmarketdata/web/src/charts/engine/drawings/model.js +57 −0
Large diff (57 lines) is not loaded by default to keep the page fast.Load diff
added hfmarketdata/web/src/charts/engine/export/png.js +52 −0
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
added hfmarketdata/web/src/charts/engine/index.js +20 −0
Large diff (20 lines) is not loaded by default to keep the page fast.Load diff
added hfmarketdata/web/src/charts/engine/interactions/pointer.js +308 −0
Large diff (308 lines) is not loaded by default to keep the page fast.Load diff
added hfmarketdata/web/src/charts/engine/panes/pane.js +49 −0
Large diff (49 lines) is not loaded by default to keep the page fast.Load diff
added hfmarketdata/web/src/charts/engine/render/axes.js +100 −0
Large diff (100 lines) is not loaded by default to keep the page fast.Load diff
added hfmarketdata/web/src/charts/engine/render/overlay.js +123 −0
Large diff (123 lines) is not loaded by default to keep the page fast.Load diff
added hfmarketdata/web/src/charts/engine/render/plots.js +148 −0
Large diff (148 lines) is not loaded by default to keep the page fast.Load diff
added hfmarketdata/web/src/charts/engine/render/series.js +175 −0
Large diff (175 lines) is not loaded by default to keep the page fast.Load diff
modified hfmarketdata/web/src/charts/engine/scales/price-scale.js +9 −4
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme