@livo-build/charts 0.2.1 → 0.2.2
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/README.md +107 -15
- package/dist/core/chart.d.ts +61 -2
- package/dist/core/chart.js +363 -32
- package/dist/core/format.d.ts +13 -1
- package/dist/core/format.js +38 -3
- package/dist/core/indicators.d.ts +14 -0
- package/dist/core/indicators.js +62 -0
- package/dist/core/renderer.d.ts +81 -1
- package/dist/core/renderer.js +344 -57
- package/dist/core/theme.d.ts +5 -1
- package/dist/core/theme.js +33 -12
- package/dist/core/types.d.ts +78 -2
- package/dist/index.d.ts +6 -6
- package/dist/index.js +4 -4
- package/dist/react/HyperliquidChart.d.ts +28 -2
- package/dist/react/HyperliquidChart.js +39 -17
- package/dist/react/PriceChart.d.ts +39 -4
- package/dist/react/PriceChart.js +52 -25
- package/dist/react/ui.d.ts +13 -0
- package/dist/react/ui.js +29 -0
- package/package.json +1 -1
package/dist/core/renderer.d.ts
CHANGED
|
@@ -1,4 +1,54 @@
|
|
|
1
|
-
import type { Candle, ChartTheme, ChartType, Indicator } from "./types";
|
|
1
|
+
import type { Candle, ChartTheme, ChartType, Drawing, Indicator, Oscillator } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Width of one candle slot (px). By default the slot is capped at `maxBarWidth` and the
|
|
4
|
+
* series is right-anchored — sparse data stays tight in the corner. With `fitContent`,
|
|
5
|
+
* the cap is dropped so `count` candles spread across the whole plot (fills the
|
|
6
|
+
* container). Dense series are identical either way (plotW/count < maxBarWidth).
|
|
7
|
+
*/
|
|
8
|
+
export declare function slotWidth(plotW: number, count: number, maxBarWidth: number, fitContent?: boolean): number;
|
|
9
|
+
/** Convert a `#rrggbb` color to `rgba(...)` with the given alpha (passes other formats through). */
|
|
10
|
+
export declare function withAlpha(color: string, alpha: number): string;
|
|
11
|
+
/** The visible candle window (which slice is on screen and the per-candle slot width). */
|
|
12
|
+
export declare function windowOf(candles: Candle[], view: Viewport, plotW: number, maxBarWidth: number, fitContent?: boolean): {
|
|
13
|
+
start: number;
|
|
14
|
+
end: number;
|
|
15
|
+
vis: Candle[];
|
|
16
|
+
n: number;
|
|
17
|
+
count: number;
|
|
18
|
+
cw: number;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Price ↔ pixel mapping for the price pane, shared by the renderer and the drawing-tool
|
|
22
|
+
* hit-testing so they never drift. Handles the log transform and the padded/zoomed range.
|
|
23
|
+
*/
|
|
24
|
+
export declare function priceScale(vis: Candle[], yZoom: number, logScale: boolean, top: number, priceH: number): {
|
|
25
|
+
yOfPrice: (v: number) => number;
|
|
26
|
+
priceOfY: (y: number) => number;
|
|
27
|
+
fwd: (v: number) => number;
|
|
28
|
+
inv: (s: number) => number;
|
|
29
|
+
sLo: number;
|
|
30
|
+
sRng: number;
|
|
31
|
+
lo: number;
|
|
32
|
+
hi: number;
|
|
33
|
+
};
|
|
34
|
+
/** Time ↔ pixel mapping for the visible window (candle index and arbitrary unix-second time). */
|
|
35
|
+
export declare function timeScale(vis: Candle[], n: number, cw: number, plotW: number, interval: number): {
|
|
36
|
+
xOf: (j: number) => number;
|
|
37
|
+
xOfTime: (t: number) => number;
|
|
38
|
+
timeOfX: (x: number) => number;
|
|
39
|
+
};
|
|
40
|
+
/** Full pixel↔data projection for the current frame — built from the same input `draw` uses. */
|
|
41
|
+
export interface Projection {
|
|
42
|
+
xOfTime: (t: number) => number;
|
|
43
|
+
timeOfX: (x: number) => number;
|
|
44
|
+
yOfPrice: (p: number) => number;
|
|
45
|
+
priceOfY: (y: number) => number;
|
|
46
|
+
plotW: number;
|
|
47
|
+
priceTop: number;
|
|
48
|
+
priceBottom: number;
|
|
49
|
+
}
|
|
50
|
+
/** Compute the {@link Projection} for interaction/hit-testing without rendering. */
|
|
51
|
+
export declare function computeProjection(input: RenderInput): Projection | null;
|
|
2
52
|
export interface Viewport {
|
|
3
53
|
/** candles visible. */
|
|
4
54
|
count: number;
|
|
@@ -26,6 +76,8 @@ export interface RenderInput {
|
|
|
26
76
|
yZoom: number;
|
|
27
77
|
/** max candle slot width in px. */
|
|
28
78
|
maxBarWidth: number;
|
|
79
|
+
/** max candle body / volume-bar width in px (default 40). */
|
|
80
|
+
maxBodyWidth?: number;
|
|
29
81
|
/** volume-panel height in px. */
|
|
30
82
|
volH: number;
|
|
31
83
|
theme: ChartTheme;
|
|
@@ -34,12 +86,40 @@ export interface RenderInput {
|
|
|
34
86
|
indicators?: Indicator[];
|
|
35
87
|
/** precomputed overlay value-series (aligned to `candles`); preferred over `indicators`. */
|
|
36
88
|
overlays?: ResolvedOverlay[];
|
|
89
|
+
/** logarithmic price axis — equal vertical distance = equal % move. */
|
|
90
|
+
logScale?: boolean;
|
|
91
|
+
/** spread candles across the full plot width instead of right-anchoring at a capped slot. */
|
|
92
|
+
fitContent?: boolean;
|
|
93
|
+
/** y-axis price label formatter (default: range-aware {@link formatAxisValue}). */
|
|
94
|
+
priceFormat?: (value: number) => string;
|
|
95
|
+
/** x-axis time label formatter (default {@link formatTime}). */
|
|
96
|
+
timeFormat?: (time: number, interval: number) => string;
|
|
97
|
+
/** number of horizontal price gridlines / labels (default 5). */
|
|
98
|
+
priceTicks?: number;
|
|
99
|
+
/** approximate number of time-axis labels (default 7). */
|
|
100
|
+
timeTicks?: number;
|
|
101
|
+
/** canvas font for axis labels (default "10px ui-monospace, monospace"); color is `theme.axis`. */
|
|
102
|
+
axisFont?: string;
|
|
103
|
+
/** oscillators (RSI / MACD) drawn in stacked sub-panes below the volume panel. */
|
|
104
|
+
oscillators?: Oscillator[];
|
|
105
|
+
/** committed user drawings (trendlines / horizontal lines). */
|
|
106
|
+
drawings?: Drawing[];
|
|
107
|
+
/** in-progress drawing rendered as a live preview (not yet committed). */
|
|
108
|
+
drawPreview?: Drawing | null;
|
|
109
|
+
/** id of the selected drawing (rendered emphasized with anchor handles). */
|
|
110
|
+
selectedDrawing?: string | null;
|
|
37
111
|
}
|
|
38
112
|
/** A resolved indicator overlay: a color and a value-series aligned to the candles. */
|
|
39
113
|
export interface ResolvedOverlay {
|
|
40
114
|
color: string;
|
|
41
115
|
values: (number | null)[];
|
|
42
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Resolve indicator configs into drawable overlay lines. Moving averages / VWAP map 1:1;
|
|
119
|
+
* a `bollinger` indicator expands into three lines (upper / mid / lower), the bands faint.
|
|
120
|
+
* Shared by the Chart controller (precompute) and `draw` (standalone callers).
|
|
121
|
+
*/
|
|
122
|
+
export declare function resolveOverlays(candles: Candle[], indicators: Indicator[]): ResolvedOverlay[];
|
|
43
123
|
/**
|
|
44
124
|
* Pure draw pass: renders grid, axes, the price series (candles or line), a volume
|
|
45
125
|
* panel, the last-price tag and the crosshair + axis labels. Returns the candle under
|
package/dist/core/renderer.js
CHANGED
|
@@ -1,5 +1,107 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { computeIndicator, INDICATOR_PALETTE } from "./indicators";
|
|
1
|
+
import { formatAxisValue, formatTime } from "./format";
|
|
2
|
+
import { computeIndicator, sourceValues, bollingerBands, INDICATOR_PALETTE, rsi, macd } from "./indicators";
|
|
3
|
+
/**
|
|
4
|
+
* Width of one candle slot (px). By default the slot is capped at `maxBarWidth` and the
|
|
5
|
+
* series is right-anchored — sparse data stays tight in the corner. With `fitContent`,
|
|
6
|
+
* the cap is dropped so `count` candles spread across the whole plot (fills the
|
|
7
|
+
* container). Dense series are identical either way (plotW/count < maxBarWidth).
|
|
8
|
+
*/
|
|
9
|
+
export function slotWidth(plotW, count, maxBarWidth, fitContent = false) {
|
|
10
|
+
const raw = plotW / Math.max(count, 1);
|
|
11
|
+
return fitContent ? raw : Math.min(raw, maxBarWidth);
|
|
12
|
+
}
|
|
13
|
+
/** Convert a `#rrggbb` color to `rgba(...)` with the given alpha (passes other formats through). */
|
|
14
|
+
export function withAlpha(color, alpha) {
|
|
15
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(color);
|
|
16
|
+
if (!m)
|
|
17
|
+
return color;
|
|
18
|
+
const n = parseInt(m[1], 16);
|
|
19
|
+
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
|
|
20
|
+
}
|
|
21
|
+
/** The visible candle window (which slice is on screen and the per-candle slot width). */
|
|
22
|
+
export function windowOf(candles, view, plotW, maxBarWidth, fitContent = false) {
|
|
23
|
+
const count = Math.min(view.count, candles.length);
|
|
24
|
+
const end = candles.length - view.offset;
|
|
25
|
+
const start = Math.max(0, end - count);
|
|
26
|
+
const vis = candles.slice(start, end);
|
|
27
|
+
return { start, end, vis, n: vis.length, count, cw: slotWidth(plotW, count, maxBarWidth, fitContent) };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Price ↔ pixel mapping for the price pane, shared by the renderer and the drawing-tool
|
|
31
|
+
* hit-testing so they never drift. Handles the log transform and the padded/zoomed range.
|
|
32
|
+
*/
|
|
33
|
+
export function priceScale(vis, yZoom, logScale, top, priceH) {
|
|
34
|
+
let lo = Infinity;
|
|
35
|
+
let hi = -Infinity;
|
|
36
|
+
for (const c of vis) {
|
|
37
|
+
lo = Math.min(lo, c.l);
|
|
38
|
+
hi = Math.max(hi, c.h);
|
|
39
|
+
}
|
|
40
|
+
const log = !!logScale && lo > 0;
|
|
41
|
+
const fwd = (v) => (log ? Math.log10(v) : v);
|
|
42
|
+
const inv = (s) => (log ? 10 ** s : s);
|
|
43
|
+
let sLo = fwd(lo);
|
|
44
|
+
let sHi = fwd(hi);
|
|
45
|
+
const sMid = (sLo + sHi) / 2;
|
|
46
|
+
const sHalf = (((sHi - sLo) / 2 || Math.abs(sHi) * 0.1 || 1) * 1.08) / yZoom;
|
|
47
|
+
sLo = sMid - sHalf;
|
|
48
|
+
sHi = sMid + sHalf;
|
|
49
|
+
const sRng = sHi - sLo || 1;
|
|
50
|
+
const yOfPrice = (v) => top + priceH * (1 - (fwd(v) - sLo) / sRng);
|
|
51
|
+
const priceOfY = (y) => inv(sLo + sRng * (1 - (y - top) / priceH));
|
|
52
|
+
return { yOfPrice, priceOfY, fwd, inv, sLo, sRng, lo, hi };
|
|
53
|
+
}
|
|
54
|
+
/** Time ↔ pixel mapping for the visible window (candle index and arbitrary unix-second time). */
|
|
55
|
+
export function timeScale(vis, n, cw, plotW, interval) {
|
|
56
|
+
const xOf = (j) => plotW - (n - 1 - j) * cw - cw / 2;
|
|
57
|
+
const t0 = n ? vis[0].time : 0;
|
|
58
|
+
const iv = interval || 1;
|
|
59
|
+
const xOfTime = (t) => xOf((t - t0) / iv);
|
|
60
|
+
const timeOfX = (x) => t0 + ((x - xOf(0)) / cw) * iv;
|
|
61
|
+
return { xOf, xOfTime, timeOfX };
|
|
62
|
+
}
|
|
63
|
+
/** Compute the {@link Projection} for interaction/hit-testing without rendering. */
|
|
64
|
+
export function computeProjection(input) {
|
|
65
|
+
const { candles, view, pads, width, maxBarWidth, volH, height, yZoom, interval } = input;
|
|
66
|
+
if (!candles.length)
|
|
67
|
+
return null;
|
|
68
|
+
const plotW = width - pads.right;
|
|
69
|
+
const panesH = (input.oscillators ?? []).map((o) => Math.max(24, o.height ?? 84)).reduce((a, b) => a + b, 0);
|
|
70
|
+
const priceH = height - pads.bottom - pads.top - volH - panesH;
|
|
71
|
+
const { vis, n, cw } = windowOf(candles, view, plotW, maxBarWidth, input.fitContent);
|
|
72
|
+
const ts = timeScale(vis, n, cw, plotW, interval);
|
|
73
|
+
const ps = priceScale(vis, yZoom, !!input.logScale, pads.top, priceH);
|
|
74
|
+
return {
|
|
75
|
+
xOfTime: ts.xOfTime,
|
|
76
|
+
timeOfX: ts.timeOfX,
|
|
77
|
+
yOfPrice: ps.yOfPrice,
|
|
78
|
+
priceOfY: ps.priceOfY,
|
|
79
|
+
plotW,
|
|
80
|
+
priceTop: pads.top,
|
|
81
|
+
priceBottom: pads.top + priceH,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Resolve indicator configs into drawable overlay lines. Moving averages / VWAP map 1:1;
|
|
86
|
+
* a `bollinger` indicator expands into three lines (upper / mid / lower), the bands faint.
|
|
87
|
+
* Shared by the Chart controller (precompute) and `draw` (standalone callers).
|
|
88
|
+
*/
|
|
89
|
+
export function resolveOverlays(candles, indicators) {
|
|
90
|
+
const out = [];
|
|
91
|
+
indicators.forEach((ind, i) => {
|
|
92
|
+
const color = ind.color || INDICATOR_PALETTE[i % INDICATOR_PALETTE.length];
|
|
93
|
+
if (ind.type === "bollinger") {
|
|
94
|
+
const { upper, mid, lower } = bollingerBands(sourceValues(candles, ind.source), ind.period, ind.mult ?? 2);
|
|
95
|
+
out.push({ color: withAlpha(color, 0.45), values: upper });
|
|
96
|
+
out.push({ color, values: mid });
|
|
97
|
+
out.push({ color: withAlpha(color, 0.45), values: lower });
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
out.push({ color, values: computeIndicator(candles, ind) });
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
3
105
|
/**
|
|
4
106
|
* Pure draw pass: renders grid, axes, the price series (candles or line), a volume
|
|
5
107
|
* panel, the last-price tag and the crosshair + axis labels. Returns the candle under
|
|
@@ -9,7 +111,13 @@ import { computeIndicator, INDICATOR_PALETTE } from "./indicators";
|
|
|
9
111
|
export function draw(input) {
|
|
10
112
|
const { ctx, width: w, height: H, candles, view, hover, interval, type, yZoom, maxBarWidth, volH, theme, pads } = input;
|
|
11
113
|
const plotW = w - pads.right;
|
|
12
|
-
|
|
114
|
+
// Oscillator sub-panes stack below the volume panel; each eats into the price pane.
|
|
115
|
+
const oscs = input.oscillators ?? [];
|
|
116
|
+
const paneHs = oscs.map((o) => Math.max(24, o.height ?? 84));
|
|
117
|
+
const panesH = paneHs.reduce((a, b) => a + b, 0);
|
|
118
|
+
const priceH = H - pads.bottom - pads.top - volH - panesH;
|
|
119
|
+
// bottom of the plotting area (above the time-axis gutter) — vertical lines span to here.
|
|
120
|
+
const chartBot = pads.top + priceH + volH + panesH;
|
|
13
121
|
if (theme.background) {
|
|
14
122
|
ctx.fillStyle = theme.background;
|
|
15
123
|
ctx.fillRect(0, 0, w, H);
|
|
@@ -17,79 +125,110 @@ export function draw(input) {
|
|
|
17
125
|
else {
|
|
18
126
|
ctx.clearRect(0, 0, w, H);
|
|
19
127
|
}
|
|
20
|
-
ctx.font = "10px ui-monospace, monospace";
|
|
128
|
+
ctx.font = input.axisFont ?? "10px ui-monospace, monospace";
|
|
21
129
|
ctx.textBaseline = "middle";
|
|
130
|
+
const fmtTime = input.timeFormat ?? formatTime;
|
|
131
|
+
// Snap 1px strokes to the pixel grid so gridlines/axes render crisp, not blurred across 2px.
|
|
132
|
+
const crisp = (v) => Math.round(v) + 0.5;
|
|
22
133
|
if (!candles.length) {
|
|
23
134
|
ctx.fillStyle = theme.axis;
|
|
24
135
|
ctx.textAlign = "center";
|
|
25
136
|
ctx.fillText("no priced trades yet", plotW / 2, H / 2);
|
|
26
137
|
return null;
|
|
27
138
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const n =
|
|
33
|
-
|
|
34
|
-
// space on the left (TradingView-style) rather than stretching across the whole plot.
|
|
35
|
-
const cw = Math.min(plotW / Math.max(count, 1), maxBarWidth);
|
|
36
|
-
const xOf = (j) => plotW - (n - 1 - j) * cw - cw / 2;
|
|
37
|
-
let lo = Infinity;
|
|
38
|
-
let hi = -Infinity;
|
|
139
|
+
// Slot width fills the plot by default (`fitContent`, on by default in the wrappers and
|
|
140
|
+
// the core Chart) so sparse data spreads out instead of bunching on the right; the candle
|
|
141
|
+
// BODY is capped separately (`maxBodyWidth`) below. fitContent:false caps the slot at
|
|
142
|
+
// `maxBarWidth` and right-anchors (the tight, TradingView-style look).
|
|
143
|
+
const { start, vis, n, cw } = windowOf(candles, view, plotW, maxBarWidth, input.fitContent);
|
|
144
|
+
const { xOf, xOfTime } = timeScale(vis, n, cw, plotW, interval);
|
|
39
145
|
let vmax = 0;
|
|
40
|
-
for (const c of vis)
|
|
41
|
-
lo = Math.min(lo, c.l);
|
|
42
|
-
hi = Math.max(hi, c.h);
|
|
146
|
+
for (const c of vis)
|
|
43
147
|
vmax = Math.max(vmax, c.vol);
|
|
44
|
-
}
|
|
45
|
-
const mid = (lo + hi) / 2;
|
|
46
|
-
const half = ((((hi - lo) / 2) || Math.abs(hi) * 0.1 || 1) * 1.08) / yZoom;
|
|
47
|
-
lo = mid - half;
|
|
48
|
-
hi = mid + half;
|
|
49
|
-
const rng = hi - lo || 1;
|
|
50
148
|
vmax = vmax || 1;
|
|
51
|
-
|
|
149
|
+
// Price↔pixel mapping (log-aware, padded, zoomed). Shared with drawing-tool hit-testing.
|
|
150
|
+
const { yOfPrice: yOf, priceOfY, inv, sLo, sRng } = priceScale(vis, yZoom, !!input.logScale, pads.top, priceH);
|
|
52
151
|
const volBot = pads.top + priceH + volH;
|
|
53
152
|
const vy = (vol) => volBot - (vol / vmax) * volH * 0.92;
|
|
54
|
-
// grid + price axis (right gutter)
|
|
153
|
+
// grid + price axis (right gutter). Labels are formatted with enough precision to stay
|
|
154
|
+
// distinct (the compact "K" formatter alone collapses a narrow high-value axis into dupes).
|
|
155
|
+
const pTicks = Math.max(1, Math.round(input.priceTicks ?? 5));
|
|
156
|
+
const tickVals = [];
|
|
157
|
+
for (let i = 0; i <= pTicks; i++)
|
|
158
|
+
tickVals.push(inv(sLo + (sRng * i) / pTicks));
|
|
159
|
+
const priceStep = Math.abs(tickVals[pTicks] - tickVals[0]) / pTicks;
|
|
160
|
+
const fmtPrice = input.priceFormat ?? ((v) => formatAxisValue(v, priceStep));
|
|
55
161
|
ctx.strokeStyle = theme.grid;
|
|
56
162
|
ctx.fillStyle = theme.axis;
|
|
57
163
|
ctx.lineWidth = 1;
|
|
58
164
|
ctx.textAlign = "left";
|
|
59
|
-
for (let i = 0; i <=
|
|
60
|
-
const
|
|
61
|
-
const y = yOf(v);
|
|
165
|
+
for (let i = 0; i <= pTicks; i++) {
|
|
166
|
+
const y = pads.top + priceH * (1 - i / pTicks);
|
|
62
167
|
ctx.beginPath();
|
|
63
|
-
ctx.moveTo(0, y);
|
|
64
|
-
ctx.lineTo(plotW, y);
|
|
168
|
+
ctx.moveTo(0, crisp(y));
|
|
169
|
+
ctx.lineTo(plotW, crisp(y));
|
|
65
170
|
ctx.stroke();
|
|
66
|
-
|
|
171
|
+
// local gap (not the average) sets precision — log ticks bunch up at the low end,
|
|
172
|
+
// and the bottom labels need more decimals than the top ones to stay distinct.
|
|
173
|
+
const local = input.priceFormat ? 0 : Math.abs((tickVals[i + 1] ?? tickVals[i - 1]) - tickVals[i]) || priceStep;
|
|
174
|
+
ctx.fillText(input.priceFormat ? fmtPrice(tickVals[i]) : formatAxisValue(tickVals[i], local), plotW + 6, y);
|
|
67
175
|
}
|
|
68
176
|
// time axis (bottom gutter), anchored to the latest candle
|
|
69
177
|
ctx.textAlign = "center";
|
|
70
|
-
const step = Math.max(1, Math.floor(n / 7));
|
|
178
|
+
const step = Math.max(1, Math.floor(n / Math.max(1, Math.round(input.timeTicks ?? 7))));
|
|
71
179
|
for (let j = n - 1; j >= 0; j -= step) {
|
|
72
180
|
const x = xOf(j);
|
|
73
181
|
ctx.strokeStyle = theme.grid;
|
|
74
182
|
ctx.beginPath();
|
|
75
|
-
ctx.moveTo(x, pads.top);
|
|
76
|
-
ctx.lineTo(x,
|
|
183
|
+
ctx.moveTo(crisp(x), pads.top);
|
|
184
|
+
ctx.lineTo(crisp(x), chartBot);
|
|
77
185
|
ctx.stroke();
|
|
78
186
|
ctx.fillStyle = theme.axis;
|
|
79
|
-
ctx.fillText(
|
|
187
|
+
ctx.fillText(fmtTime(vis[j].time, interval), x, H - pads.bottom / 2);
|
|
80
188
|
}
|
|
81
|
-
const bw = Math.max(1, Math.min(cw * 0.7,
|
|
82
|
-
// volume panel
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
189
|
+
const bw = Math.max(1, Math.min(cw * 0.7, input.maxBodyWidth ?? 40));
|
|
190
|
+
// volume panel (muted bars on a baseline), only when there's a panel to draw
|
|
191
|
+
if (volH > 0) {
|
|
192
|
+
const volTop = pads.top + priceH;
|
|
193
|
+
for (let j = 0; j < n; j++) {
|
|
194
|
+
const c = vis[j];
|
|
195
|
+
const x = xOf(j);
|
|
196
|
+
ctx.fillStyle = c.c >= c.o ? theme.volUp : theme.volDown;
|
|
197
|
+
ctx.fillRect(x - bw / 2, vy(c.vol), bw, volBot - vy(c.vol));
|
|
198
|
+
}
|
|
199
|
+
// faint separator between the price pane and the volume panel
|
|
200
|
+
ctx.strokeStyle = theme.grid;
|
|
201
|
+
ctx.beginPath();
|
|
202
|
+
ctx.moveTo(0, crisp(volTop));
|
|
203
|
+
ctx.lineTo(plotW, crisp(volTop));
|
|
204
|
+
ctx.stroke();
|
|
205
|
+
}
|
|
206
|
+
// oscillator sub-panes (RSI / MACD), stacked below the volume panel
|
|
207
|
+
let paneTop = volBot;
|
|
208
|
+
for (let p = 0; p < oscs.length; p++) {
|
|
209
|
+
drawPane(ctx, oscs[p], paneHs[p], paneTop, { candles, start, n, xOf, bw, plotW, theme });
|
|
210
|
+
paneTop += paneHs[p];
|
|
88
211
|
}
|
|
89
212
|
// price series
|
|
90
213
|
if (type === "line") {
|
|
214
|
+
const priceBot = pads.top + priceH;
|
|
215
|
+
// soft gradient area fill under the line — the "premium" line-chart look
|
|
216
|
+
if (n > 1 && ctx.createLinearGradient) {
|
|
217
|
+
const grad = ctx.createLinearGradient(0, pads.top, 0, priceBot);
|
|
218
|
+
grad.addColorStop(0, withAlpha(theme.line, 0.22));
|
|
219
|
+
grad.addColorStop(1, withAlpha(theme.line, 0));
|
|
220
|
+
ctx.fillStyle = grad;
|
|
221
|
+
ctx.beginPath();
|
|
222
|
+
ctx.moveTo(xOf(0), priceBot);
|
|
223
|
+
for (let j = 0; j < n; j++)
|
|
224
|
+
ctx.lineTo(xOf(j), yOf(vis[j].c));
|
|
225
|
+
ctx.lineTo(xOf(n - 1), priceBot);
|
|
226
|
+
ctx.closePath();
|
|
227
|
+
ctx.fill();
|
|
228
|
+
}
|
|
91
229
|
ctx.strokeStyle = theme.line;
|
|
92
|
-
ctx.lineWidth =
|
|
230
|
+
ctx.lineWidth = 2;
|
|
231
|
+
ctx.lineJoin = "round";
|
|
93
232
|
ctx.beginPath();
|
|
94
233
|
for (let j = 0; j < n; j++) {
|
|
95
234
|
const x = xOf(j);
|
|
@@ -122,11 +261,7 @@ export function draw(input) {
|
|
|
122
261
|
// supplied (the Chart controller precomputes them on data change), else computed
|
|
123
262
|
// here from the indicator config for pure/standalone callers. Series align to the
|
|
124
263
|
// FULL candle array, so the left edge of the view is correct.
|
|
125
|
-
const overlays = input.overlays ??
|
|
126
|
-
(input.indicators ?? []).map((ind, ii) => ({
|
|
127
|
-
color: ind.color || INDICATOR_PALETTE[ii % INDICATOR_PALETTE.length],
|
|
128
|
-
values: computeIndicator(candles, ind),
|
|
129
|
-
}));
|
|
264
|
+
const overlays = input.overlays ?? resolveOverlays(candles, input.indicators ?? []);
|
|
130
265
|
if (overlays.length) {
|
|
131
266
|
ctx.lineWidth = 1.3;
|
|
132
267
|
for (const ov of overlays) {
|
|
@@ -152,22 +287,35 @@ export function draw(input) {
|
|
|
152
287
|
}
|
|
153
288
|
ctx.lineWidth = 1;
|
|
154
289
|
}
|
|
290
|
+
// user drawings (trendlines / horizontal lines), clipped to the price pane
|
|
291
|
+
const drawings = input.drawings ?? [];
|
|
292
|
+
if (drawings.length || input.drawPreview) {
|
|
293
|
+
ctx.save();
|
|
294
|
+
ctx.beginPath();
|
|
295
|
+
ctx.rect(0, pads.top, plotW, priceH);
|
|
296
|
+
ctx.clip();
|
|
297
|
+
for (const d of drawings)
|
|
298
|
+
drawDrawing(ctx, d, xOfTime, yOf, plotW, theme, d.id === input.selectedDrawing);
|
|
299
|
+
if (input.drawPreview)
|
|
300
|
+
drawDrawing(ctx, input.drawPreview, xOfTime, yOf, plotW, theme, false);
|
|
301
|
+
ctx.restore();
|
|
302
|
+
}
|
|
155
303
|
// last-price line + tag
|
|
156
304
|
const last = vis[n - 1];
|
|
157
305
|
const ly = yOf(last.c);
|
|
158
306
|
const lc = last.c >= last.o ? theme.up : theme.down;
|
|
159
307
|
ctx.strokeStyle = lc;
|
|
160
|
-
ctx.setLineDash([
|
|
308
|
+
ctx.setLineDash([3, 3]);
|
|
161
309
|
ctx.beginPath();
|
|
162
|
-
ctx.moveTo(0, ly);
|
|
163
|
-
ctx.lineTo(plotW, ly);
|
|
310
|
+
ctx.moveTo(0, crisp(ly));
|
|
311
|
+
ctx.lineTo(plotW, crisp(ly));
|
|
164
312
|
ctx.stroke();
|
|
165
313
|
ctx.setLineDash([]);
|
|
166
314
|
ctx.fillStyle = lc;
|
|
167
315
|
ctx.fillRect(plotW, ly - 8, pads.right, 16);
|
|
168
316
|
ctx.fillStyle = theme.tagText;
|
|
169
317
|
ctx.textAlign = "left";
|
|
170
|
-
ctx.fillText(
|
|
318
|
+
ctx.fillText(fmtPrice(last.c), plotW + 6, ly);
|
|
171
319
|
// crosshair + axis labels
|
|
172
320
|
let active = last;
|
|
173
321
|
if (hover && hover.x < plotW && hover.y > 0 && hover.y < H - pads.bottom) {
|
|
@@ -178,20 +326,20 @@ export function draw(input) {
|
|
|
178
326
|
ctx.setLineDash([3, 3]);
|
|
179
327
|
ctx.beginPath();
|
|
180
328
|
ctx.moveTo(cx, pads.top);
|
|
181
|
-
ctx.lineTo(cx,
|
|
329
|
+
ctx.lineTo(cx, chartBot);
|
|
182
330
|
ctx.moveTo(0, hover.y);
|
|
183
331
|
ctx.lineTo(plotW, hover.y);
|
|
184
332
|
ctx.stroke();
|
|
185
333
|
ctx.setLineDash([]);
|
|
186
334
|
if (hover.y < pads.top + priceH) {
|
|
187
|
-
const pv =
|
|
335
|
+
const pv = priceOfY(hover.y);
|
|
188
336
|
ctx.fillStyle = theme.axisTagBg;
|
|
189
337
|
ctx.fillRect(plotW, hover.y - 8, pads.right, 16);
|
|
190
338
|
ctx.fillStyle = theme.axisTagText;
|
|
191
339
|
ctx.textAlign = "left";
|
|
192
|
-
ctx.fillText(
|
|
340
|
+
ctx.fillText(fmtPrice(pv), plotW + 6, hover.y);
|
|
193
341
|
}
|
|
194
|
-
const tl =
|
|
342
|
+
const tl = fmtTime(active.time, interval);
|
|
195
343
|
const tw = ctx.measureText(tl).width + 12;
|
|
196
344
|
ctx.fillStyle = theme.axisTagBg;
|
|
197
345
|
ctx.fillRect(cx - tw / 2, H - pads.bottom, tw, pads.bottom);
|
|
@@ -201,3 +349,142 @@ export function draw(input) {
|
|
|
201
349
|
}
|
|
202
350
|
return active;
|
|
203
351
|
}
|
|
352
|
+
/** Render one drawing (and its selection handles) using the current frame's projection. */
|
|
353
|
+
function drawDrawing(ctx, d, xOfTime, yOfPrice, plotW, theme, selected) {
|
|
354
|
+
const color = d.color ?? theme.line;
|
|
355
|
+
ctx.strokeStyle = color;
|
|
356
|
+
ctx.lineWidth = selected ? 2.5 : 1.5;
|
|
357
|
+
const pts = [];
|
|
358
|
+
ctx.beginPath();
|
|
359
|
+
if (d.type === "hline") {
|
|
360
|
+
const y = yOfPrice(d.a.price);
|
|
361
|
+
ctx.moveTo(0, y);
|
|
362
|
+
ctx.lineTo(plotW, y);
|
|
363
|
+
pts.push({ x: plotW / 2, y });
|
|
364
|
+
}
|
|
365
|
+
else if (d.b) {
|
|
366
|
+
const ax = xOfTime(d.a.time);
|
|
367
|
+
const ay = yOfPrice(d.a.price);
|
|
368
|
+
const bx = xOfTime(d.b.time);
|
|
369
|
+
const by = yOfPrice(d.b.price);
|
|
370
|
+
ctx.moveTo(ax, ay);
|
|
371
|
+
ctx.lineTo(bx, by);
|
|
372
|
+
pts.push({ x: ax, y: ay }, { x: bx, y: by });
|
|
373
|
+
}
|
|
374
|
+
ctx.stroke();
|
|
375
|
+
if (selected) {
|
|
376
|
+
ctx.fillStyle = color;
|
|
377
|
+
for (const p of pts)
|
|
378
|
+
ctx.fillRect(p.x - 3, p.y - 3, 6, 6);
|
|
379
|
+
}
|
|
380
|
+
ctx.lineWidth = 1;
|
|
381
|
+
}
|
|
382
|
+
/** Default oscillator colors. */
|
|
383
|
+
const OSC_COLORS = { rsi: "#ab47bc", macdLine: "#2962ff", macdSignal: "#ff9800" };
|
|
384
|
+
/** Last non-null value of a series (for the pane's inline readout). */
|
|
385
|
+
function lastDefined(s) {
|
|
386
|
+
for (let i = s.length - 1; i >= 0; i--)
|
|
387
|
+
if (s[i] != null)
|
|
388
|
+
return s[i];
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
/** Stroke a value-series across the visible window, lifting the pen across null gaps. */
|
|
392
|
+
function strokeSeries(ctx, series, env, yOf, color) {
|
|
393
|
+
ctx.strokeStyle = color;
|
|
394
|
+
ctx.lineWidth = 1.3;
|
|
395
|
+
ctx.beginPath();
|
|
396
|
+
let pen = false;
|
|
397
|
+
for (let j = 0; j < env.n; j++) {
|
|
398
|
+
const v = series[env.start + j];
|
|
399
|
+
if (v == null) {
|
|
400
|
+
pen = false;
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const x = env.xOf(j);
|
|
404
|
+
const y = yOf(v);
|
|
405
|
+
if (pen)
|
|
406
|
+
ctx.lineTo(x, y);
|
|
407
|
+
else {
|
|
408
|
+
ctx.moveTo(x, y);
|
|
409
|
+
pen = true;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
ctx.stroke();
|
|
413
|
+
ctx.lineWidth = 1;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Render one oscillator sub-pane (RSI band or MACD) in the horizontal strip [top, top+paneH].
|
|
417
|
+
* Closes are read from the FULL candle array so values at the left edge of the view are correct.
|
|
418
|
+
*/
|
|
419
|
+
function drawPane(ctx, osc, paneH, top, env) {
|
|
420
|
+
const { candles, start, n, xOf, bw, plotW, theme } = env;
|
|
421
|
+
const closes = candles.map((c) => c.c);
|
|
422
|
+
const padY = 8;
|
|
423
|
+
const innerTop = top + padY;
|
|
424
|
+
const innerH = Math.max(1, paneH - padY * 2);
|
|
425
|
+
// separator rule at the pane's top edge
|
|
426
|
+
ctx.strokeStyle = theme.grid;
|
|
427
|
+
ctx.lineWidth = 1;
|
|
428
|
+
ctx.beginPath();
|
|
429
|
+
ctx.moveTo(0, top);
|
|
430
|
+
ctx.lineTo(plotW, top);
|
|
431
|
+
ctx.stroke();
|
|
432
|
+
ctx.textAlign = "left";
|
|
433
|
+
if (osc.type === "rsi") {
|
|
434
|
+
const period = osc.period ?? 14;
|
|
435
|
+
const series = rsi(closes, period);
|
|
436
|
+
const yOf = (v) => innerTop + innerH * (1 - v / 100);
|
|
437
|
+
// 70 / 30 guide rails
|
|
438
|
+
ctx.setLineDash([2, 3]);
|
|
439
|
+
for (const lvl of [70, 30]) {
|
|
440
|
+
const y = yOf(lvl);
|
|
441
|
+
ctx.strokeStyle = theme.grid;
|
|
442
|
+
ctx.beginPath();
|
|
443
|
+
ctx.moveTo(0, y);
|
|
444
|
+
ctx.lineTo(plotW, y);
|
|
445
|
+
ctx.stroke();
|
|
446
|
+
ctx.fillStyle = theme.axis;
|
|
447
|
+
ctx.fillText(String(lvl), plotW + 6, y);
|
|
448
|
+
}
|
|
449
|
+
ctx.setLineDash([]);
|
|
450
|
+
strokeSeries(ctx, series, env, yOf, osc.color ?? OSC_COLORS.rsi);
|
|
451
|
+
const lv = lastDefined(series);
|
|
452
|
+
ctx.fillStyle = theme.axis;
|
|
453
|
+
ctx.fillText(`RSI ${period}${lv != null ? " " + lv.toFixed(1) : ""}`, 6, top + padY);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
// MACD: histogram + macd line + signal line, symmetric around zero
|
|
457
|
+
const fast = osc.fast ?? 12;
|
|
458
|
+
const slow = osc.slow ?? 26;
|
|
459
|
+
const signal = osc.signal ?? 9;
|
|
460
|
+
const { macd: line, signal: sig, hist } = macd(closes, fast, slow, signal);
|
|
461
|
+
let absMax = 0;
|
|
462
|
+
for (let j = 0; j < n; j++) {
|
|
463
|
+
for (const s of [line, sig, hist]) {
|
|
464
|
+
const v = s[start + j];
|
|
465
|
+
if (v != null)
|
|
466
|
+
absMax = Math.max(absMax, Math.abs(v));
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
absMax = absMax || 1;
|
|
470
|
+
const yOf = (v) => innerTop + innerH * (1 - (v + absMax) / (2 * absMax));
|
|
471
|
+
const zy = yOf(0);
|
|
472
|
+
ctx.strokeStyle = theme.grid;
|
|
473
|
+
ctx.beginPath();
|
|
474
|
+
ctx.moveTo(0, zy);
|
|
475
|
+
ctx.lineTo(plotW, zy);
|
|
476
|
+
ctx.stroke();
|
|
477
|
+
for (let j = 0; j < n; j++) {
|
|
478
|
+
const v = hist[start + j];
|
|
479
|
+
if (v == null)
|
|
480
|
+
continue;
|
|
481
|
+
const x = xOf(j);
|
|
482
|
+
const y = yOf(v);
|
|
483
|
+
ctx.fillStyle = v >= 0 ? theme.volUp : theme.volDown;
|
|
484
|
+
ctx.fillRect(x - bw / 2, Math.min(y, zy), bw, Math.max(1, Math.abs(y - zy)));
|
|
485
|
+
}
|
|
486
|
+
strokeSeries(ctx, line, env, yOf, osc.color ?? OSC_COLORS.macdLine);
|
|
487
|
+
strokeSeries(ctx, sig, env, yOf, OSC_COLORS.macdSignal);
|
|
488
|
+
ctx.fillStyle = theme.axis;
|
|
489
|
+
ctx.fillText(`MACD ${fast} ${slow} ${signal}`, 6, top + padY);
|
|
490
|
+
}
|
package/dist/core/theme.d.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
import type { ChartTheme } from "./types";
|
|
2
|
-
/** Default dark theme
|
|
2
|
+
/** Default dark theme — a refined, modern crypto-terminal palette. */
|
|
3
3
|
export declare const DEFAULT_THEME: ChartTheme;
|
|
4
|
+
/** Light theme preset (same hues on a clean white plot). Pass to `theme` / `setTheme`. */
|
|
5
|
+
export declare const LIGHT_THEME: ChartTheme;
|
|
6
|
+
/** Built-in theme presets, addressable by name. */
|
|
7
|
+
export declare const THEME_PRESETS: Record<"dark" | "light", ChartTheme>;
|
package/dist/core/theme.js
CHANGED
|
@@ -1,14 +1,35 @@
|
|
|
1
|
-
/** Default dark theme
|
|
1
|
+
/** Default dark theme — a refined, modern crypto-terminal palette. */
|
|
2
2
|
export const DEFAULT_THEME = {
|
|
3
|
-
up: "#
|
|
4
|
-
down: "#
|
|
5
|
-
line: "#
|
|
6
|
-
grid: "#
|
|
7
|
-
axis: "#
|
|
8
|
-
crosshair: "#
|
|
9
|
-
volUp: "rgba(
|
|
10
|
-
volDown: "rgba(
|
|
11
|
-
tagText: "#
|
|
12
|
-
axisTagBg: "#
|
|
13
|
-
axisTagText: "#
|
|
3
|
+
up: "#2ebd85",
|
|
4
|
+
down: "#f6465d",
|
|
5
|
+
line: "#5b8def",
|
|
6
|
+
grid: "#1a2030",
|
|
7
|
+
axis: "#8b919e",
|
|
8
|
+
crosshair: "#b3b9c4",
|
|
9
|
+
volUp: "rgba(46,189,133,.28)",
|
|
10
|
+
volDown: "rgba(246,70,93,.28)",
|
|
11
|
+
tagText: "#06080c",
|
|
12
|
+
axisTagBg: "#2b3242",
|
|
13
|
+
axisTagText: "#eaedf2",
|
|
14
|
+
background: "#0b0e14",
|
|
15
|
+
};
|
|
16
|
+
/** Light theme preset (same hues on a clean white plot). Pass to `theme` / `setTheme`. */
|
|
17
|
+
export const LIGHT_THEME = {
|
|
18
|
+
up: "#089981",
|
|
19
|
+
down: "#f23645",
|
|
20
|
+
line: "#3b6fe0",
|
|
21
|
+
grid: "#eceff4",
|
|
22
|
+
axis: "#6b7280",
|
|
23
|
+
crosshair: "#4b5563",
|
|
24
|
+
volUp: "rgba(8,153,129,.22)",
|
|
25
|
+
volDown: "rgba(242,54,69,.22)",
|
|
26
|
+
tagText: "#ffffff",
|
|
27
|
+
axisTagBg: "#1f2733",
|
|
28
|
+
axisTagText: "#ffffff",
|
|
29
|
+
background: "#ffffff",
|
|
30
|
+
};
|
|
31
|
+
/** Built-in theme presets, addressable by name. */
|
|
32
|
+
export const THEME_PRESETS = {
|
|
33
|
+
dark: DEFAULT_THEME,
|
|
34
|
+
light: LIGHT_THEME,
|
|
14
35
|
};
|