wick-charts 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ import type { Scale } from './hybridScale.js';
2
+ import type { ChartAxisOptions, ChartFontOptions } from './types.js';
3
+ /**
4
+ * Draws the price/time axis chrome — boundary lines, grid lines, tick
5
+ * labels, and the separator between stacked panes. Split out of
6
+ * `ChartRenderer` because none of it needs anything beyond already-resolved
7
+ * style options and per-call geometry (a `Scale`, a pixel rect): no
8
+ * series-specific, plugin-specific, or pane-identity state crosses into
9
+ * this class — `ChartRenderer.render()` still owns deciding *what* to draw
10
+ * where (the main pane vs. each indicator pane, in what order), this only
11
+ * owns *how* one axis actually gets drawn once told where.
12
+ */
13
+ export declare class AxisRenderer {
14
+ private ctx;
15
+ private axis;
16
+ private font;
17
+ constructor(ctx: CanvasRenderingContext2D, axis: Required<ChartAxisOptions>, font: Required<ChartFontOptions>);
18
+ private axisFont;
19
+ /** The decimal precision `formatPrice` should use for a given value
20
+ * range — shared by this class's own tick labels and
21
+ * `CrosshairRenderer`'s price label so both display the same value with
22
+ * the same rounding. */
23
+ priceStep(min: number, max: number): number;
24
+ /**
25
+ * Draws one pane's right-side value axis: boundary line, horizontal grid
26
+ * lines, and tick labels. Used for both the main price pane and every
27
+ * indicator pane — `topOffset` shifts everything down by that pane's own
28
+ * position in the stack (0 for the main pane, which sits at the top), so
29
+ * `yScale` only ever has to know about its own pane-local [0, chartHeight]
30
+ * range and never about where that pane lives in the full canvas.
31
+ */
32
+ renderPriceAxis(priceMin: number, priceMax: number, step: number, yScale: Scale, chartWidth: number, chartHeight: number, topOffset: number): void;
33
+ /** The horizontal rule separating an indicator pane from whatever sits
34
+ * above it (the main pane, or the previous indicator pane) — the same
35
+ * `axis.lineColor` boundary style `renderTimeAxis` already draws between
36
+ * the plotting area and the time-axis strip. */
37
+ renderPaneSeparator(top: number, chartWidth: number): void;
38
+ renderTimeAxis(times: number[], startIdx: number, visibleCount: number, chartHeight: number, chartWidth: number, xForIndex: (globalIndex: number) => number): void;
39
+ }
@@ -0,0 +1,94 @@
1
+ import { formatAxisLabel, pickTickIndices } from './axis.js';
2
+ import { formatPrice, niceTicks } from './priceAxis.js';
3
+ /**
4
+ * Draws the price/time axis chrome — boundary lines, grid lines, tick
5
+ * labels, and the separator between stacked panes. Split out of
6
+ * `ChartRenderer` because none of it needs anything beyond already-resolved
7
+ * style options and per-call geometry (a `Scale`, a pixel rect): no
8
+ * series-specific, plugin-specific, or pane-identity state crosses into
9
+ * this class — `ChartRenderer.render()` still owns deciding *what* to draw
10
+ * where (the main pane vs. each indicator pane, in what order), this only
11
+ * owns *how* one axis actually gets drawn once told where.
12
+ */
13
+ export class AxisRenderer {
14
+ constructor(ctx, axis, font) {
15
+ this.ctx = ctx;
16
+ this.axis = axis;
17
+ this.font = font;
18
+ }
19
+ axisFont() {
20
+ return `${this.font.axisSize}px ${this.font.family}`;
21
+ }
22
+ /** The decimal precision `formatPrice` should use for a given value
23
+ * range — shared by this class's own tick labels and
24
+ * `CrosshairRenderer`'s price label so both display the same value with
25
+ * the same rounding. */
26
+ priceStep(min, max) {
27
+ const ticks = niceTicks(min, max, this.axis.priceTickCount);
28
+ return ticks.length > 1 ? ticks[1] - ticks[0] : 0;
29
+ }
30
+ /**
31
+ * Draws one pane's right-side value axis: boundary line, horizontal grid
32
+ * lines, and tick labels. Used for both the main price pane and every
33
+ * indicator pane — `topOffset` shifts everything down by that pane's own
34
+ * position in the stack (0 for the main pane, which sits at the top), so
35
+ * `yScale` only ever has to know about its own pane-local [0, chartHeight]
36
+ * range and never about where that pane lives in the full canvas.
37
+ */
38
+ renderPriceAxis(priceMin, priceMax, step, yScale, chartWidth, chartHeight, topOffset) {
39
+ const { ctx, axis } = this;
40
+ const ticks = niceTicks(priceMin, priceMax, axis.priceTickCount);
41
+ ctx.strokeStyle = axis.lineColor;
42
+ ctx.beginPath();
43
+ ctx.moveTo(chartWidth + 0.5, topOffset);
44
+ ctx.lineTo(chartWidth + 0.5, topOffset + chartHeight);
45
+ ctx.stroke();
46
+ ctx.font = this.axisFont();
47
+ ctx.textAlign = 'left';
48
+ ctx.textBaseline = 'middle';
49
+ for (const value of ticks) {
50
+ const localY = yScale.map(value);
51
+ if (localY < 0 || localY > chartHeight)
52
+ continue;
53
+ const y = topOffset + localY;
54
+ ctx.strokeStyle = axis.gridLineColor;
55
+ ctx.beginPath();
56
+ ctx.moveTo(0, y + 0.5);
57
+ ctx.lineTo(chartWidth, y + 0.5);
58
+ ctx.stroke();
59
+ ctx.fillStyle = axis.textColor;
60
+ ctx.fillText(formatPrice(value, step), chartWidth + 6, y);
61
+ }
62
+ }
63
+ /** The horizontal rule separating an indicator pane from whatever sits
64
+ * above it (the main pane, or the previous indicator pane) — the same
65
+ * `axis.lineColor` boundary style `renderTimeAxis` already draws between
66
+ * the plotting area and the time-axis strip. */
67
+ renderPaneSeparator(top, chartWidth) {
68
+ const { ctx, axis } = this;
69
+ ctx.strokeStyle = axis.lineColor;
70
+ ctx.beginPath();
71
+ ctx.moveTo(0, top + 0.5);
72
+ ctx.lineTo(chartWidth, top + 0.5);
73
+ ctx.stroke();
74
+ }
75
+ renderTimeAxis(times, startIdx, visibleCount, chartHeight, chartWidth, xForIndex) {
76
+ const { ctx, axis } = this;
77
+ const visibleTimes = times.slice(startIdx, startIdx + visibleCount);
78
+ const spanSeconds = visibleTimes[visibleTimes.length - 1] - visibleTimes[0];
79
+ ctx.strokeStyle = axis.lineColor;
80
+ ctx.beginPath();
81
+ ctx.moveTo(0, chartHeight + 0.5);
82
+ ctx.lineTo(chartWidth, chartHeight + 0.5);
83
+ ctx.stroke();
84
+ ctx.fillStyle = axis.textColor;
85
+ ctx.font = this.axisFont();
86
+ ctx.textAlign = 'center';
87
+ ctx.textBaseline = 'top';
88
+ for (const localIndex of pickTickIndices(visibleCount, axis.timeMaxTicks)) {
89
+ const x = xForIndex(startIdx + localIndex);
90
+ const label = formatAxisLabel(visibleTimes[localIndex], spanSeconds);
91
+ ctx.fillText(label, x, chartHeight + 6);
92
+ }
93
+ }
94
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Binary search over an ascending-sorted number array — extracted out of
3
+ * `WickChart` since it's pure array math with no dependency on chart state,
4
+ * used by `setVisibleTimeRange` to resolve a time to its index.
5
+ */
6
+ /** First index in `values` (ascending) whose value is `>= target`, or
7
+ * `values.length` if every value is smaller — the standard binary
8
+ * lower-bound, O(log n) rather than a linear scan over what can be a
9
+ * multi-thousand-point loaded series. */
10
+ export declare function lowerBound(values: number[], target: number): number;
11
+ /** First index in `values` (ascending) whose value is `> target`, or
12
+ * `values.length` if none is — the exclusive end boundary for an inclusive
13
+ * upper bound, so a range `[lowerBound(from), upperBound(to))` includes
14
+ * every value in `[from, to]` inclusive on both ends. */
15
+ export declare function upperBound(values: number[], target: number): number;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Binary search over an ascending-sorted number array — extracted out of
3
+ * `WickChart` since it's pure array math with no dependency on chart state,
4
+ * used by `setVisibleTimeRange` to resolve a time to its index.
5
+ */
6
+ /** First index in `values` (ascending) whose value is `>= target`, or
7
+ * `values.length` if every value is smaller — the standard binary
8
+ * lower-bound, O(log n) rather than a linear scan over what can be a
9
+ * multi-thousand-point loaded series. */
10
+ export function lowerBound(values, target) {
11
+ let lo = 0;
12
+ let hi = values.length;
13
+ while (lo < hi) {
14
+ const mid = (lo + hi) >>> 1;
15
+ if (values[mid] < target)
16
+ lo = mid + 1;
17
+ else
18
+ hi = mid;
19
+ }
20
+ return lo;
21
+ }
22
+ /** First index in `values` (ascending) whose value is `> target`, or
23
+ * `values.length` if none is — the exclusive end boundary for an inclusive
24
+ * upper bound, so a range `[lowerBound(from), upperBound(to))` includes
25
+ * every value in `[from, to]` inclusive on both ends. */
26
+ export function upperBound(values, target) {
27
+ let lo = 0;
28
+ let hi = values.length;
29
+ while (lo < hi) {
30
+ const mid = (lo + hi) >>> 1;
31
+ if (values[mid] <= target)
32
+ lo = mid + 1;
33
+ else
34
+ hi = mid;
35
+ }
36
+ return lo;
37
+ }
@@ -0,0 +1,60 @@
1
+ import type { ChartCrosshairOptions, ChartFontOptions, ChartLegendOptions } from './types.js';
2
+ /** Everything one frame's hover crosshair/legend needs — computed by
3
+ * `ChartRenderer.render()` (which owns the hovered point, the active
4
+ * series, and its style) and handed in as plain data so this class stays
5
+ * generic-free and series-agnostic. `legendParts` is already the result of
6
+ * `seriesDefinition.formatLegend?.(point, style)` — this class only lays
7
+ * out and draws whatever strings it's given. */
8
+ export interface CrosshairRenderInput {
9
+ x: number;
10
+ timeSeconds: number;
11
+ hoverY: number | null;
12
+ valueMin: number;
13
+ valueMax: number;
14
+ priceStep: number;
15
+ chartWidth: number;
16
+ chartHeight: number;
17
+ /** Full pane-stack height — the dashed vertical line spans this, not
18
+ * just `chartHeight` (the main pane's own), so a hovered candle lines up
19
+ * across every indicator pane below it. */
20
+ stackHeight: number;
21
+ invertValueAxis: boolean;
22
+ legendParts: string[];
23
+ /** The canvas's own backing-store width — needed only to clamp the
24
+ * time-axis label chip so it never runs off the right edge. */
25
+ canvasWidth: number;
26
+ }
27
+ /**
28
+ * Draws the hover crosshair (dashed lines), its price/time label chips,
29
+ * and the OHLC-style legend tooltip. Split out of `ChartRenderer` for the
30
+ * same reason `AxisRenderer` was: everything it needs arrives as
31
+ * already-resolved style options plus one frame's worth of plain data
32
+ * (`CrosshairRenderInput`) — no series generic, no plugin state.
33
+ */
34
+ export declare class CrosshairRenderer {
35
+ private ctx;
36
+ private crosshair;
37
+ private legend;
38
+ private font;
39
+ private priceAxisWidth;
40
+ constructor(ctx: CanvasRenderingContext2D, crosshair: Required<ChartCrosshairOptions>, legend: Required<ChartLegendOptions>, font: Required<ChartFontOptions>, priceAxisWidth: number);
41
+ private axisFont;
42
+ private legendFont;
43
+ render(input: CrosshairRenderInput): void;
44
+ /** The OHLC(+volume) tooltip — floats near the hovered pixel like a
45
+ * speech bubble, one line per part, rather than a fixed banner glued to
46
+ * a corner of the canvas. Offset up-and-right of the cursor/finger by
47
+ * `legend.cursorGap` and clamped to both chart edges so it never runs
48
+ * off-screen, including when there's no `hoverY` to anchor to (a series
49
+ * with no primary value still gets a legend, just pinned near the top
50
+ * at the hovered column). */
51
+ private renderHoverTooltip;
52
+ /** The highlighted price-axis label that follows the crosshair's
53
+ * horizontal line — drawn over `AxisRenderer`'s own tick labels so the
54
+ * hovered value reads clearly even where it lands between two ticks. */
55
+ private renderPriceLabelChip;
56
+ /** The highlighted time-axis label under the crosshair's vertical line.
57
+ * Clamped so its background chip stays fully on-screen even when the
58
+ * hovered point sits at the very first or last visible index. */
59
+ private renderTimeLabelChip;
60
+ }
@@ -0,0 +1,121 @@
1
+ import { formatHoverTime } from './axis.js';
2
+ import { formatPrice } from './priceAxis.js';
3
+ import { pixelToValue } from './valueAxis.js';
4
+ /**
5
+ * Draws the hover crosshair (dashed lines), its price/time label chips,
6
+ * and the OHLC-style legend tooltip. Split out of `ChartRenderer` for the
7
+ * same reason `AxisRenderer` was: everything it needs arrives as
8
+ * already-resolved style options plus one frame's worth of plain data
9
+ * (`CrosshairRenderInput`) — no series generic, no plugin state.
10
+ */
11
+ export class CrosshairRenderer {
12
+ constructor(ctx, crosshair, legend, font, priceAxisWidth) {
13
+ this.ctx = ctx;
14
+ this.crosshair = crosshair;
15
+ this.legend = legend;
16
+ this.font = font;
17
+ this.priceAxisWidth = priceAxisWidth;
18
+ }
19
+ axisFont() {
20
+ return `${this.font.axisSize}px ${this.font.family}`;
21
+ }
22
+ legendFont() {
23
+ return `${this.font.legendSize}px ${this.font.family}`;
24
+ }
25
+ render(input) {
26
+ const { x, timeSeconds, hoverY, valueMin, valueMax, priceStep, chartWidth, chartHeight, stackHeight, invertValueAxis, legendParts, canvasWidth, } = input;
27
+ const { ctx, crosshair } = this;
28
+ ctx.save();
29
+ ctx.strokeStyle = crosshair.lineColor;
30
+ ctx.setLineDash([4, 4]);
31
+ // Spans the whole pane stack (not just the main pane's own
32
+ // chartHeight) so hovering a candle lines up with the same column
33
+ // across every indicator pane below it — see `stackHeight`'s own doc
34
+ // comment for why the horizontal line/legend don't follow suit.
35
+ ctx.beginPath();
36
+ ctx.moveTo(x, 0);
37
+ ctx.lineTo(x, stackHeight);
38
+ ctx.stroke();
39
+ // The horizontal line follows the actual cursor/finger position, not
40
+ // any property of the hovered point — pinning it to (say) the candle's
41
+ // close would leave it motionless while the pointer moves anywhere
42
+ // within that same candle's column, which reads as broken/stuck rather
43
+ // than as a crosshair. Only drawn while the pointer is actually inside
44
+ // the chart's vertical extent, same as AxisRenderer's tick-skip logic.
45
+ const priceLineVisible = hoverY !== null && hoverY >= 0 && hoverY <= chartHeight;
46
+ if (priceLineVisible) {
47
+ ctx.beginPath();
48
+ ctx.moveTo(0, hoverY);
49
+ ctx.lineTo(chartWidth, hoverY);
50
+ ctx.stroke();
51
+ }
52
+ ctx.restore();
53
+ if (priceLineVisible) {
54
+ // Exact inverse of the value->y mapping createScale set up for this
55
+ // frame — same helper (and same invertValueAxis flag) as
56
+ // PluginRenderApi.valueForY, see src/valueAxis.ts.
57
+ const value = pixelToValue(hoverY, valueMin, valueMax, chartHeight, invertValueAxis);
58
+ this.renderPriceLabelChip(formatPrice(value, priceStep), hoverY, chartWidth);
59
+ }
60
+ this.renderTimeLabelChip(formatHoverTime(timeSeconds), x, chartHeight, canvasWidth);
61
+ if (legendParts.length === 0)
62
+ return;
63
+ this.renderHoverTooltip(legendParts, x, hoverY, chartWidth, chartHeight);
64
+ }
65
+ /** The OHLC(+volume) tooltip — floats near the hovered pixel like a
66
+ * speech bubble, one line per part, rather than a fixed banner glued to
67
+ * a corner of the canvas. Offset up-and-right of the cursor/finger by
68
+ * `legend.cursorGap` and clamped to both chart edges so it never runs
69
+ * off-screen, including when there's no `hoverY` to anchor to (a series
70
+ * with no primary value still gets a legend, just pinned near the top
71
+ * at the hovered column). */
72
+ renderHoverTooltip(lines, x, hoverY, chartWidth, chartHeight) {
73
+ const { ctx, font, legend } = this;
74
+ ctx.font = this.legendFont();
75
+ ctx.textAlign = 'left';
76
+ ctx.textBaseline = 'top';
77
+ const lineHeight = font.legendSize + 4;
78
+ const textWidth = Math.max(...lines.map((line) => ctx.measureText(line).width));
79
+ const boxWidth = textWidth + legend.paddingX * 2;
80
+ const boxHeight = lines.length * lineHeight + legend.paddingY * 2;
81
+ const anchorY = hoverY ?? 0;
82
+ const left = Math.min(Math.max(x + legend.cursorGap, 0), Math.max(0, chartWidth - boxWidth));
83
+ const top = Math.min(Math.max(anchorY - boxHeight - legend.cursorGap, 0), Math.max(0, chartHeight - boxHeight));
84
+ ctx.fillStyle = legend.background;
85
+ ctx.fillRect(left, top, boxWidth, boxHeight);
86
+ ctx.fillStyle = legend.textColor;
87
+ lines.forEach((line, i) => {
88
+ ctx.fillText(line, left + legend.paddingX, top + legend.paddingY + i * lineHeight);
89
+ });
90
+ }
91
+ /** The highlighted price-axis label that follows the crosshair's
92
+ * horizontal line — drawn over `AxisRenderer`'s own tick labels so the
93
+ * hovered value reads clearly even where it lands between two ticks. */
94
+ renderPriceLabelChip(text, y, chartWidth) {
95
+ const { ctx, font, crosshair } = this;
96
+ ctx.font = this.axisFont();
97
+ const chipHeight = font.axisSize + crosshair.labelPaddingY * 2;
98
+ ctx.fillStyle = crosshair.labelBackground;
99
+ ctx.fillRect(chartWidth, y - chipHeight / 2, this.priceAxisWidth, chipHeight);
100
+ ctx.fillStyle = crosshair.labelTextColor;
101
+ ctx.textAlign = 'left';
102
+ ctx.textBaseline = 'middle';
103
+ ctx.fillText(text, chartWidth + crosshair.labelPaddingX, y);
104
+ }
105
+ /** The highlighted time-axis label under the crosshair's vertical line.
106
+ * Clamped so its background chip stays fully on-screen even when the
107
+ * hovered point sits at the very first or last visible index. */
108
+ renderTimeLabelChip(text, x, chartHeight, canvasWidth) {
109
+ const { ctx, font, crosshair } = this;
110
+ ctx.font = this.axisFont();
111
+ const chipWidth = ctx.measureText(text).width + crosshair.labelPaddingX * 2;
112
+ const chipHeight = font.axisSize + crosshair.labelPaddingY * 2;
113
+ const chipLeft = Math.min(Math.max(x - chipWidth / 2, 0), canvasWidth - chipWidth);
114
+ ctx.fillStyle = crosshair.labelBackground;
115
+ ctx.fillRect(chipLeft, chartHeight, chipWidth, chipHeight);
116
+ ctx.fillStyle = crosshair.labelTextColor;
117
+ ctx.textAlign = 'left';
118
+ ctx.textBaseline = 'top';
119
+ ctx.fillText(text, chipLeft + crosshair.labelPaddingX, chartHeight + crosshair.labelPaddingY);
120
+ }
121
+ }
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { lowerBound, upperBound } from './binarySearch.js';
1
2
  import { mergeSeriesPoints } from './mergeSeries.js';
2
3
  import { computePaneLayout } from './paneLayout.js';
3
4
  import { ChartRenderer } from './renderer.js';
@@ -45,39 +46,6 @@ const LONG_PRESS_MS = 350;
45
46
  * as a real drag, not a hold — cancels the pending long-press timer so a
46
47
  * fast pan gesture never flips into scrub mid-motion. */
47
48
  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
49
  /**
82
50
  * Interactive chart: drag to pan, wheel to zoom, drag the price-axis strip
83
51
  * to rescale it, hover a point for a legend. What gets plotted (candles
@@ -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
- * `renderCrosshairAndLegend` for why that has to be the raw cursor
16
- * position rather than any property of the hovered point itself. */
15
+ * `CrosshairRenderer` for why that has to be the raw cursor position
16
+ * rather than any property of the hovered point itself. */
17
17
  hoverY: number | null;
18
18
  plugins: ChartPlugin<TPoint>[];
19
19
  /** Indicator/oscillator panes declared via `WickChart.addPane`, resolved
@@ -23,15 +23,22 @@ export interface RenderInput<TPoint extends SeriesPoint> {
23
23
  panes: ResolvedPaneOptions[];
24
24
  }
25
25
  /**
26
- * The chart engine's renderer: canvas lifecycle, axes, crosshair, and
27
- * plugin drawing are all generic — none of it knows what kind of series is
28
- * on screen. The one series-specific seam is `seriesDefinition`, injected
29
- * at construction (see `src/series/types.ts`); everything above delegates
30
- * to it for value-range computation, point drawing, and legend text.
26
+ * The chart engine's renderer: owns the canvas lifecycle and orchestrates
27
+ * one frame — deciding what data is visible, computing scales, and calling
28
+ * out to collaborators for the actual pixel-pushing. None of it knows what
29
+ * kind of series is on screen: the one series-specific seam is
30
+ * `seriesDefinition`, injected at construction (see `src/series/types.ts`).
31
31
  * Stateless per call otherwise — all pan/zoom/hover state lives in
32
32
  * `Viewport` and `WickChart`; this class only turns a snapshot of that
33
33
  * state into pixels.
34
34
  *
35
+ * Axis chrome and the hover crosshair/legend are drawn by two collaborators
36
+ * (`AxisRenderer`, `CrosshairRenderer`) rather than methods on this class —
37
+ * both take only already-resolved style options and per-call geometry, no
38
+ * series generic or plugin state, so splitting them out keeps this file
39
+ * focused on orchestration (what gets drawn, in what order, with what
40
+ * scale) rather than mixing in how each individual chrome element paints.
41
+ *
35
42
  * Every visual constant below (fonts, axis sizing/coloring, crosshair
36
43
  * coloring/padding, legend color) is resolved once at construction from
37
44
  * `WickChartOptions.font`/`axis`/`crosshair`/`legend`, each merged field
@@ -44,10 +51,13 @@ export declare class ChartRenderer<TPoint extends SeriesPoint> {
44
51
  private ctx;
45
52
  private background;
46
53
  private style;
47
- private font;
54
+ /** Kept as its own field (unlike font/crosshair/legend, which only
55
+ * `AxisRenderer`/`CrosshairRenderer` need after construction) because
56
+ * `chartWidth`/`chartHeight`/`priceAxisWidth` below read it directly on
57
+ * every call, not just once at construction. */
48
58
  private axis;
49
- private crosshair;
50
- private legend;
59
+ private axisRenderer;
60
+ private crosshairRenderer;
51
61
  /** Unlike the style groups above, mutable after construction — see
52
62
  * `setInvertValueAxis`. A live toggle, not a one-time style choice, is
53
63
  * the whole point of this option (a "what if this series moved the
@@ -62,8 +72,6 @@ export declare class ChartRenderer<TPoint extends SeriesPoint> {
62
72
  get chartWidth(): number;
63
73
  get chartHeight(): number;
64
74
  get priceAxisWidth(): number;
65
- private axisFont;
66
- private legendFont;
67
75
  render(input: RenderInput<TPoint>): void;
68
76
  /**
69
77
  * Builds the `PluginRenderApi` for one pane — the main price pane or a
@@ -73,40 +81,4 @@ export declare class ChartRenderer<TPoint extends SeriesPoint> {
73
81
  * frame-ended flag, for the whole stack; see `FrameGeometry`).
74
82
  */
75
83
  private buildPluginApi;
76
- /** The decimal precision `formatPrice` should use for the current price
77
- * range — shared by the axis ticks and the crosshair's price label so
78
- * both display the same value with the same rounding. */
79
- private currentPriceStep;
80
- /**
81
- * Draws one pane's right-side value axis: boundary line, horizontal grid
82
- * lines, and tick labels. Used for both the main price pane and every
83
- * indicator pane — `topOffset` shifts everything down by that pane's own
84
- * position in the stack (0 for the main pane, which sits at the top), so
85
- * `yScale` only ever has to know about its own pane-local [0, chartHeight]
86
- * range and never about where that pane lives in the full canvas.
87
- */
88
- private renderPriceAxis;
89
- /** The horizontal rule separating an indicator pane from whatever sits
90
- * above it (the main pane, or the previous indicator pane) — the same
91
- * `axis.lineColor` boundary style `renderTimeAxis` already draws between
92
- * the plotting area and the time-axis strip. */
93
- private renderPaneSeparator;
94
- private renderTimeAxis;
95
- private renderCrosshairAndLegend;
96
- /** The OHLC(+volume) tooltip — floats near the hovered pixel like a
97
- * speech bubble, one line per part, rather than a fixed banner glued to
98
- * a corner of the canvas. Offset up-and-right of the cursor/finger by
99
- * `legend.cursorGap` and clamped to both chart edges so it never runs
100
- * off-screen, including when there's no `hoverY` to anchor to (a series
101
- * with no primary value still gets a legend, just pinned near the top
102
- * at the hovered column). */
103
- private renderHoverTooltip;
104
- /** The highlighted price-axis label that follows the crosshair's
105
- * horizontal line — drawn over `renderPriceAxis`'s own tick labels so the
106
- * hovered value reads clearly even where it lands between two ticks. */
107
- private renderPriceLabelChip;
108
- /** The highlighted time-axis label under the crosshair's vertical line.
109
- * Clamped so its background chip stays fully on-screen even when the
110
- * hovered point sits at the very first or last visible index. */
111
- private renderTimeLabelChip;
112
84
  }
