wick-charts 0.7.0 → 0.9.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 +79 -0
- 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/devicePixelRatio.d.ts +17 -0
- package/dist/devicePixelRatio.js +22 -0
- package/dist/index.d.ts +12 -4
- package/dist/index.js +4 -37
- package/dist/plugins/types.d.ts +9 -0
- package/dist/renderer.d.ts +42 -48
- package/dist/renderer.js +109 -201
- package/dist/series/line.js +6 -2
- package/dist/series/types.d.ts +19 -3
- package/dist/types.d.ts +40 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,6 +19,8 @@ npm install wick-charts
|
|
|
19
19
|
- [Candle data](#candle-data)
|
|
20
20
|
- [Line charts](#line-charts)
|
|
21
21
|
- [Styling](#styling)
|
|
22
|
+
- [Customizing the hover legend text](#customizing-the-hover-legend-text)
|
|
23
|
+
- [High-DPI displays (devicePixelRatio)](#high-dpi-displays-devicepixelratio)
|
|
22
24
|
- [Inverting the value axis](#inverting-the-value-axis)
|
|
23
25
|
- [Reading chart state](#reading-chart-state)
|
|
24
26
|
- [Setting the visible range](#setting-the-visible-range)
|
|
@@ -229,6 +231,83 @@ type-checks `style` against
|
|
|
229
231
|
`CandlestickStyle`; the more general `new WickChart(canvas, { type: 'candlestick', style })`
|
|
230
232
|
also works but doesn't — see "Series types" below for why, if you're curious.
|
|
231
233
|
|
|
234
|
+
Every `px` value above (`font.axisSize`/`legendSize`, `axis.priceWidth`/`timeHeight`,
|
|
235
|
+
`crosshair.labelPaddingX`/`labelPaddingY`, `legend.paddingX`/`paddingY`/`cursorGap`, and
|
|
236
|
+
`LineStyle.lineWidth`) is authored in **CSS pixels** — the intuitive "how big should this look
|
|
237
|
+
on screen" unit — regardless of the canvas's actual backing-store resolution. See the next
|
|
238
|
+
section for what that means in practice.
|
|
239
|
+
|
|
240
|
+
### Customizing the hover legend text
|
|
241
|
+
|
|
242
|
+
`legend` above only styles the tooltip's box (colors, padding, offset) — the *text* inside it
|
|
243
|
+
comes from the active series's `formatLegend`, which candlestick/line both ship with a fixed,
|
|
244
|
+
English, OHLC-shaped default. An app that needs different text — localized labels, or a value
|
|
245
|
+
computed from neighboring points, like percent change against the previous candle — overrides
|
|
246
|
+
it per chart instance via `formatLegend`:
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
const chart = createCandlestickChart(canvas, {
|
|
250
|
+
formatLegend(candle, style, { index, allPoints }) {
|
|
251
|
+
const prev = allPoints[index - 1];
|
|
252
|
+
const change = prev ? (((candle.close - prev.close) / prev.close) * 100).toFixed(2) : null;
|
|
253
|
+
return [
|
|
254
|
+
`시가 ${candle.open.toLocaleString()}`,
|
|
255
|
+
`고가 ${candle.high.toLocaleString()}`,
|
|
256
|
+
`저가 ${candle.low.toLocaleString()}`,
|
|
257
|
+
`종가 ${candle.close.toLocaleString()}${change === null ? '' : ` (${change}%)`}`,
|
|
258
|
+
];
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
This replaces the series's `formatLegend` outright for this one chart instance — it doesn't
|
|
264
|
+
merge with the built-in OHLC lines, so return every line you want shown. `allPoints` is every
|
|
265
|
+
point currently loaded (not just visible), the same array a `ChartPlugin` reads through
|
|
266
|
+
`PluginRenderApi.allPoints`; `index` is `candle`'s position in it, so `allPoints[index - 1]` is
|
|
267
|
+
the previous point regardless of where the user has panned/zoomed to.
|
|
268
|
+
|
|
269
|
+
`SeriesDefinition.formatLegend` (what candlestick/line ship with) is a shared default for every
|
|
270
|
+
chart of that *type*; `WickChartOptions.formatLegend` is the per-*instance* override above it —
|
|
271
|
+
reach for the latter for anything that varies by app, session, or locale rather than by chart
|
|
272
|
+
type. `createCandlestickChart`/`createLineChart` type-check `formatLegend`'s `point`/`style`
|
|
273
|
+
against the concrete series, the same way they type-check `style` itself.
|
|
274
|
+
|
|
275
|
+
Return `[]` (or `undefined`) to suppress the built-in tooltip entirely — useful if you'd rather
|
|
276
|
+
draw a completely custom tooltip layout yourself via a [`ChartPlugin`](#extending-plugins),
|
|
277
|
+
using `chart.getHoveredPoint()` to know what's hovered.
|
|
278
|
+
|
|
279
|
+
### High-DPI displays (devicePixelRatio)
|
|
280
|
+
|
|
281
|
+
The [Quick start](#quick-start) resize snippet sizes the canvas's backing store
|
|
282
|
+
(`canvas.width`/`height`) to `devicePixelRatio` times its CSS display size — the standard
|
|
283
|
+
recipe for a crisp, non-blurry `<canvas>` on a Retina/high-DPI screen. wick-charts detects this
|
|
284
|
+
itself, live, by comparing `canvas.width`/`height` against `canvas.getBoundingClientRect()` on
|
|
285
|
+
every frame — there's no `devicePixelRatio` option to set, and nothing to recompute yourself on
|
|
286
|
+
resize or on a browser zoom change; the chart just reads whatever the canvas's current backing
|
|
287
|
+
store vs. CSS size actually is.
|
|
288
|
+
|
|
289
|
+
Once detected, every CSS-pixel size option listed at the end of the previous section is scaled
|
|
290
|
+
by that ratio before it's used — so `axisSize: 10` always looks like a 10px font on screen,
|
|
291
|
+
whether the backing store is 1x or 3x the CSS size, and you never have to pre-multiply any
|
|
292
|
+
option by `window.devicePixelRatio` yourself. (Colors and tick counts — `priceTickCount`,
|
|
293
|
+
`timeMaxTicks` — aren't sizes and pass through unscaled.)
|
|
294
|
+
|
|
295
|
+
This only reaches what the engine itself draws. A `ChartPlugin` or a custom `SeriesDefinition`
|
|
296
|
+
sets its own canvas properties directly (`ctx.lineWidth`, a font size in `ctx.font`, a marker
|
|
297
|
+
radius), and the engine has no way to know which of those are meant to be sizes — so both
|
|
298
|
+
`PluginRenderApi` and `SeriesDrawContext` carry a `devicePixelRatio` field for exactly this:
|
|
299
|
+
multiply your own literal pixel sizes by it before setting them on `ctx`, the same way the
|
|
300
|
+
built-in line series scales `LineStyle.lineWidth`:
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
// inside a ChartPlugin's draw(api), or a custom SeriesDefinition's draw(context, style)
|
|
304
|
+
ctx.lineWidth = 2 * api.devicePixelRatio; // always ~2 CSS px, not 2 backing-store px
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
If you never resize the canvas to a scaled backing store at all (`canvas.width` already equals
|
|
308
|
+
its CSS display size, the default for an unstyled `<canvas>`), the ratio is exactly 1 and
|
|
309
|
+
nothing here changes anything.
|
|
310
|
+
|
|
232
311
|
### Inverting the value axis
|
|
233
312
|
|
|
234
313
|
`invertValueAxis` mirrors the value axis top-to-bottom — every pane's higher values render
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ratio between a canvas's backing-store size and its CSS display size —
|
|
3
|
+
* `canvas.width / rect.width` (or `.height`), the same signal
|
|
4
|
+
* `window.devicePixelRatio` gives once a canvas has actually been resized to
|
|
5
|
+
* match it (see the README's "High-DPI displays" section for the resize
|
|
6
|
+
* recipe this reads back). Read live off the canvas and its bounding rect
|
|
7
|
+
* rather than cached anywhere, so a change — a window dragged to a
|
|
8
|
+
* different-DPI monitor, a browser zoom level change, or simply an app
|
|
9
|
+
* resizing the canvas — is picked up on the very next call with no explicit
|
|
10
|
+
* resize notification needed, the same way `ChartRenderer.chartWidth`/
|
|
11
|
+
* `chartHeight` already track `canvas.width`/`height` live instead of
|
|
12
|
+
* caching them at construction.
|
|
13
|
+
*
|
|
14
|
+
* Returns 1 (no scaling) when the CSS size is 0 — an unattached or
|
|
15
|
+
* zero-size canvas — rather than dividing by zero.
|
|
16
|
+
*/
|
|
17
|
+
export declare function devicePixelRatio(canvas: HTMLCanvasElement, axis?: 'width' | 'height'): number;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ratio between a canvas's backing-store size and its CSS display size —
|
|
3
|
+
* `canvas.width / rect.width` (or `.height`), the same signal
|
|
4
|
+
* `window.devicePixelRatio` gives once a canvas has actually been resized to
|
|
5
|
+
* match it (see the README's "High-DPI displays" section for the resize
|
|
6
|
+
* recipe this reads back). Read live off the canvas and its bounding rect
|
|
7
|
+
* rather than cached anywhere, so a change — a window dragged to a
|
|
8
|
+
* different-DPI monitor, a browser zoom level change, or simply an app
|
|
9
|
+
* resizing the canvas — is picked up on the very next call with no explicit
|
|
10
|
+
* resize notification needed, the same way `ChartRenderer.chartWidth`/
|
|
11
|
+
* `chartHeight` already track `canvas.width`/`height` live instead of
|
|
12
|
+
* caching them at construction.
|
|
13
|
+
*
|
|
14
|
+
* Returns 1 (no scaling) when the CSS size is 0 — an unattached or
|
|
15
|
+
* zero-size canvas — rather than dividing by zero.
|
|
16
|
+
*/
|
|
17
|
+
export function devicePixelRatio(canvas, axis = 'width') {
|
|
18
|
+
const rect = canvas.getBoundingClientRect();
|
|
19
|
+
const cssSize = axis === 'width' ? rect.width : rect.height;
|
|
20
|
+
const deviceSize = axis === 'width' ? canvas.width : canvas.height;
|
|
21
|
+
return cssSize === 0 ? 1 : deviceSize / cssSize;
|
|
22
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,8 +2,8 @@ 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, WickTime } from './types.js';
|
|
6
|
-
export type { BusinessDay, Candle, LinePoint, PaneOptions, WickChartOptions, WickTime, SeriesPoint, UnixMillis, ValueRange, } from './types.js';
|
|
5
|
+
import type { Candle, LegendFormatContext, LinePoint, PaneOptions, WickChartOptions, SeriesPoint, ValueRange, WickTime } from './types.js';
|
|
6
|
+
export type { BusinessDay, Candle, LegendFormatContext, 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';
|
|
9
9
|
export { distanceToSegment, hitTestPoint, hitTestSegment } from './hitTest.js';
|
|
@@ -326,8 +326,13 @@ export declare class WickChart<TPoint extends SeriesPoint = Candle> {
|
|
|
326
326
|
* one) rather than widening `WickChartOptions` itself — that keeps every
|
|
327
327
|
* series's style shape independent of every other's.
|
|
328
328
|
*/
|
|
329
|
-
export declare function createCandlestickChart(canvas: HTMLCanvasElement, options?: Omit<WickChartOptions, 'type' | 'style'> & {
|
|
329
|
+
export declare function createCandlestickChart(canvas: HTMLCanvasElement, options?: Omit<WickChartOptions, 'type' | 'style' | 'formatLegend'> & {
|
|
330
330
|
style?: Partial<CandlestickStyle>;
|
|
331
|
+
/** Same override as `WickChartOptions.formatLegend`, with `point`/`style`/
|
|
332
|
+
* `context` narrowed to this series's own types instead of the general
|
|
333
|
+
* (unchecked) `SeriesPoint`/`unknown` shape `WickChartOptions` itself
|
|
334
|
+
* allows — see its doc comment for what this is for. */
|
|
335
|
+
formatLegend?(point: Candle, style: CandlestickStyle, context: LegendFormatContext<Candle>): string[];
|
|
331
336
|
}): WickChart<Candle>;
|
|
332
337
|
/**
|
|
333
338
|
* The second series type's equivalent of `createCandlestickChart` above —
|
|
@@ -336,6 +341,9 @@ export declare function createCandlestickChart(canvas: HTMLCanvasElement, option
|
|
|
336
341
|
* `Record<string, unknown>` `WickChartOptions.style` allows for `new
|
|
337
342
|
* WickChart(canvas, { type: 'line', style })`.
|
|
338
343
|
*/
|
|
339
|
-
export declare function createLineChart(canvas: HTMLCanvasElement, options?: Omit<WickChartOptions, 'type' | 'style'> & {
|
|
344
|
+
export declare function createLineChart(canvas: HTMLCanvasElement, options?: Omit<WickChartOptions, 'type' | 'style' | 'formatLegend'> & {
|
|
340
345
|
style?: Partial<LineStyle>;
|
|
346
|
+
/** Same override as `WickChartOptions.formatLegend`, narrowed to this
|
|
347
|
+
* series's own types — see `createCandlestickChart`'s equivalent. */
|
|
348
|
+
formatLegend?(point: LinePoint, style: LineStyle, context: LegendFormatContext<LinePoint>): string[];
|
|
341
349
|
}): WickChart<LinePoint>;
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { lowerBound, upperBound } from './binarySearch.js';
|
|
2
|
+
import { devicePixelRatio } from './devicePixelRatio.js';
|
|
1
3
|
import { mergeSeriesPoints } from './mergeSeries.js';
|
|
2
4
|
import { computePaneLayout } from './paneLayout.js';
|
|
3
5
|
import { ChartRenderer } from './renderer.js';
|
|
@@ -45,39 +47,6 @@ const LONG_PRESS_MS = 350;
|
|
|
45
47
|
* as a real drag, not a hold — cancels the pending long-press timer so a
|
|
46
48
|
* fast pan gesture never flips into scrub mid-motion. */
|
|
47
49
|
const LONG_PRESS_MOVE_TOLERANCE_PX = 10;
|
|
48
|
-
/** First index in `times` (ascending) whose value is `>= target`, or
|
|
49
|
-
* `times.length` if every value is smaller — the standard binary
|
|
50
|
-
* lower-bound, O(log n) rather than a linear scan over what can be a
|
|
51
|
-
* multi-thousand-point loaded series. Used by `setVisibleTimeRange` to
|
|
52
|
-
* resolve a `from` time to its start index. */
|
|
53
|
-
function lowerBound(times, target) {
|
|
54
|
-
let lo = 0;
|
|
55
|
-
let hi = times.length;
|
|
56
|
-
while (lo < hi) {
|
|
57
|
-
const mid = (lo + hi) >>> 1;
|
|
58
|
-
if (times[mid] < target)
|
|
59
|
-
lo = mid + 1;
|
|
60
|
-
else
|
|
61
|
-
hi = mid;
|
|
62
|
-
}
|
|
63
|
-
return lo;
|
|
64
|
-
}
|
|
65
|
-
/** First index in `times` (ascending) whose value is `> target`, or
|
|
66
|
-
* `times.length` if none is — the exclusive end boundary for a `to` time,
|
|
67
|
-
* so a range `[lowerBound(from), upperBound(to))` includes every point
|
|
68
|
-
* with a time in `[from, to]` inclusive on both ends. */
|
|
69
|
-
function upperBound(times, target) {
|
|
70
|
-
let lo = 0;
|
|
71
|
-
let hi = times.length;
|
|
72
|
-
while (lo < hi) {
|
|
73
|
-
const mid = (lo + hi) >>> 1;
|
|
74
|
-
if (times[mid] <= target)
|
|
75
|
-
lo = mid + 1;
|
|
76
|
-
else
|
|
77
|
-
hi = mid;
|
|
78
|
-
}
|
|
79
|
-
return lo;
|
|
80
|
-
}
|
|
81
50
|
/**
|
|
82
51
|
* Interactive chart: drag to pan, wheel to zoom, drag the price-axis strip
|
|
83
52
|
* to rescale it, hover a point for a legend. What gets plotted (candles
|
|
@@ -873,12 +842,10 @@ export class WickChart {
|
|
|
873
842
|
* conversion `cursorPosition` applies to absolute coordinates, extracted
|
|
874
843
|
* so pixel *deltas* (drag distance, wheel deltaX) can be converted too. */
|
|
875
844
|
devicePixelScaleX() {
|
|
876
|
-
|
|
877
|
-
return rect.width === 0 ? 1 : this.canvas.width / rect.width;
|
|
845
|
+
return devicePixelRatio(this.canvas, 'width');
|
|
878
846
|
}
|
|
879
847
|
devicePixelScaleY() {
|
|
880
|
-
|
|
881
|
-
return rect.height === 0 ? 1 : this.canvas.height / rect.height;
|
|
848
|
+
return devicePixelRatio(this.canvas, 'height');
|
|
882
849
|
}
|
|
883
850
|
}
|
|
884
851
|
/**
|
package/dist/plugins/types.d.ts
CHANGED
|
@@ -17,6 +17,15 @@ export interface PluginRenderApi<TPoint extends SeriesPoint = SeriesPoint> {
|
|
|
17
17
|
ctx: CanvasRenderingContext2D;
|
|
18
18
|
chartWidth: number;
|
|
19
19
|
chartHeight: number;
|
|
20
|
+
/** Ratio between the canvas's backing-store size and its CSS display
|
|
21
|
+
* size — 1 on a standard-DPI display, 2 on a typical Retina one.
|
|
22
|
+
* `chartWidth`/`chartHeight` and everything `xForIndex`/`yForValue`
|
|
23
|
+
* return are already in backing-store pixels, but a plugin choosing its
|
|
24
|
+
* *own* literal pixel sizes (`ctx.lineWidth`, a font size in
|
|
25
|
+
* `ctx.font`, a marker radius) should multiply them by this first, the
|
|
26
|
+
* same way the built-in line series scales `LineStyle.lineWidth` — see
|
|
27
|
+
* "High-DPI displays" in the README. */
|
|
28
|
+
devicePixelRatio: number;
|
|
20
29
|
/** Global (full sorted-array) index -> x pixel, same convention the
|
|
21
30
|
* active series draws with. Valid only for the duration of this `draw()`
|
|
22
31
|
* call — see the interface-level note on `PluginRenderApi`. */
|
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,20 +23,33 @@ 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
|
|
38
45
|
* by field over its own defaults — nothing here is a hardcoded module
|
|
39
|
-
* constant a caller can't reach.
|
|
46
|
+
* constant a caller can't reach. Every *size* among them (font sizes, axis
|
|
47
|
+
* strip widths, padding, gaps) is specified in CSS pixels and scaled by the
|
|
48
|
+
* canvas's live devicePixelRatio (see `deviceRatio`) at the top of every
|
|
49
|
+
* `render()` call before use — colors and tick counts pass through
|
|
50
|
+
* unscaled. `AxisRenderer`/`CrosshairRenderer` themselves stay unaware of
|
|
51
|
+
* this: they're handed already-scaled options each frame, the same as they
|
|
52
|
+
* were handed unscaled ones before this existed.
|
|
40
53
|
*/
|
|
41
54
|
export declare class ChartRenderer<TPoint extends SeriesPoint> {
|
|
42
55
|
private canvas;
|
|
@@ -44,8 +57,15 @@ export declare class ChartRenderer<TPoint extends SeriesPoint> {
|
|
|
44
57
|
private ctx;
|
|
45
58
|
private background;
|
|
46
59
|
private style;
|
|
47
|
-
|
|
60
|
+
/** Author-facing (CSS-pixel) style groups, resolved once at construction
|
|
61
|
+
* from `WickChartOptions.font`/`axis`/`crosshair`/`legend` — kept as
|
|
62
|
+
* fields so `render()` can rescale them fresh every frame against the
|
|
63
|
+
* canvas's current devicePixelRatio, which (a window dragged to a
|
|
64
|
+
* different-DPI monitor, a browser zoom change, or the app simply
|
|
65
|
+
* resizing the canvas) can change between frames. `axis` is additionally
|
|
66
|
+
* read directly by `chartWidth`/`chartHeight`/`priceAxisWidth` below. */
|
|
48
67
|
private axis;
|
|
68
|
+
private font;
|
|
49
69
|
private crosshair;
|
|
50
70
|
private legend;
|
|
51
71
|
/** Unlike the style groups above, mutable after construction — see
|
|
@@ -54,16 +74,26 @@ export declare class ChartRenderer<TPoint extends SeriesPoint> {
|
|
|
54
74
|
* opposite way" view a user flips on and off), so it doesn't get the
|
|
55
75
|
* "resolved once in the constructor" treatment those get. */
|
|
56
76
|
private invertValueAxis;
|
|
77
|
+
/** Per-instance override of `seriesDefinition.formatLegend` — see
|
|
78
|
+
* `WickChartOptions.formatLegend`'s own doc comment for why this exists
|
|
79
|
+
* as a chart-instance option rather than only a series-type one. */
|
|
80
|
+
private formatLegendOverride?;
|
|
57
81
|
constructor(canvas: HTMLCanvasElement, seriesDefinition: SeriesDefinition<TPoint, unknown>, options?: WickChartOptions);
|
|
58
82
|
setInvertValueAxis(inverted: boolean): void;
|
|
83
|
+
/** Ratio between the canvas's backing-store size and its CSS display
|
|
84
|
+
* size — read live, not cached, for the same reason `chartWidth`/
|
|
85
|
+
* `chartHeight` below read `canvas.width`/`height` live: whatever it is
|
|
86
|
+
* *right now* is what this frame draws at. See `src/devicePixelRatio.ts`. */
|
|
87
|
+
private get deviceRatio();
|
|
59
88
|
/** Pixel width of the point-plotting area — excludes the price-axis
|
|
60
89
|
* strip on the right. Exposed so `WickChart` can convert cursor pixel
|
|
61
|
-
* positions to point indices / values for hit-testing and dragging.
|
|
90
|
+
* positions to point indices / values for hit-testing and dragging.
|
|
91
|
+
* `priceAxisWidth` below is already devicePixelRatio-scaled, so this
|
|
92
|
+
* (and `chartHeight`) stay correct on a high-DPI canvas without
|
|
93
|
+
* `WickChart` having to know anything about DPR itself. */
|
|
62
94
|
get chartWidth(): number;
|
|
63
95
|
get chartHeight(): number;
|
|
64
96
|
get priceAxisWidth(): number;
|
|
65
|
-
private axisFont;
|
|
66
|
-
private legendFont;
|
|
67
97
|
render(input: RenderInput<TPoint>): void;
|
|
68
98
|
/**
|
|
69
99
|
* Builds the `PluginRenderApi` for one pane — the main price pane or a
|
|
@@ -73,40 +103,4 @@ export declare class ChartRenderer<TPoint extends SeriesPoint> {
|
|
|
73
103
|
* frame-ended flag, for the whole stack; see `FrameGeometry`).
|
|
74
104
|
*/
|
|
75
105
|
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
106
|
}
|
package/dist/renderer.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AxisRenderer } from './axisRenderer.js';
|
|
2
|
+
import { CrosshairRenderer } from './crosshairRenderer.js';
|
|
3
|
+
import { devicePixelRatio } from './devicePixelRatio.js';
|
|
2
4
|
import { createScale } from './hybridScale.js';
|
|
3
5
|
import { computePaneLayout } from './paneLayout.js';
|
|
4
|
-
import { formatPrice, niceTicks } from './priceAxis.js';
|
|
5
6
|
import { pixelToValue, valueAxisPixelRange } from './valueAxis.js';
|
|
6
7
|
const DEFAULT_BACKGROUND = 'transparent';
|
|
7
8
|
const DEFAULT_FONT = {
|
|
@@ -33,20 +34,33 @@ const DEFAULT_LEGEND = {
|
|
|
33
34
|
cursorGap: 12,
|
|
34
35
|
};
|
|
35
36
|
/**
|
|
36
|
-
* The chart engine's renderer: canvas lifecycle
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
37
|
+
* The chart engine's renderer: owns the canvas lifecycle and orchestrates
|
|
38
|
+
* one frame — deciding what data is visible, computing scales, and calling
|
|
39
|
+
* out to collaborators for the actual pixel-pushing. None of it knows what
|
|
40
|
+
* kind of series is on screen: the one series-specific seam is
|
|
41
|
+
* `seriesDefinition`, injected at construction (see `src/series/types.ts`).
|
|
41
42
|
* Stateless per call otherwise — all pan/zoom/hover state lives in
|
|
42
43
|
* `Viewport` and `WickChart`; this class only turns a snapshot of that
|
|
43
44
|
* state into pixels.
|
|
44
45
|
*
|
|
46
|
+
* Axis chrome and the hover crosshair/legend are drawn by two collaborators
|
|
47
|
+
* (`AxisRenderer`, `CrosshairRenderer`) rather than methods on this class —
|
|
48
|
+
* both take only already-resolved style options and per-call geometry, no
|
|
49
|
+
* series generic or plugin state, so splitting them out keeps this file
|
|
50
|
+
* focused on orchestration (what gets drawn, in what order, with what
|
|
51
|
+
* scale) rather than mixing in how each individual chrome element paints.
|
|
52
|
+
*
|
|
45
53
|
* Every visual constant below (fonts, axis sizing/coloring, crosshair
|
|
46
54
|
* coloring/padding, legend color) is resolved once at construction from
|
|
47
55
|
* `WickChartOptions.font`/`axis`/`crosshair`/`legend`, each merged field
|
|
48
56
|
* by field over its own defaults — nothing here is a hardcoded module
|
|
49
|
-
* constant a caller can't reach.
|
|
57
|
+
* constant a caller can't reach. Every *size* among them (font sizes, axis
|
|
58
|
+
* strip widths, padding, gaps) is specified in CSS pixels and scaled by the
|
|
59
|
+
* canvas's live devicePixelRatio (see `deviceRatio`) at the top of every
|
|
60
|
+
* `render()` call before use — colors and tick counts pass through
|
|
61
|
+
* unscaled. `AxisRenderer`/`CrosshairRenderer` themselves stay unaware of
|
|
62
|
+
* this: they're handed already-scaled options each frame, the same as they
|
|
63
|
+
* were handed unscaled ones before this existed.
|
|
50
64
|
*/
|
|
51
65
|
export class ChartRenderer {
|
|
52
66
|
constructor(canvas, seriesDefinition, options = {}) {
|
|
@@ -58,45 +72,87 @@ export class ChartRenderer {
|
|
|
58
72
|
this.ctx = ctx;
|
|
59
73
|
this.background = options.background ?? DEFAULT_BACKGROUND;
|
|
60
74
|
this.style = { ...seriesDefinition.defaultStyle, ...(options.style ?? {}) };
|
|
61
|
-
this.font = { ...DEFAULT_FONT, ...options.font };
|
|
62
75
|
this.axis = { ...DEFAULT_AXIS, ...options.axis };
|
|
76
|
+
this.invertValueAxis = options.invertValueAxis ?? false;
|
|
77
|
+
this.formatLegendOverride = options.formatLegend;
|
|
78
|
+
this.font = { ...DEFAULT_FONT, ...options.font };
|
|
63
79
|
this.crosshair = { ...DEFAULT_CROSSHAIR, ...options.crosshair };
|
|
64
80
|
this.legend = { ...DEFAULT_LEGEND, ...options.legend };
|
|
65
|
-
this.invertValueAxis = options.invertValueAxis ?? false;
|
|
66
81
|
}
|
|
67
82
|
setInvertValueAxis(inverted) {
|
|
68
83
|
this.invertValueAxis = inverted;
|
|
69
84
|
}
|
|
85
|
+
/** Ratio between the canvas's backing-store size and its CSS display
|
|
86
|
+
* size — read live, not cached, for the same reason `chartWidth`/
|
|
87
|
+
* `chartHeight` below read `canvas.width`/`height` live: whatever it is
|
|
88
|
+
* *right now* is what this frame draws at. See `src/devicePixelRatio.ts`. */
|
|
89
|
+
get deviceRatio() {
|
|
90
|
+
return devicePixelRatio(this.canvas, 'width');
|
|
91
|
+
}
|
|
70
92
|
/** Pixel width of the point-plotting area — excludes the price-axis
|
|
71
93
|
* strip on the right. Exposed so `WickChart` can convert cursor pixel
|
|
72
|
-
* positions to point indices / values for hit-testing and dragging.
|
|
94
|
+
* positions to point indices / values for hit-testing and dragging.
|
|
95
|
+
* `priceAxisWidth` below is already devicePixelRatio-scaled, so this
|
|
96
|
+
* (and `chartHeight`) stay correct on a high-DPI canvas without
|
|
97
|
+
* `WickChart` having to know anything about DPR itself. */
|
|
73
98
|
get chartWidth() {
|
|
74
|
-
return Math.max(0, this.canvas.width - this.
|
|
99
|
+
return Math.max(0, this.canvas.width - this.priceAxisWidth);
|
|
75
100
|
}
|
|
76
101
|
get chartHeight() {
|
|
77
|
-
return Math.max(0, this.canvas.height - this.axis.timeHeight);
|
|
102
|
+
return Math.max(0, this.canvas.height - this.axis.timeHeight * this.deviceRatio);
|
|
78
103
|
}
|
|
79
104
|
get priceAxisWidth() {
|
|
80
|
-
return this.axis.priceWidth;
|
|
81
|
-
}
|
|
82
|
-
axisFont() {
|
|
83
|
-
return `${this.font.axisSize}px ${this.font.family}`;
|
|
84
|
-
}
|
|
85
|
-
legendFont() {
|
|
86
|
-
return `${this.font.legendSize}px ${this.font.family}`;
|
|
105
|
+
return this.axis.priceWidth * this.deviceRatio;
|
|
87
106
|
}
|
|
88
107
|
render(input) {
|
|
89
108
|
const { ctx, canvas, background, seriesDefinition, style } = this;
|
|
90
109
|
const { sorted, times, viewport, hoverIndex, hoverY, plugins, panes } = input;
|
|
110
|
+
const ratio = this.deviceRatio;
|
|
111
|
+
// Scaled fresh every frame (see `deviceRatio`'s own doc comment for
|
|
112
|
+
// why this isn't done once at construction) — every *size* field gets
|
|
113
|
+
// multiplied by `ratio`, every color/count field passes through as-is.
|
|
114
|
+
// `AxisRenderer`/`CrosshairRenderer` are cheap POJO-ish collaborators
|
|
115
|
+
// with no state beyond these options, so rebuilding them here each
|
|
116
|
+
// frame is simpler than threading `ratio` through every one of their
|
|
117
|
+
// methods for what's otherwise the same "resolved options" shape they
|
|
118
|
+
// were built to take in the first place.
|
|
119
|
+
const scaledAxis = {
|
|
120
|
+
...this.axis,
|
|
121
|
+
priceWidth: this.axis.priceWidth * ratio,
|
|
122
|
+
timeHeight: this.axis.timeHeight * ratio,
|
|
123
|
+
};
|
|
124
|
+
const scaledFont = {
|
|
125
|
+
...this.font,
|
|
126
|
+
axisSize: this.font.axisSize * ratio,
|
|
127
|
+
legendSize: this.font.legendSize * ratio,
|
|
128
|
+
};
|
|
129
|
+
const scaledCrosshair = {
|
|
130
|
+
...this.crosshair,
|
|
131
|
+
labelPaddingX: this.crosshair.labelPaddingX * ratio,
|
|
132
|
+
labelPaddingY: this.crosshair.labelPaddingY * ratio,
|
|
133
|
+
};
|
|
134
|
+
const scaledLegend = {
|
|
135
|
+
...this.legend,
|
|
136
|
+
paddingX: this.legend.paddingX * ratio,
|
|
137
|
+
paddingY: this.legend.paddingY * ratio,
|
|
138
|
+
cursorGap: this.legend.cursorGap * ratio,
|
|
139
|
+
};
|
|
140
|
+
const axisRenderer = new AxisRenderer(ctx, scaledAxis, scaledFont);
|
|
141
|
+
const crosshairRenderer = new CrosshairRenderer(ctx, scaledCrosshair, scaledLegend, scaledFont, scaledAxis.priceWidth);
|
|
91
142
|
const width = canvas.width;
|
|
92
143
|
const height = canvas.height;
|
|
93
|
-
|
|
144
|
+
// Computed from `scaledAxis` (already built from `ratio` above) rather
|
|
145
|
+
// than by re-reading `this.chartWidth`/`chartHeight` — those getters
|
|
146
|
+
// recompute `deviceRatio`, which reads `getBoundingClientRect()` (a
|
|
147
|
+
// potential layout reflow in a real browser); doing that three times
|
|
148
|
+
// per frame instead of once matters at 60fps.
|
|
149
|
+
const chartWidth = Math.max(0, width - scaledAxis.priceWidth);
|
|
94
150
|
// Full stack height: the main price pane plus every declared indicator
|
|
95
151
|
// pane below it. `this.chartHeight` predates panes and named what's
|
|
96
152
|
// now only true with zero of them — kept as the property name (public
|
|
97
153
|
// API reads it through) but renamed locally here since most of this
|
|
98
154
|
// method cares about one pane's height, not the stack's.
|
|
99
|
-
const stackHeight =
|
|
155
|
+
const stackHeight = Math.max(0, height - scaledAxis.timeHeight);
|
|
100
156
|
ctx.clearRect(0, 0, width, height);
|
|
101
157
|
if (background !== 'transparent') {
|
|
102
158
|
ctx.fillStyle = background;
|
|
@@ -161,25 +217,44 @@ export class ChartRenderer {
|
|
|
161
217
|
// grid lines, and the crosshair.
|
|
162
218
|
ctx.save();
|
|
163
219
|
try {
|
|
164
|
-
seriesDefinition.draw({ ctx, visible, startIndex: startIdx, xForIndex, slotWidth, yScale, chartHeight }, style);
|
|
220
|
+
seriesDefinition.draw({ ctx, visible, startIndex: startIdx, xForIndex, slotWidth, yScale, chartHeight, devicePixelRatio: ratio }, style);
|
|
165
221
|
}
|
|
166
222
|
finally {
|
|
167
223
|
ctx.restore();
|
|
168
224
|
}
|
|
169
|
-
const priceStep =
|
|
170
|
-
|
|
225
|
+
const priceStep = axisRenderer.priceStep(valueMin, valueMax);
|
|
226
|
+
axisRenderer.renderPriceAxis(valueMin, valueMax, priceStep, yScale, chartWidth, chartHeight, mainRect.top);
|
|
171
227
|
for (const { rect, min, max, scale } of paneScales) {
|
|
172
|
-
|
|
173
|
-
const step =
|
|
174
|
-
|
|
228
|
+
axisRenderer.renderPaneSeparator(rect.top, chartWidth);
|
|
229
|
+
const step = axisRenderer.priceStep(min, max);
|
|
230
|
+
axisRenderer.renderPriceAxis(min, max, step, scale, chartWidth, rect.height, rect.top);
|
|
175
231
|
}
|
|
176
|
-
|
|
232
|
+
axisRenderer.renderTimeAxis(times, startIdx, visible.length, stackHeight, chartWidth, xForIndex);
|
|
177
233
|
if (hoverIndex !== null && hoverIndex >= startIdx && hoverIndex < endIdx) {
|
|
178
234
|
// The dashed vertical line spans the whole stack (every pane); the
|
|
179
235
|
// horizontal line, price-label chip, and OHLC legend stay scoped
|
|
180
236
|
// to the main pane only — an indicator pane's own hover readout,
|
|
181
237
|
// if it wants one, is the job of whatever plugin draws into it.
|
|
182
|
-
|
|
238
|
+
// `formatLegendOverride`, when set, replaces the series's own
|
|
239
|
+
// formatLegend entirely for this chart instance rather than
|
|
240
|
+
// merging with it — see `WickChartOptions.formatLegend`.
|
|
241
|
+
const formatLegend = this.formatLegendOverride ?? seriesDefinition.formatLegend;
|
|
242
|
+
const legendContext = { index: hoverIndex, allPoints: sorted };
|
|
243
|
+
const legendParts = formatLegend?.(sorted[hoverIndex], style, legendContext) ?? [];
|
|
244
|
+
crosshairRenderer.render({
|
|
245
|
+
x: xForIndex(hoverIndex),
|
|
246
|
+
timeSeconds: times[hoverIndex],
|
|
247
|
+
hoverY,
|
|
248
|
+
valueMin,
|
|
249
|
+
valueMax,
|
|
250
|
+
priceStep,
|
|
251
|
+
chartWidth,
|
|
252
|
+
chartHeight,
|
|
253
|
+
stackHeight,
|
|
254
|
+
invertValueAxis: this.invertValueAxis,
|
|
255
|
+
legendParts,
|
|
256
|
+
canvasWidth: canvas.width,
|
|
257
|
+
});
|
|
183
258
|
}
|
|
184
259
|
if (plugins.length > 0) {
|
|
185
260
|
// Everything every pane's PluginRenderApi shares — only the pane's
|
|
@@ -196,10 +271,10 @@ export class ChartRenderer {
|
|
|
196
271
|
allPoints: sorted,
|
|
197
272
|
frameState,
|
|
198
273
|
};
|
|
199
|
-
const mainApi = this.buildPluginApi(mainRect, valueMin, valueMax, yScale, frameGeometry);
|
|
274
|
+
const mainApi = this.buildPluginApi(mainRect, valueMin, valueMax, yScale, frameGeometry, ratio);
|
|
200
275
|
const paneApiById = new Map();
|
|
201
276
|
for (const { pane, rect, min, max, scale } of paneScales) {
|
|
202
|
-
paneApiById.set(pane.id, this.buildPluginApi(rect, min, max, scale, frameGeometry));
|
|
277
|
+
paneApiById.set(pane.id, this.buildPluginApi(rect, min, max, scale, frameGeometry, ratio));
|
|
203
278
|
}
|
|
204
279
|
for (const plugin of plugins) {
|
|
205
280
|
if (plugin.visible === false)
|
|
@@ -240,12 +315,13 @@ export class ChartRenderer {
|
|
|
240
315
|
* for this frame (shared because there is only one time axis, and one
|
|
241
316
|
* frame-ended flag, for the whole stack; see `FrameGeometry`).
|
|
242
317
|
*/
|
|
243
|
-
buildPluginApi(rect, valueMin, valueMax, scale, geometry) {
|
|
318
|
+
buildPluginApi(rect, valueMin, valueMax, scale, geometry, devicePixelRatio) {
|
|
244
319
|
const { chartWidth, xForIndex, indexForX, visibleStartIndex, visibleEndIndex, allPoints, frameState } = geometry;
|
|
245
320
|
return {
|
|
246
321
|
ctx: this.ctx,
|
|
247
322
|
chartWidth,
|
|
248
323
|
chartHeight: rect.height,
|
|
324
|
+
devicePixelRatio,
|
|
249
325
|
xForIndex,
|
|
250
326
|
yForValue: (value) => {
|
|
251
327
|
if (frameState.ended) {
|
|
@@ -267,172 +343,4 @@ export class ChartRenderer {
|
|
|
267
343
|
allPoints,
|
|
268
344
|
};
|
|
269
345
|
}
|
|
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
346
|
}
|
package/dist/series/line.js
CHANGED
|
@@ -16,7 +16,7 @@ function getValueRange(visible, scaleFactor) {
|
|
|
16
16
|
return fitRange(Math.min(...values), Math.max(...values), scaleFactor);
|
|
17
17
|
}
|
|
18
18
|
function draw(context, style) {
|
|
19
|
-
const { ctx, visible, startIndex, xForIndex, yScale } = context;
|
|
19
|
+
const { ctx, visible, startIndex, xForIndex, yScale, devicePixelRatio } = context;
|
|
20
20
|
if (visible.length === 0)
|
|
21
21
|
return;
|
|
22
22
|
// Batched through mapMany (one call per array) rather than once per
|
|
@@ -26,7 +26,11 @@ function draw(context, style) {
|
|
|
26
26
|
// filtered out first, so `ys[i]` still lines up with `visible[i]`.
|
|
27
27
|
const ys = yScale.mapMany(visible.map((p) => p.value));
|
|
28
28
|
ctx.strokeStyle = style.lineColor;
|
|
29
|
-
|
|
29
|
+
// `lineWidth` is authored in CSS pixels, like every other size in
|
|
30
|
+
// `WickChartOptions` — scaled to backing-store pixels here so the stroke
|
|
31
|
+
// renders at its intended visual thickness on a high-DPI canvas instead
|
|
32
|
+
// of half that. See `SeriesDrawContext.devicePixelRatio`.
|
|
33
|
+
ctx.lineWidth = style.lineWidth * devicePixelRatio;
|
|
30
34
|
ctx.beginPath();
|
|
31
35
|
// `drawing` tracks whether the path is mid-segment — a non-finite value
|
|
32
36
|
// (a gap in the data) breaks it, and the line resumes fresh at the next
|
package/dist/series/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Scale } from '../hybridScale.js';
|
|
2
|
-
import type { SeriesPoint, ValueRange } from '../types.js';
|
|
2
|
+
import type { LegendFormatContext, SeriesPoint, ValueRange } from '../types.js';
|
|
3
3
|
export type { ValueRange } from '../types.js';
|
|
4
4
|
/**
|
|
5
5
|
* Everything a series's `draw` needs to turn its visible points into
|
|
@@ -23,6 +23,17 @@ export interface SeriesDrawContext<TPoint extends SeriesPoint> {
|
|
|
23
23
|
/** Value (price) -> y pixel for the current frame's domain. */
|
|
24
24
|
yScale: Scale;
|
|
25
25
|
chartHeight: number;
|
|
26
|
+
/** Ratio between the canvas's backing-store size and its CSS display
|
|
27
|
+
* size — 1 on a standard-DPI display, 2 on a typical Retina one. Every
|
|
28
|
+
* geometric field above (`slotWidth`, `chartHeight`, the pixels `yScale`
|
|
29
|
+
* maps to) is already in backing-store pixels, but a *literal* pixel
|
|
30
|
+
* size in `style` (a stroke width, say — `LineStyle.lineWidth` is the
|
|
31
|
+
* built-in example) is normally authored in CSS pixels, the same
|
|
32
|
+
* intuitive unit `WickChartOptions.font`/`axis`/`crosshair`/`legend` use;
|
|
33
|
+
* multiply such a field by this before setting it on `ctx` so it renders
|
|
34
|
+
* at its intended visual size rather than half that on a 2x display. See
|
|
35
|
+
* "High-DPI displays" in the README. */
|
|
36
|
+
devicePixelRatio: number;
|
|
26
37
|
}
|
|
27
38
|
/**
|
|
28
39
|
* The single seam a new chart type has to implement. `WickChart` and
|
|
@@ -51,6 +62,11 @@ export interface SeriesDefinition<TPoint extends SeriesPoint, TStyle> {
|
|
|
51
62
|
draw(context: SeriesDrawContext<TPoint>, style: TStyle): void;
|
|
52
63
|
/** Builds the hover/crosshair legend text for one point, one string per
|
|
53
64
|
* segment (joined with spacing by the renderer). Omit to draw the
|
|
54
|
-
* crosshair line with no legend text.
|
|
55
|
-
|
|
65
|
+
* crosshair line with no legend text. `context` gives access to
|
|
66
|
+
* neighboring points (see `LegendFormatContext`) for anything the
|
|
67
|
+
* hovered point alone can't express, like a value compared against the
|
|
68
|
+
* previous point — this built-in series doesn't need it, but a custom
|
|
69
|
+
* one can. `WickChartOptions.formatLegend`, when set, overrides this per
|
|
70
|
+
* chart instance rather than per series type — see its own doc comment. */
|
|
71
|
+
formatLegend?(point: TPoint, style: TStyle, context: LegendFormatContext<TPoint>): string[];
|
|
56
72
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -169,6 +169,22 @@ export interface ResolvedPaneOptions {
|
|
|
169
169
|
heightRatio: number;
|
|
170
170
|
getValueRange: () => ValueRange;
|
|
171
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* Context `SeriesDefinition.formatLegend` and `WickChartOptions.formatLegend`
|
|
174
|
+
* get alongside the hovered point and its style — everything needed to
|
|
175
|
+
* compute something derived from *neighboring* points (a percent change
|
|
176
|
+
* vs. the previous point, say), which the hovered point alone can't
|
|
177
|
+
* express. The same `allPoints`/index-into-it shape `SeriesDrawContext`
|
|
178
|
+
* already gives a series's own `draw()`, reused here so both call sites
|
|
179
|
+
* answer "what else is near this point" the same way.
|
|
180
|
+
*/
|
|
181
|
+
export interface LegendFormatContext<TPoint extends SeriesPoint = SeriesPoint> {
|
|
182
|
+
/** Global index of the hovered point in `allPoints`. */
|
|
183
|
+
index: number;
|
|
184
|
+
/** Every point currently loaded (not just visible), sorted ascending by
|
|
185
|
+
* time — the same array `SeriesDrawContext.allPoints` is. */
|
|
186
|
+
allPoints: readonly TPoint[];
|
|
187
|
+
}
|
|
172
188
|
export interface WickChartOptions {
|
|
173
189
|
/**
|
|
174
190
|
* Which registered series type to render this chart as (see
|
|
@@ -199,6 +215,30 @@ export interface WickChartOptions {
|
|
|
199
215
|
crosshair?: ChartCrosshairOptions;
|
|
200
216
|
/** Hover legend coloring. Merged over the built-in defaults field by field. */
|
|
201
217
|
legend?: ChartLegendOptions;
|
|
218
|
+
/**
|
|
219
|
+
* Overrides the active series's own `formatLegend` (see
|
|
220
|
+
* `SeriesDefinition.formatLegend`) for this chart instance specifically —
|
|
221
|
+
* the hover legend's text, one string per line. `SeriesDefinition.formatLegend`
|
|
222
|
+
* is a shared default for every chart of that series *type* (registered
|
|
223
|
+
* once via `registerSeries`); this is a per-*instance* override for
|
|
224
|
+
* whatever varies by app/session instead of by chart type — localized
|
|
225
|
+
* labels, or a value derived from neighboring points via
|
|
226
|
+
* `context.allPoints`/`context.index` (a percent change vs. the previous
|
|
227
|
+
* point, say). Returning an empty array suppresses the built-in tooltip
|
|
228
|
+
* entirely, the same as a series with no `formatLegend` at all — draw
|
|
229
|
+
* your own via a `ChartPlugin` instead if you need a different layout,
|
|
230
|
+
* not just different text.
|
|
231
|
+
*
|
|
232
|
+
* Declared as a method (not an arrow-typed property) so, like
|
|
233
|
+
* `SeriesDefinition.formatLegend`, it type-checks bivariantly rather than
|
|
234
|
+
* contravariantly — the same tradeoff `style`'s untyped
|
|
235
|
+
* `Record<string, unknown>` already makes here: `new WickChart(canvas, {
|
|
236
|
+
* type, formatLegend })` doesn't verify `point`/`style` actually match
|
|
237
|
+
* `type`'s series, but `createCandlestickChart`/`createLineChart` narrow
|
|
238
|
+
* both to the concrete series's own types, the same way they narrow
|
|
239
|
+
* `style` — see their own doc comments.
|
|
240
|
+
*/
|
|
241
|
+
formatLegend?(point: SeriesPoint, style: unknown, context: LegendFormatContext<SeriesPoint>): string[];
|
|
202
242
|
/**
|
|
203
243
|
* Mirrors the value axis top-to-bottom — every pane's higher values
|
|
204
244
|
* render lower on screen instead of higher, with no change to the
|