| 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 | 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 | 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 | 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 | 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 | 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 | 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 | /* ═══════════════════════════ 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 | } |
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 | } |
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 | 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() { |