package/dist/renderer.js CHANGED
@@ -1,7 +1,7 @@
1
- import { formatAxisLabel, formatHoverTime, pickTickIndices } from './axis.js';
1
+ import { AxisRenderer } from './axisRenderer.js';
2
+ import { CrosshairRenderer } from './crosshairRenderer.js';
2
3
  import { createScale } from './hybridScale.js';
3
4
  import { computePaneLayout } from './paneLayout.js';
4
- import { formatPrice, niceTicks } from './priceAxis.js';
5
5
  import { pixelToValue, valueAxisPixelRange } from './valueAxis.js';
6
6
  const DEFAULT_BACKGROUND = 'transparent';
7
7
  const DEFAULT_FONT = {
@@ -33,15 +33,22 @@ const DEFAULT_LEGEND = {
33
33
  cursorGap: 12,
34
34
  };
35
35
  /**
36
- * The chart engine's renderer: canvas lifecycle, axes, crosshair, and
37
- * plugin drawing are all generic — none of it knows what kind of series is
38
- * on screen. The one series-specific seam is `seriesDefinition`, injected
39
- * at construction (see `src/series/types.ts`); everything above delegates
40
- * to it for value-range computation, point drawing, and legend text.
36
+ * The chart engine's renderer: owns the canvas lifecycle and orchestrates
37
+ * one frame — deciding what data is visible, computing scales, and calling
38
+ * out to collaborators for the actual pixel-pushing. None of it knows what
39
+ * kind of series is on screen: the one series-specific seam is
40
+ * `seriesDefinition`, injected at construction (see `src/series/types.ts`).
41
41
  * Stateless per call otherwise — all pan/zoom/hover state lives in
42
42
  * `Viewport` and `WickChart`; this class only turns a snapshot of that
43
43
  * state into pixels.
44
44
  *
45
+ * Axis chrome and the hover crosshair/legend are drawn by two collaborators
46
+ * (`AxisRenderer`, `CrosshairRenderer`) rather than methods on this class —
47
+ * both take only already-resolved style options and per-call geometry, no
48
+ * series generic or plugin state, so splitting them out keeps this file
49
+ * focused on orchestration (what gets drawn, in what order, with what
50
+ * scale) rather than mixing in how each individual chrome element paints.
51
+ *
45
52
  * Every visual constant below (fonts, axis sizing/coloring, crosshair
46
53
  * coloring/padding, legend color) is resolved once at construction from
47
54
  * `WickChartOptions.font`/`axis`/`crosshair`/`legend`, each merged field
@@ -58,11 +65,13 @@ export class ChartRenderer {
58
65
  this.ctx = ctx;
59
66
  this.background = options.background ?? DEFAULT_BACKGROUND;
60
67
  this.style = { ...seriesDefinition.defaultStyle, ...(options.style ?? {}) };
61
- this.font = { ...DEFAULT_FONT, ...options.font };
62
68
  this.axis = { ...DEFAULT_AXIS, ...options.axis };
63
- this.crosshair = { ...DEFAULT_CROSSHAIR, ...options.crosshair };
64
- this.legend = { ...DEFAULT_LEGEND, ...options.legend };
65
69
  this.invertValueAxis = options.invertValueAxis ?? false;
70
+ const font = { ...DEFAULT_FONT, ...options.font };
71
+ const crosshair = { ...DEFAULT_CROSSHAIR, ...options.crosshair };
72
+ const legend = { ...DEFAULT_LEGEND, ...options.legend };
73
+ this.axisRenderer = new AxisRenderer(ctx, this.axis, font);
74
+ this.crosshairRenderer = new CrosshairRenderer(ctx, crosshair, legend, font, this.axis.priceWidth);
66
75
  }
67
76
  setInvertValueAxis(inverted) {
68
77
  this.invertValueAxis = inverted;
@@ -79,12 +88,6 @@ export class ChartRenderer {
79
88
  get priceAxisWidth() {
80
89
  return this.axis.priceWidth;
81
90
  }
82
- axisFont() {
83
- return `${this.font.axisSize}px ${this.font.family}`;
84
- }
85
- legendFont() {
86
- return `${this.font.legendSize}px ${this.font.family}`;
87
- }
88
91
  render(input) {
89
92
  const { ctx, canvas, background, seriesDefinition, style } = this;
90
93
  const { sorted, times, viewport, hoverIndex, hoverY, plugins, panes } = input;
@@ -166,20 +169,34 @@ export class ChartRenderer {
166
169
  finally {
167
170
  ctx.restore();
168
171
  }
169
- const priceStep = this.currentPriceStep(valueMin, valueMax);
170
- this.renderPriceAxis(valueMin, valueMax, priceStep, yScale, chartWidth, chartHeight, mainRect.top);
172
+ const priceStep = this.axisRenderer.priceStep(valueMin, valueMax);
173
+ this.axisRenderer.renderPriceAxis(valueMin, valueMax, priceStep, yScale, chartWidth, chartHeight, mainRect.top);
171
174
  for (const { rect, min, max, scale } of paneScales) {
172
- this.renderPaneSeparator(rect.top, chartWidth);
173
- const step = this.currentPriceStep(min, max);
174
- this.renderPriceAxis(min, max, step, scale, chartWidth, rect.height, rect.top);
175
+ this.axisRenderer.renderPaneSeparator(rect.top, chartWidth);
176
+ const step = this.axisRenderer.priceStep(min, max);
177
+ this.axisRenderer.renderPriceAxis(min, max, step, scale, chartWidth, rect.height, rect.top);
175
178
  }
176
- this.renderTimeAxis(times, startIdx, visible.length, stackHeight, chartWidth, xForIndex);
179
+ this.axisRenderer.renderTimeAxis(times, startIdx, visible.length, stackHeight, chartWidth, xForIndex);
177
180
  if (hoverIndex !== null && hoverIndex >= startIdx && hoverIndex < endIdx) {
178
181
  // The dashed vertical line spans the whole stack (every pane); the
179
182
  // horizontal line, price-label chip, and OHLC legend stay scoped
180
183
  // to the main pane only — an indicator pane's own hover readout,
181
184
  // if it wants one, is the job of whatever plugin draws into it.
182
- this.renderCrosshairAndLegend(sorted[hoverIndex], xForIndex(hoverIndex), times[hoverIndex], hoverY, valueMin, valueMax, priceStep, chartWidth, chartHeight, stackHeight);
185
+ const legendParts = seriesDefinition.formatLegend?.(sorted[hoverIndex], style) ?? [];
186
+ this.crosshairRenderer.render({
187
+ x: xForIndex(hoverIndex),
188
+ timeSeconds: times[hoverIndex],
189
+ hoverY,
190
+ valueMin,
191
+ valueMax,
192
+ priceStep,
193
+ chartWidth,
194
+ chartHeight,
195
+ stackHeight,
196
+ invertValueAxis: this.invertValueAxis,
197
+ legendParts,
198
+ canvasWidth: canvas.width,
199
+ });
183
200
  }
184
201
  if (plugins.length > 0) {
185
202
  // Everything every pane's PluginRenderApi shares — only the pane's
@@ -267,172 +284,4 @@ export class ChartRenderer {
267
284
  allPoints,
268
285
  };
269
286
  }
270
- /** The decimal precision `formatPrice` should use for the current price
271
- * range — shared by the axis ticks and the crosshair's price label so
272
- * both display the same value with the same rounding. */
273
- currentPriceStep(priceMin, priceMax) {
274
- const ticks = niceTicks(priceMin, priceMax, this.axis.priceTickCount);
275
- return ticks.length > 1 ? ticks[1] - ticks[0] : 0;
276
- }
277
- /**
278
- * Draws one pane's right-side value axis: boundary line, horizontal grid
279
- * lines, and tick labels. Used for both the main price pane and every
280
- * indicator pane — `topOffset` shifts everything down by that pane's own
281
- * position in the stack (0 for the main pane, which sits at the top), so
282
- * `yScale` only ever has to know about its own pane-local [0, chartHeight]
283
- * range and never about where that pane lives in the full canvas.
284
- */
285
- renderPriceAxis(priceMin, priceMax, step, yScale, chartWidth, chartHeight, topOffset) {
286
- const { ctx, axis } = this;
287
- const ticks = niceTicks(priceMin, priceMax, axis.priceTickCount);
288
- ctx.strokeStyle = axis.lineColor;
289
- ctx.beginPath();
290
- ctx.moveTo(chartWidth + 0.5, topOffset);
291
- ctx.lineTo(chartWidth + 0.5, topOffset + chartHeight);
292
- ctx.stroke();
293
- ctx.font = this.axisFont();
294
- ctx.textAlign = 'left';
295
- ctx.textBaseline = 'middle';
296
- for (const value of ticks) {
297
- const localY = yScale.map(value);
298
- if (localY < 0 || localY > chartHeight)
299
- continue;
300
- const y = topOffset + localY;
301
- ctx.strokeStyle = axis.gridLineColor;
302
- ctx.beginPath();
303
- ctx.moveTo(0, y + 0.5);
304
- ctx.lineTo(chartWidth, y + 0.5);
305
- ctx.stroke();
306
- ctx.fillStyle = axis.textColor;
307
- ctx.fillText(formatPrice(value, step), chartWidth + 6, y);
308
- }
309
- }
310
- /** The horizontal rule separating an indicator pane from whatever sits
311
- * above it (the main pane, or the previous indicator pane) — the same
312
- * `axis.lineColor` boundary style `renderTimeAxis` already draws between
313
- * the plotting area and the time-axis strip. */
314
- renderPaneSeparator(top, chartWidth) {
315
- const { ctx, axis } = this;
316
- ctx.strokeStyle = axis.lineColor;
317
- ctx.beginPath();
318
- ctx.moveTo(0, top + 0.5);
319
- ctx.lineTo(chartWidth, top + 0.5);
320
- ctx.stroke();
321
- }
322
- renderTimeAxis(times, startIdx, visibleCount, chartHeight, chartWidth, xForIndex) {
323
- const { ctx, axis } = this;
324
- const visibleTimes = times.slice(startIdx, startIdx + visibleCount);
325
- const spanSeconds = visibleTimes[visibleTimes.length - 1] - visibleTimes[0];
326
- ctx.strokeStyle = axis.lineColor;
327
- ctx.beginPath();
328
- ctx.moveTo(0, chartHeight + 0.5);
329
- ctx.lineTo(chartWidth, chartHeight + 0.5);
330
- ctx.stroke();
331
- ctx.fillStyle = axis.textColor;
332
- ctx.font = this.axisFont();
333
- ctx.textAlign = 'center';
334
- ctx.textBaseline = 'top';
335
- for (const localIndex of pickTickIndices(visibleCount, axis.timeMaxTicks)) {
336
- const x = xForIndex(startIdx + localIndex);
337
- const label = formatAxisLabel(visibleTimes[localIndex], spanSeconds);
338
- ctx.fillText(label, x, chartHeight + 6);
339
- }
340
- }
341
- renderCrosshairAndLegend(point, x, timeSeconds, hoverY, valueMin, valueMax, priceStep, chartWidth, chartHeight, stackHeight) {
342
- const { ctx, canvas, seriesDefinition, style, crosshair } = this;
343
- ctx.save();
344
- ctx.strokeStyle = crosshair.lineColor;
345
- ctx.setLineDash([4, 4]);
346
- // Spans the whole pane stack (not just the main pane's own
347
- // chartHeight) so hovering a candle lines up with the same column
348
- // across every indicator pane below it — see the call site's comment
349
- // in `render()` for why the horizontal line/legend don't follow suit.
350
- ctx.beginPath();
351
- ctx.moveTo(x, 0);
352
- ctx.lineTo(x, stackHeight);
353
- ctx.stroke();
354
- // The horizontal line follows the actual cursor/finger position, not
355
- // any property of the hovered point — pinning it to (say) the candle's
356
- // close would leave it motionless while the pointer moves anywhere
357
- // within that same candle's column, which reads as broken/stuck rather
358
- // than as a crosshair. Only drawn while the pointer is actually inside
359
- // the chart's vertical extent, same as the price-axis tick-skip logic
360
- // in renderPriceAxis.
361
- const priceLineVisible = hoverY !== null && hoverY >= 0 && hoverY <= chartHeight;
362
- if (priceLineVisible) {
363
- ctx.beginPath();
364
- ctx.moveTo(0, hoverY);
365
- ctx.lineTo(chartWidth, hoverY);
366
- ctx.stroke();
367
- }
368
- ctx.restore();
369
- if (priceLineVisible) {
370
- // Exact inverse of the value->y mapping createScale set up for this
371
- // frame — same helper (and same invertValueAxis flag) as
372
- // PluginRenderApi.valueForY, see src/valueAxis.ts.
373
- const value = pixelToValue(hoverY, valueMin, valueMax, chartHeight, this.invertValueAxis);
374
- this.renderPriceLabelChip(formatPrice(value, priceStep), hoverY, chartWidth);
375
- }
376
- this.renderTimeLabelChip(formatHoverTime(timeSeconds), x, chartHeight, canvas.width);
377
- const parts = seriesDefinition.formatLegend?.(point, style) ?? [];
378
- if (parts.length === 0)
379
- return;
380
- this.renderHoverTooltip(parts, x, hoverY, chartWidth, chartHeight);
381
- }
382
- /** The OHLC(+volume) tooltip — floats near the hovered pixel like a
383
- * speech bubble, one line per part, rather than a fixed banner glued to
384
- * a corner of the canvas. Offset up-and-right of the cursor/finger by
385
- * `legend.cursorGap` and clamped to both chart edges so it never runs
386
- * off-screen, including when there's no `hoverY` to anchor to (a series
387
- * with no primary value still gets a legend, just pinned near the top
388
- * at the hovered column). */
389
- renderHoverTooltip(lines, x, hoverY, chartWidth, chartHeight) {
390
- const { ctx, font, legend } = this;
391
- ctx.font = this.legendFont();
392
- ctx.textAlign = 'left';
393
- ctx.textBaseline = 'top';
394
- const lineHeight = font.legendSize + 4;
395
- const textWidth = Math.max(...lines.map((line) => ctx.measureText(line).width));
396
- const boxWidth = textWidth + legend.paddingX * 2;
397
- const boxHeight = lines.length * lineHeight + legend.paddingY * 2;
398
- const anchorY = hoverY ?? 0;
399
- const left = Math.min(Math.max(x + legend.cursorGap, 0), Math.max(0, chartWidth - boxWidth));
400
- const top = Math.min(Math.max(anchorY - boxHeight - legend.cursorGap, 0), Math.max(0, chartHeight - boxHeight));
401
- ctx.fillStyle = legend.background;
402
- ctx.fillRect(left, top, boxWidth, boxHeight);
403
- ctx.fillStyle = legend.textColor;
404
- lines.forEach((line, i) => {
405
- ctx.fillText(line, left + legend.paddingX, top + legend.paddingY + i * lineHeight);
406
- });
407
- }
408
- /** The highlighted price-axis label that follows the crosshair's
409
- * horizontal line — drawn over `renderPriceAxis`'s own tick labels so the
410
- * hovered value reads clearly even where it lands between two ticks. */
411
- renderPriceLabelChip(text, y, chartWidth) {
412
- const { ctx, font, crosshair } = this;
413
- ctx.font = this.axisFont();
414
- const chipHeight = font.axisSize + crosshair.labelPaddingY * 2;
415
- ctx.fillStyle = crosshair.labelBackground;
416
- ctx.fillRect(chartWidth, y - chipHeight / 2, this.priceAxisWidth, chipHeight);
417
- ctx.fillStyle = crosshair.labelTextColor;
418
- ctx.textAlign = 'left';
419
- ctx.textBaseline = 'middle';
420
- ctx.fillText(text, chartWidth + crosshair.labelPaddingX, y);
421
- }
422
- /** The highlighted time-axis label under the crosshair's vertical line.
423
- * Clamped so its background chip stays fully on-screen even when the
424
- * hovered point sits at the very first or last visible index. */
425
- renderTimeLabelChip(text, x, chartHeight, canvasWidth) {
426
- const { ctx, font, crosshair } = this;
427
- ctx.font = this.axisFont();
428
- const chipWidth = ctx.measureText(text).width + crosshair.labelPaddingX * 2;
429
- const chipHeight = font.axisSize + crosshair.labelPaddingY * 2;
430
- const chipLeft = Math.min(Math.max(x - chipWidth / 2, 0), canvasWidth - chipWidth);
431
- ctx.fillStyle = crosshair.labelBackground;
432
- ctx.fillRect(chipLeft, chartHeight, chipWidth, chipHeight);
433
- ctx.fillStyle = crosshair.labelTextColor;
434
- ctx.textAlign = 'left';
435
- ctx.textBaseline = 'top';
436
- ctx.fillText(text, chipLeft + crosshair.labelPaddingX, chartHeight + crosshair.labelPaddingY);
437
- }
438
287
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wick-charts",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "An open-source financial charting library — WASM (Rust) for compute, Canvas2D for rendering.",
5
5
  "license": "MIT",
6
6
  "author": "eatnows",