wick-charts 0.3.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +545 -0
  3. package/dist/axis.d.ts +21 -0
  4. package/dist/axis.js +44 -0
  5. package/dist/dataSource.d.ts +24 -0
  6. package/dist/dataSource.js +1 -0
  7. package/dist/hitTest.d.ts +31 -0
  8. package/dist/hitTest.js +46 -0
  9. package/dist/hybridScale.d.ts +25 -0
  10. package/dist/hybridScale.js +41 -0
  11. package/dist/index.d.ts +225 -0
  12. package/dist/index.js +715 -0
  13. package/dist/mergeSeries.d.ts +14 -0
  14. package/dist/mergeSeries.js +21 -0
  15. package/dist/plugins/types.d.ts +140 -0
  16. package/dist/plugins/types.js +1 -0
  17. package/dist/priceAxis.d.ts +7 -0
  18. package/dist/priceAxis.js +49 -0
  19. package/dist/priceRange.d.ts +12 -0
  20. package/dist/priceRange.js +16 -0
  21. package/dist/renderer.d.ts +79 -0
  22. package/dist/renderer.js +318 -0
  23. package/dist/scale.d.ts +20 -0
  24. package/dist/scale.js +29 -0
  25. package/dist/series/candlestick.d.ts +20 -0
  26. package/dist/series/candlestick.js +88 -0
  27. package/dist/series/registry.d.ts +13 -0
  28. package/dist/series/registry.js +30 -0
  29. package/dist/series/types.d.ts +56 -0
  30. package/dist/series/types.js +1 -0
  31. package/dist/testHelpers.d.ts +38 -0
  32. package/dist/testHelpers.js +50 -0
  33. package/dist/time.d.ts +6 -0
  34. package/dist/time.js +58 -0
  35. package/dist/types.d.ts +151 -0
  36. package/dist/types.js +1 -0
  37. package/dist/viewport.d.ts +52 -0
  38. package/dist/viewport.js +87 -0
  39. package/dist/wasm.d.ts +29 -0
  40. package/dist/wasm.js +35 -0
  41. package/dist/wasmImporter.d.ts +6 -0
  42. package/dist/wasmImporter.js +7 -0
  43. package/package.json +39 -0
  44. package/wasm-pkg/package.json +21 -0
  45. package/wasm-pkg/wickchart_core.d.ts +59 -0
  46. package/wasm-pkg/wickchart_core.js +227 -0
  47. package/wasm-pkg/wickchart_core_bg.wasm +0 -0
  48. package/wasm-pkg/wickchart_core_bg.wasm.d.ts +11 -0
