@livo-build/charts 0.2.1

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 ADDED
@@ -0,0 +1,189 @@
1
+ # @livo-build/charts (livo-charts)
2
+
3
+ A lightweight, **dependency-free** canvas charting library. The core renders to a
4
+ `<canvas>` with zero runtime dependencies; a thin, self-styled React wrapper is
5
+ shipped under a separate entry so non-React consumers never pull React into their
6
+ bundle.
7
+
8
+ It ships a **TradingView-style trading chart**: candlesticks **or** a line series, a
9
+ **volume panel**, a **crosshair with price + time axis labels**, an **OHLCV legend**,
10
+ independent **X and Y zoom** (+ pan), and a toolbar for **intervals, USD/ETH,
11
+ price/market-cap and fullscreen**. The architecture (a framework-agnostic `Chart`
12
+ controller + a pure `draw()` pass) is built to keep growing — indicators, overlays,
13
+ multiple panes — without touching consumers.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm i @livo-build/charts
19
+ ```
20
+
21
+ `react` / `react-dom` are **optional** peers — only needed for the React entry.
22
+
23
+ ## React
24
+
25
+ ```tsx
26
+ import { PriceChart } from "@livo-build/charts/react";
27
+
28
+ <PriceChart
29
+ swaps={trades} // [{ t: unixSeconds, p: priceUsd, v: usdVolume }, …]
30
+ ethUsd={1711.81} // enables the USD↔ETH toggle
31
+ tokenAddress="0x…" // enables the Price↔MCap toggle (reads totalSupply())
32
+ decimals={18}
33
+ symbol="PEPE"
34
+ quote="WETH"
35
+ height={420}
36
+ />
37
+ ```
38
+
39
+ `swaps` are aggregated into OHLC+volume candles client-side. The toolbar
40
+ (`1s 1m 5m 15m 1h 4h 1d`, candle/line, Price/MCap, USD/ETH, flip, fullscreen) and the
41
+ OHLCV legend are built in. The canvas is long-lived, so zoom/pan survive prop updates
42
+ (streaming trades won't reset the user's view). The wrapper is styled inline — no CSS
43
+ framework required.
44
+
45
+ Market cap = `price × totalSupply()`, fetched once via `rpcUrl` (default
46
+ `https://cloudflare-eth.com`) and cached; it silently falls back to price if the RPC
47
+ is unavailable.
48
+
49
+ ### Live Hyperliquid chart
50
+
51
+ A turnkey, **live** trading chart for any Hyperliquid market — fetches the public
52
+ candle feed (no API key), polls for updates (preserving the user's zoom/pan), and
53
+ supports moving-average overlays:
54
+
55
+ ```tsx
56
+ import { HyperliquidChart } from "@livo-build/charts/react";
57
+
58
+ <HyperliquidChart
59
+ coin="BTC" // or "ETH", or a builder-dex name like "xyz:TSLA"
60
+ interval="15m" // 1m 5m 15m 1h 4h 1d (toolbar lets the user switch)
61
+ indicators={[{ type: "ema", period: 21 }, { type: "sma", period: 50 }]}
62
+ refetchMs={15000} // poll cadence; 0 = fetch once
63
+ height={420}
64
+ />
65
+ ```
66
+
67
+ `<HyperliquidChart>` is **realtime**: live candles stream over WebSocket, and older
68
+ history loads lazily as the user scrolls left (infinite back-scroll) — both with no
69
+ API key. Zoom/pan survive every update.
70
+
71
+ ### Indicators
72
+
73
+ `PriceChart` and `HyperliquidChart` accept `indicators` — overlays drawn on the price
74
+ pane. Each is `{ type: "sma" | "ema" | "wma" | "vwap", period, color?, source? }`
75
+ (`source` defaults to `close`; `vwap` is cumulative and ignores period/source).
76
+ Colors fall back to a built-in palette by position. The pure
77
+ `sma`/`ema`/`wma`/`vwap`/`bollingerBands`/`computeIndicator` helpers are exported from
78
+ the core (e.g. feed `bollingerBands(values).{upper,mid,lower}` as three overlays).
79
+
80
+ ### Realtime feeds + lazy history (any data source)
81
+
82
+ Connect a chart to a **feed** — a source that serves paged OHLCV history *and* live
83
+ updates — and `connectFeed` handles the latest page, lazy older-history loading on
84
+ left-scroll, and merging live candles (updating the in-progress bucket or appending):
85
+
86
+ ```ts
87
+ import { Chart, connectFeed, hyperliquidFeed, HL_INTERVAL_SECONDS } from "@livo-build/charts";
88
+
89
+ const chart = new Chart(el, { height: 420 });
90
+ const conn = connectFeed(chart, hyperliquidFeed({ coin: "BTC", interval: "1m" }), {
91
+ interval: HL_INTERVAL_SECONDS["1m"],
92
+ pageSize: 500,
93
+ });
94
+ // …later
95
+ conn.disconnect();
96
+ ```
97
+
98
+ Bring your own source by implementing `ChartFeed`:
99
+
100
+ ```ts
101
+ const feed = {
102
+ loadHistory: ({ interval, before, limit }) => fetchMyCandles(before, limit), // [] at the end
103
+ subscribe: ({ interval }, onCandle) => mySocket.onCandle(onCandle), // returns unsubscribe
104
+ };
105
+ ```
106
+
107
+ The chart's right-anchored view means prepending history keeps the visible window
108
+ stable, and the controller only asks for more when you scroll near the start.
109
+
110
+ ## Vanilla / framework-agnostic
111
+
112
+ ```ts
113
+ import { Chart, buildOHLC } from "@livo-build/charts";
114
+
115
+ const chart = new Chart(containerEl, {
116
+ height: 420,
117
+ onCrosshair: (candle) => renderLegend(candle), // null when empty; last candle when idle
118
+ });
119
+ chart.setInterval(300).setChartType("candle");
120
+ chart.setCandles(buildOHLC(trades, 300, { denom: "ETH", ethUsd: 1711.81 }));
121
+
122
+ // streaming update — view (zoom/pan) is preserved
123
+ chart.setCandles(buildOHLC(moreTrades, 300));
124
+
125
+ chart.destroy();
126
+ ```
127
+
128
+ ### `Chart` API
129
+
130
+ | Method | Description |
131
+ | --- | --- |
132
+ | `new Chart(container, options?)` | Creates a `<canvas>`, wires interaction, observes resize. |
133
+ | `setCandles(candles)` | Replace the series (each candle has `vol`) and redraw. |
134
+ | `setInterval(seconds)` | Bucket interval — drives time-axis label granularity. |
135
+ | `setChartType("candle" \| "line")` | Switch series style. |
136
+ | `setIndicators(indicators)` | Set the moving-average overlays and redraw. |
137
+ | `setNeedHistory(cb)` | Callback fired when the view nears the start of loaded data (lazy history). |
138
+ | `setHeight(px)` / `setTheme(partial)` | Resize / merge theme overrides. |
139
+ | `resetView()` | Reset zoom/pan (same as double-click). |
140
+ | `destroy()` | Remove listeners, observer and the canvas. |
141
+
142
+ `ChartOptions`: `height`, `theme`, `initialBars` (120), `minBars` (20),
143
+ `maxBarWidth` (18 — caps candle spacing so sparse series stay tight, right-anchored),
144
+ `volumeRatio` (0.18), `rightPad`/`bottomPad`/`topPad`, `onCrosshair`, `indicators`,
145
+ and `onNeedHistory`.
146
+
147
+ ### Helpers
148
+
149
+ - `buildOHLC(points, interval, transform?)` — bucket priced trades into OHLC+volume
150
+ candles. `transform` = `{ denom, ethUsd, flip, supply }` (`supply` → market cap).
151
+ - `transformPrice(p, transform?)` — apply the transform to one price.
152
+ - `sma` / `ema` / `wma` / `vwap` / `bollingerBands` / `computeIndicator` — pure
153
+ indicator math (aligned to input, null until the window fills).
154
+ - `connectFeed(chart, feed, opts)` — wire a `ChartFeed` (paged history + live stream)
155
+ to a chart: latest page, lazy older history, live merge. Returns `{ disconnect }`.
156
+ - `hyperliquidFeed({ coin, interval, testnet })` — a ready `ChartFeed` (REST history +
157
+ WebSocket live). `fetchHyperliquidCandles` / `fetchHlCandleWindow` / `mapHlCandles` /
158
+ `HL_INTERVAL_SECONDS` are exported for custom fetching.
159
+ - `draw(input)` — the pure render pass (exported for custom hosts/tests); returns the
160
+ crosshair candle.
161
+ - `DEFAULT_THEME`, `formatValue`, `formatVolume`, `formatTime`.
162
+
163
+ ## Interaction
164
+
165
+ - **Scroll** over the plot → zoom time; over the price axis → zoom price.
166
+ - **Drag** the plot → pan; drag the time axis → zoom X; drag the price axis → zoom Y.
167
+ - **Hover** → crosshair + price/time axis labels + the OHLCV legend.
168
+ - **Double-click** → reset zoom/pan.
169
+
170
+ ## Design notes
171
+
172
+ - **No dependencies.** The core is plain canvas 2D + `ResizeObserver`.
173
+ - **Core vs bindings.** `@livo-build/charts` is vanilla (and tailwind-free);
174
+ `@livo-build/charts/react` adds the toolbar/legend. Add more bindings the same way.
175
+ - **`draw()` is stateless.** All zoom/pan/hover state lives in the `Chart` controller.
176
+ - **Render coalescing.** High-frequency input (drag/hover/wheel) is batched into one
177
+ `requestAnimationFrame` redraw, and indicator series are precomputed on data change
178
+ (not per frame) — so panning a large series stays smooth.
179
+
180
+ ## Develop
181
+
182
+ ```bash
183
+ npm run typecheck # tsc --noEmit
184
+ npm run test # vitest (OHLC/volume aggregation + transforms)
185
+ npm run build # tsc → dist/ (index + react entries, with .d.ts)
186
+ ```
187
+
188
+ Published to npm via CI (`.github/workflows/publish-charts.yml`) on push to `main`
189
+ when `packages/charts/**` changes. Bump `version` in `package.json` to release.
@@ -0,0 +1,81 @@
1
+ import type { Candle, ChartOptions, ChartTheme, ChartType, Indicator } from "./types";
2
+ /**
3
+ * Framework-agnostic candlestick/line chart with a volume panel. Creates and owns a
4
+ * `<canvas>` inside the given container and wires the interactions:
5
+ * - wheel over the plot → zoom the time axis; wheel over the price axis → zoom price
6
+ * - drag the plot → pan; drag the time axis → zoom X; drag the price axis → zoom Y
7
+ * - hover → crosshair + axis labels, and `onCrosshair(candle)` for an external legend
8
+ * - double-click → reset zoom/pan
9
+ *
10
+ * ```ts
11
+ * const chart = new Chart(el, { height: 420, onCrosshair: (c) => renderLegend(c) });
12
+ * chart.setInterval(300).setCandles(buildOHLC(trades, 300));
13
+ * chart.setChartType("line");
14
+ * chart.destroy();
15
+ * ```
16
+ * The React wrapper in `@livo-build/charts/react` adds the toolbar (intervals, line/candle,
17
+ * USD/ETH, price/mcap, fullscreen) on top of this.
18
+ */
19
+ export declare class Chart {
20
+ private readonly container;
21
+ private readonly canvas;
22
+ private readonly ctx;
23
+ private readonly ro;
24
+ private readonly pads;
25
+ private readonly minBars;
26
+ private readonly maxBarWidth;
27
+ private readonly volumeRatio;
28
+ private readonly onCrosshair?;
29
+ private theme;
30
+ private height;
31
+ private candles;
32
+ private indicators;
33
+ /** Indicator values precomputed on data/indicator change (not per frame). */
34
+ private overlays;
35
+ private interval;
36
+ private type;
37
+ /** requestAnimationFrame handle coalescing high-frequency redraws (0 = none queued). */
38
+ private raf;
39
+ private onNeedHistory?;
40
+ private historyReqLen;
41
+ private readonly historyThreshold;
42
+ private width;
43
+ private view;
44
+ private yZoom;
45
+ private hover;
46
+ private drag;
47
+ private lastActive;
48
+ constructor(container: HTMLElement, opts?: ChartOptions);
49
+ setCandles(candles: Candle[]): this;
50
+ setInterval(interval: number): this;
51
+ setChartType(type: ChartType): this;
52
+ setIndicators(indicators: Indicator[]): this;
53
+ /** Register a callback fired when the user pans/zooms near the start of loaded data
54
+ * (so a feed can lazily load older candles). See connectFeed. */
55
+ setNeedHistory(cb: (() => void) | undefined): this;
56
+ setHeight(height: number): this;
57
+ setTheme(theme: Partial<ChartTheme>): this;
58
+ resetView(): this;
59
+ destroy(): void;
60
+ private get volH();
61
+ private get plotW();
62
+ private measure;
63
+ private clampView;
64
+ private render;
65
+ /** Coalesce high-frequency redraws (drag/hover/wheel) into one per animation frame. */
66
+ private scheduleRender;
67
+ /** Recompute indicator value-series once when data or config changes (not per frame). */
68
+ private recomputeOverlays;
69
+ /** When the view nears the start of loaded data, ask the feed for older candles
70
+ * (once per data length, so it stops at the end of history). */
71
+ private maybeRequestHistory;
72
+ private region;
73
+ private readonly onWheel;
74
+ /** Zoom the time axis by factor `f`, keeping the candle under `cursorX` anchored. */
75
+ private zoomTimeAt;
76
+ private readonly onDown;
77
+ private readonly onMove;
78
+ private readonly onUp;
79
+ private readonly onLeave;
80
+ private readonly onDouble;
81
+ }
@@ -0,0 +1,299 @@
1
+ import { DEFAULT_THEME } from "./theme";
2
+ import { draw } from "./renderer";
3
+ import { computeIndicator, INDICATOR_PALETTE } from "./indicators";
4
+ const DEFAULTS = {
5
+ height: 420,
6
+ initialBars: 120,
7
+ minBars: 20,
8
+ maxBarWidth: 18,
9
+ volumeRatio: 0.18,
10
+ rightPad: 66,
11
+ bottomPad: 22,
12
+ topPad: 8,
13
+ };
14
+ /**
15
+ * Framework-agnostic candlestick/line chart with a volume panel. Creates and owns a
16
+ * `<canvas>` inside the given container and wires the interactions:
17
+ * - wheel over the plot → zoom the time axis; wheel over the price axis → zoom price
18
+ * - drag the plot → pan; drag the time axis → zoom X; drag the price axis → zoom Y
19
+ * - hover → crosshair + axis labels, and `onCrosshair(candle)` for an external legend
20
+ * - double-click → reset zoom/pan
21
+ *
22
+ * ```ts
23
+ * const chart = new Chart(el, { height: 420, onCrosshair: (c) => renderLegend(c) });
24
+ * chart.setInterval(300).setCandles(buildOHLC(trades, 300));
25
+ * chart.setChartType("line");
26
+ * chart.destroy();
27
+ * ```
28
+ * The React wrapper in `@livo-build/charts/react` adds the toolbar (intervals, line/candle,
29
+ * USD/ETH, price/mcap, fullscreen) on top of this.
30
+ */
31
+ export class Chart {
32
+ constructor(container, opts = {}) {
33
+ this.container = container;
34
+ this.candles = [];
35
+ this.indicators = [];
36
+ /** Indicator values precomputed on data/indicator change (not per frame). */
37
+ this.overlays = [];
38
+ this.interval = 300;
39
+ this.type = "candle";
40
+ /** requestAnimationFrame handle coalescing high-frequency redraws (0 = none queued). */
41
+ this.raf = 0;
42
+ this.historyReqLen = -1;
43
+ this.historyThreshold = 8;
44
+ this.width = 0;
45
+ this.yZoom = 1;
46
+ this.hover = null;
47
+ this.drag = null;
48
+ this.lastActive = null;
49
+ this.onWheel = (e) => {
50
+ e.preventDefault();
51
+ const f = e.deltaY > 0 ? 1.15 : 0.87;
52
+ if (e.offsetX > this.plotW) {
53
+ this.yZoom = Math.min(50, Math.max(0.2, this.yZoom / f));
54
+ this.scheduleRender();
55
+ return;
56
+ }
57
+ this.zoomTimeAt(e.offsetX, f);
58
+ this.scheduleRender();
59
+ };
60
+ this.onDown = (e) => {
61
+ const r = this.canvas.getBoundingClientRect();
62
+ this.drag = {
63
+ reg: this.region(e.clientX - r.left, e.clientY - r.top),
64
+ x: e.clientX,
65
+ y: e.clientY,
66
+ offset: this.view.offset,
67
+ count: this.view.count,
68
+ yZoom: this.yZoom,
69
+ };
70
+ };
71
+ this.onMove = (e) => {
72
+ const r = this.canvas.getBoundingClientRect();
73
+ const d = this.drag;
74
+ if (d) {
75
+ if (d.reg === "y") {
76
+ this.yZoom = Math.min(50, Math.max(0.2, d.yZoom * Math.exp(-(e.clientY - d.y) / 160)));
77
+ }
78
+ else if (d.reg === "x") {
79
+ const n = this.candles.length;
80
+ this.view = { ...this.view, count: Math.min(Math.max(this.minBars, Math.round(d.count * Math.exp((e.clientX - d.x) / 260))), Math.max(this.minBars, n)) };
81
+ }
82
+ else {
83
+ const cw = Math.min(this.plotW / Math.max(Math.min(this.view.count, this.candles.length || 1), 1), this.maxBarWidth);
84
+ const offset = Math.max(0, Math.min(this.candles.length - this.minBars, d.offset + Math.round((e.clientX - d.x) / cw)));
85
+ this.view = { ...this.view, offset };
86
+ }
87
+ this.canvas.style.cursor = "grabbing";
88
+ }
89
+ this.hover = { x: e.clientX - r.left, y: e.clientY - r.top };
90
+ this.scheduleRender();
91
+ };
92
+ this.onUp = () => {
93
+ this.drag = null;
94
+ this.canvas.style.cursor = "crosshair";
95
+ };
96
+ this.onLeave = () => {
97
+ this.drag = null;
98
+ this.hover = null;
99
+ this.canvas.style.cursor = "crosshair";
100
+ this.scheduleRender();
101
+ };
102
+ this.onDouble = () => {
103
+ this.resetView();
104
+ };
105
+ this.theme = { ...DEFAULT_THEME, ...(opts.theme || {}) };
106
+ this.height = opts.height ?? DEFAULTS.height;
107
+ this.minBars = opts.minBars ?? DEFAULTS.minBars;
108
+ this.maxBarWidth = opts.maxBarWidth ?? DEFAULTS.maxBarWidth;
109
+ this.volumeRatio = opts.volumeRatio ?? DEFAULTS.volumeRatio;
110
+ this.onCrosshair = opts.onCrosshair;
111
+ this.pads = {
112
+ right: opts.rightPad ?? DEFAULTS.rightPad,
113
+ bottom: opts.bottomPad ?? DEFAULTS.bottomPad,
114
+ top: opts.topPad ?? DEFAULTS.topPad,
115
+ };
116
+ this.view = { count: opts.initialBars ?? DEFAULTS.initialBars, offset: 0 };
117
+ this.indicators = opts.indicators ?? [];
118
+ this.onNeedHistory = opts.onNeedHistory;
119
+ const doc = container.ownerDocument;
120
+ this.canvas = doc.createElement("canvas");
121
+ this.canvas.style.display = "block";
122
+ this.canvas.style.cursor = "crosshair";
123
+ container.appendChild(this.canvas);
124
+ const ctx = this.canvas.getContext("2d");
125
+ if (!ctx)
126
+ throw new Error("livo-charts: 2d canvas context unavailable");
127
+ this.ctx = ctx;
128
+ this.canvas.addEventListener("wheel", this.onWheel, { passive: false });
129
+ this.canvas.addEventListener("mousedown", this.onDown);
130
+ this.canvas.addEventListener("mousemove", this.onMove);
131
+ this.canvas.addEventListener("mouseup", this.onUp);
132
+ this.canvas.addEventListener("mouseleave", this.onLeave);
133
+ this.canvas.addEventListener("dblclick", this.onDouble);
134
+ this.ro = new ResizeObserver(() => this.measure());
135
+ this.ro.observe(container);
136
+ this.measure();
137
+ }
138
+ setCandles(candles) {
139
+ this.candles = candles;
140
+ this.recomputeOverlays();
141
+ this.clampView();
142
+ this.render();
143
+ return this;
144
+ }
145
+ setInterval(interval) {
146
+ this.interval = interval;
147
+ this.render();
148
+ return this;
149
+ }
150
+ setChartType(type) {
151
+ this.type = type;
152
+ this.render();
153
+ return this;
154
+ }
155
+ setIndicators(indicators) {
156
+ this.indicators = indicators;
157
+ this.recomputeOverlays();
158
+ this.render();
159
+ return this;
160
+ }
161
+ /** Register a callback fired when the user pans/zooms near the start of loaded data
162
+ * (so a feed can lazily load older candles). See connectFeed. */
163
+ setNeedHistory(cb) {
164
+ this.onNeedHistory = cb;
165
+ this.historyReqLen = -1;
166
+ return this;
167
+ }
168
+ setHeight(height) {
169
+ this.height = height;
170
+ this.measure();
171
+ return this;
172
+ }
173
+ setTheme(theme) {
174
+ this.theme = { ...this.theme, ...theme };
175
+ this.render();
176
+ return this;
177
+ }
178
+ resetView() {
179
+ this.view = { count: DEFAULTS.initialBars, offset: 0 };
180
+ this.yZoom = 1;
181
+ this.clampView();
182
+ this.render();
183
+ return this;
184
+ }
185
+ destroy() {
186
+ this.canvas.removeEventListener("wheel", this.onWheel);
187
+ this.canvas.removeEventListener("mousedown", this.onDown);
188
+ this.canvas.removeEventListener("mousemove", this.onMove);
189
+ this.canvas.removeEventListener("mouseup", this.onUp);
190
+ this.canvas.removeEventListener("mouseleave", this.onLeave);
191
+ this.canvas.removeEventListener("dblclick", this.onDouble);
192
+ this.ro.disconnect();
193
+ if (this.raf)
194
+ this.container.ownerDocument.defaultView?.cancelAnimationFrame(this.raf);
195
+ this.canvas.remove();
196
+ }
197
+ get volH() {
198
+ return Math.round((this.height - this.pads.bottom - this.pads.top) * this.volumeRatio);
199
+ }
200
+ get plotW() {
201
+ return this.width - this.pads.right;
202
+ }
203
+ measure() {
204
+ this.width = this.container.clientWidth || this.width;
205
+ const dpr = this.container.ownerDocument.defaultView?.devicePixelRatio || 1;
206
+ this.canvas.width = this.width * dpr;
207
+ this.canvas.height = this.height * dpr;
208
+ this.canvas.style.width = this.width + "px";
209
+ this.canvas.style.height = this.height + "px";
210
+ this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
211
+ this.render();
212
+ }
213
+ clampView() {
214
+ const n = this.candles.length;
215
+ const count = Math.min(Math.max(this.minBars, this.view.count), Math.max(this.minBars, n || this.minBars));
216
+ const offset = Math.min(Math.max(0, this.view.offset), Math.max(0, n - this.minBars));
217
+ this.view = { count, offset };
218
+ }
219
+ render() {
220
+ if (!this.width)
221
+ return;
222
+ const active = draw({
223
+ ctx: this.ctx,
224
+ width: this.width,
225
+ height: this.height,
226
+ candles: this.candles,
227
+ view: this.view,
228
+ hover: this.hover,
229
+ interval: this.interval,
230
+ type: this.type,
231
+ yZoom: this.yZoom,
232
+ maxBarWidth: this.maxBarWidth,
233
+ volH: this.volH,
234
+ theme: this.theme,
235
+ pads: this.pads,
236
+ overlays: this.overlays,
237
+ });
238
+ if (this.onCrosshair && active !== this.lastActive) {
239
+ this.lastActive = active;
240
+ this.onCrosshair(active);
241
+ }
242
+ this.maybeRequestHistory();
243
+ }
244
+ /** Coalesce high-frequency redraws (drag/hover/wheel) into one per animation frame. */
245
+ scheduleRender() {
246
+ if (this.raf)
247
+ return;
248
+ const win = this.container.ownerDocument.defaultView;
249
+ if (!win || !win.requestAnimationFrame) {
250
+ this.render();
251
+ return;
252
+ }
253
+ this.raf = win.requestAnimationFrame(() => {
254
+ this.raf = 0;
255
+ this.render();
256
+ });
257
+ }
258
+ /** Recompute indicator value-series once when data or config changes (not per frame). */
259
+ recomputeOverlays() {
260
+ this.overlays = this.indicators.map((ind, i) => ({
261
+ color: ind.color || INDICATOR_PALETTE[i % INDICATOR_PALETTE.length],
262
+ values: computeIndicator(this.candles, ind),
263
+ }));
264
+ }
265
+ /** When the view nears the start of loaded data, ask the feed for older candles
266
+ * (once per data length, so it stops at the end of history). */
267
+ maybeRequestHistory() {
268
+ if (!this.onNeedHistory || !this.candles.length)
269
+ return;
270
+ const end = this.candles.length - this.view.offset;
271
+ const start = Math.max(0, end - Math.min(this.view.count, this.candles.length));
272
+ if (start <= this.historyThreshold && this.historyReqLen !== this.candles.length) {
273
+ this.historyReqLen = this.candles.length;
274
+ this.onNeedHistory();
275
+ }
276
+ }
277
+ region(x, y) {
278
+ if (x > this.plotW)
279
+ return "y";
280
+ if (y > this.height - this.pads.bottom)
281
+ return "x";
282
+ return "plot";
283
+ }
284
+ /** Zoom the time axis by factor `f`, keeping the candle under `cursorX` anchored. */
285
+ zoomTimeAt(cursorX, f) {
286
+ const n = this.candles.length;
287
+ if (!n)
288
+ return;
289
+ const nVisOld = Math.min(this.view.count, n);
290
+ const startOld = Math.max(0, n - this.view.offset - nVisOld);
291
+ const frac = Math.max(0, Math.min(1, cursorX / this.plotW));
292
+ const absUnder = startOld + Math.round(frac * (nVisOld - 1));
293
+ const count = Math.round(Math.min(Math.max(this.minBars, this.view.count * f), Math.max(this.minBars, n)));
294
+ const nVisNew = Math.min(count, n);
295
+ const startNew = absUnder - Math.round(frac * (nVisNew - 1));
296
+ const offset = Math.max(0, Math.min(Math.max(0, n - this.minBars), n - startNew - nVisNew));
297
+ this.view = { count, offset };
298
+ }
299
+ }
@@ -0,0 +1,43 @@
1
+ import type { Candle } from "./types";
2
+ import type { Chart } from "./chart";
3
+ export interface LoadHistoryParams {
4
+ /** Bucket interval in seconds. */
5
+ interval: number;
6
+ /** Load candles ending strictly before this time (ms epoch). Omit for the latest page. */
7
+ before?: number;
8
+ /** Max candles to return. */
9
+ limit: number;
10
+ }
11
+ export interface ChartFeed {
12
+ /** Resolve a page of historical candles (ascending by time). Return [] at the end. */
13
+ loadHistory(params: LoadHistoryParams): Promise<Candle[]>;
14
+ /** Optional live stream. Call `onUpdate` per candle; return an unsubscribe fn. */
15
+ subscribe?(params: {
16
+ interval: number;
17
+ }, onUpdate: (candle: Candle) => void): () => void;
18
+ }
19
+ export interface ConnectFeedOptions {
20
+ /** Bucket interval in seconds. */
21
+ interval: number;
22
+ /** Candles per history page (default 500). */
23
+ pageSize?: number;
24
+ /** Called on every candle-set change (e.g. to update a header). */
25
+ onCandles?: (candles: Candle[]) => void;
26
+ /** Called when a history/live load throws. */
27
+ onError?: (err: unknown) => void;
28
+ }
29
+ export interface FeedConnection {
30
+ /** Tear down: unsubscribe live + detach the lazy-history hook. */
31
+ disconnect(): void;
32
+ /** The current candle buffer (ascending). */
33
+ candles(): Candle[];
34
+ }
35
+ declare function mergeLive(buf: Candle[], c: Candle): Candle[];
36
+ /**
37
+ * Wire a {@link ChartFeed} to a {@link Chart}: load the latest page, lazily prepend
38
+ * older candles when the user scrolls near the start, and merge live updates. The
39
+ * chart's right-anchored view means prepending history keeps the visible window
40
+ * stable automatically.
41
+ */
42
+ export declare function connectFeed(chart: Chart, feed: ChartFeed, opts: ConnectFeedOptions): FeedConnection;
43
+ export { mergeLive as _mergeLive };