@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.
- package/README.md +209 -20
- package/dist/core/chart.d.ts +105 -4
- package/dist/core/chart.js +482 -39
- package/dist/core/feed.js +27 -6
- package/dist/core/format.d.ts +13 -1
- package/dist/core/format.js +38 -3
- package/dist/core/indicators.d.ts +50 -0
- package/dist/core/indicators.js +181 -0
- package/dist/core/ohlc.d.ts +10 -0
- package/dist/core/ohlc.js +30 -0
- package/dist/core/polymarket.d.ts +44 -0
- package/dist/core/polymarket.js +92 -0
- package/dist/core/renderer.d.ts +96 -1
- package/dist/core/renderer.js +534 -64
- package/dist/core/signals.d.ts +63 -0
- package/dist/core/signals.js +234 -0
- package/dist/core/theme.d.ts +5 -1
- package/dist/core/theme.js +33 -12
- package/dist/core/types.d.ts +102 -3
- package/dist/index.d.ts +13 -8
- package/dist/index.js +8 -6
- package/dist/react/HyperliquidChart.d.ts +30 -2
- package/dist/react/HyperliquidChart.js +47 -17
- package/dist/react/PolymarketChart.d.ts +40 -0
- package/dist/react/PolymarketChart.js +95 -0
- package/dist/react/PriceChart.d.ts +54 -4
- package/dist/react/PriceChart.js +66 -27
- package/dist/react/SignalsChart.d.ts +37 -0
- package/dist/react/SignalsChart.js +95 -0
- package/dist/react/ui.d.ts +24 -0
- package/dist/react/ui.js +75 -0
- package/dist/react.d.ts +4 -0
- package/dist/react.js +2 -0
- package/package.json +2 -2
package/dist/core/chart.js
CHANGED
|
@@ -1,11 +1,42 @@
|
|
|
1
1
|
import { DEFAULT_THEME } from "./theme";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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:
|
|
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(
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
this.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
221
|
-
|
|
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.
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
}
|