Skip to content
benyblackPublic

About

Zero-dependency financial charts as a single Web Component. Candles, indicators, live streaming, drawing tools, co-view sync — one tag, no build step.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

WickChart

npm

<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.

image

Install

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).

Declarative live charts with <wick-feed>

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.

Information-based bars (advanced bars)

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.

Web Worker compute path (1M-bar histories)

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.

Report export (branded snapshots)

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.


Standalone export (send a chart, not a screenshot)

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 → restores

What 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.


Why another chart library?

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

Try it online

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.

Run the demo locally

npm run dev        # serves on http://localhost:5173
# or: npx serve . -l 5173
# or: python -m http.server 5173

Then 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.


Frameworks

<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.

React

npm install wickchart react
import { 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 data when the bars change — the binding compares by reference, and reassignment is what triggers a redraw (don't mutate). The same rule applies to overlays (see Server-side overlays).
  • String/number/boolean props become attributes (type, indicators, volshading, …); className/style/id reach React as usual.
  • onXxx subscribes to wick:xxx with cleanup on unmount; an events={{ 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.

Vue 3

<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>

Svelte

<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>

Data format

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 },
  // ...
]);

Attributes

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.

Built-in indicators

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

Custom indicators

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).

WickScript — custom indicators as expressions

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-in

The demo has a live input for it (type an expression, optionally tick pane, press + Expr — invalid expressions show the compiler's error inline).

Volatility-regime shading

<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.

Server-side overlays (zones & levels)

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 (by id), removeOverlay(id), clearOverlays(), and chart.overlays reads them back.
  • Colors accept hex / rgb() / CSS names plus the palette keys up | down | accent; alpha clamps 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.

Scenario mode — ghost paths & volatility cones

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.

Risk planner — R-multiple grid

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.

Bar-walk narrator — history as a story

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 too

narrateWindow (the analyzer) is exported from wickchart/core.

Delta brush — drag-select with stats

<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.

Shape search — find this pattern everywhere

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.

Story mode — guided tours of chart state

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.

AI-ready data window — getDataWindow()

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 first

AI agent interface — the chart as a tool surface

The 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.

Sonification — the chart by ear

<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).

Cross-tab co-view & presence

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.

Smart annotations

<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.

Example: VWAP via the registry

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';

Plugin layers — extend without forking

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 id

A 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.

Drawings — the wickchart-draw plugin

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).

Sessions — the wickchart-sessions plugin

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.

Replay — the wickchart-replay plugin

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.

Animate — the wickchart-animate plugin

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.

Compare — the wickchart-compare plugin

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.

Navigator — the wickchart-navigator plugin

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 again

The 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.

Alerts+ — the wickchart-alerts-plus plugin

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.

Layouts — the wickchart-layouts plugin

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.

Signals — the wickchart-signals plugin

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.

Tape — the wickchart-tape plugin

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.

Briefing — the wickchart-briefing plugin

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 clipboard

It 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.

Methods

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)

Infinite history (loadMore)

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.

Positions & alerts

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.

Timezone & VWAP sessions

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.

Stats & measure

<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.

Shareable URLs

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.

Events

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()

Theming

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 */
}

Named custom themes — registerTheme()

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.

Interactions

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

Performance

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.

Architecture notes

  • Single ES module, Custom Element + Shadow DOM, Canvas 2D with devicePixelRatio scaling and rAF-batched invalidation. The ratio is watched with a resolution media 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

Tests

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 browser

npm 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 msedge

Visual 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-snapshots

Roadmap

The 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.

Migrating from 1.x to 2.x

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 before

Unaffected: 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).

Releases

Versioned per semver; every release is a tagged GitHub Release with the changelog — see CHANGELOG.md.

License

MIT

About

Zero-dependency financial charts as a single Web Component. Candles, indicators, live streaming, drawing tools, co-view sync — one tag, no build step.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages