@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,34 @@
|
|
|
1
|
+
export const KEY_ZOOM = 1.25;
|
|
2
|
+
export function handleKey(k, t) {
|
|
3
|
+
if (k.ctrlKey || k.metaKey || k.altKey)
|
|
4
|
+
return false;
|
|
5
|
+
switch (k.key) {
|
|
6
|
+
case 'ArrowLeft':
|
|
7
|
+
t.panBy(t.barSpacing() * (k.shiftKey ? 10 : 1));
|
|
8
|
+
return true;
|
|
9
|
+
case 'ArrowRight':
|
|
10
|
+
t.panBy(-t.barSpacing() * (k.shiftKey ? 10 : 1));
|
|
11
|
+
return true;
|
|
12
|
+
case '+':
|
|
13
|
+
case '=':
|
|
14
|
+
t.zoomAt(t.plotCentre(), KEY_ZOOM);
|
|
15
|
+
return true;
|
|
16
|
+
case '-':
|
|
17
|
+
case '_':
|
|
18
|
+
t.zoomAt(t.plotCentre(), 1 / KEY_ZOOM);
|
|
19
|
+
return true;
|
|
20
|
+
case 'End':
|
|
21
|
+
t.scrollToLive();
|
|
22
|
+
return true;
|
|
23
|
+
case 'Home':
|
|
24
|
+
t.goToOldest();
|
|
25
|
+
return true;
|
|
26
|
+
case 'Escape':
|
|
27
|
+
if (!t.crosshairVisible())
|
|
28
|
+
return false;
|
|
29
|
+
t.hideCrosshair();
|
|
30
|
+
return true;
|
|
31
|
+
default:
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface WheelInput {
|
|
2
|
+
x: number;
|
|
3
|
+
deltaX: number;
|
|
4
|
+
deltaY: number;
|
|
5
|
+
deltaMode: number;
|
|
6
|
+
shiftKey: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface WheelTarget {
|
|
9
|
+
inPlot(x: number, y: number): boolean;
|
|
10
|
+
panBy(dxPx: number): void;
|
|
11
|
+
zoomAt(x: number, factor: number): void;
|
|
12
|
+
}
|
|
13
|
+
export declare function handleWheel(w: WheelInput & {
|
|
14
|
+
y: number;
|
|
15
|
+
}, t: WheelTarget): boolean;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const toPixels = (d, mode) => (mode === 1 ? d * 16 : mode === 2 ? d * 100 : d);
|
|
2
|
+
export function handleWheel(w, t) {
|
|
3
|
+
if (!t.inPlot(w.x, w.y))
|
|
4
|
+
return false;
|
|
5
|
+
const dx = toPixels(w.deltaX, w.deltaMode);
|
|
6
|
+
const dy = toPixels(w.deltaY, w.deltaMode);
|
|
7
|
+
if (w.shiftKey || Math.abs(dx) > Math.abs(dy)) {
|
|
8
|
+
const d = w.shiftKey && dx === 0 ? dy : dx;
|
|
9
|
+
if (d === 0)
|
|
10
|
+
return false;
|
|
11
|
+
t.panBy(-d);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
if (dy === 0)
|
|
15
|
+
return false;
|
|
16
|
+
t.zoomAt(w.x, Math.exp(-dy * 0.002));
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CandleSeries } from '../core/series.ts';
|
|
2
|
+
import type { BarSource, Ohlc } from './frame.ts';
|
|
3
|
+
export declare class SeriesBars implements BarSource {
|
|
4
|
+
series: CandleSeries;
|
|
5
|
+
drawnClose: number | null;
|
|
6
|
+
constructor(series: CandleSeries);
|
|
7
|
+
at(i: number, out: Ohlc): void;
|
|
8
|
+
}
|
|
9
|
+
export declare class HeikinBars implements BarSource {
|
|
10
|
+
series: CandleSeries;
|
|
11
|
+
private ha;
|
|
12
|
+
private formingHa;
|
|
13
|
+
constructor(series: CandleSeries);
|
|
14
|
+
rebuild(): void;
|
|
15
|
+
refreshForming(): void;
|
|
16
|
+
at(i: number, out: Ohlc): void;
|
|
17
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { heikinAshi } from "../core/heikinAshi.js";
|
|
2
|
+
export class SeriesBars {
|
|
3
|
+
constructor(series) {
|
|
4
|
+
this.drawnClose = null;
|
|
5
|
+
this.series = series;
|
|
6
|
+
}
|
|
7
|
+
at(i, out) {
|
|
8
|
+
const s = this.series;
|
|
9
|
+
if (i === s.t.length && s.forming) {
|
|
10
|
+
const f = s.forming;
|
|
11
|
+
out.o = f.o;
|
|
12
|
+
out.h = f.h;
|
|
13
|
+
out.l = f.l;
|
|
14
|
+
out.c = this.drawnClose ?? f.c;
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
out.o = s.o[i];
|
|
18
|
+
out.h = s.h[i];
|
|
19
|
+
out.l = s.l[i];
|
|
20
|
+
out.c = s.c[i];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export class HeikinBars {
|
|
24
|
+
constructor(series) {
|
|
25
|
+
this.ha = [];
|
|
26
|
+
this.formingHa = null;
|
|
27
|
+
this.series = series;
|
|
28
|
+
}
|
|
29
|
+
rebuild() {
|
|
30
|
+
const s = this.series;
|
|
31
|
+
const all = [];
|
|
32
|
+
for (let i = 0; i < s.t.length; i++)
|
|
33
|
+
all.push({ t: s.t[i], o: s.o[i], h: s.h[i], l: s.l[i], c: s.c[i] });
|
|
34
|
+
this.ha = heikinAshi(all);
|
|
35
|
+
this.refreshForming();
|
|
36
|
+
}
|
|
37
|
+
refreshForming() {
|
|
38
|
+
const f = this.series.forming;
|
|
39
|
+
if (!f) {
|
|
40
|
+
this.formingHa = null;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const prev = this.ha[this.ha.length - 1];
|
|
44
|
+
const c = (f.o + f.h + f.l + f.c) / 4;
|
|
45
|
+
const o = prev ? (prev.o + prev.c) / 2 : (f.o + f.c) / 2;
|
|
46
|
+
this.formingHa = { t: f.t, o, h: Math.max(f.h, o, c), l: Math.min(f.l, o, c), c };
|
|
47
|
+
}
|
|
48
|
+
at(i, out) {
|
|
49
|
+
const k = i === this.ha.length && this.formingHa ? this.formingHa : this.ha[i];
|
|
50
|
+
out.o = k.o;
|
|
51
|
+
out.h = k.h;
|
|
52
|
+
out.l = k.l;
|
|
53
|
+
out.c = k.c;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function fitCanvas(canvas, cssWidth, cssHeight, dpr) {
|
|
2
|
+
const w = Math.max(1, Math.round(cssWidth * dpr));
|
|
3
|
+
const h = Math.max(1, Math.round(cssHeight * dpr));
|
|
4
|
+
if (canvas.width !== w)
|
|
5
|
+
canvas.width = w;
|
|
6
|
+
if (canvas.height !== h)
|
|
7
|
+
canvas.height = h;
|
|
8
|
+
canvas.style.width = `${cssWidth}px`;
|
|
9
|
+
canvas.style.height = `${cssHeight}px`;
|
|
10
|
+
const ctx = canvas.getContext('2d');
|
|
11
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
12
|
+
return ctx;
|
|
13
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { TAG_PAD_X } from "./frame.js";
|
|
2
|
+
export function drawAxes(ctx, f) {
|
|
3
|
+
const { layout, theme } = f;
|
|
4
|
+
ctx.fillStyle = theme.background;
|
|
5
|
+
ctx.fillRect(layout.plotRight, 0, layout.axisWidth, layout.height);
|
|
6
|
+
ctx.fillRect(0, layout.timeTop, layout.width, layout.timeHeight);
|
|
7
|
+
ctx.beginPath();
|
|
8
|
+
ctx.lineWidth = 1;
|
|
9
|
+
ctx.moveTo(layout.plotRight + 0.5, 0);
|
|
10
|
+
ctx.lineTo(layout.plotRight + 0.5, layout.timeTop);
|
|
11
|
+
ctx.moveTo(0, layout.timeTop + 0.5);
|
|
12
|
+
ctx.lineTo(layout.width, layout.timeTop + 0.5);
|
|
13
|
+
ctx.strokeStyle = theme.grid;
|
|
14
|
+
ctx.stroke();
|
|
15
|
+
ctx.font = f.font;
|
|
16
|
+
ctx.fillStyle = theme.text;
|
|
17
|
+
ctx.textBaseline = 'middle';
|
|
18
|
+
ctx.textAlign = 'left';
|
|
19
|
+
const bottom = layout.mainTop + layout.mainHeight;
|
|
20
|
+
for (const p of f.ticks) {
|
|
21
|
+
const y = f.scale.y(p);
|
|
22
|
+
if (y < layout.mainTop + 6 || y > bottom - 6)
|
|
23
|
+
continue;
|
|
24
|
+
ctx.fillText(f.fmt(p), layout.plotRight + TAG_PAD_X, Math.round(y));
|
|
25
|
+
}
|
|
26
|
+
ctx.textAlign = 'center';
|
|
27
|
+
const ty = layout.timeTop + layout.timeHeight / 2 + 1;
|
|
28
|
+
for (const label of f.timeLabels) {
|
|
29
|
+
if (label.x < 0 || label.x > layout.plotRight)
|
|
30
|
+
continue;
|
|
31
|
+
ctx.fillText(label.text, Math.round(label.x), ty);
|
|
32
|
+
}
|
|
33
|
+
ctx.textAlign = 'left';
|
|
34
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const bar = { o: 0, h: 0, l: 0, c: 0 };
|
|
2
|
+
export function bodyWidth(barSpacing) {
|
|
3
|
+
const w = Math.max(1, Math.floor(barSpacing * 0.7));
|
|
4
|
+
return w % 2 === 0 ? w - 1 || 1 : w;
|
|
5
|
+
}
|
|
6
|
+
export function drawCandles(ctx, f) {
|
|
7
|
+
const r = f.vp.visibleRange();
|
|
8
|
+
if (!r)
|
|
9
|
+
return;
|
|
10
|
+
const wickOnly = f.vp.barSpacing < 3;
|
|
11
|
+
const bw = bodyWidth(f.vp.barSpacing);
|
|
12
|
+
const half = (bw - 1) / 2;
|
|
13
|
+
ctx.lineWidth = 1;
|
|
14
|
+
const wicks = (up) => {
|
|
15
|
+
ctx.beginPath();
|
|
16
|
+
for (let i = r.from; i <= r.to; i++) {
|
|
17
|
+
f.source.at(i, bar);
|
|
18
|
+
if (bar.c >= bar.o !== up)
|
|
19
|
+
continue;
|
|
20
|
+
const x = Math.round(f.vp.x(i)) + 0.5;
|
|
21
|
+
const yh = Math.round(f.scale.y(bar.h));
|
|
22
|
+
const yl = Math.round(f.scale.y(bar.l));
|
|
23
|
+
if (wickOnly) {
|
|
24
|
+
ctx.moveTo(x, yh);
|
|
25
|
+
ctx.lineTo(x, yl);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const top = Math.round(f.scale.y(Math.max(bar.o, bar.c)));
|
|
29
|
+
const bottom = Math.max(top + 1, Math.round(f.scale.y(Math.min(bar.o, bar.c))));
|
|
30
|
+
ctx.moveTo(x, yh);
|
|
31
|
+
ctx.lineTo(x, top);
|
|
32
|
+
ctx.moveTo(x, bottom);
|
|
33
|
+
ctx.lineTo(x, yl);
|
|
34
|
+
}
|
|
35
|
+
ctx.strokeStyle = up ? f.theme.up : f.theme.down;
|
|
36
|
+
ctx.stroke();
|
|
37
|
+
};
|
|
38
|
+
const bodies = (up) => {
|
|
39
|
+
const hollow = up && f.candleStyle === 'hollowUp';
|
|
40
|
+
ctx.beginPath();
|
|
41
|
+
for (let i = r.from; i <= r.to; i++) {
|
|
42
|
+
f.source.at(i, bar);
|
|
43
|
+
if (bar.c >= bar.o !== up)
|
|
44
|
+
continue;
|
|
45
|
+
const x0 = Math.round(f.vp.x(i)) - half;
|
|
46
|
+
const top = Math.round(f.scale.y(Math.max(bar.o, bar.c)));
|
|
47
|
+
const h = Math.max(1, Math.round(f.scale.y(Math.min(bar.o, bar.c))) - top);
|
|
48
|
+
if (hollow)
|
|
49
|
+
ctx.rect(x0 + 0.5, top + 0.5, bw - 1, h - 1);
|
|
50
|
+
else
|
|
51
|
+
ctx.rect(x0, top, bw, h);
|
|
52
|
+
}
|
|
53
|
+
if (hollow) {
|
|
54
|
+
ctx.strokeStyle = f.theme.up;
|
|
55
|
+
ctx.stroke();
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
ctx.fillStyle = up ? f.theme.up : f.theme.down;
|
|
59
|
+
ctx.fill();
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
wicks(true);
|
|
63
|
+
wicks(false);
|
|
64
|
+
if (!wickOnly) {
|
|
65
|
+
bodies(true);
|
|
66
|
+
bodies(false);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { formatSlotTime } from "../core/time.js";
|
|
2
|
+
import { TAG_HEIGHT, TAG_PAD_X } from "./frame.js";
|
|
3
|
+
export function drawCrosshair(ctx, f, c) {
|
|
4
|
+
if (c.index < 0 || c.index > f.series.lastIndex())
|
|
5
|
+
return;
|
|
6
|
+
const { layout, theme } = f;
|
|
7
|
+
const x = Math.round(f.vp.x(c.index)) + 0.5;
|
|
8
|
+
const inMain = c.y >= layout.mainTop && c.y <= layout.mainTop + layout.mainHeight;
|
|
9
|
+
const y = Math.round(c.y) + 0.5;
|
|
10
|
+
ctx.beginPath();
|
|
11
|
+
ctx.lineWidth = 1;
|
|
12
|
+
ctx.setLineDash([4, 4]);
|
|
13
|
+
ctx.strokeStyle = theme.crosshair;
|
|
14
|
+
ctx.moveTo(x, layout.mainTop);
|
|
15
|
+
ctx.lineTo(x, layout.timeTop);
|
|
16
|
+
if (inMain) {
|
|
17
|
+
ctx.moveTo(layout.plotLeft, y);
|
|
18
|
+
ctx.lineTo(layout.plotRight, y);
|
|
19
|
+
}
|
|
20
|
+
ctx.stroke();
|
|
21
|
+
ctx.setLineDash([]);
|
|
22
|
+
ctx.font = f.font;
|
|
23
|
+
ctx.textBaseline = 'middle';
|
|
24
|
+
ctx.fillStyle = theme.accent;
|
|
25
|
+
if (inMain) {
|
|
26
|
+
ctx.fillRect(layout.plotRight + 1, Math.round(c.y - TAG_HEIGHT / 2), layout.axisWidth - 1, TAG_HEIGHT);
|
|
27
|
+
ctx.fillStyle = theme.accentText;
|
|
28
|
+
ctx.textAlign = 'left';
|
|
29
|
+
ctx.fillText(f.fmt(f.scale.priceAt(c.y)), layout.plotRight + TAG_PAD_X, Math.round(c.y));
|
|
30
|
+
}
|
|
31
|
+
const text = formatSlotTime(f.series.timeAt(c.index), f.intervalMs, f.tzOffsetMs);
|
|
32
|
+
const w = Math.ceil(ctx.measureText(text).width) + TAG_PAD_X * 2;
|
|
33
|
+
const left = Math.min(layout.width - w, Math.max(0, Math.round(x - w / 2)));
|
|
34
|
+
ctx.fillStyle = theme.accent;
|
|
35
|
+
ctx.fillRect(left, layout.timeTop + 1, w, layout.timeHeight - 1);
|
|
36
|
+
ctx.fillStyle = theme.accentText;
|
|
37
|
+
ctx.textAlign = 'left';
|
|
38
|
+
ctx.fillText(text, left + TAG_PAD_X, layout.timeTop + layout.timeHeight / 2 + 1);
|
|
39
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function drawGrid(ctx, f) {
|
|
2
|
+
const { layout } = f;
|
|
3
|
+
const bottom = layout.mainTop + layout.mainHeight;
|
|
4
|
+
ctx.beginPath();
|
|
5
|
+
ctx.lineWidth = 1;
|
|
6
|
+
for (const p of f.ticks) {
|
|
7
|
+
const y = Math.round(f.scale.y(p)) + 0.5;
|
|
8
|
+
if (y < layout.mainTop || y > bottom)
|
|
9
|
+
continue;
|
|
10
|
+
ctx.moveTo(layout.plotLeft, y);
|
|
11
|
+
ctx.lineTo(layout.plotRight, y);
|
|
12
|
+
}
|
|
13
|
+
for (const label of f.timeLabels) {
|
|
14
|
+
const x = Math.round(label.x) + 0.5;
|
|
15
|
+
if (x < layout.plotLeft || x > layout.plotRight)
|
|
16
|
+
continue;
|
|
17
|
+
ctx.moveTo(x, layout.mainTop);
|
|
18
|
+
ctx.lineTo(x, layout.timeTop);
|
|
19
|
+
}
|
|
20
|
+
ctx.strokeStyle = f.theme.grid;
|
|
21
|
+
ctx.stroke();
|
|
22
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Ctx, Frame } from './frame.ts';
|
|
2
|
+
export declare const PULSE_MS = 600;
|
|
3
|
+
export interface LastPriceInput {
|
|
4
|
+
close: number;
|
|
5
|
+
wallNow: number;
|
|
6
|
+
animNow: number;
|
|
7
|
+
pulses: readonly number[];
|
|
8
|
+
reducedMotion: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function drawLastPrice(ctx: Ctx, f: Frame, input: LastPriceInput): void;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { slotEnd } from "../core/time.js";
|
|
2
|
+
import { formatCountdown } from "../core/format.js";
|
|
3
|
+
import { TAG_HEIGHT, TAG_PAD_X } from "./frame.js";
|
|
4
|
+
import { LIVE_POINT } from "./drawLine.js";
|
|
5
|
+
export const PULSE_MS = 600;
|
|
6
|
+
const PULSE_GROW = 16;
|
|
7
|
+
export function drawLastPrice(ctx, f, input) {
|
|
8
|
+
const forming = f.series.forming;
|
|
9
|
+
if (!forming)
|
|
10
|
+
return;
|
|
11
|
+
const { layout, theme } = f;
|
|
12
|
+
const half = TAG_HEIGHT;
|
|
13
|
+
const top = layout.mainTop + half;
|
|
14
|
+
const bottom = layout.mainTop + layout.mainHeight - half;
|
|
15
|
+
const rawY = f.scale.y(input.close);
|
|
16
|
+
const y = Math.round(Math.min(bottom, Math.max(top, rawY)));
|
|
17
|
+
const colour = forming.c >= forming.o ? theme.up : theme.down;
|
|
18
|
+
if (rawY >= layout.mainTop && rawY <= layout.mainTop + layout.mainHeight) {
|
|
19
|
+
ctx.beginPath();
|
|
20
|
+
ctx.lineWidth = 1;
|
|
21
|
+
ctx.setLineDash([4, 4]);
|
|
22
|
+
ctx.moveTo(layout.plotLeft, y + 0.5);
|
|
23
|
+
ctx.lineTo(layout.plotRight, y + 0.5);
|
|
24
|
+
ctx.strokeStyle = colour;
|
|
25
|
+
ctx.stroke();
|
|
26
|
+
ctx.setLineDash([]);
|
|
27
|
+
}
|
|
28
|
+
ctx.font = f.font;
|
|
29
|
+
ctx.textBaseline = 'middle';
|
|
30
|
+
ctx.textAlign = 'left';
|
|
31
|
+
ctx.fillStyle = colour;
|
|
32
|
+
ctx.fillRect(layout.plotRight + 1, y - TAG_HEIGHT, layout.axisWidth - 1, TAG_HEIGHT * 2);
|
|
33
|
+
ctx.fillStyle = theme.background;
|
|
34
|
+
ctx.fillText(f.fmt(input.close), layout.plotRight + TAG_PAD_X, y - TAG_HEIGHT / 2);
|
|
35
|
+
const left = formatCountdown(slotEnd(forming.t, f.intervalMs) - input.wallNow);
|
|
36
|
+
ctx.fillText(left, layout.plotRight + TAG_PAD_X, y + TAG_HEIGHT / 2);
|
|
37
|
+
if ((f.type === 'line' || f.type === 'area') && !input.reducedMotion) {
|
|
38
|
+
const x = f.vp.x(f.series.lastIndex());
|
|
39
|
+
if (x < layout.plotLeft || x > layout.plotRight)
|
|
40
|
+
return;
|
|
41
|
+
ctx.lineWidth = 1;
|
|
42
|
+
for (const start of input.pulses) {
|
|
43
|
+
const p = (input.animNow - start) / PULSE_MS;
|
|
44
|
+
if (p < 0 || p >= 1)
|
|
45
|
+
continue;
|
|
46
|
+
const s = LIVE_POINT + p * PULSE_GROW;
|
|
47
|
+
ctx.globalAlpha = 1 - p;
|
|
48
|
+
ctx.strokeStyle = theme.line;
|
|
49
|
+
ctx.strokeRect(Math.round(x - s / 2) + 0.5, Math.round(rawY - s / 2) + 0.5, Math.round(s), Math.round(s));
|
|
50
|
+
}
|
|
51
|
+
ctx.globalAlpha = 1;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const bar = { o: 0, h: 0, l: 0, c: 0 };
|
|
2
|
+
export const LIVE_POINT = 5;
|
|
3
|
+
export function drawLine(ctx, f) {
|
|
4
|
+
const r = f.vp.visibleRange();
|
|
5
|
+
if (!r)
|
|
6
|
+
return;
|
|
7
|
+
const bottom = f.layout.mainTop + f.layout.mainHeight;
|
|
8
|
+
const trace = () => {
|
|
9
|
+
for (let i = r.from; i <= r.to; i++) {
|
|
10
|
+
f.source.at(i, bar);
|
|
11
|
+
const x = f.vp.x(i);
|
|
12
|
+
const y = f.scale.y(bar.c);
|
|
13
|
+
if (i === r.from)
|
|
14
|
+
ctx.moveTo(x, y);
|
|
15
|
+
else
|
|
16
|
+
ctx.lineTo(x, y);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
if (f.type === 'area') {
|
|
20
|
+
ctx.beginPath();
|
|
21
|
+
trace();
|
|
22
|
+
ctx.lineTo(f.vp.x(r.to), bottom);
|
|
23
|
+
ctx.lineTo(f.vp.x(r.from), bottom);
|
|
24
|
+
ctx.closePath();
|
|
25
|
+
const g = ctx.createLinearGradient(0, f.layout.mainTop, 0, bottom);
|
|
26
|
+
g.addColorStop(0, f.theme.areaTop);
|
|
27
|
+
g.addColorStop(1, f.theme.areaBottom);
|
|
28
|
+
ctx.fillStyle = g;
|
|
29
|
+
ctx.fill();
|
|
30
|
+
}
|
|
31
|
+
ctx.beginPath();
|
|
32
|
+
trace();
|
|
33
|
+
ctx.lineWidth = 1.5;
|
|
34
|
+
ctx.lineJoin = 'miter';
|
|
35
|
+
ctx.strokeStyle = f.theme.line;
|
|
36
|
+
ctx.stroke();
|
|
37
|
+
if (f.series.forming && r.to === f.series.lastIndex()) {
|
|
38
|
+
f.source.at(r.to, bar);
|
|
39
|
+
const s = LIVE_POINT;
|
|
40
|
+
ctx.fillStyle = f.theme.line;
|
|
41
|
+
ctx.fillRect(Math.round(f.vp.x(r.to) - s / 2), Math.round(f.scale.y(bar.c) - s / 2), s, s);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { drawAxes } from "./drawAxes.js";
|
|
2
|
+
import { drawCandles } from "./drawCandles.js";
|
|
3
|
+
import { drawGrid } from "./drawGrid.js";
|
|
4
|
+
import { drawLine } from "./drawLine.js";
|
|
5
|
+
import { drawMarkers } from "./drawMarkers.js";
|
|
6
|
+
import { drawPriceLines, drawPriceLineTags } from "./drawPriceLines.js";
|
|
7
|
+
export function drawMain(ctx, f) {
|
|
8
|
+
const { layout, theme } = f;
|
|
9
|
+
ctx.fillStyle = theme.background;
|
|
10
|
+
ctx.fillRect(0, 0, layout.width, layout.height);
|
|
11
|
+
drawGrid(ctx, f);
|
|
12
|
+
if (f.series.count > 0) {
|
|
13
|
+
if (f.type === 'line' || f.type === 'area')
|
|
14
|
+
drawLine(ctx, f);
|
|
15
|
+
else
|
|
16
|
+
drawCandles(ctx, f);
|
|
17
|
+
drawPriceLines(ctx, f);
|
|
18
|
+
drawMarkers(ctx, f);
|
|
19
|
+
}
|
|
20
|
+
drawAxes(ctx, f);
|
|
21
|
+
drawPriceLineTags(ctx, f);
|
|
22
|
+
if (f.series.count === 0) {
|
|
23
|
+
ctx.font = f.font;
|
|
24
|
+
ctx.fillStyle = theme.textMuted;
|
|
25
|
+
ctx.textAlign = 'center';
|
|
26
|
+
ctx.textBaseline = 'middle';
|
|
27
|
+
ctx.fillText(f.noDataText, layout.plotRight / 2, layout.mainHeight / 2);
|
|
28
|
+
ctx.textAlign = 'left';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const bar = { o: 0, h: 0, l: 0, c: 0 };
|
|
2
|
+
export function drawMarkers(ctx, f) {
|
|
3
|
+
if (f.markers.length === 0)
|
|
4
|
+
return;
|
|
5
|
+
const r = f.vp.visibleRange();
|
|
6
|
+
if (!r)
|
|
7
|
+
return;
|
|
8
|
+
const size = Math.min(14, Math.max(8, Math.round(f.vp.barSpacing * 1.1)));
|
|
9
|
+
const stacks = new Map();
|
|
10
|
+
ctx.textAlign = 'center';
|
|
11
|
+
ctx.textBaseline = 'middle';
|
|
12
|
+
for (const m of f.markers) {
|
|
13
|
+
const idx = f.series.indexAtTime(m.t);
|
|
14
|
+
if (idx < r.from || idx > r.to)
|
|
15
|
+
continue;
|
|
16
|
+
f.source.at(idx, bar);
|
|
17
|
+
const key = `${idx}${m.kind}`;
|
|
18
|
+
const n = stacks.get(key) ?? 0;
|
|
19
|
+
stacks.set(key, n + 1);
|
|
20
|
+
const x = f.vp.x(idx);
|
|
21
|
+
const up = m.kind === 'buy';
|
|
22
|
+
const gap = 4 + n * (size + 2);
|
|
23
|
+
const apex = up ? f.scale.y(bar.l) + gap : f.scale.y(bar.h) - gap;
|
|
24
|
+
const base = up ? apex + size : apex - size;
|
|
25
|
+
ctx.beginPath();
|
|
26
|
+
ctx.moveTo(x, apex);
|
|
27
|
+
ctx.lineTo(x - size / 2, base);
|
|
28
|
+
ctx.lineTo(x + size / 2, base);
|
|
29
|
+
ctx.closePath();
|
|
30
|
+
ctx.fillStyle = up ? f.theme.up : f.theme.down;
|
|
31
|
+
ctx.fill();
|
|
32
|
+
if (m.label && size >= 11) {
|
|
33
|
+
ctx.font = `700 ${size - 4}px ${f.theme.fontFamily}`;
|
|
34
|
+
ctx.fillStyle = f.theme.background;
|
|
35
|
+
ctx.fillText(m.label.slice(0, 2), x, up ? base - size * 0.3 : base + size * 0.3);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
ctx.textAlign = 'left';
|
|
39
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Ctx, Frame } from './frame.ts';
|
|
2
|
+
export declare function spreadTags(ys: readonly number[], gap: number, top: number, bottom: number): number[];
|
|
3
|
+
export declare function drawPriceLines(ctx: Ctx, f: Frame): void;
|
|
4
|
+
export declare function drawPriceLineTags(ctx: Ctx, f: Frame): void;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { TAG_HEIGHT, TAG_PAD_X } from "./frame.js";
|
|
2
|
+
import { pinSide } from "../core/priceScale.js";
|
|
3
|
+
const DASH = { solid: [], dashed: [6, 4], dotted: [2, 3] };
|
|
4
|
+
export function spreadTags(ys, gap, top, bottom) {
|
|
5
|
+
const order = ys.map((_, i) => i).sort((a, b) => ys[a] - ys[b]);
|
|
6
|
+
const out = order.map((i) => Math.min(bottom, Math.max(top, ys[i])));
|
|
7
|
+
for (let k = 1; k < out.length; k++)
|
|
8
|
+
out[k] = Math.max(out[k], out[k - 1] + gap);
|
|
9
|
+
if (out.length > 0 && out[out.length - 1] > bottom) {
|
|
10
|
+
out[out.length - 1] = bottom;
|
|
11
|
+
for (let k = out.length - 2; k >= 0; k--)
|
|
12
|
+
out[k] = Math.min(out[k], out[k + 1] - gap);
|
|
13
|
+
}
|
|
14
|
+
const result = new Array(ys.length);
|
|
15
|
+
order.forEach((orig, k) => {
|
|
16
|
+
result[orig] = out[k];
|
|
17
|
+
});
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
export function drawPriceLines(ctx, f) {
|
|
21
|
+
ctx.lineWidth = 1;
|
|
22
|
+
for (const line of f.lines) {
|
|
23
|
+
if (pinSide(line.price, f.range))
|
|
24
|
+
continue;
|
|
25
|
+
const y = Math.round(f.scale.y(line.price)) + 0.5;
|
|
26
|
+
ctx.beginPath();
|
|
27
|
+
ctx.setLineDash(DASH[line.style ?? 'dashed']);
|
|
28
|
+
ctx.moveTo(f.layout.plotLeft, y);
|
|
29
|
+
ctx.lineTo(f.layout.plotRight, y);
|
|
30
|
+
ctx.strokeStyle = line.color;
|
|
31
|
+
ctx.stroke();
|
|
32
|
+
}
|
|
33
|
+
ctx.setLineDash([]);
|
|
34
|
+
}
|
|
35
|
+
export function drawPriceLineTags(ctx, f) {
|
|
36
|
+
if (f.lines.length === 0)
|
|
37
|
+
return;
|
|
38
|
+
const { layout } = f;
|
|
39
|
+
const half = TAG_HEIGHT / 2;
|
|
40
|
+
const top = layout.mainTop + half;
|
|
41
|
+
const bottom = layout.mainTop + layout.mainHeight - half;
|
|
42
|
+
const ys = f.lines.map((line) => {
|
|
43
|
+
const side = pinSide(line.price, f.range);
|
|
44
|
+
return side === 'above' ? top : side === 'below' ? bottom : f.scale.y(line.price);
|
|
45
|
+
});
|
|
46
|
+
const placed = spreadTags(ys, TAG_HEIGHT + 2, top, bottom);
|
|
47
|
+
ctx.font = f.font;
|
|
48
|
+
ctx.textBaseline = 'middle';
|
|
49
|
+
ctx.textAlign = 'left';
|
|
50
|
+
f.lines.forEach((line, i) => {
|
|
51
|
+
const side = pinSide(line.price, f.range);
|
|
52
|
+
const text = `${side === 'above' ? '↑ ' : side === 'below' ? '↓ ' : ''}${line.label}`;
|
|
53
|
+
const w = Math.max(layout.axisWidth, Math.ceil(ctx.measureText(text).width) + TAG_PAD_X * 2);
|
|
54
|
+
const y = Math.round(placed[i]);
|
|
55
|
+
ctx.fillStyle = line.color;
|
|
56
|
+
ctx.fillRect(layout.width - w, y - half, w, TAG_HEIGHT);
|
|
57
|
+
ctx.fillStyle = f.theme.background;
|
|
58
|
+
ctx.fillText(text, layout.width - w + TAG_PAD_X, y);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { CandleSeries } from '../core/series.ts';
|
|
2
|
+
import type { PriceRange, PriceScale } from '../core/priceScale.ts';
|
|
3
|
+
import type { TimeLabel } from '../core/timeLabels.ts';
|
|
4
|
+
import type { CandleStyle, ChartTheme, ChartType, Marker, PriceLine } from '../core/types.ts';
|
|
5
|
+
import type { Viewport } from '../core/viewport.ts';
|
|
6
|
+
export type Ctx = CanvasRenderingContext2D;
|
|
7
|
+
export interface Ohlc {
|
|
8
|
+
o: number;
|
|
9
|
+
h: number;
|
|
10
|
+
l: number;
|
|
11
|
+
c: number;
|
|
12
|
+
}
|
|
13
|
+
export interface BarSource {
|
|
14
|
+
at(i: number, out: Ohlc): void;
|
|
15
|
+
}
|
|
16
|
+
export interface Layout {
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
plotLeft: number;
|
|
20
|
+
plotRight: number;
|
|
21
|
+
mainTop: number;
|
|
22
|
+
mainHeight: number;
|
|
23
|
+
axisWidth: number;
|
|
24
|
+
timeTop: number;
|
|
25
|
+
timeHeight: number;
|
|
26
|
+
subs: {
|
|
27
|
+
top: number;
|
|
28
|
+
height: number;
|
|
29
|
+
}[];
|
|
30
|
+
}
|
|
31
|
+
export interface Frame {
|
|
32
|
+
theme: ChartTheme;
|
|
33
|
+
layout: Layout;
|
|
34
|
+
vp: Viewport;
|
|
35
|
+
scale: PriceScale;
|
|
36
|
+
series: CandleSeries;
|
|
37
|
+
source: BarSource;
|
|
38
|
+
type: ChartType;
|
|
39
|
+
candleStyle: CandleStyle;
|
|
40
|
+
range: PriceRange;
|
|
41
|
+
ticks: number[];
|
|
42
|
+
timeLabels: TimeLabel[];
|
|
43
|
+
lines: readonly PriceLine[];
|
|
44
|
+
markers: readonly Marker[];
|
|
45
|
+
fmt: (v: number) => string;
|
|
46
|
+
font: string;
|
|
47
|
+
noDataText: string;
|
|
48
|
+
intervalMs: number;
|
|
49
|
+
tzOffsetMs: number;
|
|
50
|
+
}
|
|
51
|
+
export declare const TAG_PAD_X = 6;
|
|
52
|
+
export declare const TAG_HEIGHT = 18;
|
|
53
|
+
export declare function fontOf(theme: ChartTheme): string;
|