SPB Git

spb/hfmarketdata

Public

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

charts: fonctions v2 — marqueurs de série, lignes de prix, ombrage de sessions, bar replay (setReplay/stepReplay), setRightOffset/setBarSpacing/setPriceScaleWidth/setTimeAxisVisible/setGrid/setBackground, toPNG legend, toCSV, describeVisible, émetteur multi-arguments, démo features du harnais

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 7, 2026) parent ff5d73f Browse files

3 changed files +203 −12

modified hfmarketdata/web/dev/charts-harness.js +26 −0
@@ -333,6 +333,32 @@ if (q.get('drawings') === '3') {
333 ]) 333 ])
334 } 334 }
335 if (q.get('live') === '1') liveBtn.click() 335 if (q.get('live') === '1') liveBtn.click()
336+if (q.get('features') === '1') {
337+ // v2 chart features showcase: markers, price lines, session shading, gradient background, replay, grid, offsets.
338+ const data = chart.getData(); const n = data.length; const at = i => data[Math.max(0, Math.min(n - 1, i))]
339+ chart.setMarkers([
340+ { t: at(n - 60).t, position: 'below', shape: 'arrowUp', text: 'Buy' },
341+ { t: at(n - 35).t, position: 'above', shape: 'arrowDown', text: 'Sell' },
342+ { t: at(n - 20).t, position: 'above', shape: 'circle', color: '#c98500', text: 'News' },
343+ { t: at(n - 90).t, position: 'below', shape: 'square', color: '#9085e9' },
344+ ])
345+ chart.addPriceLine({ price: at(n - 1).c * 1.006, color: '#e5484d', title: 'Resistance', style: 'dashed' })
346+ chart.addPriceLine({ price: at(n - 1).c * 0.994, color: '#2fbf71', title: 'Support', style: 'dotted', width: 2 })
347+ chart.setSessions({ regular: [10, 15.5], shade: true })
348+ chart.setBackground({ gradient: ['#0d1017', '#141a26'] })
349+ chart.setGrid({ v: false, h: true })
350+ chart.setRightOffset(14)
351+ chart.setReplay({ index: n - 30 })
352+ chart.on('replayChange', r => status(`replay ${r.index + 1}/${r.total} · ${chart.describeVisible()}`))
353+ status(chart.describeVisible())
354+}
355+g = group()
356+btn(g, '⏮', () => chart.setReplay({ index: Math.max(0, chart.getData().length - 200) }), 'desktop-only')
357+btn(g, '◂', () => chart.stepReplay(-1), 'desktop-only'); btn(g, '▸', () => chart.stepReplay(1), 'desktop-only')
358+btn(g, '⏭', () => chart.setReplay(null), 'desktop-only')
359+btn(g, 'CSV', () => { const blob = new Blob([chart.toCSV(true)], { type: 'text/csv' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'visible.csv'; a.click() }, 'desktop-only')
360+btn(g, 'Describe', () => status(chart.describeVisible()), 'desktop-only')
361+btn(g, 'PNG+legend', async () => { const blob = await chart.toPNG({ scale: 2, legend: true, watermark: `${state.symbol} · ${state.tf}` }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'chart-legend.png'; a.click() }, 'desktop-only')
336 362
337 /* ───────────── benchmark ───────────── */ 363 /* ───────────── benchmark ───────────── */
338 364
modified hfmarketdata/web/src/charts/engine/core/chart.js +153 −10
@@ -10,7 +10,7 @@ import { PriceScale } from '../scales/price-scale.js'
10 import { createLayer, crisp, withAlpha, hair, lw, snap } from '../render/canvas.js' 10 import { createLayer, crisp, withAlpha, hair, lw, snap } from '../render/canvas.js'
11 import { font, measure, FONT_SIZE } from '../render/text.js' 11 import { font, measure, FONT_SIZE } from '../render/text.js'
12 import { drawSeries, drawVolume } from '../render/series.js' 12 import { drawSeries, drawVolume } from '../render/series.js'
13−import { drawGrid, drawSessionBreaks, drawPriceAxis, drawTimeAxis, measureAxisWidth, TIME_AXIS_H, MIN_AXIS_W } from '../render/axes.js' 13+import { drawGrid, drawSessionBreaks, drawSessionShade, drawPriceAxis, drawTimeAxis, measureAxisWidth, TIME_AXIS_H, MIN_AXIS_W } from '../render/axes.js'
14 import { drawPlots, valuePath, plotColor } from '../render/plots.js' 14 import { drawPlots, valuePath, plotColor } from '../render/plots.js'
15 import { drawCrosshair, drawAxisLabel, drawTimeLabel, drawLastPrice, drawHiLoMarkers, drawWatermark, drawPaneHeader } from '../render/overlay.js' 15 import { drawCrosshair, drawAxisLabel, drawTimeLabel, drawLastPrice, drawHiLoMarkers, drawWatermark, drawPaneHeader } from '../render/overlay.js'
16 import { normalizeTheme, darkTheme } from '../theme.js' 16 import { normalizeTheme, darkTheme } from '../theme.js'
@@ -54,6 +54,7 @@ export class Chart {
54 this.emitter = new Emitter() 54 this.emitter = new Emitter()
55 this.animator = new Animator() 55 this.animator = new Animator()
56 this.store = new BarStore(this.opts.maxBars) 56 this.store = new BarStore(this.opts.maxBars)
57+ this.fullStore = this.store // v2 bar replay: `store` is a truncated view while replaying
57 this.renderStore = this.store 58 this.renderStore = this.store
58 this._haVersion = -1 59 this._haVersion = -1
59 this.ts = new TimeScale({ barSpacing: this.opts.barSpacing, minBarSpacing: this.opts.minBarSpacing, rightOffsetBars: this.opts.rightOffsetBars }) 60 this.ts = new TimeScale({ barSpacing: this.opts.barSpacing, minBarSpacing: this.opts.minBarSpacing, rightOffsetBars: this.opts.rightOffsetBars })
@@ -75,6 +76,8 @@ export class Chart {
75 this.priceLines = new Map() // v2: custom horizontal price lines 76 this.priceLines = new Map() // v2: custom horizontal price lines
76 this.replayIndex = null // v2: bar replay — bars after this index are hidden 77 this.replayIndex = null // v2: bar replay — bars after this index are hidden
77 this.sessions = null // v2: { regular: [startHour, endHour], shade: true } 78 this.sessions = null // v2: { regular: [startHour, endHour], shade: true }
79+ this.background = null // v2: color | { gradient: [c0, c1, …] } (null = theme.bg)
80+ this.fixedAxisWidth = null // v2: setPriceScaleWidth(n)
78 this.width = 0; this.height = 0; this.dpr = 1 81 this.width = 0; this.height = 0; this.dpr = 1
79 this.dirty = { layout: true, data: true, overlay: true } 82 this.dirty = { layout: true, data: true, overlay: true }
80 this._raf = 0 83 this._raf = 0
@@ -377,7 +380,8 @@ export class Chart {
377 if (need > w) w = need 380 if (need > w) w = need
378 } 381 }
379 w = Math.ceil(w / 4) * 4 382 w = Math.ceil(w / 4) * 4
380− if (w > this.axisWidth || w < this.axisWidth - 12) { 383+ if (this.fixedAxisWidth != null) w = this.fixedAxisWidth
384+ if (w > this.axisWidth || w < this.axisWidth - 12 || w !== this.axisWidth && this.fixedAxisWidth != null) {
381 this.axisWidth = w 385 this.axisWidth = w
382 this.ts.width = this.plotWidth 386 this.ts.width = this.plotWidth
383 } 387 }
@@ -411,7 +415,7 @@ export class Chart {
411 const ctx = layer.ctx 415 const ctx = layer.ctx
412 layer.clear() 416 layer.clear()
413 const W = this.plotWidth, H = p.height, theme = this.theme 417 const W = this.plotWidth, H = p.height, theme = this.theme
414− ctx.fillStyle = theme.bg 418+ ctx.fillStyle = this._bgStyle(ctx, p)
415 ctx.fillRect(0, 0, this.width, H) 419 ctx.fillRect(0, 0, this.width, H)
416 if (p.collapsed) { 420 if (p.collapsed) {
417 this._drawPriceAxisFor(p, []) 421 this._drawPriceAxisFor(p, [])
@@ -424,7 +428,7 @@ export class Chart {
424 ctx.beginPath(); ctx.rect(0, 0, W, H); ctx.clip() 428 ctx.beginPath(); ctx.rect(0, 0, W, H); ctx.clip()
425 drawGrid(ctx, { width: W, height: H, priceTicks: ticks, timeTicks: this._timeTicks || [], theme, showV: this.gridOpts.v, showH: this.gridOpts.h }) 429 drawGrid(ctx, { width: W, height: H, priceTicks: ticks, timeTicks: this._timeTicks || [], theme, showV: this.gridOpts.v, showH: this.gridOpts.h })
426 if (p.kind === 'main') { 430 if (p.kind === 'main') {
427− if (isIntraday(this.opts.timeframe)) this._drawSessionBreaks(ctx, vr, H) 431+ if (isIntraday(this.opts.timeframe)) { this._drawSessionShade(ctx, vr, H); this._drawSessionBreaks(ctx, vr, H) }
428 drawWatermark(ctx, { text: this.opts.watermark, width: W, height: H, theme }) 432 drawWatermark(ctx, { text: this.opts.watermark, width: W, height: H, theme })
429 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) } 433 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) }
430 if (this.volumeVisible) drawVolume(g, this.opts.volumeFraction) 434 if (this.volumeVisible) drawVolume(g, this.opts.volumeFraction)
@@ -453,6 +457,40 @@ export class Chart {
453 drawPriceAxis(ctx, { x0: 0, width: this.leftAxisWidth, height: p.height, ticks, theme: this.theme, side: 'left' }) 457 drawPriceAxis(ctx, { x0: 0, width: this.leftAxisWidth, height: p.height, ticks, theme: this.theme, side: 'left' })
454 } 458 }
455 459
460+ /** Background: a color or a vertical gradient spanning the whole chart (panes get their slice). */
461+ _bgStyle(ctx, p) {
462+ const bg = this.background
463+ if (!bg) return this.theme.bg
464+ if (typeof bg === 'string') return bg
465+ if (Array.isArray(bg.gradient) && bg.gradient.length >= 2) {
466+ const total = Math.max(1, this.height - this.timeAxisH)
467+ const g = ctx.createLinearGradient(0, -p.top, 0, total - p.top)
468+ bg.gradient.forEach((c, i) => g.addColorStop(i / (bg.gradient.length - 1), c))
469+ return g
470+ }
471+ return this.theme.bg
472+ }
473+
474+ /** v2 sessions: shade bars outside the regular session (intraday). */
475+ _drawSessionShade(ctx, vr, H) {
476+ const s = this.sessions
477+ if (!s || !s.shade || !Array.isArray(s.regular) || vr.to < vr.from) return
478+ const [h0, h1] = s.regular
479+ const T = this.store.t
480+ const spans = []
481+ let open = null
482+ const bs = this.ts.barSpacing
483+ for (let i = vr.from; i <= vr.to; i++) {
484+ const d = new Date(T[i]); const h = d.getUTCHours() + d.getUTCMinutes() / 60
485+ const ext = h < h0 || h >= h1
486+ if (ext && open === null) open = this.ts.x(i) - bs / 2
487+ if (!ext && open !== null) { spans.push({ x0: open, x1: this.ts.x(i) - bs / 2 }); open = null }
488+ }
489+ if (open !== null) spans.push({ x0: open, x1: this.ts.x(vr.to) + bs / 2 })
490+ if (spans.length > 400) return
491+ drawSessionShade(ctx, { spans, height: H, theme: this.theme })
492+ }
493+
456 _drawPaneBorder(p) { 494 _drawPaneBorder(p) {
457 const ctx = p.main.ctx 495 const ctx = p.main.ctx
458 if (p !== this.panes[0]) { 496 if (p !== this.panes[0]) {
@@ -803,7 +841,9 @@ export class Chart {
803 /* ═══════════════════════════ PUBLIC API ═══════════════════════════ */ 841 /* ═══════════════════════════ PUBLIC API ═══════════════════════════ */
804 842
805 setData(bars) { 843 setData(bars) {
806− this.store.set(bars || []) 844+ this.fullStore.set(bars || [])
845+ this.replayIndex = null
846+ this.store = this.fullStore
807 this.ts.count = this.store.length 847 this.ts.count = this.store.length
808 this.ts.width = this.plotWidth 848 this.ts.width = this.plotWidth
809 this._refreshDecimals() 849 this._refreshDecimals()
@@ -817,13 +857,14 @@ export class Chart {
817 } 857 }
818 858
819 prependData(older) { 859 prependData(older) {
820− const n = this.store.prepend(older || []) 860+ const n = this.fullStore.prepend(older || [])
821− if (n) { this.ts.onPrepend(n); this._refreshDecimals(); this.drawings.onDataChange(); this.invalidate('data') } 861+ if (n) { if (this.replayIndex != null) { this.replayIndex += n; this._applyReplay() } this.ts.onPrepend(n); this._refreshDecimals(); this.drawings.onDataChange(); this.invalidate('data') }
822 } 862 }
823 863
824 appendData(newer) { 864 appendData(newer) {
825− const n = this.store.append(newer || []) 865+ const n = this.fullStore.append(newer || [])
826 if (!n) return 866 if (!n) return
867+ if (this.replayIndex != null) { this._applyReplay(); this.invalidate('data'); return }
827 this.ts.count = this.store.length 868 this.ts.count = this.store.length
828 if (this.ts.stickToRight) this.ts.scrollToLatest() 869 if (this.ts.stickToRight) this.ts.scrollToLatest()
829 this._lastClose = this.store.c[this.store.length - 1] 870 this._lastClose = this.store.c[this.store.length - 1]
@@ -831,15 +872,51 @@ export class Chart {
831 } 872 }
832 873
833 updateLast(bar) { 874 updateLast(bar) {
834− const r = this.store.updateLast(bar) 875+ const r = this.fullStore.updateLast(bar)
835 if (r === 'ignored') return 876 if (r === 'ignored') return
877+ if (this.replayIndex != null) { this._applyReplay(); this.invalidate('data'); return }
836 this.ts.count = this.store.length 878 this.ts.count = this.store.length
837 if (r === 'append' && this.ts.stickToRight) this.ts.scrollToLatest() 879 if (r === 'append' && this.ts.stickToRight) this.ts.scrollToLatest()
838 if (bar.c !== this._lastClose) { this._pulseAt = performance.now(); this._lastClose = bar.c } 880 if (bar.c !== this._lastClose) { this._pulseAt = performance.now(); this._lastClose = bar.c }
839 this.invalidate('data') 881 this.invalidate('data')
840 } 882 }
841 883
842− getData() { return this.store.bars.slice() } 884+ /** Full data set (replay does not hide anything here). */
885+ getData() { return this.fullStore.bars.slice() }
886+
887+ /* ─── v2: bar replay ─── */
888+
889+ /** Hide every bar after `index` (rendering AND indicators see only bars 0…index). `null` / `{ index: null }` exits replay. */
890+ setReplay(o) {
891+ const index = o == null ? null : typeof o === 'number' ? o : o.index
892+ const n = this.fullStore.length
893+ if (index == null || !n) this.replayIndex = null
894+ else this.replayIndex = Math.max(0, Math.min(n - 1, Math.round(index)))
895+ this._applyReplay()
896+ this.ts.count = this.store.length
897+ if (this.replayIndex != null) this.ts.stickToRight = false
898+ this.invalidate('data')
899+ this.emitter.emit('replayChange', this.getReplay())
900+ }
901+
902+ stepReplay(n = 1) {
903+ if (this.replayIndex == null) { this.setReplay({ index: Math.max(0, this.fullStore.length - 1 - Math.abs(n)) }); return }
904+ this.setReplay({ index: this.replayIndex + n })
905+ }
906+
907+ getReplay() {
908+ const i = this.replayIndex
909+ return { active: i != null, index: i, total: this.fullStore.length, bar: i != null ? this.fullStore.bars[i] : null }
910+ }
911+
912+ _applyReplay() {
913+ if (this.replayIndex == null) { this.store = this.fullStore; return }
914+ const cut = new BarStore(this.opts.maxBars)
915+ cut.set(this.fullStore.bars.slice(0, this.replayIndex + 1))
916+ this.store = cut
917+ this._indicatorVersion = -1 // force indicators to recompute on the truncated data
918+ this._haVersion = -1
919+ }
843 920
844 setTimeframe(tf) { this.opts.timeframe = tf; this.invalidate('data') } 921 setTimeframe(tf) { this.opts.timeframe = tf; this.invalidate('data') }
845 922
@@ -1145,6 +1222,72 @@ export class Chart {
1145 return exportPNG(this, o || {}) 1222 return exportPNG(this, o || {})
1146 } 1223 }
1147 1224
1225+ /* ─── v2: markers & price lines ─── */
1226+
1227+ /** Series markers: [{ t, position: 'above' | 'below', shape?: 'arrowUp' | 'arrowDown' | 'circle' | 'square', color?, text?, size? }]. */
1228+ setMarkers(list) { this.markers = Array.isArray(list) ? list.filter(m => m && Number.isFinite(m.t)).map(m => ({ ...m })) : []; this.invalidate('overlay') }
1229+ getMarkers() { return this.markers.map(m => ({ ...m })) }
1230+
1231+ /** Custom horizontal price line on the main pane. Returns its id. */
1232+ addPriceLine({ id, price, color, title, style = 'solid', width = 1, axisLabel = true } = {}) {
1233+ if (!Number.isFinite(price)) throw new Error('addPriceLine: price required')
1234+ const finalId = id || `pl-${++idSeq}`
1235+ this.priceLines.set(finalId, { id: finalId, price, color: color || null, title: title || '', style, width, axisLabel })
1236+ this.invalidate('overlay')
1237+ return finalId
1238+ }
1239+ updatePriceLine(id, patch) { const pl = this.priceLines.get(id); if (!pl) return; Object.assign(pl, patch || {}); this.invalidate('overlay') }
1240+ removePriceLine(id) { if (this.priceLines.delete(id)) this.invalidate('overlay') }
1241+ getPriceLines() { return Array.from(this.priceLines.values()).map(p => ({ ...p })) }
1242+
1243+ /* ─── v2: sessions & layout ─── */
1244+
1245+ /** { regular: [startHour, endHour] (decimal wall-clock hours, e.g. [9.5, 16]), shade: boolean }. null disables. */
1246+ setSessions(o) { this.sessions = o && Array.isArray(o.regular) ? { regular: [Number(o.regular[0]), Number(o.regular[1])], shade: o.shade !== false } : null; this.invalidate('data') }
1247+ setRightOffset(bars) { this.ts.rightOffsetBars = Math.max(0, Number(bars) || 0); this.opts.rightOffsetBars = this.ts.rightOffsetBars; if (this.ts.stickToRight) this.ts.scrollToLatest(); this.invalidate('data') }
1248+ setBarSpacing(px) { const bs = Math.max(this.ts.minBarSpacing, Math.min(this.ts.maxBarSpacing, Number(px) || this.ts.barSpacing)); this._animateView({ barSpacing: bs, leftIndex: this.ts.rightIndex - (this.ts.width - bs / 2) / bs }, true) }
1249+ setPriceScaleWidth(w) { this.fixedAxisWidth = w === 'auto' || w == null ? null : Math.max(24, Math.round(Number(w))); this.invalidate('data') }
1250+ setTimeAxisVisible(v) { this.timeAxisVisible = v !== false; this.invalidate('layout') }
1251+ setGrid({ v, h } = {}) { if (v != null) this.gridOpts.v = !!v; if (h != null) this.gridOpts.h = !!h; this.invalidate('data') }
1252+ /** A CSS color, or { gradient: ['#top', '#bottom'] }. null restores the theme background. */
1253+ setBackground(bg) { this.background = bg || null; this.el.style.background = typeof bg === 'string' ? bg : bg && Array.isArray(bg.gradient) ? `linear-gradient(${bg.gradient.join(',')})` : this.theme.bg; this.invalidate('data') }
1254+
1255+ /* ─── v2: export & accessibility ─── */
1256+
1257+ /** CSV of the bars (visible range only when `visibleOnly`) with one column per indicator series. */
1258+ toCSV(visibleOnly = false) {
1259+ const vr = this.ts.visibleRange()
1260+ const from = visibleOnly ? Math.max(0, vr.from) : 0, to = visibleOnly ? Math.min(this.store.length - 1, vr.to) : this.store.length - 1
1261+ const cols = ['time', 'open', 'high', 'low', 'close', 'volume']
1262+ const series = []
1263+ for (const ind of this.indicators.values()) if (ind.values) for (const key of Object.keys(ind.values)) if (Array.isArray(ind.values[key])) { const name = `${ind.spec.title(ind.params)} ${key}`.replace(/,/g, ' '); cols.push(name); series.push(ind.values[key]) }
1264+ const iso = t => { const d = new Date(t); return isIntraday(this.opts.timeframe) ? d.toISOString().slice(0, 19).replace('T', ' ') : d.toISOString().slice(0, 10) }
1265+ const lines = [cols.join(',')]
1266+ const B = this.store.bars
1267+ for (let i = from; i <= to; i++) {
1268+ const b = B[i]
1269+ const row = [iso(b.t), b.o, b.h, b.l, b.c, b.v == null ? '' : b.v]
1270+ for (const arr of series) { const v = arr[i]; row.push(v == null || Number.isNaN(v) ? '' : v) }
1271+ lines.push(row.join(','))
1272+ }
1273+ return lines.join('\n')
1274+ }
1275+
1276+ /** One-sentence description of the visible range (for an aria-live region). */
1277+ describeVisible() {
1278+ const vr = this.ts.visibleRange()
1279+ const n = this.store.length
1280+ if (!n || vr.to < vr.from) return 'Empty chart.'
1281+ const from = Math.max(0, vr.from), to = Math.min(n - 1, vr.to)
1282+ const ex = this.store.extremes(from, to)
1283+ const first = this.store.bars[from], last = this.store.bars[to]
1284+ const chg = first.o ? ((last.c - first.o) / first.o) * 100 : 0
1285+ const tf = this.opts.timeframe
1286+ const label = this.opts.watermark ? `${this.opts.watermark.split('·')[0].trim()}, ` : ''
1287+ const f = v => this._formatMain(v)
1288+ return `${label}${to - from + 1} ${tf} bars from ${fmtFull(first.t, tf, this.opts.sessionLabel)} to ${fmtFull(last.t, tf, this.opts.sessionLabel)}. Low ${f(ex.lo)}, high ${f(ex.hi)}. Last ${f(last.c)}, ${formatPercent(chg, 2, this.opts.locale)} over the range.${this.replayIndex != null ? ` Replay at bar ${this.replayIndex + 1} of ${this.fullStore.length}.` : ''}`
1289+ }
1290+
1148 on(event, fn) { return this.emitter.on(event, fn) } 1291 on(event, fn) { return this.emitter.on(event, fn) }
1149 1292
1150 destroy() { 1293 destroy() {
modified hfmarketdata/web/src/charts/engine/export/png.js +24 −2
@@ -2,12 +2,13 @@
2 2
3 import { font } from '../render/text.js' 3 import { font } from '../render/text.js'
4 import { withAlpha } from '../render/canvas.js' 4 import { withAlpha } from '../render/canvas.js'
5+import { formatCompact } from '../format/number.js'
5 6
6 /** 7 /**
7 * @param chart internal chart (uses chart.panes, chart.timeAxis, chart.width/height, chart.theme) 8 * @param chart internal chart (uses chart.panes, chart.timeAxis, chart.width/height, chart.theme)
8− * @param o { scale?: number, watermark?: string, background?: string } 9+ * @param o { scale?: number, watermark?: string, background?: string, legend?: boolean }
9 */ 10 */
10−export async function exportPNG(chart, { scale = 2, watermark, background } = {}) { 11+export async function exportPNG(chart, { scale = 2, watermark, background, legend = false } = {}) {
11 const w = chart.width, h = chart.height 12 const w = chart.width, h = chart.height
12 const out = document.createElement('canvas') 13 const out = document.createElement('canvas')
13 out.width = Math.round(w * scale); out.height = Math.round(h * scale) 14 out.width = Math.round(w * scale); out.height = Math.round(h * scale)
@@ -22,6 +23,27 @@ export async function exportPNG(chart, { scale = 2, watermark, background } = {}
22 for (const p of chart.panes) { blit(p.main, 0, p.top); blit(p.overlay, 0, p.top) } 23 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 blit(chart.timeAxis.main, 0, chart.timeAxis.top)
24 const theme = chart.theme 25 const theme = chart.theme
26+ // v2: optional legend (last bar OHLC + indicator titles / last values) drawn top-left of the main pane.
27+ if (legend) {
28+ const st = chart.store
29+ if (st.length) {
30+ const i = st.length - 1
31+ const f = v => chart._formatMain(v)
32+ const lines = [`${watermark ? watermark + ' ' : ''}O ${f(st.o[i])} H ${f(st.h[i])} L ${f(st.l[i])} C ${f(st.c[i])}${Number.isFinite(st.v[i]) ? ` Vol ${formatCompact(st.v[i], chart.opts.locale)}` : ''}`]
33+ for (const ind of chart.indicators.values()) {
34+ if (!ind.values) continue
35+ const parts = []
36+ for (const key of ind.spec.legendKeys || Object.keys(ind.values)) { const arr = ind.values[key]; if (!Array.isArray(arr)) continue; const v = arr[Math.min(i, arr.length - 1)]; if (v != null && !Number.isNaN(v)) parts.push(`${key} ${chart._formatPane(chart.panes.find(p => p.id === ind.paneId) || chart.mainPane, v)}`) }
37+ lines.push(`${ind.spec.title(ind.params)}${parts.length ? ' ' + parts.join(' ') : ''}`)
38+ }
39+ ctx.font = font(theme, { mono: true, size: 11, weight: 500 })
40+ ctx.textBaseline = 'top'; ctx.textAlign = 'left'
41+ const tw = Math.max(...lines.map(l => ctx.measureText(l).width))
42+ const x0 = chart.plotX0 + 8, y0 = chart.mainPane.top + (watermark ? 28 : 8)
43+ ctx.fillStyle = withAlpha(theme.bg, 0.78); ctx.fillRect(x0 - 6, y0 - 4, tw + 12, lines.length * 16 + 8)
44+ lines.forEach((l, k) => { ctx.fillStyle = k === 0 ? theme.text : theme.textMuted; ctx.fillText(l, x0, y0 + k * 16) })
45+ }
46+ }
25 if (watermark) { 47 if (watermark) {
26 ctx.font = font(theme, { size: 13, weight: 600 }) 48 ctx.font = font(theme, { size: 13, weight: 600 })
27 ctx.fillStyle = withAlpha(theme.text, 0.55) 49 ctx.fillStyle = withAlpha(theme.text, 0.55)
28 50
Menu

Theme