wick-charts 0.3.0 → 0.5.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
@@ -17,10 +17,12 @@ npm install wick-charts
17
17
  - [Install](#install)
18
18
  - [Quick start](#quick-start)
19
19
  - [Candle data](#candle-data)
20
+ - [Line charts](#line-charts)
20
21
  - [Styling](#styling)
21
22
  - [Reading chart state](#reading-chart-state)
22
23
  - [Loading more history on demand](#loading-more-history-on-demand)
23
24
  - [Extending: plugins](#extending-plugins)
25
+ - [Multi-pane indicators](#multi-pane-indicators)
24
26
  - [Cleanup](#cleanup)
25
27
  - [Architecture](#architecture)
26
28
  - [Development](#development)
@@ -135,6 +137,39 @@ it — a dataset with no `volume` at all renders exactly as if the feature didn'
135
137
  array) is safe. It resets pan/zoom/hover state — call it for a genuinely new dataset, and use
136
138
  `setDataLoader()` (below) to extend the current one instead.
137
139
 
140
+ ### Line charts
141
+
142
+ For a plain time series with no OHLC shape — an equity curve, a metric over time, anything
143
+ that's just one number per point — `createLineChart` is the line-series equivalent of
144
+ `createCandlestickChart` above. A line point is `{ time, value }`:
145
+
146
+ ```ts
147
+ import { createLineChart } from 'wick-charts';
148
+
149
+ const chart = createLineChart(canvas, {
150
+ style: { lineColor: '#2196f3', lineWidth: 1.5 }, // both shown here are the defaults
151
+ });
152
+
153
+ chart.setData([
154
+ { time: '2024-01-01T00:00:00Z', value: 100 },
155
+ { time: '2024-01-02T00:00:00Z', value: 103.4 },
156
+ { time: '2024-01-03T00:00:00Z', value: 101.8 },
157
+ // ...
158
+ ]);
159
+
160
+ chart.render();
161
+ ```
162
+
163
+ Everything else — pan/zoom/hover, `setDataLoader`, `addPlugin`, `addPane`, `background`/
164
+ `font`/`axis`/`crosshair`/`legend` styling — works exactly as it does for a candlestick chart,
165
+ since none of it is specific to what's actually plotted (see "Series types" under
166
+ Architecture). `value` accepts `NaN` (or any non-finite number) as an explicit gap: the line
167
+ breaks there and resumes at the next real value, rather than plotting a bogus point or
168
+ throwing — useful for a series with missing data at some points without pre-filtering it
169
+ yourself. The hover legend shows `Value <number>` (or `Value —` for a hovered gap) in place of
170
+ candlestick's OHLC breakdown; `new WickChart(canvas, { type: 'line' })` also works, the same
171
+ untyped escape hatch `type: 'candlestick'` has, if you'd rather not import the factory.
172
+
138
173
  ### Styling
139
174
 
140
175
  Every visual aspect of the chart is an option — nothing is a fixed constant you can't reach.
@@ -354,6 +389,58 @@ or validated for uniqueness, just compared with `===` when you call `setPluginVi
354
389
  plugin with no `id` still works exactly as before; it just can't be targeted that way, only by
355
390
  holding onto its reference and calling `removePlugin` directly.
356
391
 
392
+ ### Multi-pane indicators
393
+
394
+ An oscillator like RSI or MACD has a value domain that has nothing to do with price (RSI's
395
+ fixed `[0, 100]`, say) — drawing it as a `ChartPlugin` overlay in the price pane would either
396
+ get swamped by the candles or need a hand-rolled rescale hack. `addPane` reserves a horizontal
397
+ strip below the main price pane with its own independent value axis, and a plugin's `paneId`
398
+ routes its `draw()` there instead of the price pane:
399
+
400
+ ```ts
401
+ chart.addPane({
402
+ id: 'rsi',
403
+ heightRatio: 0.25, // share of the total plotting height this pane occupies; defaults to 0.25
404
+ getValueRange: () => ({ min: 0, max: 100 }), // this pane's own value-axis domain, called every frame
405
+ });
406
+
407
+ chart.addPlugin({
408
+ paneId: 'rsi', // routes this plugin into the 'rsi' pane instead of the price pane
409
+ draw({ ctx, allPoints, visibleStartIndex, visibleEndIndex, xForIndex, yForValue }) {
410
+ const rsi = computeRsi(allPoints.map((c) => c.close), 14); // your own indicator math — see below
411
+ ctx.strokeStyle = '#bb86fc';
412
+ ctx.beginPath();
413
+ for (let i = visibleStartIndex; i < visibleEndIndex; i++) {
414
+ const x = xForIndex(i);
415
+ const y = yForValue(rsi[i]); // mapped against *this pane's* [0, 100], not price
416
+ i === visibleStartIndex ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
417
+ }
418
+ ctx.stroke();
419
+ },
420
+ });
421
+ ```
422
+
423
+ The pane itself draws nothing but a separator line and its own right-side axis (ticks resolved
424
+ from `getValueRange()`, styled through the same `axis` options as the price axis) — exactly the
425
+ same "core provides layout, the app provides the math" split plugins already use for indicator
426
+ *overlays*, just for indicators that need their own scale instead of sharing the price one. See
427
+ `demo/index.html` for a complete worked example (an RSI pane built entirely in the demo's own
428
+ code, same as the moving-average overlay above it — see "Indicators" below for why neither
429
+ ships in the library itself).
430
+
431
+ Every declared pane stacks below the previous one in call order, each shrinking the main
432
+ pane's share of the plotting height; `removePane(id)` gives that space back. A plugin whose
433
+ `paneId` doesn't match any currently-added pane falls back to drawing in the price pane rather
434
+ than silently disappearing — useful if you remove a pane before removing the plugins that
435
+ targeted it. `getPanes()` returns every declared pane's `id`/`heightRatio`, the same
436
+ snapshot-for-building-a-management-UI idea `getPlugins()` already offers for plugins.
437
+
438
+ The hover crosshair's dashed vertical line spans every pane so a hovered candle lines up
439
+ across the whole stack; the horizontal line, price-label chip, and OHLC legend stay scoped to
440
+ the price pane — an indicator pane's own hover readout, if you want one, is something its own
441
+ plugin draws (it has the same `xForIndex`/`yForValue` a price-pane plugin does, just mapped
442
+ against that pane's own value domain and pixel rect).
443
+
357
444
  ### Cleanup
358
445
 
359
446
  Call `chart.destroy()` when you're done with a chart (component unmount, etc.) — it removes a
@@ -402,31 +489,35 @@ for *this* series (a line series wouldn't have a body width or volume bars to co
402
489
 
403
490
  ### Series types
404
491
 
405
- Candlesticks are the only chart type today, but nothing above `src/series/` knows that.
406
- `WickChart` and `ChartRenderer` are generic over a point shape (`SeriesPoint` — just a
407
- `time`) and delegate every type-specific decision — how to compute the value-axis range,
408
- how to draw the visible points, what a hover legend says — to a
492
+ Candlestick and line are the two chart types today, and nothing above `src/series/` treats
493
+ either specially. `WickChart` and `ChartRenderer` are generic over a point shape
494
+ (`SeriesPoint` — just a `time`) and delegate every type-specific decision — how to compute
495
+ the value-axis range, how to draw the visible points, what a hover legend says — to a
409
496
  `SeriesDefinition` (see `src/series/types.ts`) resolved at construction time from
410
497
  `options.type` via a small registry (`src/series/registry.ts`). `src/series/candlestick.ts`
411
- is the reference implementation: it registers itself as `'candlestick'` on import, which is
412
- why importing `wick-charts` at all is enough to make that type available without the caller
413
- registering anything.
414
-
415
- Adding a second chart type (line, area, bar, ...) means writing one new file that
416
- implements `SeriesDefinition<TPoint, TStyle>` and calling `registerSeries` on it — `Viewport`,
417
- event handling, data loading, and WASM scale dispatch are all untouched, and existing
418
- `type: 'candlestick'` charts keep working exactly as before. This is the extension point the
419
- `type` option and `style` option are built around: `style` is whatever shape the chosen
420
- series's `defaultStyle` declares (candlestick's is `{ upColor, downColor }`), merged over
421
- that default rather than hardcoded into the chart itself.
498
+ and `src/series/line.ts` both register themselves (as `'candlestick'`/`'line'`) on import,
499
+ which is why importing `wick-charts` at all is enough to make either type available without
500
+ the caller registering anything.
501
+
502
+ Adding a chart type (area, bar, ...) means writing one new file that implements
503
+ `SeriesDefinition<TPoint, TStyle>` and calling `registerSeries` on it — `Viewport`, event
504
+ handling, data loading, and WASM scale dispatch are all untouched, and every existing chart
505
+ of another type keeps working exactly as before; `src/series/line.ts` is a second, smaller
506
+ worked example of this alongside candlestick's own. This is the extension point the `type`
507
+ option and `style` option are built around: `style` is whatever shape the chosen series's
508
+ `defaultStyle` declares (candlestick's is `{ upColor, downColor, ... }`, line's is `{
509
+ lineColor, lineWidth }`), merged over that default rather than hardcoded into the chart
510
+ itself.
422
511
 
423
512
  `options.type` is a plain string the registry resolves at runtime, so `new WickChart(canvas,
424
513
  { type: 'candlestick', style: {...} })` type-checks even if `style` has nothing to do with
425
514
  `CandlestickStyle` — nothing ties a runtime string to a specific `TPoint`/`TStyle` pair at the
426
- type level. `createCandlestickChart()` (in `src/index.ts`) is the fix for the one built-in
427
- type: a thin wrapper that pins both generics so its `style` is fully checked. A new series
428
- should export an equivalent `create<Name>Chart` next to it rather than widening
429
- `WickChartOptions` itself, so each series's style shape stays independent of every other's.
515
+ type level. `createCandlestickChart()`/`createLineChart()` (both in `src/index.ts`) are the
516
+ fix for the two built-in types: a thin wrapper per series that pins both generics so its
517
+ `style` is fully checked. A new series should export an equivalent `create<Name>Chart` next
518
+ to it rather than widening `WickChartOptions` itself, so each series's style shape stays
519
+ independent of every other's — line's factory is the second proof this pattern holds up, not
520
+ just a one-off written for candlestick.
430
521
 
431
522
  ### Plugins (markers, annotations, drawing tools)
432
523
 
@@ -469,6 +560,18 @@ two things `draw()` alone can't give it, both added specifically to make that bu
469
560
  `ChartRenderer.render` uses, recomputed on demand since pointer events happen between
470
561
  frames, not during one.
471
562
 
563
+ `ChartPlugin.paneId` is a third, narrower option on top of the two above — it doesn't change
564
+ what a plugin implements, only which pane's `PluginRenderApi` it receives. `ChartRenderer`
565
+ builds one `PluginRenderApi` per pane per frame (`buildPluginApi`, sharing a `FrameGeometry` for
566
+ the parts every pane has in common — the time axis and the frame-ended guard) and routes each
567
+ plugin to the one matching its `paneId`, defaulting to the main pane. A pane-targeted plugin's
568
+ `yForValue`/`valueForY` are pane-local (mapped against that pane's own `getValueRange()`) but
569
+ still return/accept absolute canvas pixels, exactly like the main pane's — so a plugin never
570
+ needs to know whether it's drawing in the price pane or a declared one, only which `paneId` it
571
+ was given. Pointer gestures (`onPointerDown`/etc.) aren't pane-aware yet: they're still offered
572
+ chart-wide the same way regardless of any plugin's `paneId`, matching the mechanism's original
573
+ scope (drawing tools on the price series) rather than a limitation specific to panes.
574
+
472
575
  ### Indicators (moving averages, Bollinger Bands, ...): deliberately not included
473
576
 
474
577
  wick-charts ships the extension point (`ChartPlugin`, `allPoints`, `xForIndex`/`yForValue`)
@@ -533,12 +636,16 @@ hover state — and on-demand history loading via `setDataLoader`. Per-candle vo
533
636
  the bottom fifth of the chart when a candle has `volume`, and are entirely omitted (nothing
534
637
  drawn, nothing reserved) for data that doesn't.
535
638
  Coordinate scaling runs on WASM once a frame's point count crosses the threshold, JS below
536
- it. Candlestick is the only registered series type so far; the plugin extension point (draw
639
+ it. Candlestick and line are the two registered series types so far (`createCandlestickChart`/
640
+ `createLineChart`); the plugin extension point (draw
537
641
  overlays plus, now, claimable pointer gestures for interactive tools — see "Plugins" above)
538
642
  has no built-in users (see "Indicators" above for why) beyond `demo/index.html`'s example. No
539
643
  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.
644
+ on. `addPane`/`removePane` let a plugin-drawn indicator (RSI, MACD, ...) reserve its own
645
+ horizontal strip with an independent value axis — see "Multi-pane indicators" above; volume
646
+ still shares the candlestick pane rather than getting its own, since it draws through the
647
+ series itself, not a pane-targeted plugin. See [CHANGELOG.md](./CHANGELOG.md) for what shipped
648
+ in each release.
542
649
 
543
650
  ## License
544
651
 
package/dist/index.d.ts CHANGED
@@ -1,8 +1,9 @@
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 { LineStyle } from './series/line.js';
5
+ import type { Candle, LinePoint, PaneOptions, WickChartOptions, SeriesPoint, ValueRange } from './types.js';
6
+ export type { BusinessDay, Candle, LinePoint, PaneOptions, WickChartOptions, WickTime, SeriesPoint, UnixMillis, ValueRange, } from './types.js';
6
7
  export type { DataLoader, DataRequest } from './dataSource.js';
7
8
  export type { ChartPlugin, ChartPointerEvent, PluginRenderApi } from './plugins/types.js';
8
9
  export { distanceToSegment, hitTestPoint, hitTestSegment } from './hitTest.js';
@@ -11,7 +12,9 @@ export { mergeSeriesPoints } from './mergeSeries.js';
11
12
  export { registerSeries, getSeries } from './series/registry.js';
12
13
  export type { SeriesDefinition, SeriesDrawContext } from './series/types.js';
13
14
  export type { CandlestickStyle } from './series/candlestick.js';
15
+ export type { LineStyle } from './series/line.js';
14
16
  export { candlestickSeries } from './series/candlestick.js';
17
+ export { lineSeries } from './series/line.js';
15
18
  export { LinearScale } from './scale.js';
16
19
  export { toUnixSeconds } from './time.js';
17
20
  export { Viewport } from './viewport.js';
@@ -41,6 +44,11 @@ export declare class WickChart<TPoint extends SeriesPoint = Candle> {
41
44
  * motionless while the pointer moves within that candle's column). */
42
45
  private hoverY;
43
46
  private plugins;
47
+ /** Indicator/oscillator panes declared via `addPane`, defaults already
48
+ * resolved — see `ResolvedPaneOptions`. Empty until an app adds one; a
49
+ * chart that never calls `addPane` renders exactly as it did before
50
+ * panes existed (single price pane filling the whole plotting height). */
51
+ private panes;
44
52
  /** The plugin whose `onPointerDown` returned `true` for the pointer
45
53
  * currently down, or `null` when no plugin has claimed the current
46
54
  * gesture (the common case — the chart handles it itself). */
@@ -95,6 +103,35 @@ export declare class WickChart<TPoint extends SeriesPoint = Candle> {
95
103
  * and re-renders. A no-op, not an error, if nothing matches — plugins
96
104
  * with no `id` set are never matched. */
97
105
  setPluginVisible(id: string, visible: boolean): this;
106
+ /**
107
+ * Reserves a horizontal strip below the main price pane (and below any
108
+ * previously-added pane — panes stack in call order) for an indicator or
109
+ * oscillator, drawn entirely by `ChartPlugin`s registered with a
110
+ * matching `paneId` (see `ChartPlugin.paneId`). The pane itself computes
111
+ * nothing: `options.getValueRange` supplies whatever value-axis domain
112
+ * makes sense for what will be plotted into it (a fixed `[0, 100]` for
113
+ * RSI, an auto-fit range closed over a MACD series a plugin already
114
+ * tracks, ...) — the same "core provides layout, the app provides the
115
+ * math" split `addPlugin` already uses for indicator overlays on the
116
+ * main pane. A no-op on layout until at least one plugin actually
117
+ * targets this pane's `id`; an empty pane still reserves its space and
118
+ * draws its own axis, just with nothing inside it.
119
+ */
120
+ addPane(options: PaneOptions): this;
121
+ /** Removes a previously-added pane by `id` and re-renders. A no-op, not
122
+ * an error, if nothing matches. Plugins still targeting the removed
123
+ * pane's `id` via `ChartPlugin.paneId` fall back to drawing in the main
124
+ * pane rather than being silently dropped — see the doc comment on
125
+ * `ChartPlugin.paneId`. */
126
+ removePane(id: string): this;
127
+ /** Every currently-declared pane's `id` and resolved `heightRatio`, in
128
+ * stacking order (top to bottom, main pane excluded since it always
129
+ * exists and always sits first) — for an app building a management UI
130
+ * around indicator panes without maintaining its own parallel list. */
131
+ getPanes(): readonly {
132
+ id: string;
133
+ heightRatio: number;
134
+ }[];
98
135
  render(): void;
99
136
  /** Coalesces render() calls into at most one per animation frame. Mouse
100
137
  * events (drag, wheel) can fire far faster than the display refreshes —
@@ -163,6 +200,17 @@ export declare class WickChart<TPoint extends SeriesPoint = Candle> {
163
200
  * on-demand (`valueForY`, dispatched pointer events) rather than only
164
201
  * during `render()`. `null` when there's nothing to compute one from. */
165
202
  private frameValueRange;
203
+ /** The main price pane's own pixel height for the *next* render — as
204
+ * opposed to `this.renderer.chartHeight`, which is the whole pane
205
+ * stack's height (main pane plus every declared indicator pane below
206
+ * it). Every pixel<->value conversion outside of `ChartRenderer.render`
207
+ * itself (price-axis drag-to-scale, `ChartPointerEvent.value`, ...) is
208
+ * about the main pane specifically — pointer gestures and price-axis
209
+ * dragging aren't pane-aware yet, so they only ever mean the main price
210
+ * pane — and has to divide by this, not the full stack, or dragging
211
+ * would run at the wrong speed (or a hovered value would come out
212
+ * wrong) as soon as an app adds its first indicator pane. */
213
+ private mainPaneHeight;
166
214
  /** y pixel -> value in the range the next render would use. `null` if
167
215
  * there's no data or no usable chart area to compute one against — see
168
216
  * `ChartPointerEvent.value`. */
@@ -223,3 +271,13 @@ export declare class WickChart<TPoint extends SeriesPoint = Candle> {
223
271
  export declare function createCandlestickChart(canvas: HTMLCanvasElement, options?: Omit<WickChartOptions, 'type' | 'style'> & {
224
272
  style?: Partial<CandlestickStyle>;
225
273
  }): WickChart<Candle>;
274
+ /**
275
+ * The second series type's equivalent of `createCandlestickChart` above —
276
+ * pins `TPoint` (`LinePoint`) and `TStyle` (`LineStyle`) so `style` is
277
+ * fully checked here the same way, rather than accepted as the untyped
278
+ * `Record<string, unknown>` `WickChartOptions.style` allows for `new
279
+ * WickChart(canvas, { type: 'line', style })`.
280
+ */
281
+ export declare function createLineChart(canvas: HTMLCanvasElement, options?: Omit<WickChartOptions, 'type' | 'style'> & {
282
+ style?: Partial<LineStyle>;
283
+ }): WickChart<LinePoint>;
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';
@@ -8,13 +9,14 @@ import { importRealWasm } from './wasmImporter.js';
8
9
  export { distanceToSegment, hitTestPoint, hitTestSegment } from './hitTest.js';
9
10
  export { mergeSeriesPoints } from './mergeSeries.js';
10
11
  export { registerSeries, getSeries } from './series/registry.js';
11
- // Also registers the 'candlestick' type as a module-load side effect — see
12
- // src/series/candlestick.ts and src/series/registry.ts. A new series type
13
- // gets the same treatment: implement SeriesDefinition, export it here (or
14
- // have the consuming app import it directly before constructing a chart of
15
- // that type), and `type: '<its key>'` becomes usable with no other change
16
- // to this file.
12
+ // Also registers 'candlestick'/'line' as a module-load side effect — see
13
+ // src/series/candlestick.ts, src/series/line.ts, and src/series/registry.ts.
14
+ // A new series type gets the same treatment: implement SeriesDefinition,
15
+ // export it here (or have the consuming app import it directly before
16
+ // constructing a chart of that type), and `type: '<its key>'` becomes
17
+ // usable with no other change to this file.
17
18
  export { candlestickSeries } from './series/candlestick.js';
19
+ export { lineSeries } from './series/line.js';
18
20
  export { LinearScale } from './scale.js';
19
21
  export { toUnixSeconds } from './time.js';
20
22
  export { Viewport } from './viewport.js';
@@ -26,6 +28,14 @@ const DEFAULT_VISIBLE_POINTS = 120;
26
28
  /** How close (in points) the visible window has to get to either edge of
27
29
  * the loaded data before `setDataLoader`'s loader is asked for more. */
28
30
  const DEFAULT_LOAD_THRESHOLD = 20;
31
+ /** `PaneOptions.heightRatio`'s default — see `addPane`. */
32
+ const DEFAULT_PANE_HEIGHT_RATIO = 0.25;
33
+ /** `PaneOptions.getValueRange`'s default — see `addPane`. Fixed at
34
+ * `[0, 1]` rather than auto-fitting to anything, since the pane has no
35
+ * data of its own to fit to: only a plugin drawing into it knows what
36
+ * range makes sense, which is exactly why `getValueRange` exists to be
37
+ * overridden. */
38
+ const DEFAULT_PANE_VALUE_RANGE = { min: 0, max: 1 };
29
39
  /** How long a single finger has to stay down before a still-in-progress
30
40
  * 'pan' touch switches to 'scrub' mode (touch has no hover, so this is its
31
41
  * substitute — hold to inspect a point instead of panning past it). */
@@ -57,6 +67,11 @@ export class WickChart {
57
67
  * motionless while the pointer moves within that candle's column). */
58
68
  this.hoverY = null;
59
69
  this.plugins = [];
70
+ /** Indicator/oscillator panes declared via `addPane`, defaults already
71
+ * resolved — see `ResolvedPaneOptions`. Empty until an app adds one; a
72
+ * chart that never calls `addPane` renders exactly as it did before
73
+ * panes existed (single price pane filling the whole plotting height). */
74
+ this.panes = [];
60
75
  /** The plugin whose `onPointerDown` returned `true` for the pointer
61
76
  * currently down, or `null` when no plugin has claimed the current
62
77
  * gesture (the common case — the chart handles it itself). */
@@ -353,6 +368,46 @@ export class WickChart {
353
368
  this.scheduleRender();
354
369
  return this;
355
370
  }
371
+ /**
372
+ * Reserves a horizontal strip below the main price pane (and below any
373
+ * previously-added pane — panes stack in call order) for an indicator or
374
+ * oscillator, drawn entirely by `ChartPlugin`s registered with a
375
+ * matching `paneId` (see `ChartPlugin.paneId`). The pane itself computes
376
+ * nothing: `options.getValueRange` supplies whatever value-axis domain
377
+ * makes sense for what will be plotted into it (a fixed `[0, 100]` for
378
+ * RSI, an auto-fit range closed over a MACD series a plugin already
379
+ * tracks, ...) — the same "core provides layout, the app provides the
380
+ * math" split `addPlugin` already uses for indicator overlays on the
381
+ * main pane. A no-op on layout until at least one plugin actually
382
+ * targets this pane's `id`; an empty pane still reserves its space and
383
+ * draws its own axis, just with nothing inside it.
384
+ */
385
+ addPane(options) {
386
+ this.panes.push({
387
+ id: options.id,
388
+ heightRatio: options.heightRatio ?? DEFAULT_PANE_HEIGHT_RATIO,
389
+ getValueRange: options.getValueRange ?? (() => DEFAULT_PANE_VALUE_RANGE),
390
+ });
391
+ this.scheduleRender();
392
+ return this;
393
+ }
394
+ /** Removes a previously-added pane by `id` and re-renders. A no-op, not
395
+ * an error, if nothing matches. Plugins still targeting the removed
396
+ * pane's `id` via `ChartPlugin.paneId` fall back to drawing in the main
397
+ * pane rather than being silently dropped — see the doc comment on
398
+ * `ChartPlugin.paneId`. */
399
+ removePane(id) {
400
+ this.panes = this.panes.filter((pane) => pane.id !== id);
401
+ this.scheduleRender();
402
+ return this;
403
+ }
404
+ /** Every currently-declared pane's `id` and resolved `heightRatio`, in
405
+ * stacking order (top to bottom, main pane excluded since it always
406
+ * exists and always sits first) — for an app building a management UI
407
+ * around indicator panes without maintaining its own parallel list. */
408
+ getPanes() {
409
+ return this.panes.map(({ id, heightRatio }) => ({ id, heightRatio }));
410
+ }
356
411
  render() {
357
412
  this.renderer.render({
358
413
  sorted: this.sorted,
@@ -361,6 +416,7 @@ export class WickChart {
361
416
  hoverIndex: this.hoverIndex,
362
417
  hoverY: this.hoverY,
363
418
  plugins: this.plugins,
419
+ panes: this.panes,
364
420
  });
365
421
  this.maybeLoadMore();
366
422
  }
@@ -463,7 +519,7 @@ export class WickChart {
463
519
  // have opposite sign.
464
520
  this.viewport.pan(-deltaXDevice / slotWidth, this.sorted.length);
465
521
  }
466
- const chartHeight = this.renderer.chartHeight;
522
+ const chartHeight = this.mainPaneHeight();
467
523
  if (chartHeight > 0 && this.viewport.valueRangeOverride) {
468
524
  const deltaYDevice = deltaYCss * this.devicePixelScaleY();
469
525
  const { min, max } = this.viewport.valueRangeOverride;
@@ -597,12 +653,25 @@ export class WickChart {
597
653
  return null;
598
654
  return this.seriesDefinition.getValueRange(visible, this.viewport.valueScaleFactor);
599
655
  }
656
+ /** The main price pane's own pixel height for the *next* render — as
657
+ * opposed to `this.renderer.chartHeight`, which is the whole pane
658
+ * stack's height (main pane plus every declared indicator pane below
659
+ * it). Every pixel<->value conversion outside of `ChartRenderer.render`
660
+ * itself (price-axis drag-to-scale, `ChartPointerEvent.value`, ...) is
661
+ * about the main pane specifically — pointer gestures and price-axis
662
+ * dragging aren't pane-aware yet, so they only ever mean the main price
663
+ * pane — and has to divide by this, not the full stack, or dragging
664
+ * would run at the wrong speed (or a hovered value would come out
665
+ * wrong) as soon as an app adds its first indicator pane. */
666
+ mainPaneHeight() {
667
+ return computePaneLayout(this.panes, this.renderer.chartHeight).main.height;
668
+ }
600
669
  /** y pixel -> value in the range the next render would use. `null` if
601
670
  * there's no data or no usable chart area to compute one against — see
602
671
  * `ChartPointerEvent.value`. */
603
672
  valueForY(y) {
604
673
  const range = this.frameValueRange();
605
- const chartHeight = this.renderer.chartHeight;
674
+ const chartHeight = this.mainPaneHeight();
606
675
  if (!range || chartHeight <= 0)
607
676
  return null;
608
677
  return range.min + (1 - y / chartHeight) * (range.max - range.min);
@@ -630,7 +699,7 @@ export class WickChart {
630
699
  * `valueForY` returns `null` for. */
631
700
  yForValue(value) {
632
701
  const range = this.frameValueRange();
633
- const chartHeight = this.renderer.chartHeight;
702
+ const chartHeight = this.mainPaneHeight();
634
703
  if (!range || chartHeight <= 0)
635
704
  return null;
636
705
  return chartHeight * (1 - (value - range.min) / (range.max - range.min));
@@ -713,3 +782,13 @@ export class WickChart {
713
782
  export function createCandlestickChart(canvas, options) {
714
783
  return new WickChart(canvas, { ...options, type: 'candlestick' });
715
784
  }
785
+ /**
786
+ * The second series type's equivalent of `createCandlestickChart` above —
787
+ * pins `TPoint` (`LinePoint`) and `TStyle` (`LineStyle`) so `style` is
788
+ * fully checked here the same way, rather than accepted as the untyped
789
+ * `Record<string, unknown>` `WickChartOptions.style` allows for `new
790
+ * WickChart(canvas, { type: 'line', style })`.
791
+ */
792
+ export function createLineChart(canvas, options) {
793
+ return new WickChart(canvas, { ...options, type: 'line' });
794
+ }
@@ -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,92 @@ 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;
120
- seriesDefinition.draw({ ctx, visible, startIndex: startIdx, xForIndex, slotWidth, yScale, chartHeight }, style);
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;
145
+ // save/restore isolates whatever canvas state a series's draw()
146
+ // touches (lineWidth, line dash, ...) from the axis/crosshair/plugin
147
+ // drawing that follows — the same isolation each plugin already gets
148
+ // around its own draw() call below. Without this, a property no
149
+ // series happened to set before (lineSeries.draw() is the first
150
+ // built-in one to set ctx.lineWidth) would silently leak into every
151
+ // subsequent stroke() this frame, including axis boundary lines,
152
+ // grid lines, and the crosshair.
153
+ ctx.save();
154
+ try {
155
+ seriesDefinition.draw({ ctx, visible, startIndex: startIdx, xForIndex, slotWidth, yScale, chartHeight }, style);
156
+ }
157
+ finally {
158
+ ctx.restore();
159
+ }
121
160
  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);
161
+ this.renderPriceAxis(valueMin, valueMax, priceStep, yScale, chartWidth, chartHeight, mainRect.top);
162
+ for (const { rect, min, max, scale } of paneScales) {
163
+ this.renderPaneSeparator(rect.top, chartWidth);
164
+ const step = this.currentPriceStep(min, max);
165
+ this.renderPriceAxis(min, max, step, scale, chartWidth, rect.height, rect.top);
166
+ }
167
+ this.renderTimeAxis(times, startIdx, visible.length, stackHeight, chartWidth, xForIndex);
124
168
  if (hoverIndex !== null && hoverIndex >= startIdx && hoverIndex < endIdx) {
125
- this.renderCrosshairAndLegend(sorted[hoverIndex], xForIndex(hoverIndex), times[hoverIndex], hoverY, valueMin, valueMax, priceStep, chartWidth, chartHeight);
169
+ // The dashed vertical line spans the whole stack (every pane); the
170
+ // horizontal line, price-label chip, and OHLC legend stay scoped
171
+ // to the main pane only — an indicator pane's own hover readout,
172
+ // if it wants one, is the job of whatever plugin draws into it.
173
+ this.renderCrosshairAndLegend(sorted[hoverIndex], xForIndex(hoverIndex), times[hoverIndex], hoverY, valueMin, valueMax, priceStep, chartWidth, chartHeight, stackHeight);
126
174
  }
127
175
  if (plugins.length > 0) {
128
- const api = {
129
- ctx,
176
+ // Everything every pane's PluginRenderApi shares — only the pane's
177
+ // own rect/value-domain/scale differ between `buildPluginApi`
178
+ // calls, so bundling the rest here keeps that call to a handful of
179
+ // pane-specific arguments instead of ten positional ones repeated
180
+ // per pane.
181
+ const frameGeometry = {
130
182
  chartWidth,
131
- chartHeight,
132
183
  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),
184
+ indexForX,
146
185
  visibleStartIndex: startIdx,
147
186
  visibleEndIndex: endIdx,
148
187
  allPoints: sorted,
188
+ frameState,
149
189
  };
190
+ const mainApi = this.buildPluginApi(mainRect, valueMin, valueMax, yScale, frameGeometry);
191
+ const paneApiById = new Map();
192
+ for (const { pane, rect, min, max, scale } of paneScales) {
193
+ paneApiById.set(pane.id, this.buildPluginApi(rect, min, max, scale, frameGeometry));
194
+ }
150
195
  for (const plugin of plugins) {
151
196
  if (plugin.visible === false)
152
197
  continue;
198
+ // A paneId with no matching pane (e.g. the pane it targeted was
199
+ // since removed) falls back to the main pane rather than being
200
+ // silently skipped — see the doc comment on `ChartPlugin.paneId`.
201
+ const api = plugin.paneId && plugin.paneId !== 'main' ? (paneApiById.get(plugin.paneId) ?? mainApi) : mainApi;
153
202
  // save/restore isolates each plugin's canvas state (strokeStyle,
154
203
  // lineDash, ...) from the next one — a plugin that forgets to
155
204
  // clean up after itself can't bleed style into whatever draws
@@ -169,10 +218,45 @@ export class ChartRenderer {
169
218
  }
170
219
  }
171
220
  finally {
172
- frameEnded = true;
221
+ frameState.ended = true;
173
222
  disposeYScale();
223
+ for (const { dispose } of paneScales)
224
+ dispose();
174
225
  }
175
226
  }
227
+ /**
228
+ * Builds the `PluginRenderApi` for one pane — the main price pane or a
229
+ * declared indicator pane, identical shape either way — from that pane's
230
+ * own rect/value-domain/scale plus whatever `geometry` every pane shares
231
+ * for this frame (shared because there is only one time axis, and one
232
+ * frame-ended flag, for the whole stack; see `FrameGeometry`).
233
+ */
234
+ buildPluginApi(rect, valueMin, valueMax, scale, geometry) {
235
+ const { chartWidth, xForIndex, indexForX, visibleStartIndex, visibleEndIndex, allPoints, frameState } = geometry;
236
+ return {
237
+ ctx: this.ctx,
238
+ chartWidth,
239
+ chartHeight: rect.height,
240
+ xForIndex,
241
+ yForValue: (value) => {
242
+ if (frameState.ended) {
243
+ throw new Error('wick-charts: PluginRenderApi.yForValue called after its frame ended — ' +
244
+ 'only call it synchronously inside ChartPlugin.draw()');
245
+ }
246
+ // Local pane-space y (scale's range is [rect.height, 0]) shifted
247
+ // into absolute canvas pixels by the pane's own top offset.
248
+ return rect.top + scale.map(value);
249
+ },
250
+ indexForX,
251
+ // Exact inverse of the mapping above: subtract the pane's top offset
252
+ // before inverting the same [valueMin, valueMax] -> [rect.height, 0]
253
+ // mapping createScale set up for it.
254
+ valueForY: (y) => valueMin + (1 - (y - rect.top) / rect.height) * (valueMax - valueMin),
255
+ visibleStartIndex,
256
+ visibleEndIndex,
257
+ allPoints,
258
+ };
259
+ }
176
260
  /** The decimal precision `formatPrice` should use for the current price
177
261
  * range — shared by the axis ticks and the crosshair's price label so
178
262
  * both display the same value with the same rounding. */
@@ -180,21 +264,30 @@ export class ChartRenderer {
180
264
  const ticks = niceTicks(priceMin, priceMax, this.axis.priceTickCount);
181
265
  return ticks.length > 1 ? ticks[1] - ticks[0] : 0;
182
266
  }
183
- renderPriceAxis(priceMin, priceMax, step, yScale, chartWidth, chartHeight) {
267
+ /**
268
+ * Draws one pane's right-side value axis: boundary line, horizontal grid
269
+ * lines, and tick labels. Used for both the main price pane and every
270
+ * indicator pane — `topOffset` shifts everything down by that pane's own
271
+ * position in the stack (0 for the main pane, which sits at the top), so
272
+ * `yScale` only ever has to know about its own pane-local [0, chartHeight]
273
+ * range and never about where that pane lives in the full canvas.
274
+ */
275
+ renderPriceAxis(priceMin, priceMax, step, yScale, chartWidth, chartHeight, topOffset) {
184
276
  const { ctx, axis } = this;
185
277
  const ticks = niceTicks(priceMin, priceMax, axis.priceTickCount);
186
278
  ctx.strokeStyle = axis.lineColor;
187
279
  ctx.beginPath();
188
- ctx.moveTo(chartWidth + 0.5, 0);
189
- ctx.lineTo(chartWidth + 0.5, chartHeight);
280
+ ctx.moveTo(chartWidth + 0.5, topOffset);
281
+ ctx.lineTo(chartWidth + 0.5, topOffset + chartHeight);
190
282
  ctx.stroke();
191
283
  ctx.font = this.axisFont();
192
284
  ctx.textAlign = 'left';
193
285
  ctx.textBaseline = 'middle';
194
286
  for (const value of ticks) {
195
- const y = yScale.map(value);
196
- if (y < 0 || y > chartHeight)
287
+ const localY = yScale.map(value);
288
+ if (localY < 0 || localY > chartHeight)
197
289
  continue;
290
+ const y = topOffset + localY;
198
291
  ctx.strokeStyle = axis.gridLineColor;
199
292
  ctx.beginPath();
200
293
  ctx.moveTo(0, y + 0.5);
@@ -204,6 +297,18 @@ export class ChartRenderer {
204
297
  ctx.fillText(formatPrice(value, step), chartWidth + 6, y);
205
298
  }
206
299
  }
300
+ /** The horizontal rule separating an indicator pane from whatever sits
301
+ * above it (the main pane, or the previous indicator pane) — the same
302
+ * `axis.lineColor` boundary style `renderTimeAxis` already draws between
303
+ * the plotting area and the time-axis strip. */
304
+ renderPaneSeparator(top, chartWidth) {
305
+ const { ctx, axis } = this;
306
+ ctx.strokeStyle = axis.lineColor;
307
+ ctx.beginPath();
308
+ ctx.moveTo(0, top + 0.5);
309
+ ctx.lineTo(chartWidth, top + 0.5);
310
+ ctx.stroke();
311
+ }
207
312
  renderTimeAxis(times, startIdx, visibleCount, chartHeight, chartWidth, xForIndex) {
208
313
  const { ctx, axis } = this;
209
314
  const visibleTimes = times.slice(startIdx, startIdx + visibleCount);
@@ -223,14 +328,18 @@ export class ChartRenderer {
223
328
  ctx.fillText(label, x, chartHeight + 6);
224
329
  }
225
330
  }
226
- renderCrosshairAndLegend(point, x, timeSeconds, hoverY, valueMin, valueMax, priceStep, chartWidth, chartHeight) {
331
+ renderCrosshairAndLegend(point, x, timeSeconds, hoverY, valueMin, valueMax, priceStep, chartWidth, chartHeight, stackHeight) {
227
332
  const { ctx, canvas, seriesDefinition, style, crosshair } = this;
228
333
  ctx.save();
229
334
  ctx.strokeStyle = crosshair.lineColor;
230
335
  ctx.setLineDash([4, 4]);
336
+ // Spans the whole pane stack (not just the main pane's own
337
+ // chartHeight) so hovering a candle lines up with the same column
338
+ // across every indicator pane below it — see the call site's comment
339
+ // in `render()` for why the horizontal line/legend don't follow suit.
231
340
  ctx.beginPath();
232
341
  ctx.moveTo(x, 0);
233
- ctx.lineTo(x, chartHeight);
342
+ ctx.lineTo(x, stackHeight);
234
343
  ctx.stroke();
235
344
  // The horizontal line follows the actual cursor/finger position, not
236
345
  // any property of the hovered point — pinning it to (say) the candle's
@@ -0,0 +1,14 @@
1
+ import type { LinePoint } from '../types.js';
2
+ import type { SeriesDefinition } from './types.js';
3
+ /** The second built-in series type — a plain single-value line, the
4
+ * simplest possible `SeriesDefinition` beyond candlestick's OHLC. Exists as
5
+ * much to exercise the registry with a genuinely different `TPoint`/`TStyle`
6
+ * pair as to be useful on its own; see `src/series/candlestick.ts` for the
7
+ * reference implementation this mirrors. */
8
+ export interface LineStyle {
9
+ /** Stroke color. Defaults to `'#2196f3'`. */
10
+ lineColor: string;
11
+ /** Stroke width, in px. Defaults to 1.5. */
12
+ lineWidth: number;
13
+ }
14
+ export declare const lineSeries: SeriesDefinition<LinePoint, LineStyle>;
@@ -0,0 +1,65 @@
1
+ import { fitRange } from '../priceRange.js';
2
+ import { registerSeries } from './registry.js';
3
+ const DEFAULT_STYLE = {
4
+ lineColor: '#2196f3',
5
+ lineWidth: 1.5,
6
+ };
7
+ /** Falls back to a fixed `[0, 1]` range when every visible point is a gap
8
+ * (`NaN`/non-finite `value`) — `Math.min()`/`Math.max()` over an empty
9
+ * array are `Infinity`/`-Infinity`, which would otherwise feed `fitRange`
10
+ * a `NaN` midpoint. Candlestick never needs this: `Candle`'s OHLC fields
11
+ * aren't optional or gap-tolerant the way a line point's `value` is. */
12
+ function getValueRange(visible, scaleFactor) {
13
+ const values = visible.map((p) => p.value).filter((v) => Number.isFinite(v));
14
+ if (values.length === 0)
15
+ return { min: 0, max: 1 };
16
+ return fitRange(Math.min(...values), Math.max(...values), scaleFactor);
17
+ }
18
+ function draw(context, style) {
19
+ const { ctx, visible, startIndex, xForIndex, yScale } = context;
20
+ if (visible.length === 0)
21
+ return;
22
+ // Batched through mapMany (one call per array) rather than once per
23
+ // point in the loop below — same reasoning as candlestick's draw(): it's
24
+ // what lets the WASM path pay the JS<->WASM boundary cost once per frame.
25
+ // NaN values map to NaN here, harmlessly — skipped below rather than
26
+ // filtered out first, so `ys[i]` still lines up with `visible[i]`.
27
+ const ys = yScale.mapMany(visible.map((p) => p.value));
28
+ ctx.strokeStyle = style.lineColor;
29
+ ctx.lineWidth = style.lineWidth;
30
+ ctx.beginPath();
31
+ // `drawing` tracks whether the path is mid-segment — a non-finite value
32
+ // (a gap in the data) breaks it, and the line resumes fresh at the next
33
+ // real value rather than jumping straight across the gap.
34
+ let drawing = false;
35
+ visible.forEach((point, i) => {
36
+ if (!Number.isFinite(point.value)) {
37
+ drawing = false;
38
+ return;
39
+ }
40
+ const x = xForIndex(startIndex + i);
41
+ const y = ys[i];
42
+ if (drawing) {
43
+ ctx.lineTo(x, y);
44
+ }
45
+ else {
46
+ ctx.moveTo(x, y);
47
+ drawing = true;
48
+ }
49
+ });
50
+ ctx.stroke();
51
+ }
52
+ function formatLegend(point) {
53
+ return [`Value ${Number.isFinite(point.value) ? point.value.toLocaleString('en-US') : '—'}`];
54
+ }
55
+ export const lineSeries = {
56
+ type: 'line',
57
+ defaultStyle: DEFAULT_STYLE,
58
+ getValueRange,
59
+ draw,
60
+ formatLegend,
61
+ };
62
+ // Registered as a module-level side effect, same as candlestickSeries — see
63
+ // src/series/candlestick.ts for why. src/index.ts imports this file so
64
+ // 'line' is available the moment the package itself is imported.
65
+ registerSeries(lineSeries);
package/dist/types.d.ts CHANGED
@@ -57,6 +57,18 @@ export interface Candle extends SeriesPoint {
57
57
  close: number;
58
58
  volume?: number;
59
59
  }
60
+ /**
61
+ * A single value plotted against time — the point shape for the built-in
62
+ * `'line'` series (see `src/series/line.ts`), and the simplest possible
63
+ * instance of `SeriesPoint` beyond `Candle`: a plain time series with
64
+ * nothing OHLC-specific about it. `value` is `NaN`-tolerant: a `NaN`
65
+ * (or non-finite) value is treated as a gap — the line breaks there and
66
+ * resumes at the next real value, rather than plotting a bogus point or
67
+ * throwing.
68
+ */
69
+ export interface LinePoint extends SeriesPoint {
70
+ value: number;
71
+ }
60
72
  /** Text styling shared by every label the chart draws — axis ticks,
61
73
  * crosshair axis labels, and the hover legend. `axisSize`/`legendSize` are
62
74
  * separate since the legend has historically been drawn one px larger to
@@ -118,6 +130,45 @@ export interface ChartLegendOptions {
118
130
  * Defaults to 12. */
119
131
  cursorGap?: number;
120
132
  }
133
+ /**
134
+ * Declares one indicator/oscillator pane — a horizontal strip reserved
135
+ * below the main price pane, with its own value-axis domain independent of
136
+ * price (an RSI pane's fixed `[0, 100]`, a MACD pane auto-fit to whatever
137
+ * it's plotting). The pane itself draws nothing: content comes entirely
138
+ * from `ChartPlugin`s registered with a matching `paneId` (see
139
+ * `ChartPlugin.paneId` and `WickChart.addPane`) — the same "core provides
140
+ * layout, the app provides the math" split the plugin system already uses
141
+ * for indicator overlays on the main pane.
142
+ */
143
+ export interface PaneOptions {
144
+ /** Stable identifier — matched against `ChartPlugin.paneId` to route a
145
+ * plugin's `draw()` into this pane instead of the main price pane.
146
+ * Uniqueness is the caller's responsibility; `addPane` doesn't enforce it. */
147
+ id: string;
148
+ /** Share of the total plotting height (canvas height minus the
149
+ * time-axis strip) this pane occupies. Every declared pane is scaled
150
+ * down proportionally (never one at a time, which would change their
151
+ * relative sizing) if their combined ratio would leave the main pane
152
+ * less than a fifth of the stack. Defaults to 0.25. */
153
+ heightRatio?: number;
154
+ /**
155
+ * This pane's own value-axis domain for the current frame, called once
156
+ * per render. Defaults to a fixed `{ min: 0, max: 1 }` if omitted, which
157
+ * is almost never meaningful — supply this for any real indicator pane
158
+ * (e.g. `() => ({ min: 0, max: 100 })` for RSI, or a closure over
159
+ * whatever series your own plugin is tracking for something auto-fit
160
+ * like MACD).
161
+ */
162
+ getValueRange?: () => ValueRange;
163
+ }
164
+ /** `PaneOptions` with every optional field defaulted — what `WickChart`
165
+ * actually stores and hands to `ChartRenderer`, so the renderer never has
166
+ * to re-apply `??` defaults on every frame. */
167
+ export interface ResolvedPaneOptions {
168
+ id: string;
169
+ heightRatio: number;
170
+ getValueRange: () => ValueRange;
171
+ }
121
172
  export interface WickChartOptions {
122
173
  /**
123
174
  * 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.5.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",