emberwick 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Emberwick contributors
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,530 @@
1
+ # Emberwick
2
+
3
+ A smooth-flowing candlestick chart for the web. Canvas-rendered, framework-free,
4
+ and driven by a pluggable data feed — drop it into any financial frontend and
5
+ point it at your own market data.
6
+
7
+ - **Zero dependencies.** The core imports nothing but DOM and Canvas APIs.
8
+ - **Framework-agnostic.** Works in React, Vue, Svelte, or a plain `<script type="module">`.
9
+ - **Actually smooth.** Ticks ease into the forming candle, the price axis glides
10
+ to new bounds, zoom is cursor-anchored and eased, panning has inertia.
11
+ - **Fast on big data.** One `requestAnimationFrame` loop, dirty-flag driven,
12
+ with visible-range culling — 500k bars loaded costs only the ~200 on screen.
13
+
14
+ ---
15
+
16
+ ## Installing
17
+
18
+ ### From npm
19
+
20
+ ```bash
21
+ npm install emberwick
22
+ ```
23
+
24
+ ```js
25
+ import { createChart, RandomFeed } from 'emberwick'
26
+ ```
27
+
28
+ > **Not published yet.** The package builds and packs (`npm run release`), but
29
+ > it has not been pushed to the registry, and the name has not been confirmed
30
+ > available — run `npm view emberwick` before relying on it.
31
+
32
+ ### From a CDN, no build step
33
+
34
+ ```html
35
+ <div id="chart" style="height: 480px"></div>
36
+ <script src="https://unpkg.com/emberwick/umd/emberwick.umd.js"></script>
37
+ <script>
38
+ const chart = Emberwick.createChart(document.getElementById('chart'))
39
+ chart.setFeed(new Emberwick.RandomFeed({ timeframe: 60000, speed: 60 }))
40
+ </script>
41
+ ```
42
+
43
+ The UMD build is core-only and exposes the global `Emberwick`.
44
+
45
+ ### By vendoring the source
46
+
47
+ Nothing stops you copying the core in directly:
48
+
49
+ ```bash
50
+ cp -r src/chart /path/to/your-project/src/emberwick
51
+ ```
52
+
53
+ ```js
54
+ import { createChart, RandomFeed } from './emberwick/index.js'
55
+ ```
56
+
57
+ The folder is self-contained — `src/chart/` has no imports that point outside
58
+ itself, and no framework dependency. Anything that can bundle ES modules
59
+ (Vite, webpack, Rollup, esbuild, or a browser with native ESM) can consume it
60
+ as-is.
61
+
62
+ ### Entry points
63
+
64
+ | Import | Contents |
65
+ |---|---|
66
+ | `emberwick` | The core: `createChart`, `Chart`, feeds, themes, motion primitives |
67
+ | `emberwick/react` | `<EmberwickChart />` React component |
68
+ | `emberwick/webcomponent` | Registers `<emberwick-chart>` (side-effecting import) |
69
+ | `emberwick/umd` | Single-file UMD build for `<script>` tags |
70
+
71
+ TypeScript declarations ship for all three entries.
72
+
73
+ ---
74
+
75
+ ## Quick start
76
+
77
+ ```js
78
+ import { createChart, RandomFeed } from 'emberwick'
79
+
80
+ const chart = createChart(document.getElementById('chart'))
81
+ await chart.setFeed(new RandomFeed({ timeframe: 60_000 }))
82
+ ```
83
+
84
+ The container needs a real size — the chart fills it and follows resizes:
85
+
86
+ ```html
87
+ <div id="chart" style="width: 100%; height: 480px"></div>
88
+ ```
89
+
90
+ > The container's `position` is set to `relative` automatically if it is
91
+ > `static`, because the canvas layers are absolutely positioned inside it.
92
+
93
+ Without a feed, push bars in directly:
94
+
95
+ ```js
96
+ const chart = createChart(el)
97
+ chart.setData(bars) // Bar[], ascending by time
98
+ chart.update(formingBar) // merge a tick into the last candle (animates)
99
+ chart.append(newBar) // open a new candle
100
+ ```
101
+
102
+ ---
103
+
104
+ ## The bar shape
105
+
106
+ One contract, used everywhere:
107
+
108
+ ```js
109
+ {
110
+ time: 1737024000000, // ms epoch, start of the bar
111
+ open: 100.2,
112
+ high: 101.4,
113
+ low: 99.8,
114
+ close: 100.9,
115
+ volume: 1420,
116
+ }
117
+ ```
118
+
119
+ Bars must be **ascending by time** and **de-duplicated**. The chart infers the
120
+ timeframe from the gap between the first two bars (or from `feed.timeframe`).
121
+
122
+ ---
123
+
124
+ ## Plugging in your own data — the `DataFeed` interface
125
+
126
+ This is the seam the whole library is built around. The core never knows where
127
+ bars come from: implement two methods and it works.
128
+
129
+ ```js
130
+ import { DataFeed } from 'emberwick'
131
+
132
+ class MyApiFeed extends DataFeed {
133
+ constructor() {
134
+ super({ symbol: 'AAPL', timeframe: 60_000 })
135
+ }
136
+
137
+ // Historical bars ENDING at `to` (exclusive). Return [] when exhausted.
138
+ async getBars({ symbol, timeframe, to, limit }) {
139
+ const qs = new URLSearchParams({ symbol, tf: timeframe, limit })
140
+ if (to) qs.set('to', to)
141
+ const res = await fetch(`/api/candles?${qs}`)
142
+ return res.json() // Bar[], ascending by time
143
+ }
144
+
145
+ // Live updates. Return an unsubscribe function.
146
+ subscribe(handler) {
147
+ const ws = new WebSocket('wss://example.com/stream')
148
+ ws.onmessage = (e) => {
149
+ const { bar, closed } = JSON.parse(e.data)
150
+ handler({ type: closed ? 'append' : 'update', bar })
151
+ }
152
+ return () => ws.close()
153
+ }
154
+ }
155
+
156
+ await chart.setFeed(new MyApiFeed())
157
+ ```
158
+
159
+ ### Feed contract
160
+
161
+ | Member | Required | Purpose |
162
+ |---|---|---|
163
+ | `symbol` | yes | Passed back to you in `getBars` |
164
+ | `timeframe` | yes | Bar duration in ms; sets the chart's time axis |
165
+ | `getBars({ symbol, timeframe, to, limit })` | yes | `Promise<Bar[]>`. `to: null` means "most recent". Return `[]` to signal no more history |
166
+ | `subscribe(handler)` | for live data | Returns an unsubscribe function |
167
+ | `prime(lastBar)` | optional | Called once after history loads, so the feed can seed its forming candle |
168
+ | `destroy()` | optional | Your own cleanup |
169
+
170
+ ### Update messages
171
+
172
+ ```js
173
+ handler({ type: 'update', bar }) // forming candle changed → animates
174
+ handler({ type: 'append', bar }) // a new candle opened
175
+ ```
176
+
177
+ `update` is the one that produces the flowing motion. Send it as often as your
178
+ feed ticks — 500 messages between two frames still cost exactly one repaint,
179
+ because rendering is decoupled from data arrival.
180
+
181
+ ### Lazy history
182
+
183
+ When the user pans to within **80 bars** of the left edge, the chart calls
184
+ `getBars({ to: oldestLoadedTime })` and prepends the result, holding the view
185
+ on the same bars. Return an empty array and it stops asking permanently.
186
+
187
+ ---
188
+
189
+ ## API
190
+
191
+ ### `createChart(container, options?)`
192
+
193
+ Returns a `Chart`. (`new Chart(container, options)` is equivalent.)
194
+
195
+ ```js
196
+ const chart = createChart(el, {
197
+ theme: { background: '#000', up: '#00d68f' },
198
+ volumeRatio: 0.18, // fraction of height for the volume strip
199
+ magnet: true, // crosshair snaps to nearest OHLC
200
+ animate: true, // live-candle easing
201
+ initialBars: 1500, // first getBars() page size
202
+ timeScale: { spacing: 9, minSpacing: 0.8, maxSpacing: 160, rightOffset: 12 },
203
+ priceScale: { mode: 'linear', tau: 120, marginTop: 0.12, marginBottom: 0.12 },
204
+ })
205
+ ```
206
+
207
+ ### Methods
208
+
209
+ | Method | Description |
210
+ |---|---|
211
+ | `setData(bars)` | Replace all bars and snap to the right edge |
212
+ | `update(bar)` | Merge a tick into the forming candle (animated) |
213
+ | `append(bar)` | Open a new candle |
214
+ | `setFeed(feed)` | `async` — loads history, then subscribes. Detaches any previous feed |
215
+ | `detachFeed()` | Unsubscribe, keep the bars on screen |
216
+ | `subscribe('crosshair', fn)` | Returns an unsubscribe fn. See events below |
217
+ | `setTheme(partial)` | Merge theme keys and repaint |
218
+ | `setPriceMode(mode)` | `'linear'` or `'log'` |
219
+ | `setAnimate(bool)` | Toggle live-candle easing |
220
+ | `setMagnet(bool)` | Toggle crosshair OHLC snapping |
221
+ | `snapToRealtime()` | Jump back to the newest bar and re-enable autoscale |
222
+ | `toImage()` | PNG data URL of the composited layers |
223
+ | `destroy()` | Remove listeners, stop the loop, drop canvases |
224
+ | `chart.fps` | Getter — measured frames per second |
225
+
226
+ ### Events
227
+
228
+ ```js
229
+ const off = chart.subscribe('crosshair', (payload) => {
230
+ if (!payload) return // pointer left the plot
231
+ const { index, bar, price } = payload
232
+ legend.textContent = `O ${bar.open} H ${bar.high} L ${bar.low} C ${bar.close}`
233
+ })
234
+ off() // unsubscribe
235
+ ```
236
+
237
+ `'crosshair'` is the only event that currently fires. See *Known gaps* below.
238
+
239
+ ---
240
+
241
+ ## Interaction
242
+
243
+ | Input | Action |
244
+ |---|---|
245
+ | Drag in plot | Pan (with inertia on release) |
246
+ | Wheel | Zoom, anchored on the cursor |
247
+ | Two-finger pinch | Zoom |
248
+ | Drag price axis | Stretch the price scale |
249
+ | Drag time axis | Zoom the time scale |
250
+ | Double-click | Snap back to realtime |
251
+ | `←` / `→` | Pan (hold `Shift` for a bigger step) |
252
+ | `+` / `-` | Zoom |
253
+
254
+ The container gets `tabindex="0"` if it has none, so keyboard nav works
255
+ without extra markup.
256
+
257
+ ---
258
+
259
+ ## Theming
260
+
261
+ Pass any subset of the theme keys; the rest fall back to `defaultTheme`.
262
+
263
+ ```js
264
+ import { defaultTheme, lightTheme } from 'emberwick'
265
+
266
+ chart.setTheme(lightTheme)
267
+ chart.setTheme({ up: '#00d68f', down: '#ff5c5c', background: '#0b0e14' })
268
+ ```
269
+
270
+ Available keys: `background`, `grid`, `axisLine`, `text`, `textStrong`, `up`,
271
+ `down`, `upFill`, `downFill`, `wickUp`, `wickDown`, `volumeUp`, `volumeDown`,
272
+ `crosshair`, `labelBg`, `labelText`, `tagText`, `font`, `priceAxisWidth`,
273
+ `timeAxisHeight`.
274
+
275
+ ---
276
+
277
+ ## Framework integration
278
+
279
+ ### React — the bundled adapter
280
+
281
+ ```jsx
282
+ import { EmberwickChart } from 'emberwick/react'
283
+ import { RandomFeed } from 'emberwick'
284
+ import { useMemo, useRef } from 'react'
285
+
286
+ export function Chart() {
287
+ // Memoise the feed — a new instance re-loads history.
288
+ const feed = useMemo(() => new RandomFeed({ timeframe: 60_000, speed: 60 }), [])
289
+ const ref = useRef(null)
290
+
291
+ return (
292
+ <div style={{ height: 480 }}>
293
+ <EmberwickChart
294
+ ref={ref}
295
+ feed={feed}
296
+ priceMode="linear"
297
+ theme={{ up: '#00d68f' }}
298
+ onCrosshair={(p) => p && console.log(p.bar)}
299
+ />
300
+ <button onClick={() => ref.current.chart.snapToRealtime()}>Realtime</button>
301
+ </div>
302
+ )
303
+ }
304
+ ```
305
+
306
+ Props: `data`, `feed`, `options`, `theme`, `priceMode`, `animate`, `magnet`,
307
+ `onCrosshair`, `className`, `style`. Any other prop is spread onto the host
308
+ `<div>`. The ref exposes `{ chart }` for imperative calls.
309
+
310
+ The component creates the chart **once** and drives it through its methods on
311
+ prop changes — it never rebuilds the canvas, so pan/zoom position and animation
312
+ state survive re-renders. `destroy()` runs on unmount, so React 18 StrictMode
313
+ double-mounting is safe.
314
+
315
+ ### React — by hand
316
+
317
+ The adapter is ~100 lines of `useEffect`; doing it yourself is fine:
318
+
319
+ ```jsx
320
+ import { useEffect, useRef } from 'react'
321
+ import { createChart, RandomFeed } from 'emberwick'
322
+
323
+ export function Chart() {
324
+ const hostRef = useRef(null)
325
+
326
+ useEffect(() => {
327
+ const chart = createChart(hostRef.current)
328
+ const feed = new RandomFeed({ timeframe: 60_000 })
329
+ chart.setFeed(feed)
330
+ return () => { feed.destroy(); chart.destroy() }
331
+ }, [])
332
+
333
+ return <div ref={hostRef} style={{ width: '100%', height: 480 }} />
334
+ }
335
+ ```
336
+
337
+ ### Web Component — works in any framework
338
+
339
+ ```html
340
+ <script type="module">
341
+ import 'emberwick/webcomponent'
342
+ import { RandomFeed } from 'emberwick'
343
+
344
+ const el = document.querySelector('emberwick-chart')
345
+ el.feed = new RandomFeed({ timeframe: 60000, speed: 60 })
346
+ el.addEventListener('crosshair', (e) => console.log(e.detail))
347
+ </script>
348
+
349
+ <emberwick-chart theme="dark" style="height: 480px"></emberwick-chart>
350
+ ```
351
+
352
+ | | |
353
+ |---|---|
354
+ | Attributes | `theme="dark\|light"`, `animate="false"`, `magnet="false"` |
355
+ | Properties | `feed`, `data`, `chart` (read-only) |
356
+ | Events | `crosshair` — `event.detail` is the payload or `null` |
357
+
358
+ The element renders into a shadow root, so host-page CSS can't reposition the
359
+ stacked canvases. Assigning `feed`/`data` before the element upgrades is safe.
360
+ This is the path for Vue, Svelte, Angular or server-rendered templates without
361
+ a framework-specific wrapper.
362
+
363
+ ### Vue 3
364
+
365
+ ```vue
366
+ <script setup>
367
+ import { onMounted, onBeforeUnmount, ref } from 'vue'
368
+ import { createChart, RandomFeed } from 'emberwick'
369
+
370
+ const host = ref(null)
371
+ let chart, feed
372
+
373
+ onMounted(() => {
374
+ chart = createChart(host.value)
375
+ feed = new RandomFeed({ timeframe: 60_000 })
376
+ chart.setFeed(feed)
377
+ })
378
+ onBeforeUnmount(() => { feed?.destroy(); chart?.destroy() })
379
+ </script>
380
+
381
+ <template><div ref="host" style="width: 100%; height: 480px" /></template>
382
+ ```
383
+
384
+ ### Plain HTML
385
+
386
+ ```html
387
+ <div id="chart" style="height: 480px"></div>
388
+ <script type="module">
389
+ import { createChart, RandomFeed } from 'emberwick'
390
+ const chart = createChart(document.getElementById('chart'))
391
+ chart.setFeed(new RandomFeed({ timeframe: 60_000 }))
392
+ </script>
393
+ ```
394
+
395
+ ---
396
+
397
+ ## `RandomFeed` — synthetic data for development
398
+
399
+ Bundled so you can build UI before your backend exists. It's an ordinary
400
+ `DataFeed` implementation with no special privileges.
401
+
402
+ ```js
403
+ new RandomFeed({
404
+ symbol: 'EMBR',
405
+ timeframe: 60_000,
406
+ seed: 7, // deterministic: same seed → same chart
407
+ start: 100, // starting price
408
+ volatility: 0.0022,
409
+ drift: 0.00002,
410
+ ticksPerSecond: 8, // live update rate
411
+ speed: 1, // time multiplier; 60 = one candle per second
412
+ })
413
+ ```
414
+
415
+ Runtime controls: `setSpeed(n)`, `setTicksPerSecond(n)`, `setPaused(bool)`,
416
+ `paused` (getter), `stop()`, `destroy()`.
417
+
418
+ Set `speed: 60` to see the flowing motion immediately instead of waiting a
419
+ minute per candle.
420
+
421
+ ---
422
+
423
+ ## Exports
424
+
425
+ ```js
426
+ import {
427
+ createChart, Chart, // entry point
428
+ DataFeed, RandomFeed, // data layer
429
+ defaultTheme, lightTheme, // themes
430
+ TimeScale, PriceScale, // scales (advanced)
431
+ Smoothed, Tween, Inertia, // motion primitives
432
+ LiveCandle,
433
+ easeOutCubic, easeInOutCubic,
434
+ mulberry32, // seeded PRNG
435
+ version,
436
+ } from 'emberwick'
437
+ ```
438
+
439
+ ---
440
+
441
+ ## How the motion works
442
+
443
+ Three independent mechanisms, which is why it reads as smooth rather than
444
+ merely animated:
445
+
446
+ 1. **Live candle** — each of O/H/L/C eases toward the incoming tick over
447
+ ~55ms, with the wick clamped so it can't invert mid-chase. A brand new
448
+ candle plays a short grow-from-centre animation as it scrolls in.
449
+ 2. **Price axis** — autoscale bounds are smoothed, so a spike glides the axis
450
+ instead of jolting it and losing the reader's place.
451
+ 3. **Time axis** — `spacing` (zoom) and `right` (edge position) are smoothed,
452
+ so wheel zoom eases and new bars slide in. **Dragging deliberately does
453
+ not ease** — easing a drag feels like lag, not polish.
454
+
455
+ All of it runs through `Smoothed`, a frame-rate-independent exponential
456
+ smoother: the same visual speed at 30fps and 144fps. The render loop returns
457
+ whether anything is still animating, so a static chart idles at zero CPU.
458
+
459
+ Rendering is split across three stacked canvases — `base` (grid + axes),
460
+ `main` (candles + volume), `overlay` (crosshair) — so moving the pointer
461
+ repaints only the crosshair, never the candles underneath. All contexts are
462
+ pre-scaled by `devicePixelRatio`, so drawing code works in CSS pixels and
463
+ output stays crisp on retina.
464
+
465
+ ---
466
+
467
+ ## Building the package
468
+
469
+ The repo is both the library and its playground. The playground is an ordinary
470
+ Vite app (`npm run dev` / `npm run build`); the library has its own builds.
471
+
472
+ ```bash
473
+ npm run build:lib # ESM, multi-entry -> dist-lib/{index,react,webcomponent}.js
474
+ npm run build:umd # minified UMD -> dist-lib/umd/emberwick.umd.js
475
+ npm run pack:lib # manifest + README + .d.ts into dist-lib/
476
+ npm run size # gzipped size budget check
477
+ npm run release # all four, in order
478
+ npm publish dist-lib # publish the assembled directory
479
+ ```
480
+
481
+ Publishing from `dist-lib/` keeps the playground, the build configs and the
482
+ app's private `package.json` out of the artifact. `package.lib.json` is the
483
+ manifest that becomes the published `package.json`.
484
+
485
+ React is an **optional peer dependency**, external in every build — importing
486
+ `emberwick` never pulls React in.
487
+
488
+ Source layout:
489
+
490
+ ```
491
+ src/chart/ the library core (zero deps, no framework)
492
+ core/ Chart, Layers, Loop, TimeScale, PriceScale, palette, formatters
493
+ render/ grid, candles, crosshair
494
+ motion/ Tween (Smoothed), Inertia, LiveCandle
495
+ data/ DataFeed (the seam), RandomFeed
496
+ index.js public API surface index.d.ts types
497
+ src/adapters/react/ <EmberwickChart />
498
+ src/adapters/webcomponent/ <emberwick-chart>
499
+ src/App.jsx, src/styles.css the playground (not published)
500
+ ```
501
+
502
+ ---
503
+
504
+ ## Known gaps
505
+
506
+ Honest list of what isn't there yet:
507
+
508
+ - **`subscribe('visibleRange', fn)` never fires.** The event is accepted and
509
+ the handler is stored, but nothing emits it. Use `'crosshair'` for now.
510
+ - **Not published to npm.** The package builds and packs, but it has not been
511
+ pushed, and the name is unverified — check `npm view emberwick` first.
512
+ - **The library builds are unverified.** `npm run build:lib` / `build:umd`
513
+ have not been executed here; this environment only runs the app build. Run
514
+ `npm run release` locally before publishing.
515
+ - **No indicators or drawing tools.** SMA/EMA/RSI/MACD, multi-pane layout and
516
+ trendlines are planned but not implemented.
517
+ - **Candlesticks only.** No Heikin-Ashi, line, area or baseline series yet.
518
+ - **No session-gap collapsing.** Weekends and market closes render as ordinary
519
+ bar-index steps, not gaps.
520
+ - **`RandomFeed` deep history is per-page coherent, not one continuous walk** —
521
+ it regenerates backwards from a seed offset, so paging far left can show a
522
+ visible seam. Real feeds don't have this artifact.
523
+ - **Types are hand-written**, not generated from source, so they can drift
524
+ from the implementation.
525
+
526
+ ---
527
+
528
+ ## License
529
+
530
+ MIT