@livo-build/charts
v0.2.9
Published
livo-charts — a lightweight, dependency-free canvas charting library (candlesticks/line/baseline/Heikin-Ashi, zoom/pan, crosshair, MA/RSI/MACD/Stochastic/ATR indicators, volume profile, drawing tools, and live Hyperliquid/Polymarket/Signal-Radar feeds) wi
Maintainers
Readme
@livo-build/charts (livo-charts)
Live demos + docs: charts.livo.build — interactive examples with copy-paste code, plus agent-friendly llms.txt / llms-full.txt.
A lightweight, dependency-free canvas charting library. The core renders to a
<canvas> 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
npm i @livo-build/chartsreact / react-dom are optional peers — only needed for the React entry.
React
import { PriceChart } from "@livo-build/charts/react";
<PriceChart
swaps={trades} // [{ t: unixSeconds, p: priceUsd, v: usdVolume }, …]
ethUsd={1711.81} // enables the USD↔ETH toggle
supply={1_000_000_000} // enables the Price↔MCap toggle (or tokenAddress to read it)
symbol="PEPE"
quote="WETH"
height={420}
/>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:
import { HyperliquidChart } from "@livo-build/charts/react";
<HyperliquidChart
coin="BTC" // or "ETH", or a builder-dex name like "xyz:TSLA"
interval="15m" // 1m 5m 15m 1h 4h 1d (toolbar lets the user switch)
indicators={[{ type: "ema", period: 21 }, { type: "sma", period: 50 }]}
refetchMs={15000} // poll cadence; 0 = fetch once
height={420}
/><HyperliquidChart> 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:
import { PolymarketChart, SignalsChart } from "@livo-build/charts/react";
// A prediction-market outcome (probabilities → a % y-axis), from the public CLOB.
<PolymarketChart tokenId={yesTokenId} bucketSeconds={3600} label="Yes" />
// A token tracked by the Livo signals engine ("Signal Radar") — real indexed history.
<SignalsChart token="PEPE" bucketSeconds={300} indicators={[{ type: "ema", period: 21 }]} />Both are ChartFeeds under the hood — drive the core directly with connectFeed:
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 CLOBprices-historyendpoint, polled for a building candle. Prices are probabilities in [0, 1] (no volume).fetchPolymarketPriceHistoryis exported for custom fetching.signalsFeed— real OHLC from the Signal Radar index: it queries/graphql(allSwapsfor 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/graphqlis unreachable (e.g. cross-origin CORS) or you passhistory: "spark", it falls back to the snapshot'sspark.fetchSignalsSwaps/fetchSignalsMarket/sparkCandlesare exported. (Cross-origin use needs/graphqlCORS-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:
<PriceChart
swaps={trades}
indicators={[{ type: "ema", period: 21 }]} // on the price pane
oscillators={[
{ type: "rsi", period: 14 }, // 0–100 band with 70/30 rails
{ type: "macd", fast: 12, slow: 26, signal: 9 }, // line + signal + histogram
{ type: "stoch", period: 14, dPeriod: 3, smooth: 3 }, // %K/%D, 80/20 rails
{ type: "atr", period: 14 }, // auto-scaled volatility (price units)
]}
/>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 withbaselinePrice(defaults to the first visible close); the core exposeschart.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 pureheikinAshi(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(...):
<PriceChart swaps={trades} volumeProfile={{ buckets: 24, width: 0.16 }} />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):
<PriceChart
swaps={trades}
priceFormat={(v) => `$${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.73Ks. 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.
<PriceChart swaps={trades} onDrawingsChange={(d) => 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.
<PriceChart
swaps={trades}
markers={fills.map((f) => ({ 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.
<PriceChart swaps={trades} compare={[{ label: "BTC", candles: btcCandles }]} />
// 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):
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:
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
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 <canvas>, 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:
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 changedHelpers
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 aChartFeed(paged history + live stream) to a chart: latest page, lazy older history, live merge. Returns{ disconnect }.hyperliquidFeed({ coin, interval, testnet })— a readyChartFeed(REST history + WebSocket live).fetchHyperliquidCandles/fetchHlCandleWindow/mapHlCandles/HL_INTERVAL_SECONDSare exported for custom fetching.polymarketFeed({ tokenId, bucketSeconds })— aChartFeedfor a Polymarket outcome (CLOB price-history + polling).fetchPolymarketPriceHistoryis exported.signalsFeed({ token, bucketSeconds })— aChartFeedfor a Signal Radar token: indexedallSwaps→ OHLC (lazy older pages) + a/data-polled live candle, spark fallback.fetchSignalsSwaps/fetchSignalsMarket/sparkCandlesare 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'sonLoadOlder, 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: trueby default) → ←/→ pan, ↑/+zoom in, ↓/-zoom out,0/Homereset. The canvas isrole="img"with a livearia-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/chartsis vanilla (and tailwind-free);@livo-build/charts/reactadds the toolbar/legend. Add more bindings the same way. draw()is stateless. All zoom/pan/hover state lives in theChartcontroller.- Render coalescing. High-frequency input (drag/hover/wheel) is batched into one
requestAnimationFrameredraw, 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
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.
