@livo-build/charts 0.2.1 → 0.2.3
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 +209 -20
- package/dist/core/chart.d.ts +105 -4
- package/dist/core/chart.js +482 -39
- package/dist/core/feed.js +27 -6
- package/dist/core/format.d.ts +13 -1
- package/dist/core/format.js +38 -3
- package/dist/core/indicators.d.ts +50 -0
- package/dist/core/indicators.js +181 -0
- package/dist/core/ohlc.d.ts +10 -0
- package/dist/core/ohlc.js +30 -0
- package/dist/core/polymarket.d.ts +44 -0
- package/dist/core/polymarket.js +92 -0
- package/dist/core/renderer.d.ts +96 -1
- package/dist/core/renderer.js +534 -64
- package/dist/core/signals.d.ts +63 -0
- package/dist/core/signals.js +234 -0
- package/dist/core/theme.d.ts +5 -1
- package/dist/core/theme.js +33 -12
- package/dist/core/types.d.ts +102 -3
- package/dist/index.d.ts +13 -8
- package/dist/index.js +8 -6
- package/dist/react/HyperliquidChart.d.ts +30 -2
- package/dist/react/HyperliquidChart.js +47 -17
- package/dist/react/PolymarketChart.d.ts +40 -0
- package/dist/react/PolymarketChart.js +95 -0
- package/dist/react/PriceChart.d.ts +54 -4
- package/dist/react/PriceChart.js +66 -27
- package/dist/react/SignalsChart.d.ts +37 -0
- package/dist/react/SignalsChart.js +95 -0
- package/dist/react/ui.d.ts +24 -0
- package/dist/react/ui.js +75 -0
- package/dist/react.d.ts +4 -0
- package/dist/react.js +2 -0
- package/package.json +2 -2
package/dist/core/renderer.js
CHANGED
|
@@ -1,5 +1,120 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { computeIndicator, INDICATOR_PALETTE } from "./indicators";
|
|
1
|
+
import { formatAxisValue, formatTime, formatValue } from "./format";
|
|
2
|
+
import { computeIndicator, sourceValues, bollingerBands, INDICATOR_PALETTE, rsi, macd, stochastic, atr, volumeProfile } from "./indicators";
|
|
3
|
+
import { heikinAshi } from "./ohlc";
|
|
4
|
+
/** Standard Fibonacci retracement ratios (0 and 1 are the anchors themselves). */
|
|
5
|
+
const FIB_LEVELS = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1];
|
|
6
|
+
/**
|
|
7
|
+
* Width of one candle slot (px). By default the slot is capped at `maxBarWidth` and the
|
|
8
|
+
* series is right-anchored — sparse data stays tight in the corner. With `fitContent`,
|
|
9
|
+
* the cap is dropped so `count` candles spread across the whole plot (fills the
|
|
10
|
+
* container). Dense series are identical either way (plotW/count < maxBarWidth).
|
|
11
|
+
*/
|
|
12
|
+
export function slotWidth(plotW, count, maxBarWidth, fitContent = false) {
|
|
13
|
+
const raw = plotW / Math.max(count, 1);
|
|
14
|
+
return fitContent ? raw : Math.min(raw, maxBarWidth);
|
|
15
|
+
}
|
|
16
|
+
/** Convert a `#rrggbb` color to `rgba(...)` with the given alpha (passes other formats through). */
|
|
17
|
+
export function withAlpha(color, alpha) {
|
|
18
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(color);
|
|
19
|
+
if (!m)
|
|
20
|
+
return color;
|
|
21
|
+
const n = parseInt(m[1], 16);
|
|
22
|
+
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
|
|
23
|
+
}
|
|
24
|
+
/** The visible candle window (which slice is on screen and the per-candle slot width).
|
|
25
|
+
* A negative `view.offset` scrolls past the newest candle into the future: `end` exceeds
|
|
26
|
+
* the data length and `rightGap` counts the empty slots between the newest candle and the
|
|
27
|
+
* right edge (so the user can pull the current price left of the edge / toward center). */
|
|
28
|
+
export function windowOf(candles, view, plotW, maxBarWidth, fitContent = false) {
|
|
29
|
+
const count = Math.min(view.count, candles.length);
|
|
30
|
+
const end = candles.length - view.offset; // virtual right edge (may exceed length when offset < 0)
|
|
31
|
+
const start = Math.max(0, end - count);
|
|
32
|
+
const vis = candles.slice(start, Math.min(candles.length, end));
|
|
33
|
+
const rightGap = Math.max(0, end - candles.length); // empty slots to the right of the newest candle
|
|
34
|
+
return { start, end, vis, n: vis.length, count, rightGap, cw: slotWidth(plotW, count, maxBarWidth, fitContent) };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Price ↔ pixel mapping for the price pane, shared by the renderer and the drawing-tool
|
|
38
|
+
* hit-testing so they never drift. Handles the log transform and the padded/zoomed range.
|
|
39
|
+
*/
|
|
40
|
+
export function priceScale(vis, yZoom, logScale, top, priceH) {
|
|
41
|
+
let lo = Infinity;
|
|
42
|
+
let hi = -Infinity;
|
|
43
|
+
for (const c of vis) {
|
|
44
|
+
lo = Math.min(lo, c.l);
|
|
45
|
+
hi = Math.max(hi, c.h);
|
|
46
|
+
}
|
|
47
|
+
const log = !!logScale && lo > 0;
|
|
48
|
+
// In log mode, clamp the forward input to a tiny positive epsilon so an arbitrary drawing
|
|
49
|
+
// anchor or crosshair price ≤ 0 yields a finite (off-screen) y instead of NaN/-Infinity.
|
|
50
|
+
const fwd = (v) => (log ? Math.log10(v > 0 ? v : 1e-12) : v);
|
|
51
|
+
const inv = (s) => (log ? 10 ** s : s);
|
|
52
|
+
let sLo = fwd(lo);
|
|
53
|
+
let sHi = fwd(hi);
|
|
54
|
+
const sMid = (sLo + sHi) / 2;
|
|
55
|
+
const sHalf = (((sHi - sLo) / 2 || Math.abs(sHi) * 0.1 || 1) * 1.08) / yZoom;
|
|
56
|
+
sLo = sMid - sHalf;
|
|
57
|
+
sHi = sMid + sHalf;
|
|
58
|
+
const sRng = sHi - sLo || 1;
|
|
59
|
+
const yOfPrice = (v) => top + priceH * (1 - (fwd(v) - sLo) / sRng);
|
|
60
|
+
const priceOfY = (y) => inv(sLo + sRng * (1 - (y - top) / priceH));
|
|
61
|
+
return { yOfPrice, priceOfY, fwd, inv, sLo, sRng, lo, hi };
|
|
62
|
+
}
|
|
63
|
+
/** Time ↔ pixel mapping for the visible window (candle index and arbitrary unix-second time).
|
|
64
|
+
* `rightGap` (empty future slots, see windowOf) shifts the candles left of the right edge. */
|
|
65
|
+
export function timeScale(vis, n, cw, plotW, interval, rightGap = 0) {
|
|
66
|
+
const xOf = (j) => plotW - (rightGap + n - 1 - j) * cw - cw / 2;
|
|
67
|
+
const t0 = n ? vis[0].time : 0;
|
|
68
|
+
const iv = interval || 1;
|
|
69
|
+
const xOfTime = (t) => xOf((t - t0) / iv);
|
|
70
|
+
const timeOfX = (x) => t0 + ((x - xOf(0)) / cw) * iv;
|
|
71
|
+
return { xOf, xOfTime, timeOfX };
|
|
72
|
+
}
|
|
73
|
+
/** Compute the {@link Projection} for interaction/hit-testing without rendering. */
|
|
74
|
+
export function computeProjection(input) {
|
|
75
|
+
const { view, pads, width, maxBarWidth, volH, height, yZoom, interval } = input;
|
|
76
|
+
// The visible price axis is the Heikin-Ashi range in `heikin` mode, so map against it.
|
|
77
|
+
// Prefer the controller's precomputed series; derive only for standalone callers.
|
|
78
|
+
const candles = input.priceCandles ?? (input.type === "heikin" ? heikinAshi(input.candles) : input.candles);
|
|
79
|
+
if (!candles.length)
|
|
80
|
+
return null;
|
|
81
|
+
const plotW = width - pads.right;
|
|
82
|
+
const panesH = (input.oscillators ?? []).map((o) => Math.max(24, o.height ?? 84)).reduce((a, b) => a + b, 0);
|
|
83
|
+
const priceH = height - pads.bottom - pads.top - volH - panesH;
|
|
84
|
+
const { vis, n, cw, rightGap } = windowOf(candles, view, plotW, maxBarWidth, input.fitContent);
|
|
85
|
+
const ts = timeScale(vis, n, cw, plotW, interval, rightGap);
|
|
86
|
+
const ps = priceScale(vis, yZoom, !!input.logScale, pads.top, priceH);
|
|
87
|
+
return {
|
|
88
|
+
xOfTime: ts.xOfTime,
|
|
89
|
+
timeOfX: ts.timeOfX,
|
|
90
|
+
yOfPrice: ps.yOfPrice,
|
|
91
|
+
priceOfY: ps.priceOfY,
|
|
92
|
+
plotW,
|
|
93
|
+
priceTop: pads.top,
|
|
94
|
+
priceBottom: pads.top + priceH,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Resolve indicator configs into drawable overlay lines. Moving averages / VWAP map 1:1;
|
|
99
|
+
* a `bollinger` indicator expands into three lines (upper / mid / lower), the bands faint.
|
|
100
|
+
* Shared by the Chart controller (precompute) and `draw` (standalone callers).
|
|
101
|
+
*/
|
|
102
|
+
export function resolveOverlays(candles, indicators) {
|
|
103
|
+
const out = [];
|
|
104
|
+
indicators.forEach((ind, i) => {
|
|
105
|
+
const color = ind.color || INDICATOR_PALETTE[i % INDICATOR_PALETTE.length];
|
|
106
|
+
if (ind.type === "bollinger") {
|
|
107
|
+
const { upper, mid, lower } = bollingerBands(sourceValues(candles, ind.source), ind.period, ind.mult ?? 2);
|
|
108
|
+
out.push({ color: withAlpha(color, 0.45), values: upper });
|
|
109
|
+
out.push({ color, values: mid });
|
|
110
|
+
out.push({ color: withAlpha(color, 0.45), values: lower });
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
out.push({ color, values: computeIndicator(candles, ind) });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
3
118
|
/**
|
|
4
119
|
* Pure draw pass: renders grid, axes, the price series (candles or line), a volume
|
|
5
120
|
* panel, the last-price tag and the crosshair + axis labels. Returns the candle under
|
|
@@ -9,7 +124,13 @@ import { computeIndicator, INDICATOR_PALETTE } from "./indicators";
|
|
|
9
124
|
export function draw(input) {
|
|
10
125
|
const { ctx, width: w, height: H, candles, view, hover, interval, type, yZoom, maxBarWidth, volH, theme, pads } = input;
|
|
11
126
|
const plotW = w - pads.right;
|
|
12
|
-
|
|
127
|
+
// Oscillator sub-panes stack below the volume panel; each eats into the price pane.
|
|
128
|
+
const oscs = input.oscillators ?? [];
|
|
129
|
+
const paneHs = oscs.map((o) => Math.max(24, o.height ?? 84));
|
|
130
|
+
const panesH = paneHs.reduce((a, b) => a + b, 0);
|
|
131
|
+
const priceH = H - pads.bottom - pads.top - volH - panesH;
|
|
132
|
+
// bottom of the plotting area (above the time-axis gutter) — vertical lines span to here.
|
|
133
|
+
const chartBot = pads.top + priceH + volH + panesH;
|
|
13
134
|
if (theme.background) {
|
|
14
135
|
ctx.fillStyle = theme.background;
|
|
15
136
|
ctx.fillRect(0, 0, w, H);
|
|
@@ -17,79 +138,122 @@ export function draw(input) {
|
|
|
17
138
|
else {
|
|
18
139
|
ctx.clearRect(0, 0, w, H);
|
|
19
140
|
}
|
|
20
|
-
ctx.font = "10px ui-monospace, monospace";
|
|
141
|
+
ctx.font = input.axisFont ?? "10px ui-monospace, monospace";
|
|
21
142
|
ctx.textBaseline = "middle";
|
|
143
|
+
let fmtTime = input.timeFormat ?? formatTime;
|
|
144
|
+
// Snap 1px strokes to the pixel grid so gridlines/axes render crisp, not blurred across 2px.
|
|
145
|
+
const crisp = (v) => Math.round(v) + 0.5;
|
|
22
146
|
if (!candles.length) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
147
|
+
const msg = input.emptyText ?? "no priced trades yet";
|
|
148
|
+
if (msg) {
|
|
149
|
+
ctx.fillStyle = theme.axis;
|
|
150
|
+
ctx.textAlign = "center";
|
|
151
|
+
ctx.fillText(msg, plotW / 2, H / 2);
|
|
152
|
+
}
|
|
26
153
|
return null;
|
|
27
154
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
155
|
+
// Slot width fills the plot by default (`fitContent`, on by default in the wrappers and
|
|
156
|
+
// the core Chart) so sparse data spreads out instead of bunching on the right; the candle
|
|
157
|
+
// BODY is capped separately (`maxBodyWidth`) below. fitContent:false caps the slot at
|
|
158
|
+
// `maxBarWidth` and right-anchors (the tight, TradingView-style look).
|
|
159
|
+
// The price series + axis use Heikin-Ashi candles in `heikin` mode (same times/volumes,
|
|
160
|
+
// smoothed OHLC); indicators/oscillators still read the RAW closes (`candles`) below.
|
|
161
|
+
// The controller precomputes this on data/type change (it's O(n)); derive only standalone.
|
|
162
|
+
const priceCandles = input.priceCandles ?? (type === "heikin" ? heikinAshi(candles) : candles);
|
|
163
|
+
const { start, vis, n, cw, rightGap } = windowOf(priceCandles, view, plotW, maxBarWidth, input.fitContent);
|
|
164
|
+
const { xOf, xOfTime } = timeScale(vis, n, cw, plotW, interval, rightGap);
|
|
165
|
+
// When sub-daily candles span more than a day, time-only labels read as out-of-order across
|
|
166
|
+
// midnight — include the date (M/D h:m) so the axis stays chronological & legible.
|
|
167
|
+
if (!input.timeFormat && n > 1 && vis[n - 1].time - vis[0].time > 86400 && interval < 86400) {
|
|
168
|
+
fmtTime = (t) => formatTime(t, 3600);
|
|
169
|
+
}
|
|
39
170
|
let vmax = 0;
|
|
40
|
-
for (const c of vis)
|
|
41
|
-
lo = Math.min(lo, c.l);
|
|
42
|
-
hi = Math.max(hi, c.h);
|
|
171
|
+
for (const c of vis)
|
|
43
172
|
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
173
|
vmax = vmax || 1;
|
|
51
|
-
|
|
174
|
+
// Price↔pixel mapping (log-aware, padded, zoomed). Shared with drawing-tool hit-testing.
|
|
175
|
+
const { yOfPrice: yOf, priceOfY, inv, sLo, sRng } = priceScale(vis, yZoom, !!input.logScale, pads.top, priceH);
|
|
52
176
|
const volBot = pads.top + priceH + volH;
|
|
53
177
|
const vy = (vol) => volBot - (vol / vmax) * volH * 0.92;
|
|
54
|
-
// grid + price axis (right gutter)
|
|
178
|
+
// grid + price axis (right gutter). Labels are formatted with enough precision to stay
|
|
179
|
+
// distinct (the compact "K" formatter alone collapses a narrow high-value axis into dupes).
|
|
180
|
+
const pTicks = Math.max(1, Math.round(input.priceTicks ?? 5));
|
|
181
|
+
const tickVals = [];
|
|
182
|
+
for (let i = 0; i <= pTicks; i++)
|
|
183
|
+
tickVals.push(inv(sLo + (sRng * i) / pTicks));
|
|
184
|
+
const priceStep = Math.abs(tickVals[pTicks] - tickVals[0]) / pTicks;
|
|
185
|
+
const fmtPrice = input.priceFormat ?? ((v) => formatAxisValue(v, priceStep));
|
|
55
186
|
ctx.strokeStyle = theme.grid;
|
|
56
187
|
ctx.fillStyle = theme.axis;
|
|
57
188
|
ctx.lineWidth = 1;
|
|
58
189
|
ctx.textAlign = "left";
|
|
59
|
-
for (let i = 0; i <=
|
|
60
|
-
const
|
|
61
|
-
const y = yOf(v);
|
|
190
|
+
for (let i = 0; i <= pTicks; i++) {
|
|
191
|
+
const y = pads.top + priceH * (1 - i / pTicks);
|
|
62
192
|
ctx.beginPath();
|
|
63
|
-
ctx.moveTo(0, y);
|
|
64
|
-
ctx.lineTo(plotW, y);
|
|
193
|
+
ctx.moveTo(0, crisp(y));
|
|
194
|
+
ctx.lineTo(plotW, crisp(y));
|
|
65
195
|
ctx.stroke();
|
|
66
|
-
|
|
196
|
+
// local gap (not the average) sets precision — log ticks bunch up at the low end,
|
|
197
|
+
// and the bottom labels need more decimals than the top ones to stay distinct.
|
|
198
|
+
const local = input.priceFormat ? 0 : Math.abs((tickVals[i + 1] ?? tickVals[i - 1]) - tickVals[i]) || priceStep;
|
|
199
|
+
ctx.fillText(input.priceFormat ? fmtPrice(tickVals[i]) : formatAxisValue(tickVals[i], local), plotW + 6, y);
|
|
67
200
|
}
|
|
68
201
|
// time axis (bottom gutter), anchored to the latest candle
|
|
69
202
|
ctx.textAlign = "center";
|
|
70
|
-
const step = Math.max(1, Math.floor(n / 7));
|
|
203
|
+
const step = Math.max(1, Math.floor(n / Math.max(1, Math.round(input.timeTicks ?? 7))));
|
|
71
204
|
for (let j = n - 1; j >= 0; j -= step) {
|
|
72
205
|
const x = xOf(j);
|
|
73
206
|
ctx.strokeStyle = theme.grid;
|
|
74
207
|
ctx.beginPath();
|
|
75
|
-
ctx.moveTo(x, pads.top);
|
|
76
|
-
ctx.lineTo(x,
|
|
208
|
+
ctx.moveTo(crisp(x), pads.top);
|
|
209
|
+
ctx.lineTo(crisp(x), chartBot);
|
|
77
210
|
ctx.stroke();
|
|
78
211
|
ctx.fillStyle = theme.axis;
|
|
79
|
-
ctx.fillText(
|
|
212
|
+
ctx.fillText(fmtTime(vis[j].time, interval), x, H - pads.bottom / 2);
|
|
80
213
|
}
|
|
81
|
-
const bw = Math.max(1, Math.min(cw * 0.7,
|
|
82
|
-
// volume panel
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
214
|
+
const bw = Math.max(1, Math.min(cw * 0.7, input.maxBodyWidth ?? 40));
|
|
215
|
+
// volume panel (muted bars on a baseline), only when there's a panel to draw
|
|
216
|
+
if (volH > 0) {
|
|
217
|
+
const volTop = pads.top + priceH;
|
|
218
|
+
for (let j = 0; j < n; j++) {
|
|
219
|
+
const c = vis[j];
|
|
220
|
+
const x = xOf(j);
|
|
221
|
+
ctx.fillStyle = c.c >= c.o ? theme.volUp : theme.volDown;
|
|
222
|
+
ctx.fillRect(x - bw / 2, vy(c.vol), bw, volBot - vy(c.vol));
|
|
223
|
+
}
|
|
224
|
+
// faint separator between the price pane and the volume panel
|
|
225
|
+
ctx.strokeStyle = theme.grid;
|
|
226
|
+
ctx.beginPath();
|
|
227
|
+
ctx.moveTo(0, crisp(volTop));
|
|
228
|
+
ctx.lineTo(plotW, crisp(volTop));
|
|
229
|
+
ctx.stroke();
|
|
230
|
+
}
|
|
231
|
+
// oscillator sub-panes (RSI / MACD), stacked below the volume panel
|
|
232
|
+
let paneTop = volBot;
|
|
233
|
+
for (let p = 0; p < oscs.length; p++) {
|
|
234
|
+
drawPane(ctx, oscs[p], paneHs[p], paneTop, { candles, start, n, xOf, bw, plotW, theme });
|
|
235
|
+
paneTop += paneHs[p];
|
|
88
236
|
}
|
|
89
237
|
// price series
|
|
90
238
|
if (type === "line") {
|
|
239
|
+
const priceBot = pads.top + priceH;
|
|
240
|
+
// soft gradient area fill under the line — the "premium" line-chart look
|
|
241
|
+
if (n > 1 && ctx.createLinearGradient) {
|
|
242
|
+
const grad = ctx.createLinearGradient(0, pads.top, 0, priceBot);
|
|
243
|
+
grad.addColorStop(0, withAlpha(theme.line, 0.22));
|
|
244
|
+
grad.addColorStop(1, withAlpha(theme.line, 0));
|
|
245
|
+
ctx.fillStyle = grad;
|
|
246
|
+
ctx.beginPath();
|
|
247
|
+
ctx.moveTo(xOf(0), priceBot);
|
|
248
|
+
for (let j = 0; j < n; j++)
|
|
249
|
+
ctx.lineTo(xOf(j), yOf(vis[j].c));
|
|
250
|
+
ctx.lineTo(xOf(n - 1), priceBot);
|
|
251
|
+
ctx.closePath();
|
|
252
|
+
ctx.fill();
|
|
253
|
+
}
|
|
91
254
|
ctx.strokeStyle = theme.line;
|
|
92
|
-
ctx.lineWidth =
|
|
255
|
+
ctx.lineWidth = 2;
|
|
256
|
+
ctx.lineJoin = "round";
|
|
93
257
|
ctx.beginPath();
|
|
94
258
|
for (let j = 0; j < n; j++) {
|
|
95
259
|
const x = xOf(j);
|
|
@@ -102,6 +266,51 @@ export function draw(input) {
|
|
|
102
266
|
ctx.stroke();
|
|
103
267
|
ctx.lineWidth = 1;
|
|
104
268
|
}
|
|
269
|
+
else if (type === "baseline") {
|
|
270
|
+
// Two-tone area around a reference price: above the baseline is "up", below is "down".
|
|
271
|
+
const priceBot = pads.top + priceH;
|
|
272
|
+
const baseP = input.baselinePrice ?? vis[0].c;
|
|
273
|
+
const by = Math.max(pads.top, Math.min(priceBot, yOf(baseP)));
|
|
274
|
+
const pts = [];
|
|
275
|
+
for (let j = 0; j < n; j++)
|
|
276
|
+
pts.push({ x: xOf(j), y: yOf(vis[j].c) });
|
|
277
|
+
const area = (color, clipTop, clipH) => {
|
|
278
|
+
if (clipH <= 0)
|
|
279
|
+
return;
|
|
280
|
+
ctx.save();
|
|
281
|
+
ctx.beginPath();
|
|
282
|
+
ctx.rect(0, clipTop, plotW, clipH);
|
|
283
|
+
ctx.clip();
|
|
284
|
+
// faint filled body…
|
|
285
|
+
ctx.fillStyle = withAlpha(color, 0.16);
|
|
286
|
+
ctx.beginPath();
|
|
287
|
+
ctx.moveTo(pts[0].x, by);
|
|
288
|
+
for (const p of pts)
|
|
289
|
+
ctx.lineTo(p.x, p.y);
|
|
290
|
+
ctx.lineTo(pts[n - 1].x, by);
|
|
291
|
+
ctx.closePath();
|
|
292
|
+
ctx.fill();
|
|
293
|
+
// …with a solid line on top (clipped to this side of the baseline)
|
|
294
|
+
ctx.strokeStyle = color;
|
|
295
|
+
ctx.lineWidth = 2;
|
|
296
|
+
ctx.lineJoin = "round";
|
|
297
|
+
ctx.beginPath();
|
|
298
|
+
for (let j = 0; j < n; j++)
|
|
299
|
+
(j ? ctx.lineTo(pts[j].x, pts[j].y) : ctx.moveTo(pts[j].x, pts[j].y));
|
|
300
|
+
ctx.stroke();
|
|
301
|
+
ctx.restore();
|
|
302
|
+
};
|
|
303
|
+
area(theme.up, pads.top, by - pads.top);
|
|
304
|
+
area(theme.down, by, priceBot - by);
|
|
305
|
+
ctx.strokeStyle = theme.grid;
|
|
306
|
+
ctx.setLineDash([4, 4]);
|
|
307
|
+
ctx.beginPath();
|
|
308
|
+
ctx.moveTo(0, crisp(by));
|
|
309
|
+
ctx.lineTo(plotW, crisp(by));
|
|
310
|
+
ctx.stroke();
|
|
311
|
+
ctx.setLineDash([]);
|
|
312
|
+
ctx.lineWidth = 1;
|
|
313
|
+
}
|
|
105
314
|
else {
|
|
106
315
|
for (let j = 0; j < n; j++) {
|
|
107
316
|
const c = vis[j];
|
|
@@ -118,15 +327,30 @@ export function draw(input) {
|
|
|
118
327
|
ctx.fillRect(x - bw / 2, Math.min(yo, yc), bw, Math.max(1, Math.abs(yc - yo)));
|
|
119
328
|
}
|
|
120
329
|
}
|
|
330
|
+
// volume-by-price histogram — translucent bars grown leftward from the right gutter,
|
|
331
|
+
// peaked at the Point of Control. Computed over the visible window so it tracks zoom/pan.
|
|
332
|
+
if (input.volumeProfile) {
|
|
333
|
+
const cfg = typeof input.volumeProfile === "object" ? input.volumeProfile : {};
|
|
334
|
+
const vp = volumeProfile(vis, cfg.buckets ?? 24);
|
|
335
|
+
const maxW = (cfg.width ?? 0.16) * plotW;
|
|
336
|
+
const color = cfg.color ?? withAlpha(theme.axis, 0.22);
|
|
337
|
+
for (const b of vp.buckets) {
|
|
338
|
+
if (b.vol <= 0)
|
|
339
|
+
continue;
|
|
340
|
+
const yTop = yOf(b.hi);
|
|
341
|
+
const yBot = yOf(b.lo);
|
|
342
|
+
const bh = Math.max(1, yBot - yTop - 1);
|
|
343
|
+
const wpx = (b.vol / vp.maxVol) * maxW;
|
|
344
|
+
// the Point-of-Control band gets a slightly stronger tint
|
|
345
|
+
ctx.fillStyle = Math.abs((b.lo + b.hi) / 2 - vp.poc) < (b.hi - b.lo) / 2 ? withAlpha(theme.line, 0.32) : color;
|
|
346
|
+
ctx.fillRect(plotW - wpx, yTop, wpx, bh);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
121
349
|
// indicator overlays (moving averages) — drawn from precomputed value-series when
|
|
122
350
|
// supplied (the Chart controller precomputes them on data change), else computed
|
|
123
351
|
// here from the indicator config for pure/standalone callers. Series align to the
|
|
124
352
|
// 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
|
-
}));
|
|
353
|
+
const overlays = input.overlays ?? resolveOverlays(candles, input.indicators ?? []);
|
|
130
354
|
if (overlays.length) {
|
|
131
355
|
ctx.lineWidth = 1.3;
|
|
132
356
|
for (const ov of overlays) {
|
|
@@ -152,46 +376,61 @@ export function draw(input) {
|
|
|
152
376
|
}
|
|
153
377
|
ctx.lineWidth = 1;
|
|
154
378
|
}
|
|
379
|
+
// user drawings (trendlines / horizontal lines), clipped to the price pane
|
|
380
|
+
const drawings = input.drawings ?? [];
|
|
381
|
+
if (drawings.length || input.drawPreview) {
|
|
382
|
+
ctx.save();
|
|
383
|
+
ctx.beginPath();
|
|
384
|
+
ctx.rect(0, pads.top, plotW, priceH);
|
|
385
|
+
ctx.clip();
|
|
386
|
+
for (const d of drawings)
|
|
387
|
+
drawDrawing(ctx, d, xOfTime, yOf, plotW, theme, d.id === input.selectedDrawing);
|
|
388
|
+
if (input.drawPreview)
|
|
389
|
+
drawDrawing(ctx, input.drawPreview, xOfTime, yOf, plotW, theme, false);
|
|
390
|
+
ctx.restore();
|
|
391
|
+
}
|
|
155
392
|
// last-price line + tag
|
|
156
393
|
const last = vis[n - 1];
|
|
157
394
|
const ly = yOf(last.c);
|
|
158
395
|
const lc = last.c >= last.o ? theme.up : theme.down;
|
|
159
396
|
ctx.strokeStyle = lc;
|
|
160
|
-
ctx.setLineDash([
|
|
397
|
+
ctx.setLineDash([3, 3]);
|
|
161
398
|
ctx.beginPath();
|
|
162
|
-
ctx.moveTo(0, ly);
|
|
163
|
-
ctx.lineTo(plotW, ly);
|
|
399
|
+
ctx.moveTo(0, crisp(ly));
|
|
400
|
+
ctx.lineTo(plotW, crisp(ly));
|
|
164
401
|
ctx.stroke();
|
|
165
402
|
ctx.setLineDash([]);
|
|
166
403
|
ctx.fillStyle = lc;
|
|
167
404
|
ctx.fillRect(plotW, ly - 8, pads.right, 16);
|
|
168
405
|
ctx.fillStyle = theme.tagText;
|
|
169
406
|
ctx.textAlign = "left";
|
|
170
|
-
ctx.fillText(
|
|
171
|
-
// crosshair + axis labels
|
|
172
|
-
|
|
407
|
+
ctx.fillText(fmtPrice(last.c), plotW + 6, ly);
|
|
408
|
+
// crosshair + axis labels. `active` is the candle returned to onCrosshair — always the
|
|
409
|
+
// RAW candle (real OHLC), even in heikin mode where the drawn body is smoothed.
|
|
410
|
+
let active = candles[start + n - 1];
|
|
173
411
|
if (hover && hover.x < plotW && hover.y > 0 && hover.y < H - pads.bottom) {
|
|
174
|
-
|
|
175
|
-
|
|
412
|
+
// invert xOf (which now accounts for rightGap whitespace); clamp into the data range.
|
|
413
|
+
const idx = Math.max(0, Math.min(n - 1, Math.round((hover.x - xOf(0)) / cw)));
|
|
414
|
+
active = candles[start + idx];
|
|
176
415
|
const cx = xOf(idx);
|
|
177
416
|
ctx.strokeStyle = theme.crosshair;
|
|
178
417
|
ctx.setLineDash([3, 3]);
|
|
179
418
|
ctx.beginPath();
|
|
180
419
|
ctx.moveTo(cx, pads.top);
|
|
181
|
-
ctx.lineTo(cx,
|
|
420
|
+
ctx.lineTo(cx, chartBot);
|
|
182
421
|
ctx.moveTo(0, hover.y);
|
|
183
422
|
ctx.lineTo(plotW, hover.y);
|
|
184
423
|
ctx.stroke();
|
|
185
424
|
ctx.setLineDash([]);
|
|
186
425
|
if (hover.y < pads.top + priceH) {
|
|
187
|
-
const pv =
|
|
426
|
+
const pv = priceOfY(hover.y);
|
|
188
427
|
ctx.fillStyle = theme.axisTagBg;
|
|
189
428
|
ctx.fillRect(plotW, hover.y - 8, pads.right, 16);
|
|
190
429
|
ctx.fillStyle = theme.axisTagText;
|
|
191
430
|
ctx.textAlign = "left";
|
|
192
|
-
ctx.fillText(
|
|
431
|
+
ctx.fillText(fmtPrice(pv), plotW + 6, hover.y);
|
|
193
432
|
}
|
|
194
|
-
const tl =
|
|
433
|
+
const tl = fmtTime(active.time, interval);
|
|
195
434
|
const tw = ctx.measureText(tl).width + 12;
|
|
196
435
|
ctx.fillStyle = theme.axisTagBg;
|
|
197
436
|
ctx.fillRect(cx - tw / 2, H - pads.bottom, tw, pads.bottom);
|
|
@@ -201,3 +440,234 @@ export function draw(input) {
|
|
|
201
440
|
}
|
|
202
441
|
return active;
|
|
203
442
|
}
|
|
443
|
+
/** Render one drawing (and its selection handles) using the current frame's projection. */
|
|
444
|
+
function drawDrawing(ctx, d, xOfTime, yOfPrice, plotW, theme, selected) {
|
|
445
|
+
const color = d.color ?? theme.line;
|
|
446
|
+
ctx.strokeStyle = color;
|
|
447
|
+
ctx.lineWidth = selected ? 2.5 : 1.5;
|
|
448
|
+
const pts = [];
|
|
449
|
+
if (d.type === "fib" && d.b) {
|
|
450
|
+
// Retracement levels between the two anchor prices; lines extend to the right edge.
|
|
451
|
+
const ax = xOfTime(d.a.time);
|
|
452
|
+
const bx = xOfTime(d.b.time);
|
|
453
|
+
const xL = Math.min(ax, bx);
|
|
454
|
+
const p0 = d.a.price;
|
|
455
|
+
const p1 = d.b.price;
|
|
456
|
+
ctx.textAlign = "left";
|
|
457
|
+
for (const r of FIB_LEVELS) {
|
|
458
|
+
const price = p0 + (p1 - p0) * r;
|
|
459
|
+
const y = yOfPrice(price);
|
|
460
|
+
ctx.strokeStyle = withAlpha(color, r === 0 || r === 1 ? 0.9 : 0.55);
|
|
461
|
+
ctx.beginPath();
|
|
462
|
+
ctx.moveTo(xL, y);
|
|
463
|
+
ctx.lineTo(plotW, y);
|
|
464
|
+
ctx.stroke();
|
|
465
|
+
ctx.fillStyle = withAlpha(color, 0.95);
|
|
466
|
+
ctx.fillText(`${(r * 100).toFixed(1)}% ${formatValue(price)}`, xL + 4, y - 6);
|
|
467
|
+
}
|
|
468
|
+
pts.push({ x: ax, y: yOfPrice(p0) }, { x: bx, y: yOfPrice(p1) });
|
|
469
|
+
}
|
|
470
|
+
else if (d.type === "rect" && d.b) {
|
|
471
|
+
const ax = xOfTime(d.a.time);
|
|
472
|
+
const ay = yOfPrice(d.a.price);
|
|
473
|
+
const bx = xOfTime(d.b.time);
|
|
474
|
+
const by = yOfPrice(d.b.price);
|
|
475
|
+
const x = Math.min(ax, bx);
|
|
476
|
+
const y = Math.min(ay, by);
|
|
477
|
+
const w = Math.abs(bx - ax);
|
|
478
|
+
const h = Math.abs(by - ay);
|
|
479
|
+
ctx.fillStyle = withAlpha(color, 0.12);
|
|
480
|
+
ctx.fillRect(x, y, w, h);
|
|
481
|
+
ctx.strokeStyle = color;
|
|
482
|
+
ctx.strokeRect(x, y, w, h);
|
|
483
|
+
pts.push({ x: ax, y: ay }, { x: bx, y: by });
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
ctx.beginPath();
|
|
487
|
+
if (d.type === "hline") {
|
|
488
|
+
const y = yOfPrice(d.a.price);
|
|
489
|
+
ctx.moveTo(0, y);
|
|
490
|
+
ctx.lineTo(plotW, y);
|
|
491
|
+
pts.push({ x: plotW / 2, y });
|
|
492
|
+
}
|
|
493
|
+
else if (d.b) {
|
|
494
|
+
const ax = xOfTime(d.a.time);
|
|
495
|
+
const ay = yOfPrice(d.a.price);
|
|
496
|
+
const bx = xOfTime(d.b.time);
|
|
497
|
+
const by = yOfPrice(d.b.price);
|
|
498
|
+
ctx.moveTo(ax, ay);
|
|
499
|
+
ctx.lineTo(bx, by);
|
|
500
|
+
pts.push({ x: ax, y: ay }, { x: bx, y: by });
|
|
501
|
+
}
|
|
502
|
+
ctx.stroke();
|
|
503
|
+
}
|
|
504
|
+
if (selected) {
|
|
505
|
+
ctx.fillStyle = color;
|
|
506
|
+
for (const p of pts)
|
|
507
|
+
ctx.fillRect(p.x - 3, p.y - 3, 6, 6);
|
|
508
|
+
}
|
|
509
|
+
ctx.lineWidth = 1;
|
|
510
|
+
}
|
|
511
|
+
/** Default oscillator colors. */
|
|
512
|
+
const OSC_COLORS = { rsi: "#ab47bc", macdLine: "#2962ff", macdSignal: "#ff9800", stochK: "#2962ff", stochD: "#ff9800", atr: "#26a69a" };
|
|
513
|
+
/** Last non-null value of a series (for the pane's inline readout). */
|
|
514
|
+
function lastDefined(s) {
|
|
515
|
+
for (let i = s.length - 1; i >= 0; i--)
|
|
516
|
+
if (s[i] != null)
|
|
517
|
+
return s[i];
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
/** Stroke a value-series across the visible window, lifting the pen across null gaps. */
|
|
521
|
+
function strokeSeries(ctx, series, env, yOf, color) {
|
|
522
|
+
ctx.strokeStyle = color;
|
|
523
|
+
ctx.lineWidth = 1.3;
|
|
524
|
+
ctx.beginPath();
|
|
525
|
+
let pen = false;
|
|
526
|
+
for (let j = 0; j < env.n; j++) {
|
|
527
|
+
const v = series[env.start + j];
|
|
528
|
+
if (v == null) {
|
|
529
|
+
pen = false;
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
const x = env.xOf(j);
|
|
533
|
+
const y = yOf(v);
|
|
534
|
+
if (pen)
|
|
535
|
+
ctx.lineTo(x, y);
|
|
536
|
+
else {
|
|
537
|
+
ctx.moveTo(x, y);
|
|
538
|
+
pen = true;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
ctx.stroke();
|
|
542
|
+
ctx.lineWidth = 1;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Render one oscillator sub-pane (RSI band or MACD) in the horizontal strip [top, top+paneH].
|
|
546
|
+
* Closes are read from the FULL candle array so values at the left edge of the view are correct.
|
|
547
|
+
*/
|
|
548
|
+
function drawPane(ctx, osc, paneH, top, env) {
|
|
549
|
+
const { candles, start, n, xOf, bw, plotW, theme } = env;
|
|
550
|
+
const closes = candles.map((c) => c.c);
|
|
551
|
+
const padY = 8;
|
|
552
|
+
const innerTop = top + padY;
|
|
553
|
+
const innerH = Math.max(1, paneH - padY * 2);
|
|
554
|
+
// separator rule at the pane's top edge
|
|
555
|
+
ctx.strokeStyle = theme.grid;
|
|
556
|
+
ctx.lineWidth = 1;
|
|
557
|
+
ctx.beginPath();
|
|
558
|
+
ctx.moveTo(0, top);
|
|
559
|
+
ctx.lineTo(plotW, top);
|
|
560
|
+
ctx.stroke();
|
|
561
|
+
ctx.textAlign = "left";
|
|
562
|
+
if (osc.type === "rsi") {
|
|
563
|
+
const period = osc.period ?? 14;
|
|
564
|
+
const series = rsi(closes, period);
|
|
565
|
+
const yOf = (v) => innerTop + innerH * (1 - v / 100);
|
|
566
|
+
// 70 / 30 guide rails
|
|
567
|
+
ctx.setLineDash([2, 3]);
|
|
568
|
+
for (const lvl of [70, 30]) {
|
|
569
|
+
const y = yOf(lvl);
|
|
570
|
+
ctx.strokeStyle = theme.grid;
|
|
571
|
+
ctx.beginPath();
|
|
572
|
+
ctx.moveTo(0, y);
|
|
573
|
+
ctx.lineTo(plotW, y);
|
|
574
|
+
ctx.stroke();
|
|
575
|
+
ctx.fillStyle = theme.axis;
|
|
576
|
+
ctx.fillText(String(lvl), plotW + 6, y);
|
|
577
|
+
}
|
|
578
|
+
ctx.setLineDash([]);
|
|
579
|
+
strokeSeries(ctx, series, env, yOf, osc.color ?? OSC_COLORS.rsi);
|
|
580
|
+
const lv = lastDefined(series);
|
|
581
|
+
ctx.fillStyle = theme.axis;
|
|
582
|
+
ctx.fillText(`RSI ${period}${lv != null ? " " + lv.toFixed(1) : ""}`, 6, top + padY);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (osc.type === "stoch") {
|
|
586
|
+
const kP = osc.period ?? 14;
|
|
587
|
+
const dP = osc.dPeriod ?? 3;
|
|
588
|
+
const sm = osc.smooth ?? 1;
|
|
589
|
+
const { k, d } = stochastic(candles, kP, dP, sm);
|
|
590
|
+
const yOf = (v) => innerTop + innerH * (1 - v / 100);
|
|
591
|
+
// 80 / 20 guide rails
|
|
592
|
+
ctx.setLineDash([2, 3]);
|
|
593
|
+
for (const lvl of [80, 20]) {
|
|
594
|
+
const y = yOf(lvl);
|
|
595
|
+
ctx.strokeStyle = theme.grid;
|
|
596
|
+
ctx.beginPath();
|
|
597
|
+
ctx.moveTo(0, y);
|
|
598
|
+
ctx.lineTo(plotW, y);
|
|
599
|
+
ctx.stroke();
|
|
600
|
+
ctx.fillStyle = theme.axis;
|
|
601
|
+
ctx.fillText(String(lvl), plotW + 6, y);
|
|
602
|
+
}
|
|
603
|
+
ctx.setLineDash([]);
|
|
604
|
+
strokeSeries(ctx, k, env, yOf, osc.color ?? OSC_COLORS.stochK);
|
|
605
|
+
strokeSeries(ctx, d, env, yOf, OSC_COLORS.stochD);
|
|
606
|
+
const lv = lastDefined(k);
|
|
607
|
+
ctx.fillStyle = theme.axis;
|
|
608
|
+
ctx.fillText(`STOCH ${kP} ${dP}${lv != null ? " " + lv.toFixed(1) : ""}`, 6, top + padY);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (osc.type === "atr") {
|
|
612
|
+
const period = osc.period ?? 14;
|
|
613
|
+
const series = atr(candles, period);
|
|
614
|
+
// auto-scale to the visible ATR range (price units, always ≥ 0)
|
|
615
|
+
let lo = Infinity;
|
|
616
|
+
let hi = -Infinity;
|
|
617
|
+
for (let j = 0; j < n; j++) {
|
|
618
|
+
const v = series[start + j];
|
|
619
|
+
if (v != null) {
|
|
620
|
+
lo = Math.min(lo, v);
|
|
621
|
+
hi = Math.max(hi, v);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
if (!isFinite(lo)) {
|
|
625
|
+
lo = 0;
|
|
626
|
+
hi = 1;
|
|
627
|
+
}
|
|
628
|
+
const padR = (hi - lo) * 0.1 || hi * 0.1 || 1;
|
|
629
|
+
lo = Math.max(0, lo - padR);
|
|
630
|
+
hi += padR;
|
|
631
|
+
const rng = hi - lo || 1;
|
|
632
|
+
const yOf = (v) => innerTop + innerH * (1 - (v - lo) / rng);
|
|
633
|
+
strokeSeries(ctx, series, env, yOf, osc.color ?? OSC_COLORS.atr);
|
|
634
|
+
const lv = lastDefined(series);
|
|
635
|
+
ctx.fillStyle = theme.axis;
|
|
636
|
+
ctx.fillText(`ATR ${period}${lv != null ? " " + formatValue(lv) : ""}`, 6, top + padY);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
// MACD: histogram + macd line + signal line, symmetric around zero
|
|
640
|
+
const fast = osc.fast ?? 12;
|
|
641
|
+
const slow = osc.slow ?? 26;
|
|
642
|
+
const signal = osc.signal ?? 9;
|
|
643
|
+
const { macd: line, signal: sig, hist } = macd(closes, fast, slow, signal);
|
|
644
|
+
let absMax = 0;
|
|
645
|
+
for (let j = 0; j < n; j++) {
|
|
646
|
+
for (const s of [line, sig, hist]) {
|
|
647
|
+
const v = s[start + j];
|
|
648
|
+
if (v != null)
|
|
649
|
+
absMax = Math.max(absMax, Math.abs(v));
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
absMax = absMax || 1;
|
|
653
|
+
const yOf = (v) => innerTop + innerH * (1 - (v + absMax) / (2 * absMax));
|
|
654
|
+
const zy = yOf(0);
|
|
655
|
+
ctx.strokeStyle = theme.grid;
|
|
656
|
+
ctx.beginPath();
|
|
657
|
+
ctx.moveTo(0, zy);
|
|
658
|
+
ctx.lineTo(plotW, zy);
|
|
659
|
+
ctx.stroke();
|
|
660
|
+
for (let j = 0; j < n; j++) {
|
|
661
|
+
const v = hist[start + j];
|
|
662
|
+
if (v == null)
|
|
663
|
+
continue;
|
|
664
|
+
const x = xOf(j);
|
|
665
|
+
const y = yOf(v);
|
|
666
|
+
ctx.fillStyle = v >= 0 ? theme.volUp : theme.volDown;
|
|
667
|
+
ctx.fillRect(x - bw / 2, Math.min(y, zy), bw, Math.max(1, Math.abs(y - zy)));
|
|
668
|
+
}
|
|
669
|
+
strokeSeries(ctx, line, env, yOf, osc.color ?? OSC_COLORS.macdLine);
|
|
670
|
+
strokeSeries(ctx, sig, env, yOf, OSC_COLORS.macdSignal);
|
|
671
|
+
ctx.fillStyle = theme.axis;
|
|
672
|
+
ctx.fillText(`MACD ${fast} ${slow} ${signal}`, 6, top + padY);
|
|
673
|
+
}
|