@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.
- package/README.md +107 -15
- package/dist/core/chart.d.ts +61 -2
- package/dist/core/chart.js +363 -32
- package/dist/core/format.d.ts +13 -1
- package/dist/core/format.js +38 -3
- package/dist/core/indicators.d.ts +14 -0
- package/dist/core/indicators.js +62 -0
- package/dist/core/renderer.d.ts +81 -1
- package/dist/core/renderer.js +344 -57
- package/dist/core/theme.d.ts +5 -1
- package/dist/core/theme.js +33 -12
- package/dist/core/types.d.ts +78 -2
- package/dist/index.d.ts +6 -6
- package/dist/index.js +4 -4
- package/dist/react/HyperliquidChart.d.ts +28 -2
- package/dist/react/HyperliquidChart.js +39 -17
- package/dist/react/PriceChart.d.ts +39 -4
- package/dist/react/PriceChart.js +52 -25
- package/dist/react/ui.d.ts +13 -0
- package/dist/react/ui.js +29 -0
- package/package.json +1 -1
package/dist/core/chart.js
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { DEFAULT_THEME } from "./theme";
|
|
2
|
-
import { draw } from "./renderer";
|
|
3
|
-
|
|
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:
|
|
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(
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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.
|
|
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
|
-
|
|
221
|
-
|
|
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.
|
|
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
|
}
|
package/dist/core/format.d.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
|
-
/**
|
|
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). */
|
package/dist/core/format.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
/**
|
|
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 (
|
|
16
|
+
return v.toLocaleString("en-US", { maximumFractionDigits: a >= 1e4 ? 0 : 2 });
|
|
12
17
|
if (a >= 1)
|
|
13
|
-
return v.
|
|
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). */
|
package/dist/core/indicators.js
CHANGED
|
@@ -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") {
|