@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.
@@ -1,5 +1,120 @@
1
- import { formatValue, formatTime } from "./format";
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
- const priceH = H - pads.bottom - pads.top - volH;
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
- ctx.fillStyle = theme.axis;
24
- ctx.textAlign = "center";
25
- ctx.fillText("no priced trades yet", plotW / 2, H / 2);
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
- const count = Math.min(view.count, candles.length);
29
- const end = candles.length - view.offset;
30
- const start = Math.max(0, end - count);
31
- const vis = candles.slice(start, end);
32
- const n = vis.length;
33
- // Cap the slot width and right-anchor a few candles cluster on the right with empty
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;
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
- const yOf = (v) => pads.top + priceH * (1 - (v - lo) / rng);
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 <= 5; i++) {
60
- const v = lo + (rng * i) / 5;
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
- ctx.fillText(formatValue(v), plotW + 6, y);
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, volBot);
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(formatTime(vis[j].time, interval), x, H - pads.bottom / 2);
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, 14));
82
- // volume panel
83
- for (let j = 0; j < n; j++) {
84
- const c = vis[j];
85
- const x = xOf(j);
86
- ctx.fillStyle = c.c >= c.o ? theme.volUp : theme.volDown;
87
- ctx.fillRect(x - bw / 2, vy(c.vol), bw, volBot - vy(c.vol));
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 = 1.5;
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([2, 2]);
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(formatValue(last.c), plotW + 6, ly);
171
- // crosshair + axis labels
172
- let active = last;
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
- const idx = Math.max(0, Math.min(n - 1, Math.round((hover.x - (plotW - (n - 1) * cw - cw / 2)) / cw)));
175
- active = vis[idx];
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, volBot);
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 = lo + rng * (1 - (hover.y - pads.top) / priceH);
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(formatValue(pv), plotW + 6, hover.y);
431
+ ctx.fillText(fmtPrice(pv), plotW + 6, hover.y);
193
432
  }
194
- const tl = formatTime(active.time, interval);
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
+ }