emberwick 0.2.0 → 0.4.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/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,16 +244,132 @@ const off = chart.subscribe('crosshair', (payload) => {
240
244
  off() // unsubscribe
241
245
  ```
242
246
 
247
+ | Event | Payload |
248
+ |---|---|
243
249
  | Event | Payload |
244
250
  |---|---|
245
251
  | `'crosshair'` | `{ index, bar, price }`, or `null` when the pointer leaves the plot |
246
252
  | `'markerHover'` | The marker under the pointer, or `null` when none is |
247
253
  | `'markerClick'` | The clicked marker. Only fires on a hit, never with `null` |
248
- | `'visibleRange'` | **Accepted but never emitted.** See *Known gaps* |
254
+ | `'visibleRange'` | `{ from, to, fromTime, toTime, barCount, spacing, settled }` |
255
+ | `'replay'` | `{ active, playing, index, length, progress, speed, time, bar, atEnd }` |
249
256
 
250
257
  A drag that happens to end on top of a marker does not fire `'markerClick'` —
251
258
  panning and clicking stay distinct.
252
259
 
260
+ ### Tracking the visible range
261
+
262
+ `'visibleRange'` is a **state** event rather than a notification, which makes it
263
+ usable without any debouncing of your own:
264
+
265
+ - A new subscriber is called **immediately** with the current window, so it
266
+ never has to wait for the user to pan before it knows what is on screen.
267
+ - It then fires **only when the window actually changes**. Indices are
268
+ integers, so a slow pan at 9px/bar produces roughly one event every nine
269
+ frames, not one per frame.
270
+ - `spacing` is reported but is deliberately *not* part of the change test — it
271
+ is a float that moves every frame of an eased zoom, and keying on it would
272
+ turn this into a 60/sec firehose. Use it for level-of-detail decisions.
273
+ - `settled` *is* part of the change test, so the final event of a gesture
274
+ always arrives with `settled: true`. That makes "wait until the view stops
275
+ moving, then do the expensive thing" a safe pattern.
276
+
277
+ ```js
278
+ const off = chart.subscribe('visibleRange', async (r) => {
279
+ // paginate backwards when the user approaches the left edge
280
+ if (r.from < 50 && r.settled && r.fromTime) {
281
+ const older = await myApi.bars({ to: r.fromTime, limit: 500 })
282
+ chart.setData(older.concat(chart.bars))
283
+ }
284
+ })
285
+ ```
286
+
287
+ Syncing a second chart is the other common use — feed `fromTime`/`toTime`
288
+ straight into the other instance. And `chart.visibleRange()` returns the same
289
+ payload on demand if you would rather poll than subscribe.
290
+
291
+ > The chart also paginates backwards **on its own** through
292
+ > `feed.getBars({ to })` whenever you have attached a feed. This event is for
293
+ > when you want to drive that yourself, or to drive something other than data.
294
+
295
+ ---
296
+
297
+ ## Replay
298
+
299
+ Play a fixed dataset back bar by bar — backtesting playback, a market-open
300
+ recap, a training drill.
301
+
302
+ ```js
303
+ chart.setData(bars)
304
+
305
+ const replay = chart.startReplay({ from: 200, speed: 4 })
306
+ replay.play()
307
+
308
+ chart.subscribe('replay', (s) => {
309
+ scrubber.value = s.index
310
+ clock.textContent = new Date(s.time).toLocaleTimeString()
311
+ if (s.atEnd) playBtn.textContent = 'Restart'
312
+ })
313
+ ```
314
+
315
+ The chart is never put into a special mode. The controller keeps the dataset
316
+ aside and hands the chart only the **revealed prefix**, so scales, crosshair,
317
+ annotations and `'visibleRange'` behave exactly as they do on live data that
318
+ happens to end at the cursor.
319
+
320
+ Revealing the next bar goes through the same path a feed tick takes, so the
321
+ candle grows in and the axis glides. Scrubbing swaps the prefix and jumps —
322
+ easing a scrub would read as lag, the same rule the pan gesture follows.
323
+
324
+ ### `chart.startReplay(options?)`
325
+
326
+ | Option | Default | Notes |
327
+ |---|---|---|
328
+ | `bars` | the chart's current bars | The dataset to replay. Never mutated |
329
+ | `from` | midpoint | Starting cursor index |
330
+ | `speed` | `1` | Multiplier, clamped to `0.25`–`500` |
331
+ | `baseInterval` | `1000` | Real ms one bar takes at 1× |
332
+ | `loop` | `false` | Restart at the end instead of stopping |
333
+ | `follow` | `true` | Re-anchor the right edge on the cursor when scrubbing |
334
+
335
+ Returns the `Replay`, or `null` when there are fewer than two bars. While a
336
+ replay is active an attached feed is ignored, so live ticks cannot fight the
337
+ cursor; `stopReplay()` restores the full dataset and resumes normal service.
338
+
339
+ ### Transport
340
+
341
+ Every method returns the controller, so calls chain.
342
+
343
+ | Method | Description |
344
+ |---|---|
345
+ | `play()` / `pause()` / `toggle()` | Pressing play at the end restarts from the beginning |
346
+ | `seek(index)` | Move the cursor. Out-of-range values clamp |
347
+ | `step(n = 1)` | Relative move; `step(-1)` goes back a bar |
348
+ | `toStart()` / `toEnd()` | Jump to either end |
349
+ | `setSpeed(x)` | Clamped to `0.25`–`500`. Never bursts bars on a rate change |
350
+ | `setLoop(bool)` | Toggle looping |
351
+
352
+ Readable state: `index`, `length`, `progress` (0–1), `speed`, `time`, `bar`,
353
+ `atEnd`, `playing`, and `interval` (real ms between bars at the current
354
+ speed).
355
+
356
+ ### The `'replay'` event
357
+
358
+ `{ active, playing, index, length, progress, speed, time, bar, atEnd }`.
359
+
360
+ Like `'visibleRange'` it is a **state** event: a new subscriber is called
361
+ immediately, and after `stopReplay()` it fires once with `active: false` so a
362
+ UI can reset itself without special-casing teardown. `chart.replayState()`
363
+ returns the same payload on demand.
364
+
365
+ **Markers after the cursor are hidden, not clamped.** Time→index resolution
366
+ snaps to the nearest bar, so without that filter every future trade would pile
367
+ onto the newest revealed candle — and a replay that shows you tomorrow's
368
+ entries is worse than no replay at all.
369
+
370
+ The cursor never goes below index 1: the scales infer the timeframe from the
371
+ first pair of bars.
372
+
253
373
  ---
254
374
 
255
375
  ## Annotations
@@ -529,6 +649,7 @@ import {
529
649
  TimeScale, PriceScale, // scales (advanced)
530
650
  Smoothed, Tween, Inertia, // motion primitives
531
651
  LiveCandle,
652
+ Replay, MIN_SPEED, MAX_SPEED, // bar-by-bar playback
532
653
  easeOutCubic, easeInOutCubic,
533
654
  mulberry32, // seeded PRNG
534
655
  version,
@@ -607,8 +728,6 @@ src/App.jsx, src/styles.css the playground (not published)
607
728
 
608
729
  Honest list of what isn't there yet:
609
730
 
610
- - **`subscribe('visibleRange', fn)` never fires.** The event is accepted and
611
- the handler is stored, but nothing emits it. Use `'crosshair'` for now.
612
731
  - **No OHLCV legend in the package.** `subscribe('crosshair', fn)` gives you
613
732
  the hovered bar; rendering the readout is still yours to do.
614
733
  - **Markers are not draggable.** They are hit-tested for hover and click, but
package/index.d.ts CHANGED
@@ -179,6 +179,28 @@ export interface CrosshairPayload {
179
179
  price: number
180
180
  }
181
181
 
182
+ /** Payload of the 'visibleRange' event, and the return of chart.visibleRange(). */
183
+ export interface VisibleRangePayload {
184
+ /** First visible bar index, clamped to the loaded data. */
185
+ from: number
186
+ /** Last visible bar index, clamped to the loaded data. */
187
+ to: number
188
+ /** `bars[from].time`, or null when no bars are loaded. */
189
+ fromTime: number | null
190
+ /** `bars[to].time`, or null when no bars are loaded. */
191
+ toTime: number | null
192
+ /** Total bars currently loaded. */
193
+ barCount: number
194
+ /** Current pixels per bar — useful for level-of-detail decisions. */
195
+ spacing: number
196
+ /**
197
+ * False while the view is still easing. The last event of a gesture always
198
+ * arrives with `settled: true`, so it is safe to defer expensive work
199
+ * (a fetch, a re-aggregation) until you see it.
200
+ */
201
+ settled: boolean
202
+ }
203
+
182
204
  /* ------------------------------------------------------------------ feed -- */
183
205
 
184
206
  export interface GetBarsRequest {
@@ -267,6 +289,80 @@ export declare class PriceScale {
267
289
  readonly hi: number
268
290
  }
269
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
+
270
366
  /* ----------------------------------------------------------------- chart -- */
271
367
 
272
368
  export declare class Chart {
@@ -295,6 +391,21 @@ export declare class Chart {
295
391
  /** Topmost marker under a plot-relative point, else null. */
296
392
  markerAt(x: number, y: number): ResolvedMarker | null
297
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
+
298
409
  setTheme(theme: Partial<Theme>): void
299
410
  setPriceMode(mode: PriceMode): void
300
411
  setAnimate(on: boolean): void
@@ -306,14 +417,21 @@ export declare class Chart {
306
417
  /** Rolling frames-per-second of the render loop. */
307
418
  readonly fps: number
308
419
 
420
+ /** The window currently on screen. Cheap enough to poll. */
421
+ visibleRange(): VisibleRangePayload
422
+
309
423
  /**
310
424
  * Subscribe to a chart event. Returns an unsubscribe function.
311
- * NOTE: 'visibleRange' is accepted but is not currently emitted.
425
+ *
426
+ * 'visibleRange' is a state event rather than a notification: a new
427
+ * subscriber is called immediately with the current window, and then only
428
+ * when that window actually changes — so no debouncing is required.
312
429
  */
313
430
  subscribe(event: 'crosshair', fn: (payload: CrosshairPayload | null) => void): () => void
314
431
  subscribe(event: 'markerClick', fn: (marker: ResolvedMarker) => void): () => void
315
432
  subscribe(event: 'markerHover', fn: (marker: ResolvedMarker | null) => void): () => void
316
- subscribe(event: 'visibleRange', fn: (range: { from: number; to: number }) => void): () => void
433
+ subscribe(event: 'visibleRange', fn: (range: VisibleRangePayload) => void): () => void
434
+ subscribe(event: 'replay', fn: (state: ReplayState) => void): () => void
317
435
 
318
436
  /** Removes listeners, canvases and the render loop. */
319
437
  destroy(): void