# @livo-build/charts (livo-charts) > **Live demos + docs: [charts.livo.build](https://charts.livo.build)** — interactive > examples with copy-paste code, plus agent-friendly [llms.txt](https://charts.livo.build/llms.txt) > / [llms-full.txt](https://charts.livo.build/llms-full.txt). A lightweight, **dependency-free** canvas charting library. The core renders to a `` with zero runtime dependencies; a thin, self-styled React wrapper is shipped under a separate entry so non-React consumers never pull React into their bundle. It ships a **TradingView-style trading chart**: candlesticks, a line, a two-tone **baseline** area **or Heikin-Ashi** candles, a **volume panel**, a **volume-by-price profile**, a **crosshair with price + time axis labels**, an **OHLCV legend**, independent **X and Y zoom** (+ pan, plus touch/pinch), and a toolbar for **intervals, USD/ETH, price/market-cap, log scale and fullscreen**. The architecture (a framework-agnostic `Chart` controller + a pure `draw()` pass) is built to keep growing — indicators, overlays, multiple panes — without touching consumers. ## Install ```bash npm i @livo-build/charts ``` `react` / `react-dom` are **optional** peers — only needed for the React entry. ## React ```tsx import { PriceChart } from "@livo-build/charts/react"; ``` `swaps` are aggregated into OHLC+volume candles client-side. The toolbar (`1s 1m 5m 15m 1h 4h 1d`, candle/line, Price/MCap, USD/ETH, flip, log, fullscreen) and the OHLCV legend are built in. The canvas is long-lived, so zoom/pan survive prop updates (streaming trades won't reset the user's view). The wrapper is styled inline — no CSS framework required. `PriceChart` only aggregates the `swaps` you give it — it doesn't fetch more, so panning stops at the oldest trade. For **infinite back-history**, pass `onLoadOlder` (fires when the user pans/zooms within ~one viewport of the start) and prepend older trades to `swaps`; the right-anchored view keeps the position stable. Scope the toolbar to intervals your data covers with `intervals={[["1m", 60], ["5m", 300]]}` (a thin series bucketed at `1h` collapses into a couple of candles), and react to interval changes with `onIntervalChange`. For a turnkey lazy-loading chart, use a feed (`HyperliquidChart` / `connectFeed`) instead. The default **visible span** is `initialBars × interval`. To open on, say, the last 7 days at 1h candles, give it a week of data and `defaultInterval={3600}` + `options={{ initialBars: 168 }}` (168 × 1h = 7 days). The user can still zoom/scroll out from there. Market cap = `price × supply`. Pass `supply` directly, or pass `tokenAddress` (+`decimals`) to read `totalSupply()` once via `rpcUrl` (default `https://cloudflare-eth.com`, cached). The **Price/MCap toggle only appears when one of those is set** — otherwise it's hidden (no dead button), and it falls back to price if the RPC read fails. ### Live Hyperliquid chart A turnkey, **live** trading chart for any Hyperliquid market — fetches the public candle feed (no API key), polls for updates (preserving the user's zoom/pan), and supports moving-average overlays: ```tsx import { HyperliquidChart } from "@livo-build/charts/react"; ``` `` is **realtime**: live candles stream over WebSocket, and older history loads lazily as the user scrolls left (infinite back-scroll) — both with no API key. Zoom/pan survive every update. ### Live Polymarket & Signal Radar charts Two more turnkey, key-free live charts wrap the framework-agnostic `Chart`: ```tsx import { PolymarketChart, SignalsChart } from "@livo-build/charts/react"; // A prediction-market outcome (probabilities → a % y-axis), from the public CLOB. // A token tracked by the Livo signals engine ("Signal Radar") — real indexed history. ``` Both are `ChartFeed`s under the hood — drive the core directly with `connectFeed`: ```ts import { Chart, connectFeed, polymarketFeed, signalsFeed } from "@livo-build/charts"; connectFeed(new Chart(el), polymarketFeed({ tokenId, bucketSeconds: 3600 }), { interval: 3600 }); connectFeed(new Chart(el2), signalsFeed({ token: "PEPE", bucketSeconds: 300 }), { interval: 300 }); ``` - **`polymarketFeed`** — bucketed OHLC from the public Polymarket CLOB `prices-history` endpoint, polled for a building candle. Prices are probabilities in [0, 1] (no volume). `fetchPolymarketPriceHistory` is exported for custom fetching. - **`signalsFeed`** — real OHLC from the Signal Radar **index**: it queries `/graphql` (`allSwaps` for the pool) and buckets the swaps into candles at any interval, paging older swaps in as you scroll — so 1m / 5m candles go days deep (the index has no TTL), and 1h/4h/1d just zoom out in time. The live candle is polled from `/data` (current price). If `/graphql` is unreachable (e.g. cross-origin CORS) or you pass `history: "spark"`, it falls back to the snapshot's `spark`. `fetchSignalsSwaps` / `fetchSignalsMarket` / `sparkCandles` are exported. (Cross-origin use needs `/graphql` CORS-enabled on the engine; same-origin always works.) ### Indicators `PriceChart` and `HyperliquidChart` accept `indicators` — overlays drawn on the price pane. Each is `{ type: "sma" | "ema" | "wma" | "vwap" | "bollinger", period, color?, source?, mult? }` (`source` defaults to `close`; `vwap` is cumulative; `bollinger` draws a 3-line band at `mult` std-devs, default 2). Colors fall back to a built-in palette by position. The pure `sma`/`ema`/`wma`/`vwap`/`bollingerBands`/`computeIndicator` helpers are exported from the core (e.g. feed `bollingerBands(values).{upper,mid,lower}` as three overlays). **Oscillators (RSI / MACD / Stochastic / ATR)** render in their own **stacked sub-panes** below the volume panel — pass `oscillators`: ```tsx ``` Each pane defaults to 84px (`height` to override). The pure `rsi(values, period)`, `macd(values, fast, slow, signal) → { macd, signal, hist }`, `stochastic(candles, kPeriod, dPeriod, smooth) → { k, d }` and `atr(candles, period)` helpers are exported too. ### Chart types: candle · line · baseline · Heikin-Ashi `chartType` (low-level `chart.setChartType(...)`, or the toolbar's ▮▯ / ╱ / ⎓ / HA buttons) switches the price series: - **`candle`** — classic OHLC candlesticks. - **`line`** — a close line with a soft gradient area fill. - **`baseline`** — a two-tone area around a reference price (green above, red below). Set the reference with `baselinePrice` (defaults to the first visible close); the core exposes `chart.setBaseline(price)`. - **`heikin`** — Heikin-Ashi candles: smoothed OHLC that filter noise and make trends easier to read. Indicators/oscillators still read the **raw** closes. The pure `heikinAshi(candles)` transform is exported. ### Volume profile (volume-by-price) A horizontal histogram of volume per price level, drawn over the price pane (peaked at the **Point of Control**). Pass `volumeProfile` (or toggle **VP** in the toolbar); the core has `chart.setVolumeProfile(...)`: ```tsx ``` It's computed over the **visible** window, so it tracks zoom/pan. The pure `volumeProfile(candles, buckets) → { buckets, maxVol, poc }` helper is exported. ### Fitting the container & axis styling By default `PriceChart` / `HyperliquidChart` **fit the container** (`fitContent`) — candles spread across the full plot width instead of clustering at a capped slot. Set `fitContent={false}` for the tight, right-anchored look (the low-level `Chart` defaults to tight; the React wrappers default to fill). The ticks are easy to style — every axis knob is a prop (and a `chart.setAxis({…})` call): ```tsx `$${v.toLocaleString()}`} // y-axis labels timeFormat={(t) => new Date(t * 1000).toLocaleTimeString()} priceTicks={6} // horizontal gridlines (default 5) timeTicks={8} // x-axis labels (default 7) axisFont="11px Inter, sans-serif" // label font (color = theme.axis) /> ``` The **default** price formatter is range-aware: it keeps just enough decimals to tell adjacent ticks apart, so a narrow high-value axis (e.g. WETH 1.71K–1.75K) renders `1.7350K / 1.7430K` instead of duplicate `1.73K`s. Tick text color comes from `theme.axis`. ### Log scale & themes The price axis can be **logarithmic** — equal vertical distance = equal % move, the right default for assets that span orders of magnitude. `PriceChart`/`HyperliquidChart` expose a **Log** toolbar toggle (or pass `options={{ logScale: true }}`); the core has `chart.setLogScale(true)`. Log mode auto-falls back to linear if any visible low is ≤ 0. Two built-in themes ship as `DEFAULT_THEME` (dark) and `LIGHT_THEME`, also addressable via `THEME_PRESETS.dark` / `THEME_PRESETS.light`. Pass either (or a partial override) as `theme`, or call `chart.setTheme(LIGHT_THEME)`. ### Drawing tools (trendlines & horizontal lines) The toolbar arms a one-shot draw: **↗** trendline (drag), **—** horizontal line (click), **fib** Fibonacci retracement (drag — levels 0/23.6/38.2/50/61.8/78.6/100% between the two prices), and **▭** rectangle (drag). Drawings are anchored in **data space**, so they stay pinned to their price/time across pan, zoom and live updates. In the default cursor mode, click a drawing to select it, drag to move it, and **double-click it to delete it**; **⌫** clears all. ```tsx save(d)} drawings={restored} /> ``` Drive it from the core too: `chart.setDrawMode("trendline" | "hline" | "none")`, `getDrawings()` / `setDrawings()` / `removeDrawing(id)` / `clearDrawings()`, and the `onDrawingsChange` callback. Pass `hideDrawingTools` to drop the buttons. The `computeProjection(input)` helper exposes the same pixel↔data mapping for custom tools. ### Trade markers & annotations Pin point annotations — trade fills, signals, news flags — to the price pane. Each marker is anchored in **data space** (`time`, optional `price`) so it tracks pan/zoom. With no `price` it pins to the candle at `time` (above the high for sells, below the low for buys). `side` drives the default color/glyph; override with `color` / `shape` (`"triangle" | "arrow" | "circle" | "flag"`) / `position`. ```tsx ({ time: f.ts, side: f.isBuy ? "buy" : "sell", text: f.label }))} /> // or on the core: chart.setMarkers([{ time, price: 105, side: "sell", text: "TP", shape: "flag" }]) ``` ### Comparison overlays (shape vs another asset) Overlay a second series for **visual shape comparison** (e.g. token vs BTC). Each compare series is drawn on its **own auto-fit scale** — so assets of wildly different magnitudes line up by shape rather than being squashed by a shared axis — with a top-left legend showing its % change over the visible window. ```tsx // or: chart.setCompare([{ label: "ETH", candles: ethCandles, color: "#26a69a" }]) ``` (Multiple oscillator sub-panes already stack below volume; literal side-by-side panes were skipped as low-value.) ### Realtime feeds + lazy history (any data source) Connect a chart to a **feed** — a source that serves paged OHLCV history *and* live updates — and `connectFeed` handles the latest page, lazy older-history loading on left-scroll, and merging live candles (updating the in-progress bucket or appending): ```ts import { Chart, connectFeed, hyperliquidFeed, HL_INTERVAL_SECONDS } from "@livo-build/charts"; const chart = new Chart(el, { height: 420 }); const conn = connectFeed(chart, hyperliquidFeed({ coin: "BTC", interval: "1m" }), { interval: HL_INTERVAL_SECONDS["1m"], pageSize: 500, }); // …later conn.disconnect(); ``` Bring your own source by implementing `ChartFeed`: ```ts const feed = { loadHistory: ({ interval, before, limit }) => fetchMyCandles(before, limit), // [] at the end subscribe: ({ interval }, onCandle) => mySocket.onCandle(onCandle), // returns unsubscribe }; ``` The chart's right-anchored view means prepending history keeps the visible window stable. The controller prefetches the next page once the view's left edge comes within ~one viewport of the oldest loaded candle (see the exported `needsHistory` helper) — so **zooming out keeps deepening the window with real data** instead of stalling at the oldest bar. It advances one page per data length and stops when the feed runs dry. ## Vanilla / framework-agnostic ```ts import { Chart, buildOHLC } from "@livo-build/charts"; const chart = new Chart(containerEl, { height: 420, onCrosshair: (candle) => renderLegend(candle), // null when empty; last candle when idle }); chart.setInterval(300).setChartType("candle"); chart.setCandles(buildOHLC(trades, 300, { denom: "ETH", ethUsd: 1711.81 })); // streaming update — view (zoom/pan) is preserved chart.setCandles(buildOHLC(moreTrades, 300)); chart.destroy(); ``` ### `Chart` API | Method | Description | | --- | --- | | `new Chart(container, options?)` | Creates a ``, wires interaction, observes resize. | | `setCandles(candles)` | Replace the series (each candle has `vol`) and redraw. | | `setInterval(seconds)` | Bucket interval — drives time-axis label granularity. | | `setChartType("candle" \| "line" \| "baseline" \| "heikin")` | Switch series style. | | `setShowVolume(on)` | Show / hide the volume panel (the price pane reclaims the space). | | `setVolumeProfile(config)` | Show/configure (or hide with `false`) the volume-by-price histogram. | | `setBaseline(price?)` | Reference price for the `baseline` type (undefined = first visible close). | | `setLogScale(on)` | Toggle the logarithmic price axis (auto-falls back to linear if any low ≤ 0). | | `setAxis({ fitContent, priceFormat, timeFormat, priceTicks, timeTicks, axisFont })` | Style the axes — only the provided keys change. | | `setIndicators(indicators)` | Set the moving-average overlays and redraw. | | `setOscillators(oscillators)` | Set the RSI / MACD sub-panes and redraw. | | `setMarkers(markers)` | Set point annotations (trade fills / signals / flags) on the price pane. | | `setCompare(series)` | Set comparison overlays (secondary assets, own auto-fit scale). | | `setDrawMode(mode)` | Arm a drawing tool (`"trendline"` / `"hline"`) or `"none"`. | | `setDrawings` / `getDrawings` / `removeDrawing(id)` / `clearDrawings()` | Manage drawings. | | `setNeedHistory(cb)` | Callback fired when the view nears the start of loaded data (lazy history). | | `setHeight(px)` / `setTheme(partial)` | Resize / merge theme overrides. | | `resetView()` | Reset zoom/pan (same as double-click). | | `getViewState()` / `setViewState(partial)` | Snapshot / restore the camera + mode (zoom, pan, `yZoom`, interval, type, log, volume) — persist across reloads. Offset/zoom re-clamp to the current data. | | `setAriaLabel(label)` | Update the canvas's accessible label (the live last-price suffix is appended automatically). | | `toImage(type?, quality?)` | Export the current canvas as a data-URL (PNG by default) for share cards / thumbnails. | | `destroy()` | Remove listeners, observer and the canvas. | `ChartOptions`: `height`, `theme`, `initialBars` (120), `minBars` (20), `maxBarWidth` (18 — caps candle spacing so sparse series stay tight, right-anchored), `volumeRatio` (0.18), `rightPad`/`bottomPad`/`topPad`, `onCrosshair`, `onCrosshairMove` (richer crosshair: candle + index + pixel position + price, `null` on leave — drives synced crosshairs and position-aware tooltips), `ariaLabel`, `keyboard` (true), `indicators`, `oscillators`, `markers`, `compare`, `drawings`, `showVolume` (true), `volumeProfile`, `baselinePrice`, `logScale` (false), `fitContent` (false), `maxBarWidth` (18) / `maxBodyWidth` (40), `priceFormat`/`timeFormat`, `priceTicks` (5) / `timeTicks` (7), `axisFont`, and `onNeedHistory`. **Persisting a view** — save on change, restore on mount: ```ts const chart = new Chart(el, { // fires on every pan/zoom-affecting hover; debounce a save of the camera onCrosshairMove: () => localStorage.setItem("chartView", JSON.stringify(chart.getViewState())), }); const saved = localStorage.getItem("chartView"); if (saved) chart.setViewState(JSON.parse(saved)); // safe even if the data length changed ``` ### Helpers - `buildOHLC(points, interval, transform?)` — bucket priced trades into OHLC+volume candles. `transform` = `{ denom, ethUsd, flip, supply }` (`supply` → market cap). - `transformPrice(p, transform?)` — apply the transform to one price. - `sma` / `ema` / `wma` / `vwap` / `bollingerBands` / `rsi` / `macd` / `stochastic` / `atr` / `volumeProfile` / `heikinAshi` / `computeIndicator` — pure indicator + transform math (aligned to input, null until the window fills). - `connectFeed(chart, feed, opts)` — wire a `ChartFeed` (paged history + live stream) to a chart: latest page, lazy older history, live merge. Returns `{ disconnect }`. - `hyperliquidFeed({ coin, interval, testnet })` — a ready `ChartFeed` (REST history + WebSocket live). `fetchHyperliquidCandles` / `fetchHlCandleWindow` / `mapHlCandles` / `HL_INTERVAL_SECONDS` are exported for custom fetching. - `polymarketFeed({ tokenId, bucketSeconds })` — a `ChartFeed` for a Polymarket outcome (CLOB price-history + polling). `fetchPolymarketPriceHistory` is exported. - `signalsFeed({ token, bucketSeconds })` — a `ChartFeed` for a Signal Radar token: indexed `allSwaps` → OHLC (lazy older pages) + a `/data`-polled live candle, spark fallback. `fetchSignalsSwaps` / `fetchSignalsMarket` / `sparkCandles` are exported. - `draw(input)` — the pure render pass (exported for custom hosts/tests); returns the crosshair candle. - `DEFAULT_THEME` / `LIGHT_THEME` / `THEME_PRESETS`, `formatValue`, `formatAxisValue` (range-aware tick labels), `formatVolume`, `formatTime`. - `slotWidth(plotW, count, maxBarWidth, fitContent?)` — candle slot-width math (exported for custom hosts). - `lttbIndices(values, threshold)` — Largest-Triangle-Three-Buckets downsampling (returns the kept indices; shape-preserving). Used internally for huge zoomed-out line/area series. - `computeProjection(input)` / `priceScale` / `timeScale` / `windowOf` — the pixel↔data mapping the renderer and drawing tools share (returns `{ xOfTime, timeOfX, yOfPrice, priceOfY, … }`). ## Interaction - **Scroll** over the plot → zoom time; over the price axis → zoom price. - **Scroll horizontally** (trackpad swipe / shift+wheel) → pan through time; scrolling back into history lazy-loads older candles from the feed. - **Drag** the plot → pan; drag the time axis → zoom X; drag the price axis → zoom Y. Panning pins the oldest candle to the left edge (no dragging into empty space on the left); on a feed, or with `PriceChart`'s `onLoadOlder`, reaching that edge streams in older history instead. You *can* scroll past the newest candle into the future — drag it up to halfway across to leave right-edge whitespace (so the current price isn't pinned to the edge). - **Hover** → crosshair + price/time axis labels + the OHLCV legend. - **Double-click** → reset zoom/pan (or delete a drawing when one is under the cursor). - **Keyboard** (when the canvas is focused; `keyboard: true` by default) → ←/→ pan, ↑/`+` zoom in, ↓/`-` zoom out, `0`/`Home` reset. The canvas is `role="img"` with a live `aria-label` (symbol + last price + candle count) for screen readers. - **Touch** → one finger pans (or zooms an axis from its gutter); two-finger pinch zooms time (horizontal spread) and price (vertical spread) together. - **Drawing tools** → arm trendline/h-line from the toolbar; select + drag to move, double-click to delete. ## Design notes - **No dependencies.** The core is plain canvas 2D + `ResizeObserver`. - **Core vs bindings.** `@livo-build/charts` is vanilla (and tailwind-free); `@livo-build/charts/react` adds the toolbar/legend. Add more bindings the same way. - **`draw()` is stateless.** All zoom/pan/hover state lives in the `Chart` controller. - **Render coalescing.** High-frequency input (drag/hover/wheel) is batched into one `requestAnimationFrame` redraw, and indicator series are precomputed on data change (not per frame) — so panning a large series stays smooth. - **Downsampling.** Zoomed-out line/baseline charts with far more candles than pixels are drawn through an LTTB-downsampled point set (~one vertex per pixel) — shape-preserving, O(plotW) canvas work instead of O(visible). 100k candles still render in a few ms. The pure `lttbIndices(values, threshold)` is exported. ## Develop ```bash npm run typecheck # tsc --noEmit npm run test # vitest (OHLC/volume aggregation + transforms, controller, render) npm run build # tsc → dist/ (index + react entries, with .d.ts) npm run size # build + gzip budget check (guards the dependency-free promise) ``` Published to npm via CI (`.github/workflows/publish-charts.yml`) on push to `main` when `packages/charts/**` changes. Bump `version` in `package.json` to release.