wick-charts 0.7.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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)
@@ -206,6 +208,8 @@ const chart = createCandlestickChart(canvas, {
206
208
  },
207
209
  crosshair: {
208
210
  lineColor: '#9090904d',
211
+ lineWidth: 1,
212
+ lineDash: [4, 4], // [] for a solid line — same convention as ctx.setLineDash
209
213
  labelBackground: '#3a3a3a',
210
214
  labelTextColor: '#f0f0f0',
211
215
  labelPaddingX: 4,
@@ -229,6 +233,103 @@ type-checks `style` against
229
233
  `CandlestickStyle`; the more general `new WickChart(canvas, { type: 'candlestick', style })`
230
234
  also works but doesn't — see "Series types" below for why, if you're curious.
231
235
 
236
+ Every `px` value above (`font.axisSize`/`legendSize`, `axis.priceWidth`/`timeHeight`,
237
+ `crosshair.labelPaddingX`/`labelPaddingY`, `legend.paddingX`/`paddingY`/`cursorGap`, and
238
+ `LineStyle.lineWidth`) is authored in **CSS pixels** — the intuitive "how big should this look
239
+ on screen" unit — regardless of the canvas's actual backing-store resolution. See the next
240
+ section for what that means in practice.
241
+
242
+ ### Customizing the hover legend text
243
+
244
+ `legend` above only styles the tooltip's box (colors, padding, offset) — the *text* inside it
245
+ comes from the active series's `formatLegend`, which candlestick/line both ship with a fixed,
246
+ English, OHLC-shaped default. An app that needs different text — localized labels, or a value
247
+ computed from neighboring points, like percent change against the previous candle — overrides
248
+ it per chart instance via `formatLegend`:
249
+
250
+ ```ts
251
+ const chart = createCandlestickChart(canvas, {
252
+ formatLegend(candle, style, { index, allPoints }) {
253
+ const prev = allPoints[index - 1];
254
+ const change = prev ? ((candle.close - prev.close) / prev.close) * 100 : null;
255
+ return [
256
+ `시가 ${candle.open.toLocaleString()}`,
257
+ `고가 ${candle.high.toLocaleString()}`,
258
+ `저가 ${candle.low.toLocaleString()}`,
259
+ `종가 ${candle.close.toLocaleString()}`,
260
+ // A line can be a plain string (drawn in legend.textColor, like the
261
+ // four above) or { text, color } for one that needs its own color —
262
+ // see "Coloring individual legend lines" just below.
263
+ change === null
264
+ ? '등락 —'
265
+ : { text: `등락 ${change >= 0 ? '+' : ''}${change.toFixed(2)}%`, color: change >= 0 ? '#22c55e' : '#ef4444' },
266
+ ];
267
+ },
268
+ });
269
+ ```
270
+
271
+ This replaces the series's `formatLegend` outright for this one chart instance — it doesn't
272
+ merge with the built-in OHLC lines, so return every line you want shown. `allPoints` is every
273
+ point currently loaded (not just visible), the same array a `ChartPlugin` reads through
274
+ `PluginRenderApi.allPoints`; `index` is `candle`'s position in it, so `allPoints[index - 1]` is
275
+ the previous point regardless of where the user has panned/zoomed to.
276
+
277
+ #### Coloring individual legend lines
278
+
279
+ Every line returned by `formatLegend` is a `LegendLine` — either a plain `string`, drawn in
280
+ `legend.textColor` like every line before this existed, or `{ text, color }` for one line that
281
+ needs its own color independent of the rest (the `change` line above: green when positive, red
282
+ when negative, regardless of `legend.textColor`). Mixing both shapes in one returned array, as
283
+ above, is the normal case — only the lines that actually need a distinct color use the object
284
+ form. There's no chart-wide "green means up" setting for this: which color means "up" is a
285
+ convention that varies by market (green-up in most markets, red-up in South Korea and a few
286
+ others) and isn't something the library should hardcode, so `formatLegend` decides it per line
287
+ the same way it decides everything else about the legend's text. This is separate from
288
+ `CandlestickStyle.upColor`/`downColor`, which is the *candle's own* body/wick color, a
289
+ chart-wide setting rather than something recomputed per hovered point.
290
+
291
+ `SeriesDefinition.formatLegend` (what candlestick/line ship with) is a shared default for every
292
+ chart of that *type*; `WickChartOptions.formatLegend` is the per-*instance* override above it —
293
+ reach for the latter for anything that varies by app, session, or locale rather than by chart
294
+ type. `createCandlestickChart`/`createLineChart` type-check `formatLegend`'s `point`/`style`
295
+ against the concrete series, the same way they type-check `style` itself.
296
+
297
+ Return `[]` (or `undefined`) to suppress the built-in tooltip entirely — useful if you'd rather
298
+ draw a completely custom tooltip layout yourself via a [`ChartPlugin`](#extending-plugins),
299
+ using `chart.getHoveredPoint()` to know what's hovered.
300
+
301
+ ### High-DPI displays (devicePixelRatio)
302
+
303
+ The [Quick start](#quick-start) resize snippet sizes the canvas's backing store
304
+ (`canvas.width`/`height`) to `devicePixelRatio` times its CSS display size — the standard
305
+ recipe for a crisp, non-blurry `<canvas>` on a Retina/high-DPI screen. wick-charts detects this
306
+ itself, live, by comparing `canvas.width`/`height` against `canvas.getBoundingClientRect()` on
307
+ every frame — there's no `devicePixelRatio` option to set, and nothing to recompute yourself on
308
+ resize or on a browser zoom change; the chart just reads whatever the canvas's current backing
309
+ store vs. CSS size actually is.
310
+
311
+ Once detected, every CSS-pixel size option listed at the end of the previous section is scaled
312
+ by that ratio before it's used — so `axisSize: 10` always looks like a 10px font on screen,
313
+ whether the backing store is 1x or 3x the CSS size, and you never have to pre-multiply any
314
+ option by `window.devicePixelRatio` yourself. (Colors and tick counts — `priceTickCount`,
315
+ `timeMaxTicks` — aren't sizes and pass through unscaled.)
316
+
317
+ This only reaches what the engine itself draws. A `ChartPlugin` or a custom `SeriesDefinition`
318
+ sets its own canvas properties directly (`ctx.lineWidth`, a font size in `ctx.font`, a marker
319
+ radius), and the engine has no way to know which of those are meant to be sizes — so both
320
+ `PluginRenderApi` and `SeriesDrawContext` carry a `devicePixelRatio` field for exactly this:
321
+ multiply your own literal pixel sizes by it before setting them on `ctx`, the same way the
322
+ built-in line series scales `LineStyle.lineWidth`:
323
+
324
+ ```ts
325
+ // inside a ChartPlugin's draw(api), or a custom SeriesDefinition's draw(context, style)
326
+ ctx.lineWidth = 2 * api.devicePixelRatio; // always ~2 CSS px, not 2 backing-store px
327
+ ```
328
+
329
+ If you never resize the canvas to a scaled backing store at all (`canvas.width` already equals
330
+ its CSS display size, the default for an unstyled `<canvas>`), the ratio is exactly 1 and
331
+ nothing here changes anything.
332
+
232
333
  ### Inverting the value axis
233
334
 
234
335
  `invertValueAxis` mirrors the value axis top-to-bottom — every pane's higher values render
@@ -513,6 +614,62 @@ the price pane — an indicator pane's own hover readout, if you want one, is so
513
614
  plugin draws (it has the same `xForIndex`/`yForValue` a price-pane plugin does, just mapped
514
615
  against that pane's own value domain and pixel rect).
515
616
 
617
+ ### Splitting volume into its own pane
618
+
619
+ The candlestick series' default volume bars are a translucent backdrop *inside* the price
620
+ pane (`CandlestickStyle.volumeAreaHeightRatio`, `0.2` of the chart height by default) — fine
621
+ for a compact chart, but on a tall one, or whenever a candle's own range dips into that bottom
622
+ margin, the bars visually collide with the candles drawn over them. The same `addPane` +
623
+ `ChartPlugin.paneId` mechanism the previous section uses for RSI/MACD works just as well for
624
+ volume — it just reads `Candle.volume` instead of computing an indicator:
625
+
626
+ ```ts
627
+ // PaneOptions.getValueRange takes no arguments, so — same as the MACD pane above needing its
628
+ // own auto-fit range — it closes over the chart's current data itself rather than reading it
629
+ // from an argument. `allData` is whatever you last passed to `chart.setData()`.
630
+ let allData: Candle[] = [];
631
+
632
+ const chart = createCandlestickChart(canvas, {
633
+ style: { volumeAreaHeightRatio: 0 }, // suppress the in-pane backdrop — see below for why
634
+ });
635
+
636
+ chart.addPane({
637
+ id: 'volume',
638
+ heightRatio: 0.15,
639
+ getValueRange: () => {
640
+ const { startIndex, endIndex } = chart.getVisibleRange();
641
+ let max = 0;
642
+ for (let i = startIndex; i < endIndex; i++) max = Math.max(max, allData[i]?.volume ?? 0);
643
+ return { min: 0, max: max || 1 };
644
+ },
645
+ });
646
+
647
+ chart.addPlugin({
648
+ paneId: 'volume',
649
+ draw({ ctx, allPoints, visibleStartIndex, visibleEndIndex, xForIndex, yForValue }) {
650
+ const zeroY = yForValue(0);
651
+ const barWidth = (xForIndex(visibleStartIndex + 1) - xForIndex(visibleStartIndex)) * 0.6;
652
+ for (let i = visibleStartIndex; i < visibleEndIndex; i++) {
653
+ const c = allPoints[i];
654
+ if (c.volume === undefined) continue;
655
+ ctx.fillStyle = c.close >= c.open ? '#26a69a' : '#ef5350';
656
+ const y = yForValue(c.volume);
657
+ ctx.fillRect(xForIndex(i) - barWidth / 2, y, barWidth, zeroY - y);
658
+ }
659
+ },
660
+ });
661
+
662
+ chart.setData(candles);
663
+ allData = candles; // keep in sync on every later setData() call too
664
+ ```
665
+
666
+ Setting `volumeAreaHeightRatio: 0` on the style matters — leaving the default `0.2` active
667
+ alongside a dedicated volume pane double-draws the bars (once as the in-pane backdrop, once in
668
+ the new pane). Unlike an indicator pane's `getValueRange`, volume's domain is naturally
669
+ `[0, max]` rather than something auto-fit around a signed value the way MACD's is — the
670
+ example above recomputes `max` from the currently visible candles each frame, the same way
671
+ `computeMacdValueRange` does in the "Multi-pane indicators" example above.
672
+
516
673
  ### Cleanup
517
674
 
518
675
  Call `chart.destroy()` when you're done with a chart (component unmount, etc.) — it removes a
@@ -1,4 +1,4 @@
1
- import type { ChartCrosshairOptions, ChartFontOptions, ChartLegendOptions } from './types.js';
1
+ import type { ChartCrosshairOptions, ChartFontOptions, ChartLegendOptions, LegendLine } from './types.js';
2
2
  /** Everything one frame's hover crosshair/legend needs — computed by
3
3
  * `ChartRenderer.render()` (which owns the hovered point, the active
4
4
  * series, and its style) and handed in as plain data so this class stays
@@ -19,7 +19,7 @@ export interface CrosshairRenderInput {
19
19
  * across every indicator pane below it. */
20
20
  stackHeight: number;
21
21
  invertValueAxis: boolean;
22
- legendParts: string[];
22
+ legendParts: LegendLine[];
23
23
  /** The canvas's own backing-store width — needed only to clamp the
24
24
  * time-axis label chip so it never runs off the right edge. */
25
25
  canvasWidth: number;
@@ -27,7 +27,8 @@ export class CrosshairRenderer {
27
27
  const { ctx, crosshair } = this;
28
28
  ctx.save();
29
29
  ctx.strokeStyle = crosshair.lineColor;
30
- ctx.setLineDash([4, 4]);
30
+ ctx.lineWidth = crosshair.lineWidth;
31
+ ctx.setLineDash(crosshair.lineDash);
31
32
  // Spans the whole pane stack (not just the main pane's own
32
33
  // chartHeight) so hovering a candle lines up with the same column
33
34
  // across every indicator pane below it — see `stackHeight`'s own doc
@@ -74,8 +75,12 @@ export class CrosshairRenderer {
74
75
  ctx.font = this.legendFont();
75
76
  ctx.textAlign = 'left';
76
77
  ctx.textBaseline = 'top';
78
+ // Each line is either a plain string or { text, color } — normalize to
79
+ // its text once up front for sizing, and read its own color (falling
80
+ // back to legend.textColor) only when actually drawing it below.
81
+ const texts = lines.map((line) => (typeof line === 'string' ? line : line.text));
77
82
  const lineHeight = font.legendSize + 4;
78
- const textWidth = Math.max(...lines.map((line) => ctx.measureText(line).width));
83
+ const textWidth = Math.max(...texts.map((text) => ctx.measureText(text).width));
79
84
  const boxWidth = textWidth + legend.paddingX * 2;
80
85
  const boxHeight = lines.length * lineHeight + legend.paddingY * 2;
81
86
  const anchorY = hoverY ?? 0;
@@ -83,9 +88,9 @@ export class CrosshairRenderer {
83
88
  const top = Math.min(Math.max(anchorY - boxHeight - legend.cursorGap, 0), Math.max(0, chartHeight - boxHeight));
84
89
  ctx.fillStyle = legend.background;
85
90
  ctx.fillRect(left, top, boxWidth, boxHeight);
86
- ctx.fillStyle = legend.textColor;
87
91
  lines.forEach((line, i) => {
88
- ctx.fillText(line, left + legend.paddingX, top + legend.paddingY + i * lineHeight);
92
+ ctx.fillStyle = typeof line === 'string' ? legend.textColor : (line.color ?? legend.textColor);
93
+ ctx.fillText(texts[i], left + legend.paddingX, top + legend.paddingY + i * lineHeight);
89
94
  });
90
95
  }
91
96
  /** The highlighted price-axis label that follows the crosshair's
@@ -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, LegendLine, LinePoint, PaneOptions, WickChartOptions, SeriesPoint, ValueRange, WickTime } from './types.js';
6
+ export type { BusinessDay, Candle, LegendFormatContext, LegendLine, 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>): LegendLine[];
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>): LegendLine[];
341
349
  }): WickChart<LinePoint>;
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { lowerBound, upperBound } from './binarySearch.js';
2
+ import { devicePixelRatio } from './devicePixelRatio.js';
2
3
  import { mergeSeriesPoints } from './mergeSeries.js';
3
4
  import { computePaneLayout } from './paneLayout.js';
4
5
  import { ChartRenderer } from './renderer.js';
@@ -841,12 +842,10 @@ export class WickChart {
841
842
  * conversion `cursorPosition` applies to absolute coordinates, extracted
842
843
  * so pixel *deltas* (drag distance, wheel deltaX) can be converted too. */
843
844
  devicePixelScaleX() {
844
- const rect = this.canvas.getBoundingClientRect();
845
- return rect.width === 0 ? 1 : this.canvas.width / rect.width;
845
+ return devicePixelRatio(this.canvas, 'width');
846
846
  }
847
847
  devicePixelScaleY() {
848
- const rect = this.canvas.getBoundingClientRect();
849
- return rect.height === 0 ? 1 : this.canvas.height / rect.height;
848
+ return devicePixelRatio(this.canvas, 'height');
850
849
  }
851
850
  }
852
851
  /**
@@ -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`. */
@@ -43,7 +43,13 @@ export interface RenderInput<TPoint extends SeriesPoint> {
43
43
  * coloring/padding, legend color) is resolved once at construction from
44
44
  * `WickChartOptions.font`/`axis`/`crosshair`/`legend`, each merged field
45
45
  * by field over its own defaults — nothing here is a hardcoded module
46
- * 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.
47
53
  */
48
54
  export declare class ChartRenderer<TPoint extends SeriesPoint> {
49
55
  private canvas;
@@ -51,24 +57,40 @@ export declare class ChartRenderer<TPoint extends SeriesPoint> {
51
57
  private ctx;
52
58
  private background;
53
59
  private style;
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. */
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. */
58
67
  private axis;
59
- private axisRenderer;
60
- private crosshairRenderer;
68
+ private font;
69
+ private crosshair;
70
+ private legend;
61
71
  /** Unlike the style groups above, mutable after construction — see
62
72
  * `setInvertValueAxis`. A live toggle, not a one-time style choice, is
63
73
  * the whole point of this option (a "what if this series moved the
64
74
  * opposite way" view a user flips on and off), so it doesn't get the
65
75
  * "resolved once in the constructor" treatment those get. */
66
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?;
67
81
  constructor(canvas: HTMLCanvasElement, seriesDefinition: SeriesDefinition<TPoint, unknown>, options?: WickChartOptions);
68
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();
69
88
  /** Pixel width of the point-plotting area — excludes the price-axis
70
89
  * strip on the right. Exposed so `WickChart` can convert cursor pixel
71
- * 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. */
72
94
  get chartWidth(): number;
73
95
  get chartHeight(): number;
74
96
  get priceAxisWidth(): number;
package/dist/renderer.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { AxisRenderer } from './axisRenderer.js';
2
2
  import { CrosshairRenderer } from './crosshairRenderer.js';
3
+ import { devicePixelRatio } from './devicePixelRatio.js';
3
4
  import { createScale } from './hybridScale.js';
4
5
  import { computePaneLayout } from './paneLayout.js';
5
6
  import { pixelToValue, valueAxisPixelRange } from './valueAxis.js';
@@ -20,6 +21,8 @@ const DEFAULT_AXIS = {
20
21
  };
21
22
  const DEFAULT_CROSSHAIR = {
22
23
  lineColor: '#9090904d',
24
+ lineWidth: 1,
25
+ lineDash: [4, 4],
23
26
  labelBackground: '#3a3a3a',
24
27
  labelTextColor: '#f0f0f0',
25
28
  labelPaddingX: 4,
@@ -53,7 +56,13 @@ const DEFAULT_LEGEND = {
53
56
  * coloring/padding, legend color) is resolved once at construction from
54
57
  * `WickChartOptions.font`/`axis`/`crosshair`/`legend`, each merged field
55
58
  * by field over its own defaults — nothing here is a hardcoded module
56
- * constant a caller can't reach.
59
+ * constant a caller can't reach. Every *size* among them (font sizes, axis
60
+ * strip widths, padding, gaps) is specified in CSS pixels and scaled by the
61
+ * canvas's live devicePixelRatio (see `deviceRatio`) at the top of every
62
+ * `render()` call before use — colors and tick counts pass through
63
+ * unscaled. `AxisRenderer`/`CrosshairRenderer` themselves stay unaware of
64
+ * this: they're handed already-scaled options each frame, the same as they
65
+ * were handed unscaled ones before this existed.
57
66
  */
58
67
  export class ChartRenderer {
59
68
  constructor(canvas, seriesDefinition, options = {}) {
@@ -67,39 +76,87 @@ export class ChartRenderer {
67
76
  this.style = { ...seriesDefinition.defaultStyle, ...(options.style ?? {}) };
68
77
  this.axis = { ...DEFAULT_AXIS, ...options.axis };
69
78
  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);
79
+ this.formatLegendOverride = options.formatLegend;
80
+ this.font = { ...DEFAULT_FONT, ...options.font };
81
+ this.crosshair = { ...DEFAULT_CROSSHAIR, ...options.crosshair };
82
+ this.legend = { ...DEFAULT_LEGEND, ...options.legend };
75
83
  }
76
84
  setInvertValueAxis(inverted) {
77
85
  this.invertValueAxis = inverted;
78
86
  }
87
+ /** Ratio between the canvas's backing-store size and its CSS display
88
+ * size — read live, not cached, for the same reason `chartWidth`/
89
+ * `chartHeight` below read `canvas.width`/`height` live: whatever it is
90
+ * *right now* is what this frame draws at. See `src/devicePixelRatio.ts`. */
91
+ get deviceRatio() {
92
+ return devicePixelRatio(this.canvas, 'width');
93
+ }
79
94
  /** Pixel width of the point-plotting area — excludes the price-axis
80
95
  * strip on the right. Exposed so `WickChart` can convert cursor pixel
81
- * positions to point indices / values for hit-testing and dragging. */
96
+ * positions to point indices / values for hit-testing and dragging.
97
+ * `priceAxisWidth` below is already devicePixelRatio-scaled, so this
98
+ * (and `chartHeight`) stay correct on a high-DPI canvas without
99
+ * `WickChart` having to know anything about DPR itself. */
82
100
  get chartWidth() {
83
- return Math.max(0, this.canvas.width - this.axis.priceWidth);
101
+ return Math.max(0, this.canvas.width - this.priceAxisWidth);
84
102
  }
85
103
  get chartHeight() {
86
- return Math.max(0, this.canvas.height - this.axis.timeHeight);
104
+ return Math.max(0, this.canvas.height - this.axis.timeHeight * this.deviceRatio);
87
105
  }
88
106
  get priceAxisWidth() {
89
- return this.axis.priceWidth;
107
+ return this.axis.priceWidth * this.deviceRatio;
90
108
  }
91
109
  render(input) {
92
110
  const { ctx, canvas, background, seriesDefinition, style } = this;
93
111
  const { sorted, times, viewport, hoverIndex, hoverY, plugins, panes } = input;
112
+ const ratio = this.deviceRatio;
113
+ // Scaled fresh every frame (see `deviceRatio`'s own doc comment for
114
+ // why this isn't done once at construction) — every *size* field gets
115
+ // multiplied by `ratio`, every color/count field passes through as-is.
116
+ // `AxisRenderer`/`CrosshairRenderer` are cheap POJO-ish collaborators
117
+ // with no state beyond these options, so rebuilding them here each
118
+ // frame is simpler than threading `ratio` through every one of their
119
+ // methods for what's otherwise the same "resolved options" shape they
120
+ // were built to take in the first place.
121
+ const scaledAxis = {
122
+ ...this.axis,
123
+ priceWidth: this.axis.priceWidth * ratio,
124
+ timeHeight: this.axis.timeHeight * ratio,
125
+ };
126
+ const scaledFont = {
127
+ ...this.font,
128
+ axisSize: this.font.axisSize * ratio,
129
+ legendSize: this.font.legendSize * ratio,
130
+ };
131
+ const scaledCrosshair = {
132
+ ...this.crosshair,
133
+ lineWidth: this.crosshair.lineWidth * ratio,
134
+ lineDash: this.crosshair.lineDash.map((segment) => segment * ratio),
135
+ labelPaddingX: this.crosshair.labelPaddingX * ratio,
136
+ labelPaddingY: this.crosshair.labelPaddingY * ratio,
137
+ };
138
+ const scaledLegend = {
139
+ ...this.legend,
140
+ paddingX: this.legend.paddingX * ratio,
141
+ paddingY: this.legend.paddingY * ratio,
142
+ cursorGap: this.legend.cursorGap * ratio,
143
+ };
144
+ const axisRenderer = new AxisRenderer(ctx, scaledAxis, scaledFont);
145
+ const crosshairRenderer = new CrosshairRenderer(ctx, scaledCrosshair, scaledLegend, scaledFont, scaledAxis.priceWidth);
94
146
  const width = canvas.width;
95
147
  const height = canvas.height;
96
- const chartWidth = this.chartWidth;
148
+ // Computed from `scaledAxis` (already built from `ratio` above) rather
149
+ // than by re-reading `this.chartWidth`/`chartHeight` — those getters
150
+ // recompute `deviceRatio`, which reads `getBoundingClientRect()` (a
151
+ // potential layout reflow in a real browser); doing that three times
152
+ // per frame instead of once matters at 60fps.
153
+ const chartWidth = Math.max(0, width - scaledAxis.priceWidth);
97
154
  // Full stack height: the main price pane plus every declared indicator
98
155
  // pane below it. `this.chartHeight` predates panes and named what's
99
156
  // now only true with zero of them — kept as the property name (public
100
157
  // API reads it through) but renamed locally here since most of this
101
158
  // method cares about one pane's height, not the stack's.
102
- const stackHeight = this.chartHeight;
159
+ const stackHeight = Math.max(0, height - scaledAxis.timeHeight);
103
160
  ctx.clearRect(0, 0, width, height);
104
161
  if (background !== 'transparent') {
105
162
  ctx.fillStyle = background;
@@ -164,40 +221,19 @@ export class ChartRenderer {
164
221
  // grid lines, and the crosshair.
165
222
  ctx.save();
166
223
  try {
167
- seriesDefinition.draw({ ctx, visible, startIndex: startIdx, xForIndex, slotWidth, yScale, chartHeight }, style);
224
+ seriesDefinition.draw({ ctx, visible, startIndex: startIdx, xForIndex, slotWidth, yScale, chartHeight, devicePixelRatio: ratio }, style);
168
225
  }
169
226
  finally {
170
227
  ctx.restore();
171
228
  }
172
- const priceStep = this.axisRenderer.priceStep(valueMin, valueMax);
173
- this.axisRenderer.renderPriceAxis(valueMin, valueMax, priceStep, yScale, chartWidth, chartHeight, mainRect.top);
229
+ const priceStep = axisRenderer.priceStep(valueMin, valueMax);
230
+ axisRenderer.renderPriceAxis(valueMin, valueMax, priceStep, yScale, chartWidth, chartHeight, mainRect.top);
174
231
  for (const { rect, min, max, scale } of paneScales) {
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);
178
- }
179
- this.axisRenderer.renderTimeAxis(times, startIdx, visible.length, stackHeight, chartWidth, xForIndex);
180
- if (hoverIndex !== null && hoverIndex >= startIdx && hoverIndex < endIdx) {
181
- // The dashed vertical line spans the whole stack (every pane); the
182
- // horizontal line, price-label chip, and OHLC legend stay scoped
183
- // to the main pane only — an indicator pane's own hover readout,
184
- // if it wants one, is the job of whatever plugin draws into it.
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
- });
232
+ axisRenderer.renderPaneSeparator(rect.top, chartWidth);
233
+ const step = axisRenderer.priceStep(min, max);
234
+ axisRenderer.renderPriceAxis(min, max, step, scale, chartWidth, rect.height, rect.top);
200
235
  }
236
+ axisRenderer.renderTimeAxis(times, startIdx, visible.length, stackHeight, chartWidth, xForIndex);
201
237
  if (plugins.length > 0) {
202
238
  // Everything every pane's PluginRenderApi shares — only the pane's
203
239
  // own rect/value-domain/scale differ between `buildPluginApi`
@@ -213,10 +249,10 @@ export class ChartRenderer {
213
249
  allPoints: sorted,
214
250
  frameState,
215
251
  };
216
- const mainApi = this.buildPluginApi(mainRect, valueMin, valueMax, yScale, frameGeometry);
252
+ const mainApi = this.buildPluginApi(mainRect, valueMin, valueMax, yScale, frameGeometry, ratio);
217
253
  const paneApiById = new Map();
218
254
  for (const { pane, rect, min, max, scale } of paneScales) {
219
- paneApiById.set(pane.id, this.buildPluginApi(rect, min, max, scale, frameGeometry));
255
+ paneApiById.set(pane.id, this.buildPluginApi(rect, min, max, scale, frameGeometry, ratio));
220
256
  }
221
257
  for (const plugin of plugins) {
222
258
  if (plugin.visible === false)
@@ -242,6 +278,37 @@ export class ChartRenderer {
242
278
  }
243
279
  }
244
280
  }
281
+ // Drawn last, after every plugin (including paneId'd ones and main-pane
282
+ // overlays like trade markers) rather than right after the axes, so the
283
+ // hover legend/crosshair always sits on top of everything else instead
284
+ // of being covered by a plugin's own output — the crosshair is the most
285
+ // "active"/topmost thing on the chart at the moment it's shown.
286
+ if (hoverIndex !== null && hoverIndex >= startIdx && hoverIndex < endIdx) {
287
+ // The dashed vertical line spans the whole stack (every pane); the
288
+ // horizontal line, price-label chip, and OHLC legend stay scoped
289
+ // to the main pane only — an indicator pane's own hover readout,
290
+ // if it wants one, is the job of whatever plugin draws into it.
291
+ // `formatLegendOverride`, when set, replaces the series's own
292
+ // formatLegend entirely for this chart instance rather than
293
+ // merging with it — see `WickChartOptions.formatLegend`.
294
+ const formatLegend = this.formatLegendOverride ?? seriesDefinition.formatLegend;
295
+ const legendContext = { index: hoverIndex, allPoints: sorted };
296
+ const legendParts = formatLegend?.(sorted[hoverIndex], style, legendContext) ?? [];
297
+ crosshairRenderer.render({
298
+ x: xForIndex(hoverIndex),
299
+ timeSeconds: times[hoverIndex],
300
+ hoverY,
301
+ valueMin,
302
+ valueMax,
303
+ priceStep,
304
+ chartWidth,
305
+ chartHeight,
306
+ stackHeight,
307
+ invertValueAxis: this.invertValueAxis,
308
+ legendParts,
309
+ canvasWidth: canvas.width,
310
+ });
311
+ }
245
312
  }
246
313
  finally {
247
314
  frameState.ended = true;
@@ -257,12 +324,13 @@ export class ChartRenderer {
257
324
  * for this frame (shared because there is only one time axis, and one
258
325
  * frame-ended flag, for the whole stack; see `FrameGeometry`).
259
326
  */
260
- buildPluginApi(rect, valueMin, valueMax, scale, geometry) {
327
+ buildPluginApi(rect, valueMin, valueMax, scale, geometry, devicePixelRatio) {
261
328
  const { chartWidth, xForIndex, indexForX, visibleStartIndex, visibleEndIndex, allPoints, frameState } = geometry;
262
329
  return {
263
330
  ctx: this.ctx,
264
331
  chartWidth,
265
332
  chartHeight: rect.height,
333
+ devicePixelRatio,
266
334
  xForIndex,
267
335
  yForValue: (value) => {
268
336
  if (frameState.ended) {
@@ -11,7 +11,12 @@ export interface CandlestickStyle {
11
11
  /** Fraction of the chart's full height that volume bars occupy, measured
12
12
  * up from the bottom. Candles still use the full height for their own
13
13
  * price scale regardless of this value — the bars sit in this bottom
14
- * margin, layered underneath. Defaults to 0.2. */
14
+ * margin, layered underneath (and can visually collide with a candle
15
+ * whose range dips into that margin). Defaults to 0.2. Set to 0 to
16
+ * suppress this in-pane backdrop entirely — do this when drawing volume
17
+ * in its own pane instead via `WickChart.addPane` + a `ChartPlugin`
18
+ * reading `Candle.volume` off `allPoints` (see "Splitting volume into
19
+ * its own pane" in the README), so the two don't double-draw. */
15
20
  volumeAreaHeightRatio: number;
16
21
  /** Opacity (0-1) of the volume bars, so they read as a backdrop rather
17
22
  * than competing with the candles drawn over them. Defaults to 0.5. */
@@ -18,6 +18,13 @@ function getValueRange(visible, scaleFactor) {
18
18
  * OHLC-only data look exactly as they did before this existed. */
19
19
  function drawVolumeBars(context, style) {
20
20
  const { ctx, visible, startIndex, xForIndex, slotWidth, chartHeight } = context;
21
+ // A caller drawing volume in its own pane via WickChart.addPane (see
22
+ // "Splitting volume into its own pane" in the README) sets this to 0 to
23
+ // fully suppress this in-pane backdrop — without this guard, the
24
+ // Math.max(1, ...) floor below still drew a 1px sliver per candle even
25
+ // at ratio 0.
26
+ if (style.volumeAreaHeightRatio <= 0)
27
+ return;
21
28
  const maxVolume = visible.reduce((max, c) => (c.volume !== undefined ? Math.max(max, c.volume) : max), 0);
22
29
  if (maxVolume <= 0)
23
30
  return;
@@ -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
- ctx.lineWidth = style.lineWidth;
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
@@ -1,5 +1,5 @@
1
1
  import type { Scale } from '../hybridScale.js';
2
- import type { SeriesPoint, ValueRange } from '../types.js';
2
+ import type { LegendFormatContext, LegendLine, 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
- formatLegend?(point: TPoint, style: TStyle): string[];
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>): LegendLine[];
56
72
  }
package/dist/types.d.ts CHANGED
@@ -103,8 +103,16 @@ export interface ChartAxisOptions {
103
103
  /** Coloring and padding for the hover crosshair's lines and its two
104
104
  * highlighted axis-label chips. */
105
105
  export interface ChartCrosshairOptions {
106
- /** Dashed crosshair line color. Defaults to `'#9090904d'`. */
106
+ /** Crosshair line color. Defaults to `'#9090904d'`. */
107
107
  lineColor?: string;
108
+ /** Crosshair line thickness, in CSS px (scaled by `devicePixelRatio`
109
+ * like every other size field here). Defaults to `1`. */
110
+ lineWidth?: number;
111
+ /** `CanvasRenderingContext2D.setLineDash` pattern, in CSS px (scaled by
112
+ * `devicePixelRatio` like every other size field here). Defaults to
113
+ * `[4, 4]`. Pass `[]` for a solid line — the same canvas convention
114
+ * `setLineDash` itself uses, so this needs no separate on/off flag. */
115
+ lineDash?: number[];
108
116
  /** Background fill of the price/time label chips. Defaults to `'#3a3a3a'`. */
109
117
  labelBackground?: string;
110
118
  /** Text color inside the label chips. Defaults to `'#f0f0f0'`. */
@@ -169,6 +177,34 @@ export interface ResolvedPaneOptions {
169
177
  heightRatio: number;
170
178
  getValueRange: () => ValueRange;
171
179
  }
180
+ /**
181
+ * Context `SeriesDefinition.formatLegend` and `WickChartOptions.formatLegend`
182
+ * get alongside the hovered point and its style — everything needed to
183
+ * compute something derived from *neighboring* points (a percent change
184
+ * vs. the previous point, say), which the hovered point alone can't
185
+ * express. The same `allPoints`/index-into-it shape `SeriesDrawContext`
186
+ * already gives a series's own `draw()`, reused here so both call sites
187
+ * answer "what else is near this point" the same way.
188
+ */
189
+ export interface LegendFormatContext<TPoint extends SeriesPoint = SeriesPoint> {
190
+ /** Global index of the hovered point in `allPoints`. */
191
+ index: number;
192
+ /** Every point currently loaded (not just visible), sorted ascending by
193
+ * time — the same array `SeriesDrawContext.allPoints` is. */
194
+ allPoints: readonly TPoint[];
195
+ }
196
+ /**
197
+ * One line of the hover legend — a plain `string` (drawn in the legend's
198
+ * configured `textColor`, same as every version of this library before
199
+ * per-line color existed) or `{ text, color }` for a line that needs its
200
+ * own color independent of the rest — a percent-change line that should
201
+ * read green/red by sign, say. Mixing both in the same `formatLegend`
202
+ * return array is fine: each line is colored independently.
203
+ */
204
+ export type LegendLine = string | {
205
+ text: string;
206
+ color?: string;
207
+ };
172
208
  export interface WickChartOptions {
173
209
  /**
174
210
  * Which registered series type to render this chart as (see
@@ -199,6 +235,32 @@ export interface WickChartOptions {
199
235
  crosshair?: ChartCrosshairOptions;
200
236
  /** Hover legend coloring. Merged over the built-in defaults field by field. */
201
237
  legend?: ChartLegendOptions;
238
+ /**
239
+ * Overrides the active series's own `formatLegend` (see
240
+ * `SeriesDefinition.formatLegend`) for this chart instance specifically —
241
+ * the hover legend's text, one `LegendLine` per line (a plain string, or
242
+ * `{ text, color }` for a line that needs its own color — see
243
+ * `LegendLine`). `SeriesDefinition.formatLegend`
244
+ * is a shared default for every chart of that series *type* (registered
245
+ * once via `registerSeries`); this is a per-*instance* override for
246
+ * whatever varies by app/session instead of by chart type — localized
247
+ * labels, or a value derived from neighboring points via
248
+ * `context.allPoints`/`context.index` (a percent change vs. the previous
249
+ * point, say). Returning an empty array suppresses the built-in tooltip
250
+ * entirely, the same as a series with no `formatLegend` at all — draw
251
+ * your own via a `ChartPlugin` instead if you need a different layout,
252
+ * not just different text.
253
+ *
254
+ * Declared as a method (not an arrow-typed property) so, like
255
+ * `SeriesDefinition.formatLegend`, it type-checks bivariantly rather than
256
+ * contravariantly — the same tradeoff `style`'s untyped
257
+ * `Record<string, unknown>` already makes here: `new WickChart(canvas, {
258
+ * type, formatLegend })` doesn't verify `point`/`style` actually match
259
+ * `type`'s series, but `createCandlestickChart`/`createLineChart` narrow
260
+ * both to the concrete series's own types, the same way they narrow
261
+ * `style` — see their own doc comments.
262
+ */
263
+ formatLegend?(point: SeriesPoint, style: unknown, context: LegendFormatContext<SeriesPoint>): LegendLine[];
202
264
  /**
203
265
  * Mirrors the value axis top-to-bottom — every pane's higher values
204
266
  * render lower on screen instead of higher, with no change to the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wick-charts",
3
- "version": "0.7.1",
3
+ "version": "0.10.0",
4
4
  "description": "An open-source financial charting library — WASM (Rust) for compute, Canvas2D for rendering.",
5
5
  "license": "MIT",
6
6
  "author": "eatnows",