emberwick 0.4.0 → 0.4.2

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,79 @@
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.2] — 2026-09-19
7
+
8
+ Metadata only. The code is identical to 0.4.1; nothing needs re-testing.
9
+
10
+ ### Added
11
+
12
+ - `repository`, `homepage`, `bugs` and `author` in the published manifest, so
13
+ the npm package page links back to the source, the demo and the issue
14
+ tracker, and carries a publisher byline. These
15
+ belong in `package.lib.json` — the root `package.json` is private and never
16
+ reaches the registry — and 0.4.1 shipped without them. npm versions are
17
+ immutable, hence a new patch rather than a corrected republish.
18
+
19
+ ## [0.4.1] — 2026-09-19
20
+
21
+ Bug-fix release. Three feed-lifecycle races could put wrong prices on screen,
22
+ so this supersedes 0.4.0 for anyone attaching a `DataFeed`.
23
+
24
+ ### Fixed
25
+
26
+ - **A slow `setFeed()` no longer overwrites a newer one.** `setFeed()` awaited
27
+ `getBars()` without recording which call was current, so switching symbols
28
+ faster than the network could resolve let the *earlier* request win: it
29
+ replaced the new symbol's bars, overwrote `_unsub` — leaving the newer
30
+ subscription permanently unreachable — and, because both feeds wrote to the
31
+ bar slot keyed on the last bar's time, made the forming candle alternate
32
+ between two instruments with no error raised. Every async feed read is now
33
+ stamped with a generation that `setFeed()`, `detachFeed()` and `destroy()`
34
+ invalidate.
35
+ - **Lazy history pages are bound to the feed that requested them.** A page in
36
+ flight during a symbol switch could prepend one instrument's bars onto
37
+ another's, and a stale empty page could latch `_exhausted` on the *new*
38
+ symbol, permanently disabling paging for an instrument with history left.
39
+ The page boundary is also re-read after the await, so a late page can no
40
+ longer splice into the middle of the array and break the ascending-by-time
41
+ invariant that marker resolution's binary search depends on.
42
+ - **Prepending history shifts the viewport exactly once.** `Smoothed.jump()`
43
+ writes both `value` and `target`, so the follow-up `set(target + n)` added
44
+ the page size a second time. The view eased past the newest bar, and because
45
+ that pushed the visible range beyond the 80-bar trigger, lazy paging stopped
46
+ firing for the rest of the session — one page load could permanently disable
47
+ the feature.
48
+ - **`destroy()` is idempotent.** A second call threw from `Layers.destroy()`,
49
+ which React 18 StrictMode's double-unmount could reach.
50
+
51
+ ### Added
52
+
53
+ - **`'error'` event.** `subscribe('error', fn)` receives the thrown value from
54
+ a failed `getBars()`, in both `setFeed()` and history paging. Previously
55
+ `setFeed()` had no error path at all: a rejection escaped as an unhandled
56
+ rejection and left a permanently blank chart that a consumer could not even
57
+ detect. With no subscriber the error is logged rather than swallowed.
58
+ - **First test suite.** `npm test` runs `test/chart-feed-race.test.mjs` on
59
+ plain Node — no jsdom, no browser. Eight of its nine cases fail against
60
+ 0.4.0. `npm run release` now runs it before building.
61
+
62
+ ### Docs
63
+
64
+ - Documented the `'error'` event and feed-failure handling.
65
+ - Removed a duplicated header row in the events table.
66
+
67
+ ## [0.4.0] — 2026-09-16
68
+
69
+ ### Added
70
+
71
+ - Bar-by-bar `Replay` with transport, scrubbing, speed control and looping.
72
+ - Annotations: nine marker shapes, price lines and shaded zones, with
73
+ collision-aware stacking and hover/click hit-testing.
74
+ - `'visibleRange'` state event and `chart.visibleRange()`.
75
+
76
+ ## [0.1.0] — 2026-09-16
77
+
78
+ First public release: canvas candlestick core, `DataFeed` seam, `RandomFeed`,
79
+ React adapter, web component, UMD build.
package/README.md CHANGED
@@ -244,8 +244,6 @@ const off = chart.subscribe('crosshair', (payload) => {
244
244
  off() // unsubscribe
245
245
  ```
246
246
 
247
- | Event | Payload |
248
- |---|---|
249
247
  | Event | Payload |
250
248
  |---|---|
251
249
  | `'crosshair'` | `{ index, bar, price }`, or `null` when the pointer leaves the plot |
@@ -253,10 +251,32 @@ off() // unsubscribe
253
251
  | `'markerClick'` | The clicked marker. Only fires on a hit, never with `null` |
254
252
  | `'visibleRange'` | `{ from, to, fromTime, toTime, barCount, spacing, settled }` |
255
253
  | `'replay'` | `{ active, playing, index, length, progress, speed, time, bar, atEnd }` |
254
+ | `'error'` | The thrown value from a failed feed read. See below |
256
255
 
257
256
  A drag that happens to end on top of a marker does not fire `'markerClick'` —
258
257
  panning and clicking stay distinct.
259
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
+
260
280
  ### Tracking the visible range
261
281
 
262
282
  `'visibleRange'` is a **state** event rather than a notification, which makes it
package/index.d.ts CHANGED
@@ -432,6 +432,11 @@ export declare class Chart {
432
432
  subscribe(event: 'markerHover', fn: (marker: ResolvedMarker | null) => void): () => void
433
433
  subscribe(event: 'visibleRange', fn: (range: VisibleRangePayload) => void): () => void
434
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
435
440
 
436
441
  /** Removes listeners, canvases and the render loop. */
437
442
  destroy(): void
package/index.js CHANGED
@@ -1292,12 +1292,15 @@ class Chart {
1292
1292
  this._loadingHistory = false;
1293
1293
  this._exhausted = false;
1294
1294
  this._replay = null;
1295
+ this._feedGen = 0;
1296
+ this._destroyed = false;
1295
1297
  this._listeners = {
1296
1298
  crosshair: /* @__PURE__ */ new Set(),
1297
1299
  visibleRange: /* @__PURE__ */ new Set(),
1298
1300
  markerClick: /* @__PURE__ */ new Set(),
1299
1301
  markerHover: /* @__PURE__ */ new Set(),
1300
- replay: /* @__PURE__ */ new Set()
1302
+ replay: /* @__PURE__ */ new Set(),
1303
+ error: /* @__PURE__ */ new Set()
1301
1304
  };
1302
1305
  this._markers = normalizeMarkers(options.markers);
1303
1306
  this.priceLines = Array.isArray(options.priceLines) ? options.priceLines.slice() : [];
@@ -1392,23 +1395,35 @@ class Chart {
1392
1395
  this.detachFeed();
1393
1396
  this.feed = feed;
1394
1397
  if (!feed) return;
1398
+ const gen = ++this._feedGen;
1395
1399
  this.ts.timeframeMs = feed.timeframe || this.ts.timeframeMs;
1396
- const bars = await feed.getBars({
1397
- symbol: feed.symbol,
1398
- timeframe: feed.timeframe,
1399
- to: null,
1400
- limit: this.options.initialBars || 1500
1401
- });
1400
+ let bars;
1401
+ try {
1402
+ bars = await feed.getBars({
1403
+ symbol: feed.symbol,
1404
+ timeframe: feed.timeframe,
1405
+ to: null,
1406
+ limit: this.options.initialBars || 1500
1407
+ });
1408
+ } catch (err) {
1409
+ if (gen !== this._feedGen || this._destroyed) return;
1410
+ this._emitError(err, "setFeed");
1411
+ return;
1412
+ }
1413
+ if (gen !== this._feedGen || this._destroyed) return;
1402
1414
  this.setData(bars);
1403
- if (typeof feed.prime === "function") feed.prime(bars[bars.length - 1]);
1415
+ if (typeof feed.prime === "function") feed.prime(this.bars[this.bars.length - 1]);
1404
1416
  this._unsub = feed.subscribe((msg) => {
1405
1417
  if (!msg || !msg.bar) return;
1418
+ if (gen !== this._feedGen || this._destroyed) return;
1406
1419
  if (this._replay) return;
1407
1420
  if (msg.type === "append") this.append(msg.bar);
1408
1421
  else this.update(msg.bar);
1409
1422
  });
1410
1423
  }
1411
1424
  detachFeed() {
1425
+ this._feedGen++;
1426
+ this._loadingHistory = false;
1412
1427
  if (this._unsub) this._unsub();
1413
1428
  this._unsub = null;
1414
1429
  this.feed = null;
@@ -1419,36 +1434,50 @@ class Chart {
1419
1434
  const { from } = this.ts.visibleRange();
1420
1435
  if (from > 80 || !this.bars.length) return;
1421
1436
  this._loadingHistory = true;
1437
+ const gen = this._feedGen;
1438
+ const feed = this.feed;
1422
1439
  try {
1423
- const oldest = this.bars[0].time;
1424
- const older = await this.feed.getBars({
1425
- symbol: this.feed.symbol,
1426
- timeframe: this.feed.timeframe,
1427
- to: oldest,
1440
+ const older = await feed.getBars({
1441
+ symbol: feed.symbol,
1442
+ timeframe: feed.timeframe,
1443
+ to: this.bars[0].time,
1428
1444
  limit: 1e3
1429
1445
  });
1446
+ if (gen !== this._feedGen || this._destroyed) return;
1447
+ if (!this.bars.length) return;
1430
1448
  if (!older || !older.length) {
1431
1449
  this._exhausted = true;
1432
- } else {
1433
- const added = older.filter((b) => b.time < oldest);
1434
- if (!added.length) {
1435
- this._exhausted = true;
1436
- } else {
1437
- this.bars = added.concat(this.bars);
1438
- const wasFollowing = this.ts.follow;
1439
- this.ts.barCount = this.bars.length;
1440
- this.ts._right.jump(this.ts._right.value + added.length);
1441
- this.ts._right.set(this.ts._right.target + added.length);
1442
- this.ts.follow = wasFollowing;
1443
- this.loop.invalidate("all");
1444
- }
1450
+ return;
1445
1451
  }
1446
- } catch (e) {
1447
- console.error("[Emberwick] history load failed", e);
1452
+ const boundary = this.bars[0].time;
1453
+ const added = older.filter((b) => b.time < boundary);
1454
+ if (!added.length) {
1455
+ this._exhausted = true;
1456
+ return;
1457
+ }
1458
+ this.bars = added.concat(this.bars);
1459
+ this.ts.barCount = this.bars.length;
1460
+ this.ts._right.jump(this.ts._right.value + added.length);
1461
+ this.loop.invalidate("all");
1462
+ } catch (err) {
1463
+ if (gen !== this._feedGen || this._destroyed) return;
1448
1464
  this._exhausted = true;
1465
+ this._emitError(err, "loadHistory");
1449
1466
  } finally {
1450
- this._loadingHistory = false;
1467
+ if (gen === this._feedGen) this._loadingHistory = false;
1468
+ }
1469
+ }
1470
+ /**
1471
+ * Feed failures are delivered as an 'error' event so an adapter can react.
1472
+ * With no subscriber they still reach the console rather than vanishing.
1473
+ */
1474
+ _emitError(err, phase) {
1475
+ const set = this._listeners.error;
1476
+ if (!set.size) {
1477
+ console.error(`[Emberwick] ${phase} failed`, err);
1478
+ return;
1451
1479
  }
1480
+ for (const fn of set) fn(err);
1452
1481
  }
1453
1482
  // ---------------------------------------------------------------- events --
1454
1483
  _bindEvents() {
@@ -1872,6 +1901,8 @@ class Chart {
1872
1901
  return this.layers.composite().toDataURL("image/png");
1873
1902
  }
1874
1903
  destroy() {
1904
+ if (this._destroyed) return;
1905
+ this._destroyed = true;
1875
1906
  const el = this.container;
1876
1907
  el.removeEventListener("pointerdown", this._onDown);
1877
1908
  el.removeEventListener("pointermove", this._onMove);
@@ -2069,7 +2100,7 @@ class RandomFeed extends DataFeed {
2069
2100
  function createChart(container, options) {
2070
2101
  return new Chart(container, options);
2071
2102
  }
2072
- const version = "0.4.0";
2103
+ const version = "0.4.2";
2073
2104
  export {
2074
2105
  Chart,
2075
2106
  DataFeed,