wick-charts 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,6 +21,7 @@ npm install wick-charts
21
21
  - [Reading chart state](#reading-chart-state)
22
22
  - [Loading more history on demand](#loading-more-history-on-demand)
23
23
  - [Extending: plugins](#extending-plugins)
24
+ - [Multi-pane indicators](#multi-pane-indicators)
24
25
  - [Cleanup](#cleanup)
25
26
  - [Architecture](#architecture)
26
27
  - [Development](#development)
@@ -354,6 +355,58 @@ or validated for uniqueness, just compared with `===` when you call `setPluginVi
354
355
  plugin with no `id` still works exactly as before; it just can't be targeted that way, only by
355
356
  holding onto its reference and calling `removePlugin` directly.
356
357
 
358
+ ### Multi-pane indicators
359
+
360
+ An oscillator like RSI or MACD has a value domain that has nothing to do with price (RSI's
361
+ fixed `[0, 100]`, say) — drawing it as a `ChartPlugin` overlay in the price pane would either
362
+ get swamped by the candles or need a hand-rolled rescale hack. `addPane` reserves a horizontal
363
+ strip below the main price pane with its own independent value axis, and a plugin's `paneId`
364
+ routes its `draw()` there instead of the price pane:
365
+
366
+ ```ts
367
+ chart.addPane({
368
+ id: 'rsi',
369
+ heightRatio: 0.25, // share of the total plotting height this pane occupies; defaults to 0.25
370
+ getValueRange: () => ({ min: 0, max: 100 }), // this pane's own value-axis domain, called every frame
371
+ });
372
+
373
+ chart.addPlugin({
374
+ paneId: 'rsi', // routes this plugin into the 'rsi' pane instead of the price pane
375
+ draw({ ctx, allPoints, visibleStartIndex, visibleEndIndex, xForIndex, yForValue }) {
376
+ const rsi = computeRsi(allPoints.map((c) => c.close), 14); // your own indicator math — see below
377
+ ctx.strokeStyle = '#bb86fc';
378
+ ctx.beginPath();
379
+ for (let i = visibleStartIndex; i < visibleEndIndex; i++) {
380
+ const x = xForIndex(i);
381
+ const y = yForValue(rsi[i]); // mapped against *this pane's* [0, 100], not price
382
+ i === visibleStartIndex ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
383
+ }
384
+ ctx.stroke();
385
+ },
386
+ });
387
+ ```
388
+
389
+ The pane itself draws nothing but a separator line and its own right-side axis (ticks resolved
390
+ from `getValueRange()`, styled through the same `axis` options as the price axis) — exactly the
391
+ same "core provides layout, the app provides the math" split plugins already use for indicator
392
+ *overlays*, just for indicators that need their own scale instead of sharing the price one. See
393
+ `demo/index.html` for a complete worked example (an RSI pane built entirely in the demo's own
394
+ code, same as the moving-average overlay above it — see "Indicators" below for why neither
395
+ ships in the library itself).
396
+
397
+ Every declared pane stacks below the previous one in call order, each shrinking the main
398
+ pane's share of the plotting height; `removePane(id)` gives that space back. A plugin whose
399
+ `paneId` doesn't match any currently-added pane falls back to drawing in the price pane rather
400
+ than silently disappearing — useful if you remove a pane before removing the plugins that
401
+ targeted it. `getPanes()` returns every declared pane's `id`/`heightRatio`, the same
402
+ snapshot-for-building-a-management-UI idea `getPlugins()` already offers for plugins.
403
+
404
+ The hover crosshair's dashed vertical line spans every pane so a hovered candle lines up
405
+ across the whole stack; the horizontal line, price-label chip, and OHLC legend stay scoped to
406
+ the price pane — an indicator pane's own hover readout, if you want one, is something its own
407
+ plugin draws (it has the same `xForIndex`/`yForValue` a price-pane plugin does, just mapped
408
+ against that pane's own value domain and pixel rect).
409
+
357
410
  ### Cleanup
358
411
 
359
412
  Call `chart.destroy()` when you're done with a chart (component unmount, etc.) — it removes a
@@ -469,6 +522,18 @@ two things `draw()` alone can't give it, both added specifically to make that bu
469
522
  `ChartRenderer.render` uses, recomputed on demand since pointer events happen between
470
523
  frames, not during one.
471
524
 
525
+ `ChartPlugin.paneId` is a third, narrower option on top of the two above — it doesn't change
526
+ what a plugin implements, only which pane's `PluginRenderApi` it receives. `ChartRenderer`
527
+ builds one `PluginRenderApi` per pane per frame (`buildPluginApi`, sharing a `FrameGeometry` for
528
+ the parts every pane has in common — the time axis and the frame-ended guard) and routes each
529
+ plugin to the one matching its `paneId`, defaulting to the main pane. A pane-targeted plugin's
530
+ `yForValue`/`valueForY` are pane-local (mapped against that pane's own `getValueRange()`) but
531
+ still return/accept absolute canvas pixels, exactly like the main pane's — so a plugin never
532
+ needs to know whether it's drawing in the price pane or a declared one, only which `paneId` it
533
+ was given. Pointer gestures (`onPointerDown`/etc.) aren't pane-aware yet: they're still offered
534
+ chart-wide the same way regardless of any plugin's `paneId`, matching the mechanism's original
535
+ scope (drawing tools on the price series) rather than a limitation specific to panes.
536
+
472
537
  ### Indicators (moving averages, Bollinger Bands, ...): deliberately not included
473
538
 
474
539
  wick-charts ships the extension point (`ChartPlugin`, `allPoints`, `xForIndex`/`yForValue`)
@@ -537,8 +602,11 @@ it. Candlestick is the only registered series type so far; the plugin extension
537
602
  overlays plus, now, claimable pointer gestures for interactive tools — see "Plugins" above)
538
603
  has no built-in users (see "Indicators" above for why) beyond `demo/index.html`'s example. No
539
604
  concrete drawing tool ships yet, only the mechanism a trend line or similar would be built
540
- on. No multi-pane support yet (volume shares the candlestick pane rather than getting its
541
- own). See [CHANGELOG.md](./CHANGELOG.md) for what shipped in each release.
605
+ on. `addPane`/`removePane` let a plugin-drawn indicator (RSI, MACD, ...) reserve its own
606
+ horizontal strip with an independent value axis see "Multi-pane indicators" above; volume
607
+ still shares the candlestick pane rather than getting its own, since it draws through the
608
+ series itself, not a pane-targeted plugin. See [CHANGELOG.md](./CHANGELOG.md) for what shipped
609
+ in each release.
542
610
 
543
611
  ## License
544
612
 
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { DataLoader } from './dataSource.js';
2
2
  import type { ChartPlugin } from './plugins/types.js';
3
3
  import type { CandlestickStyle } from './series/candlestick.js';
4
- import type { Candle, WickChartOptions, SeriesPoint, ValueRange } from './types.js';
5
- export type { BusinessDay, Candle, WickChartOptions, WickTime, SeriesPoint, UnixMillis, ValueRange } from './types.js';
4
+ import type { Candle, PaneOptions, WickChartOptions, SeriesPoint, ValueRange } from './types.js';
5
+ export type { BusinessDay, Candle, PaneOptions, WickChartOptions, WickTime, SeriesPoint, UnixMillis, ValueRange, } from './types.js';
6
6
  export type { DataLoader, DataRequest } from './dataSource.js';
7
7
  export type { ChartPlugin, ChartPointerEvent, PluginRenderApi } from './plugins/types.js';
8
8
  export { distanceToSegment, hitTestPoint, hitTestSegment } from './hitTest.js';
@@ -41,6 +41,11 @@ export declare class WickChart<TPoint extends SeriesPoint = Candle> {
41
41
  * motionless while the pointer moves within that candle's column). */
42
42
  private hoverY;
43
43
  private plugins;
44
+ /** Indicator/oscillator panes declared via `addPane`, defaults already
45
+ * resolved — see `ResolvedPaneOptions`. Empty until an app adds one; a
46
+ * chart that never calls `addPane` renders exactly as it did before
47
+ * panes existed (single price pane filling the whole plotting height). */
48
+ private panes;
44
49
  /** The plugin whose `onPointerDown` returned `true` for the pointer
45
50
  * currently down, or `null` when no plugin has claimed the current
46
51
  * gesture (the common case — the chart handles it itself). */
@@ -95,6 +100,35 @@ export declare class WickChart<TPoint extends SeriesPoint = Candle> {
95
100
  * and re-renders. A no-op, not an error, if nothing matches — plugins
96
101
  * with no `id` set are never matched. */
97
102
  setPluginVisible(id: string, visible: boolean): this;
103
+ /**
104
+ * Reserves a horizontal strip below the main price pane (and below any
105
+ * previously-added pane — panes stack in call order) for an indicator or
106
+ * oscillator, drawn entirely by `ChartPlugin`s registered with a
107
+ * matching `paneId` (see `ChartPlugin.paneId`). The pane itself computes
108
+ * nothing: `options.getValueRange` supplies whatever value-axis domain
109
+ * makes sense for what will be plotted into it (a fixed `[0, 100]` for
110
+ * RSI, an auto-fit range closed over a MACD series a plugin already
111
+ * tracks, ...) — the same "core provides layout, the app provides the
112
+ * math" split `addPlugin` already uses for indicator overlays on the
113
+ * main pane. A no-op on layout until at least one plugin actually
114
+ * targets this pane's `id`; an empty pane still reserves its space and
115
+ * draws its own axis, just with nothing inside it.
116
+ */
117
+ addPane(options: PaneOptions): this;
118
+ /** Removes a previously-added pane by `id` and re-renders. A no-op, not
119
+ * an error, if nothing matches. Plugins still targeting the removed
120
+ * pane's `id` via `ChartPlugin.paneId` fall back to drawing in the main
121
+ * pane rather than being silently dropped — see the doc comment on
122
+ * `ChartPlugin.paneId`. */
123
+ removePane(id: string): this;
124
+ /** Every currently-declared pane's `id` and resolved `heightRatio`, in
125
+ * stacking order (top to bottom, main pane excluded since it always
126
+ * exists and always sits first) — for an app building a management UI
127
+ * around indicator panes without maintaining its own parallel list. */
128
+ getPanes(): readonly {
129
+ id: string;
130
+ heightRatio: number;
131
+ }[];
98
132
  render(): void;
99
133
  /** Coalesces render() calls into at most one per animation frame. Mouse
100
134
  * events (drag, wheel) can fire far faster than the display refreshes —
@@ -163,6 +197,17 @@ export declare class WickChart<TPoint extends SeriesPoint = Candle> {
163
197
  * on-demand (`valueForY`, dispatched pointer events) rather than only
164
198
  * during `render()`. `null` when there's nothing to compute one from. */
165
199
  private frameValueRange;
200
+ /** The main price pane's own pixel height for the *next* render — as
201
+ * opposed to `this.renderer.chartHeight`, which is the whole pane
202
+ * stack's height (main pane plus every declared indicator pane below
203
+ * it). Every pixel<->value conversion outside of `ChartRenderer.render`
204
+ * itself (price-axis drag-to-scale, `ChartPointerEvent.value`, ...) is
205
+ * about the main pane specifically — pointer gestures and price-axis
206
+ * dragging aren't pane-aware yet, so they only ever mean the main price
207
+ * pane — and has to divide by this, not the full stack, or dragging
208
+ * would run at the wrong speed (or a hovered value would come out
209
+ * wrong) as soon as an app adds its first indicator pane. */
210
+ private mainPaneHeight;
166
211
  /** y pixel -> value in the range the next render would use. `null` if
167
212
  * there's no data or no usable chart area to compute one against — see
168
213
  * `ChartPointerEvent.value`. */
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { mergeSeriesPoints } from './mergeSeries.js';
2
+ import { computePaneLayout } from './paneLayout.js';
2
3
  import { ChartRenderer } from './renderer.js';
3
4
  import { getSeries } from './series/registry.js';
4
5
  import { toUnixSeconds } from './time.js';
@@ -26,6 +27,14 @@ const DEFAULT_VISIBLE_POINTS = 120;
26
27
  /** How close (in points) the visible window has to get to either edge of
27
28
  * the loaded data before `setDataLoader`'s loader is asked for more. */
28
29
  const DEFAULT_LOAD_THRESHOLD = 20;
30
+ /** `PaneOptions.heightRatio`'s default — see `addPane`. */
31
+ const DEFAULT_PANE_HEIGHT_RATIO = 0.25;
32
+ /** `PaneOptions.getValueRange`'s default — see `addPane`. Fixed at
33
+ * `[0, 1]` rather than auto-fitting to anything, since the pane has no
34
+ * data of its own to fit to: only a plugin drawing into it knows what
35
+ * range makes sense, which is exactly why `getValueRange` exists to be
36
+ * overridden. */
37
+ const DEFAULT_PANE_VALUE_RANGE = { min: 0, max: 1 };
29
38
  /** How long a single finger has to stay down before a still-in-progress
30
39
  * 'pan' touch switches to 'scrub' mode (touch has no hover, so this is its
31
40
  * substitute — hold to inspect a point instead of panning past it). */
@@ -57,6 +66,11 @@ export class WickChart {
57
66
  * motionless while the pointer moves within that candle's column). */
58
67
  this.hoverY = null;
59
68
  this.plugins = [];
69
+ /** Indicator/oscillator panes declared via `addPane`, defaults already
70
+ * resolved — see `ResolvedPaneOptions`. Empty until an app adds one; a
71
+ * chart that never calls `addPane` renders exactly as it did before
72
+ * panes existed (single price pane filling the whole plotting height). */
73
+ this.panes = [];
60
74
  /** The plugin whose `onPointerDown` returned `true` for the pointer
61
75
  * currently down, or `null` when no plugin has claimed the current
62
76
  * gesture (the common case — the chart handles it itself). */
@@ -353,6 +367,46 @@ export class WickChart {
353
367
  this.scheduleRender();
354
368
  return this;
355
369
  }
370
+ /**
371
+ * Reserves a horizontal strip below the main price pane (and below any
372
+ * previously-added pane — panes stack in call order) for an indicator or
373
+ * oscillator, drawn entirely by `ChartPlugin`s registered with a
374
+ * matching `paneId` (see `ChartPlugin.paneId`). The pane itself computes
375
+ * nothing: `options.getValueRange` supplies whatever value-axis domain
376
+ * makes sense for what will be plotted into it (a fixed `[0, 100]` for
377
+ * RSI, an auto-fit range closed over a MACD series a plugin already
378
+ * tracks, ...) — the same "core provides layout, the app provides the
379
+ * math" split `addPlugin` already uses for indicator overlays on the
380
+ * main pane. A no-op on layout until at least one plugin actually
381
+ * targets this pane's `id`; an empty pane still reserves its space and
382
+ * draws its own axis, just with nothing inside it.
383
+ */
384
+ addPane(options) {
385
+ this.panes.push({
386
+ id: options.id,
387
+ heightRatio: options.heightRatio ?? DEFAULT_PANE_HEIGHT_RATIO,
388
+ getValueRange: options.getValueRange ?? (() => DEFAULT_PANE_VALUE_RANGE),
389
+ });
390
+ this.scheduleRender();
391
+ return this;
392
+ }
393
+ /** Removes a previously-added pane by `id` and re-renders. A no-op, not
394
+ * an error, if nothing matches. Plugins still targeting the removed
395
+ * pane's `id` via `ChartPlugin.paneId` fall back to drawing in the main
396
+ * pane rather than being silently dropped — see the doc comment on
397
+ * `ChartPlugin.paneId`. */
398
+ removePane(id) {
399
+ this.panes = this.panes.filter((pane) => pane.id !== id);
400
+ this.scheduleRender();
401
+ return this;
402
+ }
403
+ /** Every currently-declared pane's `id` and resolved `heightRatio`, in
404
+ * stacking order (top to bottom, main pane excluded since it always
405
+ * exists and always sits first) — for an app building a management UI
406
+ * around indicator panes without maintaining its own parallel list. */
407
+ getPanes() {
408
+ return this.panes.map(({ id, heightRatio }) => ({ id, heightRatio }));
409
+ }
356
410
  render() {
357
411
  this.renderer.render({
358
412
  sorted: this.sorted,
@@ -361,6 +415,7 @@ export class WickChart {
361
415
  hoverIndex: this.hoverIndex,
362
416
  hoverY: this.hoverY,
363
417
  plugins: this.plugins,
418
+ panes: this.panes,
364
419
  });
365
420
  this.maybeLoadMore();
366
421
  }
@@ -463,7 +518,7 @@ export class WickChart {
463
518
  // have opposite sign.
464
519
  this.viewport.pan(-deltaXDevice / slotWidth, this.sorted.length);
465
520
  }
466
- const chartHeight = this.renderer.chartHeight;
521
+ const chartHeight = this.mainPaneHeight();
467
522
  if (chartHeight > 0 && this.viewport.valueRangeOverride) {
468
523
  const deltaYDevice = deltaYCss * this.devicePixelScaleY();
469
524
  const { min, max } = this.viewport.valueRangeOverride;
@@ -597,12 +652,25 @@ export class WickChart {
597
652
  return null;
598
653
  return this.seriesDefinition.getValueRange(visible, this.viewport.valueScaleFactor);
599
654
  }
655
+ /** The main price pane's own pixel height for the *next* render — as
656
+ * opposed to `this.renderer.chartHeight`, which is the whole pane
657
+ * stack's height (main pane plus every declared indicator pane below
658
+ * it). Every pixel<->value conversion outside of `ChartRenderer.render`
659
+ * itself (price-axis drag-to-scale, `ChartPointerEvent.value`, ...) is
660
+ * about the main pane specifically — pointer gestures and price-axis
661
+ * dragging aren't pane-aware yet, so they only ever mean the main price
662
+ * pane — and has to divide by this, not the full stack, or dragging
663
+ * would run at the wrong speed (or a hovered value would come out
664
+ * wrong) as soon as an app adds its first indicator pane. */
665
+ mainPaneHeight() {
666
+ return computePaneLayout(this.panes, this.renderer.chartHeight).main.height;
667
+ }
600
668
  /** y pixel -> value in the range the next render would use. `null` if
601
669
  * there's no data or no usable chart area to compute one against — see
602
670
  * `ChartPointerEvent.value`. */
603
671
  valueForY(y) {
604
672
  const range = this.frameValueRange();
605
- const chartHeight = this.renderer.chartHeight;
673
+ const chartHeight = this.mainPaneHeight();
606
674
  if (!range || chartHeight <= 0)
607
675
  return null;
608
676
  return range.min + (1 - y / chartHeight) * (range.max - range.min);
@@ -630,7 +698,7 @@ export class WickChart {
630
698
  * `valueForY` returns `null` for. */
631
699
  yForValue(value) {
632
700
  const range = this.frameValueRange();
633
- const chartHeight = this.renderer.chartHeight;
701
+ const chartHeight = this.mainPaneHeight();
634
702
  if (!range || chartHeight <= 0)
635
703
  return null;
636
704
  return chartHeight * (1 - (value - range.min) / (range.max - range.min));
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Pure layout math for stacking a chart's panes vertically — no canvas, no
3
+ * DOM, fully unit-testable in isolation from `ChartRenderer`. The main
4
+ * (price) pane is never passed in here: it's whatever height remains after
5
+ * every declared pane's `heightRatio` share of the total plotting height
6
+ * (canvas height minus the time-axis strip) is subtracted, which is why it
7
+ * always exists even with zero declared panes.
8
+ */
9
+ /** One caller-declared pane's layout inputs — a subset of `PaneOptions`
10
+ * (see `src/types.ts`), kept separate so this module doesn't need to know
11
+ * about `getValueRange`/`valueRange` at all. */
12
+ export interface PaneLayoutInput {
13
+ id: string;
14
+ /** Fraction of the total plotting height this pane occupies, before
15
+ * clamping. Clamped into `[MIN_PANE_HEIGHT_RATIO, MAX_TOTAL_PANE_RATIO]`
16
+ * as a share of the whole stack — see `computePaneLayout`. */
17
+ heightRatio: number;
18
+ }
19
+ /** One pane's resolved pixel rect within the plotting area (i.e. relative
20
+ * to the top of the chart, above the time-axis strip — the same origin
21
+ * `ChartRenderer.chartHeight` already uses). */
22
+ export interface PaneRect {
23
+ id: string;
24
+ top: number;
25
+ height: number;
26
+ }
27
+ /**
28
+ * Resolves every declared pane's pixel rect plus the main pane's, stacked
29
+ * top (price pane) to bottom (declared panes, in call order) inside
30
+ * `totalHeight` px. Declared ratios are scaled down proportionally (not
31
+ * clamped one by one, which would silently change the relative sizing
32
+ * between panes) whenever their sum would leave the main pane below
33
+ * `MIN_PANE_HEIGHT_RATIO` of the stack.
34
+ */
35
+ export declare function computePaneLayout(panes: PaneLayoutInput[], totalHeight: number): {
36
+ main: PaneRect;
37
+ panes: PaneRect[];
38
+ };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Pure layout math for stacking a chart's panes vertically — no canvas, no
3
+ * DOM, fully unit-testable in isolation from `ChartRenderer`. The main
4
+ * (price) pane is never passed in here: it's whatever height remains after
5
+ * every declared pane's `heightRatio` share of the total plotting height
6
+ * (canvas height minus the time-axis strip) is subtracted, which is why it
7
+ * always exists even with zero declared panes.
8
+ */
9
+ /** Floor on any single pane's share of the stack, main pane included — a
10
+ * pane asked to render at near-zero height is worse than one slightly
11
+ * taller than requested; nothing usable can be drawn (axis ticks, a
12
+ * legible plot) below this. */
13
+ const MIN_PANE_HEIGHT_RATIO = 0.05;
14
+ /** Ceiling on how much of the total plotting height every declared pane
15
+ * *combined* may claim, leaving at least this much for the main pane even
16
+ * if the sum of requested ratios would otherwise consume it entirely. */
17
+ const MAX_TOTAL_PANE_RATIO = 0.8;
18
+ /**
19
+ * Resolves every declared pane's pixel rect plus the main pane's, stacked
20
+ * top (price pane) to bottom (declared panes, in call order) inside
21
+ * `totalHeight` px. Declared ratios are scaled down proportionally (not
22
+ * clamped one by one, which would silently change the relative sizing
23
+ * between panes) whenever their sum would leave the main pane below
24
+ * `MIN_PANE_HEIGHT_RATIO` of the stack.
25
+ */
26
+ export function computePaneLayout(panes, totalHeight) {
27
+ const height = Math.max(0, totalHeight);
28
+ if (panes.length === 0) {
29
+ return { main: { id: 'main', top: 0, height }, panes: [] };
30
+ }
31
+ const rawRatios = panes.map((p) => Math.max(MIN_PANE_HEIGHT_RATIO, p.heightRatio));
32
+ const rawTotal = rawRatios.reduce((sum, r) => sum + r, 0);
33
+ // Scale every declared pane down by the same factor if together they'd
34
+ // eat more than MAX_TOTAL_PANE_RATIO of the stack — proportional, so a
35
+ // pane asking for twice another's height still ends up twice as tall.
36
+ const scale = rawTotal > MAX_TOTAL_PANE_RATIO ? MAX_TOTAL_PANE_RATIO / rawTotal : 1;
37
+ const ratios = rawRatios.map((r) => r * scale);
38
+ let top = 0;
39
+ const mainHeight = height * (1 - ratios.reduce((sum, r) => sum + r, 0));
40
+ const main = { id: 'main', top, height: mainHeight };
41
+ top += mainHeight;
42
+ const rects = panes.map((pane, i) => {
43
+ const paneHeight = height * ratios[i];
44
+ const rect = { id: pane.id, top, height: paneHeight };
45
+ top += paneHeight;
46
+ return rect;
47
+ });
48
+ return { main, panes: rects };
49
+ }
@@ -117,6 +117,17 @@ export interface ChartPlugin<TPoint extends SeriesPoint = SeriesPoint> {
117
117
  * directly and call `chart.render()`.
118
118
  */
119
119
  visible?: boolean;
120
+ /**
121
+ * Routes this plugin's `draw()` into a specific pane instead of the main
122
+ * price pane — the id must match one passed to `WickChart.addPane` (see
123
+ * `PaneOptions.id` in `src/types.ts`). Omitted, or set to `'main'`,
124
+ * keeps today's behavior: the plugin draws in the main price pane. A
125
+ * `paneId` that doesn't match any currently-added pane is treated the
126
+ * same as `'main'` (a plugin never silently stops drawing just because
127
+ * its pane was removed before it was) — call `removePlugin` yourself if
128
+ * that's not what you want when a pane goes away.
129
+ */
130
+ paneId?: string;
120
131
  draw(api: PluginRenderApi<TPoint>): void;
121
132
  /**
122
133
  * Called on pointer down inside the chart's plotting area (not the
@@ -1,6 +1,6 @@
1
1
  import type { ChartPlugin } from './plugins/types.js';
2
2
  import type { SeriesDefinition } from './series/types.js';
3
- import type { WickChartOptions, SeriesPoint } from './types.js';
3
+ import type { ResolvedPaneOptions, WickChartOptions, SeriesPoint } from './types.js';
4
4
  import type { Viewport } from './viewport.js';
5
5
  export interface RenderInput<TPoint extends SeriesPoint> {
6
6
  /** Every point, sorted ascending by normalized time. */
@@ -16,6 +16,11 @@ export interface RenderInput<TPoint extends SeriesPoint> {
16
16
  * position rather than any property of the hovered point itself. */
17
17
  hoverY: number | null;
18
18
  plugins: ChartPlugin<TPoint>[];
19
+ /** Indicator/oscillator panes declared via `WickChart.addPane`, resolved
20
+ * (defaults applied) — see `ResolvedPaneOptions`. Empty by default, in
21
+ * which case the main pane alone fills the whole plotting height exactly
22
+ * as it did before panes existed. */
23
+ panes: ResolvedPaneOptions[];
19
24
  }
20
25
  /**
21
26
  * The chart engine's renderer: canvas lifecycle, axes, crosshair, and
@@ -53,11 +58,32 @@ export declare class ChartRenderer<TPoint extends SeriesPoint> {
53
58
  private axisFont;
54
59
  private legendFont;
55
60
  render(input: RenderInput<TPoint>): void;
61
+ /**
62
+ * Builds the `PluginRenderApi` for one pane — the main price pane or a
63
+ * declared indicator pane, identical shape either way — from that pane's
64
+ * own rect/value-domain/scale plus whatever `geometry` every pane shares
65
+ * for this frame (shared because there is only one time axis, and one
66
+ * frame-ended flag, for the whole stack; see `FrameGeometry`).
67
+ */
68
+ private buildPluginApi;
56
69
  /** The decimal precision `formatPrice` should use for the current price
57
70
  * range — shared by the axis ticks and the crosshair's price label so
58
71
  * both display the same value with the same rounding. */
59
72
  private currentPriceStep;
73
+ /**
74
+ * Draws one pane's right-side value axis: boundary line, horizontal grid
75
+ * lines, and tick labels. Used for both the main price pane and every
76
+ * indicator pane — `topOffset` shifts everything down by that pane's own
77
+ * position in the stack (0 for the main pane, which sits at the top), so
78
+ * `yScale` only ever has to know about its own pane-local [0, chartHeight]
79
+ * range and never about where that pane lives in the full canvas.
80
+ */
60
81
  private renderPriceAxis;
82
+ /** The horizontal rule separating an indicator pane from whatever sits
83
+ * above it (the main pane, or the previous indicator pane) — the same
84
+ * `axis.lineColor` boundary style `renderTimeAxis` already draws between
85
+ * the plotting area and the time-axis strip. */
86
+ private renderPaneSeparator;
61
87
  private renderTimeAxis;
62
88
  private renderCrosshairAndLegend;
63
89
  /** The OHLC(+volume) tooltip — floats near the hovered pixel like a
package/dist/renderer.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { formatAxisLabel, formatHoverTime, pickTickIndices } from './axis.js';
2
2
  import { createScale } from './hybridScale.js';
3
+ import { computePaneLayout } from './paneLayout.js';
3
4
  import { formatPrice, niceTicks } from './priceAxis.js';
4
5
  const DEFAULT_BACKGROUND = 'transparent';
5
6
  const DEFAULT_FONT = {
@@ -81,18 +82,25 @@ export class ChartRenderer {
81
82
  }
82
83
  render(input) {
83
84
  const { ctx, canvas, background, seriesDefinition, style } = this;
84
- const { sorted, times, viewport, hoverIndex, hoverY, plugins } = input;
85
+ const { sorted, times, viewport, hoverIndex, hoverY, plugins, panes } = input;
85
86
  const width = canvas.width;
86
87
  const height = canvas.height;
87
88
  const chartWidth = this.chartWidth;
88
- const chartHeight = this.chartHeight;
89
+ // Full stack height: the main price pane plus every declared indicator
90
+ // pane below it. `this.chartHeight` predates panes and named what's
91
+ // now only true with zero of them — kept as the property name (public
92
+ // API reads it through) but renamed locally here since most of this
93
+ // method cares about one pane's height, not the stack's.
94
+ const stackHeight = this.chartHeight;
89
95
  ctx.clearRect(0, 0, width, height);
90
96
  if (background !== 'transparent') {
91
97
  ctx.fillStyle = background;
92
98
  ctx.fillRect(0, 0, width, height);
93
99
  }
94
- if (sorted.length === 0 || chartWidth <= 0 || chartHeight <= 0)
100
+ if (sorted.length === 0 || chartWidth <= 0 || stackHeight <= 0)
95
101
  return;
102
+ const { main: mainRect, panes: paneRects } = computePaneLayout(panes, stackHeight);
103
+ const chartHeight = mainRect.height;
96
104
  const startIdx = Math.max(0, Math.floor(viewport.startIndex));
97
105
  const endIdx = Math.min(sorted.length, Math.ceil(viewport.endIndex));
98
106
  const visible = sorted.slice(startIdx, endIdx);
@@ -105,51 +113,78 @@ export class ChartRenderer {
105
113
  // Whichever it picks, `dispose()` must run once we're done reading
106
114
  // from it (a no-op on the JS path, a real WASM memory free otherwise).
107
115
  const { scale: yScale, dispose: disposeYScale } = createScale(valueMin, valueMax, chartHeight, 0, visible.length);
108
- // Flipped in `finally`, right before `disposeYScale()` frees the WASM
109
- // scale's backing memory (a no-op on the JS path). Guards `yForValue`
110
- // below so a plugin that stashes it and calls it later gets a clear
111
- // thrown error instead of touching freed WASM memory see the
112
- // interface-level warning on `PluginRenderApi`.
113
- let frameEnded = false;
116
+ // Every indicator pane gets the exact same treatment as the main pane
117
+ // its own value domain (from `PaneOptions.getValueRange`) and its
118
+ // own JS/WASM scale over its own pixel height kept alive for the
119
+ // whole frame alongside `yScale`, since plugins targeting a pane draw
120
+ // only after every pane's axis has already been rendered.
121
+ const paneScales = paneRects.map((rect, i) => {
122
+ const pane = panes[i];
123
+ const { min, max } = pane.getValueRange();
124
+ const { scale, dispose } = createScale(min, max, rect.height, 0, visible.length);
125
+ return { pane, rect, min, max, scale, dispose };
126
+ });
127
+ // Flipped in `finally`, right before every scale above frees its WASM
128
+ // backing memory (a no-op on the JS path). Guards `yForValue` below so
129
+ // a plugin that stashes it and calls it later gets a clear thrown
130
+ // error instead of touching freed memory — see the interface-level
131
+ // warning on `PluginRenderApi`. An object (not a plain `let`) so every
132
+ // pane's plugin-api closure, built by `buildPluginApi` below, shares
133
+ // the same flag instead of each capturing its own.
134
+ const frameState = { ended: false };
114
135
  try {
115
136
  const slotWidth = chartWidth / viewport.visibleCount;
116
137
  // x position for a *global* sorted-array index — honors the (possibly
117
138
  // fractional) viewport.startIndex so panning is pixel-smooth, not
118
- // stepped a whole point at a time.
139
+ // stepped a whole point at a time. Shared by every pane: there is
140
+ // only one time axis for the whole stack.
119
141
  const xForIndex = (globalIndex) => (globalIndex - viewport.startIndex) * slotWidth + slotWidth / 2;
142
+ // Exact inverse of xForIndex above — solving
143
+ // `x = (index - viewport.startIndex) * slotWidth + slotWidth / 2` for `index`.
144
+ const indexForX = (x) => viewport.startIndex + (x - slotWidth / 2) / slotWidth;
120
145
  seriesDefinition.draw({ ctx, visible, startIndex: startIdx, xForIndex, slotWidth, yScale, chartHeight }, style);
121
146
  const priceStep = this.currentPriceStep(valueMin, valueMax);
122
- this.renderPriceAxis(valueMin, valueMax, priceStep, yScale, chartWidth, chartHeight);
123
- this.renderTimeAxis(times, startIdx, visible.length, chartHeight, chartWidth, xForIndex);
147
+ this.renderPriceAxis(valueMin, valueMax, priceStep, yScale, chartWidth, chartHeight, mainRect.top);
148
+ for (const { rect, min, max, scale } of paneScales) {
149
+ this.renderPaneSeparator(rect.top, chartWidth);
150
+ const step = this.currentPriceStep(min, max);
151
+ this.renderPriceAxis(min, max, step, scale, chartWidth, rect.height, rect.top);
152
+ }
153
+ this.renderTimeAxis(times, startIdx, visible.length, stackHeight, chartWidth, xForIndex);
124
154
  if (hoverIndex !== null && hoverIndex >= startIdx && hoverIndex < endIdx) {
125
- this.renderCrosshairAndLegend(sorted[hoverIndex], xForIndex(hoverIndex), times[hoverIndex], hoverY, valueMin, valueMax, priceStep, chartWidth, chartHeight);
155
+ // The dashed vertical line spans the whole stack (every pane); the
156
+ // horizontal line, price-label chip, and OHLC legend stay scoped
157
+ // to the main pane only — an indicator pane's own hover readout,
158
+ // if it wants one, is the job of whatever plugin draws into it.
159
+ this.renderCrosshairAndLegend(sorted[hoverIndex], xForIndex(hoverIndex), times[hoverIndex], hoverY, valueMin, valueMax, priceStep, chartWidth, chartHeight, stackHeight);
126
160
  }
127
161
  if (plugins.length > 0) {
128
- const api = {
129
- ctx,
162
+ // Everything every pane's PluginRenderApi shares — only the pane's
163
+ // own rect/value-domain/scale differ between `buildPluginApi`
164
+ // calls, so bundling the rest here keeps that call to a handful of
165
+ // pane-specific arguments instead of ten positional ones repeated
166
+ // per pane.
167
+ const frameGeometry = {
130
168
  chartWidth,
131
- chartHeight,
132
169
  xForIndex,
133
- yForValue: (value) => {
134
- if (frameEnded) {
135
- throw new Error('wick-charts: PluginRenderApi.yForValue called after its frame ended — ' +
136
- 'only call it synchronously inside ChartPlugin.draw()');
137
- }
138
- return yScale.map(value);
139
- },
140
- // Exact inverse of xForIndex above — solving
141
- // `x = (index - viewport.startIndex) * slotWidth + slotWidth / 2` for `index`.
142
- indexForX: (x) => viewport.startIndex + (x - slotWidth / 2) / slotWidth,
143
- // Exact inverse of the value->y mapping createScale set up for this
144
- // frame (domain [valueMin, valueMax] -> range [chartHeight, 0]).
145
- valueForY: (y) => valueMin + (1 - y / chartHeight) * (valueMax - valueMin),
170
+ indexForX,
146
171
  visibleStartIndex: startIdx,
147
172
  visibleEndIndex: endIdx,
148
173
  allPoints: sorted,
174
+ frameState,
149
175
  };
176
+ const mainApi = this.buildPluginApi(mainRect, valueMin, valueMax, yScale, frameGeometry);
177
+ const paneApiById = new Map();
178
+ for (const { pane, rect, min, max, scale } of paneScales) {
179
+ paneApiById.set(pane.id, this.buildPluginApi(rect, min, max, scale, frameGeometry));
180
+ }
150
181
  for (const plugin of plugins) {
151
182
  if (plugin.visible === false)
152
183
  continue;
184
+ // A paneId with no matching pane (e.g. the pane it targeted was
185
+ // since removed) falls back to the main pane rather than being
186
+ // silently skipped — see the doc comment on `ChartPlugin.paneId`.
187
+ const api = plugin.paneId && plugin.paneId !== 'main' ? (paneApiById.get(plugin.paneId) ?? mainApi) : mainApi;
153
188
  // save/restore isolates each plugin's canvas state (strokeStyle,
154
189
  // lineDash, ...) from the next one — a plugin that forgets to
155
190
  // clean up after itself can't bleed style into whatever draws
@@ -169,10 +204,45 @@ export class ChartRenderer {
169
204
  }
170
205
  }
171
206
  finally {
172
- frameEnded = true;
207
+ frameState.ended = true;
173
208
  disposeYScale();
209
+ for (const { dispose } of paneScales)
210
+ dispose();
174
211
  }
175
212
  }
213
+ /**
214
+ * Builds the `PluginRenderApi` for one pane — the main price pane or a
215
+ * declared indicator pane, identical shape either way — from that pane's
216
+ * own rect/value-domain/scale plus whatever `geometry` every pane shares
217
+ * for this frame (shared because there is only one time axis, and one
218
+ * frame-ended flag, for the whole stack; see `FrameGeometry`).
219
+ */
220
+ buildPluginApi(rect, valueMin, valueMax, scale, geometry) {
221
+ const { chartWidth, xForIndex, indexForX, visibleStartIndex, visibleEndIndex, allPoints, frameState } = geometry;
222
+ return {
223
+ ctx: this.ctx,
224
+ chartWidth,
225
+ chartHeight: rect.height,
226
+ xForIndex,
227
+ yForValue: (value) => {
228
+ if (frameState.ended) {
229
+ throw new Error('wick-charts: PluginRenderApi.yForValue called after its frame ended — ' +
230
+ 'only call it synchronously inside ChartPlugin.draw()');
231
+ }
232
+ // Local pane-space y (scale's range is [rect.height, 0]) shifted
233
+ // into absolute canvas pixels by the pane's own top offset.
234
+ return rect.top + scale.map(value);
235
+ },
236
+ indexForX,
237
+ // Exact inverse of the mapping above: subtract the pane's top offset
238
+ // before inverting the same [valueMin, valueMax] -> [rect.height, 0]
239
+ // mapping createScale set up for it.
240
+ valueForY: (y) => valueMin + (1 - (y - rect.top) / rect.height) * (valueMax - valueMin),
241
+ visibleStartIndex,
242
+ visibleEndIndex,
243
+ allPoints,
244
+ };
245
+ }
176
246
  /** The decimal precision `formatPrice` should use for the current price
177
247
  * range — shared by the axis ticks and the crosshair's price label so
178
248
  * both display the same value with the same rounding. */
@@ -180,21 +250,30 @@ export class ChartRenderer {
180
250
  const ticks = niceTicks(priceMin, priceMax, this.axis.priceTickCount);
181
251
  return ticks.length > 1 ? ticks[1] - ticks[0] : 0;
182
252
  }
183
- renderPriceAxis(priceMin, priceMax, step, yScale, chartWidth, chartHeight) {
253
+ /**
254
+ * Draws one pane's right-side value axis: boundary line, horizontal grid
255
+ * lines, and tick labels. Used for both the main price pane and every
256
+ * indicator pane — `topOffset` shifts everything down by that pane's own
257
+ * position in the stack (0 for the main pane, which sits at the top), so
258
+ * `yScale` only ever has to know about its own pane-local [0, chartHeight]
259
+ * range and never about where that pane lives in the full canvas.
260
+ */
261
+ renderPriceAxis(priceMin, priceMax, step, yScale, chartWidth, chartHeight, topOffset) {
184
262
  const { ctx, axis } = this;
185
263
  const ticks = niceTicks(priceMin, priceMax, axis.priceTickCount);
186
264
  ctx.strokeStyle = axis.lineColor;
187
265
  ctx.beginPath();
188
- ctx.moveTo(chartWidth + 0.5, 0);
189
- ctx.lineTo(chartWidth + 0.5, chartHeight);
266
+ ctx.moveTo(chartWidth + 0.5, topOffset);
267
+ ctx.lineTo(chartWidth + 0.5, topOffset + chartHeight);
190
268
  ctx.stroke();
191
269
  ctx.font = this.axisFont();
192
270
  ctx.textAlign = 'left';
193
271
  ctx.textBaseline = 'middle';
194
272
  for (const value of ticks) {
195
- const y = yScale.map(value);
196
- if (y < 0 || y > chartHeight)
273
+ const localY = yScale.map(value);
274
+ if (localY < 0 || localY > chartHeight)
197
275
  continue;
276
+ const y = topOffset + localY;
198
277
  ctx.strokeStyle = axis.gridLineColor;
199
278
  ctx.beginPath();
200
279
  ctx.moveTo(0, y + 0.5);
@@ -204,6 +283,18 @@ export class ChartRenderer {
204
283
  ctx.fillText(formatPrice(value, step), chartWidth + 6, y);
205
284
  }
206
285
  }
286
+ /** The horizontal rule separating an indicator pane from whatever sits
287
+ * above it (the main pane, or the previous indicator pane) — the same
288
+ * `axis.lineColor` boundary style `renderTimeAxis` already draws between
289
+ * the plotting area and the time-axis strip. */
290
+ renderPaneSeparator(top, chartWidth) {
291
+ const { ctx, axis } = this;
292
+ ctx.strokeStyle = axis.lineColor;
293
+ ctx.beginPath();
294
+ ctx.moveTo(0, top + 0.5);
295
+ ctx.lineTo(chartWidth, top + 0.5);
296
+ ctx.stroke();
297
+ }
207
298
  renderTimeAxis(times, startIdx, visibleCount, chartHeight, chartWidth, xForIndex) {
208
299
  const { ctx, axis } = this;
209
300
  const visibleTimes = times.slice(startIdx, startIdx + visibleCount);
@@ -223,14 +314,18 @@ export class ChartRenderer {
223
314
  ctx.fillText(label, x, chartHeight + 6);
224
315
  }
225
316
  }
226
- renderCrosshairAndLegend(point, x, timeSeconds, hoverY, valueMin, valueMax, priceStep, chartWidth, chartHeight) {
317
+ renderCrosshairAndLegend(point, x, timeSeconds, hoverY, valueMin, valueMax, priceStep, chartWidth, chartHeight, stackHeight) {
227
318
  const { ctx, canvas, seriesDefinition, style, crosshair } = this;
228
319
  ctx.save();
229
320
  ctx.strokeStyle = crosshair.lineColor;
230
321
  ctx.setLineDash([4, 4]);
322
+ // Spans the whole pane stack (not just the main pane's own
323
+ // chartHeight) so hovering a candle lines up with the same column
324
+ // across every indicator pane below it — see the call site's comment
325
+ // in `render()` for why the horizontal line/legend don't follow suit.
231
326
  ctx.beginPath();
232
327
  ctx.moveTo(x, 0);
233
- ctx.lineTo(x, chartHeight);
328
+ ctx.lineTo(x, stackHeight);
234
329
  ctx.stroke();
235
330
  // The horizontal line follows the actual cursor/finger position, not
236
331
  // any property of the hovered point — pinning it to (say) the candle's
package/dist/types.d.ts CHANGED
@@ -118,6 +118,45 @@ export interface ChartLegendOptions {
118
118
  * Defaults to 12. */
119
119
  cursorGap?: number;
120
120
  }
121
+ /**
122
+ * Declares one indicator/oscillator pane — a horizontal strip reserved
123
+ * below the main price pane, with its own value-axis domain independent of
124
+ * price (an RSI pane's fixed `[0, 100]`, a MACD pane auto-fit to whatever
125
+ * it's plotting). The pane itself draws nothing: content comes entirely
126
+ * from `ChartPlugin`s registered with a matching `paneId` (see
127
+ * `ChartPlugin.paneId` and `WickChart.addPane`) — the same "core provides
128
+ * layout, the app provides the math" split the plugin system already uses
129
+ * for indicator overlays on the main pane.
130
+ */
131
+ export interface PaneOptions {
132
+ /** Stable identifier — matched against `ChartPlugin.paneId` to route a
133
+ * plugin's `draw()` into this pane instead of the main price pane.
134
+ * Uniqueness is the caller's responsibility; `addPane` doesn't enforce it. */
135
+ id: string;
136
+ /** Share of the total plotting height (canvas height minus the
137
+ * time-axis strip) this pane occupies. Every declared pane is scaled
138
+ * down proportionally (never one at a time, which would change their
139
+ * relative sizing) if their combined ratio would leave the main pane
140
+ * less than a fifth of the stack. Defaults to 0.25. */
141
+ heightRatio?: number;
142
+ /**
143
+ * This pane's own value-axis domain for the current frame, called once
144
+ * per render. Defaults to a fixed `{ min: 0, max: 1 }` if omitted, which
145
+ * is almost never meaningful — supply this for any real indicator pane
146
+ * (e.g. `() => ({ min: 0, max: 100 })` for RSI, or a closure over
147
+ * whatever series your own plugin is tracking for something auto-fit
148
+ * like MACD).
149
+ */
150
+ getValueRange?: () => ValueRange;
151
+ }
152
+ /** `PaneOptions` with every optional field defaulted — what `WickChart`
153
+ * actually stores and hands to `ChartRenderer`, so the renderer never has
154
+ * to re-apply `??` defaults on every frame. */
155
+ export interface ResolvedPaneOptions {
156
+ id: string;
157
+ heightRatio: number;
158
+ getValueRange: () => ValueRange;
159
+ }
121
160
  export interface WickChartOptions {
122
161
  /**
123
162
  * Which registered series type to render this chart as (see
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wick-charts",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "An open-source financial charting library — WASM (Rust) for compute, Canvas2D for rendering.",
5
5
  "license": "MIT",
6
6
  "author": "eatnows",