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.
- package/LICENSE +21 -0
- package/README.md +545 -0
- package/dist/axis.d.ts +21 -0
- package/dist/axis.js +44 -0
- package/dist/dataSource.d.ts +24 -0
- package/dist/dataSource.js +1 -0
- package/dist/hitTest.d.ts +31 -0
- package/dist/hitTest.js +46 -0
- package/dist/hybridScale.d.ts +25 -0
- package/dist/hybridScale.js +41 -0
- package/dist/index.d.ts +225 -0
- package/dist/index.js +715 -0
- package/dist/mergeSeries.d.ts +14 -0
- package/dist/mergeSeries.js +21 -0
- package/dist/plugins/types.d.ts +140 -0
- package/dist/plugins/types.js +1 -0
- package/dist/priceAxis.d.ts +7 -0
- package/dist/priceAxis.js +49 -0
- package/dist/priceRange.d.ts +12 -0
- package/dist/priceRange.js +16 -0
- package/dist/renderer.d.ts +79 -0
- package/dist/renderer.js +318 -0
- package/dist/scale.d.ts +20 -0
- package/dist/scale.js +29 -0
- package/dist/series/candlestick.d.ts +20 -0
- package/dist/series/candlestick.js +88 -0
- package/dist/series/registry.d.ts +13 -0
- package/dist/series/registry.js +30 -0
- package/dist/series/types.d.ts +56 -0
- package/dist/series/types.js +1 -0
- package/dist/testHelpers.d.ts +38 -0
- package/dist/testHelpers.js +50 -0
- package/dist/time.d.ts +6 -0
- package/dist/time.js +58 -0
- package/dist/types.d.ts +151 -0
- package/dist/types.js +1 -0
- package/dist/viewport.d.ts +52 -0
- package/dist/viewport.js +87 -0
- package/dist/wasm.d.ts +29 -0
- package/dist/wasm.js +35 -0
- package/dist/wasmImporter.d.ts +6 -0
- package/dist/wasmImporter.js +7 -0
- package/package.json +39 -0
- package/wasm-pkg/package.json +21 -0
- package/wasm-pkg/wickchart_core.d.ts +59 -0
- package/wasm-pkg/wickchart_core.js +227 -0
- package/wasm-pkg/wickchart_core_bg.wasm +0 -0
- 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
|
+
}
|
package/dist/renderer.js
ADDED
|
@@ -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
|
+
}
|