<wick-chart> — a modern, simpler, more useful charting web component.
A TradingView-style financial chart as a single framework-agnostic Web Component. One file, zero dependencies, one HTML tag. Canvas-rendered, fast, themeable, and streaming-ready.
npm install wickchart// any bundler / framework — TypeScript types included
import 'wickchart'; // registers <wick-chart>
import WickChart from 'wickchart'; // for WickChart.registerIndicator(...)
import { encodeStateQuery } from 'wickchart/core'; // pure helpers
import { WickChart } from 'wickchart/react'; // React bindings (optional)Or straight from a CDN — no install, no build:
<script type="module" src="https://unpkg.com/wickchart"></script>
<wick-chart label="BTC · 1h" type="candles" indicators="sma:20 volume"></wick-chart>
<script type="module">
const chart = document.querySelector('wick-chart');
chart.setData(bars); // [{ time, open, high, low, close, volume }]
chart.update(bar); // stream live updates
</script>Works in plain HTML, React, Vue, Svelte, Angular — anywhere a <div> works.
TypeScript declarations ship inside the package (generated at pack time from
the JSDoc-annotated source — the repo itself stays 100% dependency-free JS).
One more script tag and your chart is fully live — data, backfill, streaming — with zero JavaScript written:
<script type="module" src="https://unpkg.com/wickchart/feed"></script>
<wick-feed for="chart" binance="BTCUSDT" tf="1h"></wick-feed>
<wick-chart id="chart" indicators="sma:20 volume" profile></wick-chart>| Attribute | Meaning |
|---|---|
for |
target <wick-chart> id (auto-pairs with the first chart when omitted) |
binance |
Binance symbol (BTCUSDT) — REST load + WebSocket live + backfill |
demo |
deterministic offline synthetic feed (demo="ETH" picks a base price) |
url |
generic REST endpoint returning a JSON array of bars (+ poll="10" sec) |
tf |
timeframe: 1m 3m 5m 15m 30m 1h 2h 4h 6h 12h 1d 3d 1w |
limit |
initial bars (default 500) |
live |
live="false" loads history without streaming |
aggregate |
information-based bars from a trade stream (see below) |
The element reflects its state in the status attribute (loading, live,
polling, fallback, loaded, waiting, idle) and emits
wick-feed:status / wick-feed:fallback events. When Binance is unreachable
(geo-blocked, offline), it degrades gracefully: WebSocket → REST polling → a
synthetic stream bridged from the last real price, so the chart never goes
blank. It also wires chart.onloadmore for infinite backfill automatically.
Add aggregate to any feed and bars close on information, not the clock:
<wick-feed for="c" binance="BTCUSDT" aggregate="dollar:50000"></wick-feed>
<wick-chart id="c" indicators="volume"></wick-chart>tick:200 closes a bar every 200 prints, volume:50 every 50 base units,
dollar:50000 every $50k traded — the quant-grade alternative to time
candles, built client-side from the raw trade tape (Binance aggTrade
WebSocket + paginated REST backfill; offline synthetic prints with demo=;
your own JSON trades endpoint with url= + poll=). The value you pass is
the bar size — tune it per instrument. The machinery is exported too:
import { TickBarAggregator, aggregateTrades } from 'wickchart/feed' to pipe
any trade stream through the same aggregator.
One extra import, one attribute — and the built-in indicators compute in a Web Worker, built for million-bar histories:
import 'wickchart/worker'; // once — wires a shared worker pool into the chart
<wick-chart worker indicators="sma:20 bb:20 rsi:14"></wick-chart>The dataset crosses once per bulk load as six transferable Float64Arrays
(~25 ms per million bars; a structured clone of bar objects would cost ~1 s),
and indicator tasks reference it worker-side. First paint of every indicator
line happens off the main thread; wick:worker fires as results land.
Engages at 50k+ bars with built-in indicators (custom/scripted defs are
closures and stay sync, as does everything below the threshold); results are
cached per data epoch, so streamed ticks stop recomputing the full series per
bar. No worker available? Everything silently stays synchronous — the
attribute is an optimization, never a dependency. Live demo with freeze
numbers: demo/worker.html.
Incremental tick updates (automatic, worker or not): a streamed tick —
appending a bar or replacing the forming one — patches every online-capable
series by recomputing a bounded tail with the same batch definition
(O(warm-up) ≈ 0.1 ms, not O(full history)) and only writing the last
period values, so history is never degraded by tail warm-up error. This
works on worker-computed bases too: the forming bar's indicator value stays
fresh instead of waiting for the next bulk load. Cumulative indicators
(obv, vwap) and the stateful supertrend are excluded and keep the
full-recompute behavior.
One shareable PNG — chart, visible-range stats, watermark — composed from public surfaces only, as an opt-in entry:
import { exportReport, downloadReport } from 'wickchart/report';
const url = await exportReport(chart); // PNG data URL
const blob = await exportReport(chart, { as: 'blob' });
await downloadReport(chart, 'btc-1h.png', { source: 'binance: BTCUSDT' });The header carries the title (the chart's label by default), the visible
range and the brand; the chart keeps its full DPR resolution with a corner
watermark; the stats grid covers the visible window (return, annualized
vol, max drawdown, bars, up/down, average volume, high, low); the footer
credits your source and a timestamp. Theme follows the chart's own
--wick-* CSS variables (or theme: 'dark' | 'light'), scale 1–4.
reportModel(chart, opts) is exported too — plain data, if you want your
own layout.
exportChart() produces one self-contained .html — the whole module
inlined (core + the element flattened into a single module script), the
state snapshot and the data as compact bar arrays. The file opens offline,
from a file manager or an email attachment, with zero requests — only a
zero-dependency, no-build module can do this, because there is nothing
else to inline:
import { exportChart, downloadChart, adoptShared, attachDrop } from 'wickchart/standalone';
const html = await exportChart(chart); // one self-contained .html string
await downloadChart(chart, 'btc-1h.html'); // …as a file download
await adoptShared(chart, file); // restore a shared file into a chart
attachDrop(chart); // drag one anywhere on the page → restoresWhat travels: series type, theme (a registered custom theme's palette is
embedded and re-registered on open), indicators, the view range, positions
and alerts, and the bars themselves (maxBars caps the history for
smaller files). The inverse is the same module: openShared(file) reads
the payload back out, adoptShared() applies it to a live chart, and
attachDrop(chart) wires the drag-and-drop — drop an exported file onto
any page with the element and the state restores.
Two practical notes: the export fetches the original core.js /
wick-chart.js (script-tag, ESM or CDN usage works; bundlers should pass
coreSource/chartSource strings), and pluginSources + init append
already-flattenable plugin module sources through the same machinery —
narrated stories export as playable files this way. The flattening is
pinned by tests against the real sources, so a source edit that breaks
inlining fails CI rather than someone's shared chart.
TradingView's charting library is powerful but heavy and enterprise-licensed; most wrappers add build steps and framework lock-in. WickChart takes the opposite bet:
- Zero dependencies, no build step required (~67 KB gzipped for the whole
component —
core.js+wick-chart.js, held to a 68 KB CI budget) - One tag, sane defaults — drop it in and it renders; everything optional
- Built-in usefulness — crosshair + OHLC legend, last-price line, wheel zoom, drag pan, pinch, keyboard navigation, live streaming, PNG export
- Themeable with CSS variables — two built-in themes plus named custom
themes via
registerTheme(), full control from outside the component (Shadow DOM friendly) - Accessible — focusable, arrow-key crosshair, ARIA summary of the data
The demo site is deployed to GitHub Pages: https://benyblack.github.io/wickchart/ — a landing page with a live hero chart, the full interactive demo, the zero-JavaScript declarative page, and a React demo driven entirely by React state.
Full documentation lives at benyblack.github.io/wickchart/docs.html — every attribute, method, event, the WickScript reference, overlays (with a live JSON playground), feeds, theming and framework bindings, each with runnable examples. The Plugins hub documents every opt-in package — draw, sessions, replay, animate, compare, navigator, alerts+, layouts, signals, tape, grid, paper, narrator, coview, scenario, ai, briefing — each with its own live playground. This README covers the same ground in plain markdown.
npm run dev # serves on http://localhost:5173
# or: npx serve . -l 5173
# or: python -m http.server 5173Then open http://localhost:5173/demo/.
The demo ships with an offline synthetic feed (random walk with volatility regimes + live ticking), and optionally loads real Binance data (REST + WebSocket) for BTC/ETH/SOL when the API is reachable from your network — with graceful fallback to synthetic data if it isn't.
<wick-chart> is framework-agnostic — attributes, one data property,
standard DOM events. The one opinionated wrapper ships as wickchart/react,
which turns that contract into idiomatic React with proper event
subscription/cleanup. react is an optional peer dependency: nothing
changes if you never import wickchart/react.
npm install wickchart reactimport { WickChart, useWickChart } from 'wickchart/react';
// drop-in component — props map 1:1 onto the element
export function PriceChart({ bars, onRange }) {
return (
<WickChart
type="candles"
indicators="sma:20 ema:50 volume"
volshading
label="BTC · 1h"
data={bars} // bars are assigned as a property
onRange={onRange} // subscribes to wick:range
onAlert={(e) => toast(`crossed ${e.detail.price}`)}
style={{ height: 420 }}
/>
);
}
// or the hook, when you need the imperative API
function PracticeChart({ bars }) {
const { ref, chart } = useWickChart({ data: bars, indicators: 'sma:20' });
// chart.getDataWindow(), chart.addAlert(...), chart.getState() … after mount
return <wick-chart ref={ref} style={{ height: 420 }} />;
}Rules of thumb:
- Pass a fresh array to
datawhen the bars change — the binding compares by reference, and reassignment is what triggers a redraw (don't mutate). The same rule applies tooverlays(see Server-side overlays). - String/number/boolean props become attributes (
type,indicators,volshading, …);className/style/idreach React as usual. onXxxsubscribes towick:xxxwith cleanup on unmount; anevents={{ range: fn }}object works too.- Works the same on React 16.8 → 19 — no custom-element event caveats.
No build step? The React demo runs straight off a CDN
import map — react and react-dom from esm.sh, the bindings from the
package source.
<script setup>
import { ref, onMounted } from 'vue';
import 'wickchart';
const chart = ref(null);
const bars = ref([]);
onMounted(async () => {
bars.value = await loadBars();
chart.value.data = bars.value;
chart.value.addEventListener('wick:range', (e) => console.log(e.detail));
});
</script>
<template>
<wick-chart ref="chart" type="candles" indicators="sma:20"
style="height: 420px"></wick-chart>
</template><script>
import 'wickchart';
let el;
let bars = [];
$: if (el && bars.length) el.data = bars;
</script>
<wick-chart bind:this={el} type="candles" indicators="sma:20"
on:wick:alert={(e) => console.log(e.detail)}
style="height: 420px"></wick-chart>Bars are plain objects; time accepts milliseconds or seconds (auto-detected).
For line-style data you can pass { time, value } instead of full OHLCV.
chart.setData([
{ time: 1694000000000, open: 100.5, high: 101.2, low: 99.8, close: 100.9, volume: 1200 },
// ...
]);| Attribute | Default | Description |
|---|---|---|
theme |
dark |
dark, light, or any registerTheme() name |
type |
candles |
candles, line, area, bars (OHLC), hollow (hollow up-candles), heikin (Heikin-Ashi) |
indicators |
volume* |
Space/comma-separated: sma:20, ema:50, bb:20, vwap, supertrend:10/3, donchian:20, keltner:20/2, ichimoku:9/26/52/26, rsi:14, macd:12/26/9, stoch:14/3, atr:14, obv, cci:20, wr:14, volume, or any registered indicator |
label |
– | Text shown in the legend (e.g. "BTC · 1h") |
log |
off | Logarithmic price scale |
auto |
on | Keep the right edge pinned to the latest bar while streaming |
precision |
auto | Forced decimal places for prices (auto-detected from magnitude) |
stats |
off | Live statistics chip for the visible range |
profile |
off | Volume profile overlay (POC + 70% value area) |
annotations |
off | Smart annotations (volume spikes, gaps, pivots, RSI divergences) |
volshading |
off | Volatility-regime background shading (see below) |
overlays |
– | JSON array of server-side zones & levels (see below) |
* indicators="" disables everything, including volume. Token syntax:
name[:param[/param…]][@color] — e.g. sma:20@#ff0000, macd:12/26/9.
| Name | Kind | Params | Notes |
|---|---|---|---|
sma |
overlay | period (20) |
|
ema |
overlay | period (50) |
|
bb |
overlay | period, mult (20, 2) |
Bollinger bands (3 lines) |
vwap |
overlay | – | hlc3 VWAP, resets each UTC day |
supertrend |
overlay | period, mult (10, 3) |
ATR trend line, breaks at flips |
donchian |
overlay | period (20) |
high/low channel + mid |
keltner |
overlay | period, mult (20, 2) |
EMA ± mult×ATR channel |
ichimoku |
overlay | tenkan, kijun, senkouB, disp (9, 26, 52, 26) |
5 lines + shaded kumo; senkou spans projected disp bars ahead |
rsi |
pane | period (14) |
fixed 0–100 scale, 30/70 guides |
macd |
pane | fast/slow/signal (12/26/9) |
2 lines + histogram |
stoch |
pane | period, smooth (14, 3) |
%K + %D, fixed 0–100, 20/80 guides |
atr |
pane | period (14) |
Wilder ATR |
obv |
pane | – | on-balance volume |
cci |
pane | period (20) |
±100 guides |
wr |
pane | period (14) |
Williams %R, fixed −100–0, −80/−20 guides |
volume |
overlay | – | histogram at the bottom of the price pane |
Register your own — anything from a one-liner moving average to a multi-line pane:
WickChart.registerIndicator('cvwap', { // cumulative VWAP over the whole dataset
kind: 'overlay', // or 'pane'
params: { period: 20 }, // defaults; set via indicators="cvwap:30"
compute(bars, params) { // bars: normalized {time,open,high,low,close,volume}
const out = new Array(bars.length).fill(null);
let pv = 0, vv = 0;
for (let i = 0; i < bars.length; i++) {
pv += bars[i].close * bars[i].volume;
vv += bars[i].volume;
out[i] = vv ? pv / vv : null;
}
return out; // single series — or { lines:[{name,values}], histogram }
},
// pane-only extras: guides:[30,70], range:[0,100], fmt:'price'|'fixed1'|'compact'
});
chart.indicators = 'cvwap';import WickChart from 'wickchart' gives you the class for
WickChart.registerIndicator(...) (the element is registered as a side effect
of importing the package).
No build step, no JS: write an indicator inline in the attribute. expr:{…}
draws on the price chart; pexpr:{…} gets its own pane. Add an optional
@color, mix freely with named indicators, and it all round-trips through
shareable URLs.
<wick-chart indicators="sma:20 expr:{(close - sma(close,20)) / sma(close,20) * 100}@ff6a00"></wick-chart>
<!-- oscillator in its own pane -->
<wick-chart indicators="pexpr:{rsi(close,14)} pexpr:{change(close) / close * 100}"></wick-chart>| Series variables | |
|---|---|
open high low close volume |
raw bar fields |
hl2 hlc3 ohlc4 |
classic derived prices |
| Functions | |
|---|---|
sma(x,n) ema(x,n) wma(x,n) stddev(x,n) |
moving stats (window n must be a whole number ≥ 1) |
rsi(x,n) |
RSI of any series |
hh(x,n) ll(x,n) |
rolling highest / lowest |
prev(x[,k]) change(x) |
shifted series / bar-to-bar delta |
abs(x) sqrt(x) log(x) min(a,b) max(a,b) |
element-wise math |
crossup(a,b) crossdown(a,b) |
1 on a strict cross, else 0 |
vwap() obv() atr(n) |
bar-level series — callable anywhere, e.g. crossup(close, vwap()) in alerts |
Operators are + - * / % with usual precedence, unary -, and parentheses.
Values before a window fills are NaN (not drawn), division by zero yields
NaN, and identifiers are case-insensitive.
The expression is compiled by a hand-written tokenizer + recursive-descent
parser in wickchart/core — no eval, no new Function — with caps on
length (512), tokens (128) and nesting (24). Invalid scripts are reported via
the parse result's unknown list and simply not drawn; they can never execute
anything.
Programmatically, compile once and reuse, or register it under a name for the attribute syntax:
import { scriptIndicator } from 'wickchart/core';
WickChart.registerIndicator('spread', scriptIndicator('close - ema(close,21)'));
chart.indicators = 'spread'; // now usable like any built-inThe demo has a live input for it (type an expression, optionally tick pane, press + Expr — invalid expressions show the compiler's error inline).
<wick-chart volshading> tints the price pane background by realized
volatility — the rolling stddev of log returns (20 bars by default),
classified against its own full-history percentiles: calm (≤ 30th
percentile, subtle blue), normal (untinted), hot (≥ 70th percentile,
subtle red). Market state at a glance: quiet ranges and violent expansions
read instantly, and the legend shows the hovered bar's regime and
percentile (VOL 30/70 · hot · 94%ile).
<wick-chart volshading></wick-chart> <!-- defaults 30/70, 20 bars -->
<wick-chart volshading="20/85"></wick-chart> <!-- custom cutoffs -->
<wick-chart volshading="20/85/50"></wick-chart> <!-- + 50-bar vol window -->Cutoffs are clamped so the low percentile always stays at least 2 points
below the high one; the toggle and custom cutoffs round-trip through
shareable URLs (vsh=1 / vsh=20/85). A degenerate history (flat series)
classifies everything as normal. The pieces are exported from
wickchart/core (calcRealizedVol, volRegimeBands, percentileOfSorted)
if you want to build on them.
Draw analysis from your own API straight onto the chart: supply/demand
zones (time × price rectangles) and horizontal levels, rendered
behind the candles. Zones without a to extend into future space past the
last bar, like TradingView drawings.
const res = await fetch('https://api.example.com/analysis?symbol=BTC');
chart.setOverlays(await res.json());[
// zone: from/to are timestamps (ms or s); null → chart edge
{ type: 'zone', from: 1753920000000, priceFrom: 33000, priceTo: 35600,
color: '#ef5350', alpha: 0.25, label: 'demand' },
{ type: 'zone', from: 1753920000000, // no `to` → extends
priceFrom: 37700, priceTo: 40900, color: '#26a69a' }, // to the right edge
// level: horizontal price line, full width by default
{ type: 'level', price: 28700, color: '#3f51b5', label: 'S1' },
{ type: 'level', price: 22800, color: '#3f51b5', dash: true },
]addOverlay(o)upserts one (byid),removeOverlay(id),clearOverlays(), andchart.overlaysreads them back.- Colors accept hex /
rgb()/ CSS names plus the palette keysup|down|accent;alphaclamps to 0.02–0.8 (default 0.22). - Timestamps snap to bars (before the first bar clamps left, after the last clamps right); invalid entries are dropped, never thrown — it's API data.
- Fully declarative, too — the same JSON as an attribute:
<wick-chart overlays='[{"type":"level","price":28700,"color":"#3f51b5","label":"S1"}]'></wick-chart>The React binding takes overlays as a prop (fresh array → re-apply), and
normalizeOverlays / barIndexForTime / resolveOverlayColor are exported
from wickchart/core.
Project what-if into future space: a ghost path of hypothetical prices plus a volatility cone (±1σ/±2σ bands widening with √h from realized vol).
chart.setScenario({
path: [64000, 65500, 66800, 68000], // prices for future bars 1..N
cone: true, // σ-bands from realized vol (default)
label: 'bull case',
color: 'up', // up | down | accent or safe colors
});
chart.setScenario({ horizon: 48 }); // cone-only projection
chart.clearScenario();Setting a scenario reserves future space on the right so the cone stays
visible; the horizon defaults to the path length (1–500) and levels are σ
multipliers (default [1, 2]). Like overlays, scenarios are analysis data —
excluded from shareable state, and the same shape a server-side model could
push. calcVolCone / normalizeScenario are exported from wickchart/core.
Plan the trade on the chart: entry + stop define 1R (the risk unit) and reward lines are drawn at kR beyond the entry, with the risk/reward zones shaded. Direction is derived from the stop side.
chart.setRiskPlan({ entry: 64500, stop: 63800, multiples: [1, 2, 3] });
chart.setRiskPlan({ entry: 64500, stop: 63800, targets: [65900, 67300] }); // prices → kR
chart.clearRiskPlan();
chart.riskPlan; // { entry, stop, risk, direction, levels: [{ k, price }], maxK, label }Explicit targets convert to their R multiple (wrong-side prices drop);
multiples win when both are given. At most 8 levels, each ≤ 20R; invalid
specs clear the plan, never throw. normalizeRiskPlan is exported from
wickchart/core.
narrate() builds the timeline of a window (pivot highs/lows, volume
spikes, gaps, RSI divergences, plus derived legs — the move between
opposite pivots); walk() replays the chart through it while wick:walk
events announce each step, so a caption bar can narrate the replay.
chart.narrate(); // [{ i, time, type, note, legPct?, legBars? }]
chart.walk({ from: 0, to: 500, speed: 120, step: 10 });
chart.addEventListener('wick:walk', (e) => {
// { phase: 'step' | 'end' | 'stop', index, events: [...], from, to }
});
chart.stopWalk(); // any pointer/wheel/key input stops it toonarrateWindow (the analyzer) is exported from wickchart/core.
<wick-chart brush> makes a plain drag select bars instead of panning:
a live band follows the pointer with a delta chip (Δ% · bars · high · low ·
Σvol); on release the selection commits and fires wick:brush with the
range statistics. Esc (or clearBrush()) clears it.
<wick-chart brush></wick-chart>chart.addEventListener('wick:brush', (e) => {
// { bars, from: {index, time}, to: {index, time}, delta, deltaPct,
// firstOpen, lastClose, high, low, volume }
});
chart.brushSelection; // { i0, i1, stats } | null
chart.clearBrush();Brush mode replaces plain-drag panning (shift-drag still measures);
replacing the dataset clears a committed selection. brushStats is
exported from wickchart/core.
findShape() takes a bar range (or defaults to the current brush
selection) and finds every occurrence of that shape across the whole
loaded history — z-normalized similarity, so a match is about form, not
price level or amplitude: a dip that looks like your query matches at any
scale. One O(n log n) FFT pass (MASS-style sliding dot products) keeps any
history length interactive, and overlapping windows collapse greedily so
the list isn't full of near-duplicates.
chart.findShape({ from, to, maxMatches = 8, minScore }); // no args → the brush selection
chart.addEventListener('wick:shape', (e) => {
// { query: {from, to}, matches: [{from, to, distance, score}], duration }
});
chart.shapeResult; // the same payload | null
chart.clearShape();score is the Pearson correlation of the z-normalized shapes (1 =
identical, −1 = inverted; 0.8 reads as a strong match). The result renders
as translucent bands — the query in one color, its matches in another —
until clearShape() or a setData() replacement. shapeSearch (the
math) is exported from wickchart/core and carries golden vectors in
docs/correctness.md.
A story is an array of scenes (view range, type, indicators,
overlays, scenario, risk plan + title/note). playStory() applies each
scene, eases the camera to its range, holds for dwell, and narrates
through wick:story. Record scenes with captureScene() while you
arrange the chart, or generate them from an analysis.
const story = [chart.captureScene('Overview', 'the full picture')];
story.push({ title: 'The breakout', range: { from, to }, indicators: 'sma:20' });
chart.playStory(story, { dwell: 2200, panMs: 900, loop: false });
chart.addEventListener('wick:story', (e) => {
// { phase: 'scene' | 'end' | 'stop', index, total, scene, title, note }
});
chart.stopStory(); chart.getStory();Any user interaction stops the tour. Scenes are plain data — serialize
or share them. normalizeScene / sceneList / easeInOutCubic are
exported from wickchart/core.
One call turns whatever is on screen into a compact, LLM-pasteable summary. Everything is computed locally from the visible bars — trend (least-squares drift + fit), realized-vol percentile, SMA/RSI snapshot, up/down bar mix, volume profile notes, and the same pattern detection that powers smart annotations (gaps, spikes, pivots, divergences). Nothing leaves the page until you copy it somewhere.
const s = chart.getDataWindow();
s.text; // markdown — ready to paste into any AI chat
s.trend; // { label: 'strong uptrend', slopePctPerBar: 0.77, r2: 0.94 }
s.volPctile; // 84 → hot regime relative to the window itself
s.patterns; // [{ time, note }] — most recent firstThe chart can publish its own tool manifest and accept validated tool-calls, so any LLM can operate it with zero glue code — the chart never touches the network; you supply the model call.
chart.aiTools(); // manifest: get_data_window, set_indicators, set_overlays, add_alert, …
chart.aiPrompt(); // system prompt demanding JSON [{tool, args}] ops
chart.aiContext(); // grounding: current state + visible-window summary
chart.applyAI(ops); // validated dispatcher — per-op {ok, result} / {ok:false, error}
const { results } = await chart.ask(
'add RSI, mark the demand zone, and alert me on volume spikes',
{ run: async (payload) => (await callMyLLM(payload)).ops }
);Every op is whitelisted and its args validated (indicator names checked
against the registry, overlays through the sanitizer, enums enforced) — LLM
output is treated as untrusted input, and a bad op returns an error the
model can self-correct from instead of throwing. The
docs page has a live playground driving applyAI() with an
offline demo agent (no network, no keys).
text renders like:
CHART SUMMARY — BTC · 1h · 214 bars · 2026-08-21 → 2026-09-07
- Close 97.03 (−1.20% over window). High 104.20 on 2026-08-28, low 91.40 on 2026-09-01. Max drawdown 8.1%.
- Trend: downtrend (drift −0.061%/bar, fit r² 0.58). Price below SMA20 (99.10). RSI(14) 41.3.
- Volatility: annualized 48%; latest realized vol at the 84th percentile of the window (hot regime).
- Bars: 96 up / 117 down. Volume avg 1.2K/bar, peak 8.9K on 2026-09-01.
- Notable: Gapped down −1.42% (2026-09-01); Volume 4.1× average (2026-09-03).
The demo's Explain button shows this in a panel with a one-click copy.
The pure function behind it (windowSummary(bars, i0, i1, opts)) is exported
from wickchart/core for server-side use.
<wick-chart sonify> maps price to pitch (180–880 Hz across the visible
scale, log-aware): moving the crosshair with the mouse or arrow keys plays
a short tone per bar, so trend and shape are audible — a rare accessibility
win for screen-reader users. chart.playRange() sweeps the whole visible
range as a ~4-second pitch sequence, riding the crosshair along for sighted
users. Audio starts lazily within the enabling user gesture (autoplay-policy
safe).
Tag charts with the same channel and they share pointers — across browser tabs, or between multiple charts on one page:
<wick-chart co-view="btc-room" co-view-name="ben"></wick-chart>Hovering in one tab draws a ghost crosshair (accent, dotted, with the time
pill) in every peer. Positions are synced by bar time, so peers with
different history depths still line up. Ghosts fade ~2.5 s after the peer
stops moving. Same-origin only (BroadcastChannel); the connection follows the
co-view attribute and closes with the element.
Peers also see where everyone is looking: each peer's viewport renders as a colored band (with name) along the top of the plot, updated live as they pan or zoom and swept away ~12 s after they go quiet.
chart.getPeers(); // [{ id, name, range: { from, to }, at }]
chart.addEventListener('wick:peers', (e) => {
// { peers, joined, left } — membership changes only
});PresenceTracker (the TTL bookkeeping) is exported from wickchart/core
for apps that sync presence over their own transport instead.
<wick-chart annotations> marks notable events on the visible range — volume
spikes (>3× average), price gaps, 41-bar pivot highs/lows, and RSI
divergences — with lettered badges (V/G/H/L/D). Hover a badged bar and the
legend shows a one-line insight ("Volume 4.2× average", "Bearish RSI
divergence"). The current set is emitted on every recompute via the
wick:annotations event, so hosts can build their own UI from it. Badges are
hidden at extreme zoom-out, where bars collapse into columns.
VWAP ships in the demo but not as a builtin — it's the reference for writing your own (session-anchored, resets each trading day):
import WickChart from 'wickchart';
WickChart.registerIndicator('vwap', {
kind: 'overlay',
params: {},
compute(bars) {
const out = new Array(bars.length).fill(null);
let pv = 0, vv = 0, day = -1;
for (let i = 0; i < bars.length; i++) {
const b = bars[i];
const d = new Date(b.time).setHours(0, 0, 0, 0);
if (d !== day) { day = d; pv = 0; vv = 0; }
const tp = (b.high + b.low + b.close) / 3;
pv += tp * b.volume;
vv += b.volume;
out[i] = vv ? pv / vv : null;
}
return out;
},
});
chart.indicators = 'vwap';addLayer() is the whole extension surface: an external draw hook that paints
into the render pipeline (above chart content, under the crosshair) and can
claim pointer gestures so drags reach your code instead of panning the chart.
Four public coordinate transforms — timeToX/xToTime (extrapolating past
the last bar into future space) and priceToY/yToPrice — anchor your
content in data space so it rides along with zoom and pan:
chart.addLayer({
id: 'flags',
draw(api) {
const x = api.timeToX(t), y = api.priceToY(p); // anchors, not pixels
api.ctx.fillStyle = api.palette.accent;
// …paint in CSS pixels
},
onPointer(ev) {
if (ev.type === 'down' && hitsMyContent(ev)) return true; // claim the drag
},
});
chart.requestDraw(); // repaint hook for interactive layers
chart.removeLayer('flags'); // detach by handle or idA claimed gesture delivers move/up (and cancel on Escape) to the layer
while the chart suppresses pan/brush/measure. Markers, watermarks, signal
badges — or a whole drawing toolkit — plug in without the core growing a
single tool. The main entry is covered by a CI gzip budget (68 KB) so it
stays that way.
The first official plugin: TradingView-style drawing tools as opt-in bytes
(~8 KB gz, own CI budget). Trendlines (segment/ray), horizontal levels,
rectangles, fibonacci retracements and text — all plain { time, price }
data that rides zoom & pan, survives reloads, extrapolates into future
space, and serializes to JSON. Anchors magnet-snap to bar times and OHLC.
npm install wickchart wickchart-draw // drawings are a separate opt-in package
import { attachDrawings } from 'wickchart-draw';
const draw = attachDrawings(chart);
draw.setTool('trendline'); // drag to draw; setTool(null) = select/move mode
draw.getDrawings(); // → JSON array (save it); setDrawings(saved)
draw.undo(); draw.clear();
draw.setShare(true); // shared drawings: draw on one tab, appears on all
chart.addEventListener('wick:drawings', (e) => save(e.detail.drawings));Select mode: click a drawing to select it, drag to move, drag the square
handles to re-anchor, Delete removes, Esc cancels a gesture; clicks on
empty space fall through to the chart. Placing a note opens an inline editor
(type + Enter); click a selected note again to re-edit. setShare(true)
reuses the chart's co-view room (or pass an explicit room name) — last
writer wins, remote updates never touch the local undo stack. Peer
dependency: wickchart ≥ 1.4. See the live playground in the docs (Drawings
section — it shares a room, so open it twice and draw on either chart).
Market session shading as opt-in bytes (~6 KB gz, own CI budget): Asia /
London / New York and other sessions drawn as translucent bands, with labels,
closed-weekend shading for equities/futures, and crosshair hover events.
Presets for crypto & forex use the common UTC convention; equity/futures
presets use IANA timezones, so 09:30 is the real 09:30 across DST changes.
Custom defs ({ name, start, end, tz?, days?, color?, alpha? }) cover
midnight-crossing sessions and weekday filters.
npm install wickchart wickchart-sessions // sessions are a separate opt-in package
import { attachSessions } from 'wickchart-sessions';
const sessions = attachSessions(chart, { preset: 'crypto' });
sessions.setPreset('nyse'); // 'crypto' | 'forex' | 'nyse' | 'cme' | null
sessions.setSessions([...]); // custom defs (validated; getSessions() → JSON)
sessions.setWeekends(true); // shade closed Sat+Sun (default for nyse/cme)
chart.addEventListener('wick:sessions', (e) => status.textContent = e.detail.hover || '');The hover bridge listens to the chart's own crosshair events, so shading never claims a pointer gesture — pan/zoom/measure work untouched. Peer dependency: wickchart ≥ 1.4.
Bar replay as opt-in bytes (~3 KB gz, own CI budget): play history forward
bar-by-bar or at speed while the future stays hidden. The whole engine runs
on the public data API — a setData slice hides the future, update()
appends one bar per step — so the core stays replay-free. A badge layer shows
the mode and position at a glance.
npm install wickchart wickchart-replay // replay is a separate opt-in package
import { attachReplay } from 'wickchart-replay';
const replay = attachReplay(chart);
replay.start(); // head at ~70% of the data (or pass a time/index)
replay.play(); // 4 bars/sec — play(15) for faster, pause() stops
replay.step(); // reveal one bar
replay.seek('2026-03-06'); // jump the head
replay.setLoop(true); // wrap to the anchor at the end
replay.stop(); // exit — the full dataset is restored
chart.addEventListener('wick:replay', (e) => progress.textContent =
e.detail.active ? `${e.detail.index + 1}/${e.detail.total}` : '');Anchors accept bar indices, timestamps (ms/s) or date strings; every change
fires wick:replay with the full state. Pause live feeds while replaying —
an external update()/setData() aborts replay instead of corrupting the
chart (the demo pauses its feed automatically). Paper trading and an equity
curve are the planned 0.2 follow-up. Peer dependency: wickchart ≥ 1.4.
Live-price easing as opt-in bytes (~2 KB gz, own CI budget): ticks on the
forming bar glide to their new close over a short ease instead of
teleporting. The engine wraps update() on the instance, so every tick
source — app code, <wick-feed>, the paper plugin — is eased without
wiring; it is display-path only (no drawing, no new core surface).
npm install wickchart wickchart-animate // animate is a separate opt-in package
import { attachAnimate } from 'wickchart-animate';
const anim = attachAnimate(chart, {
duration: 180, // ms per ease (default 180, 0 disables, max 1500)
easing: 'ease-out', // 'ease-out' | 'linear' | fn(t)
volume: false, // ease the volume histogram too
});
anim.detach();Eased frames rewrite the forming bar's close — the body, last-price line,
axis label, legend and stats all derive from it, so everything glides
together. Fidelity rules: the final frame always writes the true bar, a new
bar flushes the previous bar's true value first, backfills and historical
corrections pass through un-eased, a retarget mid-ease continues from the
current display value without jumping, and prefers-reduced-motion is a
hard passthrough.
One caveat worth reading before you lean on it: eased frames are live ticks
to the chart, so alert predicates and online indicator recompute see
interpolated closes — the linear path between two real closes, at most
duration of firing-time skew. If your alerts must only ever see real
prints, duration: 0 or detach() are the escape hatches. Peer
dependency: wickchart ≥ 2.3.
Normalized multi-asset overlays as opt-in bytes (~4 KB gz, own CI budget):
percent-rebased compare lines (ETH against BTC, TradingView-style) plus
derived ratio and diff lines (BTC/ETH, BTC−ETH), drawn over the
main pane against their own invisible scale so the price axis is untouched.
A legend chip row shows each series with its live value.
npm install wickchart wickchart-compare // compare is a separate opt-in package
import { attachCompare } from 'wickchart-compare';
const cmp = attachCompare(chart);
cmp.setSeries([
{ label: 'ETH', data: ethBars }, // OHLC or {time, value}
{ label: 'BTC/ETH', op: 'ratio', a: btcBars, b: ethBars }, // derived
]);
cmp.setRebase('visible'); // 0% at the window edge, re-normalized while
// panning; 'first' or an epoch anchor also work
cmp.clear(); cmp.detach();Series are sampled onto the main chart's bar times, so timeframes can mix and gaps break the line instead of bridging. Rebased values share one invisible scale inset 8% from the pane edges; the price scale is never distorted. Validated, capped at 6 series, invalid entries dropped. Peer dependency: wickchart ≥ 1.4.
The most-missed TradingView affordance: a silhouette of the whole dataset docked below the chart with a draggable viewport window (~3 KB gz, own CI budget). Drag the window to pan, grab an edge to resize, click outside it to jump — pan/zoom and the window stay in sync live, both directions.
npm install wickchart wickchart-navigator // navigator is a separate opt-in package
import { attachNavigator } from 'wickchart-navigator';
const nav = attachNavigator(chart, { height: 46 }); // strip height, 24..120
nav.detach(); // remove the strip againThe strip needs bottom space, so this plugin pairs with a small core hook:
a layer may declare insetBottom (px) — the largest declared inset reserves
a docked strip at the bottom of the canvas, panes and the time axis shrink
above it, and layers draw it as api.layout.dock. On charts without the
hook the navigator degrades silently. The silhouette is O(n) once per
(dataset, width) and cached. Peer dependency: wickchart ≥ 1.6.
The "pro" alert tier (~3 KB gz, own CI budget). Core alerts are runtime-only
by design; this adds what a trading tool actually needs, without the core
growing any of it: persistence (the alert list mirrors into
localStorage and re-arms on reload), desktop notifications + a WebAudio
beep while the tab is hidden, and an optional webhook that receives
every fire as POST { id, price, when, time, bar, key }.
npm install wickchart wickchart-alerts-plus // alerts-plus is a separate opt-in package
import { attachAlertsPlus } from 'wickchart-alerts-plus';
const ap = attachAlertsPlus(chart, {
key: 'BTC:1h', // one storage key per symbol+timeframe
notify: true, sound: true, // hidden-tab surfacing
webhook: 'https://example.com/hook', // optional
});
await ap.requestNotify(); // ask for the notification permission
ap.add({ price: 100, direction: 'above' }); // persisted, re-armed on reload
ap.add({ when: 'rsi(close,14) < 30' }); // scripted alerts persist too
ap.list(); ap.remove(id); ap.clear(); ap.sync(); ap.detach();Once-fired alerts drop out of storage automatically; alerts added directly on the chart are captured at the next save point; storage/fetch are injectable and every storage failure degrades to memory-only, never throwing. Peer dependency: wickchart ≥ 1.4.
Named workspace persistence (~3 KB gz, own CI budget): save and restore
whole chart setups by name — type, theme, log scale, toggles, indicators,
view range, positions, alerts — plus the drawing list when wickchart-draw
is attached. Everything rides the core's public getState()/setState().
npm install wickchart wickchart-layouts // layouts is a separate opt-in package
import { attachLayouts } from 'wickchart-layouts';
const layouts = attachLayouts(chart, {
key: 'my-desk', // storage key (default 'wickchart-layouts')
drawings: draw, // optional wickchart-draw handle — include drawings
});
layouts.save('swing'); // capture the current setup under a name
layouts.load('swing'); // apply it back
layouts.list(); // → [{ name, at, drawingCount }] newest first
layouts.export(); // → JSON string — share it, store it anywhere
layouts.import(json); // merge layouts back (replaces same names)
chart.addEventListener('wick:layouts', (e) => console.log(e.detail.action, e.detail.name));Entries are capped (oldest evicted), storage is injectable, storage
failures degrade to an in-memory store for the session and never throw.
Pair a load with wickchart-alerts-plus's sync() if you also persist
alerts, since a layout load replaces the chart's alert list. Peer
dependency: wickchart ≥ 1.4.
Candlestick pattern badges (~4 KB gz, own CI budget): bullish/bearish
engulfing, pin bars (hammer / shooting star) and inside bars
drawn as direction-colored letter chips above/below the bar. Hover a badged
bar and the plugin draws the explanation ("Bullish engulfing") and fires
wick:signals — the same passive crosshair bridge as wickchart-sessions,
so badges never claim a pointer gesture.
npm install wickchart wickchart-signals // signals is a separate opt-in package
import { attachSignals } from 'wickchart-signals';
const signals = attachSignals(chart);
signals.setKinds(['engulfing', 'pinbar']); // subset (default: all three; [] = off)
signals.setLabels(false); // hover explanations off
chart.addEventListener('wick:signals', (e) => status.textContent = e.detail?.label || '');Detection is O(n), cached per dataset and kind subset — pan/zoom are pure repaints. Peer dependency: wickchart ≥ 1.4.
Time & sales (~4.6 KB gz, own CI budget): a live trade-print strip docked at
the bottom of the canvas through the insetBottom hook — time · price · size rows colored by side with proportional size bars, oversized prints
highlighted. Display-only: it never claims a pointer gesture. Prints carry
an optional side; without one the plugin applies the classic tick rule
(uptick → buy, downtick → sell), carried continuously across pushes. The
same stream drives the chart: chart.setData(tape.toBars(60000)).
npm install wickchart wickchart-tape // tape is a separate opt-in package
import { attachTape } from 'wickchart-tape';
const tape = attachTape(chart, { rows: 7, bigSize: 50 });
socket.onmessage = (m) => tape.push(m.trades); // single print or batch
tape.setRows(4); tape.hide(); tape.detach(); // rows 3–8; hide frees the dock
chart.addEventListener('wick:tape', (e) => status.textContent = e.detail.total + ' prints');Keeps the newest 500 prints. Peer dependency: wickchart ≥ 1.6 (the dock hook); shares the bottom strip with wickchart-navigator, so attach one or the other.
One command that explains the chart (~3.4 KB gz, own CI budget): the
visible window synthesized into a structured markdown briefing — tape
summary, candlestick signal census, smart annotations and the narrate
timeline, composed into one ready-to-paste document. Pure composition:
the sections are windowSummary, detectSignals,
detectAnnotations and narrateWindow, so the briefing can never
disagree with the chart it describes. Computed locally — nothing leaves
the page until you copy it somewhere.
npm install wickchart wickchart-briefing // briefing is a separate opt-in package
import { attachBriefing } from 'wickchart-briefing';
attachBriefing(chart);
const b = chart.briefing(); // the model — null before data lands
b.markdown; // the document (plus structured fields)
await chart.copyBriefing(); // …straight to the clipboardIt covers the visible range (pan/zoom first — the briefing follows the
view), falls back to the full dataset, and its output is deterministic
(UTC-stable timestamps, injectable clock). Dependencies:
wickchart-signals + wickchart-narrator — the census and the timeline
are theirs, not copies. briefingModel(bars, i0, i1, opts) from
wickchart-briefing/core is the pure half — no chart, no DOM.
| Method | Description |
|---|---|
setData(bars) |
Replace the dataset (sorted automatically) |
update(bar) |
Stream: replaces last bar or appends a new one |
clearData() |
Empty the chart |
fit() |
Reset zoom to the default view (~150 bars) |
getVisibleRange() |
→ { from, to } (ms timestamps) |
setVisibleRange({from, to}) |
Jump to a time window |
findShape({from, to, …}) |
Z-normalized shape search → ranked matches + bands |
clearShape() / shapeResult |
Clear the bands / read back the last result |
exportPNG() |
→ PNG data URL of the current canvas |
getDataWindow() |
→ AI-ready summary of the visible window (see below) |
getState() |
→ serializable snapshot (type, indicators, view, positions, alerts) |
setState(state) |
Apply a snapshot; a pending view applies after the next setData() |
addLayer(layer) / removeLayer(idOrHandle) |
Register/detach a plugin layer (draw hook + optional pointer claim + optional insetBottom dock strip) |
requestDraw() |
Repaint on the next frame (interactive layers) |
timeToX(t) / xToTime(x) |
Bar time ⇄ x-pixel; extrapolates into future space |
priceToY(p) / yToPrice(y) |
Price ⇄ y-pixel in the main pane (log-aware) |
Assign a callback and the chart fetches older bars whenever the user scrolls toward the left edge — the view stays anchored while data is prepended:
chart.onloadmore = async (fromTime) => {
const res = await fetch(`/api/bars?before=${fromTime}&limit=500`);
return res.json(); // [{ time, open, high, low, close, volume }, …]
};Return an empty array (or throw) when history is exhausted and the chart stops asking. Data gaps (weekends, session breaks) are marked with subtle dashed dividers on the time axis.
Visualize trades directly on the chart — entry/stop/target zones, a live P&L chip, and price alerts that fire during streaming updates:
chart.addPosition({ side: 'long', entry: 64200, stop: 62900, target: 66800, qty: 0.5 });
chart.addPosition({ id: 'x1', side: 'short', entry: 66000, qty: 1 });
chart.removePosition('x1');
chart.addAlert({ price: 65000, direction: 'above' }); // 'above' | 'below' | 'cross'
chart.addEventListener('wick:alert', (e) => {
console.log('crossed!', e.detail.id, e.detail.price);
});
// scripted alerts — any WickScript predicate, fired on its false→true edge
chart.addAlert({ when: 'crossup(rsi(close,14), 30)' });
chart.addAlert({ when: 'volume > sma(volume,20) * 3', once: false }); // re-arms
// evaluate only on final candles, so the signal cannot repaint
chart.addAlert({ when: 'crossup(rsi(close,14), 30)', evaluate: 'close' });The P&L chip recalculates on every streamed bar. Alerts are edge-triggered
(fire once per crossing) and one-shot by default (once: false to re-arm).
Scripted alerts are evaluated locally on every streamed bar — the event
carries the triggering close as price plus the when source; an invalid
predicate is rejected (addAlert returns null), never thrown.
Live vs closed-candle evaluation. Alerts evaluate on every update by
default, the still-forming candle included — so a technical signal can
repaint (RSI crosses 30 mid-candle, price reverses, the candle closes back
above 30). Pass evaluate: 'close' to fire only on final candles, or set
<wick-chart alert-evaluate="close"> as the chart-wide default (per-alert
evaluate still wins). A candle is final once a newer bar arrives, or as
soon as the feed says so via closed: true on update() — <wick-feed>
forwards Binance's k.x flag, so the signal lands at the close rather than
one candle later. Historical corrections and backfilled candles never fire
live alerts in either mode.
Axis labels and the crosshair readout use the viewer's timezone by default.
Pin them with timezone — local, utc, or any IANA zone, DST included:
<wick-chart timezone="Europe/Stockholm"></wick-chart>
<wick-chart timezone="America/New_York"></wick-chart>Day dividers and month/year ticks follow the chosen zone, so a "1 Feb" tick is 1 February there. An unrecognised zone falls back to UTC and warns once.
VWAP's session boundary is deliberately separate from the display zone — changing the axis to Stockholm shouldn't silently re-anchor a BTC chart. It defaults to the UTC day (the crypto convention) and moves only when asked:
<wick-chart indicators="vwap" vwap-anchor="America/New_York"></wick-chart>vwap-anchor takes utc (default), local, an IANA zone, or a fixed offset
in milliseconds. Equities, futures and FX rarely open at UTC midnight, so the
default is right for crypto and wrong for most other markets — set it
deliberately. calcVWAP(bars, anchor) takes the same values directly.
<wick-chart stats> shows live statistics of the visible range — return %,
max drawdown, annualized volatility, up/down bar counts, average volume —
recalculated as you pan and zoom.
Hold Shift and drag across the chart to measure a move: an overlay shows
Δprice, Δ%, bar count and elapsed time, and a wick:measure event fires on
release (detail.from / detail.to carry index, time and price). Click or
press Esc to clear.
getState() / setState() serialize everything about the chart, and
encodeStateQuery / decodeStateQuery (exported from src/core.js) turn a
state into a compact query string — the demo maps it to the page hash, so any
chart configuration is one link away:
import { encodeStateQuery, decodeStateQuery } from 'wickchart/core';
const link = `${location.origin}#${encodeStateQuery(chart.getState())}`;
history.replaceState(null, '', link);
// later, on load:
chart.setState(decodeStateQuery(location.hash.slice(1)));Reflected properties (chart.type = 'line') work for theme, type, label,
indicators.
| Event | Detail |
|---|---|
wick:crosshair |
{ index, bar, x, y, price } on hover / arrows, null on leave |
wick:range |
{ from, to } after zoom / pan / jump |
wick:select |
{ index, bar, price } on click/tap (e.g. open an order form at that price) |
wick:brush |
{ bars, from, to, delta, deltaPct, high, low, volume } after a brush selection |
wick:shape |
{ query: {from, to}, matches: [{from, to, distance, score}], duration } from findShape() |
All colors are CSS custom properties settable on the element (they pierce the Shadow DOM):
wick-chart {
--wick-bg: #0d1117; /* transparent works too */
--wick-up: #16c784;
--wick-down: #ea3943;
--wick-accent: #4c8dff; /* line & area color */
--wick-text: #8b949e; /* axis text */
--wick-text-strong: #e6edf3; /* legend values */
--wick-grid: rgba(230,237,243,.05);
--wick-border: rgba(230,237,243,.09);
--wick-crosshair: rgba(230,237,243,.42);
--wick-rsi: #a78bfa;
--wick-overlay-0: #f0b429; /* SMA color, …-1, -2, … for more overlays */
}Ship a palette as a named theme instead of sprinkling CSS variables over
every element. registerTheme(name, palette, opts?) — exported from
'wickchart' (and 'wickchart/core') — merges a partial palette over a
built-in base ({ base: 'dark' | 'light' }, default dark), and the name
then works everywhere theme="dark" works:
import { registerTheme } from 'wickchart/core';
registerTheme('matrix', {
bg: '#001100', text: '#00ff66', textStrong: '#00ff9d',
up: '#00ff66', down: '#ff2d2d', accent: '#00d0ff', // partial — the rest
}); // comes from the base
// <wick-chart theme="matrix"></wick-chart>The merge is forgiving: unknown palette keys are dropped, a string overlay
expands to the whole indicator palette while an array pads with the base
colors, and volAlpha coerces to a number. Re-registering a name
overwrites it live, and built-in names can be replaced the same way.
Re-registering a built-in name does not change report export colors — the
report keeps its own tuned light/dark palettes.
Registration is global — every chart on the page resolves the name.
The escape hatches keep working: --wick-* CSS variables still override
the registered palette slot by slot, the PNG report export follows the
chart's registered theme, and saved layouts / shareable presets round-trip
the name. One declarative-flow note: with the HTML-attribute +
module-script pattern the chart may render once before your script
registers the theme — the name resolves at the next render (the setData
call, the first tick), not retroactively.
| Gesture | Action |
|---|---|
| Mouse wheel / trackpad ⌘+scroll | Zoom, anchored at the cursor |
| Trackpad horizontal scroll | Pan |
| Drag | Pan (auto-follow re-arms at the right edge) |
| Pinch (touch) | Zoom |
| Long press (touch) | Open the crosshair, then drag to scrub across bars |
| Vertical swipe (touch) | Scrolls the page, not the chart |
| Double-click / double-tap | Reset view |
← → (+Shift ×10) |
Move crosshair |
+ / − |
Zoom in / out |
Home / End |
Jump to oldest / newest |
Esc |
Clear crosshair |
Canvas 2D with a rAF-batched, visible-range-only render pipeline. Measured on a desktop (Chromium, 1100×760, all indicators on: SMA + EMA + RSI + volume):
| Scenario | Per full render |
|---|---|
| 600–50,000 bars, default view (~150 visible) | ~0.2 ms |
| 5,000 bars, max zoom-out (~2,900 visible) | ~6 ms |
| 50,000 bars, max zoom-out (~3,100 visible) | ~16 ms |
| Streaming tick (update + full re-render) | 0.5–19 ms |
A 60 fps frame budget is 16.7 ms, so the default view uses ~1% of a frame.
Those bars are enforced, not aspirational: e2e/perf.spec.mjs fails CI when
a full render — timed in a real browser on a 50k-bar history, read as the
median of batched samples — exceeds 1 ms at the default view or 8 ms at max
zoom-out.
Hot paths are deliberately allocation-light: date labels are built lazily only
for actual axis ticks (with cached Intl.DateTimeFormats), and candles/volume
are drawn in two batched passes by direction instead of one draw call per bar.
Deep zoom-outs are columnar: when more bars are visible than ~1.5× the pixel width, bars aggregate into per-pixel min/max columns (first open / max high / min low / last close / summed volume), so rendering any history at any zoom costs O(screen width), not O(bars). The minimum zoom level adapts to the dataset — every chart can be zoomed out until the entire history fits.
If you ever push past this (100k+ simultaneously visible bars, dozens of series, high-frequency ticks), the scaling levers are: incremental indicator updates (SMA/EMA/RSI are O(1) online), min/max columnar downsampling per pixel column, and an offscreen layer so hover only repaints the crosshair.
- Single ES module, Custom Element + Shadow DOM, Canvas 2D with
devicePixelRatio scaling and rAF-batched invalidation. The ratio is watched
with a
resolutionmedia query, so moving a window between monitors re-renders at the new resolution rather than staying soft - Only visible bars are drawn; indicator series are computed lazily and cached per data version (prefix-sum SMA, Wilder RSI)
- Time axis picks tick steps from bar interval (minutes → months) and labels day/month boundaries like a pro terminal
- No dependencies, no build step required — but it bundles/tree-shakes fine
Two suites, and they answer different questions.
npm test # Node: pure functions, indicator maths, parsing, plugins
npm run test:e2e # Playwright: the chart in a real browsernpm test is the fast one and covers the bulk of the library. What it cannot
reach is anything that only exists once a browser is involved: custom-element
upgrade, a real canvas, wheel/pointer/touch input, devicePixelRatio,
ResizeObserver, and React re-renders against a live DOM node. Bugs have
shipped in exactly that gap — a React parent re-render used to silently reset
the user's zoom, and a chart moved to a monitor with a different pixel ratio
kept rendering at the old resolution. Both are covered in e2e/ now.
Golden vectors gate the indicator math itself. Every exported kernel
(calcSMA … calcSuperTrend) runs in npm test against committed reference
vectors — generated from independent numpy/pandas implementations,
cross-checked against the third-party ta
package where conventions align, plus hand-derived round-number cases — and
fails over tolerance. The method, the pinned conventions and the cross-check
matrix live in docs/correctness.md.
Fuzz + deterministic replay is the third gate, next to size and perf.
Both halves derive everything from one 32-bit seed (tests/fuzz-gen.mjs):
the same seed regenerates the same adversarial tapes, attribute writes and
gesture coordinates, so any failure replays byte-exact —
WICK_FUZZ_SEED=<seed> npm test -- tests/fuzz.test.mjs (or
npx playwright test e2e/fuzz.spec.mjs), WICK_FUZZ_ITER=<n> digs deeper.
The Node half (tests/fuzz.test.mjs, in npm test) property-fuzzes the
pure kernels — every builtin indicator must answer every adversarial tape
without throwing and with tape-aligned number-or-null series
(ichimoku's forward-displaced senkou spans excepted), the stats/annotation/
shape-search/summary kernels never throw, and mergeOlderData keeps the
tape sorted and deduplicated. The browser half (e2e/fuzz.spec.mjs, in
npm run test:e2e) runs whole scenarios against the live element — random
tapes through setData, random attribute strings (valid, garbage and
malicious), streaming updates, and CDP-driven wheel/drag/keyboard gestures —
asserting after every step that nothing threw, the tape stays strictly
ascending, the visible range stays finite and ordered, and the chart paints.
CI runs the default budget (24 scenarios); a deep local dig of 200 finishes
in ~1.5 minutes.
The browser suite serves the repository over a small dependency-free static
server (e2e/server.mjs) and loads the library from source, so it tests the
files that ship rather than a build artifact. The React fixture pulls React
from esm.sh, the same way demo/react.html does.
Running it locally. npm run test:e2e downloads Playwright's bundled
Chromium the first time. If that CDN is blocked on your machine, point the
suite at a browser you already have:
WICK_E2E_CHANNEL=chrome npm run test:e2e # or msedgeVisual regression is opt-in. Canvas output is not pixel-identical across operating systems, so a committed baseline from one machine red-lights everyone else; the rest of the suite compares the chart against itself instead (repaint X, assert only what should have moved did). To gate on real screenshots, generate baselines on the platform that will run them:
WICK_E2E_VISUAL=1 npm run test:e2e -- --update-snapshotsThe next phase targets two summits — chart intelligence (shape/pattern search, an AI chart copilot) and provable correctness & scale (golden-vector indicator testing, fuzz/replay, the 5M-bar columnar store) — plus chart-as-artifact sharing on the side and a 3.0 hygiene cut to close. Full plan: ROADMAP.md; the shipped 1.x–2.x plan is archived as ROADMAP-1.x-2.x.md.
2.0 removes the deprecated 0.x hab-* aliases (they warn once per surface
from 1.7.1) and moves the six optional feature families out of the core
entry into their own packages — the core drops back under ~65 KB gz. The
full plan and delivery sequence: ROADMAP-V2.md.
Aliases removed. Renaming is mechanical; each warns in 1.7.1 already:
| removed in 2.0 | use instead |
|---|---|
<hab-chart> / <hab-feed> |
<wick-chart> / <wick-feed> |
hab:* events (every chart event fired twice in 1.x) |
wick:* of the same name |
hab-feed:* events |
wick-feed:* |
--hab-bg, --hab-up, … (runtime and stylesheet fallbacks) |
--wick-* of the same name |
Still on 0.x? Upgrade through 1.x first — the 1.x releases carry the aliases with warnings, so the rename can be done lazily there.
Features moved to packages. Calls keep their shape: each
attachX(chart) installs the familiar methods on the instance, so
existing call sites survive with one added import line. Without the
package, the core methods become warn-once stubs naming it:
| 1.x (in core) | 2.0 package |
|---|---|
narrate() · walk() / stopWalk() · playRange() · the sonify attribute · captureScene() / getStory() / playStory() / stopStory() |
wickchart-narrator |
the co-view / co-view-name attributes · getPeers() |
wickchart-coview |
setScenario() / clearScenario() · setRiskPlan() / clearRiskPlan() |
wickchart-scenario |
aiTools() · aiPrompt() · aiContext() · applyAI() · ask() |
wickchart-ai |
import { attachNarrator } from 'wickchart-narrator';
attachNarrator(chart); // chart.narrate() / walk() / playStory() … work as beforeUnaffected: getDataWindow() stays in core (a data API, not an LLM
API), getState() / setState() serialize none of the moved features, and
all four packages are already published and documented on the
plugins hub — you can
adopt them today, on 1.x (attaching simply shadows the core's identical
methods).
Versioned per semver; every release is a tagged GitHub Release with the changelog — see CHANGELOG.md.
MIT