@livo-build/charts 0.2.1 → 0.2.3

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.
@@ -1,11 +1,42 @@
1
1
  import { DEFAULT_THEME } from "./theme";
2
- import { draw } from "./renderer";
3
- import { computeIndicator, INDICATOR_PALETTE } from "./indicators";
2
+ import { heikinAshi } from "./ohlc";
3
+ import { computeProjection, draw, resolveOverlays, slotWidth } from "./renderer";
4
+ let drawingSeq = 0;
5
+ const nextDrawingId = () => `d${++drawingSeq}`;
6
+ /**
7
+ * Whether a feed should be asked for older candles, given the loaded length and the
8
+ * current view. Returns true when the visible window's left edge is within ~one screen
9
+ * (`max(floor, visibleCount)`) of the start of loaded data — so zooming out / panning
10
+ * left keeps deepening the window (prefetching a viewport of buffer) instead of hitting
11
+ * a wall at the oldest loaded candle and just fattening the bars. Pure + exported so the
12
+ * prefetch trigger is testable without a DOM.
13
+ */
14
+ export function needsHistory(length, count, offset, floor = 8) {
15
+ if (length <= 0)
16
+ return false;
17
+ const visible = Math.min(count, length);
18
+ const start = Math.max(0, length - offset - visible);
19
+ return start <= Math.max(floor, visible);
20
+ }
21
+ /** Largest pan offset that still fills the window — pins the OLDEST candle to the left edge
22
+ * so a left-drag can't expose empty space past the start of the data. Pure + exported for
23
+ * testing; the controller clamps every pan/zoom to it. */
24
+ export function maxPanOffset(length, count) {
25
+ return Math.max(0, length - Math.min(count, length));
26
+ }
27
+ /** Pixel distance from point P to segment A–B (for drawing hit-testing). */
28
+ function distToSeg(px, py, ax, ay, bx, by) {
29
+ const dx = bx - ax;
30
+ const dy = by - ay;
31
+ const len2 = dx * dx + dy * dy;
32
+ const t = len2 ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2)) : 0;
33
+ return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
34
+ }
4
35
  const DEFAULTS = {
5
36
  height: 420,
6
37
  initialBars: 120,
7
38
  minBars: 20,
8
- maxBarWidth: 18,
39
+ maxBarWidth: 26, // max candle SLOT width when fitContent is off (tight, right-anchored)
9
40
  volumeRatio: 0.18,
10
41
  rightPad: 66,
11
42
  bottomPad: 22,
@@ -32,11 +63,25 @@ export class Chart {
32
63
  constructor(container, opts = {}) {
33
64
  this.container = container;
34
65
  this.candles = [];
66
+ /** Price-series candles (Heikin-Ashi in "heikin" mode), precomputed on data/type change
67
+ * so the O(n) transform doesn't run every frame. */
68
+ this.priceCandles = [];
35
69
  this.indicators = [];
70
+ this.oscillators = [];
71
+ this.drawings = [];
72
+ this.drawMode = "none";
73
+ this.selectedDrawing = null;
74
+ /** in-progress trendline (drag from anchor A to B). */
75
+ this.draftDrawing = null;
76
+ /** active move of an existing drawing: its id + the data-space grab anchor + originals. */
77
+ this.drawingDrag = null;
36
78
  /** Indicator values precomputed on data/indicator change (not per frame). */
37
79
  this.overlays = [];
38
80
  this.interval = 300;
39
81
  this.type = "candle";
82
+ this.showVolume = true;
83
+ this.logScale = false;
84
+ this.fitContent = true;
40
85
  /** requestAnimationFrame handle coalescing high-frequency redraws (0 = none queued). */
41
86
  this.raf = 0;
42
87
  this.historyReqLen = -1;
@@ -45,9 +90,18 @@ export class Chart {
45
90
  this.yZoom = 1;
46
91
  this.hover = null;
47
92
  this.drag = null;
93
+ /** two-finger pinch baseline: finger spreads + view at gesture start. */
94
+ this.pinch = null;
48
95
  this.lastActive = null;
49
96
  this.onWheel = (e) => {
50
97
  e.preventDefault();
98
+ // Horizontal intent (trackpad swipe / shift+wheel) pans through time — which lazy-loads
99
+ // older candles as the view nears the start (see maybeRequestHistory). Vertical zooms.
100
+ if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
101
+ this.panByPixels(e.deltaX);
102
+ this.scheduleRender();
103
+ return;
104
+ }
51
105
  const f = e.deltaY > 0 ? 1.15 : 0.87;
52
106
  if (e.offsetX > this.plotW) {
53
107
  this.yZoom = Math.min(50, Math.max(0.2, this.yZoom / f));
@@ -59,8 +113,39 @@ export class Chart {
59
113
  };
60
114
  this.onDown = (e) => {
61
115
  const r = this.canvas.getBoundingClientRect();
116
+ const x = e.clientX - r.left;
117
+ const y = e.clientY - r.top;
118
+ const proj = this.projection();
119
+ if (proj && this.region(x, y) === "plot") {
120
+ if (this.drawMode === "hline") {
121
+ const d = { id: nextDrawingId(), type: "hline", a: { time: proj.timeOfX(x), price: proj.priceOfY(y) } };
122
+ this.drawings = [...this.drawings, d];
123
+ this.selectedDrawing = d.id;
124
+ this.emitDrawings();
125
+ this.exitDrawMode();
126
+ this.render();
127
+ return;
128
+ }
129
+ if (this.drawMode === "trendline" || this.drawMode === "fib" || this.drawMode === "rect") {
130
+ const p = { time: proj.timeOfX(x), price: proj.priceOfY(y) };
131
+ this.draftDrawing = { id: nextDrawingId(), type: this.drawMode, a: p, b: { ...p } };
132
+ this.render();
133
+ return;
134
+ }
135
+ const hit = this.hitTest(x, y, proj);
136
+ if (hit) {
137
+ this.selectedDrawing = hit.id;
138
+ this.drawingDrag = { id: hit.id, grabT: proj.timeOfX(x), grabP: proj.priceOfY(y), a: { ...hit.a }, b: hit.b ? { ...hit.b } : undefined };
139
+ this.render();
140
+ return;
141
+ }
142
+ if (this.selectedDrawing) {
143
+ this.selectedDrawing = null;
144
+ this.render();
145
+ }
146
+ }
62
147
  this.drag = {
63
- reg: this.region(e.clientX - r.left, e.clientY - r.top),
148
+ reg: this.region(x, y),
64
149
  x: e.clientX,
65
150
  y: e.clientY,
66
151
  offset: this.view.offset,
@@ -70,42 +155,140 @@ export class Chart {
70
155
  };
71
156
  this.onMove = (e) => {
72
157
  const r = this.canvas.getBoundingClientRect();
73
- const d = this.drag;
74
- if (d) {
75
- if (d.reg === "y") {
76
- this.yZoom = Math.min(50, Math.max(0.2, d.yZoom * Math.exp(-(e.clientY - d.y) / 160)));
77
- }
78
- else if (d.reg === "x") {
79
- const n = this.candles.length;
80
- this.view = { ...this.view, count: Math.min(Math.max(this.minBars, Math.round(d.count * Math.exp((e.clientX - d.x) / 260))), Math.max(this.minBars, n)) };
81
- }
82
- else {
83
- const cw = Math.min(this.plotW / Math.max(Math.min(this.view.count, this.candles.length || 1), 1), this.maxBarWidth);
84
- const offset = Math.max(0, Math.min(this.candles.length - this.minBars, d.offset + Math.round((e.clientX - d.x) / cw)));
85
- this.view = { ...this.view, offset };
86
- }
87
- this.canvas.style.cursor = "grabbing";
158
+ const x = e.clientX - r.left;
159
+ const y = e.clientY - r.top;
160
+ if (this.draftDrawing) {
161
+ const proj = this.projection();
162
+ if (proj)
163
+ this.draftDrawing = { ...this.draftDrawing, b: { time: proj.timeOfX(x), price: proj.priceOfY(y) } };
164
+ this.hover = { x, y };
165
+ this.scheduleRender();
166
+ return;
167
+ }
168
+ if (this.drawingDrag) {
169
+ const proj = this.projection();
170
+ if (proj)
171
+ this.applyDrawingDrag(x, y, proj);
172
+ this.hover = { x, y };
173
+ this.scheduleRender();
174
+ return;
88
175
  }
89
- this.hover = { x: e.clientX - r.left, y: e.clientY - r.top };
176
+ if (this.applyDrag(e.clientX, e.clientY))
177
+ this.canvas.style.cursor = "grabbing";
178
+ this.hover = { x, y };
90
179
  this.scheduleRender();
91
180
  };
92
181
  this.onUp = () => {
182
+ if (this.draftDrawing) {
183
+ const d = this.draftDrawing;
184
+ this.draftDrawing = null;
185
+ // commit only a real drag — a click without movement is discarded
186
+ if (d.b && (d.a.time !== d.b.time || d.a.price !== d.b.price)) {
187
+ this.drawings = [...this.drawings, d];
188
+ this.selectedDrawing = d.id;
189
+ this.emitDrawings();
190
+ }
191
+ this.exitDrawMode();
192
+ this.render();
193
+ return;
194
+ }
195
+ if (this.drawingDrag) {
196
+ this.drawingDrag = null;
197
+ this.emitDrawings();
198
+ }
93
199
  this.drag = null;
94
- this.canvas.style.cursor = "crosshair";
200
+ this.canvas.style.cursor = this.drawMode === "none" ? "crosshair" : "copy";
95
201
  };
96
202
  this.onLeave = () => {
97
203
  this.drag = null;
204
+ if (this.drawingDrag) {
205
+ this.drawingDrag = null;
206
+ this.emitDrawings();
207
+ }
208
+ this.draftDrawing = null;
98
209
  this.hover = null;
99
- this.canvas.style.cursor = "crosshair";
210
+ this.canvas.style.cursor = this.drawMode === "none" ? "crosshair" : "copy";
100
211
  this.scheduleRender();
101
212
  };
102
- this.onDouble = () => {
213
+ this.onDouble = (e) => {
214
+ const r = this.canvas.getBoundingClientRect();
215
+ const proj = this.projection();
216
+ if (proj) {
217
+ const hit = this.hitTest(e.clientX - r.left, e.clientY - r.top, proj);
218
+ if (hit) {
219
+ this.removeDrawing(hit.id);
220
+ return;
221
+ }
222
+ }
103
223
  this.resetView();
104
224
  };
225
+ // ---- touch: one finger pans / axis-zooms (like the mouse), two fingers pinch-zoom ----
226
+ this.onTouchStart = (e) => {
227
+ const r = this.canvas.getBoundingClientRect();
228
+ if (e.touches.length >= 2) {
229
+ const [a, b] = [e.touches[0], e.touches[1]];
230
+ this.drag = null;
231
+ this.pinch = {
232
+ dx: Math.abs(a.clientX - b.clientX) || 1,
233
+ dy: Math.abs(a.clientY - b.clientY) || 1,
234
+ cx: (a.clientX + b.clientX) / 2 - r.left,
235
+ count: this.view.count,
236
+ offset: this.view.offset,
237
+ yZoom: this.yZoom,
238
+ };
239
+ }
240
+ else if (e.touches.length === 1) {
241
+ const t = e.touches[0];
242
+ const x = t.clientX - r.left;
243
+ const y = t.clientY - r.top;
244
+ this.pinch = null;
245
+ this.drag = { reg: this.region(x, y), x: t.clientX, y: t.clientY, offset: this.view.offset, count: this.view.count, yZoom: this.yZoom };
246
+ this.hover = { x, y };
247
+ }
248
+ e.preventDefault();
249
+ this.scheduleRender();
250
+ };
251
+ this.onTouchMove = (e) => {
252
+ const r = this.canvas.getBoundingClientRect();
253
+ if (this.pinch && e.touches.length >= 2) {
254
+ const [a, b] = [e.touches[0], e.touches[1]];
255
+ const dx = Math.abs(a.clientX - b.clientX) || 1;
256
+ const dy = Math.abs(a.clientY - b.clientY) || 1;
257
+ // Reset to the gesture baseline, then apply an anchored time-zoom by the horizontal
258
+ // spread ratio (spread wider → fewer candles) and a price-zoom by the vertical ratio.
259
+ this.view = { count: this.pinch.count, offset: this.pinch.offset };
260
+ this.zoomTimeAt(this.pinch.cx, this.pinch.dx / dx);
261
+ this.yZoom = Math.min(50, Math.max(0.2, this.pinch.yZoom * (dy / this.pinch.dy)));
262
+ }
263
+ else if (this.drag && e.touches.length === 1) {
264
+ const t = e.touches[0];
265
+ this.applyDrag(t.clientX, t.clientY);
266
+ this.hover = { x: t.clientX - r.left, y: t.clientY - r.top };
267
+ }
268
+ e.preventDefault();
269
+ this.scheduleRender();
270
+ };
271
+ this.onTouchEnd = (e) => {
272
+ if (e.touches.length === 0) {
273
+ this.drag = null;
274
+ this.pinch = null;
275
+ this.hover = null;
276
+ }
277
+ else if (e.touches.length === 1) {
278
+ // dropped from two fingers to one — restart a pan from the remaining finger.
279
+ this.pinch = null;
280
+ const r = this.canvas.getBoundingClientRect();
281
+ const t = e.touches[0];
282
+ this.drag = { reg: this.region(t.clientX - r.left, t.clientY - r.top), x: t.clientX, y: t.clientY, offset: this.view.offset, count: this.view.count, yZoom: this.yZoom };
283
+ }
284
+ this.scheduleRender();
285
+ };
105
286
  this.theme = { ...DEFAULT_THEME, ...(opts.theme || {}) };
106
287
  this.height = opts.height ?? DEFAULTS.height;
107
288
  this.minBars = opts.minBars ?? DEFAULTS.minBars;
108
289
  this.maxBarWidth = opts.maxBarWidth ?? DEFAULTS.maxBarWidth;
290
+ this.maxBodyWidth = opts.maxBodyWidth;
291
+ this.showVolume = opts.showVolume ?? true;
109
292
  this.volumeRatio = opts.volumeRatio ?? DEFAULTS.volumeRatio;
110
293
  this.onCrosshair = opts.onCrosshair;
111
294
  this.pads = {
@@ -115,6 +298,20 @@ export class Chart {
115
298
  };
116
299
  this.view = { count: opts.initialBars ?? DEFAULTS.initialBars, offset: 0 };
117
300
  this.indicators = opts.indicators ?? [];
301
+ this.oscillators = opts.oscillators ?? [];
302
+ this.drawings = opts.drawings ?? [];
303
+ this.onDrawingsChange = opts.onDrawingsChange;
304
+ this.onDrawModeChange = opts.onDrawModeChange;
305
+ this.logScale = opts.logScale ?? false;
306
+ this.emptyText = opts.emptyText;
307
+ this.baselinePrice = opts.baselinePrice;
308
+ this.volumeProfile = opts.volumeProfile;
309
+ this.fitContent = opts.fitContent ?? true;
310
+ this.priceFormat = opts.priceFormat;
311
+ this.timeFormat = opts.timeFormat;
312
+ this.priceTicks = opts.priceTicks;
313
+ this.timeTicks = opts.timeTicks;
314
+ this.axisFont = opts.axisFont;
118
315
  this.onNeedHistory = opts.onNeedHistory;
119
316
  const doc = container.ownerDocument;
120
317
  this.canvas = doc.createElement("canvas");
@@ -131,12 +328,17 @@ export class Chart {
131
328
  this.canvas.addEventListener("mouseup", this.onUp);
132
329
  this.canvas.addEventListener("mouseleave", this.onLeave);
133
330
  this.canvas.addEventListener("dblclick", this.onDouble);
331
+ this.canvas.addEventListener("touchstart", this.onTouchStart, { passive: false });
332
+ this.canvas.addEventListener("touchmove", this.onTouchMove, { passive: false });
333
+ this.canvas.addEventListener("touchend", this.onTouchEnd);
334
+ this.canvas.style.touchAction = "none";
134
335
  this.ro = new ResizeObserver(() => this.measure());
135
336
  this.ro.observe(container);
136
337
  this.measure();
137
338
  }
138
339
  setCandles(candles) {
139
340
  this.candles = candles;
341
+ this.recomputePriceCandles();
140
342
  this.recomputeOverlays();
141
343
  this.clampView();
142
344
  this.render();
@@ -149,6 +351,59 @@ export class Chart {
149
351
  }
150
352
  setChartType(type) {
151
353
  this.type = type;
354
+ this.recomputePriceCandles();
355
+ this.render();
356
+ return this;
357
+ }
358
+ /** Show or hide the volume panel (the price pane reclaims the space when hidden). */
359
+ setShowVolume(on) {
360
+ this.showVolume = on;
361
+ this.render();
362
+ return this;
363
+ }
364
+ /** Toggle the logarithmic price axis (equal pixels = equal % move). */
365
+ setLogScale(on) {
366
+ this.logScale = on;
367
+ this.render();
368
+ return this;
369
+ }
370
+ /** Set the empty-state text (e.g. "Loading…" while a feed's first page is in flight; "" hides it). */
371
+ setEmptyText(text) {
372
+ this.emptyText = text;
373
+ this.render();
374
+ return this;
375
+ }
376
+ /** Set the reference price for the `baseline` chart type (undefined = first visible close). */
377
+ setBaseline(price) {
378
+ this.baselinePrice = price;
379
+ this.render();
380
+ return this;
381
+ }
382
+ /** Show/configure (or hide, with `false`) the volume-by-price histogram. */
383
+ setVolumeProfile(config) {
384
+ this.volumeProfile = config;
385
+ this.render();
386
+ return this;
387
+ }
388
+ /** Style the axes: fill-vs-tight candle spacing, custom price/time label formatters,
389
+ * tick counts, and the label font. Only the provided keys change. */
390
+ setAxis(opts) {
391
+ if (opts.fitContent !== undefined)
392
+ this.fitContent = opts.fitContent;
393
+ if (opts.priceFormat !== undefined)
394
+ this.priceFormat = opts.priceFormat;
395
+ if (opts.timeFormat !== undefined)
396
+ this.timeFormat = opts.timeFormat;
397
+ if (opts.priceTicks !== undefined)
398
+ this.priceTicks = opts.priceTicks;
399
+ if (opts.timeTicks !== undefined)
400
+ this.timeTicks = opts.timeTicks;
401
+ if (opts.axisFont !== undefined)
402
+ this.axisFont = opts.axisFont;
403
+ if (opts.maxBarWidth !== undefined)
404
+ this.maxBarWidth = opts.maxBarWidth;
405
+ if (opts.maxBodyWidth !== undefined)
406
+ this.maxBodyWidth = opts.maxBodyWidth;
152
407
  this.render();
153
408
  return this;
154
409
  }
@@ -158,6 +413,63 @@ export class Chart {
158
413
  this.render();
159
414
  return this;
160
415
  }
416
+ /** Set the oscillator sub-panes (RSI / MACD) drawn below the volume panel. */
417
+ setOscillators(oscillators) {
418
+ this.oscillators = oscillators;
419
+ this.render();
420
+ return this;
421
+ }
422
+ /** Arm a drawing tool ("trendline" / "hline"), or "none" for pan/zoom + select/move.
423
+ * Drawing tools are one-shot: after one drawing the mode auto-resets to "none". */
424
+ setDrawMode(mode) {
425
+ this.drawMode = mode;
426
+ this.canvas.style.cursor = mode === "none" ? "crosshair" : "copy";
427
+ return this;
428
+ }
429
+ /** Replace all drawings (does not fire onDrawingsChange). */
430
+ setDrawings(drawings) {
431
+ this.drawings = drawings;
432
+ this.selectedDrawing = null;
433
+ this.render();
434
+ return this;
435
+ }
436
+ /** Current drawings (a copy). */
437
+ getDrawings() {
438
+ return this.drawings.map((d) => ({ ...d }));
439
+ }
440
+ /** Remove a drawing by id. */
441
+ removeDrawing(id) {
442
+ this.drawings = this.drawings.filter((d) => d.id !== id);
443
+ if (this.selectedDrawing === id)
444
+ this.selectedDrawing = null;
445
+ this.emitDrawings();
446
+ this.render();
447
+ return this;
448
+ }
449
+ /** Remove the currently selected drawing, if any. */
450
+ deleteSelected() {
451
+ if (this.selectedDrawing)
452
+ this.removeDrawing(this.selectedDrawing);
453
+ return this;
454
+ }
455
+ /** Remove all drawings. */
456
+ clearDrawings() {
457
+ if (!this.drawings.length)
458
+ return this;
459
+ this.drawings = [];
460
+ this.selectedDrawing = null;
461
+ this.emitDrawings();
462
+ this.render();
463
+ return this;
464
+ }
465
+ emitDrawings() {
466
+ this.onDrawingsChange?.(this.getDrawings());
467
+ }
468
+ exitDrawMode() {
469
+ this.drawMode = "none";
470
+ this.canvas.style.cursor = "crosshair";
471
+ this.onDrawModeChange?.("none");
472
+ }
161
473
  /** Register a callback fired when the user pans/zooms near the start of loaded data
162
474
  * (so a feed can lazily load older candles). See connectFeed. */
163
475
  setNeedHistory(cb) {
@@ -189,12 +501,17 @@ export class Chart {
189
501
  this.canvas.removeEventListener("mouseup", this.onUp);
190
502
  this.canvas.removeEventListener("mouseleave", this.onLeave);
191
503
  this.canvas.removeEventListener("dblclick", this.onDouble);
504
+ this.canvas.removeEventListener("touchstart", this.onTouchStart);
505
+ this.canvas.removeEventListener("touchmove", this.onTouchMove);
506
+ this.canvas.removeEventListener("touchend", this.onTouchEnd);
192
507
  this.ro.disconnect();
193
508
  if (this.raf)
194
509
  this.container.ownerDocument.defaultView?.cancelAnimationFrame(this.raf);
195
510
  this.canvas.remove();
196
511
  }
197
512
  get volH() {
513
+ if (!this.showVolume)
514
+ return 0;
198
515
  return Math.round((this.height - this.pads.bottom - this.pads.top) * this.volumeRatio);
199
516
  }
200
517
  get plotW() {
@@ -210,31 +527,68 @@ export class Chart {
210
527
  this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
211
528
  this.render();
212
529
  }
530
+ /** Largest pan offset that still fills the window — pins the OLDEST candle to the left
531
+ * edge instead of letting a left-drag expose empty space past the start of the data.
532
+ * (When a feed is attached, reaching this edge trips the older-history load, so the
533
+ * data deepens and this max grows; without one, the pan simply stops here.) */
534
+ maxOffset(count = this.view.count) {
535
+ return maxPanOffset(this.candles.length, count);
536
+ }
537
+ /** Most-negative offset — how far the view may scroll PAST the newest candle into the
538
+ * future (empty space on the right), so the user can pull the current price toward the
539
+ * centre. Capped at half the visible window. */
540
+ minOffset(count = this.view.count) {
541
+ return -Math.floor(Math.min(count, this.candles.length) / 2);
542
+ }
213
543
  clampView() {
214
544
  const n = this.candles.length;
215
545
  const count = Math.min(Math.max(this.minBars, this.view.count), Math.max(this.minBars, n || this.minBars));
216
- const offset = Math.min(Math.max(0, this.view.offset), Math.max(0, n - this.minBars));
546
+ const offset = Math.min(Math.max(this.minOffset(count), this.view.offset), this.maxOffset(count));
217
547
  this.view = { count, offset };
218
548
  }
219
- render() {
220
- if (!this.width)
221
- return;
222
- const active = draw({
549
+ /** Assemble the {@link RenderInput} from current state — shared by render() and projection(). */
550
+ buildInput() {
551
+ return {
223
552
  ctx: this.ctx,
224
553
  width: this.width,
225
554
  height: this.height,
226
555
  candles: this.candles,
556
+ priceCandles: this.priceCandles,
227
557
  view: this.view,
228
558
  hover: this.hover,
229
559
  interval: this.interval,
230
560
  type: this.type,
231
561
  yZoom: this.yZoom,
232
562
  maxBarWidth: this.maxBarWidth,
563
+ maxBodyWidth: this.maxBodyWidth,
233
564
  volH: this.volH,
234
565
  theme: this.theme,
235
566
  pads: this.pads,
236
567
  overlays: this.overlays,
237
- });
568
+ oscillators: this.oscillators,
569
+ drawings: this.drawings,
570
+ drawPreview: this.draftDrawing,
571
+ selectedDrawing: this.selectedDrawing,
572
+ logScale: this.logScale,
573
+ emptyText: this.emptyText,
574
+ baselinePrice: this.baselinePrice,
575
+ volumeProfile: this.volumeProfile,
576
+ fitContent: this.fitContent,
577
+ priceFormat: this.priceFormat,
578
+ timeFormat: this.timeFormat,
579
+ priceTicks: this.priceTicks,
580
+ timeTicks: this.timeTicks,
581
+ axisFont: this.axisFont,
582
+ };
583
+ }
584
+ /** Pixel↔data projection for the current frame (null when there are no candles). */
585
+ projection() {
586
+ return computeProjection(this.buildInput());
587
+ }
588
+ render() {
589
+ if (!this.width)
590
+ return;
591
+ const active = draw(this.buildInput());
238
592
  if (this.onCrosshair && active !== this.lastActive) {
239
593
  this.lastActive = active;
240
594
  this.onCrosshair(active);
@@ -257,19 +611,21 @@ export class Chart {
257
611
  }
258
612
  /** Recompute indicator value-series once when data or config changes (not per frame). */
259
613
  recomputeOverlays() {
260
- this.overlays = this.indicators.map((ind, i) => ({
261
- color: ind.color || INDICATOR_PALETTE[i % INDICATOR_PALETTE.length],
262
- values: computeIndicator(this.candles, ind),
263
- }));
614
+ this.overlays = resolveOverlays(this.candles, this.indicators);
615
+ }
616
+ /** Recompute the price-series candles (Heikin-Ashi transform) on data/type change, so the
617
+ * O(n) pass doesn't run on every redraw or projection lookup. */
618
+ recomputePriceCandles() {
619
+ this.priceCandles = this.type === "heikin" ? heikinAshi(this.candles) : this.candles;
264
620
  }
265
- /** When the view nears the start of loaded data, ask the feed for older candles
266
- * (once per data length, so it stops at the end of history). */
621
+ /** When the view nears the start of loaded data, ask the feed for older candles. Fires
622
+ * at most once per data length, so it advances page-by-page and stops at the end of
623
+ * history. The trigger leads the left edge by ~one viewport (see {@link needsHistory}),
624
+ * so zooming out keeps loading deeper data rather than stalling at the oldest candle. */
267
625
  maybeRequestHistory() {
268
626
  if (!this.onNeedHistory || !this.candles.length)
269
627
  return;
270
- const end = this.candles.length - this.view.offset;
271
- const start = Math.max(0, end - Math.min(this.view.count, this.candles.length));
272
- if (start <= this.historyThreshold && this.historyReqLen !== this.candles.length) {
628
+ if (needsHistory(this.candles.length, this.view.count, this.view.offset, this.historyThreshold) && this.historyReqLen !== this.candles.length) {
273
629
  this.historyReqLen = this.candles.length;
274
630
  this.onNeedHistory();
275
631
  }
@@ -281,6 +637,16 @@ export class Chart {
281
637
  return "x";
282
638
  return "plot";
283
639
  }
640
+ /** Pan the time axis by a horizontal pixel delta (wheel/trackpad). Scrolling right
641
+ * (`dx > 0`) moves toward newer candles; scrolling left walks back into history. */
642
+ panByPixels(dx) {
643
+ const n = this.candles.length;
644
+ if (!n)
645
+ return;
646
+ const cw = slotWidth(this.plotW, Math.min(this.view.count, n), this.maxBarWidth, this.fitContent) || 1;
647
+ const offset = Math.min(this.maxOffset(), Math.max(this.minOffset(), this.view.offset - Math.round(dx / cw)));
648
+ this.view = { ...this.view, offset };
649
+ }
284
650
  /** Zoom the time axis by factor `f`, keeping the candle under `cursorX` anchored. */
285
651
  zoomTimeAt(cursorX, f) {
286
652
  const n = this.candles.length;
@@ -293,7 +659,84 @@ export class Chart {
293
659
  const count = Math.round(Math.min(Math.max(this.minBars, this.view.count * f), Math.max(this.minBars, n)));
294
660
  const nVisNew = Math.min(count, n);
295
661
  const startNew = absUnder - Math.round(frac * (nVisNew - 1));
296
- const offset = Math.max(0, Math.min(Math.max(0, n - this.minBars), n - startNew - nVisNew));
662
+ const offset = Math.max(0, Math.min(this.maxOffset(count), n - startNew - nVisNew));
297
663
  this.view = { count, offset };
298
664
  }
665
+ /** Topmost drawing under the pointer (within tolerance), or null. */
666
+ hitTest(x, y, proj) {
667
+ const TOL = 6;
668
+ for (let i = this.drawings.length - 1; i >= 0; i--) {
669
+ const d = this.drawings[i];
670
+ if (d.type === "hline") {
671
+ if (Math.abs(y - proj.yOfPrice(d.a.price)) <= TOL)
672
+ return d;
673
+ }
674
+ else if (d.type === "fib" && d.b) {
675
+ const ax = proj.xOfTime(d.a.time);
676
+ const bx = proj.xOfTime(d.b.time);
677
+ if (x >= Math.min(ax, bx) - TOL) {
678
+ const p0 = d.a.price;
679
+ const p1 = d.b.price;
680
+ for (const r of [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1]) {
681
+ if (Math.abs(y - proj.yOfPrice(p0 + (p1 - p0) * r)) <= TOL)
682
+ return d;
683
+ }
684
+ }
685
+ if (distToSeg(x, y, ax, proj.yOfPrice(d.a.price), bx, proj.yOfPrice(d.b.price)) <= TOL)
686
+ return d;
687
+ }
688
+ else if (d.type === "rect" && d.b) {
689
+ const ax = proj.xOfTime(d.a.time);
690
+ const ay = proj.yOfPrice(d.a.price);
691
+ const bx = proj.xOfTime(d.b.time);
692
+ const by = proj.yOfPrice(d.b.price);
693
+ if (x >= Math.min(ax, bx) - TOL && x <= Math.max(ax, bx) + TOL && y >= Math.min(ay, by) - TOL && y <= Math.max(ay, by) + TOL)
694
+ return d;
695
+ }
696
+ else if (d.b) {
697
+ const dist = distToSeg(x, y, proj.xOfTime(d.a.time), proj.yOfPrice(d.a.price), proj.xOfTime(d.b.time), proj.yOfPrice(d.b.price));
698
+ if (dist <= TOL)
699
+ return d;
700
+ }
701
+ }
702
+ return null;
703
+ }
704
+ /** Translate the dragged drawing by the pointer's data-space delta from the grab anchor. */
705
+ applyDrawingDrag(x, y, proj) {
706
+ const dd = this.drawingDrag;
707
+ if (!dd)
708
+ return;
709
+ const dt = proj.timeOfX(x) - dd.grabT;
710
+ const dp = proj.priceOfY(y) - dd.grabP;
711
+ this.drawings = this.drawings.map((d) => d.id !== dd.id
712
+ ? d
713
+ : {
714
+ ...d,
715
+ a: { time: dd.a.time + dt, price: dd.a.price + dp },
716
+ b: dd.b ? { time: dd.b.time + dt, price: dd.b.price + dp } : undefined,
717
+ });
718
+ }
719
+ /** Apply an in-progress drag (pan or axis-zoom) from the current pointer position.
720
+ * Shared by mouse-move and single-finger touch-move. Returns true if a drag is active. */
721
+ applyDrag(clientX, clientY) {
722
+ const d = this.drag;
723
+ if (!d)
724
+ return false;
725
+ if (d.reg === "y") {
726
+ this.yZoom = Math.min(50, Math.max(0.2, d.yZoom * Math.exp(-(clientY - d.y) / 160)));
727
+ }
728
+ else if (d.reg === "x") {
729
+ const n = this.candles.length;
730
+ const count = Math.min(Math.max(this.minBars, Math.round(d.count * Math.exp((clientX - d.x) / 260))), Math.max(this.minBars, n));
731
+ // a wider window changes the offset bounds — re-clamp so zooming the x-axis never voids
732
+ // the left nor over-scrolls the right.
733
+ this.view = { count, offset: Math.min(this.maxOffset(count), Math.max(this.minOffset(count), this.view.offset)) };
734
+ }
735
+ else {
736
+ const cw = slotWidth(this.plotW, Math.min(this.view.count, this.candles.length || 1), this.maxBarWidth, this.fitContent);
737
+ const offset = Math.min(this.maxOffset(), Math.max(this.minOffset(), d.offset + Math.round((clientX - d.x) / cw)));
738
+ this.view = { ...this.view, offset };
739
+ }
740
+ return true;
741
+ }
299
742
  }