@zuttora/charts 0.0.0-stage → 0.1.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 (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +48 -2
  3. package/dist/chart.d.ts +8 -0
  4. package/dist/chart.js +214 -0
  5. package/dist/chartState.d.ts +41 -0
  6. package/dist/chartState.js +59 -0
  7. package/dist/core/emitter.d.ts +8 -0
  8. package/dist/core/emitter.js +26 -0
  9. package/dist/core/format.d.ts +9 -0
  10. package/dist/core/format.js +35 -0
  11. package/dist/core/heikinAshi.d.ts +2 -0
  12. package/dist/core/heikinAshi.js +14 -0
  13. package/dist/core/priceScale.d.ts +22 -0
  14. package/dist/core/priceScale.js +92 -0
  15. package/dist/core/series.d.ts +41 -0
  16. package/dist/core/series.js +221 -0
  17. package/dist/core/time.d.ts +25 -0
  18. package/dist/core/time.js +44 -0
  19. package/dist/core/timeLabels.d.ts +11 -0
  20. package/dist/core/timeLabels.js +52 -0
  21. package/dist/core/types.d.ts +113 -0
  22. package/dist/core/types.js +1 -0
  23. package/dist/core/viewport.d.ts +41 -0
  24. package/dist/core/viewport.js +116 -0
  25. package/dist/core/warn.d.ts +1 -0
  26. package/dist/core/warn.js +3 -0
  27. package/dist/createChart.d.ts +2 -0
  28. package/dist/createChart.js +7 -0
  29. package/dist/dom/environment.d.ts +11 -0
  30. package/dist/dom/environment.js +49 -0
  31. package/dist/dom/legend.d.ts +14 -0
  32. package/dist/dom/legend.js +53 -0
  33. package/dist/dom/legendSync.d.ts +5 -0
  34. package/dist/dom/legendSync.js +23 -0
  35. package/dist/dom/root.d.ts +9 -0
  36. package/dist/dom/root.js +19 -0
  37. package/dist/host.d.ts +32 -0
  38. package/dist/host.js +62 -0
  39. package/dist/index.d.ts +7 -0
  40. package/dist/index.js +5 -0
  41. package/dist/interact/attach.d.ts +2 -0
  42. package/dist/interact/attach.js +127 -0
  43. package/dist/interact/gestures.d.ts +47 -0
  44. package/dist/interact/gestures.js +179 -0
  45. package/dist/interact/keyboard.d.ts +19 -0
  46. package/dist/interact/keyboard.js +34 -0
  47. package/dist/interact/wheel.d.ts +15 -0
  48. package/dist/interact/wheel.js +18 -0
  49. package/dist/render/bars.d.ts +17 -0
  50. package/dist/render/bars.js +55 -0
  51. package/dist/render/canvas.d.ts +2 -0
  52. package/dist/render/canvas.js +13 -0
  53. package/dist/render/drawAxes.d.ts +2 -0
  54. package/dist/render/drawAxes.js +34 -0
  55. package/dist/render/drawCandles.d.ts +3 -0
  56. package/dist/render/drawCandles.js +68 -0
  57. package/dist/render/drawCrosshair.d.ts +7 -0
  58. package/dist/render/drawCrosshair.js +39 -0
  59. package/dist/render/drawGrid.d.ts +2 -0
  60. package/dist/render/drawGrid.js +22 -0
  61. package/dist/render/drawLastPrice.d.ts +10 -0
  62. package/dist/render/drawLastPrice.js +53 -0
  63. package/dist/render/drawLine.d.ts +3 -0
  64. package/dist/render/drawLine.js +43 -0
  65. package/dist/render/drawMain.d.ts +2 -0
  66. package/dist/render/drawMain.js +30 -0
  67. package/dist/render/drawMarkers.d.ts +2 -0
  68. package/dist/render/drawMarkers.js +39 -0
  69. package/dist/render/drawPriceLines.d.ts +4 -0
  70. package/dist/render/drawPriceLines.js +60 -0
  71. package/dist/render/frame.d.ts +53 -0
  72. package/dist/render/frame.js +5 -0
  73. package/dist/render/frameBuilder.d.ts +12 -0
  74. package/dist/render/frameBuilder.js +119 -0
  75. package/dist/render/layout.d.ts +4 -0
  76. package/dist/render/layout.js +26 -0
  77. package/dist/render/paintFrame.d.ts +11 -0
  78. package/dist/render/paintFrame.js +30 -0
  79. package/dist/render/scheduler.d.ts +17 -0
  80. package/dist/render/scheduler.js +38 -0
  81. package/dist/render/tween.d.ts +12 -0
  82. package/dist/render/tween.js +38 -0
  83. package/dist/theme.d.ts +2 -0
  84. package/dist/theme.js +16 -0
  85. package/package.json +37 -4
@@ -0,0 +1,49 @@
1
+ const currentDpr = () => (typeof devicePixelRatio === 'number' ? devicePixelRatio : 1);
2
+ export function watchEnvironment(container, doc, hooks) {
3
+ let dpr = currentDpr();
4
+ const ro = typeof ResizeObserver === 'function' ? new ResizeObserver(() => hooks.resize()) : null;
5
+ ro?.observe(container);
6
+ let stopDpr = null;
7
+ const watchDpr = () => {
8
+ stopDpr?.();
9
+ stopDpr = null;
10
+ if (typeof matchMedia !== 'function')
11
+ return;
12
+ const mq = matchMedia(`(resolution: ${dpr}dppx)`);
13
+ const onChange = () => {
14
+ dpr = currentDpr();
15
+ hooks.densityChanged();
16
+ watchDpr();
17
+ };
18
+ mq.addEventListener('change', onChange);
19
+ stopDpr = () => mq.removeEventListener('change', onChange);
20
+ };
21
+ watchDpr();
22
+ let onScreen = true;
23
+ const io = typeof IntersectionObserver === 'function'
24
+ ? new IntersectionObserver((entries) => {
25
+ for (const e of entries)
26
+ onScreen = e.isIntersecting;
27
+ })
28
+ : null;
29
+ io?.observe(container);
30
+ const timer = setInterval(() => {
31
+ if (onScreen && doc.visibilityState !== 'hidden')
32
+ hooks.second();
33
+ }, 1000);
34
+ const onVisible = () => {
35
+ if (doc.visibilityState !== 'hidden')
36
+ hooks.visible();
37
+ };
38
+ doc.addEventListener('visibilitychange', onVisible);
39
+ return {
40
+ dpr: () => dpr,
41
+ dispose() {
42
+ clearInterval(timer);
43
+ ro?.disconnect();
44
+ io?.disconnect();
45
+ stopDpr?.();
46
+ doc.removeEventListener('visibilitychange', onVisible);
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,14 @@
1
+ import type { Candle, ChartLabels, ChartTheme } from '../core/types.ts';
2
+ export interface LegendInput {
3
+ candle: Candle | null;
4
+ notes: readonly string[];
5
+ caption: string | null;
6
+ }
7
+ export declare class Legend {
8
+ readonly el: HTMLDivElement;
9
+ private line;
10
+ private extra;
11
+ constructor(doc: Document, theme: ChartTheme);
12
+ applyTheme(theme: ChartTheme): void;
13
+ update(input: LegendInput, labels: ChartLabels, theme: ChartTheme, decimals: number, fmt: (v: number) => string, locale: string): void;
14
+ }
@@ -0,0 +1,53 @@
1
+ import { changeParts } from "../core/format.js";
2
+ export class Legend {
3
+ constructor(doc, theme) {
4
+ this.el = doc.createElement('div');
5
+ this.line = doc.createElement('div');
6
+ this.extra = doc.createElement('div');
7
+ this.line.style.cssText = 'display:flex;flex-wrap:wrap;column-gap:12px';
8
+ this.el.append(this.line, this.extra);
9
+ this.el.setAttribute('aria-live', 'off');
10
+ this.applyTheme(theme);
11
+ }
12
+ applyTheme(theme) {
13
+ const s = this.el.style;
14
+ s.position = 'absolute';
15
+ s.left = '8px';
16
+ s.right = '64px';
17
+ s.top = '6px';
18
+ s.pointerEvents = 'none';
19
+ s.fontFamily = theme.fontFamily;
20
+ s.fontSize = `${theme.fontSize}px`;
21
+ s.lineHeight = '1.35';
22
+ s.color = theme.text;
23
+ s.whiteSpace = 'pre';
24
+ s.userSelect = 'none';
25
+ }
26
+ update(input, labels, theme, decimals, fmt, locale) {
27
+ const k = input.candle;
28
+ this.line.textContent = '';
29
+ this.extra.textContent = '';
30
+ if (!k)
31
+ return;
32
+ const parts = [
33
+ [labels.open, k.o],
34
+ [labels.high, k.h],
35
+ [labels.low, k.l],
36
+ [labels.close, k.c],
37
+ ];
38
+ const doc = this.el.ownerDocument;
39
+ for (const [name, value] of parts) {
40
+ const span = doc.createElement('span');
41
+ span.textContent = `${name} ${fmt(value)}`;
42
+ this.line.append(span);
43
+ }
44
+ const ch = changeParts(k.o, k.c, decimals, locale);
45
+ const change = doc.createElement('span');
46
+ change.textContent = `${ch.arrow} ${ch.amount} (${ch.percent})`.trim();
47
+ change.style.color = ch.direction > 0 ? theme.up : ch.direction < 0 ? theme.down : theme.textMuted;
48
+ this.line.append(change);
49
+ const lines = input.caption ? [input.caption, ...input.notes] : [...input.notes];
50
+ this.extra.textContent = lines.join('\n');
51
+ this.extra.style.color = theme.textMuted;
52
+ }
53
+ }
@@ -0,0 +1,5 @@
1
+ import type { Candle } from '../core/types.ts';
2
+ import type { ChartState } from '../chartState.ts';
3
+ import type { Legend } from './legend.ts';
4
+ export declare function crosshairCandle(s: ChartState): Candle | null;
5
+ export declare function syncLegend(s: ChartState, legend: Legend): void;
@@ -0,0 +1,23 @@
1
+ import { priceFormatter } from "../core/format.js";
2
+ export function crosshairCandle(s) {
3
+ const c = s.crosshair;
4
+ if (!c || c.index < 0 || c.index > s.series.lastIndex())
5
+ return null;
6
+ return s.series.candleAt(c.index);
7
+ }
8
+ export function syncLegend(s, legend) {
9
+ const o = s.options;
10
+ let candle = crosshairCandle(s);
11
+ let idx = s.crosshair ? s.crosshair.index : -1;
12
+ if (!candle && s.series.count > 0) {
13
+ idx = s.series.lastIndex();
14
+ candle = s.series.candleAt(idx);
15
+ }
16
+ const notes = [];
17
+ if (candle) {
18
+ for (const m of s.markers)
19
+ if (m.tooltip && s.series.indexAtTime(m.t) === idx)
20
+ notes.push(m.tooltip);
21
+ }
22
+ legend.update({ candle, notes, caption: o.type === 'heikinAshi' ? o.labels.heikinAshi : null }, o.labels, o.theme, o.priceDecimals, priceFormatter(o.priceDecimals, o.locale), o.locale);
23
+ }
@@ -0,0 +1,9 @@
1
+ import type { ChartTheme } from '../core/types.ts';
2
+ import { Legend } from './legend.ts';
3
+ export interface ChartDom {
4
+ root: HTMLDivElement;
5
+ mainCanvas: HTMLCanvasElement;
6
+ overlayCanvas: HTMLCanvasElement;
7
+ legend: Legend;
8
+ }
9
+ export declare function buildDom(doc: Document, theme: ChartTheme): ChartDom;
@@ -0,0 +1,19 @@
1
+ import { Legend } from "./legend.js";
2
+ export function buildDom(doc, theme) {
3
+ const root = doc.createElement('div');
4
+ root.style.cssText =
5
+ 'position:relative;width:100%;height:100%;overflow:hidden;outline:none;touch-action:pan-y;user-select:none;-webkit-user-select:none';
6
+ root.tabIndex = 0;
7
+ root.setAttribute('role', 'img');
8
+ const mainCanvas = doc.createElement('canvas');
9
+ const overlayCanvas = doc.createElement('canvas');
10
+ for (const c of [mainCanvas, overlayCanvas]) {
11
+ c.style.position = 'absolute';
12
+ c.style.left = '0';
13
+ c.style.top = '0';
14
+ c.style.pointerEvents = 'none';
15
+ }
16
+ const legend = new Legend(doc, theme);
17
+ root.append(mainCanvas, overlayCanvas, legend.el);
18
+ return { root, mainCanvas, overlayCanvas, legend };
19
+ }
package/dist/host.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type { ChartEvents } from './core/types.ts';
2
+ import type { Emitter } from './core/emitter.ts';
3
+ import type { ChartState } from './chartState.ts';
4
+ import type { Frame, Layout } from './render/frame.ts';
5
+ export interface ChartHost {
6
+ root: HTMLElement;
7
+ state: ChartState;
8
+ setCrosshair(pos: {
9
+ x: number;
10
+ y: number;
11
+ } | null): void;
12
+ viewChanged(): void;
13
+ cancelAnimation(): void;
14
+ invalidate(layer: 'main' | 'overlay'): void;
15
+ scrollToLive(): void;
16
+ layout(): Layout | null;
17
+ addDisposer(fn: () => void): void;
18
+ }
19
+ export interface HostDeps {
20
+ root: HTMLElement;
21
+ state: ChartState;
22
+ events: Emitter<ChartEvents>;
23
+ invalidate(layer: 'main' | 'overlay'): void;
24
+ invalidateAll(): void;
25
+ updateLegend(): void;
26
+ isDestroyed(): boolean;
27
+ lastFrame(): Frame | null;
28
+ now(): number;
29
+ }
30
+ export declare function createHost(d: HostDeps): ChartHost & {
31
+ dispose(): void;
32
+ };
package/dist/host.js ADDED
@@ -0,0 +1,62 @@
1
+ import { crosshairCandle } from "./dom/legendSync.js";
2
+ import { SCROLL_TWEEN_MS } from "./render/frameBuilder.js";
3
+ export function createHost(d) {
4
+ const s = d.state;
5
+ const disposers = [];
6
+ return {
7
+ root: d.root,
8
+ state: s,
9
+ setCrosshair(pos) {
10
+ if (d.isDestroyed())
11
+ return;
12
+ const frame = d.lastFrame();
13
+ let next = null;
14
+ if (pos && s.series.count > 0 && frame) {
15
+ const idx = Math.round(s.vp.indexAt(pos.x));
16
+ const inPlot = pos.x >= 0 && pos.x <= frame.layout.plotRight && idx >= 0 && idx <= s.series.lastIndex();
17
+ if (inPlot)
18
+ next = { x: pos.x, y: pos.y, index: idx };
19
+ }
20
+ s.crosshair = next;
21
+ const candle = crosshairCandle(s);
22
+ const key = candle ? candle.t : null;
23
+ if (key !== s.lastCrosshairTime) {
24
+ s.lastCrosshairTime = key;
25
+ d.events.emit('crosshair', candle);
26
+ }
27
+ d.updateLegend();
28
+ d.invalidate('overlay');
29
+ },
30
+ viewChanged() {
31
+ d.invalidateAll();
32
+ },
33
+ cancelAnimation() {
34
+ s.rightAnimating = false;
35
+ },
36
+ invalidate: d.invalidate,
37
+ scrollToLive() {
38
+ if (d.isDestroyed())
39
+ return;
40
+ if (s.options.reducedMotion) {
41
+ s.vp.goLive();
42
+ }
43
+ else {
44
+ s.rightTween.jump(s.vp.rightIndex);
45
+ s.rightTween.set(s.vp.liveRightIndex(), d.now(), SCROLL_TWEEN_MS);
46
+ s.rightAnimating = true;
47
+ }
48
+ d.invalidateAll();
49
+ },
50
+ layout() {
51
+ return d.lastFrame()?.layout ?? null;
52
+ },
53
+ addDisposer(fn) {
54
+ disposers.push(fn);
55
+ },
56
+ dispose() {
57
+ for (const fn of disposers)
58
+ fn();
59
+ disposers.length = 0;
60
+ },
61
+ };
62
+ }
@@ -0,0 +1,7 @@
1
+ export type { Candle, CandleStyle, Chart, ChartEvents, ChartLabels, ChartOptions, ChartTheme, ChartType, Marker, PriceLine, Time, } from './core/types.ts';
2
+ export { createChart } from './createChart.ts';
3
+ export { bucketStart, IST_OFFSET_MS, INTERVAL_MS } from './core/time.ts';
4
+ export type { IntervalId } from './core/time.ts';
5
+ export { heikinAshi } from './core/heikinAshi.ts';
6
+ export { zuttoraTheme } from './theme.ts';
7
+ export declare const VERSION = "zuttora-charts@0.1.0";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { createChart } from "./createChart.js";
2
+ export { bucketStart, IST_OFFSET_MS, INTERVAL_MS } from "./core/time.js";
3
+ export { heikinAshi } from "./core/heikinAshi.js";
4
+ export { zuttoraTheme } from "./theme.js";
5
+ export const VERSION = 'zuttora-charts@0.1.0';
@@ -0,0 +1,2 @@
1
+ import type { ChartHost } from '../host.ts';
2
+ export declare function attachInteraction(host: ChartHost): void;
@@ -0,0 +1,127 @@
1
+ import { Gestures } from "./gestures.js";
2
+ import { handleKey } from "./keyboard.js";
3
+ import { handleWheel } from "./wheel.js";
4
+ export function attachInteraction(host) {
5
+ const root = host.root;
6
+ const s = host.state;
7
+ const changed = () => {
8
+ host.cancelAnimation();
9
+ host.viewChanged();
10
+ };
11
+ const target = {
12
+ region(x, y) {
13
+ const l = host.layout();
14
+ if (!l || x < 0 || y < 0 || x > l.width || y > l.height)
15
+ return 'outside';
16
+ if (y >= l.timeTop)
17
+ return 'time';
18
+ return x < l.plotRight ? 'plot' : 'axis';
19
+ },
20
+ panBy(dx) {
21
+ s.vp.panBy(dx);
22
+ changed();
23
+ },
24
+ zoomAt(x, factor) {
25
+ s.vp.zoomAt(x, factor);
26
+ changed();
27
+ },
28
+ scaleBy(dy) {
29
+ s.scale.scaleBy(dy);
30
+ changed();
31
+ },
32
+ resetAuto() {
33
+ s.scale.resetAuto();
34
+ changed();
35
+ },
36
+ resetView() {
37
+ s.vp.reset();
38
+ s.scale.resetAuto();
39
+ changed();
40
+ },
41
+ showCrosshair: (x, y) => host.setCrosshair({ x, y }),
42
+ hideCrosshair: () => host.setCrosshair(null),
43
+ now: () => (typeof performance !== 'undefined' ? performance.now() : Date.now()),
44
+ setTimer: (fn, ms) => setTimeout(fn, ms),
45
+ clearTimer: (id) => clearTimeout(id),
46
+ };
47
+ const gestures = new Gestures(target);
48
+ const local = (e) => {
49
+ const r = root.getBoundingClientRect();
50
+ return { id: e.pointerId, type: e.pointerType, x: e.clientX - r.left, y: e.clientY - r.top, button: e.button };
51
+ };
52
+ const onDown = (e) => {
53
+ if (gestures.down(local(e))) {
54
+ try {
55
+ root.setPointerCapture(e.pointerId);
56
+ }
57
+ catch {
58
+ }
59
+ root.focus({ preventScroll: true });
60
+ }
61
+ };
62
+ const onMove = (e) => gestures.move(local(e));
63
+ const onUp = (e) => {
64
+ gestures.up(local(e));
65
+ try {
66
+ root.releasePointerCapture(e.pointerId);
67
+ }
68
+ catch {
69
+ }
70
+ };
71
+ const onCancel = (e) => gestures.cancel(local(e));
72
+ const onLeave = (e) => {
73
+ if (e.pointerType !== 'touch')
74
+ gestures.leave();
75
+ };
76
+ const onWheel = (e) => {
77
+ const r = root.getBoundingClientRect();
78
+ const used = handleWheel({ x: e.clientX - r.left, y: e.clientY - r.top, deltaX: e.deltaX, deltaY: e.deltaY, deltaMode: e.deltaMode, shiftKey: e.shiftKey }, {
79
+ inPlot: (x, y) => target.region(x, y) === 'plot',
80
+ panBy: target.panBy,
81
+ zoomAt: target.zoomAt,
82
+ });
83
+ if (used)
84
+ e.preventDefault();
85
+ };
86
+ const onKey = (e) => {
87
+ const used = handleKey(e, {
88
+ barSpacing: () => s.vp.barSpacing,
89
+ plotCentre: () => (s.vp.plotLeft + s.vp.plotRight) / 2,
90
+ panBy: target.panBy,
91
+ zoomAt: target.zoomAt,
92
+ scrollToLive: () => host.scrollToLive(),
93
+ goToOldest: () => {
94
+ s.vp.setRightIndex(s.vp.plotWidth / s.vp.barSpacing);
95
+ changed();
96
+ },
97
+ hideCrosshair: target.hideCrosshair,
98
+ crosshairVisible: () => s.crosshair !== null,
99
+ });
100
+ if (used) {
101
+ e.preventDefault();
102
+ e.stopPropagation();
103
+ }
104
+ };
105
+ const onFocus = () => root.style.setProperty('outline', `2px solid ${s.options.theme.accent}`);
106
+ const onBlur = () => root.style.setProperty('outline', 'none');
107
+ root.addEventListener('pointerdown', onDown);
108
+ root.addEventListener('pointermove', onMove);
109
+ root.addEventListener('pointerup', onUp);
110
+ root.addEventListener('pointercancel', onCancel);
111
+ root.addEventListener('pointerleave', onLeave);
112
+ root.addEventListener('wheel', onWheel, { passive: false });
113
+ root.addEventListener('keydown', onKey);
114
+ root.addEventListener('focus', onFocus);
115
+ root.addEventListener('blur', onBlur);
116
+ host.addDisposer(() => {
117
+ root.removeEventListener('pointerdown', onDown);
118
+ root.removeEventListener('pointermove', onMove);
119
+ root.removeEventListener('pointerup', onUp);
120
+ root.removeEventListener('pointercancel', onCancel);
121
+ root.removeEventListener('pointerleave', onLeave);
122
+ root.removeEventListener('wheel', onWheel);
123
+ root.removeEventListener('keydown', onKey);
124
+ root.removeEventListener('focus', onFocus);
125
+ root.removeEventListener('blur', onBlur);
126
+ });
127
+ }
@@ -0,0 +1,47 @@
1
+ export type Region = 'plot' | 'axis' | 'time' | 'outside';
2
+ export interface GestureTarget {
3
+ region(x: number, y: number): Region;
4
+ panBy(dxPx: number): void;
5
+ zoomAt(x: number, factor: number): void;
6
+ scaleBy(dyPx: number): void;
7
+ resetAuto(): void;
8
+ resetView(): void;
9
+ showCrosshair(x: number, y: number): void;
10
+ hideCrosshair(): void;
11
+ now(): number;
12
+ setTimer(fn: () => void, ms: number): number;
13
+ clearTimer(id: number): void;
14
+ }
15
+ export interface PointerInput {
16
+ id: number;
17
+ type: string;
18
+ x: number;
19
+ y: number;
20
+ button: number;
21
+ }
22
+ export declare const HOLD_MS = 300;
23
+ export declare const HOLD_SLOP = 6;
24
+ export declare const MOUSE_SLOP = 3;
25
+ export declare const DOUBLE_MS = 300;
26
+ export declare const DOUBLE_DIST = 24;
27
+ export declare class Gestures {
28
+ private target;
29
+ private pointers;
30
+ private mode;
31
+ private holdTimer;
32
+ private lastTap;
33
+ private pinchDist;
34
+ private pinchMidX;
35
+ private pinchMidY;
36
+ constructor(target: GestureTarget);
37
+ down(p: PointerInput): boolean;
38
+ move(p: PointerInput): void;
39
+ up(p: PointerInput): void;
40
+ cancel(p: PointerInput): void;
41
+ leave(): void;
42
+ private hold;
43
+ private tap;
44
+ private clearHold;
45
+ private measurePinch;
46
+ private pinchStep;
47
+ }
@@ -0,0 +1,179 @@
1
+ export const HOLD_MS = 300;
2
+ export const HOLD_SLOP = 6;
3
+ export const MOUSE_SLOP = 3;
4
+ export const DOUBLE_MS = 300;
5
+ export const DOUBLE_DIST = 24;
6
+ const TAP_MS = 400;
7
+ export class Gestures {
8
+ constructor(target) {
9
+ this.pointers = new Map();
10
+ this.mode = 'idle';
11
+ this.holdTimer = null;
12
+ this.lastTap = null;
13
+ this.pinchDist = 0;
14
+ this.pinchMidX = 0;
15
+ this.pinchMidY = 0;
16
+ this.target = target;
17
+ }
18
+ down(p) {
19
+ if (p.type === 'mouse' && p.button !== 0)
20
+ return false;
21
+ const region = this.target.region(p.x, p.y);
22
+ if (region === 'outside' || region === 'time')
23
+ return false;
24
+ this.pointers.set(p.id, {
25
+ type: p.type, x: p.x, y: p.y, startX: p.x, startY: p.y, startT: this.target.now(), region, dragged: false,
26
+ });
27
+ if (this.pointers.size >= 2) {
28
+ this.clearHold();
29
+ this.target.hideCrosshair();
30
+ this.mode = 'pinch';
31
+ this.measurePinch();
32
+ return true;
33
+ }
34
+ if (region === 'axis') {
35
+ this.mode = 'axis';
36
+ }
37
+ else if (p.type === 'touch') {
38
+ this.mode = 'press';
39
+ this.holdTimer = this.target.setTimer(() => this.hold(p.id), HOLD_MS);
40
+ }
41
+ else {
42
+ this.mode = 'pan';
43
+ }
44
+ return true;
45
+ }
46
+ move(p) {
47
+ const t = this.pointers.get(p.id);
48
+ if (!t) {
49
+ if (p.type !== 'touch') {
50
+ if (this.target.region(p.x, p.y) === 'plot')
51
+ this.target.showCrosshair(p.x, p.y);
52
+ else
53
+ this.target.hideCrosshair();
54
+ }
55
+ return;
56
+ }
57
+ const dx = p.x - t.x;
58
+ const dy = p.y - t.y;
59
+ t.x = p.x;
60
+ t.y = p.y;
61
+ switch (this.mode) {
62
+ case 'press':
63
+ if (Math.hypot(p.x - t.startX, p.y - t.startY) > HOLD_SLOP) {
64
+ this.clearHold();
65
+ this.mode = 'pan';
66
+ t.dragged = true;
67
+ this.target.panBy(p.x - t.startX);
68
+ }
69
+ break;
70
+ case 'pan':
71
+ if (t.type === 'mouse' && Math.hypot(p.x - t.startX, p.y - t.startY) <= MOUSE_SLOP && !t.dragged)
72
+ break;
73
+ t.dragged = true;
74
+ this.target.panBy(dx);
75
+ if (t.type !== 'touch' && this.target.region(p.x, p.y) === 'plot')
76
+ this.target.showCrosshair(p.x, p.y);
77
+ break;
78
+ case 'axis':
79
+ t.dragged = true;
80
+ this.target.scaleBy(dy);
81
+ break;
82
+ case 'crosshair':
83
+ this.target.showCrosshair(p.x, p.y);
84
+ break;
85
+ case 'pinch':
86
+ this.pinchStep();
87
+ break;
88
+ default:
89
+ break;
90
+ }
91
+ }
92
+ up(p) {
93
+ const t = this.pointers.get(p.id);
94
+ if (!t)
95
+ return;
96
+ this.pointers.delete(p.id);
97
+ this.clearHold();
98
+ const wasMode = this.mode;
99
+ if (this.pointers.size >= 1 && wasMode === 'pinch') {
100
+ this.mode = 'idle';
101
+ }
102
+ else if (this.pointers.size === 0) {
103
+ this.mode = 'idle';
104
+ }
105
+ if (wasMode === 'crosshair') {
106
+ this.target.hideCrosshair();
107
+ return;
108
+ }
109
+ if (wasMode === 'pinch')
110
+ return;
111
+ const quick = this.target.now() - t.startT <= TAP_MS;
112
+ const still = Math.hypot(t.x - t.startX, t.y - t.startY) <= HOLD_SLOP && !t.dragged;
113
+ if (quick && still)
114
+ this.tap(t);
115
+ if (t.type === 'touch')
116
+ this.target.hideCrosshair();
117
+ }
118
+ cancel(p) {
119
+ const t = this.pointers.get(p.id);
120
+ if (!t)
121
+ return;
122
+ this.pointers.delete(p.id);
123
+ this.clearHold();
124
+ if (this.pointers.size === 0)
125
+ this.mode = 'idle';
126
+ if (t.type === 'touch')
127
+ this.target.hideCrosshair();
128
+ }
129
+ leave() {
130
+ if (this.pointers.size === 0)
131
+ this.target.hideCrosshair();
132
+ }
133
+ hold(id) {
134
+ this.holdTimer = null;
135
+ const t = this.pointers.get(id);
136
+ if (!t || this.mode !== 'press')
137
+ return;
138
+ this.mode = 'crosshair';
139
+ this.target.showCrosshair(t.x, t.y);
140
+ }
141
+ tap(t) {
142
+ const now = this.target.now();
143
+ const last = this.lastTap;
144
+ if (last && now - last.t <= DOUBLE_MS && Math.hypot(t.x - last.x, t.y - last.y) <= DOUBLE_DIST) {
145
+ this.lastTap = null;
146
+ if (t.region === 'axis')
147
+ this.target.resetAuto();
148
+ else
149
+ this.target.resetView();
150
+ return;
151
+ }
152
+ this.lastTap = { t: now, x: t.x, y: t.y };
153
+ }
154
+ clearHold() {
155
+ if (this.holdTimer !== null)
156
+ this.target.clearTimer(this.holdTimer);
157
+ this.holdTimer = null;
158
+ }
159
+ measurePinch() {
160
+ const [a, b] = [...this.pointers.values()];
161
+ if (!a || !b)
162
+ return;
163
+ this.pinchDist = Math.max(1, Math.hypot(a.x - b.x, a.y - b.y));
164
+ this.pinchMidX = (a.x + b.x) / 2;
165
+ this.pinchMidY = (a.y + b.y) / 2;
166
+ }
167
+ pinchStep() {
168
+ const [a, b] = [...this.pointers.values()];
169
+ if (!a || !b)
170
+ return;
171
+ const dist = Math.max(1, Math.hypot(a.x - b.x, a.y - b.y));
172
+ const midX = (a.x + b.x) / 2;
173
+ this.target.zoomAt(midX, dist / this.pinchDist);
174
+ this.target.panBy(midX - this.pinchMidX);
175
+ this.pinchDist = dist;
176
+ this.pinchMidX = midX;
177
+ this.pinchMidY = (a.y + b.y) / 2;
178
+ }
179
+ }
@@ -0,0 +1,19 @@
1
+ export interface KeyInput {
2
+ key: string;
3
+ shiftKey: boolean;
4
+ ctrlKey: boolean;
5
+ metaKey: boolean;
6
+ altKey: boolean;
7
+ }
8
+ export interface KeyTarget {
9
+ barSpacing(): number;
10
+ plotCentre(): number;
11
+ panBy(dxPx: number): void;
12
+ zoomAt(x: number, factor: number): void;
13
+ scrollToLive(): void;
14
+ goToOldest(): void;
15
+ hideCrosshair(): void;
16
+ crosshairVisible(): boolean;
17
+ }
18
+ export declare const KEY_ZOOM = 1.25;
19
+ export declare function handleKey(k: KeyInput, t: KeyTarget): boolean;