@@ -0,0 +1,14 @@
1
+ import type { SeriesPoint } from './types.js';
2
+ /**
3
+ * Merges `incoming` into `existing`, de-duplicating by normalized time
4
+ * (incoming wins on conflict — it's the freshly-fetched source of truth)
5
+ * and returns a fully re-sorted ascending array. Generic over any point
6
+ * shape with a `time` field, not just `Candle` — a future series type
7
+ * reuses this unchanged.
8
+ *
9
+ * Overlap is expected, not an edge case: a loader asked for "everything
10
+ * before time T" may reasonably return a batch that laps back over points
11
+ * the chart already has, and the caller shouldn't have to worry about
12
+ * trimming it exactly.
13
+ */
14
+ export declare function mergeSeriesPoints<TPoint extends SeriesPoint>(existing: TPoint[], incoming: TPoint[]): TPoint[];
@@ -0,0 +1,21 @@
1
+ import { toUnixSeconds } from './time.js';
2
+ /**
3
+ * Merges `incoming` into `existing`, de-duplicating by normalized time
4
+ * (incoming wins on conflict — it's the freshly-fetched source of truth)
5
+ * and returns a fully re-sorted ascending array. Generic over any point
6
+ * shape with a `time` field, not just `Candle` — a future series type
7
+ * reuses this unchanged.
8
+ *
9
+ * Overlap is expected, not an edge case: a loader asked for "everything
10
+ * before time T" may reasonably return a batch that laps back over points
11
+ * the chart already has, and the caller shouldn't have to worry about
12
+ * trimming it exactly.
13
+ */
14
+ export function mergeSeriesPoints(existing, incoming) {
15
+ const byTime = new Map();
16
+ for (const p of existing)
17
+ byTime.set(toUnixSeconds(p.time), p);
18
+ for (const p of incoming)
19
+ byTime.set(toUnixSeconds(p.time), p);
20
+ return Array.from(byTime.values()).sort((a, b) => toUnixSeconds(a.time) - toUnixSeconds(b.time));
21
+ }
@@ -0,0 +1,140 @@
1
+ import type { SeriesPoint } from '../types.js';
2
+ /**
3
+ * What a plugin gets to draw with, rebuilt fresh by `ChartRenderer` every
4
+ * frame from that frame's own geometry — a plugin never caches this across
5
+ * renders, since panning/zooming changes every value here.
6
+ *
7
+ * Stronger than "don't cache it": `xForIndex` and `yForValue` must only be
8
+ * called *synchronously*, inside the `draw()` call that received this
9
+ * object. `yForValue` closes over the frame's `Scale`, which for a
10
+ * large-enough series is WASM-backed and gets its backing memory freed the
11
+ * moment `draw()` returns (see `ChartRenderer.render`'s `finally` block) —
12
+ * stashing this object for a `setTimeout`, a promise callback, or the next
13
+ * frame and calling `yForValue` from there is a use-after-free, not just a
14
+ * staleness bug.
15
+ */
16
+ export interface PluginRenderApi<TPoint extends SeriesPoint = SeriesPoint> {
17
+ ctx: CanvasRenderingContext2D;
18
+ chartWidth: number;
19
+ chartHeight: number;
20
+ /** Global (full sorted-array) index -> x pixel, same convention the
21
+ * active series draws with. Valid only for the duration of this `draw()`
22
+ * call — see the interface-level note on `PluginRenderApi`. */
23
+ xForIndex: (globalIndex: number) => number;
24
+ /** Value in the current frame's y-domain -> y pixel. Valid only for the
25
+ * duration of this `draw()` call — see the interface-level note on
26
+ * `PluginRenderApi`. */
27
+ yForValue: (value: number) => number;
28
+ /** x pixel -> global (possibly fractional) index — the exact inverse of
29
+ * `xForIndex`, i.e. `xForIndex(indexForX(x)) === x`. For placing or
30
+ * hit-testing something at a pixel position instead of a known index. */
31
+ indexForX: (x: number) => number;
32
+ /** y pixel -> value in the current frame's y-domain — the exact inverse
33
+ * of `yForValue`. */
34
+ valueForY: (y: number) => number;
35
+ /** Index range currently visible, global (sorted-array) indices. */
36
+ visibleStartIndex: number;
37
+ visibleEndIndex: number;
38
+ /**
39
+ * Every point currently loaded (not just visible), sorted ascending by
40
+ * time — the same array the active series slices its own `visible` from.
41
+ * A plugin that needs data outside the visible window (a moving average
42
+ * needs `period - 1` points of "warm-up" history before the first
43
+ * visible bar to be accurate there) reads from here rather than being
44
+ * limited to `visibleStartIndex..visibleEndIndex`.
45
+ */
46
+ allPoints: readonly TPoint[];
47
+ }
48
+ /**
49
+ * A pointer (mouse, or single-finger touch) position, given to a plugin's
50
+ * `onPointerDown`/`onPointerMove`/`onPointerUp`. `x`/`y` are canvas
51
+ * backing-store pixels — the same convention `PluginRenderApi`'s
52
+ * `xForIndex`/`yForValue` use. `index` and `value` are that position
53
+ * already converted to data space (the chart's own inverse-coordinate
54
+ * math, so a plugin never has to duplicate it): `index` is a possibly
55
+ * fractional global (sorted-array) index, and `value` is the value under
56
+ * the pointer in the current frame's y-domain — `null` if there's no data
57
+ * or no usable chart area to compute one against.
58
+ *
59
+ * `xForIndex`/`yForValue` are the forward direction — for converting a
60
+ * shape a plugin is storing in data space (so it survives pan/zoom) back
61
+ * to pixels at the moment of this event, to compare against `x`/`y` with
62
+ * `hitTestSegment`/`hitTestPoint` (see `src/hitTest.ts`). Same caveat as
63
+ * `PluginRenderApi`'s mapping functions: valid for this dispatch only —
64
+ * the mapping shifts on the next pan/zoom/frame, so call them
65
+ * synchronously inside the handler that received this event, never stash
66
+ * them for later.
67
+ */
68
+ export interface ChartPointerEvent {
69
+ x: number;
70
+ y: number;
71
+ index: number;
72
+ value: number | null;
73
+ xForIndex: (index: number) => number;
74
+ yForValue: (value: number) => number | null;
75
+ }
76
+ /**
77
+ * The extension point for anything that draws *on top of* a chart without
78
+ * being the chart itself — price/event markers, alert lines, drawing
79
+ * tools, annotations, indicator overlays. Register instances via
80
+ * `WickChart.addPlugin`; `ChartRenderer` calls `draw` once per frame,
81
+ * after the active series and axes, so plugin output always sits above the
82
+ * plotted data.
83
+ *
84
+ * This is deliberately a single narrow interface rather than a family of
85
+ * marker/annotation/tool base classes — a plugin author owns their own
86
+ * styling and reads whatever data it needs (candle fields, indicator
87
+ * state, ...) off `allPoints`, and just needs pixel-space geometry for the
88
+ * current frame, which `PluginRenderApi` provides. Generic over the same
89
+ * `TPoint` the chart itself is generic over, so a candlestick chart's
90
+ * plugins see `Candle`-shaped points without a cast.
91
+ *
92
+ * The optional `onPointer*` hooks are what an *interactive* plugin (a
93
+ * trend line, a drawing tool — anything placed or edited by the user,
94
+ * rather than purely computed from data like an indicator) needs beyond
95
+ * `draw`: a way to see raw pointer gestures on the chart, which
96
+ * `WickChart` would otherwise consume entirely for its own panning.
97
+ */
98
+ export interface ChartPlugin<TPoint extends SeriesPoint = SeriesPoint> {
99
+ /**
100
+ * Stable identifier for this plugin instance, opaque to the chart —
101
+ * only used to look a plugin back up via `WickChart.setPluginVisible`
102
+ * once an app is managing a growing list of indicators/drawing tools and
103
+ * no longer wants to hold onto every instance it created. Not required:
104
+ * a plugin with no `id` can still be added/removed by reference via
105
+ * `addPlugin`/`removePlugin`, it just can't be targeted by
106
+ * `setPluginVisible`. Uniqueness across the chart's plugins is the
107
+ * caller's responsibility — the chart doesn't enforce it.
108
+ */
109
+ id?: string;
110
+ /**
111
+ * Whether this plugin currently draws and can claim pointer gestures.
112
+ * Defaults to `true` (a plugin with no `visible` field behaves exactly
113
+ * as before this field existed). Set to `false` to hide a plugin
114
+ * without losing its state by removing it — e.g. an indicator or
115
+ * drawing tool a user toggled off in a management UI but might turn
116
+ * back on. Toggle it via `WickChart.setPluginVisible`, or mutate it
117
+ * directly and call `chart.render()`.
118
+ */
119
+ visible?: boolean;
120
+ draw(api: PluginRenderApi<TPoint>): void;
121
+ /**
122
+ * Called on pointer down inside the chart's plotting area (not the
123
+ * price-axis strip). Return `true` to *claim* the gesture: `WickChart`
124
+ * then suppresses its own panning/hover for this pointer until it's
125
+ * released, and routes `onPointerMove`/`onPointerUp` to this plugin and
126
+ * no other. Return `false`/`undefined` (the default, if omitted) to
127
+ * leave the gesture to the chart's own panning — a plugin should only
128
+ * claim a gesture while actively placing or editing something of its
129
+ * own, not on every pointer down.
130
+ *
131
+ * Checked in reverse-registration order (the most recently added plugin
132
+ * gets first refusal), and the first plugin to claim a gesture wins —
133
+ * at most one plugin owns a given pointer down-to-up sequence.
134
+ */
135
+ onPointerDown?(event: ChartPointerEvent): boolean | void;
136
+ /** Only called for a gesture this plugin's `onPointerDown` claimed. */
137
+ onPointerMove?(event: ChartPointerEvent): void;
138
+ /** Only called for a gesture this plugin's `onPointerDown` claimed. */
139
+ onPointerUp?(event: ChartPointerEvent): void;
140
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ /** Generates ~`targetCount` evenly-spaced, human-friendly tick values
2
+ * covering [min, max] — e.g. 71000, 71500, 72000 rather than the raw
3
+ * fractional steps a naive linear split would produce. */
4
+ export declare function niceTicks(min: number, max: number, targetCount: number): number[];
5
+ /** Formats a price with a decimal count inferred from the tick step, so
6
+ * 0.5-step ticks show "71000.5" and 100-step ticks show "71000". */
7
+ export declare function formatPrice(value: number, step: number): string;
@@ -0,0 +1,49 @@
1
+ /** Rounds `range` to a "nice" 1/2/5×10^n value — the classic Heckbert
2
+ * nice-numbers step used by every axis-tick generator (d3 included). */
3
+ function niceNumber(range, round) {
4
+ const exponent = Math.floor(Math.log10(range));
5
+ const fraction = range / 10 ** exponent;
6
+ let niceFraction;
7
+ if (round) {
8
+ if (fraction < 1.5)
9
+ niceFraction = 1;
10
+ else if (fraction < 3)
11
+ niceFraction = 2;
12
+ else if (fraction < 7)
13
+ niceFraction = 5;
14
+ else
15
+ niceFraction = 10;
16
+ }
17
+ else {
18
+ if (fraction <= 1)
19
+ niceFraction = 1;
20
+ else if (fraction <= 2)
21
+ niceFraction = 2;
22
+ else if (fraction <= 5)
23
+ niceFraction = 5;
24
+ else
25
+ niceFraction = 10;
26
+ }
27
+ return niceFraction * 10 ** exponent;
28
+ }
29
+ /** Generates ~`targetCount` evenly-spaced, human-friendly tick values
30
+ * covering [min, max] — e.g. 71000, 71500, 72000 rather than the raw
31
+ * fractional steps a naive linear split would produce. */
32
+ export function niceTicks(min, max, targetCount) {
33
+ if (min === max)
34
+ return [min];
35
+ const step = niceNumber((max - min) / Math.max(1, targetCount - 1), true);
36
+ const niceMin = Math.floor(min / step) * step;
37
+ const niceMax = Math.ceil(max / step) * step;
38
+ const ticks = [];
39
+ for (let v = niceMin; v <= niceMax + step * 0.5; v += step) {
40
+ ticks.push(Math.round(v * 1e8) / 1e8); // strip float noise
41
+ }
42
+ return ticks;
43
+ }
44
+ /** Formats a price with a decimal count inferred from the tick step, so
45
+ * 0.5-step ticks show "71000.5" and 100-step ticks show "71000". */
46
+ export function formatPrice(value, step) {
47
+ const decimals = step > 0 && step < 1 ? Math.max(0, -Math.floor(Math.log10(step))) : 0;
48
+ return value.toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
49
+ }
@@ -0,0 +1,12 @@
1
+ import type { ValueRange } from './types.js';
2
+ /**
3
+ * Widens/narrows a raw [min, max] domain around its center by
4
+ * `scaleFactor`, with a floor so a flat or single-point series still gets
5
+ * a visible span instead of collapsing to zero height. This is the shared
6
+ * "auto-fit + manual zoom" math every series's `getValueRange` needs —
7
+ * only how a series derives its own raw min/max (candlestick: high/low
8
+ * across visible candles; a future line series: min/max of `.value`)
9
+ * differs between series types, so that part lives in each series
10
+ * definition instead of here. See `src/series/candlestick.ts` for a caller.
11
+ */
12
+ export declare function fitRange(rawMin: number, rawMax: number, scaleFactor: number): ValueRange;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Widens/narrows a raw [min, max] domain around its center by
3
+ * `scaleFactor`, with a floor so a flat or single-point series still gets
4
+ * a visible span instead of collapsing to zero height. This is the shared
5
+ * "auto-fit + manual zoom" math every series's `getValueRange` needs —
6
+ * only how a series derives its own raw min/max (candlestick: high/low
7
+ * across visible candles; a future line series: min/max of `.value`)
8
+ * differs between series types, so that part lives in each series
9
+ * definition instead of here. See `src/series/candlestick.ts` for a caller.
10
+ */
11
+ export function fitRange(rawMin, rawMax, scaleFactor) {
12
+ const mid = (rawMin + rawMax) / 2;
13
+ const rawHalfSpan = (rawMax - rawMin) / 2 || Math.abs(mid) * 0.01 || 1;
14
+ const halfSpan = rawHalfSpan * scaleFactor;
15
+ return { min: mid - halfSpan, max: mid + halfSpan };
16
+ }
@@ -0,0 +1,79 @@
1
+ import type { ChartPlugin } from './plugins/types.js';
2
+ import type { SeriesDefinition } from './series/types.js';
3
+ import type { WickChartOptions, SeriesPoint } from './types.js';
4
+ import type { Viewport } from './viewport.js';
5
+ export interface RenderInput<TPoint extends SeriesPoint> {
6
+ /** Every point, sorted ascending by normalized time. */
7
+ sorted: TPoint[];
8
+ /** Parallel to `sorted` — each already run through `toUnixSeconds`. */
9
+ times: number[];
10
+ viewport: Viewport;
11
+ /** Index into `sorted` (not viewport-local) of the hovered point, or null. */
12
+ hoverIndex: number | null;
13
+ /** Device-pixel y of the pointer/finger that produced `hoverIndex`, or
14
+ * null. Drives the crosshair's horizontal line directly — see
15
+ * `renderCrosshairAndLegend` for why that has to be the raw cursor
16
+ * position rather than any property of the hovered point itself. */
17
+ hoverY: number | null;
18
+ plugins: ChartPlugin<TPoint>[];
19
+ }
20
+ /**
21
+ * The chart engine's renderer: canvas lifecycle, axes, crosshair, and
22
+ * plugin drawing are all generic — none of it knows what kind of series is
23
+ * on screen. The one series-specific seam is `seriesDefinition`, injected
24
+ * at construction (see `src/series/types.ts`); everything above delegates
25
+ * to it for value-range computation, point drawing, and legend text.
26
+ * Stateless per call otherwise — all pan/zoom/hover state lives in
27
+ * `Viewport` and `WickChart`; this class only turns a snapshot of that
28
+ * state into pixels.
29
+ *
30
+ * Every visual constant below (fonts, axis sizing/coloring, crosshair
31
+ * coloring/padding, legend color) is resolved once at construction from
32
+ * `WickChartOptions.font`/`axis`/`crosshair`/`legend`, each merged field
33
+ * by field over its own defaults — nothing here is a hardcoded module
34
+ * constant a caller can't reach.
35
+ */
36
+ export declare class ChartRenderer<TPoint extends SeriesPoint> {
37
+ private canvas;
38
+ private seriesDefinition;
39
+ private ctx;
40
+ private background;
41
+ private style;
42
+ private font;
43
+ private axis;
44
+ private crosshair;
45
+ private legend;
46
+ constructor(canvas: HTMLCanvasElement, seriesDefinition: SeriesDefinition<TPoint, unknown>, options?: WickChartOptions);
47
+ /** Pixel width of the point-plotting area — excludes the price-axis
48
+ * strip on the right. Exposed so `WickChart` can convert cursor pixel
49
+ * positions to point indices / values for hit-testing and dragging. */
50
+ get chartWidth(): number;
51
+ get chartHeight(): number;
52
+ get priceAxisWidth(): number;
53
+ private axisFont;
54
+ private legendFont;
55
+ render(input: RenderInput<TPoint>): void;
56
+ /** The decimal precision `formatPrice` should use for the current price
57
+ * range — shared by the axis ticks and the crosshair's price label so
58
+ * both display the same value with the same rounding. */
59
+ private currentPriceStep;
60
+ private renderPriceAxis;
61
+ private renderTimeAxis;
62
+ private renderCrosshairAndLegend;
63
+ /** The OHLC(+volume) tooltip — floats near the hovered pixel like a
64
+ * speech bubble, one line per part, rather than a fixed banner glued to
65
+ * a corner of the canvas. Offset up-and-right of the cursor/finger by
66
+ * `legend.cursorGap` and clamped to both chart edges so it never runs
67
+ * off-screen, including when there's no `hoverY` to anchor to (a series
68
+ * with no primary value still gets a legend, just pinned near the top
69
+ * at the hovered column). */
70
+ private renderHoverTooltip;
71
+ /** The highlighted price-axis label that follows the crosshair's
72
+ * horizontal line — drawn over `renderPriceAxis`'s own tick labels so the
73
+ * hovered value reads clearly even where it lands between two ticks. */
74
+ private renderPriceLabelChip;
75
+ /** The highlighted time-axis label under the crosshair's vertical line.
76
+ * Clamped so its background chip stays fully on-screen even when the
77
+ * hovered point sits at the very first or last visible index. */
78
+ private renderTimeLabelChip;
79
+ }
@@ -0,0 +1,318 @@
1
+ import { formatAxisLabel, formatHoverTime, pickTickIndices } from './axis.js';
2
+ import { createScale } from './hybridScale.js';
3
+ import { formatPrice, niceTicks } from './priceAxis.js';
4
+ const DEFAULT_BACKGROUND = 'transparent';
5
+ const DEFAULT_FONT = {
6
+ family: 'sans-serif',
7
+ axisSize: 10,
8
+ legendSize: 11,
9
+ };
10
+ const DEFAULT_AXIS = {
11
+ priceWidth: 64,
12
+ timeHeight: 24,
13
+ priceTickCount: 5,
14
+ timeMaxTicks: 6,
15
+ textColor: '#787878',
16
+ lineColor: '#33333333',
17
+ gridLineColor: '#2a2a2a55',
18
+ };
19
+ const DEFAULT_CROSSHAIR = {
20
+ lineColor: '#9090904d',
21
+ labelBackground: '#3a3a3a',
22
+ labelTextColor: '#f0f0f0',
23
+ labelPaddingX: 4,
24
+ labelPaddingY: 3,
25
+ };
26
+ const DEFAULT_LEGEND = {
27
+ textColor: '#f0f0f0',
28
+ background: '#3a3a3a',
29
+ paddingX: 8,
30
+ paddingY: 6,
31
+ cursorGap: 12,
32
+ };
33
+ /**
34
+ * The chart engine's renderer: canvas lifecycle, axes, crosshair, and
35
+ * plugin drawing are all generic — none of it knows what kind of series is
36
+ * on screen. The one series-specific seam is `seriesDefinition`, injected
37
+ * at construction (see `src/series/types.ts`); everything above delegates
38
+ * to it for value-range computation, point drawing, and legend text.
39
+ * Stateless per call otherwise — all pan/zoom/hover state lives in
40
+ * `Viewport` and `WickChart`; this class only turns a snapshot of that
41
+ * state into pixels.
42
+ *
43
+ * Every visual constant below (fonts, axis sizing/coloring, crosshair
44
+ * coloring/padding, legend color) is resolved once at construction from
45
+ * `WickChartOptions.font`/`axis`/`crosshair`/`legend`, each merged field
46
+ * by field over its own defaults — nothing here is a hardcoded module
47
+ * constant a caller can't reach.
48
+ */
49
+ export class ChartRenderer {
50
+ constructor(canvas, seriesDefinition, options = {}) {
51
+ this.canvas = canvas;
52
+ this.seriesDefinition = seriesDefinition;
53
+ const ctx = canvas.getContext('2d');
54
+ if (!ctx)
55
+ throw new Error('wick-charts: canvas 2d context unavailable');
56
+ this.ctx = ctx;
57
+ this.background = options.background ?? DEFAULT_BACKGROUND;
58
+ this.style = { ...seriesDefinition.defaultStyle, ...(options.style ?? {}) };
59
+ this.font = { ...DEFAULT_FONT, ...options.font };
60
+ this.axis = { ...DEFAULT_AXIS, ...options.axis };
61
+ this.crosshair = { ...DEFAULT_CROSSHAIR, ...options.crosshair };
62
+ this.legend = { ...DEFAULT_LEGEND, ...options.legend };
63
+ }
64
+ /** Pixel width of the point-plotting area — excludes the price-axis
65
+ * strip on the right. Exposed so `WickChart` can convert cursor pixel
66
+ * positions to point indices / values for hit-testing and dragging. */
67
+ get chartWidth() {
68
+ return Math.max(0, this.canvas.width - this.axis.priceWidth);
69
+ }
70
+ get chartHeight() {
71
+ return Math.max(0, this.canvas.height - this.axis.timeHeight);
72
+ }
73
+ get priceAxisWidth() {
74
+ return this.axis.priceWidth;
75
+ }
76
+ axisFont() {
77
+ return `${this.font.axisSize}px ${this.font.family}`;
78
+ }
79
+ legendFont() {
80
+ return `${this.font.legendSize}px ${this.font.family}`;
81
+ }
82
+ render(input) {
83
+ const { ctx, canvas, background, seriesDefinition, style } = this;
84
+ const { sorted, times, viewport, hoverIndex, hoverY, plugins } = input;
85
+ const width = canvas.width;
86
+ const height = canvas.height;
87
+ const chartWidth = this.chartWidth;
88
+ const chartHeight = this.chartHeight;
89
+ ctx.clearRect(0, 0, width, height);
90
+ if (background !== 'transparent') {
91
+ ctx.fillStyle = background;
92
+ ctx.fillRect(0, 0, width, height);
93
+ }
94
+ if (sorted.length === 0 || chartWidth <= 0 || chartHeight <= 0)
95
+ return;
96
+ const startIdx = Math.max(0, Math.floor(viewport.startIndex));
97
+ const endIdx = Math.min(sorted.length, Math.ceil(viewport.endIndex));
98
+ const visible = sorted.slice(startIdx, endIdx);
99
+ if (visible.length === 0)
100
+ return;
101
+ // Manual mode (user has dragged/scaled the price axis) wins once set;
102
+ // otherwise fit to whatever points are currently visible.
103
+ const { min: valueMin, max: valueMax } = viewport.valueRangeOverride ?? seriesDefinition.getValueRange(visible, viewport.valueScaleFactor);
104
+ // JS below a few hundred points, WASM above — see hybridScale.ts.
105
+ // Whichever it picks, `dispose()` must run once we're done reading
106
+ // from it (a no-op on the JS path, a real WASM memory free otherwise).
107
+ const { scale: yScale, dispose: disposeYScale } = createScale(valueMin, valueMax, chartHeight, 0, visible.length);
108
+ // Flipped in `finally`, right before `disposeYScale()` frees the WASM
109
+ // scale's backing memory (a no-op on the JS path). Guards `yForValue`
110
+ // below so a plugin that stashes it and calls it later gets a clear
111
+ // thrown error instead of touching freed WASM memory — see the
112
+ // interface-level warning on `PluginRenderApi`.
113
+ let frameEnded = false;
114
+ try {
115
+ const slotWidth = chartWidth / viewport.visibleCount;
116
+ // x position for a *global* sorted-array index — honors the (possibly
117
+ // fractional) viewport.startIndex so panning is pixel-smooth, not
118
+ // stepped a whole point at a time.
119
+ const xForIndex = (globalIndex) => (globalIndex - viewport.startIndex) * slotWidth + slotWidth / 2;
120
+ seriesDefinition.draw({ ctx, visible, startIndex: startIdx, xForIndex, slotWidth, yScale, chartHeight }, style);
121
+ const priceStep = this.currentPriceStep(valueMin, valueMax);
122
+ this.renderPriceAxis(valueMin, valueMax, priceStep, yScale, chartWidth, chartHeight);
123
+ this.renderTimeAxis(times, startIdx, visible.length, chartHeight, chartWidth, xForIndex);
124
+ if (hoverIndex !== null && hoverIndex >= startIdx && hoverIndex < endIdx) {
125
+ this.renderCrosshairAndLegend(sorted[hoverIndex], xForIndex(hoverIndex), times[hoverIndex], hoverY, valueMin, valueMax, priceStep, chartWidth, chartHeight);
126
+ }
127
+ if (plugins.length > 0) {
128
+ const api = {
129
+ ctx,
130
+ chartWidth,
131
+ chartHeight,
132
+ xForIndex,
133
+ yForValue: (value) => {
134
+ if (frameEnded) {
135
+ throw new Error('wick-charts: PluginRenderApi.yForValue called after its frame ended — ' +
136
+ 'only call it synchronously inside ChartPlugin.draw()');
137
+ }
138
+ return yScale.map(value);
139
+ },
140
+ // Exact inverse of xForIndex above — solving
141
+ // `x = (index - viewport.startIndex) * slotWidth + slotWidth / 2` for `index`.
142
+ indexForX: (x) => viewport.startIndex + (x - slotWidth / 2) / slotWidth,
143
+ // Exact inverse of the value->y mapping createScale set up for this
144
+ // frame (domain [valueMin, valueMax] -> range [chartHeight, 0]).
145
+ valueForY: (y) => valueMin + (1 - y / chartHeight) * (valueMax - valueMin),
146
+ visibleStartIndex: startIdx,
147
+ visibleEndIndex: endIdx,
148
+ allPoints: sorted,
149
+ };
150
+ for (const plugin of plugins) {
151
+ if (plugin.visible === false)
152
+ continue;
153
+ // save/restore isolates each plugin's canvas state (strokeStyle,
154
+ // lineDash, ...) from the next one — a plugin that forgets to
155
+ // clean up after itself can't bleed style into whatever draws
156
+ // after it. try/catch isolates failures the same way: one
157
+ // plugin throwing shouldn't blank out the rest of the chart.
158
+ ctx.save();
159
+ try {
160
+ plugin.draw(api);
161
+ }
162
+ catch (error) {
163
+ console.error('wick-charts: a plugin threw during draw()', error);
164
+ }
165
+ finally {
166
+ ctx.restore();
167
+ }
168
+ }
169
+ }
170
+ }
171
+ finally {
172
+ frameEnded = true;
173
+ disposeYScale();
174
+ }
175
+ }
176
+ /** The decimal precision `formatPrice` should use for the current price
177
+ * range — shared by the axis ticks and the crosshair's price label so
178
+ * both display the same value with the same rounding. */
179
+ currentPriceStep(priceMin, priceMax) {
180
+ const ticks = niceTicks(priceMin, priceMax, this.axis.priceTickCount);
181
+ return ticks.length > 1 ? ticks[1] - ticks[0] : 0;
182
+ }
183
+ renderPriceAxis(priceMin, priceMax, step, yScale, chartWidth, chartHeight) {
184
+ const { ctx, axis } = this;
185
+ const ticks = niceTicks(priceMin, priceMax, axis.priceTickCount);
186
+ ctx.strokeStyle = axis.lineColor;
187
+ ctx.beginPath();
188
+ ctx.moveTo(chartWidth + 0.5, 0);
189
+ ctx.lineTo(chartWidth + 0.5, chartHeight);
190
+ ctx.stroke();
191
+ ctx.font = this.axisFont();
192
+ ctx.textAlign = 'left';
193
+ ctx.textBaseline = 'middle';
194
+ for (const value of ticks) {
195
+ const y = yScale.map(value);
196
+ if (y < 0 || y > chartHeight)
197
+ continue;
198
+ ctx.strokeStyle = axis.gridLineColor;
199
+ ctx.beginPath();
200
+ ctx.moveTo(0, y + 0.5);
201
+ ctx.lineTo(chartWidth, y + 0.5);
202
+ ctx.stroke();
203
+ ctx.fillStyle = axis.textColor;
204
+ ctx.fillText(formatPrice(value, step), chartWidth + 6, y);
205
+ }
206
+ }
207
+ renderTimeAxis(times, startIdx, visibleCount, chartHeight, chartWidth, xForIndex) {
208
+ const { ctx, axis } = this;
209
+ const visibleTimes = times.slice(startIdx, startIdx + visibleCount);
210
+ const spanSeconds = visibleTimes[visibleTimes.length - 1] - visibleTimes[0];
211
+ ctx.strokeStyle = axis.lineColor;
212
+ ctx.beginPath();
213
+ ctx.moveTo(0, chartHeight + 0.5);
214
+ ctx.lineTo(chartWidth, chartHeight + 0.5);
215
+ ctx.stroke();
216
+ ctx.fillStyle = axis.textColor;
217
+ ctx.font = this.axisFont();
218
+ ctx.textAlign = 'center';
219
+ ctx.textBaseline = 'top';
220
+ for (const localIndex of pickTickIndices(visibleCount, axis.timeMaxTicks)) {
221
+ const x = xForIndex(startIdx + localIndex);
222
+ const label = formatAxisLabel(visibleTimes[localIndex], spanSeconds);
223
+ ctx.fillText(label, x, chartHeight + 6);
224
+ }
225
+ }
226
+ renderCrosshairAndLegend(point, x, timeSeconds, hoverY, valueMin, valueMax, priceStep, chartWidth, chartHeight) {
227
+ const { ctx, canvas, seriesDefinition, style, crosshair } = this;
228
+ ctx.save();
229
+ ctx.strokeStyle = crosshair.lineColor;
230
+ ctx.setLineDash([4, 4]);
231
+ ctx.beginPath();
232
+ ctx.moveTo(x, 0);
233
+ ctx.lineTo(x, chartHeight);
234
+ ctx.stroke();
235
+ // The horizontal line follows the actual cursor/finger position, not
236
+ // any property of the hovered point — pinning it to (say) the candle's
237
+ // close would leave it motionless while the pointer moves anywhere
238
+ // within that same candle's column, which reads as broken/stuck rather
239
+ // than as a crosshair. Only drawn while the pointer is actually inside
240
+ // the chart's vertical extent, same as the price-axis tick-skip logic
241
+ // in renderPriceAxis.
242
+ const priceLineVisible = hoverY !== null && hoverY >= 0 && hoverY <= chartHeight;
243
+ if (priceLineVisible) {
244
+ ctx.beginPath();
245
+ ctx.moveTo(0, hoverY);
246
+ ctx.lineTo(chartWidth, hoverY);
247
+ ctx.stroke();
248
+ }
249
+ ctx.restore();
250
+ if (priceLineVisible) {
251
+ // Exact inverse of the value->y mapping createScale set up for this
252
+ // frame — same formula as PluginRenderApi.valueForY.
253
+ const value = valueMin + (1 - hoverY / chartHeight) * (valueMax - valueMin);
254
+ this.renderPriceLabelChip(formatPrice(value, priceStep), hoverY, chartWidth);
255
+ }
256
+ this.renderTimeLabelChip(formatHoverTime(timeSeconds), x, chartHeight, canvas.width);
257
+ const parts = seriesDefinition.formatLegend?.(point, style) ?? [];
258
+ if (parts.length === 0)
259
+ return;
260
+ this.renderHoverTooltip(parts, x, hoverY, chartWidth, chartHeight);
261
+ }
262
+ /** The OHLC(+volume) tooltip — floats near the hovered pixel like a
263
+ * speech bubble, one line per part, rather than a fixed banner glued to
264
+ * a corner of the canvas. Offset up-and-right of the cursor/finger by
265
+ * `legend.cursorGap` and clamped to both chart edges so it never runs
266
+ * off-screen, including when there's no `hoverY` to anchor to (a series
267
+ * with no primary value still gets a legend, just pinned near the top
268
+ * at the hovered column). */
269
+ renderHoverTooltip(lines, x, hoverY, chartWidth, chartHeight) {
270
+ const { ctx, font, legend } = this;
271
+ ctx.font = this.legendFont();
272
+ ctx.textAlign = 'left';
273
+ ctx.textBaseline = 'top';
274
+ const lineHeight = font.legendSize + 4;
275
+ const textWidth = Math.max(...lines.map((line) => ctx.measureText(line).width));
276
+ const boxWidth = textWidth + legend.paddingX * 2;
277
+ const boxHeight = lines.length * lineHeight + legend.paddingY * 2;
278
+ const anchorY = hoverY ?? 0;
279
+ const left = Math.min(Math.max(x + legend.cursorGap, 0), Math.max(0, chartWidth - boxWidth));
280
+ const top = Math.min(Math.max(anchorY - boxHeight - legend.cursorGap, 0), Math.max(0, chartHeight - boxHeight));
281
+ ctx.fillStyle = legend.background;
282
+ ctx.fillRect(left, top, boxWidth, boxHeight);
283
+ ctx.fillStyle = legend.textColor;
284
+ lines.forEach((line, i) => {
285
+ ctx.fillText(line, left + legend.paddingX, top + legend.paddingY + i * lineHeight);
286
+ });
287
+ }
288
+ /** The highlighted price-axis label that follows the crosshair's
289
+ * horizontal line — drawn over `renderPriceAxis`'s own tick labels so the
290
+ * hovered value reads clearly even where it lands between two ticks. */
291
+ renderPriceLabelChip(text, y, chartWidth) {
292
+ const { ctx, font, crosshair } = this;
293
+ ctx.font = this.axisFont();
294
+ const chipHeight = font.axisSize + crosshair.labelPaddingY * 2;
295
+ ctx.fillStyle = crosshair.labelBackground;
296
+ ctx.fillRect(chartWidth, y - chipHeight / 2, this.priceAxisWidth, chipHeight);
297
+ ctx.fillStyle = crosshair.labelTextColor;
298
+ ctx.textAlign = 'left';
299
+ ctx.textBaseline = 'middle';
300
+ ctx.fillText(text, chartWidth + crosshair.labelPaddingX, y);
301
+ }
302
+ /** The highlighted time-axis label under the crosshair's vertical line.
303
+ * Clamped so its background chip stays fully on-screen even when the
304
+ * hovered point sits at the very first or last visible index. */
305
+ renderTimeLabelChip(text, x, chartHeight, canvasWidth) {
306
+ const { ctx, font, crosshair } = this;
307
+ ctx.font = this.axisFont();
308
+ const chipWidth = ctx.measureText(text).width + crosshair.labelPaddingX * 2;
309
+ const chipHeight = font.axisSize + crosshair.labelPaddingY * 2;
310
+ const chipLeft = Math.min(Math.max(x - chipWidth / 2, 0), canvasWidth - chipWidth);
311
+ ctx.fillStyle = crosshair.labelBackground;
312
+ ctx.fillRect(chipLeft, chartHeight, chipWidth, chipHeight);
313
+ ctx.fillStyle = crosshair.labelTextColor;
314
+ ctx.textAlign = 'left';
315
+ ctx.textBaseline = 'top';
316
+ ctx.fillText(text, chipLeft + crosshair.labelPaddingX, chartHeight + crosshair.labelPaddingY);
317
+ }
318
+ }