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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +545 -0
  3. package/dist/axis.d.ts +21 -0
  4. package/dist/axis.js +44 -0
  5. package/dist/dataSource.d.ts +24 -0
  6. package/dist/dataSource.js +1 -0
  7. package/dist/hitTest.d.ts +31 -0
  8. package/dist/hitTest.js +46 -0
  9. package/dist/hybridScale.d.ts +25 -0
  10. package/dist/hybridScale.js +41 -0
  11. package/dist/index.d.ts +225 -0
  12. package/dist/index.js +715 -0
  13. package/dist/mergeSeries.d.ts +14 -0
  14. package/dist/mergeSeries.js +21 -0
  15. package/dist/plugins/types.d.ts +140 -0
  16. package/dist/plugins/types.js +1 -0
  17. package/dist/priceAxis.d.ts +7 -0
  18. package/dist/priceAxis.js +49 -0
  19. package/dist/priceRange.d.ts +12 -0
  20. package/dist/priceRange.js +16 -0
  21. package/dist/renderer.d.ts +79 -0
  22. package/dist/renderer.js +318 -0
  23. package/dist/scale.d.ts +20 -0
  24. package/dist/scale.js +29 -0
  25. package/dist/series/candlestick.d.ts +20 -0
  26. package/dist/series/candlestick.js +88 -0
  27. package/dist/series/registry.d.ts +13 -0
  28. package/dist/series/registry.js +30 -0
  29. package/dist/series/types.d.ts +56 -0
  30. package/dist/series/types.js +1 -0
  31. package/dist/testHelpers.d.ts +38 -0
  32. package/dist/testHelpers.js +50 -0
  33. package/dist/time.d.ts +6 -0
  34. package/dist/time.js +58 -0
  35. package/dist/types.d.ts +151 -0
  36. package/dist/types.js +1 -0
  37. package/dist/viewport.d.ts +52 -0
  38. package/dist/viewport.js +87 -0
  39. package/dist/wasm.d.ts +29 -0
  40. package/dist/wasm.js +35 -0
  41. package/dist/wasmImporter.d.ts +6 -0
  42. package/dist/wasmImporter.js +7 -0
  43. package/package.json +39 -0
  44. package/wasm-pkg/package.json +21 -0
  45. package/wasm-pkg/wickchart_core.d.ts +59 -0
  46. package/wasm-pkg/wickchart_core.js +227 -0
  47. package/wasm-pkg/wickchart_core_bg.wasm +0 -0
  48. 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
+ }