@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.
@@ -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
@@ -1,5 +1,107 @@
1
- import { formatValue, formatTime } from "./format";
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
- const priceH = H - pads.bottom - pads.top - volH;
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
- 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;
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
- const yOf = (v) => pads.top + priceH * (1 - (v - lo) / rng);
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 <= 5; i++) {
60
- const v = lo + (rng * i) / 5;
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
- ctx.fillText(formatValue(v), plotW + 6, y);
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, volBot);
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(formatTime(vis[j].time, interval), x, H - pads.bottom / 2);
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, 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));
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 = 1.5;
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([2, 2]);
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(formatValue(last.c), plotW + 6, ly);
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, volBot);
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 = lo + rng * (1 - (hover.y - pads.top) / priceH);
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(formatValue(pv), plotW + 6, hover.y);
340
+ ctx.fillText(fmtPrice(pv), plotW + 6, hover.y);
193
341
  }
194
- const tl = formatTime(active.time, interval);
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
+ }
@@ -1,3 +1,7 @@
1
1
  import type { ChartTheme } from "./types";
2
- /** Default dark theme (TradingView-ish palette). */
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>;
@@ -1,14 +1,35 @@
1
- /** Default dark theme (TradingView-ish palette). */
1
+ /** Default dark theme — a refined, modern crypto-terminal palette. */
2
2
  export const DEFAULT_THEME = {
3
- up: "#26a69a",
4
- down: "#ef5350",
5
- line: "#2962ff",
6
- grid: "#1c2030",
7
- axis: "#787b86",
8
- crosshair: "#9598a1",
9
- volUp: "rgba(38,166,154,.5)",
10
- volDown: "rgba(239,83,80,.5)",
11
- tagText: "#0a0a0a",
12
- axisTagBg: "#2a2e39",
13
- axisTagText: "#d1d4dc",
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
  };