@livo-build/charts 0.2.2 → 0.2.4
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 +116 -19
- package/dist/core/chart.d.ts +44 -2
- package/dist/core/chart.js +123 -11
- package/dist/core/feed.js +27 -6
- package/dist/core/indicators.d.ts +36 -0
- package/dist/core/indicators.js +119 -0
- package/dist/core/ohlc.d.ts +10 -0
- package/dist/core/ohlc.js +30 -0
- package/dist/core/polymarket.d.ts +53 -0
- package/dist/core/polymarket.js +173 -0
- package/dist/core/renderer.d.ts +19 -4
- package/dist/core/renderer.js +217 -34
- package/dist/core/signals.d.ts +63 -0
- package/dist/core/signals.js +234 -0
- package/dist/core/types.d.ts +31 -8
- package/dist/index.d.ts +9 -4
- package/dist/index.js +5 -3
- package/dist/react/HyperliquidChart.d.ts +4 -2
- package/dist/react/HyperliquidChart.js +11 -3
- package/dist/react/PolymarketChart.d.ts +45 -0
- package/dist/react/PolymarketChart.js +95 -0
- package/dist/react/PriceChart.d.ts +18 -3
- package/dist/react/PriceChart.js +16 -4
- package/dist/react/SignalsChart.d.ts +37 -0
- package/dist/react/SignalsChart.js +95 -0
- package/dist/react/ui.d.ts +13 -2
- package/dist/react/ui.js +46 -0
- package/dist/react.d.ts +4 -0
- package/dist/react.js +2 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -9,10 +9,11 @@ A lightweight, **dependency-free** canvas charting library. The core renders to
|
|
|
9
9
|
shipped under a separate entry so non-React consumers never pull React into their
|
|
10
10
|
bundle.
|
|
11
11
|
|
|
12
|
-
It ships a **TradingView-style trading chart**: candlesticks
|
|
13
|
-
**
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
It ships a **TradingView-style trading chart**: candlesticks, a line, a two-tone
|
|
13
|
+
**baseline** area **or Heikin-Ashi** candles, a **volume panel**, a **volume-by-price
|
|
14
|
+
profile**, a **crosshair with price + time axis labels**, an **OHLCV legend**, independent
|
|
15
|
+
**X and Y zoom** (+ pan, plus touch/pinch), and a toolbar for **intervals, USD/ETH,
|
|
16
|
+
price/market-cap, log scale and fullscreen**. The architecture (a framework-agnostic `Chart`
|
|
16
17
|
controller + a pure `draw()` pass) is built to keep growing — indicators, overlays,
|
|
17
18
|
multiple panes — without touching consumers.
|
|
18
19
|
|
|
@@ -45,6 +46,18 @@ OHLCV legend are built in. The canvas is long-lived, so zoom/pan survive prop up
|
|
|
45
46
|
(streaming trades won't reset the user's view). The wrapper is styled inline — no CSS
|
|
46
47
|
framework required.
|
|
47
48
|
|
|
49
|
+
`PriceChart` only aggregates the `swaps` you give it — it doesn't fetch more, so panning
|
|
50
|
+
stops at the oldest trade. For **infinite back-history**, pass `onLoadOlder` (fires when the
|
|
51
|
+
user pans/zooms within ~one viewport of the start) and prepend older trades to `swaps`; the
|
|
52
|
+
right-anchored view keeps the position stable. Scope the toolbar to intervals your data
|
|
53
|
+
covers with `intervals={[["1m", 60], ["5m", 300]]}` (a thin series bucketed at `1h`
|
|
54
|
+
collapses into a couple of candles), and react to interval changes with `onIntervalChange`.
|
|
55
|
+
For a turnkey lazy-loading chart, use a feed (`HyperliquidChart` / `connectFeed`) instead.
|
|
56
|
+
|
|
57
|
+
The default **visible span** is `initialBars × interval`. To open on, say, the last 7 days at
|
|
58
|
+
1h candles, give it a week of data and `defaultInterval={3600}` + `options={{ initialBars: 168 }}`
|
|
59
|
+
(168 × 1h = 7 days). The user can still zoom/scroll out from there.
|
|
60
|
+
|
|
48
61
|
Market cap = `price × supply`. Pass `supply` directly, or pass `tokenAddress` (+`decimals`)
|
|
49
62
|
to read `totalSupply()` once via `rpcUrl` (default `https://cloudflare-eth.com`, cached).
|
|
50
63
|
The **Price/MCap toggle only appears when one of those is set** — otherwise it's hidden
|
|
@@ -72,6 +85,40 @@ import { HyperliquidChart } from "@livo-build/charts/react";
|
|
|
72
85
|
history loads lazily as the user scrolls left (infinite back-scroll) — both with no
|
|
73
86
|
API key. Zoom/pan survive every update.
|
|
74
87
|
|
|
88
|
+
### Live Polymarket & Signal Radar charts
|
|
89
|
+
|
|
90
|
+
Two more turnkey, key-free live charts wrap the framework-agnostic `Chart`:
|
|
91
|
+
|
|
92
|
+
```tsx
|
|
93
|
+
import { PolymarketChart, SignalsChart } from "@livo-build/charts/react";
|
|
94
|
+
|
|
95
|
+
// A prediction-market outcome (probabilities → a % y-axis), from the public CLOB.
|
|
96
|
+
<PolymarketChart tokenId={yesTokenId} bucketSeconds={3600} label="Yes" />
|
|
97
|
+
|
|
98
|
+
// A token tracked by the Livo signals engine ("Signal Radar") — real indexed history.
|
|
99
|
+
<SignalsChart token="PEPE" bucketSeconds={300} indicators={[{ type: "ema", period: 21 }]} />
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Both are `ChartFeed`s under the hood — drive the core directly with `connectFeed`:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { Chart, connectFeed, polymarketFeed, signalsFeed } from "@livo-build/charts";
|
|
106
|
+
|
|
107
|
+
connectFeed(new Chart(el), polymarketFeed({ tokenId, bucketSeconds: 3600 }), { interval: 3600 });
|
|
108
|
+
connectFeed(new Chart(el2), signalsFeed({ token: "PEPE", bucketSeconds: 300 }), { interval: 300 });
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
- **`polymarketFeed`** — bucketed OHLC from the public Polymarket CLOB `prices-history`
|
|
112
|
+
endpoint, polled for a building candle. Prices are probabilities in [0, 1] (no volume).
|
|
113
|
+
`fetchPolymarketPriceHistory` is exported for custom fetching.
|
|
114
|
+
- **`signalsFeed`** — real OHLC from the Signal Radar **index**: it queries `/graphql`
|
|
115
|
+
(`allSwaps` for the pool) and buckets the swaps into candles at any interval, paging older
|
|
116
|
+
swaps in as you scroll — so 1m / 5m candles go days deep (the index has no TTL), and 1h/4h/1d
|
|
117
|
+
just zoom out in time. The live candle is polled from `/data` (current price). If `/graphql`
|
|
118
|
+
is unreachable (e.g. cross-origin CORS) or you pass `history: "spark"`, it falls back to the
|
|
119
|
+
snapshot's `spark`. `fetchSignalsSwaps` / `fetchSignalsMarket` / `sparkCandles` are exported.
|
|
120
|
+
(Cross-origin use needs `/graphql` CORS-enabled on the engine; same-origin always works.)
|
|
121
|
+
|
|
75
122
|
### Indicators
|
|
76
123
|
|
|
77
124
|
`PriceChart` and `HyperliquidChart` accept `indicators` — overlays drawn on the price
|
|
@@ -82,8 +129,8 @@ Colors fall back to a built-in palette by position. The pure
|
|
|
82
129
|
`sma`/`ema`/`wma`/`vwap`/`bollingerBands`/`computeIndicator` helpers are exported from
|
|
83
130
|
the core (e.g. feed `bollingerBands(values).{upper,mid,lower}` as three overlays).
|
|
84
131
|
|
|
85
|
-
**Oscillators (RSI / MACD)** render in their own **stacked sub-panes**
|
|
86
|
-
panel — pass `oscillators`:
|
|
132
|
+
**Oscillators (RSI / MACD / Stochastic / ATR)** render in their own **stacked sub-panes**
|
|
133
|
+
below the volume panel — pass `oscillators`:
|
|
87
134
|
|
|
88
135
|
```tsx
|
|
89
136
|
<PriceChart
|
|
@@ -92,12 +139,43 @@ panel — pass `oscillators`:
|
|
|
92
139
|
oscillators={[
|
|
93
140
|
{ type: "rsi", period: 14 }, // 0–100 band with 70/30 rails
|
|
94
141
|
{ type: "macd", fast: 12, slow: 26, signal: 9 }, // line + signal + histogram
|
|
142
|
+
{ type: "stoch", period: 14, dPeriod: 3, smooth: 3 }, // %K/%D, 80/20 rails
|
|
143
|
+
{ type: "atr", period: 14 }, // auto-scaled volatility (price units)
|
|
95
144
|
]}
|
|
96
145
|
/>
|
|
97
146
|
```
|
|
98
147
|
|
|
99
|
-
Each pane defaults to 84px (`height` to override). The pure `rsi(values, period)
|
|
100
|
-
`macd(values, fast, slow, signal) → { macd, signal, hist }
|
|
148
|
+
Each pane defaults to 84px (`height` to override). The pure `rsi(values, period)`,
|
|
149
|
+
`macd(values, fast, slow, signal) → { macd, signal, hist }`,
|
|
150
|
+
`stochastic(candles, kPeriod, dPeriod, smooth) → { k, d }` and `atr(candles, period)`
|
|
151
|
+
helpers are exported too.
|
|
152
|
+
|
|
153
|
+
### Chart types: candle · line · baseline · Heikin-Ashi
|
|
154
|
+
|
|
155
|
+
`chartType` (low-level `chart.setChartType(...)`, or the toolbar's ▮▯ / ╱ / ⎓ / HA buttons)
|
|
156
|
+
switches the price series:
|
|
157
|
+
|
|
158
|
+
- **`candle`** — classic OHLC candlesticks.
|
|
159
|
+
- **`line`** — a close line with a soft gradient area fill.
|
|
160
|
+
- **`baseline`** — a two-tone area around a reference price (green above, red below). Set the
|
|
161
|
+
reference with `baselinePrice` (defaults to the first visible close); the core exposes
|
|
162
|
+
`chart.setBaseline(price)`.
|
|
163
|
+
- **`heikin`** — Heikin-Ashi candles: smoothed OHLC that filter noise and make trends easier
|
|
164
|
+
to read. Indicators/oscillators still read the **raw** closes. The pure `heikinAshi(candles)`
|
|
165
|
+
transform is exported.
|
|
166
|
+
|
|
167
|
+
### Volume profile (volume-by-price)
|
|
168
|
+
|
|
169
|
+
A horizontal histogram of volume per price level, drawn over the price pane (peaked at the
|
|
170
|
+
**Point of Control**). Pass `volumeProfile` (or toggle **VP** in the toolbar); the core has
|
|
171
|
+
`chart.setVolumeProfile(...)`:
|
|
172
|
+
|
|
173
|
+
```tsx
|
|
174
|
+
<PriceChart swaps={trades} volumeProfile={{ buckets: 24, width: 0.16 }} />
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
It's computed over the **visible** window, so it tracks zoom/pan. The pure
|
|
178
|
+
`volumeProfile(candles, buckets) → { buckets, maxVol, poc }` helper is exported.
|
|
101
179
|
|
|
102
180
|
### Fitting the container & axis styling
|
|
103
181
|
|
|
@@ -136,11 +214,12 @@ via `THEME_PRESETS.dark` / `THEME_PRESETS.light`. Pass either (or a partial over
|
|
|
136
214
|
|
|
137
215
|
### Drawing tools (trendlines & horizontal lines)
|
|
138
216
|
|
|
139
|
-
The toolbar
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
to delete it**; **⌫**
|
|
217
|
+
The toolbar arms a one-shot draw: **↗** trendline (drag), **—** horizontal line (click),
|
|
218
|
+
**fib** Fibonacci retracement (drag — levels 0/23.6/38.2/50/61.8/78.6/100% between the two
|
|
219
|
+
prices), and **▭** rectangle (drag). Drawings are anchored in **data space**, so they stay
|
|
220
|
+
pinned to their price/time across pan, zoom and live updates. In the default cursor mode,
|
|
221
|
+
click a drawing to select it, drag to move it, and **double-click it to delete it**; **⌫**
|
|
222
|
+
clears all.
|
|
144
223
|
|
|
145
224
|
```tsx
|
|
146
225
|
<PriceChart swaps={trades} onDrawingsChange={(d) => save(d)} drawings={restored} />
|
|
@@ -179,7 +258,10 @@ const feed = {
|
|
|
179
258
|
```
|
|
180
259
|
|
|
181
260
|
The chart's right-anchored view means prepending history keeps the visible window
|
|
182
|
-
stable
|
|
261
|
+
stable. The controller prefetches the next page once the view's left edge comes within
|
|
262
|
+
~one viewport of the oldest loaded candle (see the exported `needsHistory` helper) — so
|
|
263
|
+
**zooming out keeps deepening the window with real data** instead of stalling at the
|
|
264
|
+
oldest bar. It advances one page per data length and stops when the feed runs dry.
|
|
183
265
|
|
|
184
266
|
## Vanilla / framework-agnostic
|
|
185
267
|
|
|
@@ -206,8 +288,10 @@ chart.destroy();
|
|
|
206
288
|
| `new Chart(container, options?)` | Creates a `<canvas>`, wires interaction, observes resize. |
|
|
207
289
|
| `setCandles(candles)` | Replace the series (each candle has `vol`) and redraw. |
|
|
208
290
|
| `setInterval(seconds)` | Bucket interval — drives time-axis label granularity. |
|
|
209
|
-
| `setChartType("candle" \| "line")` | Switch series style. |
|
|
291
|
+
| `setChartType("candle" \| "line" \| "baseline" \| "heikin")` | Switch series style. |
|
|
210
292
|
| `setShowVolume(on)` | Show / hide the volume panel (the price pane reclaims the space). |
|
|
293
|
+
| `setVolumeProfile(config)` | Show/configure (or hide with `false`) the volume-by-price histogram. |
|
|
294
|
+
| `setBaseline(price?)` | Reference price for the `baseline` type (undefined = first visible close). |
|
|
211
295
|
| `setLogScale(on)` | Toggle the logarithmic price axis (auto-falls back to linear if any low ≤ 0). |
|
|
212
296
|
| `setAxis({ fitContent, priceFormat, timeFormat, priceTicks, timeTicks, axisFont })` | Style the axes — only the provided keys change. |
|
|
213
297
|
| `setIndicators(indicators)` | Set the moving-average overlays and redraw. |
|
|
@@ -222,7 +306,8 @@ chart.destroy();
|
|
|
222
306
|
`ChartOptions`: `height`, `theme`, `initialBars` (120), `minBars` (20),
|
|
223
307
|
`maxBarWidth` (18 — caps candle spacing so sparse series stay tight, right-anchored),
|
|
224
308
|
`volumeRatio` (0.18), `rightPad`/`bottomPad`/`topPad`, `onCrosshair`, `indicators`,
|
|
225
|
-
`oscillators`, `drawings`, `showVolume` (true), `
|
|
309
|
+
`oscillators`, `drawings`, `showVolume` (true), `volumeProfile`, `baselinePrice`,
|
|
310
|
+
`logScale` (false), `fitContent` (false),
|
|
226
311
|
`maxBarWidth` (18) / `maxBodyWidth` (40), `priceFormat`/`timeFormat`, `priceTicks` (5) /
|
|
227
312
|
`timeTicks` (7), `axisFont`, and `onNeedHistory`.
|
|
228
313
|
|
|
@@ -231,13 +316,19 @@ chart.destroy();
|
|
|
231
316
|
- `buildOHLC(points, interval, transform?)` — bucket priced trades into OHLC+volume
|
|
232
317
|
candles. `transform` = `{ denom, ethUsd, flip, supply }` (`supply` → market cap).
|
|
233
318
|
- `transformPrice(p, transform?)` — apply the transform to one price.
|
|
234
|
-
- `sma` / `ema` / `wma` / `vwap` / `bollingerBands` / `rsi` / `macd` / `
|
|
235
|
-
|
|
319
|
+
- `sma` / `ema` / `wma` / `vwap` / `bollingerBands` / `rsi` / `macd` / `stochastic` / `atr`
|
|
320
|
+
/ `volumeProfile` / `heikinAshi` / `computeIndicator` — pure indicator + transform math
|
|
321
|
+
(aligned to input, null until the window fills).
|
|
236
322
|
- `connectFeed(chart, feed, opts)` — wire a `ChartFeed` (paged history + live stream)
|
|
237
323
|
to a chart: latest page, lazy older history, live merge. Returns `{ disconnect }`.
|
|
238
324
|
- `hyperliquidFeed({ coin, interval, testnet })` — a ready `ChartFeed` (REST history +
|
|
239
325
|
WebSocket live). `fetchHyperliquidCandles` / `fetchHlCandleWindow` / `mapHlCandles` /
|
|
240
326
|
`HL_INTERVAL_SECONDS` are exported for custom fetching.
|
|
327
|
+
- `polymarketFeed({ tokenId, bucketSeconds })` — a `ChartFeed` for a Polymarket outcome
|
|
328
|
+
(CLOB price-history + polling). `fetchPolymarketPriceHistory` is exported.
|
|
329
|
+
- `signalsFeed({ token, bucketSeconds })` — a `ChartFeed` for a Signal Radar token: indexed
|
|
330
|
+
`allSwaps` → OHLC (lazy older pages) + a `/data`-polled live candle, spark fallback.
|
|
331
|
+
`fetchSignalsSwaps` / `fetchSignalsMarket` / `sparkCandles` are exported.
|
|
241
332
|
- `draw(input)` — the pure render pass (exported for custom hosts/tests); returns the
|
|
242
333
|
crosshair candle.
|
|
243
334
|
- `DEFAULT_THEME` / `LIGHT_THEME` / `THEME_PRESETS`, `formatValue`, `formatAxisValue`
|
|
@@ -251,7 +342,13 @@ chart.destroy();
|
|
|
251
342
|
## Interaction
|
|
252
343
|
|
|
253
344
|
- **Scroll** over the plot → zoom time; over the price axis → zoom price.
|
|
254
|
-
- **
|
|
345
|
+
- **Scroll horizontally** (trackpad swipe / shift+wheel) → pan through time; scrolling
|
|
346
|
+
back into history lazy-loads older candles from the feed.
|
|
347
|
+
- **Drag** the plot → pan; drag the time axis → zoom X; drag the price axis → zoom Y. Panning
|
|
348
|
+
pins the oldest candle to the left edge (no dragging into empty space on the left); on a feed,
|
|
349
|
+
or with `PriceChart`'s `onLoadOlder`, reaching that edge streams in older history instead. You
|
|
350
|
+
*can* scroll past the newest candle into the future — drag it up to halfway across to leave
|
|
351
|
+
right-edge whitespace (so the current price isn't pinned to the edge).
|
|
255
352
|
- **Hover** → crosshair + price/time axis labels + the OHLCV legend.
|
|
256
353
|
- **Double-click** → reset zoom/pan (or delete a drawing when one is under the cursor).
|
|
257
354
|
- **Touch** → one finger pans (or zooms an axis from its gutter); two-finger pinch
|
package/dist/core/chart.d.ts
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import type { AxisOptions, Candle, ChartOptions, ChartTheme, ChartType, DrawMode, Drawing, Indicator, Oscillator } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Whether a feed should be asked for older candles, given the loaded length and the
|
|
4
|
+
* current view. Returns true when the visible window's left edge is within ~one screen
|
|
5
|
+
* (`max(floor, visibleCount)`) of the start of loaded data — so zooming out / panning
|
|
6
|
+
* left keeps deepening the window (prefetching a viewport of buffer) instead of hitting
|
|
7
|
+
* a wall at the oldest loaded candle and just fattening the bars. Pure + exported so the
|
|
8
|
+
* prefetch trigger is testable without a DOM.
|
|
9
|
+
*/
|
|
10
|
+
export declare function needsHistory(length: number, count: number, offset: number, floor?: number): boolean;
|
|
11
|
+
/** Largest pan offset that still fills the window — pins the OLDEST candle to the left edge
|
|
12
|
+
* so a left-drag can't expose empty space past the start of the data. Pure + exported for
|
|
13
|
+
* testing; the controller clamps every pan/zoom to it. */
|
|
14
|
+
export declare function maxPanOffset(length: number, count: number): number;
|
|
2
15
|
/**
|
|
3
16
|
* Framework-agnostic candlestick/line chart with a volume panel. Creates and owns a
|
|
4
17
|
* `<canvas>` inside the given container and wires the interactions:
|
|
@@ -30,6 +43,9 @@ export declare class Chart {
|
|
|
30
43
|
private theme;
|
|
31
44
|
private height;
|
|
32
45
|
private candles;
|
|
46
|
+
/** Price-series candles (Heikin-Ashi in "heikin" mode), precomputed on data/type change
|
|
47
|
+
* so the O(n) transform doesn't run every frame. */
|
|
48
|
+
private priceCandles;
|
|
33
49
|
private indicators;
|
|
34
50
|
private oscillators;
|
|
35
51
|
private drawings;
|
|
@@ -47,6 +63,9 @@ export declare class Chart {
|
|
|
47
63
|
private type;
|
|
48
64
|
private showVolume;
|
|
49
65
|
private logScale;
|
|
66
|
+
private emptyText?;
|
|
67
|
+
private baselinePrice?;
|
|
68
|
+
private volumeProfile?;
|
|
50
69
|
private fitContent;
|
|
51
70
|
private priceFormat?;
|
|
52
71
|
private timeFormat?;
|
|
@@ -74,6 +93,12 @@ export declare class Chart {
|
|
|
74
93
|
setShowVolume(on: boolean): this;
|
|
75
94
|
/** Toggle the logarithmic price axis (equal pixels = equal % move). */
|
|
76
95
|
setLogScale(on: boolean): this;
|
|
96
|
+
/** Set the empty-state text (e.g. "Loading…" while a feed's first page is in flight; "" hides it). */
|
|
97
|
+
setEmptyText(text: string | undefined): this;
|
|
98
|
+
/** Set the reference price for the `baseline` chart type (undefined = first visible close). */
|
|
99
|
+
setBaseline(price: number | undefined): this;
|
|
100
|
+
/** Show/configure (or hide, with `false`) the volume-by-price histogram. */
|
|
101
|
+
setVolumeProfile(config: ChartOptions["volumeProfile"]): this;
|
|
77
102
|
/** Style the axes: fill-vs-tight candle spacing, custom price/time label formatters,
|
|
78
103
|
* tick counts, and the label font. Only the provided keys change. */
|
|
79
104
|
setAxis(opts: AxisOptions): this;
|
|
@@ -105,6 +130,15 @@ export declare class Chart {
|
|
|
105
130
|
private get volH();
|
|
106
131
|
private get plotW();
|
|
107
132
|
private measure;
|
|
133
|
+
/** Largest pan offset that still fills the window — pins the OLDEST candle to the left
|
|
134
|
+
* edge instead of letting a left-drag expose empty space past the start of the data.
|
|
135
|
+
* (When a feed is attached, reaching this edge trips the older-history load, so the
|
|
136
|
+
* data deepens and this max grows; without one, the pan simply stops here.) */
|
|
137
|
+
private maxOffset;
|
|
138
|
+
/** Most-negative offset — how far the view may scroll PAST the newest candle into the
|
|
139
|
+
* future (empty space on the right), so the user can pull the current price toward the
|
|
140
|
+
* centre. Capped at half the visible window. */
|
|
141
|
+
private minOffset;
|
|
108
142
|
private clampView;
|
|
109
143
|
/** Assemble the {@link RenderInput} from current state — shared by render() and projection(). */
|
|
110
144
|
private buildInput;
|
|
@@ -115,11 +149,19 @@ export declare class Chart {
|
|
|
115
149
|
private scheduleRender;
|
|
116
150
|
/** Recompute indicator value-series once when data or config changes (not per frame). */
|
|
117
151
|
private recomputeOverlays;
|
|
118
|
-
/**
|
|
119
|
-
* (
|
|
152
|
+
/** Recompute the price-series candles (Heikin-Ashi transform) on data/type change, so the
|
|
153
|
+
* O(n) pass doesn't run on every redraw or projection lookup. */
|
|
154
|
+
private recomputePriceCandles;
|
|
155
|
+
/** When the view nears the start of loaded data, ask the feed for older candles. Fires
|
|
156
|
+
* at most once per data length, so it advances page-by-page and stops at the end of
|
|
157
|
+
* history. The trigger leads the left edge by ~one viewport (see {@link needsHistory}),
|
|
158
|
+
* so zooming out keeps loading deeper data rather than stalling at the oldest candle. */
|
|
120
159
|
private maybeRequestHistory;
|
|
121
160
|
private region;
|
|
122
161
|
private readonly onWheel;
|
|
162
|
+
/** Pan the time axis by a horizontal pixel delta (wheel/trackpad). Scrolling right
|
|
163
|
+
* (`dx > 0`) moves toward newer candles; scrolling left walks back into history. */
|
|
164
|
+
private panByPixels;
|
|
123
165
|
/** Zoom the time axis by factor `f`, keeping the candle under `cursorX` anchored. */
|
|
124
166
|
private zoomTimeAt;
|
|
125
167
|
/** Topmost drawing under the pointer (within tolerance), or null. */
|
package/dist/core/chart.js
CHANGED
|
@@ -1,7 +1,29 @@
|
|
|
1
1
|
import { DEFAULT_THEME } from "./theme";
|
|
2
|
+
import { heikinAshi } from "./ohlc";
|
|
2
3
|
import { computeProjection, draw, resolveOverlays, slotWidth } from "./renderer";
|
|
3
4
|
let drawingSeq = 0;
|
|
4
5
|
const nextDrawingId = () => `d${++drawingSeq}`;
|
|
6
|
+
/**
|
|
7
|
+
* Whether a feed should be asked for older candles, given the loaded length and the
|
|
8
|
+
* current view. Returns true when the visible window's left edge is within ~one screen
|
|
9
|
+
* (`max(floor, visibleCount)`) of the start of loaded data — so zooming out / panning
|
|
10
|
+
* left keeps deepening the window (prefetching a viewport of buffer) instead of hitting
|
|
11
|
+
* a wall at the oldest loaded candle and just fattening the bars. Pure + exported so the
|
|
12
|
+
* prefetch trigger is testable without a DOM.
|
|
13
|
+
*/
|
|
14
|
+
export function needsHistory(length, count, offset, floor = 8) {
|
|
15
|
+
if (length <= 0)
|
|
16
|
+
return false;
|
|
17
|
+
const visible = Math.min(count, length);
|
|
18
|
+
const start = Math.max(0, length - offset - visible);
|
|
19
|
+
return start <= Math.max(floor, visible);
|
|
20
|
+
}
|
|
21
|
+
/** Largest pan offset that still fills the window — pins the OLDEST candle to the left edge
|
|
22
|
+
* so a left-drag can't expose empty space past the start of the data. Pure + exported for
|
|
23
|
+
* testing; the controller clamps every pan/zoom to it. */
|
|
24
|
+
export function maxPanOffset(length, count) {
|
|
25
|
+
return Math.max(0, length - Math.min(count, length));
|
|
26
|
+
}
|
|
5
27
|
/** Pixel distance from point P to segment A–B (for drawing hit-testing). */
|
|
6
28
|
function distToSeg(px, py, ax, ay, bx, by) {
|
|
7
29
|
const dx = bx - ax;
|
|
@@ -41,6 +63,9 @@ export class Chart {
|
|
|
41
63
|
constructor(container, opts = {}) {
|
|
42
64
|
this.container = container;
|
|
43
65
|
this.candles = [];
|
|
66
|
+
/** Price-series candles (Heikin-Ashi in "heikin" mode), precomputed on data/type change
|
|
67
|
+
* so the O(n) transform doesn't run every frame. */
|
|
68
|
+
this.priceCandles = [];
|
|
44
69
|
this.indicators = [];
|
|
45
70
|
this.oscillators = [];
|
|
46
71
|
this.drawings = [];
|
|
@@ -70,6 +95,13 @@ export class Chart {
|
|
|
70
95
|
this.lastActive = null;
|
|
71
96
|
this.onWheel = (e) => {
|
|
72
97
|
e.preventDefault();
|
|
98
|
+
// Horizontal intent (trackpad swipe / shift+wheel) pans through time — which lazy-loads
|
|
99
|
+
// older candles as the view nears the start (see maybeRequestHistory). Vertical zooms.
|
|
100
|
+
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
|
101
|
+
this.panByPixels(e.deltaX);
|
|
102
|
+
this.scheduleRender();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
73
105
|
const f = e.deltaY > 0 ? 1.15 : 0.87;
|
|
74
106
|
if (e.offsetX > this.plotW) {
|
|
75
107
|
this.yZoom = Math.min(50, Math.max(0.2, this.yZoom / f));
|
|
@@ -94,9 +126,9 @@ export class Chart {
|
|
|
94
126
|
this.render();
|
|
95
127
|
return;
|
|
96
128
|
}
|
|
97
|
-
if (this.drawMode === "trendline") {
|
|
129
|
+
if (this.drawMode === "trendline" || this.drawMode === "fib" || this.drawMode === "rect") {
|
|
98
130
|
const p = { time: proj.timeOfX(x), price: proj.priceOfY(y) };
|
|
99
|
-
this.draftDrawing = { id: nextDrawingId(), type:
|
|
131
|
+
this.draftDrawing = { id: nextDrawingId(), type: this.drawMode, a: p, b: { ...p } };
|
|
100
132
|
this.render();
|
|
101
133
|
return;
|
|
102
134
|
}
|
|
@@ -271,6 +303,9 @@ export class Chart {
|
|
|
271
303
|
this.onDrawingsChange = opts.onDrawingsChange;
|
|
272
304
|
this.onDrawModeChange = opts.onDrawModeChange;
|
|
273
305
|
this.logScale = opts.logScale ?? false;
|
|
306
|
+
this.emptyText = opts.emptyText;
|
|
307
|
+
this.baselinePrice = opts.baselinePrice;
|
|
308
|
+
this.volumeProfile = opts.volumeProfile;
|
|
274
309
|
this.fitContent = opts.fitContent ?? true;
|
|
275
310
|
this.priceFormat = opts.priceFormat;
|
|
276
311
|
this.timeFormat = opts.timeFormat;
|
|
@@ -303,6 +338,7 @@ export class Chart {
|
|
|
303
338
|
}
|
|
304
339
|
setCandles(candles) {
|
|
305
340
|
this.candles = candles;
|
|
341
|
+
this.recomputePriceCandles();
|
|
306
342
|
this.recomputeOverlays();
|
|
307
343
|
this.clampView();
|
|
308
344
|
this.render();
|
|
@@ -315,6 +351,7 @@ export class Chart {
|
|
|
315
351
|
}
|
|
316
352
|
setChartType(type) {
|
|
317
353
|
this.type = type;
|
|
354
|
+
this.recomputePriceCandles();
|
|
318
355
|
this.render();
|
|
319
356
|
return this;
|
|
320
357
|
}
|
|
@@ -330,6 +367,24 @@ export class Chart {
|
|
|
330
367
|
this.render();
|
|
331
368
|
return this;
|
|
332
369
|
}
|
|
370
|
+
/** Set the empty-state text (e.g. "Loading…" while a feed's first page is in flight; "" hides it). */
|
|
371
|
+
setEmptyText(text) {
|
|
372
|
+
this.emptyText = text;
|
|
373
|
+
this.render();
|
|
374
|
+
return this;
|
|
375
|
+
}
|
|
376
|
+
/** Set the reference price for the `baseline` chart type (undefined = first visible close). */
|
|
377
|
+
setBaseline(price) {
|
|
378
|
+
this.baselinePrice = price;
|
|
379
|
+
this.render();
|
|
380
|
+
return this;
|
|
381
|
+
}
|
|
382
|
+
/** Show/configure (or hide, with `false`) the volume-by-price histogram. */
|
|
383
|
+
setVolumeProfile(config) {
|
|
384
|
+
this.volumeProfile = config;
|
|
385
|
+
this.render();
|
|
386
|
+
return this;
|
|
387
|
+
}
|
|
333
388
|
/** Style the axes: fill-vs-tight candle spacing, custom price/time label formatters,
|
|
334
389
|
* tick counts, and the label font. Only the provided keys change. */
|
|
335
390
|
setAxis(opts) {
|
|
@@ -472,10 +527,23 @@ export class Chart {
|
|
|
472
527
|
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
473
528
|
this.render();
|
|
474
529
|
}
|
|
530
|
+
/** Largest pan offset that still fills the window — pins the OLDEST candle to the left
|
|
531
|
+
* edge instead of letting a left-drag expose empty space past the start of the data.
|
|
532
|
+
* (When a feed is attached, reaching this edge trips the older-history load, so the
|
|
533
|
+
* data deepens and this max grows; without one, the pan simply stops here.) */
|
|
534
|
+
maxOffset(count = this.view.count) {
|
|
535
|
+
return maxPanOffset(this.candles.length, count);
|
|
536
|
+
}
|
|
537
|
+
/** Most-negative offset — how far the view may scroll PAST the newest candle into the
|
|
538
|
+
* future (empty space on the right), so the user can pull the current price toward the
|
|
539
|
+
* centre. Capped at half the visible window. */
|
|
540
|
+
minOffset(count = this.view.count) {
|
|
541
|
+
return -Math.floor(Math.min(count, this.candles.length) / 2);
|
|
542
|
+
}
|
|
475
543
|
clampView() {
|
|
476
544
|
const n = this.candles.length;
|
|
477
545
|
const count = Math.min(Math.max(this.minBars, this.view.count), Math.max(this.minBars, n || this.minBars));
|
|
478
|
-
const offset = Math.min(Math.max(
|
|
546
|
+
const offset = Math.min(Math.max(this.minOffset(count), this.view.offset), this.maxOffset(count));
|
|
479
547
|
this.view = { count, offset };
|
|
480
548
|
}
|
|
481
549
|
/** Assemble the {@link RenderInput} from current state — shared by render() and projection(). */
|
|
@@ -485,6 +553,7 @@ export class Chart {
|
|
|
485
553
|
width: this.width,
|
|
486
554
|
height: this.height,
|
|
487
555
|
candles: this.candles,
|
|
556
|
+
priceCandles: this.priceCandles,
|
|
488
557
|
view: this.view,
|
|
489
558
|
hover: this.hover,
|
|
490
559
|
interval: this.interval,
|
|
@@ -501,6 +570,9 @@ export class Chart {
|
|
|
501
570
|
drawPreview: this.draftDrawing,
|
|
502
571
|
selectedDrawing: this.selectedDrawing,
|
|
503
572
|
logScale: this.logScale,
|
|
573
|
+
emptyText: this.emptyText,
|
|
574
|
+
baselinePrice: this.baselinePrice,
|
|
575
|
+
volumeProfile: this.volumeProfile,
|
|
504
576
|
fitContent: this.fitContent,
|
|
505
577
|
priceFormat: this.priceFormat,
|
|
506
578
|
timeFormat: this.timeFormat,
|
|
@@ -541,14 +613,19 @@ export class Chart {
|
|
|
541
613
|
recomputeOverlays() {
|
|
542
614
|
this.overlays = resolveOverlays(this.candles, this.indicators);
|
|
543
615
|
}
|
|
544
|
-
/**
|
|
545
|
-
* (
|
|
616
|
+
/** Recompute the price-series candles (Heikin-Ashi transform) on data/type change, so the
|
|
617
|
+
* O(n) pass doesn't run on every redraw or projection lookup. */
|
|
618
|
+
recomputePriceCandles() {
|
|
619
|
+
this.priceCandles = this.type === "heikin" ? heikinAshi(this.candles) : this.candles;
|
|
620
|
+
}
|
|
621
|
+
/** When the view nears the start of loaded data, ask the feed for older candles. Fires
|
|
622
|
+
* at most once per data length, so it advances page-by-page and stops at the end of
|
|
623
|
+
* history. The trigger leads the left edge by ~one viewport (see {@link needsHistory}),
|
|
624
|
+
* so zooming out keeps loading deeper data rather than stalling at the oldest candle. */
|
|
546
625
|
maybeRequestHistory() {
|
|
547
626
|
if (!this.onNeedHistory || !this.candles.length)
|
|
548
627
|
return;
|
|
549
|
-
|
|
550
|
-
const start = Math.max(0, end - Math.min(this.view.count, this.candles.length));
|
|
551
|
-
if (start <= this.historyThreshold && this.historyReqLen !== this.candles.length) {
|
|
628
|
+
if (needsHistory(this.candles.length, this.view.count, this.view.offset, this.historyThreshold) && this.historyReqLen !== this.candles.length) {
|
|
552
629
|
this.historyReqLen = this.candles.length;
|
|
553
630
|
this.onNeedHistory();
|
|
554
631
|
}
|
|
@@ -560,6 +637,16 @@ export class Chart {
|
|
|
560
637
|
return "x";
|
|
561
638
|
return "plot";
|
|
562
639
|
}
|
|
640
|
+
/** Pan the time axis by a horizontal pixel delta (wheel/trackpad). Scrolling right
|
|
641
|
+
* (`dx > 0`) moves toward newer candles; scrolling left walks back into history. */
|
|
642
|
+
panByPixels(dx) {
|
|
643
|
+
const n = this.candles.length;
|
|
644
|
+
if (!n)
|
|
645
|
+
return;
|
|
646
|
+
const cw = slotWidth(this.plotW, Math.min(this.view.count, n), this.maxBarWidth, this.fitContent) || 1;
|
|
647
|
+
const offset = Math.min(this.maxOffset(), Math.max(this.minOffset(), this.view.offset - Math.round(dx / cw)));
|
|
648
|
+
this.view = { ...this.view, offset };
|
|
649
|
+
}
|
|
563
650
|
/** Zoom the time axis by factor `f`, keeping the candle under `cursorX` anchored. */
|
|
564
651
|
zoomTimeAt(cursorX, f) {
|
|
565
652
|
const n = this.candles.length;
|
|
@@ -572,7 +659,7 @@ export class Chart {
|
|
|
572
659
|
const count = Math.round(Math.min(Math.max(this.minBars, this.view.count * f), Math.max(this.minBars, n)));
|
|
573
660
|
const nVisNew = Math.min(count, n);
|
|
574
661
|
const startNew = absUnder - Math.round(frac * (nVisNew - 1));
|
|
575
|
-
const offset = Math.max(0, Math.min(
|
|
662
|
+
const offset = Math.max(0, Math.min(this.maxOffset(count), n - startNew - nVisNew));
|
|
576
663
|
this.view = { count, offset };
|
|
577
664
|
}
|
|
578
665
|
/** Topmost drawing under the pointer (within tolerance), or null. */
|
|
@@ -584,6 +671,28 @@ export class Chart {
|
|
|
584
671
|
if (Math.abs(y - proj.yOfPrice(d.a.price)) <= TOL)
|
|
585
672
|
return d;
|
|
586
673
|
}
|
|
674
|
+
else if (d.type === "fib" && d.b) {
|
|
675
|
+
const ax = proj.xOfTime(d.a.time);
|
|
676
|
+
const bx = proj.xOfTime(d.b.time);
|
|
677
|
+
if (x >= Math.min(ax, bx) - TOL) {
|
|
678
|
+
const p0 = d.a.price;
|
|
679
|
+
const p1 = d.b.price;
|
|
680
|
+
for (const r of [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1]) {
|
|
681
|
+
if (Math.abs(y - proj.yOfPrice(p0 + (p1 - p0) * r)) <= TOL)
|
|
682
|
+
return d;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
if (distToSeg(x, y, ax, proj.yOfPrice(d.a.price), bx, proj.yOfPrice(d.b.price)) <= TOL)
|
|
686
|
+
return d;
|
|
687
|
+
}
|
|
688
|
+
else if (d.type === "rect" && d.b) {
|
|
689
|
+
const ax = proj.xOfTime(d.a.time);
|
|
690
|
+
const ay = proj.yOfPrice(d.a.price);
|
|
691
|
+
const bx = proj.xOfTime(d.b.time);
|
|
692
|
+
const by = proj.yOfPrice(d.b.price);
|
|
693
|
+
if (x >= Math.min(ax, bx) - TOL && x <= Math.max(ax, bx) + TOL && y >= Math.min(ay, by) - TOL && y <= Math.max(ay, by) + TOL)
|
|
694
|
+
return d;
|
|
695
|
+
}
|
|
587
696
|
else if (d.b) {
|
|
588
697
|
const dist = distToSeg(x, y, proj.xOfTime(d.a.time), proj.yOfPrice(d.a.price), proj.xOfTime(d.b.time), proj.yOfPrice(d.b.price));
|
|
589
698
|
if (dist <= TOL)
|
|
@@ -618,11 +727,14 @@ export class Chart {
|
|
|
618
727
|
}
|
|
619
728
|
else if (d.reg === "x") {
|
|
620
729
|
const n = this.candles.length;
|
|
621
|
-
|
|
730
|
+
const count = Math.min(Math.max(this.minBars, Math.round(d.count * Math.exp((clientX - d.x) / 260))), Math.max(this.minBars, n));
|
|
731
|
+
// a wider window changes the offset bounds — re-clamp so zooming the x-axis never voids
|
|
732
|
+
// the left nor over-scrolls the right.
|
|
733
|
+
this.view = { count, offset: Math.min(this.maxOffset(count), Math.max(this.minOffset(count), this.view.offset)) };
|
|
622
734
|
}
|
|
623
735
|
else {
|
|
624
736
|
const cw = slotWidth(this.plotW, Math.min(this.view.count, this.candles.length || 1), this.maxBarWidth, this.fitContent);
|
|
625
|
-
const offset = Math.
|
|
737
|
+
const offset = Math.min(this.maxOffset(), Math.max(this.minOffset(), d.offset + Math.round((clientX - d.x) / cw)));
|
|
626
738
|
this.view = { ...this.view, offset };
|
|
627
739
|
}
|
|
628
740
|
return true;
|
package/dist/core/feed.js
CHANGED
|
@@ -40,14 +40,24 @@ export function connectFeed(chart, feed, opts) {
|
|
|
40
40
|
return;
|
|
41
41
|
loadingOlder = true;
|
|
42
42
|
try {
|
|
43
|
+
const oldest = buf.length ? buf[0].time : Infinity;
|
|
43
44
|
const before = buf.length ? buf[0].time * 1000 : undefined;
|
|
44
45
|
const older = await feed.loadHistory({ interval: opts.interval, before, limit });
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
// Merge by time. An incoming candle for an EXISTING bucket replaces it — a feed that
|
|
47
|
+
// re-buckets accumulated data (e.g. signalsFeed) returns a more-complete boundary
|
|
48
|
+
// candle than the partial one already shown. Strictly-older candles extend history.
|
|
49
|
+
// Stop only when nothing older than the current oldest arrives.
|
|
50
|
+
let addedOlder = false;
|
|
51
|
+
const byTime = new Map(buf.map((c) => [c.time, c]));
|
|
52
|
+
for (const c of older) {
|
|
53
|
+
if (c.time < oldest)
|
|
54
|
+
addedOlder = true;
|
|
55
|
+
byTime.set(c.time, c);
|
|
56
|
+
}
|
|
57
|
+
if (!addedOlder)
|
|
48
58
|
exhausted = true;
|
|
49
59
|
else
|
|
50
|
-
push([...
|
|
60
|
+
push([...byTime.values()].sort((a, b) => a.time - b.time));
|
|
51
61
|
}
|
|
52
62
|
catch (e) {
|
|
53
63
|
opts.onError?.(e);
|
|
@@ -58,6 +68,10 @@ export function connectFeed(chart, feed, opts) {
|
|
|
58
68
|
};
|
|
59
69
|
chart.setInterval(opts.interval);
|
|
60
70
|
chart.setNeedHistory(() => void loadOlder());
|
|
71
|
+
// Clear any candles from a previous connection (e.g. an interval switch reconnects with a
|
|
72
|
+
// new feed): otherwise the old bars linger, mislabeled at the new interval, until the first
|
|
73
|
+
// page arrives. Reset to empty so the host's loading state shows instead of stale data.
|
|
74
|
+
chart.setCandles([]);
|
|
61
75
|
// initial page + live subscription
|
|
62
76
|
void (async () => {
|
|
63
77
|
try {
|
|
@@ -67,12 +81,19 @@ export function connectFeed(chart, feed, opts) {
|
|
|
67
81
|
catch (e) {
|
|
68
82
|
opts.onError?.(e);
|
|
69
83
|
}
|
|
70
|
-
|
|
71
|
-
|
|
84
|
+
// Guard against a disconnect that happened DURING the initial load: don't open a live
|
|
85
|
+
// stream after teardown (it would leak a WebSocket / poll timer), and if disconnect
|
|
86
|
+
// races the subscribe call, tear the stream down immediately.
|
|
87
|
+
if (feed.subscribe && live) {
|
|
88
|
+
const u = feed.subscribe({ interval: opts.interval }, (c) => {
|
|
72
89
|
const next = mergeLive(buf, c);
|
|
73
90
|
if (next !== buf)
|
|
74
91
|
push(next);
|
|
75
92
|
});
|
|
93
|
+
if (live)
|
|
94
|
+
unsub = u;
|
|
95
|
+
else
|
|
96
|
+
u();
|
|
76
97
|
}
|
|
77
98
|
})();
|
|
78
99
|
return {
|