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 +79 -0
- package/README.md +22 -2
- package/index.d.ts +5 -0
- package/index.js +61 -30
- package/index.js.map +1 -1
- package/package.json +10 -1
- package/umd/emberwick.umd.js +1 -1
- package/umd/emberwick.umd.js.map +1 -1
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
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
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
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1447
|
-
|
|
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.
|
|
2103
|
+
const version = "0.4.2";
|
|
2073
2104
|
export {
|
|
2074
2105
|
Chart,
|
|
2075
2106
|
DataFeed,
|