emberwick 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/README.md +106 -2
- package/index.d.ts +95 -0
- package/index.js +384 -41
- package/index.js.map +1 -1
- package/package.json +1 -1
- package/umd/emberwick.umd.js +1 -1
- package/umd/emberwick.umd.js.map +1 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to Emberwick are documented here.
|
|
4
|
+
This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
|
+
|
|
6
|
+
## [0.4.1] — 2026-09-19
|
|
7
|
+
|
|
8
|
+
Bug-fix release. Three feed-lifecycle races could put wrong prices on screen,
|
|
9
|
+
so this supersedes 0.4.0 for anyone attaching a `DataFeed`.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **A slow `setFeed()` no longer overwrites a newer one.** `setFeed()` awaited
|
|
14
|
+
`getBars()` without recording which call was current, so switching symbols
|
|
15
|
+
faster than the network could resolve let the *earlier* request win: it
|
|
16
|
+
replaced the new symbol's bars, overwrote `_unsub` — leaving the newer
|
|
17
|
+
subscription permanently unreachable — and, because both feeds wrote to the
|
|
18
|
+
bar slot keyed on the last bar's time, made the forming candle alternate
|
|
19
|
+
between two instruments with no error raised. Every async feed read is now
|
|
20
|
+
stamped with a generation that `setFeed()`, `detachFeed()` and `destroy()`
|
|
21
|
+
invalidate.
|
|
22
|
+
- **Lazy history pages are bound to the feed that requested them.** A page in
|
|
23
|
+
flight during a symbol switch could prepend one instrument's bars onto
|
|
24
|
+
another's, and a stale empty page could latch `_exhausted` on the *new*
|
|
25
|
+
symbol, permanently disabling paging for an instrument with history left.
|
|
26
|
+
The page boundary is also re-read after the await, so a late page can no
|
|
27
|
+
longer splice into the middle of the array and break the ascending-by-time
|
|
28
|
+
invariant that marker resolution's binary search depends on.
|
|
29
|
+
- **Prepending history shifts the viewport exactly once.** `Smoothed.jump()`
|
|
30
|
+
writes both `value` and `target`, so the follow-up `set(target + n)` added
|
|
31
|
+
the page size a second time. The view eased past the newest bar, and because
|
|
32
|
+
that pushed the visible range beyond the 80-bar trigger, lazy paging stopped
|
|
33
|
+
firing for the rest of the session — one page load could permanently disable
|
|
34
|
+
the feature.
|
|
35
|
+
- **`destroy()` is idempotent.** A second call threw from `Layers.destroy()`,
|
|
36
|
+
which React 18 StrictMode's double-unmount could reach.
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- **`'error'` event.** `subscribe('error', fn)` receives the thrown value from
|
|
41
|
+
a failed `getBars()`, in both `setFeed()` and history paging. Previously
|
|
42
|
+
`setFeed()` had no error path at all: a rejection escaped as an unhandled
|
|
43
|
+
rejection and left a permanently blank chart that a consumer could not even
|
|
44
|
+
detect. With no subscriber the error is logged rather than swallowed.
|
|
45
|
+
- **First test suite.** `npm test` runs `test/chart-feed-race.test.mjs` on
|
|
46
|
+
plain Node — no jsdom, no browser. Eight of its nine cases fail against
|
|
47
|
+
0.4.0. `npm run release` now runs it before building.
|
|
48
|
+
|
|
49
|
+
### Docs
|
|
50
|
+
|
|
51
|
+
- Documented the `'error'` event and feed-failure handling.
|
|
52
|
+
- Removed a duplicated header row in the events table.
|
|
53
|
+
|
|
54
|
+
## [0.4.0] — 2026-09-16
|
|
55
|
+
|
|
56
|
+
### Added
|
|
57
|
+
|
|
58
|
+
- Bar-by-bar `Replay` with transport, scrubbing, speed control and looping.
|
|
59
|
+
- Annotations: nine marker shapes, price lines and shaded zones, with
|
|
60
|
+
collision-aware stacking and hover/click hit-testing.
|
|
61
|
+
- `'visibleRange'` state event and `chart.visibleRange()`.
|
|
62
|
+
|
|
63
|
+
## [0.1.0] — 2026-09-16
|
|
64
|
+
|
|
65
|
+
First public release: canvas candlestick core, `DataFeed` seam, `RandomFeed`,
|
|
66
|
+
React adapter, web component, UMD build.
|
package/README.md
CHANGED
|
@@ -225,6 +225,10 @@ const chart = createChart(el, {
|
|
|
225
225
|
| `setAnimate(bool)` | Toggle live-candle easing |
|
|
226
226
|
| `setMagnet(bool)` | Toggle crosshair OHLC snapping |
|
|
227
227
|
| `snapToRealtime()` | Jump back to the newest bar and re-enable autoscale |
|
|
228
|
+
| `startReplay(options?)` | Begin bar-by-bar playback. Returns the `Replay`, or `null` if there is nothing to replay |
|
|
229
|
+
| `stopReplay()` | Leave replay and reveal the whole dataset again |
|
|
230
|
+
| `replayState()` | Current playback state. `{ active: false, ... }` when not replaying |
|
|
231
|
+
| `chart.replay` | Getter — the active `Replay` controller, or `null` |
|
|
228
232
|
| `toImage()` | PNG data URL of the composited layers |
|
|
229
233
|
| `destroy()` | Remove listeners, stop the loop, drop canvases |
|
|
230
234
|
| `chart.fps` | Getter — measured frames per second |
|
|
@@ -240,18 +244,39 @@ const off = chart.subscribe('crosshair', (payload) => {
|
|
|
240
244
|
off() // unsubscribe
|
|
241
245
|
```
|
|
242
246
|
|
|
243
|
-
| Event | Payload |
|
|
244
|
-
|---|---|
|
|
245
247
|
| Event | Payload |
|
|
246
248
|
|---|---|
|
|
247
249
|
| `'crosshair'` | `{ index, bar, price }`, or `null` when the pointer leaves the plot |
|
|
248
250
|
| `'markerHover'` | The marker under the pointer, or `null` when none is |
|
|
249
251
|
| `'markerClick'` | The clicked marker. Only fires on a hit, never with `null` |
|
|
250
252
|
| `'visibleRange'` | `{ from, to, fromTime, toTime, barCount, spacing, settled }` |
|
|
253
|
+
| `'replay'` | `{ active, playing, index, length, progress, speed, time, bar, atEnd }` |
|
|
254
|
+
| `'error'` | The thrown value from a failed feed read. See below |
|
|
251
255
|
|
|
252
256
|
A drag that happens to end on top of a marker does not fire `'markerClick'` —
|
|
253
257
|
panning and clicking stay distinct.
|
|
254
258
|
|
|
259
|
+
### Feed errors
|
|
260
|
+
|
|
261
|
+
`getBars()` is your code, so it can reject — a 502, an expired token, an
|
|
262
|
+
aborted request. Both paths that call it (`setFeed()` and the lazy history
|
|
263
|
+
paging) route a rejection to `'error'` rather than letting it escape as an
|
|
264
|
+
unhandled rejection:
|
|
265
|
+
|
|
266
|
+
```js
|
|
267
|
+
chart.subscribe('error', (err) => {
|
|
268
|
+
toast('Could not load market data')
|
|
269
|
+
console.error(err)
|
|
270
|
+
})
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
With no `'error'` subscriber the failure is logged to the console instead of
|
|
274
|
+
vanishing. `setFeed()` itself never rejects, so `await chart.setFeed(feed)`
|
|
275
|
+
is safe to leave unguarded; subscribe to `'error'` to react to the failure.
|
|
276
|
+
|
|
277
|
+
A failed history page sets the same "stop asking" latch an empty page does, so
|
|
278
|
+
the chart will not retry that boundary on every pan.
|
|
279
|
+
|
|
255
280
|
### Tracking the visible range
|
|
256
281
|
|
|
257
282
|
`'visibleRange'` is a **state** event rather than a notification, which makes it
|
|
@@ -289,6 +314,84 @@ payload on demand if you would rather poll than subscribe.
|
|
|
289
314
|
|
|
290
315
|
---
|
|
291
316
|
|
|
317
|
+
## Replay
|
|
318
|
+
|
|
319
|
+
Play a fixed dataset back bar by bar — backtesting playback, a market-open
|
|
320
|
+
recap, a training drill.
|
|
321
|
+
|
|
322
|
+
```js
|
|
323
|
+
chart.setData(bars)
|
|
324
|
+
|
|
325
|
+
const replay = chart.startReplay({ from: 200, speed: 4 })
|
|
326
|
+
replay.play()
|
|
327
|
+
|
|
328
|
+
chart.subscribe('replay', (s) => {
|
|
329
|
+
scrubber.value = s.index
|
|
330
|
+
clock.textContent = new Date(s.time).toLocaleTimeString()
|
|
331
|
+
if (s.atEnd) playBtn.textContent = 'Restart'
|
|
332
|
+
})
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
The chart is never put into a special mode. The controller keeps the dataset
|
|
336
|
+
aside and hands the chart only the **revealed prefix**, so scales, crosshair,
|
|
337
|
+
annotations and `'visibleRange'` behave exactly as they do on live data that
|
|
338
|
+
happens to end at the cursor.
|
|
339
|
+
|
|
340
|
+
Revealing the next bar goes through the same path a feed tick takes, so the
|
|
341
|
+
candle grows in and the axis glides. Scrubbing swaps the prefix and jumps —
|
|
342
|
+
easing a scrub would read as lag, the same rule the pan gesture follows.
|
|
343
|
+
|
|
344
|
+
### `chart.startReplay(options?)`
|
|
345
|
+
|
|
346
|
+
| Option | Default | Notes |
|
|
347
|
+
|---|---|---|
|
|
348
|
+
| `bars` | the chart's current bars | The dataset to replay. Never mutated |
|
|
349
|
+
| `from` | midpoint | Starting cursor index |
|
|
350
|
+
| `speed` | `1` | Multiplier, clamped to `0.25`–`500` |
|
|
351
|
+
| `baseInterval` | `1000` | Real ms one bar takes at 1× |
|
|
352
|
+
| `loop` | `false` | Restart at the end instead of stopping |
|
|
353
|
+
| `follow` | `true` | Re-anchor the right edge on the cursor when scrubbing |
|
|
354
|
+
|
|
355
|
+
Returns the `Replay`, or `null` when there are fewer than two bars. While a
|
|
356
|
+
replay is active an attached feed is ignored, so live ticks cannot fight the
|
|
357
|
+
cursor; `stopReplay()` restores the full dataset and resumes normal service.
|
|
358
|
+
|
|
359
|
+
### Transport
|
|
360
|
+
|
|
361
|
+
Every method returns the controller, so calls chain.
|
|
362
|
+
|
|
363
|
+
| Method | Description |
|
|
364
|
+
|---|---|
|
|
365
|
+
| `play()` / `pause()` / `toggle()` | Pressing play at the end restarts from the beginning |
|
|
366
|
+
| `seek(index)` | Move the cursor. Out-of-range values clamp |
|
|
367
|
+
| `step(n = 1)` | Relative move; `step(-1)` goes back a bar |
|
|
368
|
+
| `toStart()` / `toEnd()` | Jump to either end |
|
|
369
|
+
| `setSpeed(x)` | Clamped to `0.25`–`500`. Never bursts bars on a rate change |
|
|
370
|
+
| `setLoop(bool)` | Toggle looping |
|
|
371
|
+
|
|
372
|
+
Readable state: `index`, `length`, `progress` (0–1), `speed`, `time`, `bar`,
|
|
373
|
+
`atEnd`, `playing`, and `interval` (real ms between bars at the current
|
|
374
|
+
speed).
|
|
375
|
+
|
|
376
|
+
### The `'replay'` event
|
|
377
|
+
|
|
378
|
+
`{ active, playing, index, length, progress, speed, time, bar, atEnd }`.
|
|
379
|
+
|
|
380
|
+
Like `'visibleRange'` it is a **state** event: a new subscriber is called
|
|
381
|
+
immediately, and after `stopReplay()` it fires once with `active: false` so a
|
|
382
|
+
UI can reset itself without special-casing teardown. `chart.replayState()`
|
|
383
|
+
returns the same payload on demand.
|
|
384
|
+
|
|
385
|
+
**Markers after the cursor are hidden, not clamped.** Time→index resolution
|
|
386
|
+
snaps to the nearest bar, so without that filter every future trade would pile
|
|
387
|
+
onto the newest revealed candle — and a replay that shows you tomorrow's
|
|
388
|
+
entries is worse than no replay at all.
|
|
389
|
+
|
|
390
|
+
The cursor never goes below index 1: the scales infer the timeframe from the
|
|
391
|
+
first pair of bars.
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
292
395
|
## Annotations
|
|
293
396
|
|
|
294
397
|
Three independent collections, each replaced wholesale. Zones paint behind the
|
|
@@ -566,6 +669,7 @@ import {
|
|
|
566
669
|
TimeScale, PriceScale, // scales (advanced)
|
|
567
670
|
Smoothed, Tween, Inertia, // motion primitives
|
|
568
671
|
LiveCandle,
|
|
672
|
+
Replay, MIN_SPEED, MAX_SPEED, // bar-by-bar playback
|
|
569
673
|
easeOutCubic, easeInOutCubic,
|
|
570
674
|
mulberry32, // seeded PRNG
|
|
571
675
|
version,
|
package/index.d.ts
CHANGED
|
@@ -289,6 +289,80 @@ export declare class PriceScale {
|
|
|
289
289
|
readonly hi: number
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
+
/* ---------------------------------------------------------------- replay -- */
|
|
293
|
+
|
|
294
|
+
export interface ReplayOptions {
|
|
295
|
+
/** Dataset to replay. Defaults to the chart's current bars. */
|
|
296
|
+
bars?: Bar[]
|
|
297
|
+
/** Starting cursor index. Default: the midpoint of the dataset. */
|
|
298
|
+
from?: number
|
|
299
|
+
/** Rate multiplier, clamped to 0.25–500. Default 1. */
|
|
300
|
+
speed?: number
|
|
301
|
+
/** Real milliseconds one bar takes at 1×. Default 1000. */
|
|
302
|
+
baseInterval?: number
|
|
303
|
+
/** Restart from the beginning instead of stopping at the end. Default false. */
|
|
304
|
+
loop?: boolean
|
|
305
|
+
/** Re-anchor the right edge on the cursor when scrubbing. Default true. */
|
|
306
|
+
follow?: boolean
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Payload of the 'replay' event, and the return of chart.replayState(). */
|
|
310
|
+
export interface ReplayState {
|
|
311
|
+
/** False when the chart is not replaying; every other field is then inert. */
|
|
312
|
+
active: boolean
|
|
313
|
+
playing: boolean
|
|
314
|
+
/** Cursor: index of the newest revealed bar. -1 when inactive. */
|
|
315
|
+
index: number
|
|
316
|
+
/** Size of the dataset being replayed. */
|
|
317
|
+
length: number
|
|
318
|
+
/** 0 at the first playable bar, 1 at the last. */
|
|
319
|
+
progress: number
|
|
320
|
+
speed: number
|
|
321
|
+
/** `time` of the bar at the cursor. */
|
|
322
|
+
time: number | null
|
|
323
|
+
bar: Bar | null
|
|
324
|
+
atEnd: boolean
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Bar-by-bar playback over a fixed dataset. Obtained from
|
|
329
|
+
* `chart.startReplay()` or `chart.replay`; not constructed directly.
|
|
330
|
+
*/
|
|
331
|
+
export declare class Replay {
|
|
332
|
+
readonly source: Bar[]
|
|
333
|
+
readonly length: number
|
|
334
|
+
readonly lastIndex: number
|
|
335
|
+
readonly atEnd: boolean
|
|
336
|
+
readonly bar: Bar | null
|
|
337
|
+
readonly time: number | null
|
|
338
|
+
readonly progress: number
|
|
339
|
+
/** Real ms between bars at the current speed. */
|
|
340
|
+
readonly interval: number
|
|
341
|
+
index: number
|
|
342
|
+
speed: number
|
|
343
|
+
playing: boolean
|
|
344
|
+
looping: boolean
|
|
345
|
+
follow: boolean
|
|
346
|
+
baseInterval: number
|
|
347
|
+
|
|
348
|
+
play(): this
|
|
349
|
+
pause(): this
|
|
350
|
+
toggle(): this
|
|
351
|
+
/** Clamped to 0.25–500. */
|
|
352
|
+
setSpeed(speed: number): this
|
|
353
|
+
setLoop(on: boolean): this
|
|
354
|
+
/** Move the cursor. Out-of-range values clamp. */
|
|
355
|
+
seek(index: number): this
|
|
356
|
+
step(n?: number): this
|
|
357
|
+
toStart(): this
|
|
358
|
+
toEnd(): this
|
|
359
|
+
state(): ReplayState
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Speed bounds accepted by `setSpeed`. */
|
|
363
|
+
export declare const MIN_SPEED: number
|
|
364
|
+
export declare const MAX_SPEED: number
|
|
365
|
+
|
|
292
366
|
/* ----------------------------------------------------------------- chart -- */
|
|
293
367
|
|
|
294
368
|
export declare class Chart {
|
|
@@ -317,6 +391,21 @@ export declare class Chart {
|
|
|
317
391
|
/** Topmost marker under a plot-relative point, else null. */
|
|
318
392
|
markerAt(x: number, y: number): ResolvedMarker | null
|
|
319
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Start bar-by-bar playback. With no `bars`, the chart's current data is
|
|
396
|
+
* the dataset. Returns null if there are fewer than two bars to replay.
|
|
397
|
+
*
|
|
398
|
+
* chart.startReplay({ from: 200, speed: 4 })
|
|
399
|
+
* chart.replay.play()
|
|
400
|
+
*/
|
|
401
|
+
startReplay(options?: ReplayOptions): Replay | null
|
|
402
|
+
/** Leave replay and reveal the whole dataset again. */
|
|
403
|
+
stopReplay(): void
|
|
404
|
+
/** The active controller, or null. */
|
|
405
|
+
readonly replay: Replay | null
|
|
406
|
+
/** Current playback state; `{ active: false, ... }` when not replaying. */
|
|
407
|
+
replayState(): ReplayState
|
|
408
|
+
|
|
320
409
|
setTheme(theme: Partial<Theme>): void
|
|
321
410
|
setPriceMode(mode: PriceMode): void
|
|
322
411
|
setAnimate(on: boolean): void
|
|
@@ -342,6 +431,12 @@ export declare class Chart {
|
|
|
342
431
|
subscribe(event: 'markerClick', fn: (marker: ResolvedMarker) => void): () => void
|
|
343
432
|
subscribe(event: 'markerHover', fn: (marker: ResolvedMarker | null) => void): () => void
|
|
344
433
|
subscribe(event: 'visibleRange', fn: (range: VisibleRangePayload) => void): () => void
|
|
434
|
+
subscribe(event: 'replay', fn: (state: ReplayState) => void): () => void
|
|
435
|
+
/**
|
|
436
|
+
* Feed failures: a rejected `getBars()` from either `setFeed()` or lazy
|
|
437
|
+
* history paging. With no subscriber the error is logged instead.
|
|
438
|
+
*/
|
|
439
|
+
subscribe(event: 'error', fn: (error: unknown) => void): () => void
|
|
345
440
|
|
|
346
441
|
/** Removes listeners, canvases and the render loop. */
|
|
347
442
|
destroy(): void
|