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 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