@livo-build/charts 0.2.1 → 0.2.2

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,20 @@
1
1
  import { DEFAULT_THEME } from "./theme";
2
- import { draw } from "./renderer";
3
- import { computeIndicator, INDICATOR_PALETTE } from "./indicators";
2
+ import { computeProjection, draw, resolveOverlays, slotWidth } from "./renderer";
3
+ let drawingSeq = 0;
4
+ const nextDrawingId = () => `d${++drawingSeq}`;
5
+ /** Pixel distance from point P to segment A–B (for drawing hit-testing). */
6
+ function distToSeg(px, py, ax, ay, bx, by) {
7
+ const dx = bx - ax;
8
+ const dy = by - ay;
9
+ const len2 = dx * dx + dy * dy;
10
+ const t = len2 ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2)) : 0;
11
+ return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
12
+ }
4
13
  const DEFAULTS = {
5
14
  height: 420,
6
15
  initialBars: 120,
7
16
  minBars: 20,
8
- maxBarWidth: 18,
17
+ maxBarWidth: 26, // max candle SLOT width when fitContent is off (tight, right-anchored)
9
18
  volumeRatio: 0.18,
10
19
  rightPad: 66,
11
20
  bottomPad: 22,
@@ -33,10 +42,21 @@ export class Chart {
33
42
  this.container = container;
34
43
  this.candles = [];
35
44
  this.indicators = [];
45
+ this.oscillators = [];
46
+ this.drawings = [];
47
+ this.drawMode = "none";
48
+ this.selectedDrawing = null;
49
+ /** in-progress trendline (drag from anchor A to B). */
50
+ this.draftDrawing = null;
51
+ /** active move of an existing drawing: its id + the data-space grab anchor + originals. */
52
+ this.drawingDrag = null;
36
53
  /** Indicator values precomputed on data/indicator change (not per frame). */
37
54
  this.overlays = [];
38
55
  this.interval = 300;
39
56
  this.type = "candle";
57
+ this.showVolume = true;
58
+ this.logScale = false;
59
+ this.fitContent = true;
40
60
  /** requestAnimationFrame handle coalescing high-frequency redraws (0 = none queued). */
41
61
  this.raf = 0;
42
62
  this.historyReqLen = -1;
@@ -45,6 +65,8 @@ export class Chart {
45
65
  this.yZoom = 1;
46
66
  this.hover = null;
47
67
  this.drag = null;
68
+ /** two-finger pinch baseline: finger spreads + view at gesture start. */
69
+ this.pinch = null;
48
70
  this.lastActive = null;
49
71
  this.onWheel = (e) => {
50
72
  e.preventDefault();
@@ -59,8 +81,39 @@ export class Chart {
59
81
  };
60
82
  this.onDown = (e) => {
61
83
  const r = this.canvas.getBoundingClientRect();
84
+ const x = e.clientX - r.left;
85
+ const y = e.clientY - r.top;
86
+ const proj = this.projection();
87
+ if (proj && this.region(x, y) === "plot") {
88
+ if (this.drawMode === "hline") {
89
+ const d = { id: nextDrawingId(), type: "hline", a: { time: proj.timeOfX(x), price: proj.priceOfY(y) } };
90
+ this.drawings = [...this.drawings, d];
91
+ this.selectedDrawing = d.id;
92
+ this.emitDrawings();
93
+ this.exitDrawMode();
94
+ this.render();
95
+ return;
96
+ }
97
+ if (this.drawMode === "trendline") {
98
+ const p = { time: proj.timeOfX(x), price: proj.priceOfY(y) };
99
+ this.draftDrawing = { id: nextDrawingId(), type: "trendline", a: p, b: { ...p } };
100
+ this.render();
101
+ return;
102
+ }
103
+ const hit = this.hitTest(x, y, proj);
104
+ if (hit) {
105
+ this.selectedDrawing = hit.id;
106
+ this.drawingDrag = { id: hit.id, grabT: proj.timeOfX(x), grabP: proj.priceOfY(y), a: { ...hit.a }, b: hit.b ? { ...hit.b } : undefined };
107
+ this.render();
108
+ return;
109
+ }
110
+ if (this.selectedDrawing) {
111
+ this.selectedDrawing = null;
112
+ this.render();
113
+ }
114
+ }
62
115
  this.drag = {
63
- reg: this.region(e.clientX - r.left, e.clientY - r.top),
116
+ reg: this.region(x, y),
64
117
  x: e.clientX,
65
118
  y: e.clientY,
66
119
  offset: this.view.offset,
@@ -70,42 +123,140 @@ export class Chart {
70
123
  };
71
124
  this.onMove = (e) => {
72
125
  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";
126
+ const x = e.clientX - r.left;
127
+ const y = e.clientY - r.top;
128
+ if (this.draftDrawing) {
129
+ const proj = this.projection();
130
+ if (proj)
131
+ this.draftDrawing = { ...this.draftDrawing, b: { time: proj.timeOfX(x), price: proj.priceOfY(y) } };
132
+ this.hover = { x, y };
133
+ this.scheduleRender();
134
+ return;
88
135
  }
89
- this.hover = { x: e.clientX - r.left, y: e.clientY - r.top };
136
+ if (this.drawingDrag) {
137
+ const proj = this.projection();
138
+ if (proj)
139
+ this.applyDrawingDrag(x, y, proj);
140
+ this.hover = { x, y };
141
+ this.scheduleRender();
142
+ return;
143
+ }
144
+ if (this.applyDrag(e.clientX, e.clientY))
145
+ this.canvas.style.cursor = "grabbing";
146
+ this.hover = { x, y };
90
147
  this.scheduleRender();
91
148
  };
92
149
  this.onUp = () => {
150
+ if (this.draftDrawing) {
151
+ const d = this.draftDrawing;
152
+ this.draftDrawing = null;
153
+ // commit only a real drag — a click without movement is discarded
154
+ if (d.b && (d.a.time !== d.b.time || d.a.price !== d.b.price)) {
155
+ this.drawings = [...this.drawings, d];
156
+ this.selectedDrawing = d.id;
157
+ this.emitDrawings();
158
+ }
159
+ this.exitDrawMode();
160
+ this.render();
161
+ return;
162
+ }
163
+ if (this.drawingDrag) {
164
+ this.drawingDrag = null;
165
+ this.emitDrawings();
166
+ }
93
167
  this.drag = null;
94
- this.canvas.style.cursor = "crosshair";
168
+ this.canvas.style.cursor = this.drawMode === "none" ? "crosshair" : "copy";
95
169
  };
96
170
  this.onLeave = () => {
97
171
  this.drag = null;
172
+ if (this.drawingDrag) {
173
+ this.drawingDrag = null;
174
+ this.emitDrawings();
175
+ }
176
+ this.draftDrawing = null;
98
177
  this.hover = null;
99
- this.canvas.style.cursor = "crosshair";
178
+ this.canvas.style.cursor = this.drawMode === "none" ? "crosshair" : "copy";
100
179
  this.scheduleRender();
101
180
  };
102
- this.onDouble = () => {
181
+ this.onDouble = (e) => {
182
+ const r = this.canvas.getBoundingClientRect();
183
+ const proj = this.projection();
184
+ if (proj) {
185
+ const hit = this.hitTest(e.clientX - r.left, e.clientY - r.top, proj);
186
+ if (hit) {
187
+ this.removeDrawing(hit.id);
188
+ return;
189
+ }
190
+ }
103
191
  this.resetView();
104
192
  };
193
+ // ---- touch: one finger pans / axis-zooms (like the mouse), two fingers pinch-zoom ----
194
+ this.onTouchStart = (e) => {
195
+ const r = this.canvas.getBoundingClientRect();
196
+ if (e.touches.length >= 2) {
197
+ const [a, b] = [e.touches[0], e.touches[1]];
198
+ this.drag = null;
199
+ this.pinch = {
200
+ dx: Math.abs(a.clientX - b.clientX) || 1,
201
+ dy: Math.abs(a.clientY - b.clientY) || 1,
202
+ cx: (a.clientX + b.clientX) / 2 - r.left,
203
+ count: this.view.count,
204
+ offset: this.view.offset,
205
+ yZoom: this.yZoom,
206
+ };
207
+ }
208
+ else if (e.touches.length === 1) {
209
+ const t = e.touches[0];
210
+ const x = t.clientX - r.left;
211
+ const y = t.clientY - r.top;
212
+ this.pinch = null;
213
+ this.drag = { reg: this.region(x, y), x: t.clientX, y: t.clientY, offset: this.view.offset, count: this.view.count, yZoom: this.yZoom };
214
+ this.hover = { x, y };
215
+ }
216
+ e.preventDefault();
217
+ this.scheduleRender();
218
+ };
219
+ this.onTouchMove = (e) => {
220
+ const r = this.canvas.getBoundingClientRect();
221
+ if (this.pinch && e.touches.length >= 2) {
222
+ const [a, b] = [e.touches[0], e.touches[1]];
223
+ const dx = Math.abs(a.clientX - b.clientX) || 1;
224
+ const dy = Math.abs(a.clientY - b.clientY) || 1;
225
+ // Reset to the gesture baseline, then apply an anchored time-zoom by the horizontal
226
+ // spread ratio (spread wider → fewer candles) and a price-zoom by the vertical ratio.
227
+ this.view = { count: this.pinch.count, offset: this.pinch.offset };
228
+ this.zoomTimeAt(this.pinch.cx, this.pinch.dx / dx);
229
+ this.yZoom = Math.min(50, Math.max(0.2, this.pinch.yZoom * (dy / this.pinch.dy)));
230
+ }
231
+ else if (this.drag && e.touches.length === 1) {
232
+ const t = e.touches[0];
233
+ this.applyDrag(t.clientX, t.clientY);
234
+ this.hover = { x: t.clientX - r.left, y: t.clientY - r.top };
235
+ }
236
+ e.preventDefault();
237
+ this.scheduleRender();
238
+ };
239
+ this.onTouchEnd = (e) => {
240
+ if (e.touches.length === 0) {
241
+ this.drag = null;
242
+ this.pinch = null;
243
+ this.hover = null;
244
+ }
245
+ else if (e.touches.length === 1) {
246
+ // dropped from two fingers to one — restart a pan from the remaining finger.
247
+ this.pinch = null;
248
+ const r = this.canvas.getBoundingClientRect();
249
+ const t = e.touches[0];
250
+ 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 };
251
+ }
252
+ this.scheduleRender();
253
+ };
105
254
  this.theme = { ...DEFAULT_THEME, ...(opts.theme || {}) };
106
255
  this.height = opts.height ?? DEFAULTS.height;
107
256
  this.minBars = opts.minBars ?? DEFAULTS.minBars;
108
257
  this.maxBarWidth = opts.maxBarWidth ?? DEFAULTS.maxBarWidth;
258
+ this.maxBodyWidth = opts.maxBodyWidth;
259
+ this.showVolume = opts.showVolume ?? true;
109
260
  this.volumeRatio = opts.volumeRatio ?? DEFAULTS.volumeRatio;
110
261
  this.onCrosshair = opts.onCrosshair;
111
262
  this.pads = {
@@ -115,6 +266,17 @@ export class Chart {
115
266
  };
116
267
  this.view = { count: opts.initialBars ?? DEFAULTS.initialBars, offset: 0 };
117
268
  this.indicators = opts.indicators ?? [];
269
+ this.oscillators = opts.oscillators ?? [];
270
+ this.drawings = opts.drawings ?? [];
271
+ this.onDrawingsChange = opts.onDrawingsChange;
272
+ this.onDrawModeChange = opts.onDrawModeChange;
273
+ this.logScale = opts.logScale ?? false;
274
+ this.fitContent = opts.fitContent ?? true;
275
+ this.priceFormat = opts.priceFormat;
276
+ this.timeFormat = opts.timeFormat;
277
+ this.priceTicks = opts.priceTicks;
278
+ this.timeTicks = opts.timeTicks;
279
+ this.axisFont = opts.axisFont;
118
280
  this.onNeedHistory = opts.onNeedHistory;
119
281
  const doc = container.ownerDocument;
120
282
  this.canvas = doc.createElement("canvas");
@@ -131,6 +293,10 @@ export class Chart {
131
293
  this.canvas.addEventListener("mouseup", this.onUp);
132
294
  this.canvas.addEventListener("mouseleave", this.onLeave);
133
295
  this.canvas.addEventListener("dblclick", this.onDouble);
296
+ this.canvas.addEventListener("touchstart", this.onTouchStart, { passive: false });
297
+ this.canvas.addEventListener("touchmove", this.onTouchMove, { passive: false });
298
+ this.canvas.addEventListener("touchend", this.onTouchEnd);
299
+ this.canvas.style.touchAction = "none";
134
300
  this.ro = new ResizeObserver(() => this.measure());
135
301
  this.ro.observe(container);
136
302
  this.measure();
@@ -152,12 +318,103 @@ export class Chart {
152
318
  this.render();
153
319
  return this;
154
320
  }
321
+ /** Show or hide the volume panel (the price pane reclaims the space when hidden). */
322
+ setShowVolume(on) {
323
+ this.showVolume = on;
324
+ this.render();
325
+ return this;
326
+ }
327
+ /** Toggle the logarithmic price axis (equal pixels = equal % move). */
328
+ setLogScale(on) {
329
+ this.logScale = on;
330
+ this.render();
331
+ return this;
332
+ }
333
+ /** Style the axes: fill-vs-tight candle spacing, custom price/time label formatters,
334
+ * tick counts, and the label font. Only the provided keys change. */
335
+ setAxis(opts) {
336
+ if (opts.fitContent !== undefined)
337
+ this.fitContent = opts.fitContent;
338
+ if (opts.priceFormat !== undefined)
339
+ this.priceFormat = opts.priceFormat;
340
+ if (opts.timeFormat !== undefined)
341
+ this.timeFormat = opts.timeFormat;
342
+ if (opts.priceTicks !== undefined)
343
+ this.priceTicks = opts.priceTicks;
344
+ if (opts.timeTicks !== undefined)
345
+ this.timeTicks = opts.timeTicks;
346
+ if (opts.axisFont !== undefined)
347
+ this.axisFont = opts.axisFont;
348
+ if (opts.maxBarWidth !== undefined)
349
+ this.maxBarWidth = opts.maxBarWidth;
350
+ if (opts.maxBodyWidth !== undefined)
351
+ this.maxBodyWidth = opts.maxBodyWidth;
352
+ this.render();
353
+ return this;
354
+ }
155
355
  setIndicators(indicators) {
156
356
  this.indicators = indicators;
157
357
  this.recomputeOverlays();
158
358
  this.render();
159
359
  return this;
160
360
  }
361
+ /** Set the oscillator sub-panes (RSI / MACD) drawn below the volume panel. */
362
+ setOscillators(oscillators) {
363
+ this.oscillators = oscillators;
364
+ this.render();
365
+ return this;
366
+ }
367
+ /** Arm a drawing tool ("trendline" / "hline"), or "none" for pan/zoom + select/move.
368
+ * Drawing tools are one-shot: after one drawing the mode auto-resets to "none". */
369
+ setDrawMode(mode) {
370
+ this.drawMode = mode;
371
+ this.canvas.style.cursor = mode === "none" ? "crosshair" : "copy";
372
+ return this;
373
+ }
374
+ /** Replace all drawings (does not fire onDrawingsChange). */
375
+ setDrawings(drawings) {
376
+ this.drawings = drawings;
377
+ this.selectedDrawing = null;
378
+ this.render();
379
+ return this;
380
+ }
381
+ /** Current drawings (a copy). */
382
+ getDrawings() {
383
+ return this.drawings.map((d) => ({ ...d }));
384
+ }
385
+ /** Remove a drawing by id. */
386
+ removeDrawing(id) {
387
+ this.drawings = this.drawings.filter((d) => d.id !== id);
388
+ if (this.selectedDrawing === id)
389
+ this.selectedDrawing = null;
390
+ this.emitDrawings();
391
+ this.render();
392
+ return this;
393
+ }
394
+ /** Remove the currently selected drawing, if any. */
395
+ deleteSelected() {
396
+ if (this.selectedDrawing)
397
+ this.removeDrawing(this.selectedDrawing);
398
+ return this;
399
+ }
400
+ /** Remove all drawings. */
401
+ clearDrawings() {
402
+ if (!this.drawings.length)
403
+ return this;
404
+ this.drawings = [];
405
+ this.selectedDrawing = null;
406
+ this.emitDrawings();
407
+ this.render();
408
+ return this;
409
+ }
410
+ emitDrawings() {
411
+ this.onDrawingsChange?.(this.getDrawings());
412
+ }
413
+ exitDrawMode() {
414
+ this.drawMode = "none";
415
+ this.canvas.style.cursor = "crosshair";
416
+ this.onDrawModeChange?.("none");
417
+ }
161
418
  /** Register a callback fired when the user pans/zooms near the start of loaded data
162
419
  * (so a feed can lazily load older candles). See connectFeed. */
163
420
  setNeedHistory(cb) {
@@ -189,12 +446,17 @@ export class Chart {
189
446
  this.canvas.removeEventListener("mouseup", this.onUp);
190
447
  this.canvas.removeEventListener("mouseleave", this.onLeave);
191
448
  this.canvas.removeEventListener("dblclick", this.onDouble);
449
+ this.canvas.removeEventListener("touchstart", this.onTouchStart);
450
+ this.canvas.removeEventListener("touchmove", this.onTouchMove);
451
+ this.canvas.removeEventListener("touchend", this.onTouchEnd);
192
452
  this.ro.disconnect();
193
453
  if (this.raf)
194
454
  this.container.ownerDocument.defaultView?.cancelAnimationFrame(this.raf);
195
455
  this.canvas.remove();
196
456
  }
197
457
  get volH() {
458
+ if (!this.showVolume)
459
+ return 0;
198
460
  return Math.round((this.height - this.pads.bottom - this.pads.top) * this.volumeRatio);
199
461
  }
200
462
  get plotW() {
@@ -216,10 +478,9 @@ export class Chart {
216
478
  const offset = Math.min(Math.max(0, this.view.offset), Math.max(0, n - this.minBars));
217
479
  this.view = { count, offset };
218
480
  }
219
- render() {
220
- if (!this.width)
221
- return;
222
- const active = draw({
481
+ /** Assemble the {@link RenderInput} from current state — shared by render() and projection(). */
482
+ buildInput() {
483
+ return {
223
484
  ctx: this.ctx,
224
485
  width: this.width,
225
486
  height: this.height,
@@ -230,11 +491,32 @@ export class Chart {
230
491
  type: this.type,
231
492
  yZoom: this.yZoom,
232
493
  maxBarWidth: this.maxBarWidth,
494
+ maxBodyWidth: this.maxBodyWidth,
233
495
  volH: this.volH,
234
496
  theme: this.theme,
235
497
  pads: this.pads,
236
498
  overlays: this.overlays,
237
- });
499
+ oscillators: this.oscillators,
500
+ drawings: this.drawings,
501
+ drawPreview: this.draftDrawing,
502
+ selectedDrawing: this.selectedDrawing,
503
+ logScale: this.logScale,
504
+ fitContent: this.fitContent,
505
+ priceFormat: this.priceFormat,
506
+ timeFormat: this.timeFormat,
507
+ priceTicks: this.priceTicks,
508
+ timeTicks: this.timeTicks,
509
+ axisFont: this.axisFont,
510
+ };
511
+ }
512
+ /** Pixel↔data projection for the current frame (null when there are no candles). */
513
+ projection() {
514
+ return computeProjection(this.buildInput());
515
+ }
516
+ render() {
517
+ if (!this.width)
518
+ return;
519
+ const active = draw(this.buildInput());
238
520
  if (this.onCrosshair && active !== this.lastActive) {
239
521
  this.lastActive = active;
240
522
  this.onCrosshair(active);
@@ -257,10 +539,7 @@ export class Chart {
257
539
  }
258
540
  /** Recompute indicator value-series once when data or config changes (not per frame). */
259
541
  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
- }));
542
+ this.overlays = resolveOverlays(this.candles, this.indicators);
264
543
  }
265
544
  /** When the view nears the start of loaded data, ask the feed for older candles
266
545
  * (once per data length, so it stops at the end of history). */
@@ -296,4 +575,56 @@ export class Chart {
296
575
  const offset = Math.max(0, Math.min(Math.max(0, n - this.minBars), n - startNew - nVisNew));
297
576
  this.view = { count, offset };
298
577
  }
578
+ /** Topmost drawing under the pointer (within tolerance), or null. */
579
+ hitTest(x, y, proj) {
580
+ const TOL = 6;
581
+ for (let i = this.drawings.length - 1; i >= 0; i--) {
582
+ const d = this.drawings[i];
583
+ if (d.type === "hline") {
584
+ if (Math.abs(y - proj.yOfPrice(d.a.price)) <= TOL)
585
+ return d;
586
+ }
587
+ else if (d.b) {
588
+ 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));
589
+ if (dist <= TOL)
590
+ return d;
591
+ }
592
+ }
593
+ return null;
594
+ }
595
+ /** Translate the dragged drawing by the pointer's data-space delta from the grab anchor. */
596
+ applyDrawingDrag(x, y, proj) {
597
+ const dd = this.drawingDrag;
598
+ if (!dd)
599
+ return;
600
+ const dt = proj.timeOfX(x) - dd.grabT;
601
+ const dp = proj.priceOfY(y) - dd.grabP;
602
+ this.drawings = this.drawings.map((d) => d.id !== dd.id
603
+ ? d
604
+ : {
605
+ ...d,
606
+ a: { time: dd.a.time + dt, price: dd.a.price + dp },
607
+ b: dd.b ? { time: dd.b.time + dt, price: dd.b.price + dp } : undefined,
608
+ });
609
+ }
610
+ /** Apply an in-progress drag (pan or axis-zoom) from the current pointer position.
611
+ * Shared by mouse-move and single-finger touch-move. Returns true if a drag is active. */
612
+ applyDrag(clientX, clientY) {
613
+ const d = this.drag;
614
+ if (!d)
615
+ return false;
616
+ if (d.reg === "y") {
617
+ this.yZoom = Math.min(50, Math.max(0.2, d.yZoom * Math.exp(-(clientY - d.y) / 160)));
618
+ }
619
+ else if (d.reg === "x") {
620
+ const n = this.candles.length;
621
+ this.view = { ...this.view, count: Math.min(Math.max(this.minBars, Math.round(d.count * Math.exp((clientX - d.x) / 260))), Math.max(this.minBars, n)) };
622
+ }
623
+ else {
624
+ const cw = slotWidth(this.plotW, Math.min(this.view.count, this.candles.length || 1), this.maxBarWidth, this.fitContent);
625
+ const offset = Math.max(0, Math.min(this.candles.length - this.minBars, d.offset + Math.round((clientX - d.x) / cw)));
626
+ this.view = { ...this.view, offset };
627
+ }
628
+ return true;
629
+ }
299
630
  }
@@ -1,5 +1,17 @@
1
- /** Compact value formatter: large numbers grouped (K/M/B), small numbers to significant digits. */
1
+ /**
2
+ * Value formatter for price axes + OHLC readouts. M/B compaction only kicks in at
3
+ * a million — between $1k and $1M the FULL grouped number is shown (1,730 not the
4
+ * lossy "1.73K"), so adjacent axis gridlines and a candle's O/H/L/C stay
5
+ * distinguishable on mid-priced assets. Trailing zeros are dropped.
6
+ */
2
7
  export declare function formatValue(v: number): string;
8
+ /**
9
+ * Axis value formatter that adapts decimal precision to the tick `step` so adjacent
10
+ * gridline labels stay distinct. The compact formatter rounds to 2 decimals (≈$10 at
11
+ * the "K" scale), which collapses a 1.71K–1.75K axis into duplicate "1.73K"s; passing
12
+ * the per-tick step keeps just enough digits to separate them (e.g. 1.7350K / 1.7430K).
13
+ */
14
+ export declare function formatAxisValue(v: number, step?: number): string;
3
15
  /** Volume formatter with a leading `$`. */
4
16
  export declare function formatVolume(v: number): string;
5
17
  /** Axis time label whose granularity follows the bucket `interval` (seconds). */
@@ -1,4 +1,9 @@
1
- /** Compact value formatter: large numbers grouped (K/M/B), small numbers to significant digits. */
1
+ /**
2
+ * Value formatter for price axes + OHLC readouts. M/B compaction only kicks in at
3
+ * a million — between $1k and $1M the FULL grouped number is shown (1,730 not the
4
+ * lossy "1.73K"), so adjacent axis gridlines and a candle's O/H/L/C stay
5
+ * distinguishable on mid-priced assets. Trailing zeros are dropped.
6
+ */
2
7
  export function formatValue(v) {
3
8
  if (!isFinite(v))
4
9
  return "-";
@@ -8,13 +13,43 @@ export function formatValue(v) {
8
13
  if (a >= 1e6)
9
14
  return (v / 1e6).toFixed(2) + "M";
10
15
  if (a >= 1e3)
11
- return (v / 1e3).toFixed(2) + "K";
16
+ return v.toLocaleString("en-US", { maximumFractionDigits: a >= 1e4 ? 0 : 2 });
12
17
  if (a >= 1)
13
- return v.toFixed(2);
18
+ return v.toLocaleString("en-US", { maximumFractionDigits: a >= 100 ? 2 : 4 });
14
19
  if (a > 0)
15
20
  return v.toPrecision(4);
16
21
  return "0";
17
22
  }
23
+ /**
24
+ * Axis value formatter that adapts decimal precision to the tick `step` so adjacent
25
+ * gridline labels stay distinct. The compact formatter rounds to 2 decimals (≈$10 at
26
+ * the "K" scale), which collapses a 1.71K–1.75K axis into duplicate "1.73K"s; passing
27
+ * the per-tick step keeps just enough digits to separate them (e.g. 1.7350K / 1.7430K).
28
+ */
29
+ export function formatAxisValue(v, step = 0) {
30
+ if (!isFinite(v))
31
+ return "-";
32
+ const a = Math.abs(v);
33
+ let div = 1;
34
+ let suf = "";
35
+ if (a >= 1e9) {
36
+ div = 1e9;
37
+ suf = "B";
38
+ }
39
+ else if (a >= 1e6) {
40
+ div = 1e6;
41
+ suf = "M";
42
+ }
43
+ else if (a >= 1e3) {
44
+ div = 1e3;
45
+ suf = "K";
46
+ }
47
+ // decimals fine enough to tell two ticks `step` apart; floor at 1 for K/M/B, 2 otherwise.
48
+ const floor = div > 1 ? 1 : 2;
49
+ const s = Math.abs(step) / div;
50
+ const dec = s > 0 ? Math.max(floor === 2 ? 0 : floor, Math.min(8, Math.ceil(-Math.log10(s)))) : floor;
51
+ return (v / div).toFixed(dec) + suf;
52
+ }
18
53
  /** Volume formatter with a leading `$`. */
19
54
  export function formatVolume(v) {
20
55
  const a = Math.abs(v);
@@ -30,6 +30,20 @@ export declare function bollingerBands(values: number[], period?: number, mult?:
30
30
  mid: (number | null)[];
31
31
  lower: (number | null)[];
32
32
  };
33
+ /**
34
+ * Wilder's Relative Strength Index over `period` (default 14). Output aligned to input;
35
+ * null until `period` deltas are available. Bounded 0–100 (100 when there are no losses).
36
+ */
37
+ export declare function rsi(values: number[], period?: number): (number | null)[];
38
+ /**
39
+ * MACD: the difference of a `fast` and `slow` EMA, its `signal` EMA, and the histogram
40
+ * (macd − signal). Three series aligned to input; null until each EMA's window fills.
41
+ */
42
+ export declare function macd(values: number[], fast?: number, slow?: number, signal?: number): {
43
+ macd: (number | null)[];
44
+ signal: (number | null)[];
45
+ hist: (number | null)[];
46
+ };
33
47
  /** Pull a price series (default close) out of candles. */
34
48
  export declare function sourceValues(candles: Candle[], source?: PriceSource): number[];
35
49
  /** Compute an indicator's value series, aligned to `candles` (null where undefined). */
@@ -93,6 +93,68 @@ export function bollingerBands(values, period = 20, mult = 2) {
93
93
  }
94
94
  return { upper, mid, lower };
95
95
  }
96
+ /**
97
+ * Wilder's Relative Strength Index over `period` (default 14). Output aligned to input;
98
+ * null until `period` deltas are available. Bounded 0–100 (100 when there are no losses).
99
+ */
100
+ export function rsi(values, period = 14) {
101
+ const out = new Array(values.length).fill(null);
102
+ if (!(period > 0) || values.length <= period)
103
+ return out;
104
+ let gain = 0;
105
+ let loss = 0;
106
+ for (let i = 1; i <= period; i++) {
107
+ const d = values[i] - values[i - 1];
108
+ if (d >= 0)
109
+ gain += d;
110
+ else
111
+ loss -= d;
112
+ }
113
+ let avgG = gain / period;
114
+ let avgL = loss / period;
115
+ out[period] = avgL === 0 ? 100 : 100 - 100 / (1 + avgG / avgL);
116
+ for (let i = period + 1; i < values.length; i++) {
117
+ const d = values[i] - values[i - 1];
118
+ avgG = (avgG * (period - 1) + (d > 0 ? d : 0)) / period;
119
+ avgL = (avgL * (period - 1) + (d < 0 ? -d : 0)) / period;
120
+ out[i] = avgL === 0 ? 100 : 100 - 100 / (1 + avgG / avgL);
121
+ }
122
+ return out;
123
+ }
124
+ /** EMA over a sparse series (nulls before the first value), seeded with the SMA of the
125
+ * first `period` defined values. Used to take the signal EMA of the MACD line. */
126
+ function emaNullable(series, period) {
127
+ const out = new Array(series.length).fill(null);
128
+ const start = series.findIndex((v) => v != null);
129
+ if (start < 0 || !(period > 0) || series.length - start < period)
130
+ return out;
131
+ const k = 2 / (period + 1);
132
+ let seed = 0;
133
+ for (let i = start; i < start + period; i++)
134
+ seed += series[i];
135
+ let prev = seed / period;
136
+ out[start + period - 1] = prev;
137
+ for (let i = start + period; i < series.length; i++) {
138
+ const v = series[i];
139
+ if (v == null)
140
+ continue;
141
+ prev = v * k + prev * (1 - k);
142
+ out[i] = prev;
143
+ }
144
+ return out;
145
+ }
146
+ /**
147
+ * MACD: the difference of a `fast` and `slow` EMA, its `signal` EMA, and the histogram
148
+ * (macd − signal). Three series aligned to input; null until each EMA's window fills.
149
+ */
150
+ export function macd(values, fast = 12, slow = 26, signal = 9) {
151
+ const ef = ema(values, fast);
152
+ const es = ema(values, slow);
153
+ const line = values.map((_, i) => (ef[i] != null && es[i] != null ? ef[i] - es[i] : null));
154
+ const sig = emaNullable(line, signal);
155
+ const hist = line.map((m, i) => (m != null && sig[i] != null ? m - sig[i] : null));
156
+ return { macd: line, signal: sig, hist };
157
+ }
96
158
  const SOURCE_KEY = { open: "o", high: "h", low: "l", close: "c" };
97
159
  /** Pull a price series (default close) out of candles. */
98
160
  export function sourceValues(candles, source = "close") {