@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.
- package/LICENSE +21 -0
- package/README.md +48 -2
- package/dist/chart.d.ts +8 -0
- package/dist/chart.js +214 -0
- package/dist/chartState.d.ts +41 -0
- package/dist/chartState.js +59 -0
- package/dist/core/emitter.d.ts +8 -0
- package/dist/core/emitter.js +26 -0
- package/dist/core/format.d.ts +9 -0
- package/dist/core/format.js +35 -0
- package/dist/core/heikinAshi.d.ts +2 -0
- package/dist/core/heikinAshi.js +14 -0
- package/dist/core/priceScale.d.ts +22 -0
- package/dist/core/priceScale.js +92 -0
- package/dist/core/series.d.ts +41 -0
- package/dist/core/series.js +221 -0
- package/dist/core/time.d.ts +25 -0
- package/dist/core/time.js +44 -0
- package/dist/core/timeLabels.d.ts +11 -0
- package/dist/core/timeLabels.js +52 -0
- package/dist/core/types.d.ts +113 -0
- package/dist/core/types.js +1 -0
- package/dist/core/viewport.d.ts +41 -0
- package/dist/core/viewport.js +116 -0
- package/dist/core/warn.d.ts +1 -0
- package/dist/core/warn.js +3 -0
- package/dist/createChart.d.ts +2 -0
- package/dist/createChart.js +7 -0
- package/dist/dom/environment.d.ts +11 -0
- package/dist/dom/environment.js +49 -0
- package/dist/dom/legend.d.ts +14 -0
- package/dist/dom/legend.js +53 -0
- package/dist/dom/legendSync.d.ts +5 -0
- package/dist/dom/legendSync.js +23 -0
- package/dist/dom/root.d.ts +9 -0
- package/dist/dom/root.js +19 -0
- package/dist/host.d.ts +32 -0
- package/dist/host.js +62 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +5 -0
- package/dist/interact/attach.d.ts +2 -0
- package/dist/interact/attach.js +127 -0
- package/dist/interact/gestures.d.ts +47 -0
- package/dist/interact/gestures.js +179 -0
- package/dist/interact/keyboard.d.ts +19 -0
- package/dist/interact/keyboard.js +34 -0
- package/dist/interact/wheel.d.ts +15 -0
- package/dist/interact/wheel.js +18 -0
- package/dist/render/bars.d.ts +17 -0
- package/dist/render/bars.js +55 -0
- package/dist/render/canvas.d.ts +2 -0
- package/dist/render/canvas.js +13 -0
- package/dist/render/drawAxes.d.ts +2 -0
- package/dist/render/drawAxes.js +34 -0
- package/dist/render/drawCandles.d.ts +3 -0
- package/dist/render/drawCandles.js +68 -0
- package/dist/render/drawCrosshair.d.ts +7 -0
- package/dist/render/drawCrosshair.js +39 -0
- package/dist/render/drawGrid.d.ts +2 -0
- package/dist/render/drawGrid.js +22 -0
- package/dist/render/drawLastPrice.d.ts +10 -0
- package/dist/render/drawLastPrice.js +53 -0
- package/dist/render/drawLine.d.ts +3 -0
- package/dist/render/drawLine.js +43 -0
- package/dist/render/drawMain.d.ts +2 -0
- package/dist/render/drawMain.js +30 -0
- package/dist/render/drawMarkers.d.ts +2 -0
- package/dist/render/drawMarkers.js +39 -0
- package/dist/render/drawPriceLines.d.ts +4 -0
- package/dist/render/drawPriceLines.js +60 -0
- package/dist/render/frame.d.ts +53 -0
- package/dist/render/frame.js +5 -0
- package/dist/render/frameBuilder.d.ts +12 -0
- package/dist/render/frameBuilder.js +119 -0
- package/dist/render/layout.d.ts +4 -0
- package/dist/render/layout.js +26 -0
- package/dist/render/paintFrame.d.ts +11 -0
- package/dist/render/paintFrame.js +30 -0
- package/dist/render/scheduler.d.ts +17 -0
- package/dist/render/scheduler.js +38 -0
- package/dist/render/tween.d.ts +12 -0
- package/dist/render/tween.js +38 -0
- package/dist/theme.d.ts +2 -0
- package/dist/theme.js +16 -0
- 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;
|
package/dist/dom/root.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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,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;
|