wick-charts 0.6.0 → 0.7.1
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 +43 -1
- package/dist/axisRenderer.d.ts +39 -0
- package/dist/axisRenderer.js +94 -0
- package/dist/binarySearch.d.ts +15 -0
- package/dist/binarySearch.js +37 -0
- package/dist/crosshairRenderer.d.ts +60 -0
- package/dist/crosshairRenderer.js +121 -0
- package/dist/index.d.ts +43 -1
- package/dist/index.js +57 -0
- package/dist/renderer.d.ts +20 -48
- package/dist/renderer.js +40 -191
- package/dist/viewport.d.ts +11 -0
- package/dist/viewport.js +16 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ npm install wick-charts
|
|
|
21
21
|
- [Styling](#styling)
|
|
22
22
|
- [Inverting the value axis](#inverting-the-value-axis)
|
|
23
23
|
- [Reading chart state](#reading-chart-state)
|
|
24
|
+
- [Setting the visible range](#setting-the-visible-range)
|
|
24
25
|
- [Loading more history on demand](#loading-more-history-on-demand)
|
|
25
26
|
- [Extending: plugins](#extending-plugins)
|
|
26
27
|
- [Multi-pane indicators](#multi-pane-indicators)
|
|
@@ -269,10 +270,47 @@ without reaching into the chart's internals:
|
|
|
269
270
|
```ts
|
|
270
271
|
chart.getPointCount(); // total candles loaded (not just visible)
|
|
271
272
|
chart.getVisibleRange(); // { startIndex, endIndex, visibleCount }
|
|
273
|
+
chart.getVisibleTimeRange(); // { from, to } in unix seconds, or null with no data
|
|
272
274
|
chart.getValueRangeOverride(); // { min, max } once the user has dragged the price axis, else null
|
|
273
275
|
chart.getHoveredPoint(); // the candle under the cursor/finger, or null
|
|
274
276
|
```
|
|
275
277
|
|
|
278
|
+
### Setting the visible range
|
|
279
|
+
|
|
280
|
+
The write side of `getVisibleRange`/`getVisibleTimeRange` — jump the pan/zoom window
|
|
281
|
+
programmatically instead of only ever through a drag/scroll gesture:
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
chart.setVisibleRange({ startIndex: 50, endIndex: 100 }); // index-based, like getVisibleRange()
|
|
285
|
+
chart.setVisibleTimeRange({ from: '2024-02-01T00:00:00Z', to: '2024-03-01T00:00:00Z' });
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
`setVisibleTimeRange` is the one to reach for when syncing one chart's pan/zoom onto another
|
|
289
|
+
**independent** `WickChart` instance that shares a time axis — a common pattern for a price
|
|
290
|
+
chart and an indicator chart panned together, or any "these views move as one" UI. Indices
|
|
291
|
+
aren't safe for this: two charts may have loaded different amounts of history via
|
|
292
|
+
`setDataLoader`, so the same index means a different candle in each, while the same time
|
|
293
|
+
always means the same point (or the nearest one either chart actually has loaded). A time
|
|
294
|
+
value in `from`/`to` accepts every shape `Candle.time` does (unix seconds/ms, ISO string,
|
|
295
|
+
`{ businessDay }}`); `getVisibleTimeRange()` always returns plain unix seconds.
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
// Keep `follower` in lockstep with `driver` — see demo/sync.html for a
|
|
299
|
+
// complete two-chart example, including what to do about there being no
|
|
300
|
+
// pan/zoom change event yet (see "Status" below).
|
|
301
|
+
function syncLoop() {
|
|
302
|
+
const range = driver.getVisibleTimeRange();
|
|
303
|
+
if (range) follower.setVisibleTimeRange(range);
|
|
304
|
+
requestAnimationFrame(syncLoop);
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Both setters clamp an out-of-range request instead of throwing (the same way a drag/zoom
|
|
309
|
+
gesture can never overscroll past the loaded data) and clear the current hover, since a jump
|
|
310
|
+
is a discontinuous change — a hover position computed for the window before it no longer
|
|
311
|
+
lines up with anything until the pointer moves again. Neither touches the value axis (manual
|
|
312
|
+
price-range override, invert) — only the time window moves.
|
|
313
|
+
|
|
276
314
|
### Loading more history on demand
|
|
277
315
|
|
|
278
316
|
`setDataLoader` lets you start with a small window and stream in more as the user pans toward
|
|
@@ -680,7 +718,11 @@ horizontal strip with an independent value axis — see "Multi-pane indicators"
|
|
|
680
718
|
still shares the candlestick pane rather than getting its own, since it draws through the
|
|
681
719
|
series itself, not a pane-targeted plugin. `invertValueAxis`/`setInvertValueAxis` mirror the
|
|
682
720
|
whole stack's value axis top-to-bottom without touching the underlying data — see "Inverting
|
|
683
|
-
the value axis" above.
|
|
721
|
+
the value axis" above. `setVisibleRange`/`setVisibleTimeRange` let the pan/zoom window be set
|
|
722
|
+
programmatically (see "Setting the visible range" above) — there's no pan/zoom *change* event
|
|
723
|
+
yet, so keeping one chart synced to another (`demo/sync.html`) means polling
|
|
724
|
+
`getVisibleTimeRange()` (e.g. once per animation frame) rather than reacting to a callback.
|
|
725
|
+
See [CHANGELOG.md](./CHANGELOG.md) for what shipped in each release.
|
|
684
726
|
|
|
685
727
|
## License
|
|
686
728
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Scale } from './hybridScale.js';
|
|
2
|
+
import type { ChartAxisOptions, ChartFontOptions } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Draws the price/time axis chrome — boundary lines, grid lines, tick
|
|
5
|
+
* labels, and the separator between stacked panes. Split out of
|
|
6
|
+
* `ChartRenderer` because none of it needs anything beyond already-resolved
|
|
7
|
+
* style options and per-call geometry (a `Scale`, a pixel rect): no
|
|
8
|
+
* series-specific, plugin-specific, or pane-identity state crosses into
|
|
9
|
+
* this class — `ChartRenderer.render()` still owns deciding *what* to draw
|
|
10
|
+
* where (the main pane vs. each indicator pane, in what order), this only
|
|
11
|
+
* owns *how* one axis actually gets drawn once told where.
|
|
12
|
+
*/
|
|
13
|
+
export declare class AxisRenderer {
|
|
14
|
+
private ctx;
|
|
15
|
+
private axis;
|
|
16
|
+
private font;
|
|
17
|
+
constructor(ctx: CanvasRenderingContext2D, axis: Required<ChartAxisOptions>, font: Required<ChartFontOptions>);
|
|
18
|
+
private axisFont;
|
|
19
|
+
/** The decimal precision `formatPrice` should use for a given value
|
|
20
|
+
* range — shared by this class's own tick labels and
|
|
21
|
+
* `CrosshairRenderer`'s price label so both display the same value with
|
|
22
|
+
* the same rounding. */
|
|
23
|
+
priceStep(min: number, max: number): number;
|
|
24
|
+
/**
|
|
25
|
+
* Draws one pane's right-side value axis: boundary line, horizontal grid
|
|
26
|
+
* lines, and tick labels. Used for both the main price pane and every
|
|
27
|
+
* indicator pane — `topOffset` shifts everything down by that pane's own
|
|
28
|
+
* position in the stack (0 for the main pane, which sits at the top), so
|
|
29
|
+
* `yScale` only ever has to know about its own pane-local [0, chartHeight]
|
|
30
|
+
* range and never about where that pane lives in the full canvas.
|
|
31
|
+
*/
|
|
32
|
+
renderPriceAxis(priceMin: number, priceMax: number, step: number, yScale: Scale, chartWidth: number, chartHeight: number, topOffset: number): void;
|
|
33
|
+
/** The horizontal rule separating an indicator pane from whatever sits
|
|
34
|
+
* above it (the main pane, or the previous indicator pane) — the same
|
|
35
|
+
* `axis.lineColor` boundary style `renderTimeAxis` already draws between
|
|
36
|
+
* the plotting area and the time-axis strip. */
|
|
37
|
+
renderPaneSeparator(top: number, chartWidth: number): void;
|
|
38
|
+
renderTimeAxis(times: number[], startIdx: number, visibleCount: number, chartHeight: number, chartWidth: number, xForIndex: (globalIndex: number) => number): void;
|
|
39
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { formatAxisLabel, pickTickIndices } from './axis.js';
|
|
2
|
+
import { formatPrice, niceTicks } from './priceAxis.js';
|
|
3
|
+
/**
|
|
4
|
+
* Draws the price/time axis chrome — boundary lines, grid lines, tick
|
|
5
|
+
* labels, and the separator between stacked panes. Split out of
|
|
6
|
+
* `ChartRenderer` because none of it needs anything beyond already-resolved
|
|
7
|
+
* style options and per-call geometry (a `Scale`, a pixel rect): no
|
|
8
|
+
* series-specific, plugin-specific, or pane-identity state crosses into
|
|
9
|
+
* this class — `ChartRenderer.render()` still owns deciding *what* to draw
|
|
10
|
+
* where (the main pane vs. each indicator pane, in what order), this only
|
|
11
|
+
* owns *how* one axis actually gets drawn once told where.
|
|
12
|
+
*/
|
|
13
|
+
export class AxisRenderer {
|
|
14
|
+
constructor(ctx, axis, font) {
|
|
15
|
+
this.ctx = ctx;
|
|
16
|
+
this.axis = axis;
|
|
17
|
+
this.font = font;
|
|
18
|
+
}
|
|
19
|
+
axisFont() {
|
|
20
|
+
return `${this.font.axisSize}px ${this.font.family}`;
|
|
21
|
+
}
|
|
22
|
+
/** The decimal precision `formatPrice` should use for a given value
|
|
23
|
+
* range — shared by this class's own tick labels and
|
|
24
|
+
* `CrosshairRenderer`'s price label so both display the same value with
|
|
25
|
+
* the same rounding. */
|
|
26
|
+
priceStep(min, max) {
|
|
27
|
+
const ticks = niceTicks(min, max, this.axis.priceTickCount);
|
|
28
|
+
return ticks.length > 1 ? ticks[1] - ticks[0] : 0;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Draws one pane's right-side value axis: boundary line, horizontal grid
|
|
32
|
+
* lines, and tick labels. Used for both the main price pane and every
|
|
33
|
+
* indicator pane — `topOffset` shifts everything down by that pane's own
|
|
34
|
+
* position in the stack (0 for the main pane, which sits at the top), so
|
|
35
|
+
* `yScale` only ever has to know about its own pane-local [0, chartHeight]
|
|
36
|
+
* range and never about where that pane lives in the full canvas.
|
|
37
|
+
*/
|
|
38
|
+
renderPriceAxis(priceMin, priceMax, step, yScale, chartWidth, chartHeight, topOffset) {
|
|
39
|
+
const { ctx, axis } = this;
|
|
40
|
+
const ticks = niceTicks(priceMin, priceMax, axis.priceTickCount);
|
|
41
|
+
ctx.strokeStyle = axis.lineColor;
|
|
42
|
+
ctx.beginPath();
|
|
43
|
+
ctx.moveTo(chartWidth + 0.5, topOffset);
|
|
44
|
+
ctx.lineTo(chartWidth + 0.5, topOffset + chartHeight);
|
|
45
|
+
ctx.stroke();
|
|
46
|
+
ctx.font = this.axisFont();
|
|
47
|
+
ctx.textAlign = 'left';
|
|
48
|
+
ctx.textBaseline = 'middle';
|
|
49
|
+
for (const value of ticks) {
|
|
50
|
+
const localY = yScale.map(value);
|
|
51
|
+
if (localY < 0 || localY > chartHeight)
|
|
52
|
+
continue;
|
|
53
|
+
const y = topOffset + localY;
|
|
54
|
+
ctx.strokeStyle = axis.gridLineColor;
|
|
55
|
+
ctx.beginPath();
|
|
56
|
+
ctx.moveTo(0, y + 0.5);
|
|
57
|
+
ctx.lineTo(chartWidth, y + 0.5);
|
|
58
|
+
ctx.stroke();
|
|
59
|
+
ctx.fillStyle = axis.textColor;
|
|
60
|
+
ctx.fillText(formatPrice(value, step), chartWidth + 6, y);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** The horizontal rule separating an indicator pane from whatever sits
|
|
64
|
+
* above it (the main pane, or the previous indicator pane) — the same
|
|
65
|
+
* `axis.lineColor` boundary style `renderTimeAxis` already draws between
|
|
66
|
+
* the plotting area and the time-axis strip. */
|
|
67
|
+
renderPaneSeparator(top, chartWidth) {
|
|
68
|
+
const { ctx, axis } = this;
|
|
69
|
+
ctx.strokeStyle = axis.lineColor;
|
|
70
|
+
ctx.beginPath();
|
|
71
|
+
ctx.moveTo(0, top + 0.5);
|
|
72
|
+
ctx.lineTo(chartWidth, top + 0.5);
|
|
73
|
+
ctx.stroke();
|
|
74
|
+
}
|
|
75
|
+
renderTimeAxis(times, startIdx, visibleCount, chartHeight, chartWidth, xForIndex) {
|
|
76
|
+
const { ctx, axis } = this;
|
|
77
|
+
const visibleTimes = times.slice(startIdx, startIdx + visibleCount);
|
|
78
|
+
const spanSeconds = visibleTimes[visibleTimes.length - 1] - visibleTimes[0];
|
|
79
|
+
ctx.strokeStyle = axis.lineColor;
|
|
80
|
+
ctx.beginPath();
|
|
81
|
+
ctx.moveTo(0, chartHeight + 0.5);
|
|
82
|
+
ctx.lineTo(chartWidth, chartHeight + 0.5);
|
|
83
|
+
ctx.stroke();
|
|
84
|
+
ctx.fillStyle = axis.textColor;
|
|
85
|
+
ctx.font = this.axisFont();
|
|
86
|
+
ctx.textAlign = 'center';
|
|
87
|
+
ctx.textBaseline = 'top';
|
|
88
|
+
for (const localIndex of pickTickIndices(visibleCount, axis.timeMaxTicks)) {
|
|
89
|
+
const x = xForIndex(startIdx + localIndex);
|
|
90
|
+
const label = formatAxisLabel(visibleTimes[localIndex], spanSeconds);
|
|
91
|
+
ctx.fillText(label, x, chartHeight + 6);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binary search over an ascending-sorted number array — extracted out of
|
|
3
|
+
* `WickChart` since it's pure array math with no dependency on chart state,
|
|
4
|
+
* used by `setVisibleTimeRange` to resolve a time to its index.
|
|
5
|
+
*/
|
|
6
|
+
/** First index in `values` (ascending) whose value is `>= target`, or
|
|
7
|
+
* `values.length` if every value is smaller — the standard binary
|
|
8
|
+
* lower-bound, O(log n) rather than a linear scan over what can be a
|
|
9
|
+
* multi-thousand-point loaded series. */
|
|
10
|
+
export declare function lowerBound(values: number[], target: number): number;
|
|
11
|
+
/** First index in `values` (ascending) whose value is `> target`, or
|
|
12
|
+
* `values.length` if none is — the exclusive end boundary for an inclusive
|
|
13
|
+
* upper bound, so a range `[lowerBound(from), upperBound(to))` includes
|
|
14
|
+
* every value in `[from, to]` inclusive on both ends. */
|
|
15
|
+
export declare function upperBound(values: number[], target: number): number;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binary search over an ascending-sorted number array — extracted out of
|
|
3
|
+
* `WickChart` since it's pure array math with no dependency on chart state,
|
|
4
|
+
* used by `setVisibleTimeRange` to resolve a time to its index.
|
|
5
|
+
*/
|
|
6
|
+
/** First index in `values` (ascending) whose value is `>= target`, or
|
|
7
|
+
* `values.length` if every value is smaller — the standard binary
|
|
8
|
+
* lower-bound, O(log n) rather than a linear scan over what can be a
|
|
9
|
+
* multi-thousand-point loaded series. */
|
|
10
|
+
export function lowerBound(values, target) {
|
|
11
|
+
let lo = 0;
|
|
12
|
+
let hi = values.length;
|
|
13
|
+
while (lo < hi) {
|
|
14
|
+
const mid = (lo + hi) >>> 1;
|
|
15
|
+
if (values[mid] < target)
|
|
16
|
+
lo = mid + 1;
|
|
17
|
+
else
|
|
18
|
+
hi = mid;
|
|
19
|
+
}
|
|
20
|
+
return lo;
|
|
21
|
+
}
|
|
22
|
+
/** First index in `values` (ascending) whose value is `> target`, or
|
|
23
|
+
* `values.length` if none is — the exclusive end boundary for an inclusive
|
|
24
|
+
* upper bound, so a range `[lowerBound(from), upperBound(to))` includes
|
|
25
|
+
* every value in `[from, to]` inclusive on both ends. */
|
|
26
|
+
export function upperBound(values, target) {
|
|
27
|
+
let lo = 0;
|
|
28
|
+
let hi = values.length;
|
|
29
|
+
while (lo < hi) {
|
|
30
|
+
const mid = (lo + hi) >>> 1;
|
|
31
|
+
if (values[mid] <= target)
|
|
32
|
+
lo = mid + 1;
|
|
33
|
+
else
|
|
34
|
+
hi = mid;
|
|
35
|
+
}
|
|
36
|
+
return lo;
|
|
37
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ChartCrosshairOptions, ChartFontOptions, ChartLegendOptions } from './types.js';
|
|
2
|
+
/** Everything one frame's hover crosshair/legend needs — computed by
|
|
3
|
+
* `ChartRenderer.render()` (which owns the hovered point, the active
|
|
4
|
+
* series, and its style) and handed in as plain data so this class stays
|
|
5
|
+
* generic-free and series-agnostic. `legendParts` is already the result of
|
|
6
|
+
* `seriesDefinition.formatLegend?.(point, style)` — this class only lays
|
|
7
|
+
* out and draws whatever strings it's given. */
|
|
8
|
+
export interface CrosshairRenderInput {
|
|
9
|
+
x: number;
|
|
10
|
+
timeSeconds: number;
|
|
11
|
+
hoverY: number | null;
|
|
12
|
+
valueMin: number;
|
|
13
|
+
valueMax: number;
|
|
14
|
+
priceStep: number;
|
|
15
|
+
chartWidth: number;
|
|
16
|
+
chartHeight: number;
|
|
17
|
+
/** Full pane-stack height — the dashed vertical line spans this, not
|
|
18
|
+
* just `chartHeight` (the main pane's own), so a hovered candle lines up
|
|
19
|
+
* across every indicator pane below it. */
|
|
20
|
+
stackHeight: number;
|
|
21
|
+
invertValueAxis: boolean;
|
|
22
|
+
legendParts: string[];
|
|
23
|
+
/** The canvas's own backing-store width — needed only to clamp the
|
|
24
|
+
* time-axis label chip so it never runs off the right edge. */
|
|
25
|
+
canvasWidth: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Draws the hover crosshair (dashed lines), its price/time label chips,
|
|
29
|
+
* and the OHLC-style legend tooltip. Split out of `ChartRenderer` for the
|
|
30
|
+
* same reason `AxisRenderer` was: everything it needs arrives as
|
|
31
|
+
* already-resolved style options plus one frame's worth of plain data
|
|
32
|
+
* (`CrosshairRenderInput`) — no series generic, no plugin state.
|
|
33
|
+
*/
|
|
34
|
+
export declare class CrosshairRenderer {
|
|
35
|
+
private ctx;
|
|
36
|
+
private crosshair;
|
|
37
|
+
private legend;
|
|
38
|
+
private font;
|
|
39
|
+
private priceAxisWidth;
|
|
40
|
+
constructor(ctx: CanvasRenderingContext2D, crosshair: Required<ChartCrosshairOptions>, legend: Required<ChartLegendOptions>, font: Required<ChartFontOptions>, priceAxisWidth: number);
|
|
41
|
+
private axisFont;
|
|
42
|
+
private legendFont;
|
|
43
|
+
render(input: CrosshairRenderInput): void;
|
|
44
|
+
/** The OHLC(+volume) tooltip — floats near the hovered pixel like a
|
|
45
|
+
* speech bubble, one line per part, rather than a fixed banner glued to
|
|
46
|
+
* a corner of the canvas. Offset up-and-right of the cursor/finger by
|
|
47
|
+
* `legend.cursorGap` and clamped to both chart edges so it never runs
|
|
48
|
+
* off-screen, including when there's no `hoverY` to anchor to (a series
|
|
49
|
+
* with no primary value still gets a legend, just pinned near the top
|
|
50
|
+
* at the hovered column). */
|
|
51
|
+
private renderHoverTooltip;
|
|
52
|
+
/** The highlighted price-axis label that follows the crosshair's
|
|
53
|
+
* horizontal line — drawn over `AxisRenderer`'s own tick labels so the
|
|
54
|
+
* hovered value reads clearly even where it lands between two ticks. */
|
|
55
|
+
private renderPriceLabelChip;
|
|
56
|
+
/** The highlighted time-axis label under the crosshair's vertical line.
|
|
57
|
+
* Clamped so its background chip stays fully on-screen even when the
|
|
58
|
+
* hovered point sits at the very first or last visible index. */
|
|
59
|
+
private renderTimeLabelChip;
|
|
60
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { formatHoverTime } from './axis.js';
|
|
2
|
+
import { formatPrice } from './priceAxis.js';
|
|
3
|
+
import { pixelToValue } from './valueAxis.js';
|
|
4
|
+
/**
|
|
5
|
+
* Draws the hover crosshair (dashed lines), its price/time label chips,
|
|
6
|
+
* and the OHLC-style legend tooltip. Split out of `ChartRenderer` for the
|
|
7
|
+
* same reason `AxisRenderer` was: everything it needs arrives as
|
|
8
|
+
* already-resolved style options plus one frame's worth of plain data
|
|
9
|
+
* (`CrosshairRenderInput`) — no series generic, no plugin state.
|
|
10
|
+
*/
|
|
11
|
+
export class CrosshairRenderer {
|
|
12
|
+
constructor(ctx, crosshair, legend, font, priceAxisWidth) {
|
|
13
|
+
this.ctx = ctx;
|
|
14
|
+
this.crosshair = crosshair;
|
|
15
|
+
this.legend = legend;
|
|
16
|
+
this.font = font;
|
|
17
|
+
this.priceAxisWidth = priceAxisWidth;
|
|
18
|
+
}
|
|
19
|
+
axisFont() {
|
|
20
|
+
return `${this.font.axisSize}px ${this.font.family}`;
|
|
21
|
+
}
|
|
22
|
+
legendFont() {
|
|
23
|
+
return `${this.font.legendSize}px ${this.font.family}`;
|
|
24
|
+
}
|
|
25
|
+
render(input) {
|
|
26
|
+
const { x, timeSeconds, hoverY, valueMin, valueMax, priceStep, chartWidth, chartHeight, stackHeight, invertValueAxis, legendParts, canvasWidth, } = input;
|
|
27
|
+
const { ctx, crosshair } = this;
|
|
28
|
+
ctx.save();
|
|
29
|
+
ctx.strokeStyle = crosshair.lineColor;
|
|
30
|
+
ctx.setLineDash([4, 4]);
|
|
31
|
+
// Spans the whole pane stack (not just the main pane's own
|
|
32
|
+
// chartHeight) so hovering a candle lines up with the same column
|
|
33
|
+
// across every indicator pane below it — see `stackHeight`'s own doc
|
|
34
|
+
// comment for why the horizontal line/legend don't follow suit.
|
|
35
|
+
ctx.beginPath();
|
|
36
|
+
ctx.moveTo(x, 0);
|
|
37
|
+
ctx.lineTo(x, stackHeight);
|
|
38
|
+
ctx.stroke();
|
|
39
|
+
// The horizontal line follows the actual cursor/finger position, not
|
|
40
|
+
// any property of the hovered point — pinning it to (say) the candle's
|
|
41
|
+
// close would leave it motionless while the pointer moves anywhere
|
|
42
|
+
// within that same candle's column, which reads as broken/stuck rather
|
|
43
|
+
// than as a crosshair. Only drawn while the pointer is actually inside
|
|
44
|
+
// the chart's vertical extent, same as AxisRenderer's tick-skip logic.
|
|
45
|
+
const priceLineVisible = hoverY !== null && hoverY >= 0 && hoverY <= chartHeight;
|
|
46
|
+
if (priceLineVisible) {
|
|
47
|
+
ctx.beginPath();
|
|
48
|
+
ctx.moveTo(0, hoverY);
|
|
49
|
+
ctx.lineTo(chartWidth, hoverY);
|
|
50
|
+
ctx.stroke();
|
|
51
|
+
}
|
|
52
|
+
ctx.restore();
|
|
53
|
+
if (priceLineVisible) {
|
|
54
|
+
// Exact inverse of the value->y mapping createScale set up for this
|
|
55
|
+
// frame — same helper (and same invertValueAxis flag) as
|
|
56
|
+
// PluginRenderApi.valueForY, see src/valueAxis.ts.
|
|
57
|
+
const value = pixelToValue(hoverY, valueMin, valueMax, chartHeight, invertValueAxis);
|
|
58
|
+
this.renderPriceLabelChip(formatPrice(value, priceStep), hoverY, chartWidth);
|
|
59
|
+
}
|
|
60
|
+
this.renderTimeLabelChip(formatHoverTime(timeSeconds), x, chartHeight, canvasWidth);
|
|
61
|
+
if (legendParts.length === 0)
|
|
62
|
+
return;
|
|
63
|
+
this.renderHoverTooltip(legendParts, x, hoverY, chartWidth, chartHeight);
|
|
64
|
+
}
|
|
65
|
+
/** The OHLC(+volume) tooltip — floats near the hovered pixel like a
|
|
66
|
+
* speech bubble, one line per part, rather than a fixed banner glued to
|
|
67
|
+
* a corner of the canvas. Offset up-and-right of the cursor/finger by
|
|
68
|
+
* `legend.cursorGap` and clamped to both chart edges so it never runs
|
|
69
|
+
* off-screen, including when there's no `hoverY` to anchor to (a series
|
|
70
|
+
* with no primary value still gets a legend, just pinned near the top
|
|
71
|
+
* at the hovered column). */
|
|
72
|
+
renderHoverTooltip(lines, x, hoverY, chartWidth, chartHeight) {
|
|
73
|
+
const { ctx, font, legend } = this;
|
|
74
|
+
ctx.font = this.legendFont();
|
|
75
|
+
ctx.textAlign = 'left';
|
|
76
|
+
ctx.textBaseline = 'top';
|
|
77
|
+
const lineHeight = font.legendSize + 4;
|
|
78
|
+
const textWidth = Math.max(...lines.map((line) => ctx.measureText(line).width));
|
|
79
|
+
const boxWidth = textWidth + legend.paddingX * 2;
|
|
80
|
+
const boxHeight = lines.length * lineHeight + legend.paddingY * 2;
|
|
81
|
+
const anchorY = hoverY ?? 0;
|
|
82
|
+
const left = Math.min(Math.max(x + legend.cursorGap, 0), Math.max(0, chartWidth - boxWidth));
|
|
83
|
+
const top = Math.min(Math.max(anchorY - boxHeight - legend.cursorGap, 0), Math.max(0, chartHeight - boxHeight));
|
|
84
|
+
ctx.fillStyle = legend.background;
|
|
85
|
+
ctx.fillRect(left, top, boxWidth, boxHeight);
|
|
86
|
+
ctx.fillStyle = legend.textColor;
|
|
87
|
+
lines.forEach((line, i) => {
|
|
88
|
+
ctx.fillText(line, left + legend.paddingX, top + legend.paddingY + i * lineHeight);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/** The highlighted price-axis label that follows the crosshair's
|
|
92
|
+
* horizontal line — drawn over `AxisRenderer`'s own tick labels so the
|
|
93
|
+
* hovered value reads clearly even where it lands between two ticks. */
|
|
94
|
+
renderPriceLabelChip(text, y, chartWidth) {
|
|
95
|
+
const { ctx, font, crosshair } = this;
|
|
96
|
+
ctx.font = this.axisFont();
|
|
97
|
+
const chipHeight = font.axisSize + crosshair.labelPaddingY * 2;
|
|
98
|
+
ctx.fillStyle = crosshair.labelBackground;
|
|
99
|
+
ctx.fillRect(chartWidth, y - chipHeight / 2, this.priceAxisWidth, chipHeight);
|
|
100
|
+
ctx.fillStyle = crosshair.labelTextColor;
|
|
101
|
+
ctx.textAlign = 'left';
|
|
102
|
+
ctx.textBaseline = 'middle';
|
|
103
|
+
ctx.fillText(text, chartWidth + crosshair.labelPaddingX, y);
|
|
104
|
+
}
|
|
105
|
+
/** The highlighted time-axis label under the crosshair's vertical line.
|
|
106
|
+
* Clamped so its background chip stays fully on-screen even when the
|
|
107
|
+
* hovered point sits at the very first or last visible index. */
|
|
108
|
+
renderTimeLabelChip(text, x, chartHeight, canvasWidth) {
|
|
109
|
+
const { ctx, font, crosshair } = this;
|
|
110
|
+
ctx.font = this.axisFont();
|
|
111
|
+
const chipWidth = ctx.measureText(text).width + crosshair.labelPaddingX * 2;
|
|
112
|
+
const chipHeight = font.axisSize + crosshair.labelPaddingY * 2;
|
|
113
|
+
const chipLeft = Math.min(Math.max(x - chipWidth / 2, 0), canvasWidth - chipWidth);
|
|
114
|
+
ctx.fillStyle = crosshair.labelBackground;
|
|
115
|
+
ctx.fillRect(chipLeft, chartHeight, chipWidth, chipHeight);
|
|
116
|
+
ctx.fillStyle = crosshair.labelTextColor;
|
|
117
|
+
ctx.textAlign = 'left';
|
|
118
|
+
ctx.textBaseline = 'top';
|
|
119
|
+
ctx.fillText(text, chipLeft + crosshair.labelPaddingX, chartHeight + crosshair.labelPaddingY);
|
|
120
|
+
}
|
|
121
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ 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
4
|
import type { LineStyle } from './series/line.js';
|
|
5
|
-
import type { Candle, LinePoint, PaneOptions, WickChartOptions, SeriesPoint, ValueRange } from './types.js';
|
|
5
|
+
import type { Candle, LinePoint, PaneOptions, WickChartOptions, SeriesPoint, ValueRange, WickTime } from './types.js';
|
|
6
6
|
export type { BusinessDay, Candle, LinePoint, PaneOptions, WickChartOptions, WickTime, SeriesPoint, UnixMillis, ValueRange, } from './types.js';
|
|
7
7
|
export type { DataLoader, DataRequest } from './dataSource.js';
|
|
8
8
|
export type { ChartPlugin, ChartPointerEvent, PluginRenderApi } from './plugins/types.js';
|
|
@@ -155,6 +155,48 @@ export declare class WickChart<TPoint extends SeriesPoint = Candle> {
|
|
|
155
155
|
endIndex: number;
|
|
156
156
|
visibleCount: number;
|
|
157
157
|
};
|
|
158
|
+
/**
|
|
159
|
+
* Jumps the visible pan/zoom window directly to `[startIndex, endIndex)`
|
|
160
|
+
* — the programmatic, index-based counterpart to `getVisibleRange()`'s
|
|
161
|
+
* own shape, for anything that already knows the target in index terms
|
|
162
|
+
* (a minimap click, a saved bookmark). Clamped the same way a drag/zoom
|
|
163
|
+
* gesture already is (see `Viewport.setVisibleIndexRange`) rather than
|
|
164
|
+
* throwing on an out-of-range request. Clears the current hover, the
|
|
165
|
+
* same way `setData()` does — a jump is a discontinuous change, and a
|
|
166
|
+
* hover position computed for the window before it is no longer
|
|
167
|
+
* meaningful until the pointer actually moves again.
|
|
168
|
+
*
|
|
169
|
+
* Indices aren't a safe way to sync two independent `WickChart`
|
|
170
|
+
* instances sharing a time axis — they may have loaded different
|
|
171
|
+
* amounts of history via `setDataLoader`, so the same index means a
|
|
172
|
+
* different point in each. Use `setVisibleTimeRange` for that instead.
|
|
173
|
+
*/
|
|
174
|
+
setVisibleRange(range: {
|
|
175
|
+
startIndex: number;
|
|
176
|
+
endIndex: number;
|
|
177
|
+
}): this;
|
|
178
|
+
/**
|
|
179
|
+
* The time-based counterpart to `setVisibleRange` — jumps to whatever
|
|
180
|
+
* window of the currently loaded data falls within `[from, to]`
|
|
181
|
+
* (inclusive both ends), resolved against this chart's own loaded
|
|
182
|
+
* points. This is what makes syncing one chart's pan/zoom onto another
|
|
183
|
+
* independent `WickChart` instance sharing a time axis possible: read
|
|
184
|
+
* the source chart's `getVisibleTimeRange()` and pass it straight to
|
|
185
|
+
* this one, and the two stay in sync by time even if they've loaded
|
|
186
|
+
* different amounts of history. A no-op if no data has been loaded yet.
|
|
187
|
+
*/
|
|
188
|
+
setVisibleTimeRange(range: {
|
|
189
|
+
from: WickTime;
|
|
190
|
+
to: WickTime;
|
|
191
|
+
}): this;
|
|
192
|
+
/** The currently visible window's time span, in unix seconds — `null`
|
|
193
|
+
* when there's no data to report one for. The time-based counterpart to
|
|
194
|
+
* `getVisibleRange()`, and the read side `setVisibleTimeRange` is meant
|
|
195
|
+
* to be paired with for syncing one chart's pan/zoom onto another. */
|
|
196
|
+
getVisibleTimeRange(): {
|
|
197
|
+
from: number;
|
|
198
|
+
to: number;
|
|
199
|
+
} | null;
|
|
158
200
|
/** The value axis's manual range once the user has dragged or scaled it
|
|
159
201
|
* — `null` if the axis is still auto-fitting to whatever's visible
|
|
160
202
|
* (the default until the user first touches it vertically). */
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { lowerBound, upperBound } from './binarySearch.js';
|
|
1
2
|
import { mergeSeriesPoints } from './mergeSeries.js';
|
|
2
3
|
import { computePaneLayout } from './paneLayout.js';
|
|
3
4
|
import { ChartRenderer } from './renderer.js';
|
|
@@ -452,6 +453,62 @@ export class WickChart {
|
|
|
452
453
|
visibleCount: this.viewport.visibleCount,
|
|
453
454
|
};
|
|
454
455
|
}
|
|
456
|
+
/**
|
|
457
|
+
* Jumps the visible pan/zoom window directly to `[startIndex, endIndex)`
|
|
458
|
+
* — the programmatic, index-based counterpart to `getVisibleRange()`'s
|
|
459
|
+
* own shape, for anything that already knows the target in index terms
|
|
460
|
+
* (a minimap click, a saved bookmark). Clamped the same way a drag/zoom
|
|
461
|
+
* gesture already is (see `Viewport.setVisibleIndexRange`) rather than
|
|
462
|
+
* throwing on an out-of-range request. Clears the current hover, the
|
|
463
|
+
* same way `setData()` does — a jump is a discontinuous change, and a
|
|
464
|
+
* hover position computed for the window before it is no longer
|
|
465
|
+
* meaningful until the pointer actually moves again.
|
|
466
|
+
*
|
|
467
|
+
* Indices aren't a safe way to sync two independent `WickChart`
|
|
468
|
+
* instances sharing a time axis — they may have loaded different
|
|
469
|
+
* amounts of history via `setDataLoader`, so the same index means a
|
|
470
|
+
* different point in each. Use `setVisibleTimeRange` for that instead.
|
|
471
|
+
*/
|
|
472
|
+
setVisibleRange(range) {
|
|
473
|
+
this.viewport.setVisibleIndexRange(range.startIndex, range.endIndex, this.sorted.length);
|
|
474
|
+
this.hoverIndex = null;
|
|
475
|
+
this.hoverY = null;
|
|
476
|
+
this.scheduleRender();
|
|
477
|
+
return this;
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* The time-based counterpart to `setVisibleRange` — jumps to whatever
|
|
481
|
+
* window of the currently loaded data falls within `[from, to]`
|
|
482
|
+
* (inclusive both ends), resolved against this chart's own loaded
|
|
483
|
+
* points. This is what makes syncing one chart's pan/zoom onto another
|
|
484
|
+
* independent `WickChart` instance sharing a time axis possible: read
|
|
485
|
+
* the source chart's `getVisibleTimeRange()` and pass it straight to
|
|
486
|
+
* this one, and the two stay in sync by time even if they've loaded
|
|
487
|
+
* different amounts of history. A no-op if no data has been loaded yet.
|
|
488
|
+
*/
|
|
489
|
+
setVisibleTimeRange(range) {
|
|
490
|
+
if (this.times.length === 0)
|
|
491
|
+
return this;
|
|
492
|
+
const fromSeconds = toUnixSeconds(range.from);
|
|
493
|
+
const toSeconds = toUnixSeconds(range.to);
|
|
494
|
+
return this.setVisibleRange({
|
|
495
|
+
startIndex: lowerBound(this.times, fromSeconds),
|
|
496
|
+
endIndex: upperBound(this.times, toSeconds),
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
/** The currently visible window's time span, in unix seconds — `null`
|
|
500
|
+
* when there's no data to report one for. The time-based counterpart to
|
|
501
|
+
* `getVisibleRange()`, and the read side `setVisibleTimeRange` is meant
|
|
502
|
+
* to be paired with for syncing one chart's pan/zoom onto another. */
|
|
503
|
+
getVisibleTimeRange() {
|
|
504
|
+
if (this.sorted.length === 0)
|
|
505
|
+
return null;
|
|
506
|
+
const startIdx = Math.max(0, Math.floor(this.viewport.startIndex));
|
|
507
|
+
const endIdx = Math.min(this.sorted.length, Math.ceil(this.viewport.endIndex));
|
|
508
|
+
if (endIdx <= startIdx)
|
|
509
|
+
return null;
|
|
510
|
+
return { from: this.times[startIdx], to: this.times[endIdx - 1] };
|
|
511
|
+
}
|
|
455
512
|
/** The value axis's manual range once the user has dragged or scaled it
|
|
456
513
|
* — `null` if the axis is still auto-fitting to whatever's visible
|
|
457
514
|
* (the default until the user first touches it vertically). */
|
package/dist/renderer.d.ts
CHANGED
|
@@ -12,8 +12,8 @@ export interface RenderInput<TPoint extends SeriesPoint> {
|
|
|
12
12
|
hoverIndex: number | null;
|
|
13
13
|
/** Device-pixel y of the pointer/finger that produced `hoverIndex`, or
|
|
14
14
|
* null. Drives the crosshair's horizontal line directly — see
|
|
15
|
-
* `
|
|
16
|
-
*
|
|
15
|
+
* `CrosshairRenderer` for why that has to be the raw cursor position
|
|
16
|
+
* rather than any property of the hovered point itself. */
|
|
17
17
|
hoverY: number | null;
|
|
18
18
|
plugins: ChartPlugin<TPoint>[];
|
|
19
19
|
/** Indicator/oscillator panes declared via `WickChart.addPane`, resolved
|
|
@@ -23,15 +23,22 @@ export interface RenderInput<TPoint extends SeriesPoint> {
|
|
|
23
23
|
panes: ResolvedPaneOptions[];
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
|
-
* The chart engine's renderer: canvas lifecycle
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
26
|
+
* The chart engine's renderer: owns the canvas lifecycle and orchestrates
|
|
27
|
+
* one frame — deciding what data is visible, computing scales, and calling
|
|
28
|
+
* out to collaborators for the actual pixel-pushing. None of it knows what
|
|
29
|
+
* kind of series is on screen: the one series-specific seam is
|
|
30
|
+
* `seriesDefinition`, injected at construction (see `src/series/types.ts`).
|
|
31
31
|
* Stateless per call otherwise — all pan/zoom/hover state lives in
|
|
32
32
|
* `Viewport` and `WickChart`; this class only turns a snapshot of that
|
|
33
33
|
* state into pixels.
|
|
34
34
|
*
|
|
35
|
+
* Axis chrome and the hover crosshair/legend are drawn by two collaborators
|
|
36
|
+
* (`AxisRenderer`, `CrosshairRenderer`) rather than methods on this class —
|
|
37
|
+
* both take only already-resolved style options and per-call geometry, no
|
|
38
|
+
* series generic or plugin state, so splitting them out keeps this file
|
|
39
|
+
* focused on orchestration (what gets drawn, in what order, with what
|
|
40
|
+
* scale) rather than mixing in how each individual chrome element paints.
|
|
41
|
+
*
|
|
35
42
|
* Every visual constant below (fonts, axis sizing/coloring, crosshair
|
|
36
43
|
* coloring/padding, legend color) is resolved once at construction from
|
|
37
44
|
* `WickChartOptions.font`/`axis`/`crosshair`/`legend`, each merged field
|
|
@@ -44,10 +51,13 @@ export declare class ChartRenderer<TPoint extends SeriesPoint> {
|
|
|
44
51
|
private ctx;
|
|
45
52
|
private background;
|
|
46
53
|
private style;
|
|
47
|
-
|
|
54
|
+
/** Kept as its own field (unlike font/crosshair/legend, which only
|
|
55
|
+
* `AxisRenderer`/`CrosshairRenderer` need after construction) because
|
|
56
|
+
* `chartWidth`/`chartHeight`/`priceAxisWidth` below read it directly on
|
|
57
|
+
* every call, not just once at construction. */
|
|
48
58
|
private axis;
|
|
49
|
-
private
|
|
50
|
-
private
|
|
59
|
+
private axisRenderer;
|
|
60
|
+
private crosshairRenderer;
|
|
51
61
|
/** Unlike the style groups above, mutable after construction — see
|
|
52
62
|
* `setInvertValueAxis`. A live toggle, not a one-time style choice, is
|
|
53
63
|
* the whole point of this option (a "what if this series moved the
|
|
@@ -62,8 +72,6 @@ export declare class ChartRenderer<TPoint extends SeriesPoint> {
|
|
|
62
72
|
get chartWidth(): number;
|
|
63
73
|
get chartHeight(): number;
|
|
64
74
|
get priceAxisWidth(): number;
|
|
65
|
-
private axisFont;
|
|
66
|
-
private legendFont;
|
|
67
75
|
render(input: RenderInput<TPoint>): void;
|
|
68
76
|
/**
|
|
69
77
|
* Builds the `PluginRenderApi` for one pane — the main price pane or a
|
|
@@ -73,40 +81,4 @@ export declare class ChartRenderer<TPoint extends SeriesPoint> {
|
|
|
73
81
|
* frame-ended flag, for the whole stack; see `FrameGeometry`).
|
|
74
82
|
*/
|
|
75
83
|
private buildPluginApi;
|
|
76
|
-
/** The decimal precision `formatPrice` should use for the current price
|
|
77
|
-
* range — shared by the axis ticks and the crosshair's price label so
|
|
78
|
-
* both display the same value with the same rounding. */
|
|
79
|
-
private currentPriceStep;
|
|
80
|
-
/**
|
|
81
|
-
* Draws one pane's right-side value axis: boundary line, horizontal grid
|
|
82
|
-
* lines, and tick labels. Used for both the main price pane and every
|
|
83
|
-
* indicator pane — `topOffset` shifts everything down by that pane's own
|
|
84
|
-
* position in the stack (0 for the main pane, which sits at the top), so
|
|
85
|
-
* `yScale` only ever has to know about its own pane-local [0, chartHeight]
|
|
86
|
-
* range and never about where that pane lives in the full canvas.
|
|
87
|
-
*/
|
|
88
|
-
private renderPriceAxis;
|
|
89
|
-
/** The horizontal rule separating an indicator pane from whatever sits
|
|
90
|
-
* above it (the main pane, or the previous indicator pane) — the same
|
|
91
|
-
* `axis.lineColor` boundary style `renderTimeAxis` already draws between
|
|
92
|
-
* the plotting area and the time-axis strip. */
|
|
93
|
-
private renderPaneSeparator;
|
|
94
|
-
private renderTimeAxis;
|
|
95
|
-
private renderCrosshairAndLegend;
|
|
96
|
-
/** The OHLC(+volume) tooltip — floats near the hovered pixel like a
|
|
97
|
-
* speech bubble, one line per part, rather than a fixed banner glued to
|
|
98
|
-
* a corner of the canvas. Offset up-and-right of the cursor/finger by
|
|
99
|
-
* `legend.cursorGap` and clamped to both chart edges so it never runs
|
|
100
|
-
* off-screen, including when there's no `hoverY` to anchor to (a series
|
|
101
|
-
* with no primary value still gets a legend, just pinned near the top
|
|
102
|
-
* at the hovered column). */
|
|
103
|
-
private renderHoverTooltip;
|
|
104
|
-
/** The highlighted price-axis label that follows the crosshair's
|
|
105
|
-
* horizontal line — drawn over `renderPriceAxis`'s own tick labels so the
|
|
106
|
-
* hovered value reads clearly even where it lands between two ticks. */
|
|
107
|
-
private renderPriceLabelChip;
|
|
108
|
-
/** The highlighted time-axis label under the crosshair's vertical line.
|
|
109
|
-
* Clamped so its background chip stays fully on-screen even when the
|
|
110
|
-
* hovered point sits at the very first or last visible index. */
|
|
111
|
-
private renderTimeLabelChip;
|
|
112
84
|
}
|
package/dist/renderer.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AxisRenderer } from './axisRenderer.js';
|
|
2
|
+
import { CrosshairRenderer } from './crosshairRenderer.js';
|
|
2
3
|
import { createScale } from './hybridScale.js';
|
|
3
4
|
import { computePaneLayout } from './paneLayout.js';
|
|
4
|
-
import { formatPrice, niceTicks } from './priceAxis.js';
|
|
5
5
|
import { pixelToValue, valueAxisPixelRange } from './valueAxis.js';
|
|
6
6
|
const DEFAULT_BACKGROUND = 'transparent';
|
|
7
7
|
const DEFAULT_FONT = {
|
|
@@ -33,15 +33,22 @@ const DEFAULT_LEGEND = {
|
|
|
33
33
|
cursorGap: 12,
|
|
34
34
|
};
|
|
35
35
|
/**
|
|
36
|
-
* The chart engine's renderer: canvas lifecycle
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
36
|
+
* The chart engine's renderer: owns the canvas lifecycle and orchestrates
|
|
37
|
+
* one frame — deciding what data is visible, computing scales, and calling
|
|
38
|
+
* out to collaborators for the actual pixel-pushing. None of it knows what
|
|
39
|
+
* kind of series is on screen: the one series-specific seam is
|
|
40
|
+
* `seriesDefinition`, injected at construction (see `src/series/types.ts`).
|
|
41
41
|
* Stateless per call otherwise — all pan/zoom/hover state lives in
|
|
42
42
|
* `Viewport` and `WickChart`; this class only turns a snapshot of that
|
|
43
43
|
* state into pixels.
|
|
44
44
|
*
|
|
45
|
+
* Axis chrome and the hover crosshair/legend are drawn by two collaborators
|
|
46
|
+
* (`AxisRenderer`, `CrosshairRenderer`) rather than methods on this class —
|
|
47
|
+
* both take only already-resolved style options and per-call geometry, no
|
|
48
|
+
* series generic or plugin state, so splitting them out keeps this file
|
|
49
|
+
* focused on orchestration (what gets drawn, in what order, with what
|
|
50
|
+
* scale) rather than mixing in how each individual chrome element paints.
|
|
51
|
+
*
|
|
45
52
|
* Every visual constant below (fonts, axis sizing/coloring, crosshair
|
|
46
53
|
* coloring/padding, legend color) is resolved once at construction from
|
|
47
54
|
* `WickChartOptions.font`/`axis`/`crosshair`/`legend`, each merged field
|
|
@@ -58,11 +65,13 @@ export class ChartRenderer {
|
|
|
58
65
|
this.ctx = ctx;
|
|
59
66
|
this.background = options.background ?? DEFAULT_BACKGROUND;
|
|
60
67
|
this.style = { ...seriesDefinition.defaultStyle, ...(options.style ?? {}) };
|
|
61
|
-
this.font = { ...DEFAULT_FONT, ...options.font };
|
|
62
68
|
this.axis = { ...DEFAULT_AXIS, ...options.axis };
|
|
63
|
-
this.crosshair = { ...DEFAULT_CROSSHAIR, ...options.crosshair };
|
|
64
|
-
this.legend = { ...DEFAULT_LEGEND, ...options.legend };
|
|
65
69
|
this.invertValueAxis = options.invertValueAxis ?? false;
|
|
70
|
+
const font = { ...DEFAULT_FONT, ...options.font };
|
|
71
|
+
const crosshair = { ...DEFAULT_CROSSHAIR, ...options.crosshair };
|
|
72
|
+
const legend = { ...DEFAULT_LEGEND, ...options.legend };
|
|
73
|
+
this.axisRenderer = new AxisRenderer(ctx, this.axis, font);
|
|
74
|
+
this.crosshairRenderer = new CrosshairRenderer(ctx, crosshair, legend, font, this.axis.priceWidth);
|
|
66
75
|
}
|
|
67
76
|
setInvertValueAxis(inverted) {
|
|
68
77
|
this.invertValueAxis = inverted;
|
|
@@ -79,12 +88,6 @@ export class ChartRenderer {
|
|
|
79
88
|
get priceAxisWidth() {
|
|
80
89
|
return this.axis.priceWidth;
|
|
81
90
|
}
|
|
82
|
-
axisFont() {
|
|
83
|
-
return `${this.font.axisSize}px ${this.font.family}`;
|
|
84
|
-
}
|
|
85
|
-
legendFont() {
|
|
86
|
-
return `${this.font.legendSize}px ${this.font.family}`;
|
|
87
|
-
}
|
|
88
91
|
render(input) {
|
|
89
92
|
const { ctx, canvas, background, seriesDefinition, style } = this;
|
|
90
93
|
const { sorted, times, viewport, hoverIndex, hoverY, plugins, panes } = input;
|
|
@@ -166,20 +169,34 @@ export class ChartRenderer {
|
|
|
166
169
|
finally {
|
|
167
170
|
ctx.restore();
|
|
168
171
|
}
|
|
169
|
-
const priceStep = this.
|
|
170
|
-
this.renderPriceAxis(valueMin, valueMax, priceStep, yScale, chartWidth, chartHeight, mainRect.top);
|
|
172
|
+
const priceStep = this.axisRenderer.priceStep(valueMin, valueMax);
|
|
173
|
+
this.axisRenderer.renderPriceAxis(valueMin, valueMax, priceStep, yScale, chartWidth, chartHeight, mainRect.top);
|
|
171
174
|
for (const { rect, min, max, scale } of paneScales) {
|
|
172
|
-
this.renderPaneSeparator(rect.top, chartWidth);
|
|
173
|
-
const step = this.
|
|
174
|
-
this.renderPriceAxis(min, max, step, scale, chartWidth, rect.height, rect.top);
|
|
175
|
+
this.axisRenderer.renderPaneSeparator(rect.top, chartWidth);
|
|
176
|
+
const step = this.axisRenderer.priceStep(min, max);
|
|
177
|
+
this.axisRenderer.renderPriceAxis(min, max, step, scale, chartWidth, rect.height, rect.top);
|
|
175
178
|
}
|
|
176
|
-
this.renderTimeAxis(times, startIdx, visible.length, stackHeight, chartWidth, xForIndex);
|
|
179
|
+
this.axisRenderer.renderTimeAxis(times, startIdx, visible.length, stackHeight, chartWidth, xForIndex);
|
|
177
180
|
if (hoverIndex !== null && hoverIndex >= startIdx && hoverIndex < endIdx) {
|
|
178
181
|
// The dashed vertical line spans the whole stack (every pane); the
|
|
179
182
|
// horizontal line, price-label chip, and OHLC legend stay scoped
|
|
180
183
|
// to the main pane only — an indicator pane's own hover readout,
|
|
181
184
|
// if it wants one, is the job of whatever plugin draws into it.
|
|
182
|
-
|
|
185
|
+
const legendParts = seriesDefinition.formatLegend?.(sorted[hoverIndex], style) ?? [];
|
|
186
|
+
this.crosshairRenderer.render({
|
|
187
|
+
x: xForIndex(hoverIndex),
|
|
188
|
+
timeSeconds: times[hoverIndex],
|
|
189
|
+
hoverY,
|
|
190
|
+
valueMin,
|
|
191
|
+
valueMax,
|
|
192
|
+
priceStep,
|
|
193
|
+
chartWidth,
|
|
194
|
+
chartHeight,
|
|
195
|
+
stackHeight,
|
|
196
|
+
invertValueAxis: this.invertValueAxis,
|
|
197
|
+
legendParts,
|
|
198
|
+
canvasWidth: canvas.width,
|
|
199
|
+
});
|
|
183
200
|
}
|
|
184
201
|
if (plugins.length > 0) {
|
|
185
202
|
// Everything every pane's PluginRenderApi shares — only the pane's
|
|
@@ -267,172 +284,4 @@ export class ChartRenderer {
|
|
|
267
284
|
allPoints,
|
|
268
285
|
};
|
|
269
286
|
}
|
|
270
|
-
/** The decimal precision `formatPrice` should use for the current price
|
|
271
|
-
* range — shared by the axis ticks and the crosshair's price label so
|
|
272
|
-
* both display the same value with the same rounding. */
|
|
273
|
-
currentPriceStep(priceMin, priceMax) {
|
|
274
|
-
const ticks = niceTicks(priceMin, priceMax, this.axis.priceTickCount);
|
|
275
|
-
return ticks.length > 1 ? ticks[1] - ticks[0] : 0;
|
|
276
|
-
}
|
|
277
|
-
/**
|
|
278
|
-
* Draws one pane's right-side value axis: boundary line, horizontal grid
|
|
279
|
-
* lines, and tick labels. Used for both the main price pane and every
|
|
280
|
-
* indicator pane — `topOffset` shifts everything down by that pane's own
|
|
281
|
-
* position in the stack (0 for the main pane, which sits at the top), so
|
|
282
|
-
* `yScale` only ever has to know about its own pane-local [0, chartHeight]
|
|
283
|
-
* range and never about where that pane lives in the full canvas.
|
|
284
|
-
*/
|
|
285
|
-
renderPriceAxis(priceMin, priceMax, step, yScale, chartWidth, chartHeight, topOffset) {
|
|
286
|
-
const { ctx, axis } = this;
|
|
287
|
-
const ticks = niceTicks(priceMin, priceMax, axis.priceTickCount);
|
|
288
|
-
ctx.strokeStyle = axis.lineColor;
|
|
289
|
-
ctx.beginPath();
|
|
290
|
-
ctx.moveTo(chartWidth + 0.5, topOffset);
|
|
291
|
-
ctx.lineTo(chartWidth + 0.5, topOffset + chartHeight);
|
|
292
|
-
ctx.stroke();
|
|
293
|
-
ctx.font = this.axisFont();
|
|
294
|
-
ctx.textAlign = 'left';
|
|
295
|
-
ctx.textBaseline = 'middle';
|
|
296
|
-
for (const value of ticks) {
|
|
297
|
-
const localY = yScale.map(value);
|
|
298
|
-
if (localY < 0 || localY > chartHeight)
|
|
299
|
-
continue;
|
|
300
|
-
const y = topOffset + localY;
|
|
301
|
-
ctx.strokeStyle = axis.gridLineColor;
|
|
302
|
-
ctx.beginPath();
|
|
303
|
-
ctx.moveTo(0, y + 0.5);
|
|
304
|
-
ctx.lineTo(chartWidth, y + 0.5);
|
|
305
|
-
ctx.stroke();
|
|
306
|
-
ctx.fillStyle = axis.textColor;
|
|
307
|
-
ctx.fillText(formatPrice(value, step), chartWidth + 6, y);
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
/** The horizontal rule separating an indicator pane from whatever sits
|
|
311
|
-
* above it (the main pane, or the previous indicator pane) — the same
|
|
312
|
-
* `axis.lineColor` boundary style `renderTimeAxis` already draws between
|
|
313
|
-
* the plotting area and the time-axis strip. */
|
|
314
|
-
renderPaneSeparator(top, chartWidth) {
|
|
315
|
-
const { ctx, axis } = this;
|
|
316
|
-
ctx.strokeStyle = axis.lineColor;
|
|
317
|
-
ctx.beginPath();
|
|
318
|
-
ctx.moveTo(0, top + 0.5);
|
|
319
|
-
ctx.lineTo(chartWidth, top + 0.5);
|
|
320
|
-
ctx.stroke();
|
|
321
|
-
}
|
|
322
|
-
renderTimeAxis(times, startIdx, visibleCount, chartHeight, chartWidth, xForIndex) {
|
|
323
|
-
const { ctx, axis } = this;
|
|
324
|
-
const visibleTimes = times.slice(startIdx, startIdx + visibleCount);
|
|
325
|
-
const spanSeconds = visibleTimes[visibleTimes.length - 1] - visibleTimes[0];
|
|
326
|
-
ctx.strokeStyle = axis.lineColor;
|
|
327
|
-
ctx.beginPath();
|
|
328
|
-
ctx.moveTo(0, chartHeight + 0.5);
|
|
329
|
-
ctx.lineTo(chartWidth, chartHeight + 0.5);
|
|
330
|
-
ctx.stroke();
|
|
331
|
-
ctx.fillStyle = axis.textColor;
|
|
332
|
-
ctx.font = this.axisFont();
|
|
333
|
-
ctx.textAlign = 'center';
|
|
334
|
-
ctx.textBaseline = 'top';
|
|
335
|
-
for (const localIndex of pickTickIndices(visibleCount, axis.timeMaxTicks)) {
|
|
336
|
-
const x = xForIndex(startIdx + localIndex);
|
|
337
|
-
const label = formatAxisLabel(visibleTimes[localIndex], spanSeconds);
|
|
338
|
-
ctx.fillText(label, x, chartHeight + 6);
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
renderCrosshairAndLegend(point, x, timeSeconds, hoverY, valueMin, valueMax, priceStep, chartWidth, chartHeight, stackHeight) {
|
|
342
|
-
const { ctx, canvas, seriesDefinition, style, crosshair } = this;
|
|
343
|
-
ctx.save();
|
|
344
|
-
ctx.strokeStyle = crosshair.lineColor;
|
|
345
|
-
ctx.setLineDash([4, 4]);
|
|
346
|
-
// Spans the whole pane stack (not just the main pane's own
|
|
347
|
-
// chartHeight) so hovering a candle lines up with the same column
|
|
348
|
-
// across every indicator pane below it — see the call site's comment
|
|
349
|
-
// in `render()` for why the horizontal line/legend don't follow suit.
|
|
350
|
-
ctx.beginPath();
|
|
351
|
-
ctx.moveTo(x, 0);
|
|
352
|
-
ctx.lineTo(x, stackHeight);
|
|
353
|
-
ctx.stroke();
|
|
354
|
-
// The horizontal line follows the actual cursor/finger position, not
|
|
355
|
-
// any property of the hovered point — pinning it to (say) the candle's
|
|
356
|
-
// close would leave it motionless while the pointer moves anywhere
|
|
357
|
-
// within that same candle's column, which reads as broken/stuck rather
|
|
358
|
-
// than as a crosshair. Only drawn while the pointer is actually inside
|
|
359
|
-
// the chart's vertical extent, same as the price-axis tick-skip logic
|
|
360
|
-
// in renderPriceAxis.
|
|
361
|
-
const priceLineVisible = hoverY !== null && hoverY >= 0 && hoverY <= chartHeight;
|
|
362
|
-
if (priceLineVisible) {
|
|
363
|
-
ctx.beginPath();
|
|
364
|
-
ctx.moveTo(0, hoverY);
|
|
365
|
-
ctx.lineTo(chartWidth, hoverY);
|
|
366
|
-
ctx.stroke();
|
|
367
|
-
}
|
|
368
|
-
ctx.restore();
|
|
369
|
-
if (priceLineVisible) {
|
|
370
|
-
// Exact inverse of the value->y mapping createScale set up for this
|
|
371
|
-
// frame — same helper (and same invertValueAxis flag) as
|
|
372
|
-
// PluginRenderApi.valueForY, see src/valueAxis.ts.
|
|
373
|
-
const value = pixelToValue(hoverY, valueMin, valueMax, chartHeight, this.invertValueAxis);
|
|
374
|
-
this.renderPriceLabelChip(formatPrice(value, priceStep), hoverY, chartWidth);
|
|
375
|
-
}
|
|
376
|
-
this.renderTimeLabelChip(formatHoverTime(timeSeconds), x, chartHeight, canvas.width);
|
|
377
|
-
const parts = seriesDefinition.formatLegend?.(point, style) ?? [];
|
|
378
|
-
if (parts.length === 0)
|
|
379
|
-
return;
|
|
380
|
-
this.renderHoverTooltip(parts, x, hoverY, chartWidth, chartHeight);
|
|
381
|
-
}
|
|
382
|
-
/** The OHLC(+volume) tooltip — floats near the hovered pixel like a
|
|
383
|
-
* speech bubble, one line per part, rather than a fixed banner glued to
|
|
384
|
-
* a corner of the canvas. Offset up-and-right of the cursor/finger by
|
|
385
|
-
* `legend.cursorGap` and clamped to both chart edges so it never runs
|
|
386
|
-
* off-screen, including when there's no `hoverY` to anchor to (a series
|
|
387
|
-
* with no primary value still gets a legend, just pinned near the top
|
|
388
|
-
* at the hovered column). */
|
|
389
|
-
renderHoverTooltip(lines, x, hoverY, chartWidth, chartHeight) {
|
|
390
|
-
const { ctx, font, legend } = this;
|
|
391
|
-
ctx.font = this.legendFont();
|
|
392
|
-
ctx.textAlign = 'left';
|
|
393
|
-
ctx.textBaseline = 'top';
|
|
394
|
-
const lineHeight = font.legendSize + 4;
|
|
395
|
-
const textWidth = Math.max(...lines.map((line) => ctx.measureText(line).width));
|
|
396
|
-
const boxWidth = textWidth + legend.paddingX * 2;
|
|
397
|
-
const boxHeight = lines.length * lineHeight + legend.paddingY * 2;
|
|
398
|
-
const anchorY = hoverY ?? 0;
|
|
399
|
-
const left = Math.min(Math.max(x + legend.cursorGap, 0), Math.max(0, chartWidth - boxWidth));
|
|
400
|
-
const top = Math.min(Math.max(anchorY - boxHeight - legend.cursorGap, 0), Math.max(0, chartHeight - boxHeight));
|
|
401
|
-
ctx.fillStyle = legend.background;
|
|
402
|
-
ctx.fillRect(left, top, boxWidth, boxHeight);
|
|
403
|
-
ctx.fillStyle = legend.textColor;
|
|
404
|
-
lines.forEach((line, i) => {
|
|
405
|
-
ctx.fillText(line, left + legend.paddingX, top + legend.paddingY + i * lineHeight);
|
|
406
|
-
});
|
|
407
|
-
}
|
|
408
|
-
/** The highlighted price-axis label that follows the crosshair's
|
|
409
|
-
* horizontal line — drawn over `renderPriceAxis`'s own tick labels so the
|
|
410
|
-
* hovered value reads clearly even where it lands between two ticks. */
|
|
411
|
-
renderPriceLabelChip(text, y, chartWidth) {
|
|
412
|
-
const { ctx, font, crosshair } = this;
|
|
413
|
-
ctx.font = this.axisFont();
|
|
414
|
-
const chipHeight = font.axisSize + crosshair.labelPaddingY * 2;
|
|
415
|
-
ctx.fillStyle = crosshair.labelBackground;
|
|
416
|
-
ctx.fillRect(chartWidth, y - chipHeight / 2, this.priceAxisWidth, chipHeight);
|
|
417
|
-
ctx.fillStyle = crosshair.labelTextColor;
|
|
418
|
-
ctx.textAlign = 'left';
|
|
419
|
-
ctx.textBaseline = 'middle';
|
|
420
|
-
ctx.fillText(text, chartWidth + crosshair.labelPaddingX, y);
|
|
421
|
-
}
|
|
422
|
-
/** The highlighted time-axis label under the crosshair's vertical line.
|
|
423
|
-
* Clamped so its background chip stays fully on-screen even when the
|
|
424
|
-
* hovered point sits at the very first or last visible index. */
|
|
425
|
-
renderTimeLabelChip(text, x, chartHeight, canvasWidth) {
|
|
426
|
-
const { ctx, font, crosshair } = this;
|
|
427
|
-
ctx.font = this.axisFont();
|
|
428
|
-
const chipWidth = ctx.measureText(text).width + crosshair.labelPaddingX * 2;
|
|
429
|
-
const chipHeight = font.axisSize + crosshair.labelPaddingY * 2;
|
|
430
|
-
const chipLeft = Math.min(Math.max(x - chipWidth / 2, 0), canvasWidth - chipWidth);
|
|
431
|
-
ctx.fillStyle = crosshair.labelBackground;
|
|
432
|
-
ctx.fillRect(chipLeft, chartHeight, chipWidth, chipHeight);
|
|
433
|
-
ctx.fillStyle = crosshair.labelTextColor;
|
|
434
|
-
ctx.textAlign = 'left';
|
|
435
|
-
ctx.textBaseline = 'top';
|
|
436
|
-
ctx.fillText(text, chipLeft + crosshair.labelPaddingX, chartHeight + crosshair.labelPaddingY);
|
|
437
|
-
}
|
|
438
287
|
}
|
package/dist/viewport.d.ts
CHANGED
|
@@ -33,6 +33,17 @@ export declare class Viewport {
|
|
|
33
33
|
* keeping the point at `anchorIndex` under the same relative position —
|
|
34
34
|
* the standard "zoom toward the cursor" feel. */
|
|
35
35
|
zoom(factor: number, anchorIndex: number, totalCount: number): void;
|
|
36
|
+
/**
|
|
37
|
+
* Replaces the visible window outright with `[startIndex, endIndex)`,
|
|
38
|
+
* clamped the same way `pan`/`zoom` already are (a floor on
|
|
39
|
+
* `visibleCount` so the window never collapses to nothing, and never
|
|
40
|
+
* extends past `[0, totalCount]`). The primitive a programmatic "jump to
|
|
41
|
+
* this range" builds on (see `WickChart.setVisibleRange`/
|
|
42
|
+
* `setVisibleTimeRange`) — unlike `pan`/`zoom`, which shift or scale the
|
|
43
|
+
* *current* window for a continuous gesture, this discards it and starts
|
|
44
|
+
* fresh from whatever was requested.
|
|
45
|
+
*/
|
|
46
|
+
setVisibleIndexRange(startIndex: number, endIndex: number, totalCount: number): void;
|
|
36
47
|
/** Multiplies the value-scale factor, clamped to a sane range so the
|
|
37
48
|
* value axis can't be dragged into showing nothing or clipping data.
|
|
38
49
|
* Only affects the auto-fit path — a no-op once `valueRangeOverride` is
|
package/dist/viewport.js
CHANGED
|
@@ -50,6 +50,22 @@ export class Viewport {
|
|
|
50
50
|
const maxStart = Math.max(0, totalCount - this.visibleCount);
|
|
51
51
|
this.startIndex = clamp(anchorIndex - anchorRatio * newVisibleCount, 0, maxStart);
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Replaces the visible window outright with `[startIndex, endIndex)`,
|
|
55
|
+
* clamped the same way `pan`/`zoom` already are (a floor on
|
|
56
|
+
* `visibleCount` so the window never collapses to nothing, and never
|
|
57
|
+
* extends past `[0, totalCount]`). The primitive a programmatic "jump to
|
|
58
|
+
* this range" builds on (see `WickChart.setVisibleRange`/
|
|
59
|
+
* `setVisibleTimeRange`) — unlike `pan`/`zoom`, which shift or scale the
|
|
60
|
+
* *current* window for a continuous gesture, this discards it and starts
|
|
61
|
+
* fresh from whatever was requested.
|
|
62
|
+
*/
|
|
63
|
+
setVisibleIndexRange(startIndex, endIndex, totalCount) {
|
|
64
|
+
const requestedCount = endIndex - startIndex;
|
|
65
|
+
this.visibleCount = clamp(requestedCount, MIN_VISIBLE_COUNT, Math.max(totalCount, MIN_VISIBLE_COUNT));
|
|
66
|
+
const maxStart = Math.max(0, totalCount - this.visibleCount);
|
|
67
|
+
this.startIndex = clamp(startIndex, 0, maxStart);
|
|
68
|
+
}
|
|
53
69
|
/** Multiplies the value-scale factor, clamped to a sane range so the
|
|
54
70
|
* value axis can't be dragged into showing nothing or clipping data.
|
|
55
71
|
* Only affects the auto-fit path — a no-op once `valueRangeOverride` is
|