emberwick 0.2.0 → 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.
package/README.md CHANGED
@@ -240,16 +240,53 @@ const off = chart.subscribe('crosshair', (payload) => {
240
240
  off() // unsubscribe
241
241
  ```
242
242
 
243
+ | Event | Payload |
244
+ |---|---|
243
245
  | Event | Payload |
244
246
  |---|---|
245
247
  | `'crosshair'` | `{ index, bar, price }`, or `null` when the pointer leaves the plot |
246
248
  | `'markerHover'` | The marker under the pointer, or `null` when none is |
247
249
  | `'markerClick'` | The clicked marker. Only fires on a hit, never with `null` |
248
- | `'visibleRange'` | **Accepted but never emitted.** See *Known gaps* |
250
+ | `'visibleRange'` | `{ from, to, fromTime, toTime, barCount, spacing, settled }` |
249
251
 
250
252
  A drag that happens to end on top of a marker does not fire `'markerClick'` —
251
253
  panning and clicking stay distinct.
252
254
 
255
+ ### Tracking the visible range
256
+
257
+ `'visibleRange'` is a **state** event rather than a notification, which makes it
258
+ usable without any debouncing of your own:
259
+
260
+ - A new subscriber is called **immediately** with the current window, so it
261
+ never has to wait for the user to pan before it knows what is on screen.
262
+ - It then fires **only when the window actually changes**. Indices are
263
+ integers, so a slow pan at 9px/bar produces roughly one event every nine
264
+ frames, not one per frame.
265
+ - `spacing` is reported but is deliberately *not* part of the change test — it
266
+ is a float that moves every frame of an eased zoom, and keying on it would
267
+ turn this into a 60/sec firehose. Use it for level-of-detail decisions.
268
+ - `settled` *is* part of the change test, so the final event of a gesture
269
+ always arrives with `settled: true`. That makes "wait until the view stops
270
+ moving, then do the expensive thing" a safe pattern.
271
+
272
+ ```js
273
+ const off = chart.subscribe('visibleRange', async (r) => {
274
+ // paginate backwards when the user approaches the left edge
275
+ if (r.from < 50 && r.settled && r.fromTime) {
276
+ const older = await myApi.bars({ to: r.fromTime, limit: 500 })
277
+ chart.setData(older.concat(chart.bars))
278
+ }
279
+ })
280
+ ```
281
+
282
+ Syncing a second chart is the other common use — feed `fromTime`/`toTime`
283
+ straight into the other instance. And `chart.visibleRange()` returns the same
284
+ payload on demand if you would rather poll than subscribe.
285
+
286
+ > The chart also paginates backwards **on its own** through
287
+ > `feed.getBars({ to })` whenever you have attached a feed. This event is for
288
+ > when you want to drive that yourself, or to drive something other than data.
289
+
253
290
  ---
254
291
 
255
292
  ## Annotations
@@ -607,8 +644,6 @@ src/App.jsx, src/styles.css the playground (not published)
607
644
 
608
645
  Honest list of what isn't there yet:
609
646
 
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
647
  - **No OHLCV legend in the package.** `subscribe('crosshair', fn)` gives you
613
648
  the hovered bar; rendering the readout is still yours to do.
614
649
  - **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 {
@@ -306,14 +328,20 @@ export declare class Chart {
306
328
  /** Rolling frames-per-second of the render loop. */
307
329
  readonly fps: number
308
330
 
331
+ /** The window currently on screen. Cheap enough to poll. */
332
+ visibleRange(): VisibleRangePayload
333
+
309
334
  /**
310
335
  * Subscribe to a chart event. Returns an unsubscribe function.
311
- * NOTE: 'visibleRange' is accepted but is not currently emitted.
336
+ *
337
+ * 'visibleRange' is a state event rather than a notification: a new
338
+ * subscriber is called immediately with the current window, and then only
339
+ * when that window actually changes — so no debouncing is required.
312
340
  */
313
341
  subscribe(event: 'crosshair', fn: (payload: CrosshairPayload | null) => void): () => void
314
342
  subscribe(event: 'markerClick', fn: (marker: ResolvedMarker) => void): () => void
315
343
  subscribe(event: 'markerHover', fn: (marker: ResolvedMarker | null) => void): () => void
316
- subscribe(event: 'visibleRange', fn: (range: { from: number; to: number }) => void): () => void
344
+ subscribe(event: 'visibleRange', fn: (range: VisibleRangePayload) => void): () => void
317
345
 
318
346
  /** Removes listeners, canvases and the render loop. */
319
347
  destroy(): void
package/index.js CHANGED
@@ -1394,8 +1394,57 @@ class Chart {
1394
1394
  const set = this._listeners[event];
1395
1395
  if (!set) throw new Error(`Chart: unknown event "${event}"`);
1396
1396
  set.add(fn);
1397
+ if (event === "visibleRange") {
1398
+ const payload = this.visibleRange();
1399
+ this._rangeKey = this._rangeIdentity(payload);
1400
+ fn(payload);
1401
+ }
1397
1402
  return () => set.delete(fn);
1398
1403
  }
1404
+ // ------------------------------------------------------------------ range --
1405
+ /**
1406
+ * The window currently on screen. Cheap enough to poll, though
1407
+ * subscribe('visibleRange', fn) is the better way to track it.
1408
+ */
1409
+ visibleRange() {
1410
+ const { from, to } = this.ts.visibleRange();
1411
+ return this._rangePayload(from, to);
1412
+ }
1413
+ _rangePayload(from, to) {
1414
+ const n = this.bars.length;
1415
+ return {
1416
+ from,
1417
+ to,
1418
+ fromTime: n ? this.bars[from].time : null,
1419
+ toTime: n ? this.bars[to].time : null,
1420
+ barCount: n,
1421
+ spacing: this.ts.spacing,
1422
+ settled: this.ts.settled
1423
+ };
1424
+ }
1425
+ _rangeIdentity(p) {
1426
+ return `${p.from}:${p.to}:${p.fromTime}:${p.toTime}:${p.settled ? 1 : 0}`;
1427
+ }
1428
+ /**
1429
+ * Fires only when the window actually changed, so a consumer can hang a
1430
+ * fetch off it without debouncing. Two deliberate choices:
1431
+ *
1432
+ * - `spacing` is NOT part of the identity. It is a float that moves on every
1433
+ * frame of an eased zoom, so keying on it would make this a 60/sec
1434
+ * firehose. It is still reported, for level-of-detail decisions.
1435
+ * - `settled` IS part of the identity, so the final event of a gesture
1436
+ * always arrives with settled:true. Without it, code that defers expensive
1437
+ * work until the view stops moving would wait forever.
1438
+ */
1439
+ _emitVisibleRange(from, to) {
1440
+ const set = this._listeners.visibleRange;
1441
+ if (!set.size) return;
1442
+ const payload = this._rangePayload(from, to);
1443
+ const key = this._rangeIdentity(payload);
1444
+ if (key === this._rangeKey) return;
1445
+ this._rangeKey = key;
1446
+ for (const fn of set) fn(payload);
1447
+ }
1399
1448
  // ----------------------------------------------------------- annotations --
1400
1449
  /** Replace every marker. Each `time` is resolved to its nearest bar. */
1401
1450
  setMarkers(markers) {
@@ -1482,6 +1531,7 @@ class Chart {
1482
1531
  if (redrawAll || dirty.has("overlay")) {
1483
1532
  drawCrosshair(this.layers.ctx.overlay, state);
1484
1533
  }
1534
+ this._emitVisibleRange(from, to);
1485
1535
  return animating;
1486
1536
  }
1487
1537
  // ------------------------------------------------------------------- api --