wick-charts 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +545 -0
- package/dist/axis.d.ts +21 -0
- package/dist/axis.js +44 -0
- package/dist/dataSource.d.ts +24 -0
- package/dist/dataSource.js +1 -0
- package/dist/hitTest.d.ts +31 -0
- package/dist/hitTest.js +46 -0
- package/dist/hybridScale.d.ts +25 -0
- package/dist/hybridScale.js +41 -0
- package/dist/index.d.ts +225 -0
- package/dist/index.js +715 -0
- package/dist/mergeSeries.d.ts +14 -0
- package/dist/mergeSeries.js +21 -0
- package/dist/plugins/types.d.ts +140 -0
- package/dist/plugins/types.js +1 -0
- package/dist/priceAxis.d.ts +7 -0
- package/dist/priceAxis.js +49 -0
- package/dist/priceRange.d.ts +12 -0
- package/dist/priceRange.js +16 -0
- package/dist/renderer.d.ts +79 -0
- package/dist/renderer.js +318 -0
- package/dist/scale.d.ts +20 -0
- package/dist/scale.js +29 -0
- package/dist/series/candlestick.d.ts +20 -0
- package/dist/series/candlestick.js +88 -0
- package/dist/series/registry.d.ts +13 -0
- package/dist/series/registry.js +30 -0
- package/dist/series/types.d.ts +56 -0
- package/dist/series/types.js +1 -0
- package/dist/testHelpers.d.ts +38 -0
- package/dist/testHelpers.js +50 -0
- package/dist/time.d.ts +6 -0
- package/dist/time.js +58 -0
- package/dist/types.d.ts +151 -0
- package/dist/types.js +1 -0
- package/dist/viewport.d.ts +52 -0
- package/dist/viewport.js +87 -0
- package/dist/wasm.d.ts +29 -0
- package/dist/wasm.js +35 -0
- package/dist/wasmImporter.d.ts +6 -0
- package/dist/wasmImporter.js +7 -0
- package/package.json +39 -0
- package/wasm-pkg/package.json +21 -0
- package/wasm-pkg/wickchart_core.d.ts +59 -0
- package/wasm-pkg/wickchart_core.js +227 -0
- package/wasm-pkg/wickchart_core_bg.wasm +0 -0
- package/wasm-pkg/wickchart_core_bg.wasm.d.ts +11 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
import { mergeSeriesPoints } from './mergeSeries.js';
|
|
2
|
+
import { ChartRenderer } from './renderer.js';
|
|
3
|
+
import { getSeries } from './series/registry.js';
|
|
4
|
+
import { toUnixSeconds } from './time.js';
|
|
5
|
+
import { Viewport } from './viewport.js';
|
|
6
|
+
import { loadWasm } from './wasm.js';
|
|
7
|
+
import { importRealWasm } from './wasmImporter.js';
|
|
8
|
+
export { distanceToSegment, hitTestPoint, hitTestSegment } from './hitTest.js';
|
|
9
|
+
export { mergeSeriesPoints } from './mergeSeries.js';
|
|
10
|
+
export { registerSeries, getSeries } from './series/registry.js';
|
|
11
|
+
// Also registers the 'candlestick' type as a module-load side effect — see
|
|
12
|
+
// src/series/candlestick.ts and src/series/registry.ts. A new series type
|
|
13
|
+
// gets the same treatment: implement SeriesDefinition, export it here (or
|
|
14
|
+
// have the consuming app import it directly before constructing a chart of
|
|
15
|
+
// that type), and `type: '<its key>'` becomes usable with no other change
|
|
16
|
+
// to this file.
|
|
17
|
+
export { candlestickSeries } from './series/candlestick.js';
|
|
18
|
+
export { LinearScale } from './scale.js';
|
|
19
|
+
export { toUnixSeconds } from './time.js';
|
|
20
|
+
export { Viewport } from './viewport.js';
|
|
21
|
+
export { getCachedWasmModule, loadWasm } from './wasm.js';
|
|
22
|
+
/** How many points are visible by default when `setData` is called without
|
|
23
|
+
* an explicit window — opens on a recent slice rather than the entire
|
|
24
|
+
* series zoomed all the way out, which would leave no room to pan. */
|
|
25
|
+
const DEFAULT_VISIBLE_POINTS = 120;
|
|
26
|
+
/** How close (in points) the visible window has to get to either edge of
|
|
27
|
+
* the loaded data before `setDataLoader`'s loader is asked for more. */
|
|
28
|
+
const DEFAULT_LOAD_THRESHOLD = 20;
|
|
29
|
+
/** How long a single finger has to stay down before a still-in-progress
|
|
30
|
+
* 'pan' touch switches to 'scrub' mode (touch has no hover, so this is its
|
|
31
|
+
* substitute — hold to inspect a point instead of panning past it). */
|
|
32
|
+
const LONG_PRESS_MS = 350;
|
|
33
|
+
/** A finger moving more than this many CSS px from where it landed counts
|
|
34
|
+
* as a real drag, not a hold — cancels the pending long-press timer so a
|
|
35
|
+
* fast pan gesture never flips into scrub mid-motion. */
|
|
36
|
+
const LONG_PRESS_MOVE_TOLERANCE_PX = 10;
|
|
37
|
+
/**
|
|
38
|
+
* Interactive chart: drag to pan, wheel to zoom, drag the price-axis strip
|
|
39
|
+
* to rescale it, hover a point for a legend. What gets plotted (candles
|
|
40
|
+
* today; a future line/area/bar series) is decided entirely by
|
|
41
|
+
* `options.type` and the `SeriesDefinition` registered under it — this
|
|
42
|
+
* class only owns generic engine concerns (viewport math, mouse/touch/wheel
|
|
43
|
+
* handling, on-demand data loading, render scheduling) and never touches a
|
|
44
|
+
* point's fields directly. Construct once per canvas; call `destroy()`
|
|
45
|
+
* when done with it (unmount) to remove the window-level mouseup listener.
|
|
46
|
+
*/
|
|
47
|
+
export class WickChart {
|
|
48
|
+
constructor(canvas, options) {
|
|
49
|
+
this.canvas = canvas;
|
|
50
|
+
this.sorted = [];
|
|
51
|
+
this.times = [];
|
|
52
|
+
this.hoverIndex = null;
|
|
53
|
+
/** Device-pixel y of the pointer/finger that produced `hoverIndex` — the
|
|
54
|
+
* crosshair's horizontal line follows this directly, not any property of
|
|
55
|
+
* the hovered point itself (see `ChartRenderer.renderCrosshairAndLegend`
|
|
56
|
+
* for why: pinning it to, say, the candle's close would leave the line
|
|
57
|
+
* motionless while the pointer moves within that candle's column). */
|
|
58
|
+
this.hoverY = null;
|
|
59
|
+
this.plugins = [];
|
|
60
|
+
/** The plugin whose `onPointerDown` returned `true` for the pointer
|
|
61
|
+
* currently down, or `null` when no plugin has claimed the current
|
|
62
|
+
* gesture (the common case — the chart handles it itself). */
|
|
63
|
+
this.activeGesturePlugin = null;
|
|
64
|
+
this.dragMode = null;
|
|
65
|
+
this.lastX = 0;
|
|
66
|
+
this.lastY = 0;
|
|
67
|
+
this.renderScheduled = false;
|
|
68
|
+
this.pendingAnimationFrame = null;
|
|
69
|
+
/** Distance (CSS px) between two touches on the previous touchmove —
|
|
70
|
+
* `null` whenever fewer than two fingers are down. Compared frame to
|
|
71
|
+
* frame (not against a fixed start value) so it composes naturally with
|
|
72
|
+
* the same incremental-delta style `applyPanDelta`/`applyValueScaleDelta`
|
|
73
|
+
* already use. */
|
|
74
|
+
this.pinchLastDistance = null;
|
|
75
|
+
/** Where the current single-finger touch landed — compared against the
|
|
76
|
+
* live position to tell a hold from a drag; see LONG_PRESS_MOVE_TOLERANCE_PX. */
|
|
77
|
+
this.touchStartX = 0;
|
|
78
|
+
this.touchStartY = 0;
|
|
79
|
+
this.longPressTimer = null;
|
|
80
|
+
this.loader = null;
|
|
81
|
+
this.loadThreshold = DEFAULT_LOAD_THRESHOLD;
|
|
82
|
+
this.loading = { before: false, after: false };
|
|
83
|
+
/** Set once a loader for a direction returns empty — stops re-asking at
|
|
84
|
+
* every threshold crossing until `setData` resets it (a fresh dataset
|
|
85
|
+
* may come from a different source that does have more). */
|
|
86
|
+
this.exhausted = { before: false, after: false };
|
|
87
|
+
this.onMouseDown = (e) => {
|
|
88
|
+
const { x, y } = this.cursorPosition(e);
|
|
89
|
+
if (x < this.renderer.chartWidth && this.dispatchPointerDown(x, y))
|
|
90
|
+
return;
|
|
91
|
+
this.dragMode = x >= this.renderer.chartWidth ? 'value-scale' : 'pan';
|
|
92
|
+
this.lastX = e.clientX;
|
|
93
|
+
this.lastY = e.clientY;
|
|
94
|
+
// Any drag that can touch the price axis (main-pane vertical pan or the
|
|
95
|
+
// price-axis-strip scale drag) switches the axis to manual mode first,
|
|
96
|
+
// seeded from whatever is on screen right now — otherwise there's no
|
|
97
|
+
// "current range" to shift or scale relative to.
|
|
98
|
+
this.ensureValueRangeOverride();
|
|
99
|
+
};
|
|
100
|
+
this.onMouseMove = (e) => {
|
|
101
|
+
if (this.activeGesturePlugin) {
|
|
102
|
+
this.lastX = e.clientX;
|
|
103
|
+
this.lastY = e.clientY;
|
|
104
|
+
const { x, y } = this.cursorPosition(e);
|
|
105
|
+
this.activeGesturePlugin.onPointerMove?.(this.pointerEventAt(x, y));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (this.dragMode === 'pan') {
|
|
109
|
+
this.applyPanDelta(e.clientX, e.clientY);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (this.dragMode === 'value-scale') {
|
|
113
|
+
this.applyValueScaleDelta(e.clientY);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
this.updateHover(e);
|
|
117
|
+
};
|
|
118
|
+
this.onMouseUp = (e) => {
|
|
119
|
+
if (this.activeGesturePlugin) {
|
|
120
|
+
const { x, y } = this.cursorPosition(e);
|
|
121
|
+
this.activeGesturePlugin.onPointerUp?.(this.pointerEventAt(x, y));
|
|
122
|
+
this.activeGesturePlugin = null;
|
|
123
|
+
this.scheduleRender();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
this.dragMode = null;
|
|
127
|
+
};
|
|
128
|
+
this.onMouseLeave = () => {
|
|
129
|
+
this.dragMode = null;
|
|
130
|
+
if (this.hoverIndex !== null) {
|
|
131
|
+
this.hoverIndex = null;
|
|
132
|
+
this.hoverY = null;
|
|
133
|
+
this.scheduleRender();
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
this.onTouchStart = (e) => {
|
|
137
|
+
e.preventDefault();
|
|
138
|
+
if (e.touches.length === 2) {
|
|
139
|
+
// A second finger landing takes over from whatever single-finger
|
|
140
|
+
// gesture might have been in progress — including a pending
|
|
141
|
+
// long-press, an already-active scrub, or a plugin gesture, none of
|
|
142
|
+
// which make sense once this becomes a pinch. Clearing the hover
|
|
143
|
+
// here (not just relying on the eventual touchend) matters because
|
|
144
|
+
// dragMode stops being 'scrub' the instant we set it to null two
|
|
145
|
+
// lines down.
|
|
146
|
+
this.clearLongPressTimer();
|
|
147
|
+
if (this.activeGesturePlugin) {
|
|
148
|
+
this.activeGesturePlugin.onPointerUp?.(this.pointerEventAtLast());
|
|
149
|
+
this.activeGesturePlugin = null;
|
|
150
|
+
}
|
|
151
|
+
if (this.hoverIndex !== null) {
|
|
152
|
+
this.hoverIndex = null;
|
|
153
|
+
this.hoverY = null;
|
|
154
|
+
this.scheduleRender();
|
|
155
|
+
}
|
|
156
|
+
this.dragMode = null;
|
|
157
|
+
this.pinchLastDistance = this.touchDistance(e.touches[0], e.touches[1]);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (e.touches.length === 1) {
|
|
161
|
+
const touch = e.touches[0];
|
|
162
|
+
const { x, y } = this.cursorPosition(touch);
|
|
163
|
+
this.lastX = touch.clientX;
|
|
164
|
+
this.lastY = touch.clientY;
|
|
165
|
+
if (x < this.renderer.chartWidth && this.dispatchPointerDown(x, y))
|
|
166
|
+
return;
|
|
167
|
+
this.dragMode = x >= this.renderer.chartWidth ? 'value-scale' : 'pan';
|
|
168
|
+
this.touchStartX = touch.clientX;
|
|
169
|
+
this.touchStartY = touch.clientY;
|
|
170
|
+
this.ensureValueRangeOverride();
|
|
171
|
+
// Touch has no hover, so holding a finger still is its substitute:
|
|
172
|
+
// if it's still a 'pan' candidate (not already moved into a real
|
|
173
|
+
// drag, not on the price-axis strip) when this fires, switch to
|
|
174
|
+
// inspecting the point under the finger instead of panning.
|
|
175
|
+
if (this.dragMode === 'pan') {
|
|
176
|
+
this.clearLongPressTimer();
|
|
177
|
+
this.longPressTimer = setTimeout(() => {
|
|
178
|
+
this.longPressTimer = null;
|
|
179
|
+
if (this.dragMode === 'pan') {
|
|
180
|
+
this.dragMode = 'scrub';
|
|
181
|
+
this.updateHover({ clientX: this.lastX, clientY: this.lastY });
|
|
182
|
+
}
|
|
183
|
+
}, LONG_PRESS_MS);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
this.onTouchMove = (e) => {
|
|
188
|
+
e.preventDefault();
|
|
189
|
+
if (e.touches.length === 2) {
|
|
190
|
+
const [t0, t1] = [e.touches[0], e.touches[1]];
|
|
191
|
+
const distance = this.touchDistance(t0, t1);
|
|
192
|
+
const slotWidth = this.renderer.chartWidth / this.viewport.visibleCount;
|
|
193
|
+
if (this.pinchLastDistance !== null && slotWidth > 0) {
|
|
194
|
+
const { x } = this.cursorPosition({
|
|
195
|
+
clientX: (t0.clientX + t1.clientX) / 2,
|
|
196
|
+
clientY: (t0.clientY + t1.clientY) / 2,
|
|
197
|
+
});
|
|
198
|
+
const anchorIndex = this.viewport.startIndex + x / slotWidth;
|
|
199
|
+
// Fingers spreading apart (distance growing) zooms in, matching
|
|
200
|
+
// the standard pinch-to-zoom direction — mirrors onWheel's
|
|
201
|
+
// "scroll down = zoom out" the same way a trackpad pinch does.
|
|
202
|
+
const factor = this.pinchLastDistance / distance;
|
|
203
|
+
this.viewport.zoom(factor, anchorIndex, this.sorted.length);
|
|
204
|
+
this.scheduleRender();
|
|
205
|
+
}
|
|
206
|
+
this.pinchLastDistance = distance;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (e.touches.length !== 1)
|
|
210
|
+
return;
|
|
211
|
+
const touch = e.touches[0];
|
|
212
|
+
if (this.activeGesturePlugin) {
|
|
213
|
+
this.lastX = touch.clientX;
|
|
214
|
+
this.lastY = touch.clientY;
|
|
215
|
+
const { x, y } = this.cursorPosition(touch);
|
|
216
|
+
this.activeGesturePlugin.onPointerMove?.(this.pointerEventAt(x, y));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (this.dragMode === 'pan') {
|
|
220
|
+
const movedDistance = Math.hypot(touch.clientX - this.touchStartX, touch.clientY - this.touchStartY);
|
|
221
|
+
if (movedDistance > LONG_PRESS_MOVE_TOLERANCE_PX) {
|
|
222
|
+
// A real drag, not a hold — the long-press timer (if still
|
|
223
|
+
// pending) would otherwise fire mid-drag and yank control away
|
|
224
|
+
// from panning.
|
|
225
|
+
this.clearLongPressTimer();
|
|
226
|
+
}
|
|
227
|
+
this.applyPanDelta(touch.clientX, touch.clientY);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (this.dragMode === 'value-scale') {
|
|
231
|
+
this.applyValueScaleDelta(touch.clientY);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (this.dragMode === 'scrub') {
|
|
235
|
+
this.updateHover(touch);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
this.onTouchEnd = (e) => {
|
|
239
|
+
if (e.touches.length < 2)
|
|
240
|
+
this.pinchLastDistance = null;
|
|
241
|
+
if (e.touches.length === 0) {
|
|
242
|
+
this.clearLongPressTimer();
|
|
243
|
+
if (this.activeGesturePlugin) {
|
|
244
|
+
// touchend's own .touches is already empty — the lifted finger's
|
|
245
|
+
// last known position is whatever lastX/lastY was last set to (on
|
|
246
|
+
// touchstart or the most recent touchmove), not anything on this event.
|
|
247
|
+
this.activeGesturePlugin.onPointerUp?.(this.pointerEventAtLast());
|
|
248
|
+
this.activeGesturePlugin = null;
|
|
249
|
+
this.scheduleRender();
|
|
250
|
+
}
|
|
251
|
+
// Scrubbing has no persistent state after the finger lifts — unlike
|
|
252
|
+
// a mouse, which can keep hovering the last position, a lifted
|
|
253
|
+
// finger isn't "still pointing" at anything, so the legend/crosshair
|
|
254
|
+
// should disappear rather than stay pinned to wherever it last was.
|
|
255
|
+
// Unconditional on hoverIndex alone (not gated on dragMode === 'scrub')
|
|
256
|
+
// so a hover left over from a scrub-then-pinch sequence still clears
|
|
257
|
+
// here even though dragMode was already reset to null earlier.
|
|
258
|
+
if (this.hoverIndex !== null) {
|
|
259
|
+
this.hoverIndex = null;
|
|
260
|
+
this.hoverY = null;
|
|
261
|
+
this.scheduleRender();
|
|
262
|
+
}
|
|
263
|
+
this.dragMode = null;
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
this.onWheel = (e) => {
|
|
267
|
+
e.preventDefault();
|
|
268
|
+
if (e.deltaX === 0 && e.deltaY === 0)
|
|
269
|
+
return; // e.g. a momentum-scroll's trailing zero-delta event
|
|
270
|
+
const slotWidth = this.renderer.chartWidth / this.viewport.visibleCount;
|
|
271
|
+
if (slotWidth <= 0)
|
|
272
|
+
return;
|
|
273
|
+
// Trackpad horizontal swipes (and shift+wheel) report mostly on deltaX;
|
|
274
|
+
// vertical wheel/scroll reports on deltaY. Whichever dominates decides
|
|
275
|
+
// pan vs zoom — a horizontal-leaning gesture should never zoom.
|
|
276
|
+
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
|
277
|
+
const deltaXDevice = e.deltaX * this.devicePixelScaleX();
|
|
278
|
+
this.viewport.pan(deltaXDevice / slotWidth, this.sorted.length);
|
|
279
|
+
this.scheduleRender();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const { x } = this.cursorPosition(e);
|
|
283
|
+
const anchorIndex = this.viewport.startIndex + x / slotWidth;
|
|
284
|
+
const factor = e.deltaY > 0 ? 1.1 : 1 / 1.1; // scroll down = zoom out
|
|
285
|
+
this.viewport.zoom(factor, anchorIndex, this.sorted.length);
|
|
286
|
+
this.scheduleRender();
|
|
287
|
+
};
|
|
288
|
+
this.seriesDefinition = getSeries(options?.type ?? 'candlestick');
|
|
289
|
+
this.renderer = new ChartRenderer(canvas, this.seriesDefinition, options);
|
|
290
|
+
this.viewport = new Viewport(0);
|
|
291
|
+
// Without this, a touch drag on the canvas also scrolls/zooms the page
|
|
292
|
+
// underneath it — the browser's native touch gestures and this class's
|
|
293
|
+
// own pan/pinch handling would otherwise fight over the same gesture.
|
|
294
|
+
canvas.style.touchAction = 'none';
|
|
295
|
+
this.attachEvents();
|
|
296
|
+
// Kicked off once per chart instance, not awaited — the renderer reads
|
|
297
|
+
// whatever's cached synchronously (see hybridScale.ts) and just keeps
|
|
298
|
+
// using the JS fallback for every render before this resolves.
|
|
299
|
+
void loadWasm(importRealWasm);
|
|
300
|
+
}
|
|
301
|
+
setData(points) {
|
|
302
|
+
this.sorted = [...points].sort((a, b) => toUnixSeconds(a.time) - toUnixSeconds(b.time));
|
|
303
|
+
this.times = this.sorted.map((p) => toUnixSeconds(p.time));
|
|
304
|
+
this.viewport = new Viewport(this.sorted.length, DEFAULT_VISIBLE_POINTS);
|
|
305
|
+
this.hoverIndex = null;
|
|
306
|
+
this.hoverY = null;
|
|
307
|
+
this.exhausted = { before: false, after: false };
|
|
308
|
+
return this;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Registers a callback the chart asks for more points when the visible
|
|
312
|
+
* window gets within `threshold` points of either edge of what's
|
|
313
|
+
* currently loaded. The chart never fetches on its own — it only decides
|
|
314
|
+
* *when* more data is needed and merges what the loader returns; the
|
|
315
|
+
* loader owns *how* (REST call, cache, websocket replay, whatever).
|
|
316
|
+
*/
|
|
317
|
+
setDataLoader(loader, threshold = DEFAULT_LOAD_THRESHOLD) {
|
|
318
|
+
this.loader = loader;
|
|
319
|
+
this.loadThreshold = threshold;
|
|
320
|
+
return this;
|
|
321
|
+
}
|
|
322
|
+
/** Registers a plugin (marker, annotation, drawing tool, ...) drawn on
|
|
323
|
+
* top of the chart every frame after the series and axes — see
|
|
324
|
+
* `src/plugins/types.ts`. Adding overlay features this way, rather than
|
|
325
|
+
* by extending `WickChart` itself, is what keeps the core closed to
|
|
326
|
+
* modification: a marker implementation never needs to touch this file. */
|
|
327
|
+
addPlugin(plugin) {
|
|
328
|
+
this.plugins.push(plugin);
|
|
329
|
+
this.scheduleRender();
|
|
330
|
+
return this;
|
|
331
|
+
}
|
|
332
|
+
removePlugin(plugin) {
|
|
333
|
+
this.plugins = this.plugins.filter((p) => p !== plugin);
|
|
334
|
+
this.scheduleRender();
|
|
335
|
+
return this;
|
|
336
|
+
}
|
|
337
|
+
/** Every currently-registered plugin, in registration order — for an app
|
|
338
|
+
* building a management UI (a list of attached indicators/drawing tools
|
|
339
|
+
* with visibility toggles or delete buttons) without maintaining its own
|
|
340
|
+
* parallel bookkeeping of every `addPlugin` call. A copy, not a live
|
|
341
|
+
* view: mutating the returned array doesn't affect the chart. */
|
|
342
|
+
getPlugins() {
|
|
343
|
+
return [...this.plugins];
|
|
344
|
+
}
|
|
345
|
+
/** Shows or hides every plugin whose `id` matches (see `ChartPlugin.id`)
|
|
346
|
+
* and re-renders. A no-op, not an error, if nothing matches — plugins
|
|
347
|
+
* with no `id` set are never matched. */
|
|
348
|
+
setPluginVisible(id, visible) {
|
|
349
|
+
for (const plugin of this.plugins) {
|
|
350
|
+
if (plugin.id === id)
|
|
351
|
+
plugin.visible = visible;
|
|
352
|
+
}
|
|
353
|
+
this.scheduleRender();
|
|
354
|
+
return this;
|
|
355
|
+
}
|
|
356
|
+
render() {
|
|
357
|
+
this.renderer.render({
|
|
358
|
+
sorted: this.sorted,
|
|
359
|
+
times: this.times,
|
|
360
|
+
viewport: this.viewport,
|
|
361
|
+
hoverIndex: this.hoverIndex,
|
|
362
|
+
hoverY: this.hoverY,
|
|
363
|
+
plugins: this.plugins,
|
|
364
|
+
});
|
|
365
|
+
this.maybeLoadMore();
|
|
366
|
+
}
|
|
367
|
+
/** Coalesces render() calls into at most one per animation frame. Mouse
|
|
368
|
+
* events (drag, wheel) can fire far faster than the display refreshes —
|
|
369
|
+
* calling render() directly from each one redraws the full canvas once
|
|
370
|
+
* per event instead of once per frame, which is what actually causes
|
|
371
|
+
* dragging to feel janky, not anything data-loading does. */
|
|
372
|
+
scheduleRender() {
|
|
373
|
+
if (this.renderScheduled)
|
|
374
|
+
return;
|
|
375
|
+
this.renderScheduled = true;
|
|
376
|
+
this.pendingAnimationFrame = requestAnimationFrame(() => {
|
|
377
|
+
this.renderScheduled = false;
|
|
378
|
+
this.pendingAnimationFrame = null;
|
|
379
|
+
this.render();
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
/** How many points are currently loaded (not just visible) — grows as
|
|
383
|
+
* `setDataLoader`'s loader supplies more history. */
|
|
384
|
+
getPointCount() {
|
|
385
|
+
return this.sorted.length;
|
|
386
|
+
}
|
|
387
|
+
/** The currently visible window, in point indices into the full loaded
|
|
388
|
+
* series. Useful for building UI around the chart (a minimap, a "jump to
|
|
389
|
+
* latest" button) without reaching into private state. */
|
|
390
|
+
getVisibleRange() {
|
|
391
|
+
return {
|
|
392
|
+
startIndex: this.viewport.startIndex,
|
|
393
|
+
endIndex: this.viewport.endIndex,
|
|
394
|
+
visibleCount: this.viewport.visibleCount,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
/** The value axis's manual range once the user has dragged or scaled it
|
|
398
|
+
* — `null` if the axis is still auto-fitting to whatever's visible
|
|
399
|
+
* (the default until the user first touches it vertically). */
|
|
400
|
+
getValueRangeOverride() {
|
|
401
|
+
// A copy, not the live internal object — a caller mutating what they
|
|
402
|
+
// got back should never be able to corrupt the viewport's own state.
|
|
403
|
+
const range = this.viewport.valueRangeOverride;
|
|
404
|
+
return range ? { ...range } : null;
|
|
405
|
+
}
|
|
406
|
+
/** The point currently under the cursor (crosshair/legend target), or
|
|
407
|
+
* `null` when nothing is hovered. */
|
|
408
|
+
getHoveredPoint() {
|
|
409
|
+
return this.hoverIndex === null ? null : (this.sorted[this.hoverIndex] ?? null);
|
|
410
|
+
}
|
|
411
|
+
/** Removes all attached listeners. Call on unmount — the mouseup
|
|
412
|
+
* listener is on `window` (so drags don't get stuck if the cursor
|
|
413
|
+
* leaves the canvas mid-drag) and won't be garbage-collected on its own. */
|
|
414
|
+
destroy() {
|
|
415
|
+
this.clearLongPressTimer();
|
|
416
|
+
if (this.pendingAnimationFrame !== null) {
|
|
417
|
+
cancelAnimationFrame(this.pendingAnimationFrame);
|
|
418
|
+
this.pendingAnimationFrame = null;
|
|
419
|
+
}
|
|
420
|
+
const { canvas } = this;
|
|
421
|
+
canvas.removeEventListener('mousedown', this.onMouseDown);
|
|
422
|
+
canvas.removeEventListener('mousemove', this.onMouseMove);
|
|
423
|
+
window.removeEventListener('mouseup', this.onMouseUp);
|
|
424
|
+
canvas.removeEventListener('mouseleave', this.onMouseLeave);
|
|
425
|
+
canvas.removeEventListener('wheel', this.onWheel);
|
|
426
|
+
canvas.removeEventListener('touchstart', this.onTouchStart);
|
|
427
|
+
canvas.removeEventListener('touchmove', this.onTouchMove);
|
|
428
|
+
canvas.removeEventListener('touchend', this.onTouchEnd);
|
|
429
|
+
canvas.removeEventListener('touchcancel', this.onTouchEnd);
|
|
430
|
+
}
|
|
431
|
+
attachEvents() {
|
|
432
|
+
const { canvas } = this;
|
|
433
|
+
canvas.addEventListener('mousedown', this.onMouseDown);
|
|
434
|
+
canvas.addEventListener('mousemove', this.onMouseMove);
|
|
435
|
+
window.addEventListener('mouseup', this.onMouseUp);
|
|
436
|
+
canvas.addEventListener('mouseleave', this.onMouseLeave);
|
|
437
|
+
canvas.addEventListener('wheel', this.onWheel, { passive: false });
|
|
438
|
+
// touchstart/touchmove must be non-passive since they call
|
|
439
|
+
// preventDefault() to stop the page from scrolling under the drag.
|
|
440
|
+
canvas.addEventListener('touchstart', this.onTouchStart, { passive: false });
|
|
441
|
+
canvas.addEventListener('touchmove', this.onTouchMove, { passive: false });
|
|
442
|
+
canvas.addEventListener('touchend', this.onTouchEnd);
|
|
443
|
+
canvas.addEventListener('touchcancel', this.onTouchEnd);
|
|
444
|
+
}
|
|
445
|
+
/** Shared by both mouse drag and single-finger touch drag: shifts the
|
|
446
|
+
* visible time window and, once the value axis is in manual mode, the
|
|
447
|
+
* visible value window too — see the "pan" branch `onMouseMove` used to
|
|
448
|
+
* inline before mouse and touch needed the exact same math. */
|
|
449
|
+
applyPanDelta(clientX, clientY) {
|
|
450
|
+
// Coordinates are in CSS pixels; chartWidth/chartHeight are in canvas
|
|
451
|
+
// backing-store (device) pixels, which differ under devicePixelRatio
|
|
452
|
+
// scaling — convert before dividing or drags feel sluggish/dead on
|
|
453
|
+
// high-DPI screens.
|
|
454
|
+
const deltaXCss = clientX - this.lastX;
|
|
455
|
+
const deltaYCss = clientY - this.lastY;
|
|
456
|
+
this.lastX = clientX;
|
|
457
|
+
this.lastY = clientY;
|
|
458
|
+
const deltaXDevice = deltaXCss * this.devicePixelScaleX();
|
|
459
|
+
const slotWidth = this.renderer.chartWidth / this.viewport.visibleCount;
|
|
460
|
+
if (slotWidth > 0) {
|
|
461
|
+
// Dragging right pulls the timeline back into view — like sliding
|
|
462
|
+
// paper under a fixed magnifier — so pixel delta and index delta
|
|
463
|
+
// have opposite sign.
|
|
464
|
+
this.viewport.pan(-deltaXDevice / slotWidth, this.sorted.length);
|
|
465
|
+
}
|
|
466
|
+
const chartHeight = this.renderer.chartHeight;
|
|
467
|
+
if (chartHeight > 0 && this.viewport.valueRangeOverride) {
|
|
468
|
+
const deltaYDevice = deltaYCss * this.devicePixelScaleY();
|
|
469
|
+
const { min, max } = this.viewport.valueRangeOverride;
|
|
470
|
+
const valuePerPixel = (max - min) / chartHeight;
|
|
471
|
+
// Dragging down moves the visible value window down (content
|
|
472
|
+
// follows the cursor), matching the horizontal drag's "grab and
|
|
473
|
+
// slide" feel — see the pan call above for the mirrored X case.
|
|
474
|
+
this.viewport.panValueRange(deltaYDevice * valuePerPixel);
|
|
475
|
+
}
|
|
476
|
+
this.scheduleRender();
|
|
477
|
+
}
|
|
478
|
+
/** Shared by both mouse drag and single-finger touch drag on the
|
|
479
|
+
* price-axis strip. */
|
|
480
|
+
applyValueScaleDelta(clientY) {
|
|
481
|
+
const deltaY = clientY - this.lastY;
|
|
482
|
+
this.lastY = clientY;
|
|
483
|
+
// Dragging the price axis down widens the visible value range
|
|
484
|
+
// (the series looks shorter); dragging up narrows it (looks taller).
|
|
485
|
+
this.viewport.scaleValueRange(Math.pow(1.006, deltaY));
|
|
486
|
+
this.scheduleRender();
|
|
487
|
+
}
|
|
488
|
+
clearLongPressTimer() {
|
|
489
|
+
if (this.longPressTimer !== null) {
|
|
490
|
+
clearTimeout(this.longPressTimer);
|
|
491
|
+
this.longPressTimer = null;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
touchDistance(a, b) {
|
|
495
|
+
return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
|
|
496
|
+
}
|
|
497
|
+
updateHover(point) {
|
|
498
|
+
const { x, y } = this.cursorPosition(point);
|
|
499
|
+
if (x >= this.renderer.chartWidth || this.sorted.length === 0) {
|
|
500
|
+
if (this.hoverIndex !== null) {
|
|
501
|
+
this.hoverIndex = null;
|
|
502
|
+
this.hoverY = null;
|
|
503
|
+
this.scheduleRender();
|
|
504
|
+
}
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const slotWidth = this.renderer.chartWidth / this.viewport.visibleCount;
|
|
508
|
+
if (slotWidth <= 0)
|
|
509
|
+
return;
|
|
510
|
+
const rawIndex = Math.floor(this.viewport.startIndex + x / slotWidth);
|
|
511
|
+
const nextHover = Math.min(this.sorted.length - 1, Math.max(0, rawIndex));
|
|
512
|
+
// Re-render on *either* changing — not just a new candle column. The
|
|
513
|
+
// crosshair's horizontal line tracks y continuously (see
|
|
514
|
+
// ChartRenderer.renderCrosshairAndLegend), so without the y check here
|
|
515
|
+
// it would only move when the pointer crosses into a different candle,
|
|
516
|
+
// looking stuck the rest of the time.
|
|
517
|
+
if (nextHover !== this.hoverIndex || y !== this.hoverY) {
|
|
518
|
+
this.hoverIndex = nextHover;
|
|
519
|
+
this.hoverY = y;
|
|
520
|
+
this.scheduleRender();
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/** Checks whether the visible window is close enough to either edge of
|
|
524
|
+
* the loaded data to ask `this.loader` for more. Safe to call after
|
|
525
|
+
* every render — guarded so it never fires two overlapping requests for
|
|
526
|
+
* the same direction or re-asks a direction that already came back
|
|
527
|
+
* empty. */
|
|
528
|
+
maybeLoadMore() {
|
|
529
|
+
if (!this.loader || this.sorted.length === 0)
|
|
530
|
+
return;
|
|
531
|
+
if (!this.loading.before && !this.exhausted.before && this.viewport.startIndex < this.loadThreshold) {
|
|
532
|
+
this.requestMore('before', this.times[0]);
|
|
533
|
+
}
|
|
534
|
+
const remainingAfter = this.sorted.length - this.viewport.endIndex;
|
|
535
|
+
if (!this.loading.after && !this.exhausted.after && remainingAfter < this.loadThreshold) {
|
|
536
|
+
this.requestMore('after', this.times[this.times.length - 1]);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
requestMore(direction, boundary) {
|
|
540
|
+
const loader = this.loader;
|
|
541
|
+
if (!loader)
|
|
542
|
+
return;
|
|
543
|
+
this.loading[direction] = true;
|
|
544
|
+
Promise.resolve(loader({ direction, boundary, count: this.loadThreshold * 2 }))
|
|
545
|
+
.then((newPoints) => this.applyLoadedPoints(direction, newPoints))
|
|
546
|
+
.catch(() => {
|
|
547
|
+
// A failed fetch just means we try again next time the viewport
|
|
548
|
+
// re-crosses the threshold — not `exhausted`, since the data may
|
|
549
|
+
// well exist and the next attempt could succeed.
|
|
550
|
+
})
|
|
551
|
+
.finally(() => {
|
|
552
|
+
this.loading[direction] = false;
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
applyLoadedPoints(direction, newPoints) {
|
|
556
|
+
if (newPoints.length === 0) {
|
|
557
|
+
this.exhausted[direction] = true;
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const previousCount = this.sorted.length;
|
|
561
|
+
this.sorted = mergeSeriesPoints(this.sorted, newPoints);
|
|
562
|
+
this.times = this.sorted.map((p) => toUnixSeconds(p.time));
|
|
563
|
+
if (direction === 'before') {
|
|
564
|
+
// Every point prepended shifts every existing index forward by the
|
|
565
|
+
// same amount — without this the visible window would visually jump
|
|
566
|
+
// to show older data instead of staying put once the fetch lands.
|
|
567
|
+
const prepended = this.sorted.length - previousCount;
|
|
568
|
+
this.viewport.startIndex += prepended;
|
|
569
|
+
}
|
|
570
|
+
this.render();
|
|
571
|
+
}
|
|
572
|
+
/** Switches the price/value axis to manual mode if it hasn't been
|
|
573
|
+
* already, seeding it from the current auto-fit range so the first pixel
|
|
574
|
+
* of a drag doesn't jump. No-op on subsequent calls (already manual). */
|
|
575
|
+
ensureValueRangeOverride() {
|
|
576
|
+
if (this.viewport.valueRangeOverride)
|
|
577
|
+
return;
|
|
578
|
+
const range = this.frameValueRange();
|
|
579
|
+
if (range)
|
|
580
|
+
this.viewport.setValueRangeOverride(range);
|
|
581
|
+
}
|
|
582
|
+
/** The value range the *next* render would use — whatever's already
|
|
583
|
+
* manually overridden, or a fresh auto-fit computed the same way
|
|
584
|
+
* `ChartRenderer.render` does. Used outside of a render pass itself, by
|
|
585
|
+
* anything that needs to convert a pixel position to a data value
|
|
586
|
+
* on-demand (`valueForY`, dispatched pointer events) rather than only
|
|
587
|
+
* during `render()`. `null` when there's nothing to compute one from. */
|
|
588
|
+
frameValueRange() {
|
|
589
|
+
if (this.viewport.valueRangeOverride)
|
|
590
|
+
return this.viewport.valueRangeOverride;
|
|
591
|
+
if (this.sorted.length === 0)
|
|
592
|
+
return null;
|
|
593
|
+
const startIdx = Math.max(0, Math.floor(this.viewport.startIndex));
|
|
594
|
+
const endIdx = Math.min(this.sorted.length, Math.ceil(this.viewport.endIndex));
|
|
595
|
+
const visible = this.sorted.slice(startIdx, endIdx);
|
|
596
|
+
if (visible.length === 0)
|
|
597
|
+
return null;
|
|
598
|
+
return this.seriesDefinition.getValueRange(visible, this.viewport.valueScaleFactor);
|
|
599
|
+
}
|
|
600
|
+
/** y pixel -> value in the range the next render would use. `null` if
|
|
601
|
+
* there's no data or no usable chart area to compute one against — see
|
|
602
|
+
* `ChartPointerEvent.value`. */
|
|
603
|
+
valueForY(y) {
|
|
604
|
+
const range = this.frameValueRange();
|
|
605
|
+
const chartHeight = this.renderer.chartHeight;
|
|
606
|
+
if (!range || chartHeight <= 0)
|
|
607
|
+
return null;
|
|
608
|
+
return range.min + (1 - y / chartHeight) * (range.max - range.min);
|
|
609
|
+
}
|
|
610
|
+
/** x pixel -> global (possibly fractional) index — the exact inverse of
|
|
611
|
+
* the renderer's own `xForIndex`, so a pointer event lines up with
|
|
612
|
+
* wherever the chart itself would draw that index. */
|
|
613
|
+
indexForX(x) {
|
|
614
|
+
const slotWidth = this.renderer.chartWidth / this.viewport.visibleCount;
|
|
615
|
+
if (slotWidth <= 0)
|
|
616
|
+
return this.viewport.startIndex;
|
|
617
|
+
return this.viewport.startIndex + (x - slotWidth / 2) / slotWidth;
|
|
618
|
+
}
|
|
619
|
+
/** Global (possibly fractional) index -> x pixel — the exact inverse of
|
|
620
|
+
* `indexForX` above, and the same formula `ChartRenderer.render` draws
|
|
621
|
+
* with for the current viewport. Exposed on `ChartPointerEvent` so a
|
|
622
|
+
* plugin can convert a shape it's storing in data space back to pixels
|
|
623
|
+
* for hit-testing, without duplicating this math itself. */
|
|
624
|
+
xForIndex(index) {
|
|
625
|
+
const slotWidth = this.renderer.chartWidth / this.viewport.visibleCount;
|
|
626
|
+
return (index - this.viewport.startIndex) * slotWidth + slotWidth / 2;
|
|
627
|
+
}
|
|
628
|
+
/** Value in the range the next render would use -> y pixel — the exact
|
|
629
|
+
* inverse of `valueForY` above. `null` under the same conditions
|
|
630
|
+
* `valueForY` returns `null` for. */
|
|
631
|
+
yForValue(value) {
|
|
632
|
+
const range = this.frameValueRange();
|
|
633
|
+
const chartHeight = this.renderer.chartHeight;
|
|
634
|
+
if (!range || chartHeight <= 0)
|
|
635
|
+
return null;
|
|
636
|
+
return chartHeight * (1 - (value - range.min) / (range.max - range.min));
|
|
637
|
+
}
|
|
638
|
+
pointerEventAt(x, y) {
|
|
639
|
+
return {
|
|
640
|
+
x,
|
|
641
|
+
y,
|
|
642
|
+
index: this.indexForX(x),
|
|
643
|
+
value: this.valueForY(y),
|
|
644
|
+
xForIndex: (index) => this.xForIndex(index),
|
|
645
|
+
yForValue: (value) => this.yForValue(value),
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
/** Same as `pointerEventAt`, but starting from `lastX`/`lastY` (raw
|
|
649
|
+
* `clientX`/`clientY`, tracked on every pointer move) instead of
|
|
650
|
+
* already-converted chart-area pixels — for the two touch-end paths
|
|
651
|
+
* where there's no current touch position to read coordinates from. */
|
|
652
|
+
pointerEventAtLast() {
|
|
653
|
+
const { x, y } = this.cursorPosition({ clientX: this.lastX, clientY: this.lastY });
|
|
654
|
+
return this.pointerEventAt(x, y);
|
|
655
|
+
}
|
|
656
|
+
/** Offers a pointer-down at `(x, y)` (chart-area pixels) to each plugin
|
|
657
|
+
* in reverse-registration order, stopping at the first one whose
|
|
658
|
+
* `onPointerDown` returns `true`. That plugin becomes
|
|
659
|
+
* `activeGesturePlugin` for the rest of the gesture; returns whether
|
|
660
|
+
* anyone claimed it, so callers know whether to skip their own default
|
|
661
|
+
* pan/price-scale handling. */
|
|
662
|
+
dispatchPointerDown(x, y) {
|
|
663
|
+
const event = this.pointerEventAt(x, y);
|
|
664
|
+
for (let i = this.plugins.length - 1; i >= 0; i--) {
|
|
665
|
+
const plugin = this.plugins[i];
|
|
666
|
+
if (plugin.visible === false)
|
|
667
|
+
continue;
|
|
668
|
+
if (plugin.onPointerDown?.(event)) {
|
|
669
|
+
this.activeGesturePlugin = plugin;
|
|
670
|
+
return true;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return false;
|
|
674
|
+
}
|
|
675
|
+
/** Position in canvas backing-store pixels, accounting for the gap
|
|
676
|
+
* between the canvas's CSS display size and its drawing-buffer size
|
|
677
|
+
* (e.g. when the canvas width attribute is device-pixel-ratio scaled).
|
|
678
|
+
* Takes any `{clientX, clientY}` point rather than `MouseEvent`
|
|
679
|
+
* specifically, since a `Touch` (or a synthesized pinch midpoint) has
|
|
680
|
+
* the same two fields and needs the exact same conversion. */
|
|
681
|
+
cursorPosition(point) {
|
|
682
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
683
|
+
return {
|
|
684
|
+
x: (point.clientX - rect.left) * this.devicePixelScaleX(),
|
|
685
|
+
y: (point.clientY - rect.top) * this.devicePixelScaleY(),
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
/** CSS-pixel-to-backing-store-pixel ratio for the X axis — same
|
|
689
|
+
* conversion `cursorPosition` applies to absolute coordinates, extracted
|
|
690
|
+
* so pixel *deltas* (drag distance, wheel deltaX) can be converted too. */
|
|
691
|
+
devicePixelScaleX() {
|
|
692
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
693
|
+
return rect.width === 0 ? 1 : this.canvas.width / rect.width;
|
|
694
|
+
}
|
|
695
|
+
devicePixelScaleY() {
|
|
696
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
697
|
+
return rect.height === 0 ? 1 : this.canvas.height / rect.height;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* `new WickChart(canvas, { type: 'candlestick', style: {...} })` type-checks
|
|
702
|
+
* even if `style` has nothing to do with `CandlestickStyle` — `type` is a
|
|
703
|
+
* runtime string the registry resolves, so nothing ties it to a specific
|
|
704
|
+
* `TStyle` at the type level (see `src/series/registry.ts`). This factory
|
|
705
|
+
* pins both `TPoint` (`Candle`) and `TStyle` (`CandlestickStyle`) for the
|
|
706
|
+
* one series built into the library, so `style` is fully checked here.
|
|
707
|
+
*
|
|
708
|
+
* A new series type gets the same treatment: export an equivalent
|
|
709
|
+
* `create<Name>Chart` next to it (in your own module, or a file like this
|
|
710
|
+
* one) rather than widening `WickChartOptions` itself — that keeps every
|
|
711
|
+
* series's style shape independent of every other's.
|
|
712
|
+
*/
|
|
713
|
+
export function createCandlestickChart(canvas, options) {
|
|
714
|
+
return new WickChart(canvas, { ...options, type: 'candlestick' });
|
|
715
|
+
}
|