| 10 |
10 |
import { createLayer, crisp, withAlpha, hair, lw, snap } from '../render/canvas.js' |
| 11 |
11 |
import { font, measure, FONT_SIZE } from '../render/text.js' |
| 12 |
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 |
14 |
import { drawPlots, valuePath, plotColor } from '../render/plots.js' |
| 15 |
15 |
import { drawCrosshair, drawAxisLabel, drawTimeLabel, drawLastPrice, drawHiLoMarkers, drawWatermark, drawPaneHeader } from '../render/overlay.js' |
| 16 |
16 |
import { normalizeTheme, darkTheme } from '../theme.js' |
| 54 |
54 |
this.emitter = new Emitter() |
| 55 |
55 |
this.animator = new Animator() |
| 56 |
56 |
this.store = new BarStore(this.opts.maxBars) |
|
57 |
+ this.fullStore = this.store // v2 bar replay: `store` is a truncated view while replaying |
| 57 |
58 |
this.renderStore = this.store |
| 58 |
59 |
this._haVersion = -1 |
| 59 |
60 |
this.ts = new TimeScale({ barSpacing: this.opts.barSpacing, minBarSpacing: this.opts.minBarSpacing, rightOffsetBars: this.opts.rightOffsetBars }) |
| 75 |
76 |
this.priceLines = new Map() // v2: custom horizontal price lines |
| 76 |
77 |
this.replayIndex = null // v2: bar replay — bars after this index are hidden |
| 77 |
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 |
81 |
this.width = 0; this.height = 0; this.dpr = 1 |
| 79 |
82 |
this.dirty = { layout: true, data: true, overlay: true } |
| 80 |
83 |
this._raf = 0 |
| 377 |
380 |
if (need > w) w = need |
| 378 |
381 |
} |
| 379 |
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 |
385 |
this.axisWidth = w |
| 382 |
386 |
this.ts.width = this.plotWidth |
| 383 |
387 |
} |
| 411 |
415 |
const ctx = layer.ctx |
| 412 |
416 |
layer.clear() |
| 413 |
417 |
const W = this.plotWidth, H = p.height, theme = this.theme |
| 414 |
|
− ctx.fillStyle = theme.bg |
|
418 |
+ ctx.fillStyle = this._bgStyle(ctx, p) |
| 415 |
419 |
ctx.fillRect(0, 0, this.width, H) |
| 416 |
420 |
if (p.collapsed) { |
| 417 |
421 |
this._drawPriceAxisFor(p, []) |
| 424 |
428 |
ctx.beginPath(); ctx.rect(0, 0, W, H); ctx.clip() |
| 425 |
429 |
drawGrid(ctx, { width: W, height: H, priceTicks: ticks, timeTicks: this._timeTicks || [], theme, showV: this.gridOpts.v, showH: this.gridOpts.h }) |
| 426 |
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 |
432 |
drawWatermark(ctx, { text: this.opts.watermark, width: W, height: H, theme }) |
| 429 |
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 |
434 |
if (this.volumeVisible) drawVolume(g, this.opts.volumeFraction) |
| 453 |
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 |
494 |
_drawPaneBorder(p) { |
| 457 |
495 |
const ctx = p.main.ctx |
| 458 |
496 |
if (p !== this.panes[0]) { |
| 803 |
841 |
/* ═══════════════════════════ PUBLIC API ═══════════════════════════ */ |
| 804 |
842 |
|
| 805 |
843 |
setData(bars) { |
| 806 |
|
− this.store.set(bars || []) |
|
844 |
+ this.fullStore.set(bars || []) |
|
845 |
+ this.replayIndex = null |
|
846 |
+ this.store = this.fullStore |
| 807 |
847 |
this.ts.count = this.store.length |
| 808 |
848 |
this.ts.width = this.plotWidth |
| 809 |
849 |
this._refreshDecimals() |
| 817 |
857 |
} |
| 818 |
858 |
|
| 819 |
859 |
prependData(older) { |
| 820 |
|
− const n = this.store.prepend(older || []) |
| 821 |
|
− if (n) { this.ts.onPrepend(n); this._refreshDecimals(); this.drawings.onDataChange(); this.invalidate('data') } |
|
860 |
+ const n = this.fullStore.prepend(older || []) |
|
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 |
864 |
appendData(newer) { |
| 825 |
|
− const n = this.store.append(newer || []) |
|
865 |
+ const n = this.fullStore.append(newer || []) |
| 826 |
866 |
if (!n) return |
|
867 |
+ if (this.replayIndex != null) { this._applyReplay(); this.invalidate('data'); return } |
| 827 |
868 |
this.ts.count = this.store.length |
| 828 |
869 |
if (this.ts.stickToRight) this.ts.scrollToLatest() |
| 829 |
870 |
this._lastClose = this.store.c[this.store.length - 1] |
| 831 |
872 |
} |
| 832 |
873 |
|
| 833 |
874 |
updateLast(bar) { |
| 834 |
|
− const r = this.store.updateLast(bar) |
|
875 |
+ const r = this.fullStore.updateLast(bar) |
| 835 |
876 |
if (r === 'ignored') return |
|
877 |
+ if (this.replayIndex != null) { this._applyReplay(); this.invalidate('data'); return } |
| 836 |
878 |
this.ts.count = this.store.length |
| 837 |
879 |
if (r === 'append' && this.ts.stickToRight) this.ts.scrollToLatest() |
| 838 |
880 |
if (bar.c !== this._lastClose) { this._pulseAt = performance.now(); this._lastClose = bar.c } |
| 839 |
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 |
921 |
setTimeframe(tf) { this.opts.timeframe = tf; this.invalidate('data') } |
| 845 |
922 |
|
| 1145 |
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 |
1291 |
on(event, fn) { return this.emitter.on(event, fn) } |
| 1149 |
1292 |
|
| 1150 |
1293 |
destroy() { |