@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 +189 -0
- package/dist/core/chart.d.ts +81 -0
- package/dist/core/chart.js +299 -0
- package/dist/core/feed.d.ts +43 -0
- package/dist/core/feed.js +87 -0
- package/dist/core/format.d.ts +6 -0
- package/dist/core/format.js +40 -0
- package/dist/core/indicators.d.ts +36 -0
- package/dist/core/indicators.js +112 -0
- package/dist/core/live.d.ts +60 -0
- package/dist/core/live.js +107 -0
- package/dist/core/ohlc.d.ts +9 -0
- package/dist/core/ohlc.js +36 -0
- package/dist/core/renderer.d.ts +49 -0
- package/dist/core/renderer.js +203 -0
- package/dist/core/theme.d.ts +3 -0
- package/dist/core/theme.js +14 -0
- package/dist/core/types.d.ts +80 -0
- package/dist/core/types.js +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +11 -0
- package/dist/react/HyperliquidChart.d.ts +28 -0
- package/dist/react/HyperliquidChart.js +79 -0
- package/dist/react/PriceChart.d.ts +31 -0
- package/dist/react/PriceChart.js +87 -0
- package/dist/react.d.ts +4 -0
- package/dist/react.js +3 -0
- package/package.json +67 -0
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 };
|