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 +70 -2
- package/dist/index.d.ts +47 -2
- package/dist/index.js +71 -3
- package/dist/paneLayout.d.ts +38 -0
- package/dist/paneLayout.js +49 -0
- package/dist/plugins/types.d.ts +11 -0
- package/dist/renderer.d.ts +27 -1
- package/dist/renderer.js +132 -37
- package/dist/types.d.ts +39 -0
- package/package.json +1 -1
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.
|
|
541
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/dist/plugins/types.d.ts
CHANGED
|
@@ -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
|
package/dist/renderer.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 ||
|
|
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
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
196
|
-
if (
|
|
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,
|
|
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
|