wickchart 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2621 @@
1
+ /* ==========================================================================
2
+ * <hab-chart> — a modern, dependency-free financial charting web component.
3
+ *
4
+ * <hab-chart label="BTC · 1h" type="candles" indicators="sma:20 volume">
5
+ * </hab-chart>
6
+ * <script type="module">
7
+ * const chart = document.querySelector('hab-chart');
8
+ * chart.setData(bars); // [{ time, open, high, low, close, volume }]
9
+ * chart.update(bar); // streaming update / append
10
+ * </script>
11
+ *
12
+ * Zero dependencies. Canvas-rendered. Framework-agnostic (works in React,
13
+ * Vue, Svelte, plain HTML). Themeable with --hab-* CSS custom properties.
14
+ *
15
+ * MIT License.
16
+ * ========================================================================== */
17
+
18
+ import {
19
+ clamp, isNum, numberFmt, fmtCompact, autoPrecision, niceStep, hexToRgba,
20
+ FONT_STACK, axisFont, pillFont, roundRectPath,
21
+ TIME_STEPS, HOUR, DAY, hhmm, fmtDay, fmtMonth, fmtYear, fmtFull,
22
+ THEMES, mergeOlderData, detectGaps,
23
+ parseIndicators, normalizeIndicatorResult, BUILTIN_INDICATORS,
24
+ positionPnl, checkAlertCross, computeStats, safeColor,
25
+ SERIES_TYPES, calcHeikinAshi, buildColumns, computeVolumeProfile,
26
+ calcRSI, detectAnnotations, priceToFreq,
27
+ } from './core.js';
28
+
29
+ /* ------------------------------------------------------------------ *
30
+ * <hab-chart>
31
+ * ------------------------------------------------------------------ */
32
+
33
+ /* SSR safety: importing this module under Node (Next.js/Nuxt server render)
34
+ * must not throw — the element simply registers only in browsers. */
35
+ const HTMLElementBase = typeof HTMLElement !== 'undefined' ? HTMLElement : class {};
36
+
37
+ class HabChart extends HTMLElementBase {
38
+ static get observedAttributes() {
39
+ return ['theme', 'type', 'log', 'auto', 'indicators', 'precision', 'label', 'stats', 'profile', 'annotations', 'co-view', 'sonify'];
40
+ }
41
+
42
+ constructor() {
43
+ super();
44
+ const root = this.attachShadow({ mode: 'open' });
45
+ root.innerHTML = `
46
+ <style>
47
+ :host {
48
+ display: block;
49
+ position: relative;
50
+ width: 100%;
51
+ height: 100%;
52
+ min-height: 220px;
53
+ contain: content;
54
+ }
55
+ :host(:focus-visible) {
56
+ outline: 2px solid var(--hab-accent, #4c8dff);
57
+ outline-offset: -2px;
58
+ }
59
+ .wrap { position: absolute; inset: 0; overflow: hidden; }
60
+ canvas {
61
+ position: absolute; inset: 0;
62
+ width: 100%; height: 100%;
63
+ display: block;
64
+ touch-action: none;
65
+ cursor: crosshair;
66
+ user-select: none;
67
+ -webkit-user-select: none;
68
+ }
69
+ canvas.grabbing { cursor: grabbing; }
70
+ .legend {
71
+ position: absolute; left: 10px; top: 8px; z-index: 2;
72
+ pointer-events: none;
73
+ font: 500 12px/1.7 ${FONT_STACK};
74
+ letter-spacing: 0.01em;
75
+ max-width: calc(100% - 24px);
76
+ }
77
+ .legend .row { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
78
+ .legend .sym {
79
+ color: var(--hab-text-strong, #e6edf3);
80
+ font-weight: 700;
81
+ font-size: 13px;
82
+ letter-spacing: 0.02em;
83
+ }
84
+ .legend .kv { display: inline-flex; gap: 5px; align-items: baseline; white-space: nowrap; }
85
+ .legend .k { color: var(--hab-text, #8b949e); font-size: 11px; }
86
+ .legend .v { color: var(--hab-text-strong, #e6edf3); font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
87
+ .legend .pct { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
88
+ .legend .up { color: var(--hab-up, #16c784); }
89
+ .legend .dn { color: var(--hab-down, #ea3943); }
90
+ .legend .ind {
91
+ display: inline-flex; align-items: center; gap: 6px;
92
+ color: var(--hab-text, #8b949e); font-size: 11.5px; white-space: nowrap;
93
+ }
94
+ .legend .ind i { width: 8px; height: 2.5px; border-radius: 2px; display: inline-block; }
95
+ .legend .ind .v { font-size: 12px; }
96
+ .legend .insight {
97
+ color: var(--hab-accent, #4c8dff);
98
+ background: var(--hab-chip, rgba(127, 137, 153, 0.12));
99
+ border-radius: 6px;
100
+ padding: 1px 8px;
101
+ font-size: 11.5px;
102
+ font-weight: 600;
103
+ }
104
+ .nodata {
105
+ position: absolute; inset: 0;
106
+ display: flex; align-items: center; justify-content: center;
107
+ color: var(--hab-text, #8b949e);
108
+ font: 500 13px ${FONT_STACK};
109
+ pointer-events: none;
110
+ }
111
+ .nodata[hidden] { display: none; }
112
+ .hud {
113
+ position: absolute; right: 10px; top: 8px; z-index: 2;
114
+ display: flex; flex-direction: column; gap: 4px; align-items: flex-end;
115
+ pointer-events: none;
116
+ font: 600 11.5px/1.4 ${FONT_STACK};
117
+ }
118
+ .hud .pos {
119
+ display: inline-flex; gap: 9px; align-items: baseline; white-space: nowrap;
120
+ background: var(--hab-chip, rgba(127, 137, 153, 0.12));
121
+ border: 1px solid var(--hab-border, rgba(148, 163, 184, 0.2));
122
+ border-radius: 7px;
123
+ padding: 3px 9px;
124
+ }
125
+ .hud .statsrow {
126
+ display: inline-flex; gap: 12px; white-space: nowrap;
127
+ background: var(--hab-chip, rgba(127, 137, 153, 0.12));
128
+ border: 1px solid var(--hab-border, rgba(148, 163, 184, 0.2));
129
+ border-radius: 7px;
130
+ padding: 3px 10px;
131
+ color: var(--hab-text, #8b949e);
132
+ font-variant-numeric: tabular-nums;
133
+ }
134
+ .hud .statsrow b { color: var(--hab-text-strong, #e6edf3); font-weight: 600; }
135
+ .hud .k { color: var(--hab-text, #8b949e); font-weight: 500; }
136
+ .hud .v { color: var(--hab-text-strong, #e6edf3); font-variant-numeric: tabular-nums; }
137
+ .hud .up { color: var(--hab-up, #16c784); }
138
+ .hud .dn { color: var(--hab-down, #ea3943); }
139
+ </style>
140
+ <div class="wrap" part="wrap">
141
+ <canvas part="canvas" role="img"></canvas>
142
+ <div class="legend" part="legend" aria-hidden="true"></div>
143
+ <div class="hud" part="hud" aria-hidden="true">
144
+ <div class="poss"></div>
145
+ <div class="statsrow"></div>
146
+ </div>
147
+ <div class="nodata" hidden>No data</div>
148
+ </div>`;
149
+
150
+ this._canvas = root.querySelector('canvas');
151
+ this._ctx = this._canvas.getContext('2d');
152
+ this._legend = root.querySelector('.legend');
153
+ this._hud = root.querySelector('.hud');
154
+ this._poss = root.querySelector('.poss');
155
+ this._statsRow = root.querySelector('.statsrow');
156
+ this._nodata = root.querySelector('.nodata');
157
+
158
+ this._data = [];
159
+ this._version = 0;
160
+ this._view = { rightIndex: 10, spacing: 8 };
161
+ this._auto = true;
162
+ this._needsFit = true;
163
+ this._hover = null; // { index, x, y }
164
+ this._dt = HOUR; // median bar interval (ms)
165
+ this._ly = null; // last layout
166
+ this._cache = { v: -1, map: {} };
167
+ this._pal = null; // palette cache
168
+ this._palKey = '';
169
+ this._legendKey = '';
170
+ this._raf = 0;
171
+ this._connected = false;
172
+
173
+ // defaults; attributes (if present) override via attributeChangedCallback
174
+ this._theme = 'dark';
175
+ this._type = 'candles';
176
+ this._log = false;
177
+ this._precision = null;
178
+ this._label = '';
179
+ this._stats = false;
180
+ this._statsKey = '';
181
+ this._profile = false;
182
+ this._profileKey = '';
183
+ this._profileRes = null;
184
+ this._annotations = false;
185
+ this._annoKey = '';
186
+ this._annoList = null;
187
+
188
+ // cross-tab co-view state
189
+ this._coviewName = null;
190
+ this._coviewCh = null;
191
+ this._coviewPeer = '';
192
+ this._coviewLast = 0;
193
+ this._ghost = null;
194
+ this._ghostTimer = 0;
195
+
196
+ // sonification state
197
+ this._sonify = false;
198
+ this._actx = null;
199
+ this._lastToneIdx = -1;
200
+ this._playToken = 0;
201
+ this._measure = null; // { iA, pA, iB, pB, done }
202
+ this._measuring = false;
203
+ this._ind = { overlays: [], panes: [], volume: true };
204
+
205
+ this._pointers = new Map();
206
+ this._pan = null;
207
+ this._pinch = null;
208
+
209
+ // history backfill state (onloadmore declared as a class field above)
210
+ this._loadingMore = false;
211
+ this._noMore = false;
212
+
213
+ // trading overlays
214
+ this._positions = [];
215
+ this._alerts = [];
216
+ this._seq = 0;
217
+
218
+ this._onResize = () => this._invalidate();
219
+ this._onPointerDown = (e) => this._pointerDown(e);
220
+ this._onPointerMove = (e) => this._pointerMove(e);
221
+ this._onPointerUp = (e) => this._pointerUp(e);
222
+ this._onPointerLeave = () => {
223
+ if (this._hover) {
224
+ this._hover = null;
225
+ this._emitCrosshair(null);
226
+ this._invalidate();
227
+ }
228
+ };
229
+ this._onWheel = (e) => this._wheel(e);
230
+ this._onDbl = () => this.fit();
231
+ this._onKey = (e) => this._keydown(e);
232
+ }
233
+
234
+ connectedCallback() {
235
+ this._connected = true;
236
+ if (this.tabIndex < 0) this.tabIndex = 0;
237
+ this._ro =
238
+ this._ro ||
239
+ new ResizeObserver(() => {
240
+ if (this._ly) this._invalidate();
241
+ else this._needsFit = true, this._invalidate();
242
+ });
243
+ this._ro.observe(this);
244
+
245
+ const cv = this._canvas;
246
+ cv.addEventListener('pointerdown', this._onPointerDown);
247
+ cv.addEventListener('pointermove', this._onPointerMove);
248
+ cv.addEventListener('pointerup', this._onPointerUp);
249
+ cv.addEventListener('pointercancel', this._onPointerUp);
250
+ cv.addEventListener('pointerleave', this._onPointerLeave);
251
+ cv.addEventListener('wheel', this._onWheel, { passive: false });
252
+ cv.addEventListener('dblclick', this._onDbl);
253
+ this.addEventListener('keydown', this._onKey);
254
+
255
+ if (document.fonts && document.fonts.ready) {
256
+ document.fonts.ready.then(() => this._invalidate()).catch(() => {});
257
+ }
258
+ if (this._coviewName) this._setupCoView();
259
+ this._invalidate();
260
+ }
261
+
262
+ disconnectedCallback() {
263
+ this._connected = false;
264
+ if (this._coviewCh) {
265
+ try {
266
+ this._coviewCh.close();
267
+ } catch (_) {}
268
+ this._coviewCh = null;
269
+ }
270
+ clearTimeout(this._ghostTimer);
271
+ if (this._ro) this._ro.disconnect();
272
+ const cv = this._canvas;
273
+ cv.removeEventListener('pointerdown', this._onPointerDown);
274
+ cv.removeEventListener('pointermove', this._onPointerMove);
275
+ cv.removeEventListener('pointerup', this._onPointerUp);
276
+ cv.removeEventListener('pointercancel', this._onPointerUp);
277
+ cv.removeEventListener('pointerleave', this._onPointerLeave);
278
+ cv.removeEventListener('wheel', this._onWheel);
279
+ cv.removeEventListener('dblclick', this._onDbl);
280
+ this.removeEventListener('keydown', this._onKey);
281
+ if (this._raf) cancelAnimationFrame(this._raf), (this._raf = 0);
282
+ }
283
+
284
+ attributeChangedCallback(name, _old, val) {
285
+ switch (name) {
286
+ case 'theme':
287
+ this._theme = val === 'light' ? 'light' : 'dark';
288
+ break;
289
+ case 'type':
290
+ this._type = SERIES_TYPES.includes(val) ? val : 'candles';
291
+ break;
292
+ case 'log':
293
+ this._log = val != null && val !== 'false';
294
+ break;
295
+ case 'auto':
296
+ this._auto = val == null || val !== 'false';
297
+ break;
298
+ case 'precision':
299
+ this._precision = val != null && val !== '' ? clamp(parseInt(val, 10) || 0, 0, 12) : null;
300
+ break;
301
+ case 'label':
302
+ this._label = val || '';
303
+ break;
304
+ case 'indicators':
305
+ this._ind = parseIndicators(val, HabChart._registry());
306
+ break;
307
+ case 'stats':
308
+ this._stats = val != null && val !== 'false';
309
+ this._statsKey = '';
310
+ break;
311
+ case 'profile':
312
+ this._profile = val != null && val !== 'false';
313
+ this._profileKey = '';
314
+ break;
315
+ case 'annotations':
316
+ this._annotations = val != null && val !== 'false';
317
+ this._annoKey = '';
318
+ break;
319
+ case 'co-view':
320
+ this._coviewName = val || null;
321
+ this._setupCoView();
322
+ break;
323
+ case 'sonify':
324
+ this._sonify = val != null && val !== 'false';
325
+ this._lastToneIdx = -1;
326
+ break;
327
+ }
328
+ this._invalidate();
329
+ }
330
+
331
+ /* ------------------------------------------------------------ *
332
+ * Indicator registry
333
+ * ------------------------------------------------------------ */
334
+
335
+ static _registryMap = null;
336
+
337
+ /** Lazily-built registry, seeded with the built-in indicators. */
338
+ static _registry() {
339
+ if (!HabChart._registryMap) {
340
+ HabChart._registryMap = new Map(BUILTIN_INDICATORS);
341
+ }
342
+ return HabChart._registryMap;
343
+ }
344
+
345
+ /**
346
+ * Register a custom indicator.
347
+ *
348
+ * HabChart.registerIndicator('vwap', {
349
+ * kind: 'overlay', // or 'pane'
350
+ * params: { period: 20 }, // defaults; settable via name:period
351
+ * compute(bars, params) { // bars: normalized {time,o,h,l,c,v}
352
+ * return smaOfCloses; // single series…
353
+ * // …or { lines: [{name, values}], histogram } for multi-line/panes
354
+ * },
355
+ * guides: [30, 70], // pane only: dashed guide levels
356
+ * range: [0, 100], // pane only: fixed scale
357
+ * fmt: 'price' | 'fixed1', // legend/axis number format
358
+ * });
359
+ * chart.indicators = 'vwap:20';
360
+ */
361
+ /** @param {import('./core.js').IndicatorDef} def */
362
+ static registerIndicator(name, def) {
363
+ if (typeof name !== 'string' || !/^[A-Za-z][A-Za-z0-9_]*$/.test(name)) {
364
+ throw new Error('registerIndicator: invalid name');
365
+ }
366
+ if (!def || typeof def.compute !== 'function') {
367
+ throw new Error('registerIndicator: def.compute must be a function');
368
+ }
369
+ HabChart._registry().set(name.toLowerCase(), {
370
+ kind: def.kind === 'pane' ? 'pane' : 'overlay',
371
+ ...def,
372
+ });
373
+ }
374
+
375
+ /** The custom element class (also exported implicitly for users). */
376
+ static get elementName() {
377
+ return 'hab-chart';
378
+ }
379
+
380
+ /* ------------------------------------------------------------ *
381
+ * Public API
382
+ * ------------------------------------------------------------ */
383
+
384
+ get data() {
385
+ return this._data;
386
+ }
387
+
388
+ /**
389
+ * Replace the dataset.
390
+ * @param {Array<import('./core.js').Bar>} bars
391
+ */
392
+ setData(bars) {
393
+ if (!Array.isArray(bars) || !bars.length) {
394
+ this.clearData();
395
+ return;
396
+ }
397
+ const norm = [];
398
+ for (const b of bars) {
399
+ const nb = HabChart._normBar(b);
400
+ if (nb) norm.push(nb);
401
+ }
402
+ // skip the O(n log n) sort when already ascending (typical for feeds)
403
+ let sorted = true;
404
+ for (let i = 1; i < norm.length; i++) {
405
+ if (norm[i].time < norm[i - 1].time) {
406
+ sorted = false;
407
+ break;
408
+ }
409
+ }
410
+ if (!sorted) norm.sort((a, b) => a.time - b.time);
411
+ this._data = norm;
412
+ this._version++;
413
+ this._computeDt();
414
+ this._needsFit = true;
415
+ this._auto = this._autoAttr();
416
+ this._hover = null;
417
+ this._noMore = false;
418
+ this._updateAria();
419
+ this._invalidate();
420
+ }
421
+
422
+ /**
423
+ * Stream a bar: replaces the last bar when `time` matches, appends when
424
+ * newer, inserts/backfills when older.
425
+ * @param {import('./core.js').Bar} bar
426
+ */
427
+ update(bar) {
428
+ const b = HabChart._normBar(bar);
429
+ if (!b) return;
430
+ const d = this._data;
431
+ const last = d[d.length - 1];
432
+ this._checkAlerts(last ? last.close : NaN, b);
433
+ if (!last || b.time > last.time) {
434
+ d.push(b);
435
+ if (d.length > 1) this._computeDt();
436
+ } else if (b.time === last.time) {
437
+ d[d.length - 1] = b;
438
+ } else {
439
+ // out-of-order / backfill: replace matching or insert
440
+ let i = d.length - 1;
441
+ while (i >= 0 && d[i].time > b.time) i--;
442
+ if (i >= 0 && d[i].time === b.time) d[i] = b;
443
+ else d.splice(i + 1, 0, b);
444
+ this._computeDt();
445
+ }
446
+ this._version++;
447
+ if (this._hover && this._hover.index >= d.length) this._hover = null;
448
+ this._updateAria();
449
+ this._invalidate();
450
+ }
451
+
452
+ clearData() {
453
+ this._data = [];
454
+ this._version++;
455
+ this._hover = null;
456
+ this._needsFit = true;
457
+ this._noMore = false;
458
+ this._updateAria();
459
+ this._invalidate();
460
+ }
461
+
462
+ /**
463
+ * Fetch older history when the view approaches the left edge.
464
+ * The host app assigns `chart.onloadmore = async (fromTime) => bars`.
465
+ * Bars strictly older than the current first bar are prepended and the
466
+ * view stays anchored. Return [] / null to signal "no more data".
467
+ * @type {null|((fromTime: number) => Promise<Array<import('./core.js').Bar>>|Array<import('./core.js').Bar>)}
468
+ */
469
+ onloadmore = null;
470
+
471
+ _maybeLoadMore(iLeft) {
472
+ if (this._loadingMore || this._noMore) return;
473
+ if (typeof this.onloadmore !== 'function' || !this._data.length || !this._ly) return;
474
+ const threshold = Math.max(2, (this._ly.plotRight / this._view.spacing) * 0.08);
475
+ if (iLeft > threshold) return;
476
+ const fromTime = this._data[0].time;
477
+ this._loadingMore = true;
478
+ Promise.resolve(this.onloadmore(fromTime))
479
+ .then((bars) => {
480
+ this._loadingMore = false;
481
+ if (!this._connected) return;
482
+ if (!Array.isArray(bars) || !bars.length) {
483
+ this._noMore = true;
484
+ return;
485
+ }
486
+ const older = [];
487
+ for (const b of bars) {
488
+ const nb = HabChart._normBar(b);
489
+ if (nb) older.push(nb);
490
+ }
491
+ const { bars: merged, added } = mergeOlderData(this._data, older);
492
+ if (!added) {
493
+ this._noMore = true;
494
+ return;
495
+ }
496
+ this._data = merged;
497
+ this._version++;
498
+ this._computeDt();
499
+ // keep the exact same bars on screen: every index shifts by `added`
500
+ this._view.rightIndex += added;
501
+ if (this._hover) this._hover.index = Math.min(this._hover.index + added, this._data.length - 1);
502
+ this._clampView();
503
+ this._invalidate();
504
+ })
505
+ .catch(() => {
506
+ this._loadingMore = false;
507
+ this._noMore = true;
508
+ });
509
+ }
510
+
511
+ /** Reset zoom to the default view (last ~150 bars). */
512
+ fit() {
513
+ this._needsFit = true;
514
+ this._auto = true;
515
+ this._invalidate();
516
+ this._emitRange();
517
+ }
518
+
519
+ /** Visible time window. @returns {{from:number,to:number}|null} */
520
+ /** @returns {{from: number, to: number}|null} visible time window (ms) */
521
+ getVisibleRange() {
522
+ const d = this._data;
523
+ if (!d.length || !this._ly) return null;
524
+ const { plotRight } = this._ly;
525
+ const { rightIndex, spacing } = this._view;
526
+ const left = clamp(Math.round(rightIndex - plotRight / spacing), 0, d.length - 1);
527
+ const right = clamp(Math.round(rightIndex), 0, d.length - 1);
528
+ return { from: d[left].time, to: d[right].time };
529
+ }
530
+
531
+ /** Set visible time window ({from, to} in ms). */
532
+ /** @param {{from: number, to: number}} range times in ms */
533
+ setVisibleRange(range) {
534
+ const d = this._data;
535
+ if (!d.length || !range || !this._ly) return;
536
+ const from = HabChart._timeToMs(range.from);
537
+ const to = HabChart._timeToMs(range.to);
538
+ let i0 = HabChart._indexForTime(d, from);
539
+ let i1 = HabChart._indexForTime(d, to);
540
+ if (i0 > i1) [i0, i1] = [i1, i0];
541
+ if (i1 - i0 < 2) return;
542
+ const { plotRight } = this._ly;
543
+ this._view.spacing = clamp(plotRight / (i1 - i0), this._minSpacing(), HabChart._MAX_SP);
544
+ this._view.rightIndex = i1;
545
+ this._auto = false;
546
+ this._clampView();
547
+ this._invalidate();
548
+ this._emitRange();
549
+ }
550
+
551
+ /** Current canvas as a PNG data URL. */
552
+ exportPNG() {
553
+ return this._canvas.toDataURL('image/png');
554
+ }
555
+
556
+ /* ------------------------------------------------------------ *
557
+ * State serialization
558
+ * ------------------------------------------------------------ */
559
+
560
+ /**
561
+ * Serializable snapshot of the chart's configuration and view.
562
+ * Feed it to setState() (or encodeStateQuery for shareable URLs).
563
+ */
564
+ /** @returns {import('./core.js').ChartState} */
565
+ getState() {
566
+ const range = this.getVisibleRange();
567
+ const ind = [];
568
+ if (this._ind.volume) ind.push('volume');
569
+ for (const o of this._ind.overlays) ind.push(o.key);
570
+ for (const p of this._ind.panes) ind.push(p.key);
571
+ return {
572
+ type: this._type,
573
+ theme: this._theme,
574
+ log: this._log,
575
+ stats: this._stats,
576
+ profile: this._profile,
577
+ annotations: this._annotations,
578
+ indicators: ind.join(' '),
579
+ view: range ? { from: range.from, to: range.to } : null,
580
+ positions: this._positions.map((p) => ({
581
+ id: p.id, side: p.side, entry: p.entry, stop: p.stop, target: p.target, qty: p.qty,
582
+ })),
583
+ alerts: this._alerts
584
+ .filter((a) => !a.fired)
585
+ .map((a) => ({ id: a.id, price: a.price, direction: a.direction, once: a.once })),
586
+ };
587
+ }
588
+
589
+ /**
590
+ * Apply a state snapshot (from getState()). If a view range is included
591
+ * and data is not loaded yet, it is applied after the next setData().
592
+ * @param {import('./core.js').ChartState} state
593
+ */
594
+ setState(state) {
595
+ if (!state || typeof state !== 'object') return;
596
+ if (state.type) this.setAttribute('type', state.type);
597
+ if (state.theme) this.setAttribute('theme', state.theme);
598
+ if (typeof state.log === 'boolean') this.toggleAttribute('log', state.log);
599
+ if (typeof state.stats === 'boolean') this.setAttribute('stats', String(state.stats));
600
+ if (typeof state.profile === 'boolean') this.setAttribute('profile', String(state.profile));
601
+ if (typeof state.annotations === 'boolean') this.setAttribute('annotations', String(state.annotations));
602
+ if (typeof state.label === 'string') this.setAttribute('label', state.label);
603
+ if (typeof state.indicators === 'string') {
604
+ this.setAttribute('indicators', state.indicators);
605
+ }
606
+ if (Array.isArray(state.positions)) {
607
+ this._positions = state.positions
608
+ .filter((p) => p && isNum(p.entry))
609
+ .map((p) => ({
610
+ id: p.id != null ? String(p.id) : 'pos-' + ++this._seq,
611
+ side: p.side === 'short' ? 'short' : 'long',
612
+ entry: p.entry,
613
+ stop: isNum(p.stop) ? p.stop : null,
614
+ target: isNum(p.target) ? p.target : null,
615
+ qty: isNum(p.qty) ? p.qty : null,
616
+ }));
617
+ this._posVersion = (this._posVersion || 0) + 1;
618
+ }
619
+ if (Array.isArray(state.alerts)) {
620
+ this._alerts = state.alerts
621
+ .filter((a) => a && isNum(a.price))
622
+ .map((a) => ({
623
+ id: a.id != null ? String(a.id) : 'alert-' + ++this._seq,
624
+ price: a.price,
625
+ direction: a.direction || 'cross',
626
+ once: a.once !== false,
627
+ fired: false,
628
+ }));
629
+ }
630
+ if (state.view && state.view.from != null && state.view.to != null) {
631
+ if (this._ly && this._data.length > 1) {
632
+ this.setVisibleRange(state.view);
633
+ } else {
634
+ this._pendingRange = state.view;
635
+ }
636
+ }
637
+ this._invalidate();
638
+ }
639
+
640
+ /* ------------------------------------------------------------ *
641
+ * Positions & alerts
642
+ * ------------------------------------------------------------ */
643
+
644
+ /**
645
+ * Visualize a position / order.
646
+ * @param {{id?: string, side?: 'long'|'short', entry: number,
647
+ * stop?: number, target?: number, qty?: number}} pos
648
+ * @returns {string|null} the position id
649
+ */
650
+ addPosition(pos) {
651
+ if (!pos || !isNum(pos.entry)) return null;
652
+ const p = {
653
+ id: pos.id != null ? String(pos.id) : 'pos-' + ++this._seq,
654
+ side: pos.side === 'short' ? 'short' : 'long',
655
+ entry: pos.entry,
656
+ stop: isNum(pos.stop) ? pos.stop : null,
657
+ target: isNum(pos.target) ? pos.target : null,
658
+ qty: isNum(pos.qty) ? pos.qty : null,
659
+ };
660
+ const i = this._positions.findIndex((x) => x.id === p.id);
661
+ if (i >= 0) this._positions[i] = p;
662
+ else this._positions.push(p);
663
+ this._posVersion = (this._posVersion || 0) + 1;
664
+ this._invalidate();
665
+ return p.id;
666
+ }
667
+
668
+ removePosition(id) {
669
+ this._positions = this._positions.filter((p) => p.id !== String(id));
670
+ this._posVersion = (this._posVersion || 0) + 1;
671
+ this._invalidate();
672
+ }
673
+
674
+ clearPositions() {
675
+ this._positions = [];
676
+ this._posVersion = (this._posVersion || 0) + 1;
677
+ this._invalidate();
678
+ }
679
+
680
+ /**
681
+ * Price alert. Fires `hab:alert` ({id, price, bar}) on an edge crossing
682
+ * during streaming updates.
683
+ * @param {{id?: string, price: number, direction?: 'above'|'below'|'cross',
684
+ * once?: boolean}} alert
685
+ * @returns {string|null} the alert id
686
+ */
687
+ addAlert(alert) {
688
+ if (!alert || !isNum(alert.price)) return null;
689
+ const a = {
690
+ id: alert.id != null ? String(alert.id) : 'alert-' + ++this._seq,
691
+ price: alert.price,
692
+ direction: alert.direction || 'cross',
693
+ once: alert.once !== false,
694
+ fired: false,
695
+ };
696
+ const i = this._alerts.findIndex((x) => x.id === a.id);
697
+ if (i >= 0) this._alerts[i] = a;
698
+ else this._alerts.push(a);
699
+ this._invalidate();
700
+ return a.id;
701
+ }
702
+
703
+ removeAlert(id) {
704
+ this._alerts = this._alerts.filter((a) => a.id !== String(id));
705
+ this._invalidate();
706
+ }
707
+
708
+ clearAlerts() {
709
+ this._alerts = [];
710
+ this._invalidate();
711
+ }
712
+
713
+ /** Check alerts against an incoming bar (prev close → new close). */
714
+ _checkAlerts(prevClose, bar) {
715
+ if (!this._alerts.length || !isNum(prevClose)) return;
716
+ for (const a of [...this._alerts]) {
717
+ if (a.fired) continue;
718
+ if (checkAlertCross(a, prevClose, bar.close)) {
719
+ a.fired = true;
720
+ this.dispatchEvent(
721
+ new CustomEvent('hab:alert', { detail: { id: a.id, price: a.price, bar } })
722
+ );
723
+ if (a.once) this._alerts = this._alerts.filter((x) => x !== a);
724
+ }
725
+ }
726
+ }
727
+
728
+ /* reflected properties */
729
+ get theme() { return this._theme; }
730
+ set theme(v) { this.setAttribute('theme', v); }
731
+ get type() { return this._type; }
732
+ set type(v) { this.setAttribute('type', v); }
733
+ get label() { return this._label; }
734
+ set label(v) { this.setAttribute('label', v); }
735
+ get indicators() {
736
+ return this.getAttribute('indicators');
737
+ }
738
+ set indicators(v) { this.setAttribute('indicators', v == null ? '' : v); }
739
+
740
+ /* ------------------------------------------------------------ *
741
+ * Normalization / internals
742
+ * ------------------------------------------------------------ */
743
+
744
+ static _MAX_SP = 90;
745
+
746
+ /**
747
+ * Lowest allowed px/bar: either 0.35, or whatever fits the entire
748
+ * dataset on screen — so any history can be zoomed out fully.
749
+ */
750
+ _minSpacing() {
751
+ const ly = this._ly;
752
+ const w = ly ? ly.plotRight : 600;
753
+ return Math.min(0.35, w / Math.max(60, this._data.length));
754
+ }
755
+
756
+ static _timeToMs(t) {
757
+ return isNum(t) ? (t < 1e12 ? t * 1000 : t) : Date.now();
758
+ }
759
+
760
+ static _normBar(b) {
761
+ if (!b) return null;
762
+ const t = b.time != null ? b.time : b.t;
763
+ if (!isNum(t)) return null;
764
+ const time = HabChart._timeToMs(t);
765
+ const close = isNum(b.close) ? b.close : isNum(b.value) ? b.value : NaN;
766
+ if (!isNum(close)) return null;
767
+ const open = isNum(b.open) ? b.open : close;
768
+ return {
769
+ time,
770
+ open,
771
+ high: isNum(b.high) ? b.high : Math.max(open, close),
772
+ low: isNum(b.low) ? b.low : Math.min(open, close),
773
+ close,
774
+ volume: isNum(b.volume) ? b.volume : isNum(b.v) ? b.v : 0,
775
+ };
776
+ }
777
+
778
+ static _indexForTime(d, time) {
779
+ let lo = 0;
780
+ let hi = d.length - 1;
781
+ while (lo < hi) {
782
+ const mid = (lo + hi) >> 1;
783
+ if (d[mid].time < time) lo = mid + 1;
784
+ else hi = mid;
785
+ }
786
+ return lo;
787
+ }
788
+
789
+ _autoAttr() {
790
+ const a = this.getAttribute('auto');
791
+ return a == null || a !== 'false';
792
+ }
793
+
794
+ _computeDt() {
795
+ const d = this._data;
796
+ const n = d.length;
797
+ if (n < 2) {
798
+ this._dt = HOUR;
799
+ return;
800
+ }
801
+ const diffs = [];
802
+ const from = Math.max(1, n - 300);
803
+ for (let i = from; i < n; i++) {
804
+ const df = d[i].time - d[i - 1].time;
805
+ if (df > 0) diffs.push(df);
806
+ }
807
+ if (!diffs.length) {
808
+ this._dt = HOUR;
809
+ return;
810
+ }
811
+ diffs.sort((a, b) => a - b);
812
+ this._dt = diffs[diffs.length >> 1] || HOUR;
813
+ }
814
+
815
+ _updateAria() {
816
+ const d = this._data;
817
+ const last = d[d.length - 1];
818
+ const prev = d[d.length - 2];
819
+ if (!last) {
820
+ this._canvas.setAttribute('aria-label', (this._label || 'Chart') + ': no data');
821
+ return;
822
+ }
823
+ const pct = prev ? ((last.close - prev.close) / prev.close) * 100 : 0;
824
+ this._canvas.setAttribute(
825
+ 'aria-label',
826
+ `${this._label || 'Chart'}: last ${numberFmt(this._prec(last.close)).format(last.close)}, ${pct >= 0 ? '+' : ''}${pct.toFixed(2)} percent, ${d.length} bars`
827
+ );
828
+ }
829
+
830
+ _invalidate() {
831
+ if (!this._connected || this._raf) return;
832
+ this._raf = requestAnimationFrame(() => this._render());
833
+ }
834
+
835
+ _prec(v) {
836
+ return this._precision != null ? this._precision : autoPrecision(v);
837
+ }
838
+
839
+ _palette() {
840
+ if (this._pal && this._palKey === this._theme) return this._pal;
841
+ const base = THEMES[this._theme] || THEMES.dark;
842
+ const cs = getComputedStyle(this);
843
+ const get = (name, fallback) => {
844
+ const v = cs.getPropertyValue('--hab-' + name).trim();
845
+ return v || fallback;
846
+ };
847
+ const pal = {};
848
+ for (const k of Object.keys(base)) {
849
+ if (k === 'overlay') {
850
+ const o = [];
851
+ for (let i = 0; i < base.overlay.length; i++) {
852
+ o.push(get('overlay-' + i, base.overlay[i]));
853
+ }
854
+ // allow single overlay color
855
+ const single = cs.getPropertyValue('--hab-overlay').trim();
856
+ pal.overlay = single ? base.overlay.map(() => single) : o;
857
+ } else {
858
+ pal[k] = get(k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), base[k]);
859
+ }
860
+ }
861
+ pal.volAlpha = parseFloat(pal.volAlpha);
862
+ if (!isNum(pal.volAlpha)) pal.volAlpha = 0.33;
863
+ this._pal = pal;
864
+ this._palKey = this._theme;
865
+ return pal;
866
+ }
867
+
868
+ /**
869
+ * Bars used for drawing/reading OHLC: raw data, or the Heikin-Ashi
870
+ * transform for `type="heikin"` (cached per data version).
871
+ */
872
+ _renderBars() {
873
+ if (this._type !== 'heikin') return this._data;
874
+ if (this._cache.v !== this._version || !this._cache.map.__heikin) {
875
+ if (this._cache.v !== this._version) this._cache = { v: this._version, map: {} };
876
+ this._cache.map.__heikin = calcHeikinAshi(this._data);
877
+ }
878
+ return this._cache.map.__heikin;
879
+ }
880
+
881
+ /** RSI(14) over raw closes, cached per data version (annotation input). */
882
+ _cachedRSI14() {
883
+ if (this._cache.v !== this._version) {
884
+ this._cache = { v: this._version, map: {} };
885
+ }
886
+ if (!this._cache.map.__rsi14) {
887
+ this._cache.map.__rsi14 = calcRSI(this._data.map((b) => b.close), 14);
888
+ }
889
+ return this._cache.map.__rsi14;
890
+ }
891
+
892
+ /** Compute (and cache per data version) an indicator entry's series. */
893
+ _indicatorSeries(entry) {
894
+ if (this._cache.v !== this._version) {
895
+ this._cache = { v: this._version, map: {} };
896
+ }
897
+ const k = 'ind:' + entry.key;
898
+ if (!this._cache.map[k]) {
899
+ let res;
900
+ try {
901
+ res = entry.def.compute(this._data, entry.params);
902
+ } catch (err) {
903
+ res = null;
904
+ }
905
+ this._cache.map[k] = normalizeIndicatorResult(res);
906
+ }
907
+ return this._cache.map[k];
908
+ }
909
+
910
+ /** Resolve a line color: #hex / rgb() / CSS name / palette key ('rsi', 'up', …) / cycle.
911
+ * Untrusted values (URL/attribute-sourced) are validated — never interpolated raw. */
912
+ _lineColor(entry, line, pal, cycleIdx) {
913
+ const raw =
914
+ (line && line.color) ||
915
+ (entry && entry.color) ||
916
+ (entry && entry.def && entry.def.color) ||
917
+ null;
918
+ const fallback = pal.overlay[cycleIdx % pal.overlay.length];
919
+ if (!raw) return fallback;
920
+ const c = safeColor(raw);
921
+ if (!c) return fallback; // injection attempt or garbage → safe default
922
+ return pal[c] || c;
923
+ }
924
+
925
+ /* ------------------------------------------------------------ *
926
+ * Layout / view helpers
927
+ * ------------------------------------------------------------ */
928
+
929
+ _ensureCanvas() {
930
+ const W = this.clientWidth;
931
+ const H = this.clientHeight;
932
+ if (!W || !H) return false;
933
+ const dpr = clamp(window.devicePixelRatio || 1, 1, 2.5);
934
+ const bw = Math.round(W * dpr);
935
+ const bh = Math.round(H * dpr);
936
+ if (this._canvas.width !== bw || this._canvas.height !== bh) {
937
+ this._canvas.width = bw;
938
+ this._canvas.height = bh;
939
+ }
940
+ this._W = W;
941
+ this._H = H;
942
+ this._dpr = dpr;
943
+ return true;
944
+ }
945
+
946
+ _rightMargin() {
947
+ const ly = this._ly;
948
+ const w = ly ? ly.plotRight : 600;
949
+ return Math.max(3, (w / this._view.spacing) * 0.06);
950
+ }
951
+
952
+ _applyFit() {
953
+ const d = this._data;
954
+ if (!d.length || !this._ly) return;
955
+ const { plotRight } = this._ly;
956
+ const target = Math.min(d.length, 150);
957
+ this._view.spacing = clamp(plotRight / target, this._minSpacing(), HabChart._MAX_SP);
958
+ this._view.rightIndex = d.length - 1 + this._rightMargin();
959
+ }
960
+
961
+ _clampView() {
962
+ const d = this._data;
963
+ const ly = this._ly;
964
+ if (!d.length || !ly) return;
965
+ const v = this._view;
966
+ v.spacing = clamp(v.spacing, this._minSpacing(), HabChart._MAX_SP);
967
+ const visible = ly.plotRight / v.spacing;
968
+ const maxRight = d.length - 1 + Math.max(6, visible * 0.5);
969
+ const minRight = Math.min(2, d.length - 1);
970
+ v.rightIndex = clamp(v.rightIndex, minRight, maxRight);
971
+ }
972
+
973
+ _atRight() {
974
+ const d = this._data;
975
+ if (!d.length) return true;
976
+ const ri = this._view.rightIndex;
977
+ const m = this._rightMargin();
978
+ return ri >= d.length - 1 - 0.5 && ri <= d.length - 1 + m + 1;
979
+ }
980
+
981
+ _xFor(i) {
982
+ const ly = this._ly;
983
+ return ly ? ly.plotRight - (this._view.rightIndex - i) * this._view.spacing : 0;
984
+ }
985
+
986
+ _indexForX(x) {
987
+ const ly = this._ly;
988
+ if (!ly) return 0;
989
+ return this._view.rightIndex - (ly.plotRight - x) / this._view.spacing;
990
+ }
991
+
992
+ /* ------------------------------------------------------------ *
993
+ * Scales & ticks
994
+ * ------------------------------------------------------------ */
995
+
996
+ _mainScale(i0, i1, cols) {
997
+ const d = this._renderBars();
998
+ const candles = this._type === 'candles' || this._type === 'hollow' || this._type === 'bars' || this._type === 'heikin';
999
+ let lo = Infinity;
1000
+ let hi = -Infinity;
1001
+ if (cols) {
1002
+ for (const c of cols) {
1003
+ const h = candles ? c.high : c.close;
1004
+ const l = candles ? c.low : c.close;
1005
+ if (l < lo) lo = l;
1006
+ if (h > hi) hi = h;
1007
+ }
1008
+ } else {
1009
+ for (let i = i0; i <= i1; i++) {
1010
+ const b = d[i];
1011
+ if (candles) {
1012
+ if (b.low < lo) lo = b.low;
1013
+ if (b.high > hi) hi = b.high;
1014
+ } else {
1015
+ if (b.close < lo) lo = b.close;
1016
+ if (b.close > hi) hi = b.close;
1017
+ }
1018
+ }
1019
+ }
1020
+ for (const ov of this._ind.overlays) {
1021
+ const res = this._indicatorSeries(ov);
1022
+ for (const ln of res.lines) {
1023
+ const s = ln.values;
1024
+ if (cols) {
1025
+ for (const c of cols) {
1026
+ const val = s[c.i1];
1027
+ if (isNum(val)) {
1028
+ if (val < lo) lo = val;
1029
+ if (val > hi) hi = val;
1030
+ }
1031
+ }
1032
+ } else {
1033
+ for (let i = i0; i <= i1; i++) {
1034
+ const v = s[i];
1035
+ if (isNum(v)) {
1036
+ if (v < lo) lo = v;
1037
+ if (v > hi) hi = v;
1038
+ }
1039
+ }
1040
+ }
1041
+ }
1042
+ }
1043
+ if (!isFinite(lo) || !isFinite(hi)) {
1044
+ lo = 0;
1045
+ hi = 1;
1046
+ }
1047
+ if (hi === lo) {
1048
+ const e = Math.abs(hi) * 0.005 || 1;
1049
+ hi += e;
1050
+ lo -= e;
1051
+ }
1052
+ const pad = (hi - lo) * 0.08;
1053
+ let min = lo - pad;
1054
+ let max = hi + pad;
1055
+ const useLog = this._log && min > 0;
1056
+ if (useLog) {
1057
+ min = Math.log10(min);
1058
+ max = Math.log10(max);
1059
+ if (max - min < 1e-9) max = min + 1;
1060
+ }
1061
+ return { min, max, useLog, rawMin: lo, rawHi: hi };
1062
+ }
1063
+
1064
+ _priceTicks(scale, height) {
1065
+ const target = clamp(Math.round(height / 60), 3, 9);
1066
+ const step = niceStep(scale.rawHi - scale.rawMin, target);
1067
+ const ticks = [];
1068
+ if (!(step > 0)) return ticks;
1069
+ const start = Math.ceil(scale.rawMin / step) * step;
1070
+ for (let v = start, guard = 0; v <= scale.rawHi && guard < 200; v += step, guard++) {
1071
+ ticks.push(Math.abs(v) < step * 1e-9 ? 0 : v);
1072
+ }
1073
+ return ticks;
1074
+ }
1075
+
1076
+ /**
1077
+ * Time-axis ticks. `sampleIdx` (optional, ascending indices) restricts
1078
+ * the walk to those bars — used at deep zoom where bars are aggregated
1079
+ * into pixel columns (keeps this O(screen) instead of O(visible bars)).
1080
+ */
1081
+ _timeTicks(i0, i1, sampleIdx) {
1082
+ const d = this._data;
1083
+ const sp = this._view.spacing;
1084
+ const dt = this._dt || HOUR;
1085
+ const minPx = 88;
1086
+
1087
+ // choose step: fixed steps first, then month/year steps
1088
+ let stepMs = null;
1089
+ let stepLabel = 'time';
1090
+ for (const s of TIME_STEPS) {
1091
+ if ((s.ms / dt) * sp >= minPx) {
1092
+ stepMs = s.ms;
1093
+ stepLabel = s.label;
1094
+ break;
1095
+ }
1096
+ }
1097
+ let monthStep = 0;
1098
+ let yearStep = 0;
1099
+ if (stepMs == null) {
1100
+ const monthsPx = (30 * DAY / dt) * sp;
1101
+ if (monthsPx >= minPx) {
1102
+ monthStep = monthsPx >= minPx * 6 ? 6 : monthsPx >= minPx * 3 ? 3 : 1;
1103
+ } else {
1104
+ yearStep = 1;
1105
+ }
1106
+ }
1107
+
1108
+ const tz = (t) => -new Date(t).getTimezoneOffset() * 60000;
1109
+ const ticks = [];
1110
+ let prevKey = null;
1111
+ // Labels are built lazily — only for bars that actually start a new step.
1112
+ // Formatting every visible bar (toLocaleDateString) once cost ~30µs/bar.
1113
+ const visit = (i) => {
1114
+ if (i < 0 || i >= d.length) return;
1115
+ const t = d[i].time;
1116
+ let key;
1117
+ let label = null;
1118
+ if (stepMs != null) {
1119
+ key = Math.floor((t + tz(t)) / stepMs);
1120
+ if (prevKey !== null && key !== prevKey) {
1121
+ if (stepLabel === 'time') {
1122
+ const prevT = d[i - 1] ? d[i - 1].time : t;
1123
+ const dayKey = Math.floor((t + tz(t)) / DAY);
1124
+ const prevDay = Math.floor((prevT + tz(prevT)) / DAY);
1125
+ label = dayKey !== prevDay ? fmtDay(t) : hhmm(t);
1126
+ } else {
1127
+ const dt_ = new Date(t);
1128
+ label = dt_.getDate() === 1 ? fmtMonth(t, dt_.getMonth() === 0) : fmtDay(t);
1129
+ }
1130
+ }
1131
+ } else if (monthStep) {
1132
+ const dt_ = new Date(t);
1133
+ key = Math.floor((dt_.getFullYear() * 12 + dt_.getMonth()) / monthStep);
1134
+ if (prevKey !== null && key !== prevKey) {
1135
+ label = fmtMonth(t, dt_.getMonth() === 0 || monthStep > 1);
1136
+ }
1137
+ } else {
1138
+ key = new Date(t).getFullYear();
1139
+ if (prevKey !== null && key !== prevKey) label = fmtYear(t);
1140
+ }
1141
+ if (label !== null) ticks.push({ x: this._xFor(i), label });
1142
+ prevKey = key;
1143
+ };
1144
+ if (sampleIdx) {
1145
+ for (const i of sampleIdx) visit(i);
1146
+ } else {
1147
+ for (let i = Math.max(0, i0 - 1); i <= i1; i++) visit(i);
1148
+ }
1149
+ return ticks;
1150
+ }
1151
+
1152
+ /* ------------------------------------------------------------ *
1153
+ * Render
1154
+ * ------------------------------------------------------------ */
1155
+
1156
+ _render() {
1157
+ this._raf = 0;
1158
+ if (!this._connected) return;
1159
+ if (!this._ensureCanvas()) return;
1160
+
1161
+ const ctx = this._ctx;
1162
+ ctx.setTransform(this._dpr, 0, 0, this._dpr, 0, 0);
1163
+ const pal = this._palette();
1164
+ const d = this._renderBars();
1165
+ const W = this._W;
1166
+ const H = this._H;
1167
+
1168
+ /* layout */
1169
+ ctx.font = axisFont();
1170
+ let priceW = 0;
1171
+ const measure = (v) => ctx.measureText(v).width;
1172
+ {
1173
+ const sample = d.length ? d[d.length - 1].close : 0;
1174
+ const p = this._prec(sample || 1);
1175
+ const f = numberFmt(p);
1176
+ priceW = Math.max(
1177
+ 52,
1178
+ Math.ceil(
1179
+ Math.max(
1180
+ measure(f.format(sample || 1000)),
1181
+ measure(fmtCompact(1234567))
1182
+ )
1183
+ ) + 16
1184
+ );
1185
+ }
1186
+ const timeH = 26;
1187
+ const plotRight = Math.max(30, W - priceW);
1188
+ const plotBottom = H - timeH;
1189
+ const paneList = this._ind.panes;
1190
+ const paneArea = paneList.length
1191
+ ? Math.min(
1192
+ Math.round(plotBottom * 0.55),
1193
+ paneList.length * clamp(Math.round(plotBottom * 0.26), 60, 190)
1194
+ )
1195
+ : 0;
1196
+ const eachPaneH = paneList.length ? Math.floor(paneArea / paneList.length) : 0;
1197
+ const mainH = plotBottom - (paneList.length ? paneList.length * (eachPaneH + 1) : 0);
1198
+ const panes = paneList.map((entry, k) => {
1199
+ const y0 = mainH + 1 + k * (eachPaneH + 1);
1200
+ return { entry, y0, y1: y0 + eachPaneH - 1, h: eachPaneH - 1 };
1201
+ });
1202
+ const ly = (this._ly = {
1203
+ W,
1204
+ H,
1205
+ priceW,
1206
+ timeH,
1207
+ plotRight,
1208
+ plotBottom,
1209
+ main: { y0: 0, y1: mainH, h: mainH },
1210
+ panes,
1211
+ });
1212
+
1213
+ /* background */
1214
+ ctx.fillStyle = pal.bg;
1215
+ ctx.fillRect(0, 0, W, H);
1216
+ this._nodata.hidden = d.length > 0;
1217
+ if (!d.length) {
1218
+ this._legend.innerHTML = '';
1219
+ this._poss.innerHTML = '';
1220
+ this._statsRow.innerHTML = '';
1221
+ this._legendKey = 'empty';
1222
+ return;
1223
+ }
1224
+
1225
+ /* view */
1226
+ if (this._needsFit) {
1227
+ this._applyFit();
1228
+ this._needsFit = false;
1229
+ }
1230
+ if (this._auto) this._view.rightIndex = d.length - 1 + this._rightMargin();
1231
+ this._clampView();
1232
+ if (this._pendingRange) {
1233
+ const pr = this._pendingRange;
1234
+ this._pendingRange = null;
1235
+ this.setVisibleRange(pr);
1236
+ }
1237
+
1238
+ const v = this._view;
1239
+ const sp = v.spacing;
1240
+ const count = plotRight / sp;
1241
+ const iLeft = v.rightIndex - count;
1242
+ const i0 = Math.max(0, Math.floor(iLeft) - 1);
1243
+ const i1 = Math.min(d.length - 1, Math.ceil(v.rightIndex) + 1);
1244
+ this._maybeLoadMore(iLeft);
1245
+
1246
+ // deep zoom-out: aggregate bars into ~1px columns so render cost is
1247
+ // bounded by screen width, not history length
1248
+ const needCols = sp < 0.7 && i1 - i0 + 1 > plotRight * 1.5;
1249
+ const cols = needCols
1250
+ ? buildColumns(d, i0, i1, (i) => this._xFor(i), plotRight)
1251
+ : null;
1252
+
1253
+ const scale = this._mainScale(i0, i1, cols);
1254
+ this._lastScale = scale;
1255
+ const { min, max, useLog } = scale;
1256
+ const main = ly.main;
1257
+ const tf = (p) => (useLog ? Math.log10(Math.max(p, 1e-12)) : p);
1258
+ const yOf = (p) =>
1259
+ clamp(main.y0 + ((max - tf(p)) / (max - min)) * main.h, main.y0 - 40, main.y1 + 40);
1260
+ const invY = (y) => {
1261
+ const t = max - ((y - main.y0) / main.h) * (max - min);
1262
+ return useLog ? Math.pow(10, t) : t;
1263
+ };
1264
+
1265
+ /* ticks */
1266
+ const pticks = this._priceTicks(scale, main.h);
1267
+ const tticks = this._timeTicks(
1268
+ i0,
1269
+ i1,
1270
+ cols ? cols.map((c) => c.i1) : null
1271
+ );
1272
+
1273
+ /* grid */
1274
+ ctx.strokeStyle = pal.grid;
1275
+ ctx.lineWidth = 1;
1276
+ ctx.beginPath();
1277
+ for (const t of pticks) {
1278
+ const y = Math.round(yOf(t)) + 0.5;
1279
+ if (y < main.y0 || y > main.y1) continue;
1280
+ ctx.moveTo(0, y);
1281
+ ctx.lineTo(plotRight, y);
1282
+ }
1283
+ for (const t of tticks) {
1284
+ const x = Math.round(t.x) + 0.5;
1285
+ if (x < 0 || x > plotRight) continue;
1286
+ ctx.moveTo(x, 0);
1287
+ ctx.lineTo(x, plotBottom);
1288
+ }
1289
+ ctx.stroke();
1290
+
1291
+ /* position zones (under series) */
1292
+ for (const pos of this._positions) {
1293
+ const yE = clamp(yOf(pos.entry), main.y0, main.y1);
1294
+ if (isNum(pos.target)) {
1295
+ const yT = clamp(yOf(pos.target), main.y0, main.y1);
1296
+ ctx.fillStyle = hexToRgba(pal.up, 0.07);
1297
+ ctx.fillRect(0, Math.min(yE, yT), plotRight, Math.abs(yT - yE));
1298
+ }
1299
+ if (isNum(pos.stop)) {
1300
+ const yS = clamp(yOf(pos.stop), main.y0, main.y1);
1301
+ ctx.fillStyle = hexToRgba(pal.down, 0.07);
1302
+ ctx.fillRect(0, Math.min(yE, yS), plotRight, Math.abs(yS - yE));
1303
+ }
1304
+ }
1305
+
1306
+ /* volume profile (behind the series) */
1307
+ if (this._profile) {
1308
+ const pkey = `${i0}:${i1}:${this._version}`;
1309
+ if (this._profileKey !== pkey) {
1310
+ this._profileRes = computeVolumeProfile(d, i0, i1);
1311
+ this._profileKey = pkey;
1312
+ }
1313
+ const pr = this._profileRes;
1314
+ if (pr) {
1315
+ const fp = numberFmt(this._prec(scale.rawHi || 1));
1316
+ const maxW = plotRight * 0.18;
1317
+ for (let r = 0; r < pr.rows.length; r++) {
1318
+ const row = pr.rows[r];
1319
+ if (!row.v) continue;
1320
+ const yTop = yOf(pr.priceMin + (r + 1) * pr.rowH);
1321
+ const yBot = yOf(pr.priceMin + r * pr.rowH);
1322
+ const w = (row.v / pr.maxV) * maxW;
1323
+ const inVA = r >= pr.valIndex && r <= pr.vahIndex;
1324
+ ctx.globalAlpha = inVA ? 0.38 : 0.2;
1325
+ ctx.fillStyle = row.up >= row.dn ? pal.up : pal.down;
1326
+ ctx.fillRect(plotRight - w, yBot, w, Math.max(1, yTop - yBot - 0.5));
1327
+ }
1328
+ ctx.globalAlpha = 1;
1329
+ // POC
1330
+ ctx.strokeStyle = pal.accent;
1331
+ ctx.setLineDash([6, 4]);
1332
+ const yPoc = Math.round(yOf(pr.poc)) + 0.5;
1333
+ ctx.beginPath();
1334
+ ctx.moveTo(0, yPoc);
1335
+ ctx.lineTo(plotRight, yPoc);
1336
+ ctx.stroke();
1337
+ // value area edges
1338
+ ctx.strokeStyle = pal.guide;
1339
+ ctx.beginPath();
1340
+ for (const [lv, y] of [
1341
+ [pr.vah, Math.round(yOf(pr.vah)) + 0.5],
1342
+ [pr.val, Math.round(yOf(pr.val)) + 0.5],
1343
+ ]) {
1344
+ void lv;
1345
+ ctx.moveTo(0, y);
1346
+ ctx.lineTo(plotRight, y);
1347
+ }
1348
+ ctx.stroke();
1349
+ ctx.setLineDash([]);
1350
+ // right-axis labels
1351
+ ctx.font = axisFont(600);
1352
+ ctx.textAlign = 'right';
1353
+ ctx.textBaseline = 'middle';
1354
+ ctx.fillStyle = pal.accent;
1355
+ ctx.fillText(`POC ${fp.format(pr.poc)}`, W - 6, yPoc);
1356
+ ctx.fillStyle = pal.text;
1357
+ ctx.font = axisFont(500);
1358
+ ctx.fillText(`VAH ${fp.format(pr.vah)}`, W - 6, yOf(pr.vah));
1359
+ ctx.fillText(`VAL ${fp.format(pr.val)}`, W - 6, yOf(pr.val));
1360
+ }
1361
+ }
1362
+
1363
+ /* smart annotations (skipped at deep zoom where bars collapse into columns) */
1364
+ if (this._annotations && !cols) {
1365
+ const akey = `${i0}:${i1}:${this._version}`;
1366
+ if (this._annoKey !== akey) {
1367
+ this._annoList = detectAnnotations(this._data, i0, i1, this._cachedRSI14());
1368
+ this._annoKey = akey;
1369
+ this._legendKey = ''; // legend may now show insights at the hovered bar
1370
+ this.dispatchEvent(
1371
+ new CustomEvent('hab:annotations', { detail: { annotations: this._annoList } })
1372
+ );
1373
+ }
1374
+ const A = this._annoList;
1375
+ if (A.length) {
1376
+ const BADGE_BG = {
1377
+ volspike: '#f0b429',
1378
+ gap: '#22d3ee',
1379
+ pivothigh: '#8b949e',
1380
+ pivotlow: '#8b949e',
1381
+ divbear: '#ea3943',
1382
+ divbull: '#16c784',
1383
+ };
1384
+ const BADGE_TXT = { volspike: 'V', gap: 'G', pivothigh: 'H', pivotlow: 'L', divbear: 'D', divbull: 'D' };
1385
+ for (const a of A) {
1386
+ const x = this._xFor(a.i);
1387
+ if (x < 10 || x > plotRight - 10) continue;
1388
+ const b = d[a.i];
1389
+ if (!b) continue;
1390
+ const y = a.side === 'high' ? yOf(b.high) - 9 : yOf(b.low) + 9;
1391
+ ctx.beginPath();
1392
+ ctx.arc(x, y, 6, 0, Math.PI * 2);
1393
+ ctx.fillStyle = BADGE_BG[a.type] || '#8b949e';
1394
+ ctx.fill();
1395
+ ctx.lineWidth = 1.5;
1396
+ ctx.strokeStyle = pal.bg && pal.bg !== 'transparent' ? pal.bg : '#0d1117';
1397
+ ctx.stroke();
1398
+ ctx.fillStyle = '#ffffff';
1399
+ ctx.font = `700 7.5px ${FONT_STACK}`;
1400
+ ctx.textAlign = 'center';
1401
+ ctx.textBaseline = 'middle';
1402
+ ctx.fillText(BADGE_TXT[a.type] || '?', x, y + 0.5);
1403
+ }
1404
+ ctx.lineWidth = 1;
1405
+ }
1406
+ }
1407
+
1408
+ /* volume overlay */
1409
+ if (this._ind.volume) {
1410
+ let vmax = 0;
1411
+ if (cols) {
1412
+ for (const c of cols) if (c.volume > vmax) vmax = c.volume;
1413
+ } else {
1414
+ for (let i = i0; i <= i1; i++) if (d[i].volume > vmax) vmax = d[i].volume;
1415
+ }
1416
+ if (vmax > 0) {
1417
+ const bodyW = Math.max(1, Math.floor(sp * 0.7));
1418
+ const areaH = main.h * 0.2;
1419
+ ctx.globalAlpha = pal.volAlpha;
1420
+ if (cols) {
1421
+ for (const c of cols) {
1422
+ const h = (c.volume / vmax) * areaH;
1423
+ if (h <= 0) continue;
1424
+ ctx.fillStyle = c.close >= c.open ? pal.up : pal.down;
1425
+ ctx.fillRect(c.x, main.y1 - 1 - h, 1, h);
1426
+ }
1427
+ } else {
1428
+ // two passes by direction: fillStyle set twice instead of per bar
1429
+ for (let pass = 0; pass < 2; pass++) {
1430
+ ctx.fillStyle = pass === 0 ? pal.up : pal.down;
1431
+ for (let i = i0; i <= i1; i++) {
1432
+ const b = d[i];
1433
+ if ((b.close >= b.open) !== (pass === 0)) continue;
1434
+ const h = (b.volume / vmax) * areaH;
1435
+ if (h <= 0) continue;
1436
+ const x = this._xFor(i);
1437
+ ctx.fillRect(Math.round(x - bodyW / 2), main.y1 - 1 - h, bodyW, h);
1438
+ }
1439
+ }
1440
+ }
1441
+ ctx.globalAlpha = 1;
1442
+ }
1443
+ }
1444
+
1445
+ /* series */
1446
+ const tStyle = this._type;
1447
+ if (tStyle === 'candles' || tStyle === 'hollow' || tStyle === 'bars' || tStyle === 'heikin') {
1448
+ if (cols) {
1449
+ // deep zoom: one hi-lo line per pixel column, colored by column direction
1450
+ for (let pass = 0; pass < 2; pass++) {
1451
+ ctx.strokeStyle = pass === 0 ? pal.up : pal.down;
1452
+ ctx.beginPath();
1453
+ for (const c of cols) {
1454
+ if ((c.close >= c.open) !== (pass === 0)) continue;
1455
+ const x = c.x + 0.5;
1456
+ ctx.moveTo(x, yOf(c.high));
1457
+ ctx.lineTo(x, yOf(c.low));
1458
+ }
1459
+ ctx.stroke();
1460
+ }
1461
+ } else {
1462
+ const bodyW = Math.max(1, Math.floor(sp * 0.7));
1463
+ const hollow = tStyle === 'hollow';
1464
+ const barsStyle = tStyle === 'bars';
1465
+ const tickLen = barsStyle ? Math.max(2, Math.floor(sp * 0.35)) : 0;
1466
+ // two passes (up/down): one wick path + one body batch per direction
1467
+ for (let pass = 0; pass < 2; pass++) {
1468
+ ctx.strokeStyle = pass === 0 ? pal.up : pal.down;
1469
+ ctx.fillStyle = ctx.strokeStyle;
1470
+ ctx.beginPath();
1471
+ for (let i = i0; i <= i1; i++) {
1472
+ const b = d[i];
1473
+ if ((b.close >= b.open) !== (pass === 0)) continue;
1474
+ const x = Math.round(this._xFor(i)) + 0.5;
1475
+ ctx.moveTo(x, yOf(b.high));
1476
+ ctx.lineTo(x, yOf(b.low));
1477
+ if (barsStyle && bodyW >= 3) {
1478
+ // OHLC ticks: open to the left, close to the right
1479
+ ctx.moveTo(x - tickLen, yOf(b.open));
1480
+ ctx.lineTo(x, yOf(b.open));
1481
+ ctx.moveTo(x, yOf(b.close));
1482
+ ctx.lineTo(x + tickLen, yOf(b.close));
1483
+ }
1484
+ }
1485
+ ctx.stroke();
1486
+ if (bodyW > 2 && !barsStyle) {
1487
+ const hollowPass = hollow && pass === 0; // up candles are outlined only
1488
+ for (let i = i0; i <= i1; i++) {
1489
+ const b = d[i];
1490
+ if ((b.close >= b.open) !== (pass === 0)) continue;
1491
+ const x = this._xFor(i);
1492
+ const yTop = yOf(Math.max(b.open, b.close));
1493
+ const yBot = yOf(Math.min(b.open, b.close));
1494
+ const h = Math.max(1, yBot - yTop);
1495
+ const bx = Math.round(x - bodyW / 2);
1496
+ if (hollowPass) {
1497
+ ctx.strokeRect(bx + 0.5, yTop + 0.5, Math.max(1, bodyW - 1), Math.max(1, h - 1));
1498
+ } else {
1499
+ ctx.fillRect(bx, yTop, bodyW, h);
1500
+ }
1501
+ }
1502
+ }
1503
+ }
1504
+ }
1505
+ } else {
1506
+ // line / area (column-sampled at deep zoom)
1507
+ const accent = pal.accent;
1508
+ const pts = [];
1509
+ if (cols) {
1510
+ for (const c of cols) pts.push([c.x, yOf(c.close)]);
1511
+ } else {
1512
+ for (let i = i0; i <= i1; i++) pts.push([this._xFor(i), yOf(d[i].close)]);
1513
+ }
1514
+ if (this._type === 'area') {
1515
+ const grad = ctx.createLinearGradient(0, main.y0, 0, main.y1);
1516
+ const c0 = hexToRgba(accent, 0.25);
1517
+ const c1 = hexToRgba(accent, 0.02);
1518
+ if (c0 !== accent || c1 !== accent) {
1519
+ grad.addColorStop(0, c0);
1520
+ grad.addColorStop(1, c1);
1521
+ } else {
1522
+ grad.addColorStop(0, accent);
1523
+ grad.addColorStop(1, accent);
1524
+ }
1525
+ ctx.globalAlpha = c0 !== accent ? 1 : 0.12;
1526
+ ctx.beginPath();
1527
+ ctx.moveTo(pts.length ? pts[0][0] : 0, pts.length ? pts[0][1] : main.y1);
1528
+ for (let k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]);
1529
+ if (pts.length) {
1530
+ ctx.lineTo(pts[pts.length - 1][0], main.y1);
1531
+ ctx.lineTo(pts[0][0], main.y1);
1532
+ }
1533
+ ctx.closePath();
1534
+ ctx.fillStyle = grad;
1535
+ ctx.fill();
1536
+ ctx.globalAlpha = 1;
1537
+ }
1538
+ ctx.beginPath();
1539
+ for (let k = 0; k < pts.length; k++) {
1540
+ if (k === 0) ctx.moveTo(pts[k][0], pts[k][1]);
1541
+ else ctx.lineTo(pts[k][0], pts[k][1]);
1542
+ }
1543
+ ctx.strokeStyle = accent;
1544
+ ctx.lineWidth = 2;
1545
+ ctx.lineJoin = 'round';
1546
+ ctx.lineCap = 'round';
1547
+ ctx.stroke();
1548
+ ctx.lineWidth = 1;
1549
+ // last point dot
1550
+ if (pts.length) {
1551
+ const [lx, lyv] = pts[pts.length - 1];
1552
+ if (lx >= -4 && lx <= plotRight + 4) {
1553
+ ctx.fillStyle = accent;
1554
+ ctx.beginPath();
1555
+ ctx.arc(clamp(lx, 0, plotRight), clamp(lyv, main.y0, main.y1), 2.6, 0, Math.PI * 2);
1556
+ ctx.fill();
1557
+ }
1558
+ }
1559
+ }
1560
+
1561
+ /* overlay indicators */
1562
+ this._ind.overlays.forEach((entry, idx) => {
1563
+ const res = this._indicatorSeries(entry);
1564
+ res.lines.forEach((ln, li) => {
1565
+ const s = ln.values;
1566
+ if (!s) return;
1567
+ const color = this._lineColor(entry, ln, pal, idx + li);
1568
+ ctx.strokeStyle = color;
1569
+ ctx.lineWidth = 1.5;
1570
+ ctx.lineJoin = 'round';
1571
+ ctx.beginPath();
1572
+ let started = false;
1573
+ if (cols) {
1574
+ for (const c of cols) {
1575
+ const val = s[c.i1];
1576
+ if (!isNum(val)) {
1577
+ started = false;
1578
+ continue;
1579
+ }
1580
+ if (!started) {
1581
+ ctx.moveTo(c.x, yOf(val));
1582
+ started = true;
1583
+ } else ctx.lineTo(c.x, yOf(val));
1584
+ }
1585
+ } else {
1586
+ for (let i = i0; i <= i1; i++) {
1587
+ const val = s[i];
1588
+ if (!isNum(val)) {
1589
+ started = false;
1590
+ continue;
1591
+ }
1592
+ const x = this._xFor(i);
1593
+ const y = yOf(val);
1594
+ if (!started) {
1595
+ ctx.moveTo(x, y);
1596
+ started = true;
1597
+ } else ctx.lineTo(x, y);
1598
+ }
1599
+ }
1600
+ ctx.stroke();
1601
+ });
1602
+ ctx.lineWidth = 1;
1603
+ });
1604
+
1605
+ /* session / data-gap dividers */
1606
+ {
1607
+ const gaps = detectGaps(d, i0, i1, this._dt, 3);
1608
+ if (gaps.length) {
1609
+ ctx.save();
1610
+ ctx.strokeStyle = pal.crosshair;
1611
+ ctx.globalAlpha = 0.55;
1612
+ ctx.setLineDash([2, 4]);
1613
+ ctx.lineWidth = 1;
1614
+ ctx.beginPath();
1615
+ for (const gi of gaps) {
1616
+ const x =
1617
+ Math.round((this._xFor(gi - 1) + this._xFor(gi)) / 2) + 0.5;
1618
+ if (x < 0 || x > plotRight) continue;
1619
+ ctx.moveTo(x, 0);
1620
+ ctx.lineTo(x, plotBottom);
1621
+ }
1622
+ ctx.stroke();
1623
+ ctx.restore();
1624
+ }
1625
+ }
1626
+
1627
+ /* position lines, tags & price alerts */
1628
+ if (this._positions.length || this._alerts.length) {
1629
+ const fP = numberFmt(this._prec(scale.rawHi || 1));
1630
+
1631
+ // alerts: dashed lines + diamond marker at the right edge
1632
+ ctx.save();
1633
+ ctx.setLineDash([5, 4]);
1634
+ ctx.strokeStyle = pal.overlay[0];
1635
+ for (const a of this._alerts) {
1636
+ if (a.fired) continue;
1637
+ const y = yOf(a.price);
1638
+ if (y < main.y0 || y > main.y1) continue;
1639
+ ctx.globalAlpha = 0.8;
1640
+ ctx.beginPath();
1641
+ ctx.moveTo(0, Math.round(y) + 0.5);
1642
+ ctx.lineTo(plotRight, Math.round(y) + 0.5);
1643
+ ctx.stroke();
1644
+ ctx.globalAlpha = 1;
1645
+ ctx.fillStyle = pal.overlay[0];
1646
+ const mx = plotRight - 7;
1647
+ ctx.beginPath();
1648
+ ctx.moveTo(mx, y - 4);
1649
+ ctx.lineTo(mx + 4, y);
1650
+ ctx.lineTo(mx, y + 4);
1651
+ ctx.lineTo(mx - 4, y);
1652
+ ctx.closePath();
1653
+ ctx.fill();
1654
+ }
1655
+ ctx.restore();
1656
+
1657
+ for (const pos of this._positions) {
1658
+ const yE = clamp(yOf(pos.entry), main.y0, main.y1);
1659
+ ctx.strokeStyle = pal.accent;
1660
+ ctx.lineWidth = 1.5;
1661
+ ctx.beginPath();
1662
+ ctx.moveTo(0, Math.round(yE) + 0.5);
1663
+ ctx.lineTo(plotRight, Math.round(yE) + 0.5);
1664
+ ctx.stroke();
1665
+ ctx.lineWidth = 1;
1666
+ ctx.setLineDash([4, 3]);
1667
+ for (const [lv, col] of [
1668
+ [pos.stop, pal.down],
1669
+ [pos.target, pal.up],
1670
+ ]) {
1671
+ if (!isNum(lv)) continue;
1672
+ const y = clamp(yOf(lv), main.y0, main.y1);
1673
+ ctx.strokeStyle = col;
1674
+ ctx.beginPath();
1675
+ ctx.moveTo(0, Math.round(y) + 0.5);
1676
+ ctx.lineTo(plotRight, Math.round(y) + 0.5);
1677
+ ctx.stroke();
1678
+ }
1679
+ ctx.setLineDash([]);
1680
+ const tag = `${pos.side === 'short' ? 'S' : 'L'} ${fP.format(pos.entry)}`;
1681
+ ctx.font = pillFont();
1682
+ const tw = ctx.measureText(tag).width + 10;
1683
+ this._pill(plotRight - tw - 8, yE, tag, pal.accent, pal.pillText, 'left', tw);
1684
+ }
1685
+ }
1686
+
1687
+ /* indicator panes */
1688
+ for (const pr of ly.panes) {
1689
+ const entry = pr.entry;
1690
+ const res = this._indicatorSeries(entry);
1691
+ if (!res.lines.length && !res.histogram) continue;
1692
+ const fmtV = (v) =>
1693
+ entry.def.fmt === 'fixed1'
1694
+ ? numberFmt(1).format(v)
1695
+ : numberFmt(this._prec(scale.rawHi || 1)).format(v);
1696
+
1697
+ // pane scale (fixed range or autoscaled from visible values)
1698
+ let pmin = Infinity;
1699
+ let pmax = -Infinity;
1700
+ if (Array.isArray(entry.def.range) && entry.def.range.length === 2) {
1701
+ pmin = entry.def.range[0];
1702
+ pmax = entry.def.range[1];
1703
+ } else {
1704
+ const scan = (arr) => {
1705
+ for (let i = i0; i <= i1; i++) {
1706
+ const v = arr[i];
1707
+ if (isNum(v)) {
1708
+ if (v < pmin) pmin = v;
1709
+ if (v > pmax) pmax = v;
1710
+ }
1711
+ }
1712
+ };
1713
+ for (const ln of res.lines) scan(ln.values);
1714
+ if (res.histogram) scan(res.histogram);
1715
+ if (!isFinite(pmin) || !isFinite(pmax)) {
1716
+ pmin = 0;
1717
+ pmax = 1;
1718
+ }
1719
+ if (pmax === pmin) {
1720
+ const e = Math.abs(pmax) * 0.05 || 1;
1721
+ pmax += e;
1722
+ pmin -= e;
1723
+ }
1724
+ const pad = (pmax - pmin) * 0.08;
1725
+ pmin -= pad;
1726
+ pmax += pad;
1727
+ }
1728
+ const pyOf = (v) => pr.y0 + 5 + ((pmax - v) / (pmax - pmin)) * (pr.h - 10);
1729
+ const invPy = (y) => pmax - ((y - pr.y0 - 5) / (pr.h - 10)) * (pmax - pmin);
1730
+ pr.pyOf = pyOf;
1731
+ pr.invPy = invPy;
1732
+
1733
+ ctx.save();
1734
+ // guides
1735
+ ctx.strokeStyle = pal.guide;
1736
+ ctx.setLineDash([3, 4]);
1737
+ for (const g of entry.def.guides || []) {
1738
+ const y = Math.round(pyOf(g)) + 0.5;
1739
+ if (y < pr.y0 || y > pr.y1) continue;
1740
+ ctx.beginPath();
1741
+ ctx.moveTo(0, y);
1742
+ ctx.lineTo(plotRight, y);
1743
+ ctx.stroke();
1744
+ }
1745
+ ctx.setLineDash([]);
1746
+
1747
+ // histogram (e.g. MACD)
1748
+ if (res.histogram) {
1749
+ const bodyW = Math.max(1, Math.floor(sp * 0.55));
1750
+ const y0 = clamp(pyOf(0), pr.y0, pr.y1);
1751
+ ctx.globalAlpha = 0.55;
1752
+ if (cols) {
1753
+ for (const c of cols) {
1754
+ const val = res.histogram[c.i1];
1755
+ if (!isNum(val)) continue;
1756
+ ctx.fillStyle = val >= 0 ? pal.up : pal.down;
1757
+ const y = pyOf(val);
1758
+ ctx.fillRect(c.x, Math.min(y, y0), 1, Math.max(1, Math.abs(y - y0)));
1759
+ }
1760
+ } else {
1761
+ for (let pass = 0; pass < 2; pass++) {
1762
+ ctx.fillStyle = pass === 0 ? pal.up : pal.down;
1763
+ for (let i = i0; i <= i1; i++) {
1764
+ const v = res.histogram[i];
1765
+ if (!isNum(v)) continue;
1766
+ if ((v >= 0) !== (pass === 0)) continue;
1767
+ const y = pyOf(v);
1768
+ const x = this._xFor(i);
1769
+ ctx.fillRect(
1770
+ Math.round(x - bodyW / 2),
1771
+ Math.min(y, y0),
1772
+ bodyW,
1773
+ Math.max(1, Math.abs(y - y0))
1774
+ );
1775
+ }
1776
+ }
1777
+ }
1778
+ ctx.globalAlpha = 1;
1779
+ }
1780
+
1781
+ // lines
1782
+ res.lines.forEach((ln, li) => {
1783
+ const color = this._lineColor(entry, ln, pal, li);
1784
+ ctx.strokeStyle = color;
1785
+ ctx.lineWidth = 1.5;
1786
+ ctx.lineJoin = 'round';
1787
+ ctx.beginPath();
1788
+ let started = false;
1789
+ const plot = (x, val) => {
1790
+ if (!started) {
1791
+ ctx.moveTo(x, pyOf(val));
1792
+ started = true;
1793
+ } else ctx.lineTo(x, pyOf(val));
1794
+ };
1795
+ if (cols) {
1796
+ for (const c of cols) {
1797
+ const val = ln.values[c.i1];
1798
+ if (!isNum(val)) {
1799
+ started = false;
1800
+ continue;
1801
+ }
1802
+ plot(c.x, val);
1803
+ }
1804
+ } else {
1805
+ for (let i = i0; i <= i1; i++) {
1806
+ const val = ln.values[i];
1807
+ if (!isNum(val)) {
1808
+ started = false;
1809
+ continue;
1810
+ }
1811
+ plot(this._xFor(i), val);
1812
+ }
1813
+ }
1814
+ ctx.stroke();
1815
+ });
1816
+ ctx.lineWidth = 1;
1817
+ ctx.restore();
1818
+
1819
+ // right-axis labels for guide levels
1820
+ ctx.font = axisFont(400);
1821
+ ctx.fillStyle = pal.text;
1822
+ ctx.textAlign = 'right';
1823
+ ctx.textBaseline = 'middle';
1824
+ for (const g of entry.def.guides || []) {
1825
+ ctx.fillText(fmtV(g), W - 6, pyOf(g));
1826
+ }
1827
+
1828
+ // pane label + live values
1829
+ const hi = this._hover ? clamp(this._hover.index, 0, d.length - 1) : d.length - 1;
1830
+ const vals = res.lines
1831
+ .map((ln) => (isNum(ln.values[hi]) ? fmtV(ln.values[hi]) : '—'))
1832
+ .join(' ');
1833
+ ctx.font = axisFont(600);
1834
+ ctx.fillStyle = pal.text;
1835
+ ctx.textAlign = 'left';
1836
+ ctx.textBaseline = 'top';
1837
+ ctx.globalAlpha = 0.9;
1838
+ ctx.fillText(
1839
+ `${entry.name.toUpperCase()} ${Object.values(entry.params).join(' ')}${vals ? ' ' + vals : ''}`,
1840
+ 8,
1841
+ pr.y0 + 5
1842
+ );
1843
+ ctx.globalAlpha = 1;
1844
+ }
1845
+
1846
+ /* pane separators & axis borders */
1847
+ ctx.strokeStyle = pal.border;
1848
+ ctx.beginPath();
1849
+ for (const pr of ly.panes) {
1850
+ const y = Math.round(pr.y0) - 0.5;
1851
+ ctx.moveTo(0, y);
1852
+ ctx.lineTo(W, y);
1853
+ }
1854
+ ctx.moveTo(Math.round(plotRight) + 0.5, 0);
1855
+ ctx.lineTo(Math.round(plotRight) + 0.5, plotBottom);
1856
+ ctx.moveTo(0, Math.round(plotBottom) + 0.5);
1857
+ ctx.lineTo(W, Math.round(plotBottom) + 0.5);
1858
+ ctx.stroke();
1859
+
1860
+ /* axis labels */
1861
+ const f = numberFmt(this._prec(scale.rawHi || 1));
1862
+ ctx.font = axisFont();
1863
+ ctx.fillStyle = pal.text;
1864
+ ctx.textAlign = 'right';
1865
+ ctx.textBaseline = 'middle';
1866
+ for (const t of pticks) {
1867
+ const y = yOf(t);
1868
+ if (y < main.y0 + 7 || y > main.y1 - 5) continue;
1869
+ ctx.fillText(f.format(t), W - 6, y);
1870
+ }
1871
+ ctx.textAlign = 'center';
1872
+ for (const t of tticks) {
1873
+ if (t.x < 10 || t.x > plotRight - 10) continue;
1874
+ ctx.fillText(t.label, t.x, plotBottom + 13);
1875
+ }
1876
+
1877
+ /* last price line + pill */
1878
+ const lastBar = d[d.length - 1];
1879
+ const prevBar = d[d.length - 2] || lastBar;
1880
+ const lastY = clamp(yOf(lastBar.close), main.y0 + 9, main.y1 - 9);
1881
+ const lastUp = lastBar.close >= prevBar.close;
1882
+ if (lastY > main.y0 && lastY < main.y1) {
1883
+ ctx.save();
1884
+ ctx.strokeStyle = lastUp ? pal.up : pal.down;
1885
+ ctx.globalAlpha = 0.7;
1886
+ ctx.setLineDash([1, 3]);
1887
+ ctx.beginPath();
1888
+ ctx.moveTo(0, Math.round(lastY) + 0.5);
1889
+ ctx.lineTo(plotRight, Math.round(lastY) + 0.5);
1890
+ ctx.stroke();
1891
+ ctx.restore();
1892
+ this._pill(
1893
+ plotRight + 2,
1894
+ lastY,
1895
+ f.format(lastBar.close),
1896
+ lastUp ? pal.up : pal.down,
1897
+ pal.pillText,
1898
+ 'left'
1899
+ );
1900
+ }
1901
+
1902
+ /* crosshair */
1903
+ if (this._hover && this._hover.index < d.length) {
1904
+ const h = this._hover;
1905
+ const hx = this._xFor(h.index);
1906
+ ctx.save();
1907
+ ctx.strokeStyle = pal.crosshair;
1908
+ ctx.setLineDash([4, 4]);
1909
+ ctx.beginPath();
1910
+ const cx = Math.round(hx) + 0.5;
1911
+ if (cx >= 0 && cx <= plotRight) {
1912
+ ctx.moveTo(cx, 0);
1913
+ ctx.lineTo(cx, plotBottom);
1914
+ }
1915
+ const inMain = h.y <= main.y1;
1916
+ const paneUnder = inMain
1917
+ ? null
1918
+ : ly.panes.find((p) => h.y >= p.y0 && h.y <= p.y1);
1919
+ if (inMain || paneUnder) {
1920
+ const hy = Math.round(h.y) + 0.5;
1921
+ ctx.moveTo(0, hy);
1922
+ ctx.lineTo(plotRight, hy);
1923
+ }
1924
+ ctx.stroke();
1925
+ ctx.restore();
1926
+
1927
+ // price pill
1928
+ if (inMain) {
1929
+ this._pill(
1930
+ plotRight + 2,
1931
+ h.y,
1932
+ f.format(invY(h.y)),
1933
+ pal.crosshairBg,
1934
+ pal.crosshairText,
1935
+ 'left'
1936
+ );
1937
+ } else if (paneUnder) {
1938
+ const fmtV =
1939
+ paneUnder.entry.def.fmt === 'fixed1'
1940
+ ? (v) => v.toFixed(1)
1941
+ : (v) => f.format(v);
1942
+ this._pill(
1943
+ plotRight + 2,
1944
+ h.y,
1945
+ fmtV(paneUnder.invPy(h.y)),
1946
+ pal.crosshairBg,
1947
+ pal.crosshairText,
1948
+ 'left'
1949
+ );
1950
+ }
1951
+
1952
+ // time pill
1953
+ const tLabel = fmtFull(d[h.index].time);
1954
+ ctx.font = pillFont();
1955
+ const tw = ctx.measureText(tLabel).width + 12;
1956
+ this._pill(
1957
+ clamp(hx - tw / 2, 2, plotRight - tw - 2),
1958
+ plotBottom + 2,
1959
+ tLabel,
1960
+ pal.crosshairBg,
1961
+ pal.crosshairText,
1962
+ 'left',
1963
+ tw
1964
+ );
1965
+ }
1966
+
1967
+ /* co-view ghost crosshair (peer pointer from another tab/chart) */
1968
+ if (this._ghost) {
1969
+ const g = this._ghost;
1970
+ const gx = this._xFor(g.index);
1971
+ const gxVisible = gx >= 0 && gx <= plotRight;
1972
+ ctx.save();
1973
+ ctx.strokeStyle = pal.accent;
1974
+ ctx.globalAlpha = 0.7;
1975
+ ctx.setLineDash([2, 3]);
1976
+ ctx.beginPath();
1977
+ if (gxVisible) {
1978
+ const cx = Math.round(gx) + 0.5;
1979
+ ctx.moveTo(cx, 0);
1980
+ ctx.lineTo(cx, plotBottom);
1981
+ }
1982
+ if (g.yFrac != null) {
1983
+ const gy = Math.round(main.y0 + g.yFrac * main.h) + 0.5;
1984
+ ctx.moveTo(0, gy);
1985
+ ctx.lineTo(plotRight, gy);
1986
+ if (gxVisible) {
1987
+ ctx.fillStyle = pal.accent;
1988
+ ctx.beginPath();
1989
+ ctx.arc(gx, main.y0 + g.yFrac * main.h, 3, 0, Math.PI * 2);
1990
+ ctx.fill();
1991
+ }
1992
+ }
1993
+ ctx.stroke();
1994
+ ctx.restore();
1995
+ if (gxVisible && this._data[g.index]) {
1996
+ const tLabel = fmtFull(this._data[g.index].time);
1997
+ ctx.font = pillFont();
1998
+ const tw = ctx.measureText(tLabel).width + 12;
1999
+ this._pill(
2000
+ clamp(gx - tw / 2, 2, plotRight - tw - 2),
2001
+ plotBottom + 2,
2002
+ tLabel,
2003
+ pal.accent,
2004
+ pal.pillText,
2005
+ 'left',
2006
+ tw
2007
+ );
2008
+ }
2009
+ }
2010
+
2011
+ /* measure tool overlay */
2012
+ if (this._measure && this._measure.pA != null && this._measure.pB != null) {
2013
+ const m = this._measure;
2014
+ const xa = this._xFor(m.iA);
2015
+ const xb = this._xFor(m.iB);
2016
+ const ya = clamp(yOf(m.pA), main.y0, main.y1);
2017
+ const yb = clamp(yOf(m.pB), main.y0, main.y1);
2018
+ const rx0 = Math.min(xa, xb);
2019
+ const rx1 = Math.max(xa, xb);
2020
+ const ry0 = Math.min(ya, yb);
2021
+ const ry1 = Math.max(ya, yb);
2022
+ if (rx1 - rx0 > 2 && ry1 - ry0 > 2) {
2023
+ ctx.save();
2024
+ ctx.fillStyle = hexToRgba(pal.accent, 0.06);
2025
+ ctx.fillRect(rx0, ry0, rx1 - rx0, ry1 - ry0);
2026
+ ctx.strokeStyle = pal.crosshair;
2027
+ ctx.setLineDash([4, 4]);
2028
+ ctx.strokeRect(Math.round(rx0) + 0.5, Math.round(ry0) + 0.5, rx1 - rx0, ry1 - ry0);
2029
+ ctx.restore();
2030
+ }
2031
+ const barsN = Math.abs(m.iB - m.iA);
2032
+ const ms = Math.abs((d[m.iB] ? d[m.iB].time : 0) - (d[m.iA] ? d[m.iA].time : 0));
2033
+ const hrs = Math.floor(ms / 3600e3);
2034
+ const dP = m.pB - m.pA;
2035
+ const dPct = m.pA ? (dP / m.pA) * 100 : 0;
2036
+ const label =
2037
+ `${dP >= 0 ? '+' : ''}${f.format(dP)} (${dPct >= 0 ? '+' : ''}${dPct.toFixed(2)}%)` +
2038
+ ` · ${barsN} bars` +
2039
+ ` · ${hrs >= 24 ? Math.floor(hrs / 24) + 'd ' + (hrs % 24) + 'h' : hrs + 'h'}`;
2040
+ ctx.font = pillFont();
2041
+ const tw = ctx.measureText(label).width + 14;
2042
+ this._pill(
2043
+ clamp((rx0 + rx1) / 2 - tw / 2, 2, plotRight - tw - 2),
2044
+ clamp((ry0 + ry1) / 2, 10, plotBottom - 10),
2045
+ label,
2046
+ pal.crosshairBg,
2047
+ pal.crosshairText,
2048
+ 'left',
2049
+ tw
2050
+ );
2051
+ }
2052
+
2053
+ /* visible-range stats chip */
2054
+ if (this._stats) {
2055
+ const st = computeStats(d, i0, i1, this._dt);
2056
+ const skey = st ? `${i0}:${i1}:${this._version}` : 'none';
2057
+ if (skey !== this._statsKey) {
2058
+ this._statsKey = skey;
2059
+ if (st) {
2060
+ const pct = (v, dgt = 2) => `${v >= 0 ? '+' : ''}${v.toFixed(dgt)}%`;
2061
+ this._statsRow.innerHTML =
2062
+ `<span><b>${pct(st.changePct)}</b></span>` +
2063
+ `<span>maxDD ${st.maxDDPct.toFixed(1)}%</span>` +
2064
+ `<span>ann.vol ${st.annVolPct.toFixed(0)}%</span>` +
2065
+ `<span>up ${st.up} / dn ${st.dn}</span>` +
2066
+ `<span>vol ${fmtCompact(st.avgVolume)}</span>`;
2067
+ } else {
2068
+ this._statsRow.innerHTML = '';
2069
+ }
2070
+ }
2071
+ } else if (this._statsRow.innerHTML) {
2072
+ this._statsRow.innerHTML = '';
2073
+ this._statsKey = '';
2074
+ }
2075
+
2076
+ this._updateLegend();
2077
+ }
2078
+
2079
+ _pill(x, y, text, bg, fg, align = 'left', widthOverride) {
2080
+ const ctx = this._ctx;
2081
+ ctx.save();
2082
+ ctx.font = pillFont();
2083
+ const tw = widthOverride || ctx.measureText(text).width + 12;
2084
+ const th = 18;
2085
+ const yy = clamp(y - th / 2, 0, this._H - th);
2086
+ ctx.fillStyle = bg;
2087
+ roundRectPath(ctx, x, yy, tw, th, 4);
2088
+ ctx.fill();
2089
+ ctx.fillStyle = fg;
2090
+ ctx.textAlign = 'center';
2091
+ ctx.textBaseline = 'middle';
2092
+ ctx.fillText(text, x + tw / 2, yy + th / 2 + 0.5);
2093
+ ctx.restore();
2094
+ }
2095
+
2096
+ _updateLegend() {
2097
+ const d = this._renderBars();
2098
+ if (!d.length) {
2099
+ this._legend.innerHTML = '';
2100
+ return;
2101
+ }
2102
+ const hoverIdx = this._hover ? this._hover.index : d.length - 1;
2103
+ const idx = clamp(hoverIdx, 0, d.length - 1);
2104
+ const key = [
2105
+ idx, this._version, this._type, this._label, this._theme,
2106
+ this.getAttribute('indicators'), this._positions.length, this._posVersion || 0,
2107
+ ].join('|');
2108
+ if (key === this._legendKey) return;
2109
+ this._legendKey = key;
2110
+
2111
+ const b = d[idx];
2112
+ const p = this._prec(b.close);
2113
+ const f = numberFmt(p);
2114
+ const pct = b.open ? ((b.close - b.open) / b.open) * 100 : 0;
2115
+ const up = b.close >= b.open;
2116
+ const cls = up ? 'up' : 'dn';
2117
+ const sign = pct >= 0 ? '+' : '';
2118
+
2119
+ const esc = (s) =>
2120
+ String(s).replace(/[&<>"']/g, (c) =>
2121
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])
2122
+ );
2123
+
2124
+ let html = `<div class="row"><span class="sym">${esc(this._label || '')}</span>`;
2125
+ if (this._type === 'candles') {
2126
+ html +=
2127
+ `<span class="kv"><span class="k">O</span><span class="v">${f.format(b.open)}</span></span>` +
2128
+ `<span class="kv"><span class="k">H</span><span class="v">${f.format(b.high)}</span></span>` +
2129
+ `<span class="kv"><span class="k">L</span><span class="v">${f.format(b.low)}</span></span>`;
2130
+ }
2131
+ html += `<span class="kv"><span class="k">C</span><span class="v ${cls}">${f.format(b.close)}</span></span>`;
2132
+ html += `<span class="pct ${cls}">${sign}${pct.toFixed(2)}%</span>`;
2133
+ if (b.volume > 0 || this._ind.volume) {
2134
+ html += `<span class="kv"><span class="k">Vol</span><span class="v">${fmtCompact(b.volume)}</span></span>`;
2135
+ }
2136
+ html += `</div>`;
2137
+
2138
+ const palNow = this._palette();
2139
+ this._ind.overlays.forEach((entry, i) => {
2140
+ const res = this._indicatorSeries(entry);
2141
+ if (!res.lines.length) return;
2142
+ const dotColor = this._lineColor(entry, res.lines[0], palNow, i);
2143
+ const vals = res.lines
2144
+ .map((ln) => (isNum(ln.values[idx]) ? f.format(ln.values[idx]) : '—'))
2145
+ .join(' ');
2146
+ html +=
2147
+ `<div class="row"><span class="ind">` +
2148
+ `<i style="background:${dotColor}"></i>${entry.name.toUpperCase()} ${Object.values(entry.params).join(' ')}` +
2149
+ `</span><span class="v">${vals}</span></div>`;
2150
+ });
2151
+
2152
+ if (this._annotations && this._annoList) {
2153
+ const notes = this._annoList.filter((a) => a.i === idx).map((a) => a.note);
2154
+ if (notes.length) {
2155
+ html += `<div class="row"><span class="insight">${notes.map(esc).join(' · ')}</span></div>`;
2156
+ }
2157
+ }
2158
+
2159
+ this._legend.innerHTML = html;
2160
+ this._updateHud();
2161
+ }
2162
+
2163
+ /** Position P&L chips (top-right HTML overlay). */
2164
+ _updateHud() {
2165
+ const poss = this._poss;
2166
+ if (!this._positions.length) {
2167
+ if (poss.innerHTML) poss.innerHTML = '';
2168
+ return;
2169
+ }
2170
+ const d = this._data;
2171
+ const price = d.length ? d[d.length - 1].close : NaN;
2172
+ const f = numberFmt(this._prec(price || 1));
2173
+ const esc = (s) =>
2174
+ String(s).replace(/[&<>"']/g, (c) =>
2175
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])
2176
+ );
2177
+ let html = '';
2178
+ for (const p of this._positions) {
2179
+ const pnl = positionPnl(p, price);
2180
+ const pct = p.entry ? (pnl / p.entry) * 100 : 0;
2181
+ const cls = pnl >= 0 ? 'up' : 'dn';
2182
+ const qtyStr = p.qty != null ? ' ' + p.qty : '';
2183
+ html +=
2184
+ `<div class="pos">` +
2185
+ `<span class="k">${esc(p.side === 'short' ? 'SHORT' : 'LONG')}${esc(qtyStr)} @ ${f.format(p.entry)}</span>` +
2186
+ `<span class="v ${cls}">${pnl >= 0 ? '+' : ''}${f.format(pnl)} (${pct >= 0 ? '+' : ''}${pct.toFixed(2)}%)</span>` +
2187
+ `</div>`;
2188
+ }
2189
+ poss.innerHTML = html;
2190
+ }
2191
+
2192
+ /* ------------------------------------------------------------ *
2193
+ * Interaction
2194
+ * ------------------------------------------------------------ */
2195
+
2196
+ _localPoint(e) {
2197
+ const r = this._canvas.getBoundingClientRect();
2198
+ return { x: e.clientX - r.left, y: e.clientY - r.top };
2199
+ }
2200
+
2201
+ _pointerDown(e) {
2202
+ if (e.button !== 0) return;
2203
+ this._canvas.setPointerCapture(e.pointerId);
2204
+ const pt = this._localPoint(e);
2205
+ this._pointers.set(e.pointerId, pt);
2206
+ if (this._pointers.size === 2) {
2207
+ const [a, b] = [...this._pointers.values()];
2208
+ const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
2209
+ this._pinch = {
2210
+ dist: Math.hypot(a.x - b.x, a.y - b.y) || 1,
2211
+ spacing: this._view.spacing,
2212
+ idxAtMid: this._indexForX(mid.x),
2213
+ midX: mid.x,
2214
+ };
2215
+ this._pan = null;
2216
+ this._measuring = false;
2217
+ } else if (e.shiftKey && this._data.length) {
2218
+ // shift+drag → measure tool
2219
+ this._measuring = true;
2220
+ const idx = clamp(Math.round(this._indexForX(pt.x)), 0, this._data.length - 1);
2221
+ this._measure = {
2222
+ iA: idx,
2223
+ pA: this._yToPrice(pt.y),
2224
+ iB: idx,
2225
+ pB: this._yToPrice(pt.y),
2226
+ done: false,
2227
+ };
2228
+ this._pan = null;
2229
+ this._invalidate();
2230
+ } else {
2231
+ this._pan = { x: pt.x, rightIndex: this._view.rightIndex, moved: false };
2232
+ this._canvas.classList.add('grabbing');
2233
+ }
2234
+ }
2235
+
2236
+ _pointerMove(e) {
2237
+ const pt = this._localPoint(e);
2238
+ if (this._pointers.has(e.pointerId)) this._pointers.set(e.pointerId, pt);
2239
+ const ly = this._ly;
2240
+
2241
+ if (this._pinch && this._pointers.size >= 2 && ly) {
2242
+ const [a, b] = [...this._pointers.values()];
2243
+ const dist = Math.hypot(a.x - b.x, a.y - b.y) || 1;
2244
+ const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
2245
+ const s = clamp(
2246
+ (this._pinch.spacing * dist) / this._pinch.dist,
2247
+ this._minSpacing(),
2248
+ HabChart._MAX_SP
2249
+ );
2250
+ this._view.spacing = s;
2251
+ this._view.rightIndex = this._pinch.idxAtMid + (ly.plotRight - mid.x) / s;
2252
+ this._auto = this._atRight();
2253
+ this._clampView();
2254
+ this._hover = null;
2255
+ this._invalidate();
2256
+ this._emitRange();
2257
+ return;
2258
+ }
2259
+
2260
+ if (this._measuring && this._pointers.has(e.pointerId)) {
2261
+ const idx = clamp(Math.round(this._indexForX(pt.x)), 0, this._data.length - 1);
2262
+ this._measure.iB = idx;
2263
+ this._measure.pB = this._yToPrice(pt.y);
2264
+ this._invalidate();
2265
+ return;
2266
+ }
2267
+
2268
+ if (this._pan && this._pointers.has(e.pointerId) && ly) {
2269
+ const dx = pt.x - this._pan.x;
2270
+ if (Math.abs(dx) > 3) this._pan.moved = true;
2271
+ this._view.rightIndex = this._pan.rightIndex - dx / this._view.spacing;
2272
+ this._auto = this._atRight();
2273
+ this._clampView();
2274
+ this._invalidate();
2275
+ this._emitRange();
2276
+ return;
2277
+ }
2278
+
2279
+ if (!ly) return;
2280
+ // hover / crosshair
2281
+ const idx = clamp(Math.round(this._indexForX(pt.x)), 0, this._data.length - 1);
2282
+ this._hover = { index: idx, x: this._xFor(idx), y: pt.y };
2283
+ this._maybeSonify(idx);
2284
+ this._emitCrosshair(this._hover);
2285
+ this._invalidate();
2286
+ }
2287
+
2288
+ _pointerUp(e) {
2289
+ const had = this._pointers.delete(e.pointerId);
2290
+ if (this._pointers.size < 2) this._pinch = null;
2291
+ if (this._pointers.size === 0) {
2292
+ this._canvas.classList.remove('grabbing');
2293
+ if (this._measuring) {
2294
+ this._measuring = false;
2295
+ if (this._measure) {
2296
+ this._measure.done = true;
2297
+ const m = this._measure;
2298
+ const d = this._data;
2299
+ const barA = d[clamp(m.iA, 0, d.length - 1)];
2300
+ const barB = d[clamp(m.iB, 0, d.length - 1)];
2301
+ this.dispatchEvent(
2302
+ new CustomEvent('hab:measure', {
2303
+ detail: {
2304
+ from: { index: m.iA, time: barA.time, price: m.pA },
2305
+ to: { index: m.iB, time: barB.time, price: m.pB },
2306
+ bars: Math.abs(m.iB - m.iA),
2307
+ },
2308
+ })
2309
+ );
2310
+ }
2311
+ } else if (this._pan && had && !this._pan.moved && this._data.length) {
2312
+ if (this._measure) {
2313
+ // a plain click clears a finished measurement
2314
+ this._measure = null;
2315
+ this._invalidate();
2316
+ } else {
2317
+ // tap / click select
2318
+ const pt = this._localPoint(e);
2319
+ const idx = clamp(Math.round(this._indexForX(pt.x)), 0, this._data.length - 1);
2320
+ const price = this._yToPrice(pt.y);
2321
+ this.dispatchEvent(
2322
+ new CustomEvent('hab:select', {
2323
+ detail: { index: idx, bar: this._data[idx], price },
2324
+ })
2325
+ );
2326
+ }
2327
+ }
2328
+ this._pan = null;
2329
+ }
2330
+ }
2331
+
2332
+ _yToPrice(y) {
2333
+ const ly = this._ly;
2334
+ if (!ly || !this._data.length) return null;
2335
+ const scale = this._lastScale;
2336
+ if (!scale) return null;
2337
+ const { min, max, useLog } = scale;
2338
+ const t = max - ((y - ly.main.y0) / ly.main.h) * (max - min);
2339
+ return useLog ? Math.pow(10, t) : t;
2340
+ }
2341
+
2342
+ _wheel(e) {
2343
+ const ly = this._ly;
2344
+ if (!ly || !this._data.length) return;
2345
+ e.preventDefault();
2346
+ const pt = this._localPoint(e);
2347
+ const dx = e.deltaX;
2348
+ const dy = e.deltaY * (e.deltaMode === 1 ? 33 : 1);
2349
+
2350
+ if (Math.abs(dx) > Math.abs(dy) && !e.ctrlKey) {
2351
+ // trackpad horizontal scroll → pan
2352
+ this._view.rightIndex -= dx / this._view.spacing;
2353
+ this._auto = this._atRight();
2354
+ this._clampView();
2355
+ this._invalidate();
2356
+ this._emitRange();
2357
+ return;
2358
+ }
2359
+
2360
+ const factor = Math.exp(-dy * (e.ctrlKey ? 0.008 : 0.0016));
2361
+ const oldSp = this._view.spacing;
2362
+ const newSp = clamp(oldSp * factor, this._minSpacing(), HabChart._MAX_SP);
2363
+ if (newSp === oldSp) return;
2364
+ const idxAtCursor = this._indexForX(pt.x);
2365
+ this._view.spacing = newSp;
2366
+ this._view.rightIndex = idxAtCursor + (ly.plotRight - pt.x) / newSp;
2367
+ this._auto = this._atRight();
2368
+ this._clampView();
2369
+ this._invalidate();
2370
+ this._emitRange();
2371
+ }
2372
+
2373
+ _keydown(e) {
2374
+ const ly = this._ly;
2375
+ if (!ly || !this._data.length) return;
2376
+ const d = this._data;
2377
+ const key = e.key;
2378
+ const step = e.shiftKey ? 10 : 1;
2379
+ let handled = true;
2380
+
2381
+ if (key === 'ArrowLeft' || key === 'ArrowRight') {
2382
+ const cur = this._hover ? this._hover.index : d.length - 1;
2383
+ const idx = clamp(cur + (key === 'ArrowRight' ? step : -step), 0, d.length - 1);
2384
+ this._hover = { index: idx, x: this._xFor(idx), y: this._hover ? this._hover.y : ly.main.y1 * 0.5 };
2385
+ this._maybeSonify(idx);
2386
+ this._emitCrosshair(this._hover);
2387
+ this._invalidate();
2388
+ } else if (key === 'Home') {
2389
+ this._view.rightIndex = Math.min(2 + ly.plotRight / this._view.spacing, d.length - 1);
2390
+ this._auto = this._atRight();
2391
+ this._invalidate();
2392
+ this._emitRange();
2393
+ } else if (key === 'End') {
2394
+ this._view.rightIndex = d.length - 1 + this._rightMargin();
2395
+ this._auto = true;
2396
+ this._invalidate();
2397
+ this._emitRange();
2398
+ } else if (key === '+' || key === '=') {
2399
+ this._view.spacing = clamp(this._view.spacing * 1.25, this._minSpacing(), HabChart._MAX_SP);
2400
+ this._clampView();
2401
+ this._invalidate();
2402
+ this._emitRange();
2403
+ } else if (key === '-' || key === '_') {
2404
+ this._view.spacing = clamp(this._view.spacing / 1.25, this._minSpacing(), HabChart._MAX_SP);
2405
+ this._clampView();
2406
+ this._invalidate();
2407
+ this._emitRange();
2408
+ } else if (key === 'Escape') {
2409
+ this._hover = null;
2410
+ this._measure = null;
2411
+ this._measuring = false;
2412
+ this._emitCrosshair(null);
2413
+ this._invalidate();
2414
+ } else if (key === 'Enter' || key === ' ') {
2415
+ this.fit();
2416
+ } else {
2417
+ handled = false;
2418
+ }
2419
+ if (handled) e.preventDefault();
2420
+ }
2421
+
2422
+ /* ------------------------------------------------------------ *
2423
+ * Sonification — the chart by ear (a11y)
2424
+ * ------------------------------------------------------------ */
2425
+
2426
+ /** Lazily-created shared AudioContext (enable within a user gesture). */
2427
+ _audio() {
2428
+ if (this._actx) return this._actx;
2429
+ const AC = window.AudioContext || window.webkitAudioContext;
2430
+ if (!AC) return null;
2431
+ try {
2432
+ this._actx = new AC();
2433
+ } catch (_) {
2434
+ this._actx = null;
2435
+ }
2436
+ return this._actx;
2437
+ }
2438
+
2439
+ /** Short sine blip; `when` schedules against AudioContext time. */
2440
+ _tone(freq, dur = 0.14, when = 0) {
2441
+ const ctx = this._audio();
2442
+ if (!ctx) return;
2443
+ if (ctx.state === 'suspended') ctx.resume().catch(() => {});
2444
+ const t0 = when || ctx.currentTime;
2445
+ const osc = ctx.createOscillator();
2446
+ const gain = ctx.createGain();
2447
+ osc.type = 'sine';
2448
+ osc.frequency.value = freq;
2449
+ gain.gain.setValueAtTime(0.0001, t0);
2450
+ gain.gain.exponentialRampToValueAtTime(0.18, t0 + 0.012);
2451
+ gain.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
2452
+ osc.connect(gain).connect(ctx.destination);
2453
+ osc.start(t0);
2454
+ osc.stop(t0 + dur + 0.03);
2455
+ }
2456
+
2457
+ /** One tone for a bar's close, pitched by its position on the y-scale. */
2458
+ _sonifyBar(i) {
2459
+ if (!this._sonify || !this._data.length || !this._lastScale) return;
2460
+ const d = this._renderBars();
2461
+ const b = d[clamp(i, 0, d.length - 1)];
2462
+ if (!b) return;
2463
+ this._tone(priceToFreq(b.close, this._lastScale));
2464
+ }
2465
+
2466
+ /** One tone per crosshair bar change (dedupes y-only moves). */
2467
+ _maybeSonify(idx) {
2468
+ if (!this._sonify) return;
2469
+ if (this._lastToneIdx === idx) return;
2470
+ this._lastToneIdx = idx;
2471
+ this._sonifyBar(idx);
2472
+ }
2473
+
2474
+ /**
2475
+ * Play the visible range as a pitch sweep (~4s), riding the crosshair —
2476
+ * the audible equivalent of running your eye along the price line.
2477
+ */
2478
+ playRange() {
2479
+ if (!this._data.length || !this._ly) return;
2480
+ const ctx = this._audio();
2481
+ if (!ctx) return;
2482
+ if (ctx.state === 'suspended') ctx.resume().catch(() => {});
2483
+ const d = this._renderBars();
2484
+ const count = Math.max(2, Math.round(this._ly.plotRight / this._view.spacing));
2485
+ const i0 = clamp(Math.floor(this._view.rightIndex - count) - 1, 0, d.length - 1);
2486
+ const i1 = clamp(Math.ceil(this._view.rightIndex), 0, d.length - 1);
2487
+ if (i1 - i0 < 2) return;
2488
+ const N = Math.min(120, i1 - i0 + 1);
2489
+ const stepMs = Math.min(70, Math.max(24, 4000 / N));
2490
+ const t0 = ctx.currentTime + 0.05;
2491
+ for (let k = 0; k < N; k++) {
2492
+ const i = Math.round(i0 + ((i1 - i0) * k) / (N - 1));
2493
+ const b = d[i];
2494
+ if (!b) continue;
2495
+ this._tone(priceToFreq(b.close, this._lastScale), stepMs / 1000 * 0.9, t0 + (k * stepMs) / 1000);
2496
+ }
2497
+ // ride the crosshair along the sweep for sighted users
2498
+ this._playToken++;
2499
+ const token = this._playToken;
2500
+ let k = 0;
2501
+ const timer = setInterval(() => {
2502
+ if (token !== this._playToken || !this._connected) {
2503
+ clearInterval(timer);
2504
+ return;
2505
+ }
2506
+ if (k >= N) {
2507
+ clearInterval(timer);
2508
+ this._hover = null;
2509
+ this._emitCrosshair(null);
2510
+ this._invalidate();
2511
+ return;
2512
+ }
2513
+ const i = Math.round(i0 + ((i1 - i0) * k) / (N - 1));
2514
+ this._hover = { index: i, x: this._xFor(i), y: this._ly ? this._ly.main.h * 0.5 : 0 };
2515
+ this._invalidate();
2516
+ k++;
2517
+ }, stepMs);
2518
+ }
2519
+
2520
+ _emitCrosshair(hover) {
2521
+ let detail = null;
2522
+ if (hover && this._data[hover.index]) {
2523
+ detail = {
2524
+ index: hover.index,
2525
+ bar: this._data[hover.index],
2526
+ x: hover.x,
2527
+ y: hover.y,
2528
+ price: this._yToPrice(hover.y),
2529
+ };
2530
+ }
2531
+ this.dispatchEvent(new CustomEvent('hab:crosshair', { detail }));
2532
+
2533
+ // co-view: share the pointer with peer charts (leave events bypass throttle)
2534
+ if (this._coviewCh) {
2535
+ if (!detail) {
2536
+ this._coviewSend({ type: 'cross', time: null, yFrac: null });
2537
+ } else {
2538
+ const now = performance.now();
2539
+ if (now - this._coviewLast > 40) {
2540
+ this._coviewLast = now;
2541
+ const ly = this._ly;
2542
+ this._coviewSend({
2543
+ type: 'cross',
2544
+ time: detail.bar.time,
2545
+ yFrac: ly && isNum(detail.y) ? clamp(detail.y / ly.main.h, 0, 1) : null,
2546
+ });
2547
+ }
2548
+ }
2549
+ }
2550
+ }
2551
+
2552
+ /* ------------------------------------------------------------ *
2553
+ * Cross-tab co-view (BroadcastChannel)
2554
+ * ------------------------------------------------------------ */
2555
+
2556
+ /** Join/leave the co-view channel named by the `co-view` attribute. */
2557
+ _setupCoView() {
2558
+ if (this._coviewCh) {
2559
+ try {
2560
+ this._coviewCh.close();
2561
+ } catch (_) {}
2562
+ this._coviewCh = null;
2563
+ }
2564
+ clearTimeout(this._ghostTimer);
2565
+ if (this._ghost) {
2566
+ this._ghost = null;
2567
+ this._invalidate();
2568
+ }
2569
+ const name = this._coviewName;
2570
+ if (!name || !this._connected || typeof BroadcastChannel === 'undefined') return;
2571
+ if (!this._coviewPeer) this._coviewPeer = 'p' + Math.random().toString(36).slice(2, 8);
2572
+ try {
2573
+ const ch = new BroadcastChannel('hab-co-view:' + name);
2574
+ ch.onmessage = (ev) => this._onCoMessage(ev.data);
2575
+ this._coviewCh = ch;
2576
+ } catch (_) {}
2577
+ }
2578
+
2579
+ _coviewSend(msg) {
2580
+ if (!this._coviewCh) return;
2581
+ try {
2582
+ this._coviewCh.postMessage({ v: 1, peer: this._coviewPeer, ...msg });
2583
+ } catch (_) {}
2584
+ }
2585
+
2586
+ _onCoMessage(m) {
2587
+ if (!m || m.v !== 1 || m.peer === this._coviewPeer || m.type !== 'cross') return;
2588
+ if (m.time == null) {
2589
+ if (this._ghost) {
2590
+ this._ghost = null;
2591
+ clearTimeout(this._ghostTimer);
2592
+ this._invalidate();
2593
+ }
2594
+ return;
2595
+ }
2596
+ if (!isNum(m.time) || !this._data.length) return;
2597
+ this._ghost = {
2598
+ index: HabChart._indexForTime(this._data, m.time),
2599
+ yFrac: isNum(m.yFrac) ? clamp(m.yFrac, 0, 1) : null,
2600
+ at: Date.now(),
2601
+ };
2602
+ clearTimeout(this._ghostTimer);
2603
+ this._ghostTimer = setTimeout(() => {
2604
+ this._ghost = null;
2605
+ this._invalidate();
2606
+ }, 2500);
2607
+ this._invalidate();
2608
+ }
2609
+
2610
+ _emitRange() {
2611
+ const r = this.getVisibleRange();
2612
+ if (!r) return;
2613
+ this.dispatchEvent(new CustomEvent('hab:range', { detail: r }));
2614
+ }
2615
+ }
2616
+
2617
+ if (typeof customElements !== 'undefined' && !customElements.get('hab-chart')) {
2618
+ customElements.define('hab-chart', HabChart);
2619
+ }
2620
+
2621
+ export default HabChart;