wick-charts 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +545 -0
  3. package/dist/axis.d.ts +21 -0
  4. package/dist/axis.js +44 -0
  5. package/dist/dataSource.d.ts +24 -0
  6. package/dist/dataSource.js +1 -0
  7. package/dist/hitTest.d.ts +31 -0
  8. package/dist/hitTest.js +46 -0
  9. package/dist/hybridScale.d.ts +25 -0
  10. package/dist/hybridScale.js +41 -0
  11. package/dist/index.d.ts +225 -0
  12. package/dist/index.js +715 -0
  13. package/dist/mergeSeries.d.ts +14 -0
  14. package/dist/mergeSeries.js +21 -0
  15. package/dist/plugins/types.d.ts +140 -0
  16. package/dist/plugins/types.js +1 -0
  17. package/dist/priceAxis.d.ts +7 -0
  18. package/dist/priceAxis.js +49 -0
  19. package/dist/priceRange.d.ts +12 -0
  20. package/dist/priceRange.js +16 -0
  21. package/dist/renderer.d.ts +79 -0
  22. package/dist/renderer.js +318 -0
  23. package/dist/scale.d.ts +20 -0
  24. package/dist/scale.js +29 -0
  25. package/dist/series/candlestick.d.ts +20 -0
  26. package/dist/series/candlestick.js +88 -0
  27. package/dist/series/registry.d.ts +13 -0
  28. package/dist/series/registry.js +30 -0
  29. package/dist/series/types.d.ts +56 -0
  30. package/dist/series/types.js +1 -0
  31. package/dist/testHelpers.d.ts +38 -0
  32. package/dist/testHelpers.js +50 -0
  33. package/dist/time.d.ts +6 -0
  34. package/dist/time.js +58 -0
  35. package/dist/types.d.ts +151 -0
  36. package/dist/types.js +1 -0
  37. package/dist/viewport.d.ts +52 -0
  38. package/dist/viewport.js +87 -0
  39. package/dist/wasm.d.ts +29 -0
  40. package/dist/wasm.js +35 -0
  41. package/dist/wasmImporter.d.ts +6 -0
  42. package/dist/wasmImporter.js +7 -0
  43. package/package.json +39 -0
  44. package/wasm-pkg/package.json +21 -0
  45. package/wasm-pkg/wickchart_core.d.ts +59 -0
  46. package/wasm-pkg/wickchart_core.js +227 -0
  47. package/wasm-pkg/wickchart_core_bg.wasm +0 -0
  48. package/wasm-pkg/wickchart_core_bg.wasm.d.ts +11 -0
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
+ [![npm version](https://img.shields.io/npm/v/wick-charts.svg)](https://www.npmjs.com/package/wick-charts)
4
+ [![license](https://img.shields.io/npm/l/wick-charts.svg)](./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 {};