@livo-build/charts 0.2.2 → 0.2.3

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
@@ -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 **or** a line series, a
13
- **volume panel**, a **crosshair with price + time axis labels**, an **OHLCV legend**,
14
- independent **X and Y zoom** (+ pan, plus touch/pinch), and a toolbar for **intervals,
15
- USD/ETH, price/market-cap, log scale and fullscreen**. The architecture (a framework-agnostic `Chart`
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** below the volume
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)` and
100
- `macd(values, fast, slow, signal) → { macd, signal, hist }` helpers are exported too.
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's **↗** (trendline) and **—** (horizontal line) buttons arm a one-shot draw:
140
- drag for a trendline, click for a horizontal price line. Drawings are anchored in **data
141
- space**, so they stay pinned to their price/time across pan, zoom and live updates. In the
142
- default cursor mode, click a line to select it, drag to move it, and **double-click a line
143
- to delete it**; **⌫** clears all.
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, and the controller only asks for more when you scroll near the start.
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), `logScale` (false), `fitContent` (false),
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` / `computeIndicator`
235
- pure indicator math (aligned to input, null until the window fills).
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
- - **Drag** the plot → pan; drag the time axis → zoom X; drag the price axis → zoom Y.
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
@@ -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
- /** When the view nears the start of loaded data, ask the feed for older candles
119
- * (once per data length, so it stops at the end of history). */
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. */
@@ -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: "trendline", a: p, b: { ...p } };
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(0, this.view.offset), Math.max(0, n - this.minBars));
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
- /** When the view nears the start of loaded data, ask the feed for older candles
545
- * (once per data length, so it stops at the end of history). */
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
- const end = this.candles.length - this.view.offset;
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(Math.max(0, n - this.minBars), n - startNew - nVisNew));
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
- this.view = { ...this.view, count: Math.min(Math.max(this.minBars, Math.round(d.count * Math.exp((clientX - d.x) / 260))), Math.max(this.minBars, n)) };
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.max(0, Math.min(this.candles.length - this.minBars, d.offset + Math.round((clientX - d.x) / cw)));
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
- const seen = new Set(buf.map((c) => c.time));
46
- const fresh = older.filter((c) => !seen.has(c.time)).sort((a, b) => a.time - b.time);
47
- if (fresh.length === 0)
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([...fresh, ...buf]);
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
- if (feed.subscribe) {
71
- unsub = feed.subscribe({ interval: opts.interval }, (c) => {
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 {