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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 eatnows
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
# wick-charts
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/wick-charts)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
|
|
6
|
+
An open-source financial charting library. WASM (Rust) for compute, Canvas2D for rendering.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install wick-charts
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Contents
|
|
13
|
+
|
|
14
|
+
- [Why](#why)
|
|
15
|
+
- [Requirements](#requirements)
|
|
16
|
+
- [Usage](#usage)
|
|
17
|
+
- [Install](#install)
|
|
18
|
+
- [Quick start](#quick-start)
|
|
19
|
+
- [Candle data](#candle-data)
|
|
20
|
+
- [Styling](#styling)
|
|
21
|
+
- [Reading chart state](#reading-chart-state)
|
|
22
|
+
- [Loading more history on demand](#loading-more-history-on-demand)
|
|
23
|
+
- [Extending: plugins](#extending-plugins)
|
|
24
|
+
- [Cleanup](#cleanup)
|
|
25
|
+
- [Architecture](#architecture)
|
|
26
|
+
- [Development](#development)
|
|
27
|
+
- [Contributing](#contributing)
|
|
28
|
+
- [Status](#status)
|
|
29
|
+
- [License](#license)
|
|
30
|
+
|
|
31
|
+
## Why
|
|
32
|
+
|
|
33
|
+
A serious trading UI needs more than a candlestick renderer on a page — drawing tools,
|
|
34
|
+
multi-pane indicator stacks, replay, and large-series performance all matter once real
|
|
35
|
+
usage starts. wick-charts aims to cover that ground natively from the start, while keeping
|
|
36
|
+
rendering on the simplest thing that can possibly work (Canvas2D — no WebGL until profiling
|
|
37
|
+
says it's actually needed).
|
|
38
|
+
|
|
39
|
+
## Requirements
|
|
40
|
+
|
|
41
|
+
- **A browser, not Node/SSR.** The chart draws into a real `<canvas>` element and reads
|
|
42
|
+
`devicePixelRatio`/pointer events directly — there's no server-side rendering path. In a
|
|
43
|
+
framework with SSR (Next.js, Nuxt, SvelteKit, ...), construct the chart only on the client
|
|
44
|
+
(inside `useEffect`, `onMounted`, or the equivalent for your framework).
|
|
45
|
+
- **A bundler that can load `.wasm` as an asset** — Vite, webpack 5+, Rollup with a WASM
|
|
46
|
+
plugin, or similar. The WASM module is loaded via a relative dynamic `import()`
|
|
47
|
+
(`wasmImporter.ts`) the way `wasm-pack --target web` output expects; every mainstream
|
|
48
|
+
bundler handles this out of the box (verified against a plain Vite build as part of this
|
|
49
|
+
package's own release checklist). A bundler that can't resolve it isn't a hard failure —
|
|
50
|
+
see the note on `wasm-pkg/` under [Install](#install) — but coordinate scaling runs
|
|
51
|
+
entirely on the slower JS fallback if it never loads.
|
|
52
|
+
- **TypeScript is optional.** The library is written in TypeScript and ships its own `.d.ts`
|
|
53
|
+
files, but nothing about the API requires it — every example below works unchanged with a
|
|
54
|
+
`.js` file and no type annotations.
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
### Install
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npm install wick-charts
|
|
62
|
+
# or: pnpm add wick-charts / yarn add wick-charts
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`dist/` and `wasm-pkg/` ship together inside the package and must stay siblings — the compiled
|
|
66
|
+
JS does `import('../wasm-pkg/...')` relative to its own location, which is already how the
|
|
67
|
+
package is laid out once installed, so this only matters if you copy files out of
|
|
68
|
+
`node_modules` by hand instead of depending on the package normally. A missing or unreachable
|
|
69
|
+
`wasm-pkg/` isn't a hard failure either way — it's caught internally and the chart falls back
|
|
70
|
+
to the plain-JS scale for every frame (see [Architecture](#architecture)), so a broken path
|
|
71
|
+
degrades performance silently rather than crashing.
|
|
72
|
+
|
|
73
|
+
### Quick start
|
|
74
|
+
|
|
75
|
+
```html
|
|
76
|
+
<canvas id="chart"></canvas>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { createCandlestickChart } from 'wick-charts';
|
|
81
|
+
|
|
82
|
+
const canvas = document.getElementById('chart') as HTMLCanvasElement;
|
|
83
|
+
|
|
84
|
+
// The canvas element's width/height attributes are its backing-store
|
|
85
|
+
// (pixel) size — independent of whatever size CSS displays it at. Set
|
|
86
|
+
// both explicitly (accounting for devicePixelRatio) before constructing
|
|
87
|
+
// the chart, and again on every resize.
|
|
88
|
+
function resizeCanvas() {
|
|
89
|
+
const rect = canvas.parentElement!.getBoundingClientRect();
|
|
90
|
+
const dpr = window.devicePixelRatio || 1;
|
|
91
|
+
canvas.width = Math.round(rect.width * dpr);
|
|
92
|
+
canvas.height = Math.round(rect.height * dpr);
|
|
93
|
+
}
|
|
94
|
+
resizeCanvas();
|
|
95
|
+
|
|
96
|
+
const chart = createCandlestickChart(canvas);
|
|
97
|
+
|
|
98
|
+
chart.setData([
|
|
99
|
+
{ time: '2024-01-01T00:00:00Z', open: 100, high: 105, low: 98, close: 103 },
|
|
100
|
+
{ time: '2024-01-02T00:00:00Z', open: 103, high: 110, low: 101, close: 108 },
|
|
101
|
+
{ time: '2024-01-03T00:00:00Z', open: 108, high: 109, low: 104, close: 106 },
|
|
102
|
+
// ...
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
chart.render();
|
|
106
|
+
|
|
107
|
+
window.addEventListener('resize', () => {
|
|
108
|
+
resizeCanvas();
|
|
109
|
+
chart.render(); // re-render at the new backing-store size
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Panning, zooming, the price-axis drag, hover, and touch (single-finger pan, pinch-to-zoom,
|
|
114
|
+
long-press to scrub) all work immediately after `render()` — no further wiring needed; see
|
|
115
|
+
"Status" below for the full interaction list.
|
|
116
|
+
|
|
117
|
+
### Candle data
|
|
118
|
+
|
|
119
|
+
A candle is `{ time, open, high, low, close, volume? }`. `time` accepts several shapes so you
|
|
120
|
+
don't have to pre-convert whatever your data source hands you:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
{ time: 1704067200 } // unix seconds
|
|
124
|
+
{ time: { unixMs: 1704067200000 } } // unix milliseconds
|
|
125
|
+
{ time: '2024-01-01T00:00:00Z' } // ISO 8601 string
|
|
126
|
+
{ time: { businessDay: { year: 2024, month: 1, day: 1 } } } // calendar day, no time-of-day
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`volume` is entirely optional and per-candle: include it and a translucent bar is drawn for
|
|
130
|
+
that candle in the bottom fifth of the chart, scaled against the largest volume currently in
|
|
131
|
+
view; omit it (on some candles, or on all of them) and nothing is drawn or reserved for
|
|
132
|
+
it — a dataset with no `volume` at all renders exactly as if the feature didn't exist.
|
|
133
|
+
|
|
134
|
+
`setData()` sorts by time itself, so passing data in any order (or re-calling it with a fresh
|
|
135
|
+
array) is safe. It resets pan/zoom/hover state — call it for a genuinely new dataset, and use
|
|
136
|
+
`setDataLoader()` (below) to extend the current one instead.
|
|
137
|
+
|
|
138
|
+
### Styling
|
|
139
|
+
|
|
140
|
+
Every visual aspect of the chart is an option — nothing is a fixed constant you can't reach.
|
|
141
|
+
They split into two groups: `style` is specific to the active series (candlestick's colors,
|
|
142
|
+
body width, volume bars); `background`/`font`/`axis`/`crosshair`/`legend` are engine-level,
|
|
143
|
+
shared by whatever series is active, and merged field by field over their own defaults so you
|
|
144
|
+
only need to specify what you're changing:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
const chart = createCandlestickChart(canvas, {
|
|
148
|
+
background: '#0d1117',
|
|
149
|
+
style: {
|
|
150
|
+
upColor: '#26a69a',
|
|
151
|
+
downColor: '#ef5350',
|
|
152
|
+
bodyWidthRatio: 0.6, // candle width as a fraction of its slot; the rest is gap
|
|
153
|
+
volumeAreaHeightRatio: 0.2, // how much of the chart height volume bars occupy
|
|
154
|
+
volumeBarOpacity: 0.5,
|
|
155
|
+
},
|
|
156
|
+
font: {
|
|
157
|
+
family: 'sans-serif',
|
|
158
|
+
axisSize: 10, // axis ticks + crosshair axis labels
|
|
159
|
+
legendSize: 11, // the hover legend
|
|
160
|
+
},
|
|
161
|
+
axis: {
|
|
162
|
+
priceWidth: 64, // width, in px, of the price-axis strip on the right
|
|
163
|
+
timeHeight: 24, // height, in px, of the time-axis strip at the bottom
|
|
164
|
+
priceTickCount: 5,
|
|
165
|
+
timeMaxTicks: 6,
|
|
166
|
+
textColor: '#787878',
|
|
167
|
+
lineColor: '#33333333',
|
|
168
|
+
gridLineColor: '#2a2a2a55',
|
|
169
|
+
},
|
|
170
|
+
crosshair: {
|
|
171
|
+
lineColor: '#9090904d',
|
|
172
|
+
labelBackground: '#3a3a3a',
|
|
173
|
+
labelTextColor: '#f0f0f0',
|
|
174
|
+
labelPaddingX: 4,
|
|
175
|
+
labelPaddingY: 3,
|
|
176
|
+
},
|
|
177
|
+
legend: {
|
|
178
|
+
textColor: '#f0f0f0', // the OHLC(+volume) hover tooltip's text
|
|
179
|
+
background: '#3a3a3a', // the tooltip's background fill
|
|
180
|
+
paddingX: 8, // horizontal padding inside the tooltip
|
|
181
|
+
paddingY: 6, // vertical padding inside the tooltip
|
|
182
|
+
cursorGap: 12, // gap, in px, between the hovered pixel and the tooltip
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Every value shown above is the built-in default — this example changes nothing; it's a
|
|
188
|
+
reference for what exists. The `legend` options style a small tooltip — one line per
|
|
189
|
+
`formatLegend()` part — that follows the hovered pixel like a speech bubble, offset up and to
|
|
190
|
+
the right of it, and clamped so it never runs off the chart's edges. `createCandlestickChart`
|
|
191
|
+
type-checks `style` against
|
|
192
|
+
`CandlestickStyle`; the more general `new WickChart(canvas, { type: 'candlestick', style })`
|
|
193
|
+
also works but doesn't — see "Series types" below for why, if you're curious.
|
|
194
|
+
|
|
195
|
+
### Reading chart state
|
|
196
|
+
|
|
197
|
+
Useful for building UI around the canvas (a legend, a toolbar, a "jump to latest" button)
|
|
198
|
+
without reaching into the chart's internals:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
chart.getPointCount(); // total candles loaded (not just visible)
|
|
202
|
+
chart.getVisibleRange(); // { startIndex, endIndex, visibleCount }
|
|
203
|
+
chart.getValueRangeOverride(); // { min, max } once the user has dragged the price axis, else null
|
|
204
|
+
chart.getHoveredPoint(); // the candle under the cursor/finger, or null
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Loading more history on demand
|
|
208
|
+
|
|
209
|
+
`setDataLoader` lets you start with a small window and stream in more as the user pans toward
|
|
210
|
+
either edge, without the library ever calling `fetch` itself:
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
chart.setDataLoader(async ({ direction, boundary, count }) => {
|
|
214
|
+
// direction: 'before' (user panned toward older data) or 'after' (toward newer)
|
|
215
|
+
// boundary: unix seconds — the earliest ('before') or latest ('after') time already loaded
|
|
216
|
+
// count: how many candles would satisfy this request (a hint, not a hard requirement)
|
|
217
|
+
const candles = await fetchCandlesFrom(direction, boundary, count);
|
|
218
|
+
return candles; // an empty array tells the chart "no more data this way" until setData() resets it
|
|
219
|
+
}, /* threshold, in candles, default 20 */ 20);
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Extending: plugins
|
|
223
|
+
|
|
224
|
+
For overlays on top of the chart (markers, alert lines, annotations) that don't need to be a
|
|
225
|
+
whole chart type of their own:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
chart.addPlugin({
|
|
229
|
+
draw({ ctx, xForIndex, yForValue, visibleStartIndex, visibleEndIndex }) {
|
|
230
|
+
const index = 42;
|
|
231
|
+
if (index < visibleStartIndex || index >= visibleEndIndex) return;
|
|
232
|
+
ctx.fillStyle = '#ffcc00';
|
|
233
|
+
ctx.beginPath();
|
|
234
|
+
ctx.arc(xForIndex(index), yForValue(150), 4, 0, Math.PI * 2);
|
|
235
|
+
ctx.fill();
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Only call `xForIndex`/`yForValue` synchronously inside `draw()` — see "Plugins" below for why.
|
|
241
|
+
`removePlugin()` takes the same object back out. `allPoints` (the full loaded series, not
|
|
242
|
+
just what's visible) is there for exactly this kind of overlay: a moving average or any other
|
|
243
|
+
windowed calculation needs `period - 1` points of history *before* the visible window to be
|
|
244
|
+
accurate at its left edge. `demo/index.html` has a complete worked example (a moving average
|
|
245
|
+
built entirely in the demo's own code, period and color included) — see "Indicators" below
|
|
246
|
+
for why that lives in the demo and not in the library itself.
|
|
247
|
+
|
|
248
|
+
#### Interactive plugins: drawing tools
|
|
249
|
+
|
|
250
|
+
A plugin that only draws (a marker, an indicator overlay) never needs anything beyond `draw()`.
|
|
251
|
+
One that's placed or edited by the user — a trend line, a horizontal price alert someone drags
|
|
252
|
+
into position — needs to see raw pointer gestures too, which `WickChart` would otherwise
|
|
253
|
+
consume entirely for its own panning. `onPointerDown`/`onPointerMove`/`onPointerUp` are for
|
|
254
|
+
exactly this:
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
let start = null;
|
|
258
|
+
|
|
259
|
+
chart.addPlugin({
|
|
260
|
+
draw({ ctx, xForIndex, yForValue }) {
|
|
261
|
+
if (!start) return;
|
|
262
|
+
ctx.strokeStyle = '#00c2ff';
|
|
263
|
+
ctx.beginPath();
|
|
264
|
+
ctx.moveTo(xForIndex(start.index), yForValue(start.value));
|
|
265
|
+
ctx.lineTo(xForIndex(start.end.index), yForValue(start.end.value));
|
|
266
|
+
ctx.stroke();
|
|
267
|
+
},
|
|
268
|
+
onPointerDown(e) {
|
|
269
|
+
if (e.value === null) return false; // nothing to anchor a line to
|
|
270
|
+
start = { index: e.index, value: e.value, end: e };
|
|
271
|
+
return true; // claim the gesture — the chart won't pan while this line is being drawn
|
|
272
|
+
},
|
|
273
|
+
onPointerMove(e) {
|
|
274
|
+
start.end = e;
|
|
275
|
+
},
|
|
276
|
+
onPointerUp(e) {
|
|
277
|
+
start.end = e; // the line is now finished; a real tool would push it into a list and stop editing
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
`e.index`/`e.value` are the pointer position already converted to data space — a possibly
|
|
283
|
+
fractional index and the value under the cursor in the current frame's y-domain — computed
|
|
284
|
+
the exact same way `xForIndex`/`yForValue` map the other direction, so a line anchored at
|
|
285
|
+
`e.index`/`e.value` and drawn back through `xForIndex`/`yForValue` lines up with the pointer
|
|
286
|
+
exactly. This example always claims the gesture once a value exists, which is enough to prove
|
|
287
|
+
the mechanism but would fight with panning in a real app (every drag becomes a new line) — a
|
|
288
|
+
real drawing tool gates `onPointerDown` behind its own "tool active" state (a toggle button,
|
|
289
|
+
a keyboard modifier, whatever fits the app), only claiming gestures while armed.
|
|
290
|
+
|
|
291
|
+
#### Selecting a placed shape: hit-testing
|
|
292
|
+
|
|
293
|
+
Placing a line is only half of a drawing tool — re-selecting one that's already on the chart
|
|
294
|
+
(to drag it, delete it, or just highlight it) means answering "is this click on/near the shape
|
|
295
|
+
I already drew," which a `<canvas>` can't tell you on its own: it never reports which pixels
|
|
296
|
+
belong to what you painted, only raw pointer coordinates. `distanceToSegment`/`hitTestSegment`/
|
|
297
|
+
`hitTestPoint` (from `wick-charts`) are that missing piece — the point-to-segment geometry
|
|
298
|
+
every line-shaped drawing tool needs, written once instead of re-derived (and subtly
|
|
299
|
+
mis-derived at the endpoints) per plugin:
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
import { hitTestSegment } from 'wick-charts';
|
|
303
|
+
|
|
304
|
+
chart.addPlugin({
|
|
305
|
+
draw({ ctx, xForIndex, yForValue }) {
|
|
306
|
+
ctx.strokeStyle = selected ? '#ffcc00' : '#00c2ff';
|
|
307
|
+
ctx.beginPath();
|
|
308
|
+
ctx.moveTo(xForIndex(line.start.index), yForValue(line.start.value));
|
|
309
|
+
ctx.lineTo(xForIndex(line.end.index), yForValue(line.end.value));
|
|
310
|
+
ctx.stroke();
|
|
311
|
+
},
|
|
312
|
+
onPointerDown(e) {
|
|
313
|
+
// convert the line's own data-space endpoints to this event's pixels —
|
|
314
|
+
// e.xForIndex/e.yForValue are the forward direction, the same mapping
|
|
315
|
+
// e.index/e.value came from, always valid for the pointer position this
|
|
316
|
+
// particular event carries even as the chart pans/zooms between clicks
|
|
317
|
+
const x1 = e.xForIndex(line.start.index);
|
|
318
|
+
const y1 = e.yForValue(line.start.value);
|
|
319
|
+
const x2 = e.xForIndex(line.end.index);
|
|
320
|
+
const y2 = e.yForValue(line.end.value);
|
|
321
|
+
selected = y1 !== null && y2 !== null && hitTestSegment(e.x, e.y, x1, y1, x2, y2);
|
|
322
|
+
return selected; // claim the gesture only once selected, to drag it from here
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
The line's endpoints are kept in data space (`index`/`value`), not pixels — that's what makes
|
|
328
|
+
them survive a pan or zoom between when the line was drawn and when the user clicks it again.
|
|
329
|
+
`hitTestPoint` is the same idea for a single point (a marker, a drag handle on one endpoint)
|
|
330
|
+
rather than an edge; both default to a 6px tolerance, comfortably clickable with a mouse and
|
|
331
|
+
forgiving enough for a fingertip on touch.
|
|
332
|
+
|
|
333
|
+
#### Managing a growing list of plugins
|
|
334
|
+
|
|
335
|
+
An app with more than a couple of indicators/drawing tools attached usually wants a UI for
|
|
336
|
+
them — a panel listing what's currently on the chart, with a way to hide or remove each one —
|
|
337
|
+
rather than holding onto every instance it ever passed to `addPlugin` by hand. Give a plugin an
|
|
338
|
+
`id` and the chart can look it back up without the app tracking the object reference itself:
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
chart.addPlugin({ id: 'ma-20', draw(api) { /* ... */ } });
|
|
342
|
+
chart.addPlugin({ id: 'trend-1', draw(api) { /* ... */ }, onPointerDown, onPointerMove, onPointerUp });
|
|
343
|
+
|
|
344
|
+
chart.getPlugins(); // [{ id: 'ma-20', ... }, { id: 'trend-1', ... }] — a snapshot, safe to render a list from
|
|
345
|
+
|
|
346
|
+
chart.setPluginVisible('ma-20', false); // hides it and re-renders, but keeps its state —
|
|
347
|
+
// toggle it back on with `true` later
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
`visible` defaults to `true`; a hidden plugin is skipped both when drawing and when a pointer
|
|
351
|
+
gesture is being offered around, so a hidden drawing tool can't be nudged by an accidental
|
|
352
|
+
click while it's toggled off. `id` is optional and opaque to the chart — it's never generated
|
|
353
|
+
or validated for uniqueness, just compared with `===` when you call `setPluginVisible`. A
|
|
354
|
+
plugin with no `id` still works exactly as before; it just can't be targeted that way, only by
|
|
355
|
+
holding onto its reference and calling `removePlugin` directly.
|
|
356
|
+
|
|
357
|
+
### Cleanup
|
|
358
|
+
|
|
359
|
+
Call `chart.destroy()` when you're done with a chart (component unmount, etc.) — it removes a
|
|
360
|
+
window-level listener and cancels any pending scheduled render that a plain garbage collect
|
|
361
|
+
wouldn't clean up on its own.
|
|
362
|
+
|
|
363
|
+
## Architecture
|
|
364
|
+
|
|
365
|
+
- **`crates/wickchart-core`** (Rust → WASM): owns the one numeric hot path that's actually
|
|
366
|
+
the charting engine's own — domain→pixel scaling over large series, where avoiding JS
|
|
367
|
+
interpreter overhead shows up in a profile. Deliberately not indicator math; see
|
|
368
|
+
"Indicators" below.
|
|
369
|
+
- **`src/`** (TypeScript): the public API and the Canvas2D renderer.
|
|
370
|
+
- `WickChart` owns the canvas, event wiring (pan/zoom/price-axis drag/hover), on-demand
|
|
371
|
+
data loading, and the render loop. None of it knows what's actually being plotted — see
|
|
372
|
+
"Series types" below.
|
|
373
|
+
- `Viewport` is the pure pan/zoom/value-range state — no DOM, fully unit tested. "Value"
|
|
374
|
+
is deliberately generic (`valueRangeOverride`, `scaleValueRange`, ...): it's whatever
|
|
375
|
+
the active series's y-domain is, price for candlesticks and no different in kind for
|
|
376
|
+
a future series with its own value domain.
|
|
377
|
+
- `setDataLoader()` lets the chart pull more history on demand as the user pans toward
|
|
378
|
+
either edge of what's loaded, without the library ever making a network call itself —
|
|
379
|
+
see `src/dataSource.ts`.
|
|
380
|
+
- Small series use a plain-JS `LinearScale` (see `src/scale.ts`); at
|
|
381
|
+
`WASM_SCALE_THRESHOLD` points (see `src/hybridScale.ts`) the renderer switches to the
|
|
382
|
+
compiled WASM `Scale` instead, batching each frame's coordinate mapping into one
|
|
383
|
+
`mapMany` call per array rather than one JS↔WASM crossing per point.
|
|
384
|
+
|
|
385
|
+
The WASM module loads in the background the moment a `WickChart` is constructed
|
|
386
|
+
(`src/wasm.ts` + `src/wasmImporter.ts`) and is never awaited on the render path — every
|
|
387
|
+
frame before it resolves just uses the JS scale, so there's no load-time flash or blocking.
|
|
388
|
+
|
|
389
|
+
### Engine-level styling vs. series style
|
|
390
|
+
|
|
391
|
+
`ChartRenderer` resolves `WickChartOptions.font`/`axis`/`crosshair`/`legend` once, in its
|
|
392
|
+
constructor (each merged field-by-field over its own `DEFAULT_*` object in `renderer.ts`),
|
|
393
|
+
into private fields it reads from everywhere it used to reference a module-level constant —
|
|
394
|
+
axis strip sizing (`chartWidth`/`chartHeight` derive from `axis.priceWidth`/`timeHeight`
|
|
395
|
+
instead of fixed numbers), tick counts, every color, every font string, crosshair label
|
|
396
|
+
padding. None of it is series-specific: a future line series draws through the exact same
|
|
397
|
+
axes, crosshair, and legend chrome a candlestick chart does, so this styling lives one level
|
|
398
|
+
above `SeriesDefinition`, not inside it. `CandlestickStyle` (`src/series/candlestick.ts`) is
|
|
399
|
+
the series-level counterpart — `bodyWidthRatio`, `volumeAreaHeightRatio`, `volumeBarOpacity`
|
|
400
|
+
alongside the original `upColor`/`downColor` — for the handful of things that only make sense
|
|
401
|
+
for *this* series (a line series wouldn't have a body width or volume bars to configure).
|
|
402
|
+
|
|
403
|
+
### Series types
|
|
404
|
+
|
|
405
|
+
Candlesticks are the only chart type today, but nothing above `src/series/` knows that.
|
|
406
|
+
`WickChart` and `ChartRenderer` are generic over a point shape (`SeriesPoint` — just a
|
|
407
|
+
`time`) and delegate every type-specific decision — how to compute the value-axis range,
|
|
408
|
+
how to draw the visible points, what a hover legend says — to a
|
|
409
|
+
`SeriesDefinition` (see `src/series/types.ts`) resolved at construction time from
|
|
410
|
+
`options.type` via a small registry (`src/series/registry.ts`). `src/series/candlestick.ts`
|
|
411
|
+
is the reference implementation: it registers itself as `'candlestick'` on import, which is
|
|
412
|
+
why importing `wick-charts` at all is enough to make that type available without the caller
|
|
413
|
+
registering anything.
|
|
414
|
+
|
|
415
|
+
Adding a second chart type (line, area, bar, ...) means writing one new file that
|
|
416
|
+
implements `SeriesDefinition<TPoint, TStyle>` and calling `registerSeries` on it — `Viewport`,
|
|
417
|
+
event handling, data loading, and WASM scale dispatch are all untouched, and existing
|
|
418
|
+
`type: 'candlestick'` charts keep working exactly as before. This is the extension point the
|
|
419
|
+
`type` option and `style` option are built around: `style` is whatever shape the chosen
|
|
420
|
+
series's `defaultStyle` declares (candlestick's is `{ upColor, downColor }`), merged over
|
|
421
|
+
that default rather than hardcoded into the chart itself.
|
|
422
|
+
|
|
423
|
+
`options.type` is a plain string the registry resolves at runtime, so `new WickChart(canvas,
|
|
424
|
+
{ type: 'candlestick', style: {...} })` type-checks even if `style` has nothing to do with
|
|
425
|
+
`CandlestickStyle` — nothing ties a runtime string to a specific `TPoint`/`TStyle` pair at the
|
|
426
|
+
type level. `createCandlestickChart()` (in `src/index.ts`) is the fix for the one built-in
|
|
427
|
+
type: a thin wrapper that pins both generics so its `style` is fully checked. A new series
|
|
428
|
+
should export an equivalent `create<Name>Chart` next to it rather than widening
|
|
429
|
+
`WickChartOptions` itself, so each series's style shape stays independent of every other's.
|
|
430
|
+
|
|
431
|
+
### Plugins (markers, annotations, drawing tools)
|
|
432
|
+
|
|
433
|
+
A second, narrower extension point covers anything drawn *on top of* a chart without being
|
|
434
|
+
a chart type of its own — price markers, alert lines, annotations, indicator overlays.
|
|
435
|
+
`WickChart.addPlugin()` registers an object implementing `ChartPlugin<TPoint>`
|
|
436
|
+
(`src/plugins/types.ts`); `ChartRenderer` calls its `draw()` once per frame, after the series
|
|
437
|
+
and axes, with a `PluginRenderApi<TPoint>` built fresh from that frame's own pan/zoom state
|
|
438
|
+
(`xForIndex`, `yForValue`, chart geometry, plus `allPoints` — the full loaded series, not just
|
|
439
|
+
what's visible, and `visibleStartIndex`/`visibleEndIndex` to know which of it is on screen).
|
|
440
|
+
|
|
441
|
+
Each plugin's `draw()` runs wrapped in its own `ctx.save()`/`ctx.restore()` and its own
|
|
442
|
+
`try`/`catch`: a plugin that leaves canvas state dirty (`strokeStyle`, line dash, ...) can't
|
|
443
|
+
bleed it into the next plugin or into next frame's axes, and a plugin that throws gets logged
|
|
444
|
+
via `console.error` and skipped rather than blanking the rest of the chart. `yForValue` (and,
|
|
445
|
+
by contract, `xForIndex`) must only be called synchronously inside that one `draw()` call —
|
|
446
|
+
above the `WASM_SCALE_THRESHOLD` point count, `yForValue` closes over a WASM-backed scale
|
|
447
|
+
that's freed the moment `draw()` returns, and calling it later throws rather than touching
|
|
448
|
+
freed memory.
|
|
449
|
+
|
|
450
|
+
A `ChartPlugin` that only implements `draw()` covers markers and indicator overlays — anything
|
|
451
|
+
purely computed from data. A drawing tool (a trend line the user places by dragging) needs
|
|
452
|
+
two things `draw()` alone can't give it, both added specifically to make that buildable:
|
|
453
|
+
|
|
454
|
+
- **Inverse coordinate mapping** — `PluginRenderApi.indexForX`/`valueForY`, the exact
|
|
455
|
+
inverses of `xForIndex`/`yForValue` (`xForIndex(indexForX(x)) === x`). Computed directly
|
|
456
|
+
from the same `valueMin`/`valueMax`/`chartHeight`/`slotWidth` the forward direction already
|
|
457
|
+
uses — no changes to `Scale`/`hybridScale.ts`/the WASM crate were needed, since inverting a
|
|
458
|
+
pointer position happens on user gestures, not once per point per frame, so it was never a
|
|
459
|
+
case the batched/WASM-accelerated path was for.
|
|
460
|
+
- **Pointer gesture claiming** — `ChartPlugin.onPointerDown`/`onPointerMove`/`onPointerUp`.
|
|
461
|
+
`WickChart` offers every pointer-down inside the chart area (never the price-axis strip)
|
|
462
|
+
to its plugins in reverse-registration order *before* deciding its own pan/price-scale
|
|
463
|
+
mode; the first plugin whose `onPointerDown` returns `true` becomes the gesture's sole
|
|
464
|
+
owner (`activeGesturePlugin`) until pointer-up, and the chart's own panning/hover is
|
|
465
|
+
suppressed for that gesture entirely. A second touch landing mid-gesture ends it early
|
|
466
|
+
(calls `onPointerUp`) the same way it already cancelled an in-progress scrub. Each
|
|
467
|
+
`ChartPointerEvent` carries the raw pixel position plus the same `index`/`value` conversion
|
|
468
|
+
`indexForX`/`valueForY` do, computed via `frameValueRange()` — the same value-range logic
|
|
469
|
+
`ChartRenderer.render` uses, recomputed on demand since pointer events happen between
|
|
470
|
+
frames, not during one.
|
|
471
|
+
|
|
472
|
+
### Indicators (moving averages, Bollinger Bands, ...): deliberately not included
|
|
473
|
+
|
|
474
|
+
wick-charts ships the extension point (`ChartPlugin`, `allPoints`, `xForIndex`/`yForValue`)
|
|
475
|
+
and nothing built on top of it. This was a real decision, not an oversight — charting
|
|
476
|
+
libraries generally land somewhere on a spectrum: some ship no indicators at all, only a
|
|
477
|
+
generic primitive/plugin API plus docs on building your own, leaving actual indicators to a
|
|
478
|
+
community ecosystem; some bundle dozens directly into the core with a registration escape
|
|
479
|
+
hatch for custom ones; some ship official indicators the vendor maintains, but as separate
|
|
480
|
+
opt-in modules on top of a public extension class, so a consumer who never touches indicators
|
|
481
|
+
never pays for them; and some have no indicator concept at all, treating an indicator as
|
|
482
|
+
nothing more than an ordinary dataset the application computes and plots itself.
|
|
483
|
+
|
|
484
|
+
wick-charts follows the first pattern: indicator math has too many real conventions (SMA vs.
|
|
485
|
+
EMA, population vs. sample standard deviation, Wilder's smoothing for RSI, ...) for a charting
|
|
486
|
+
engine to pick one and call it correct for everyone, and every one bundled is one more thing
|
|
487
|
+
this library has to maintain forever. `demo/index.html` has a from-scratch moving-average
|
|
488
|
+
`ChartPlugin` as a worked example of what building one looks like — period and color included,
|
|
489
|
+
entirely in application code, not imported from the library.
|
|
490
|
+
|
|
491
|
+
## Development
|
|
492
|
+
|
|
493
|
+
Building from source — for contributors, or if you'd rather depend on a local checkout than
|
|
494
|
+
the published package:
|
|
495
|
+
|
|
496
|
+
```bash
|
|
497
|
+
git clone https://github.com/eatnows/wick-charts.git
|
|
498
|
+
cd wick-charts
|
|
499
|
+
pnpm install
|
|
500
|
+
|
|
501
|
+
# TypeScript
|
|
502
|
+
pnpm build:wasm # wasm-pack build → wasm-pkg/ (gitignored, regenerate after touching the Rust crate)
|
|
503
|
+
pnpm test # vitest
|
|
504
|
+
pnpm build # tsc
|
|
505
|
+
pnpm demo # builds both, then serves demo/index.html locally
|
|
506
|
+
|
|
507
|
+
# Rust
|
|
508
|
+
cargo test # native unit tests
|
|
509
|
+
cargo check --target wasm32-unknown-unknown # compiles for the wasm target
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
`pnpm build` cleans `dist/` first, so it's safe to rerun after removing or renaming source
|
|
513
|
+
files — nothing compiled from a deleted file lingers into the next build.
|
|
514
|
+
|
|
515
|
+
## Contributing
|
|
516
|
+
|
|
517
|
+
Issues and pull requests are welcome — for anything nontrivial, opening an issue first to
|
|
518
|
+
talk through the approach is appreciated, especially around the "core only, no built-in
|
|
519
|
+
indicators/drawing tools" boundary described above, since that's a deliberate design stance
|
|
520
|
+
rather than a gap waiting to be filled. Run the checks above (`pnpm test`, `pnpm build`, and
|
|
521
|
+
`cargo test`/`cargo check` if the Rust crate changed) before opening a PR — there's no CI
|
|
522
|
+
configured yet, so these are the same checks a maintainer will run by hand.
|
|
523
|
+
|
|
524
|
+
## Status
|
|
525
|
+
|
|
526
|
+
Interactive on both mouse and touch: pan (drag or horizontal scroll/swipe), zoom (vertical
|
|
527
|
+
scroll or a two-finger pinch, both cursor/midpoint-anchored), price-axis drag-to-scale,
|
|
528
|
+
hover crosshair with an OHLC(+volume) legend and axis labels (the horizontal line and its
|
|
529
|
+
price-axis label follow the actual cursor/finger row continuously, not a fixed value like the
|
|
530
|
+
hovered candle's close — a full date+time label follows the hovered candle on the time axis) —
|
|
531
|
+
a still finger held past a short delay substitutes for hover on touch, since touch has no
|
|
532
|
+
hover state — and on-demand history loading via `setDataLoader`. Per-candle volume bars draw in
|
|
533
|
+
the bottom fifth of the chart when a candle has `volume`, and are entirely omitted (nothing
|
|
534
|
+
drawn, nothing reserved) for data that doesn't.
|
|
535
|
+
Coordinate scaling runs on WASM once a frame's point count crosses the threshold, JS below
|
|
536
|
+
it. Candlestick is the only registered series type so far; the plugin extension point (draw
|
|
537
|
+
overlays plus, now, claimable pointer gestures for interactive tools — see "Plugins" above)
|
|
538
|
+
has no built-in users (see "Indicators" above for why) beyond `demo/index.html`'s example. No
|
|
539
|
+
concrete drawing tool ships yet, only the mechanism a trend line or similar would be built
|
|
540
|
+
on. No multi-pane support yet (volume shares the candlestick pane rather than getting its
|
|
541
|
+
own). See [CHANGELOG.md](./CHANGELOG.md) for what shipped in each release.
|
|
542
|
+
|
|
543
|
+
## License
|
|
544
|
+
|
|
545
|
+
[MIT](./LICENSE)
|
package/dist/axis.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Picks a label granularity from how wide a time range the axis is
|
|
3
|
+
* covering — not from the interval between individual points, since a
|
|
4
|
+
* daily chart over a week and an hourly chart over a week should both
|
|
5
|
+
* show dates, not times.
|
|
6
|
+
*/
|
|
7
|
+
export declare function formatAxisLabel(unixSeconds: number, spanSeconds: number): string;
|
|
8
|
+
/**
|
|
9
|
+
* Full "YYYY-MM-DD HH:mm" for a single hovered instant — unlike
|
|
10
|
+
* `formatAxisLabel`, which trims precision to fit a shared span of tick
|
|
11
|
+
* labels, a crosshair label describes exactly one point and has no
|
|
12
|
+
* neighbors to stay legible next to, so it always shows the full date and
|
|
13
|
+
* time regardless of how zoomed in or out the chart is.
|
|
14
|
+
*/
|
|
15
|
+
export declare function formatHoverTime(unixSeconds: number): string;
|
|
16
|
+
/**
|
|
17
|
+
* Evenly-spaced indices into a `length`-long series, capped at `maxTicks`.
|
|
18
|
+
* Used to decide which points get an axis label — labeling every point
|
|
19
|
+
* would overlap into unreadable mush on anything but a tiny series.
|
|
20
|
+
*/
|
|
21
|
+
export declare function pickTickIndices(length: number, maxTicks: number): number[];
|
package/dist/axis.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const SECONDS_PER_DAY = 86400;
|
|
2
|
+
const SECONDS_PER_90_DAYS = 90 * SECONDS_PER_DAY;
|
|
3
|
+
/**
|
|
4
|
+
* Picks a label granularity from how wide a time range the axis is
|
|
5
|
+
* covering — not from the interval between individual points, since a
|
|
6
|
+
* daily chart over a week and an hourly chart over a week should both
|
|
7
|
+
* show dates, not times.
|
|
8
|
+
*/
|
|
9
|
+
export function formatAxisLabel(unixSeconds, spanSeconds) {
|
|
10
|
+
const iso = new Date(unixSeconds * 1000).toISOString();
|
|
11
|
+
if (spanSeconds <= SECONDS_PER_DAY)
|
|
12
|
+
return iso.slice(11, 16); // HH:mm
|
|
13
|
+
if (spanSeconds <= SECONDS_PER_90_DAYS)
|
|
14
|
+
return iso.slice(5, 10); // MM-DD
|
|
15
|
+
return iso.slice(0, 7); // YYYY-MM
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Full "YYYY-MM-DD HH:mm" for a single hovered instant — unlike
|
|
19
|
+
* `formatAxisLabel`, which trims precision to fit a shared span of tick
|
|
20
|
+
* labels, a crosshair label describes exactly one point and has no
|
|
21
|
+
* neighbors to stay legible next to, so it always shows the full date and
|
|
22
|
+
* time regardless of how zoomed in or out the chart is.
|
|
23
|
+
*/
|
|
24
|
+
export function formatHoverTime(unixSeconds) {
|
|
25
|
+
const iso = new Date(unixSeconds * 1000).toISOString();
|
|
26
|
+
return `${iso.slice(0, 10)} ${iso.slice(11, 16)}`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Evenly-spaced indices into a `length`-long series, capped at `maxTicks`.
|
|
30
|
+
* Used to decide which points get an axis label — labeling every point
|
|
31
|
+
* would overlap into unreadable mush on anything but a tiny series.
|
|
32
|
+
*/
|
|
33
|
+
export function pickTickIndices(length, maxTicks) {
|
|
34
|
+
if (length <= 0)
|
|
35
|
+
return [];
|
|
36
|
+
if (length <= maxTicks)
|
|
37
|
+
return Array.from({ length }, (_, i) => i);
|
|
38
|
+
const step = (length - 1) / (maxTicks - 1);
|
|
39
|
+
const indices = new Set();
|
|
40
|
+
for (let i = 0; i < maxTicks; i++) {
|
|
41
|
+
indices.add(Math.round(i * step));
|
|
42
|
+
}
|
|
43
|
+
return Array.from(indices).sort((a, b) => a - b);
|
|
44
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Candle, SeriesPoint } from './types.js';
|
|
2
|
+
export interface DataRequest {
|
|
3
|
+
/** 'before' = the user panned toward older history; 'after' = toward
|
|
4
|
+
* newer/future data. */
|
|
5
|
+
direction: 'before' | 'after';
|
|
6
|
+
/** Unix seconds. For 'before', return points with time < boundary; for
|
|
7
|
+
* 'after', time > boundary. This is always the earliest ('before') or
|
|
8
|
+
* latest ('after') time currently loaded — never a guess. */
|
|
9
|
+
boundary: number;
|
|
10
|
+
/** A hint for how many points would satisfy this request, not a hard
|
|
11
|
+
* requirement — the loader may return more, fewer, or none (an empty
|
|
12
|
+
* array/resolved-empty tells the chart "no more data in this direction,"
|
|
13
|
+
* and it stops asking until `setData` resets that). */
|
|
14
|
+
count: number;
|
|
15
|
+
}
|
|
16
|
+
/** Given a request, resolve with the points that satisfy it (any order,
|
|
17
|
+
* any overlap with what's already loaded — `mergeSeriesPoints` handles
|
|
18
|
+
* both). Sync or async; the chart awaits either. Errors are swallowed by
|
|
19
|
+
* the chart (a failed fetch just means it tries again next time the
|
|
20
|
+
* viewport crosses the threshold) — callers that need to surface failures
|
|
21
|
+
* should catch inside the loader and resolve `[]`. Generic over `TPoint`
|
|
22
|
+
* (defaulted to `Candle`) so a future non-candle series can use the same
|
|
23
|
+
* on-demand loading mechanism. */
|
|
24
|
+
export type DataLoader<TPoint extends SeriesPoint = Candle> = (request: DataRequest) => TPoint[] | Promise<TPoint[]>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|