SPB Git forge

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)

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%

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

Simon-Pierre Boucher committed 18 days ago (Sep 7, 2026) parent 068b577

17 changed files +2,965 −5

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