npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

emberwick

v0.14.0

Published

Smooth flowing candlestick charts for the web — canvas-rendered, zero-dependency, pluggable data feeds.

Readme

Emberwick

A smooth-flowing candlestick chart for the web. Canvas-rendered, framework-free, and driven by a pluggable data feed — drop it into any financial frontend and point it at your own market data.

  • Zero dependencies. The core imports nothing but DOM and Canvas APIs.
  • Framework-agnostic. Works in React, Vue, Svelte, or a plain <script type="module">.
  • Actually smooth. Ticks ease into the forming candle, the price axis glides to new bounds, zoom is cursor-anchored and eased, panning has inertia. (While a live feed is attached and the chart is following realtime, zoom holds the right edge instead, so the newest candle stays put.)
  • Fast on big data. One requestAnimationFrame loop, dirty-flag driven, with visible-range culling — 500k bars loaded costs only the ~200 on screen.
  • Annotated. Nine marker shapes, price lines and shaded zones, with collision-aware stacking and hit-testing for hover and click.
  • Drawn on. Trendlines, Fibonacci, channels, ranges and positions, pinned to time and price, snapped where you mean and undoable — in a separate entry that is absent from your bundle until you import it. See Drawing tools.
  • Profiled. Session and visible-range volume profiles with the point of control and value area, drawn under the candles from data you supply, in a third entry that is also absent until you import it. See Volume profiles.

Installing

From npm

npm install emberwick
import { createChart, RandomFeed } from 'emberwick'

From a CDN, no build step

<div id="chart" style="height: 480px"></div>
<script src="https://unpkg.com/emberwick/umd/emberwick.umd.js"></script>
<script>
  const chart = Emberwick.createChart(document.getElementById('chart'))
  chart.setFeed(new Emberwick.RandomFeed({ timeframe: 60000, speed: 60 }))
</script>

Drawing tools are a second file, loaded after the first:

<script src="https://unpkg.com/emberwick/umd/emberwick.umd.js"></script>
<script src="https://unpkg.com/emberwick/umd/emberwick-drawings.umd.js"></script>
<script>
  const chart = Emberwick.createChart(document.getElementById('chart'))
  const drawings = EmberwickDrawings.enableDrawings(chart)
</script>

Volume profiles are a file of their own too, loaded the same way:

<script src="https://unpkg.com/emberwick/umd/emberwick.umd.js"></script>
<script src="https://unpkg.com/emberwick/umd/emberwick-profiles.umd.js"></script>
<script>
  const chart = Emberwick.createChart(document.getElementById('chart'))
  const profile = EmberwickProfiles.createVolumeProfile(chart, { data })
</script>

The UMD core build exposes the global Emberwick, the drawings build EmberwickDrawings and the profiles build EmberwickProfiles. Each opt-in file reads window.Emberwick rather than carrying a second copy of the core, so loading one first throws a message that says to load emberwick.umd.js before it (the profiles file also refuses a core older than 0.13, which has no layer under the candles for it to draw on); folding them into the core file would make every CDN user pay for features they never enable.

All three UMD builds are for <script> tags and CDNs only — there is deliberately no emberwick/umd import specifier, because a UMD file loaded as an ES module exports nothing and quietly assigns a global instead.

Emberwick is ESM-only. import works everywhere; require('emberwick') does not, and Node reports that as No "exports" main defined. Use a dynamic await import('emberwick') from CommonJS, or the UMD build above.

By vendoring the source

Nothing stops you copying the core in directly:

cp -r src/chart /path/to/your-project/src/chart
cp -r src/drawings /path/to/your-project/src/drawings   # only if you want drawing tools
cp -r src/profiles /path/to/your-project/src/profiles   # only if you want volume profiles
import { createChart, RandomFeed } from './chart/index.js'
import { enableDrawings } from './drawings/index.js'
import { createVolumeProfile } from './profiles/index.js'

Copy them as siblings: src/drawings/ and src/profiles/ each reach the core only through ../chart/index.js, and neither imports the other. Vendor them from one release; a mismatch logs one warning at createDrawings or createVolumeProfile. The core folder has no framework dependency and never imports either. It has one import that points outside it, though: render/watermark.js reads the logo paths from src/brand.js, so copy that file to src/brand.js beside the folder (or point the import at your own). Earlier versions of this section claimed the folder was fully self-contained; it was not. Anything that can bundle ES modules (Vite, webpack, Rollup, esbuild, or a browser with native ESM) can consume it as-is.

Entry points

| Import | Contents | |---|---| | emberwick | The core: createChart, Chart, feeds, themes, motion primitives | | emberwick/react | <EmberwickChart /> React component | | emberwick/webcomponent | Registers <emberwick-chart> (side-effecting import) | | emberwick/drawings | Drawing tools: enableDrawings, createDrawings, the nine tools, normalizeDrawings. 52.4 KB gzipped as the minified UMD, 71.3 KB as unminified ESM, and only when you import it | | emberwick/profiles | Volume profiles: createVolumeProfile, computeValueArea. 6.2 KB gzipped as the minified UMD, 9.7 KB as unminified ESM, and only when you import it |

TypeScript declarations ship for all five entries. The UMD build is not an import specifier — it is a file you point a <script> tag or a CDN at.


Quick start

import { createChart, RandomFeed } from 'emberwick'

const chart = createChart(document.getElementById('chart'))
await chart.setFeed(new RandomFeed({ timeframe: 60_000 }))

The container needs a real size — the chart fills it and follows resizes:

<div id="chart" style="width: 100%; height: 480px"></div>

The container's position is set to relative automatically if it is static, because the canvas layers are absolutely positioned inside it.

Without a feed, push bars in directly:

const chart = createChart(el)
chart.setData(bars)          // Bar[], ascending by time
chart.update(formingBar)     // merge a tick into the last candle (animates)
chart.append(newBar)         // open a new candle

The bar shape

One contract, used everywhere:

{
  time: 1737024000000,  // ms epoch, start of the bar
  open: 100.2,
  high: 101.4,
  low:  99.8,
  close: 100.9,
  volume: 1420,
}

Prices may be numbers or numeric strings. Plenty of real sources send strings — Laravel serialises decimal columns that way, as do several exchange REST APIs — so they are coerced rather than rejected. Anything else (null, undefined, booleans, objects, 'abc') is not a price: it is skipped, and a chart with nothing plottable draws no price axis rather than inventing one.

Bars must be ascending by time and de-duplicated. A bar older than the newest one is dropped rather than applied — an out-of-order tick would otherwise overwrite the newest candle and leave a duplicate timestamp behind.

The chart infers the timeframe from the median gap between consecutive bars, sampled across the dataset (or takes it from feed.timeframe). The median rather than the first pair, because any exchange with a trading session puts a large gap at each day boundary — on NSE minute data, bars[1] - bars[0] across an overnight break reads as 17.75 hours.


Plugging in your own data — the DataFeed interface

This is the seam the whole library is built around. The core never knows where bars come from: implement two methods and it works.

import { DataFeed } from 'emberwick'

class MyApiFeed extends DataFeed {
  constructor() {
    super({ symbol: 'AAPL', timeframe: 60_000 })
  }

  // Historical bars ENDING at `to` (exclusive). Return [] when exhausted.
  async getBars({ symbol, timeframe, to, limit }) {
    const qs = new URLSearchParams({ symbol, tf: timeframe, limit })
    if (to) qs.set('to', to)
    const res = await fetch(`/api/candles?${qs}`)
    return res.json()   // Bar[], ascending by time
  }

  // Live updates. Return an unsubscribe function.
  subscribe(handler) {
    const ws = new WebSocket('wss://example.com/stream')
    ws.onmessage = (e) => {
      const { bar, closed } = JSON.parse(e.data)
      handler({ type: closed ? 'append' : 'update', bar })
    }
    return () => ws.close()
  }
}

await chart.setFeed(new MyApiFeed())

Feed contract

| Member | Required | Purpose | |---|---|---| | symbol | yes | Passed back to you in getBars | | timeframe | yes | Bar duration in ms; sets the chart's time axis | | getBars({ symbol, timeframe, to, limit }) | yes | Promise<Bar[]>. to: null means "most recent". Return [] to signal no more history | | subscribe(handler) | for live data | Returns an unsubscribe function | | prime(lastBar) | optional | Called once after history loads, so the feed can seed its forming candle | | now() | optional | The current time in ms on the bars' clock, for the candle-close countdown. See Candle countdown | | destroy() | optional | Yours to call. The chart never does: detachFeed() and chart.destroy() only run the unsubscribe that subscribe() returned |

Update messages

handler({ type: 'update', bar })  // forming candle changed → animates
handler({ type: 'append', bar })  // a new candle opened

update is the one that produces the flowing motion. Send it as often as your feed ticks — 500 messages between two frames still cost exactly one repaint, because rendering is decoupled from data arrival.

Lazy history

When the user pans to within 80 bars of the left edge, the chart calls getBars({ to: oldestLoadedTime, limit: 1000 }) and prepends the result, holding the view on the same bars. Return an empty array and it stops asking permanently. The page size is fixed at 1000; initialBars sizes only the first load.

A rejected page is retried on the next pan and only stops being requested after three consecutive failures — subscribe to 'error' to see them.


API

createChart(container, options?)

Returns a Chart. (new Chart(container, options) is equivalent.)

const chart = createChart(el, {
  theme: { background: '#000', up: '#00d68f' },
  volumeRatio: 0.18,     // fraction of height for the volume strip
  magnet: true,          // crosshair snaps to nearest OHLC
  animate: true,         // live-candle easing
  countdown: true,       // time to candle close, in the last-price tag
  clock: () => Date.now(),   // now, on the bars' clock (see Candle countdown)
  touchCrosshair: true,  // tap to place, long-press to scrub (see Touch)
  touchCrosshairDelay: 350,  // ms a finger rests before a drag scrubs
  initialBars: 1500,     // first getBars() page size
  timeScale: { spacing: 9, minSpacing: 0.8, maxSpacing: 160, rightOffset: 12 },
  priceScale: { mode: 'linear', tau: 120, marginTop: 0.12, marginBottom: 0.12 },

  // Annotations can be supplied up front instead of via the setters.
  markers: [{ time: 1717070400000, shape: 'arrowUp', text: 'BUY' }],
  priceLines: [{ price: 148.2, title: 'target' }],
  zones: [{ from: 143.5, to: 145.9, label: 'value area' }],
})

Methods

| Method | Description | |---|---| | setData(bars) | Replace all bars and snap to the right edge | | update(bar) | Merge a tick into the forming candle (animated) | | append(bar) | Open a new candle | | setFeed(feed) | async — loads history, then subscribes. Detaches any previous feed | | detachFeed() | Unsubscribe, keep the bars on screen | | subscribe(event, fn) | Returns an unsubscribe fn. See events below | | setMarkers(markers) | Replace every marker | | getMarkers() | Current markers, normalised, each with its resolved bar index. See below | | addMarker(marker) | Append one marker | | removeMarker(id) | Remove by id | | clearMarkers() | Remove all markers | | setPriceLines(lines) | Replace every horizontal price line | | setZones(zones) | Replace every shaded region | | markerAt(x, y) | Hit-test plot coordinates, returns a marker or null | | setTheme(partial) | Merge theme keys and repaint | | setPriceMode(mode) | 'linear' or 'log' | | setAnimate(bool) | Toggle live-candle easing | | setMagnet(bool) | Toggle crosshair OHLC snapping | | setCountdown(bool) | Toggle the candle-close countdown in the last-price tag | | visibleRange() | The window currently on screen — the same payload the 'visibleRange' event carries | | snapToRealtime() | Jump back to the newest bar and re-enable autoscale | | startReplay(options?) | Begin bar-by-bar playback. Returns the Replay, or null if there is nothing to replay | | stopReplay() | Leave replay and reveal the whole dataset again | | replayState() | Current playback state. { active: false, ... } when not replaying | | chart.replay | Getter — the active Replay controller, or null | | addPlugin(plugin) / removePlugin(plugin) | Experimental. Attach an opt-in layer that gets its own canvas and the first offer of every gesture. See Plugins | | toImage() | PNG data URL of the composited layers | | fitContent() | Zoom and scroll so the whole dataset is on screen. Not animated | | setVisibleRange({ from, to }) | Open on a window of it instead — inclusive bar indices. Not animated | | setTimeZone(zone) | Format every rendered timestamp in an IANA zone, or null for local | | hideCrosshair() | Dismiss a crosshair a tap or long-press left standing. No-op on mouse | | resize() | Re-measure the container now. Resizes and pixel-ratio changes are automatic | | resume() | Restart the render loop after it gave up. See below | | destroy() | Remove listeners, stop the loop, drop canvases | | chart.fps | Getter — measured frames per second |

Events

const off = chart.subscribe('crosshair', (payload) => {
  if (!payload) return           // pointer is on no pane
  const { index, bar, price, pane } = payload   // `price` is in THAT pane's scale
  legend.textContent = `O ${bar.open} H ${bar.high} L ${bar.low} C ${bar.close}`
})
off()  // unsubscribe

| Event | Payload | |---|---| | 'crosshair' | { index, bar, price, pane }, or null when the pointer is on no pane — off the plot, on the time axis, or in the seam between two | | 'markerHover' | The marker under the pointer, or null when none is | | 'markerClick' | The clicked marker. Only fires on a hit, never with null | | 'visibleRange' | { from, to, fromTime, toTime, barCount, spacing, settled } | | 'replay' | { active, playing, index, length, progress, speed, time, bar, atEnd } | | 'error' | The thrown value from a failed feed read. See below |

A drag that happens to end on top of a marker does not fire 'markerClick' — panning and clicking stay distinct.

Feed errors

getBars() is your code, so it can reject — a 502, an expired token, an aborted request. Both paths that call it (setFeed() and the lazy history paging) route a rejection to 'error' rather than letting it escape as an unhandled rejection:

chart.subscribe('error', (err) => {
  toast('Could not load market data')
  console.error(err)
})

With no 'error' subscriber the failure is logged to the console instead of vanishing. setFeed() itself never rejects, so await chart.setFeed(feed) is safe to leave unguarded; subscribe to 'error' to react to the failure.

A failed history page retries on the next pan, and stops being requested only after three consecutive failures — one transient 500 does not permanently disable lazy history.

Frame errors

'error' also fires if the render loop gives up. A single throwing frame is recovered automatically: the pending layers are put back and the frame is retried. Ten consecutive failures — a lost canvas context, say — stop the loop instead of spinning at 60fps, and that is reported here:

chart.subscribe('error', (err) => {
  console.error(err)
  // once the cause is dealt with:
  chart.resume()
})

resume() returns false if the chart is destroyed or the loop is already running, so it is safe to call blindly. Nothing else revives a stopped loop — not even invalidate() — because a chart on a live feed would otherwise re-enter the failure on every tick.

Tracking the visible range

'visibleRange' is a state event rather than a notification, which makes it usable without any debouncing of your own:

  • A new subscriber is called immediately with the current window, so it never has to wait for the user to pan before it knows what is on screen.
  • It then fires only when the window actually changes. Indices are integers, so a slow pan at 9px/bar produces roughly one event every nine frames, not one per frame.
  • spacing is reported but is deliberately not part of the change test — it is a float that moves every frame of an eased zoom, and keying on it would turn this into a 60/sec firehose. Use it for level-of-detail decisions.
  • settled is part of the change test, so the final event of a gesture always arrives with settled: true. That makes "wait until the view stops moving, then do the expensive thing" a safe pattern.
const off = chart.subscribe('visibleRange', async (r) => {
  // paginate backwards when the user approaches the left edge
  if (r.from < 50 && r.settled && r.fromTime) {
    const older = await myApi.bars({ to: r.fromTime, limit: 500 })
    chart.setData(older.concat(chart.bars))
  }
})

Syncing a second chart is the other common use — feed fromTime/toTime straight into the other instance. And chart.visibleRange() returns the same payload on demand if you would rather poll than subscribe.

The chart also paginates backwards on its own through feed.getBars({ to }) whenever you have attached a feed. This event is for when you want to drive that yourself, or to drive something other than data.


Candle countdown

The last-price tag on the price axis counts down to the forming candle's close, the way TradingView does: one box centred on the price line, the price in its upper half and the time left in its lower half.

┌─────────┐
│ 184.25  │  ← last close, coloured up/down as the line is
│  04:37  │  ← closes in 4 minutes 37 seconds
└─────────┘

The shape follows the chart timeframe, so the tag keeps one width while it counts: MM:SS under an hour, H:MM:SS under a day, Dd HH:MM:SS from a day up. Seconds round up, so a bar reads 00:01 until the moment it closes. On by default; countdown: false or setCountdown(false) removes the row and the tag is exactly the single-row tag it was.

It is hidden when there is nothing honest to show. The time left is the last bar's open plus the timeframe, minus now:

| now is | The tag shows | |---|---| | inside the last bar | the countdown | | up to one bar past its close | 00:00 — the bar closed and the next has not arrived; a feed is usually a beat late | | more than one bar past its close | nothing: history, a stopped feed, a closed market | | up to one bar before its open | a full bar — a few seconds of clock skew must not blink the tag at every open | | further before | nothing: that is a different clock, not skew |

Whose clock. now is Date.now() unless the bars are not on wall-clock time. Then supply it, in ms on the bars' clock, either per chart or from the feed, which knows its own tape best; options.clock wins over feed.now():

// Bars stamped in exchange-local time, as if it were UTC: shift the clock the
// same way, and the countdown lines up with the open.
createChart(el, { clock: () => Date.now() + 5.5 * 36e5 })

// A feed that replays a tape at 60x.
class TapeFeed extends DataFeed {
  now() { return this.tapeStart + (Date.now() - this.realStart) * 60 }
}

RandomFeed implements now(), so the demo's speed: 60 candles count a whole minute down each second.

Under replay the countdown is the replay's own clock: the fraction of the current bar that has played. At 1× a 5-minute bar counts 05:00 → 00:00 in one second; paused, the bar is whole and reads 05:00.

Cost. A chart showing a countdown repaints once per second while idle, at the next whole second of its clock. A chart without one — hidden, or off — stays at zero CPU, as before.


Replay

Play a fixed dataset back bar by bar — backtesting playback, a market-open recap, a training drill.

chart.setData(bars)

const replay = chart.startReplay({ from: 200, speed: 4 })
replay.play()

chart.subscribe('replay', (s) => {
  scrubber.value = s.index
  clock.textContent = new Date(s.time).toLocaleTimeString()
  if (s.atEnd) playBtn.textContent = 'Restart'
})

The chart is never put into a special mode. The controller keeps the dataset aside and hands the chart only the revealed prefix, so scales, crosshair, annotations and 'visibleRange' behave exactly as they do on live data that happens to end at the cursor.

Revealing the next bar goes through the same path a feed tick takes, so the candle grows in and the axis glides. Scrubbing swaps the prefix and jumps — easing a scrub would read as lag, the same rule the pan gesture follows.

chart.startReplay(options?)

| Option | Default | Notes | |---|---|---| | bars | the chart's current bars | The dataset to replay. Never mutated | | from | midpoint | Starting cursor index | | speed | 1 | Multiplier, clamped to 0.25–500 | | baseInterval | 1000 | Real ms one bar takes at 1× | | loop | false | Restart at the end instead of stopping | | follow | true | Re-anchor the right edge on the cursor when scrubbing |

Returns the Replay, or null when there are fewer than two bars. While a replay is active an attached feed is ignored, so live ticks cannot fight the cursor; stopReplay() restores the full dataset and resumes normal service.

setData() also ends a replay — new data means the dataset being replayed no longer exists. The controller you were handed is detached at that point and its transport methods become no-ops, so check chart.replay rather than holding the old reference. Calling startReplay() twice detaches the first controller the same way.

Transport

Every method returns the controller, so calls chain.

| Method | Description | |---|---| | play() / pause() / toggle() | Pressing play at the end restarts from the beginning | | seek(index) | Move the cursor. Out-of-range values clamp | | step(n = 1) | Relative move; step(-1) goes back a bar | | toStart() / toEnd() | Jump to either end | | setSpeed(x) | Clamped to 0.25–500. Never bursts bars on a rate change | | setLoop(bool) | Toggle looping |

Readable state: index, length, progress (0–1), speed, time, bar, atEnd, playing, interval (real ms between bars at the current speed) and phase (how far into the current bar playback is, 0–1; what the candle-close countdown reads under replay).

The 'replay' event

{ active, playing, index, length, progress, speed, time, bar, atEnd }.

Like 'visibleRange' it is a state event: a new subscriber is called immediately, and after stopReplay() it fires once with active: false so a UI can reset itself without special-casing teardown. chart.replayState() returns the same payload on demand.

Markers after the cursor are hidden, not clamped. Time→index resolution snaps to the nearest bar, so without that filter every future trade would pile onto the newest revealed candle — and a replay that shows you tomorrow's entries is worse than no replay at all.

The cursor never goes below index 1: the scales infer the timeframe from the first pair of bars.


Series

Candles are not the only thing worth drawing on a price axis. A series is an arbitrary y-value over the same time axis — a moving average, a VWAP, an equity curve, a band.

chart.setSeries('ema20', {
  data: [{ time: 1717070400000, value: 148.2 }, ...],
  color: '#c084fc',
})
chart.setSeries('sma50', { data: sma50, color: '#38bdf8', lineWidth: 1 })

Series are keyed, so updating one leaves the rest alone — a panel of twelve indicators does not rebuild eleven of them to toggle the twelfth. Insertion order is draw order, and they paint over the candles but under price lines and markers.

| Method | Description | |---|---| | setSeries(id, options) | Create, or update in place. Omit data to change only presentation | | setSeriesData(id, points) | Replace the points, keep the options | | setSeriesVisible(id, bool) | Hide without discarding. Hidden series do not autoscale | | removeSeries(id) | Drop one | | clearSeries() | Drop all | | getSeries() | Each series' resolved options, in draw order |

| Option | Default | Notes | |---|---|---| | data | — | { time, value }[]. time is ms epoch, resolved to the nearest bar | | pane | 'price' | Which pane draws it. Throws if that pane does not exist | | color | theme.textStrong | | | lineWidth | 1.5 | | | lineStyle | 'solid' | 'solid', 'dashed', 'dotted' | | stepped | false | Draw as a step function instead of interpolating | | visible | true | | | title | — | Passthrough metadata for getSeries(). Never drawn |

Gaps

A point with no value lifts the pen. value absent, null, or anything non-numeric ends the current line; the next valued point starts a fresh one.

chart.setSeries('rsi', { data: [
  { time: t0, value: 55.2 },
  { time: t1 },              // no value here — the line breaks
  { time: t2, value: 61.8 },
]})

This is the one design decision worth stating plainly: a missing value is never drawn as zero and never interpolated across. An indicator with no value during its warm-up period is not an indicator sitting at zero, and a line that quietly connects across a gap is a line that lies about the data.

Scale

Visible series widen the price scale along with the bars, so a value outside the candle range is in view rather than clipped at the edge. Hidden series are excluded from that. Points more than one timeframe outside the loaded bar range are not drawn at all, on the same reasoning as markers.

Series share the price axis with the candles. An equity curve at 200,000 against a price around 100 will technically render, but the candles will be a flat line — a second pane with its own scale is the answer, and it does not exist yet.


Panes

An oscillator cannot share a scale with a price. RSI lives on 0–100 and MACD around zero; put either on a chart of an instrument trading at 24,000 and the candles collapse into a flat line.

A pane is a horizontal band with its own price scale, sharing the time axis with every other pane.

chart.addPane('rsi', { weight: 1 })
chart.setSeries('rsi14', { data: points, pane: 'rsi', color: '#c084fc' })

| Method | Description | |---|---| | addPane(id, options) | Add a band below the existing ones. Returns its PaneInfo | | removePane(id) | Remove it, and every series routed to it | | panes() | Every pane, top to bottom. panes()[0] is always the price pane | | pane(id) | One pane, or null | | paneScale(id) | That pane's live PriceScale | | paneRect(id) | A copy of its rect, in CSS px |

| Option | Default | Notes | |---|---|---| | weight | 1 | Flex share of the plot height. The price pane is 3 | | minHeight | 40 | Floor in CSS px | | priceScale | chart's | This pane's scale options | | title | — | Passthrough metadata. Not drawn |

'price' is the pane candles, volume, markers, price lines and zones always draw on. It cannot be removed, and it is always the top band.

An unknown pane id throws. Defaulting to the price pane would put an RSI at 50 through a 24,000 scale and flatten the candles — exactly the failure panes exist to prevent, arriving with no error at all.

A pane whose series are all hidden, or which has none, draws no axis rather than inventing one — the same rule the price pane follows with no bars.

Migrating from chart.ps and chart.plot

Both still work and still address the price pane. paneScale('price') and paneRect('price') are the replacements, and they say which pane they mean:

const y = chart.paneScale('rsi').y(70)     // where 70 sits in the RSI pane
const band = chart.paneRect('rsi')         // { x, y, w, h }

One difference worth knowing: chart.plot is a live rect mutated in place, so a reference held across a resize stays correct. paneRect() returns a copy.


Annotations

Three independent collections, each replaced wholesale. Zones paint behind the candles; price lines and markers paint in front.

chart.setMarkers([
  { time: 1717070400000, shape: 'arrowUp',   text: 'BUY 120',
    data: { orderId: 'A-7741' } },
  { time: 1717074000000, shape: 'flag',      text: 'Earnings', color: '#c084fc' },
  { time: 1717077600000, shape: 'arrowDown', text: 'SELL 160' },
])

chart.setPriceLines([
  { price: 148.20, title: 'target', color: '#26a69a' },
  { price: 141.05, title: 'stop', lineStyle: 'dotted' },
])

chart.setZones([
  { from: 143.5, to: 145.9, label: 'value area' },
])

chart.subscribe('markerClick', (m) => openTicket(m.data.orderId))

Markers

A marker is pinned to a timestamp, not a bar index, and resolves to the nearest bar. Load an older page of history and every marker re-resolves, so nothing drifts off its candle.

Supply your own id if you have one. A generated id is derived from the marker's own identity rather than its array position, so passing the same markers to setMarkers() twice yields the same ids and an id captured for removeMarker() stays valid.

A marker more than one timeframe outside the loaded range is hidden, not clamped to the end bar. A trade from six months before the loaded window is not an event that happened at the left edge of the chart, and drawing it there is worse than not drawing it at all. Page that history in and it appears.

| Field | Default | Notes | |---|---|---| | time | required | ms since epoch, snapped to the closest bar | | id | derived | Stable across calls — see below | | id | generated | Needed for removeMarker(id) | | shape | 'circle' | See the list below | | position | shape-dependent | 'aboveBar', 'belowBar', 'inBar', 'atPrice' | | price | — | Only used when position is 'atPrice' | | color | theme.up / theme.down | Down-pointing shapes default to the down colour | | text | — | Short caption. For 'label' it is drawn inside the pill | | textColor | theme | Caption colour | | size | 1 | Scale factor | | data | — | Anything. Handed straight back on hover and click |

Shapes: arrowUp, arrowDown, triangleUp, triangleDown, circle, square, diamond, flag, label.

Position defaults follow the trading convention: up-pointing shapes sit below the bar, down-pointing shapes above it, everything else above.

When index is resolved. getMarkers() hands back the normalised markers, but time→index resolution happens in the render frame, not in setMarkers(). Call them back to back and every index is still -1; it is populated after the next frame. -1 also means deliberately hidden — a marker more than one timeframe outside the loaded range resolves to -1 rather than clamping onto an end bar — so treat it as "not drawn", not as "not yet known".

Ids. Supply your own, or let one be derived from the marker's identity (time, shape, and which duplicate it is). Derived ids are stable across setMarkers() calls, page loads and two charts showing the same data, so an id captured for removeMarker() stays valid.

Overlap and density. Markers sharing a bar are stacked rather than drawn on top of each other. Once a candle body is 3px wide or less — around 5.6px per bar, since a body is 72% of the slot — dense runs thin to roughly one marker per 4px — a thousand trades stay legible and stay fast. A marker on the forming candle anchors to the animated values, so it flows with the live bar.

Price lines

| Field | Default | Notes | |---|---|---| | price | required | | | color | theme.textStrong | | | lineStyle | 'dashed' | 'solid', 'dashed', 'dotted' | | lineWidth | 1 | | | lineVisible | true | false keeps the title pill and axis tag but draws no rule | | title | — | Pill drawn at the left end | | axisLabel | true | Price tag on the axis |

Zones

Supply from/to for a price band spanning the full width, or fromTime/toTime for a time band spanning the full height.

| Field | Default | Notes | |---|---|---| | from / to | — | Price band bounds. Non-finite values are skipped silently | | fromTime / toTime | — | Time band bounds, ms epoch, resolved to the nearest bar | | color | theme-derived | Fill | | border | — | Stroke around the band; omitted when unset | | label | — | Drawn at the top-left corner of the band |

chart.setZones([
  { from: 143.5, to: 145.9, label: 'value area' },
  { fromTime: 1717070400000, toTime: 1717074000000,
    color: 'rgba(239,83,80,0.07)', border: 'rgba(239,83,80,0.3)' },
])

Autoscale fits the bars, not the annotations — a price line far outside the data range is simply off-screen. Derive extreme values from the visible range if you need them guaranteed visible.


Drawing tools

Trendlines, levels, Fibonacci, channels, ranges, measures, positions and text, drawn on the chart by the reader and stored as plain data by you. They are a separate entry, emberwick/drawings, so a chart that never imports it carries none of the code (see Size and opt-in).

Enabling

import { createChart } from 'emberwick'
import { enableDrawings } from 'emberwick/drawings'

const chart = createChart(el)
const drawings = enableDrawings(chart)

drawings.setTool('trendLine')          // the reader clicks or drags on the chart
drawings.subscribe('change', () => save(drawings.getDrawings()))

enableDrawings(chart, options?) gives you the nine standard tools. createDrawings(chart, { tools: [trendLine, fibRetracement] }) gives you exactly the tools you pass, and takes no default on purpose: a default parameter would keep all nine alive in every bundle that imports the function, and the tools tree-shake only if nothing names them.

A second enableDrawings on the same chart throws until the first controller is destroy()ed. That is deliberate: React StrictMode runs an effect, its cleanup and the effect again, and the second run must get a fresh controller, never the destroyed one.

Options, all optional:

enableDrawings(chart, {
  drawings: saved,          // initial load; not undoable, see "Saving and loading"
  magnet: 'inherit',        // 'inherit' | 'off' | 'weak' | 'strong'
  stickyTools: false,       // keep the tool armed after each drawing
  quickMeasure: true,       // Shift+click-click measures with no tool armed
  keyboard: true,           // Delete, arrows, Ctrl/Cmd+Z ... while the chart has focus
  textEditor: true,         // false hands text editing to you through 'edit'
  historyLimit: 100,        // undo depth
  motion: 'auto',           // 'auto' | 'full' | 'reduced' | 'none'
  readOnly: false,          // render, hover, select and events, but no edits
  defaults: { trendLine: { style: { lineWidth: 2 } } },  // per-type starting style/options
  theme: { accent: '#ff8a00' },                          // see "Styling"
  maxDrawings: 5000,        // rows past it are rejected with reason 'limit'
})

platform, prefersReducedMotion and idFactory are also injectable, for tests and for hosts that mint their own ids.

Tools and presets

Nine stored types, fourteen presets. A preset is a type plus the options it starts with, and variants share a type on purpose: a ray is a trend line whose extend is 'right', so the reader can turn it back into a segment after drawing it, and you store one kind of row for both.

| Preset (setTool) | Stored type | What it draws | |---|---|---| | trendLine, ray, extendedLine, arrow | trendLine | Two points. Optional extension to either side, arrow caps, and a live +12.40 (+1.52%) · 38 bars · 6h 20m readout | | horizontalLine, horizontalRay | horizontalLine | One price. A tag on the price axis, and draggable by that tag | | verticalLine | verticalLine | One moment, across every pane (or its own pane only), with a time tag | | rectangle | rectangle | Two corners. Fill, optional middle line, optional stats | | parallelChannel | parallelChannel | A baseline, then a click for the parallel. The second click has an auto-fit magnet to the highest high or lowest low between the ends | | fibRetracement | fibRetracement | Standard levels 0 to 1 visible, 1.272, 1.618 and 2.618 hidden; each level's colour and visibility is stored. Linear or log interpolation | | measure | measure | Price change, percent, bars, duration and volume across a box | | long, short | position | Entry, target and stop. One click makes a bracket sized from the 14-bar ATR (1.5 ATR stop, 2R target); a press-drag sets the target and mirrors the stop. Reads R:R, and once the bars after the entry reach the target or the stop, says which happened | | text | text | A note, edited in place |

TOOL_PRESETS is exported with each preset's label and group, so a toolbar can be built from it rather than kept in step with it by hand. (The demo's rail is.)

A few behaviours worth knowing before they surprise you:

  • The position tool is honest about a bad stop. A stop on the wrong side of the entry is allowed, because clamping or flipping user data hides the mistake. Both zones turn magenta (theme.warn), the label reads R:R —, and a one-shot pulse plays as it crosses over. Nothing pulses again afterwards.
  • A stop and a target in one bar count as the stop, and the label says (same bar). Bars alone cannot say which came first, and the pessimistic reading is the one to build a review on.
  • Everything that reads OHLC or volume reads revealed bars only, so a position replayed through history flips to "Target hit" on the step that reveals it, not before.

Drawing with a mouse

| Input | Action | |---|---| | Pick a tool, click and drag | Draws it (release to finish) | | Pick a tool, click, move, click | Also draws it: a quick click-click places both ends | | Click a drawing | Selects it. Handles appear | | Drag a handle | Reshapes; the opposite corner or edge of a rectangle stays put and flips cleanly when passed | | Drag a selected drawing's body | Moves it | | Alt + drag a body | Drags a copy | | Double-click a text | Edits it; on any other drawing, fires 'edit' | | Shift + drag, no tool armed | Quick measure: an ephemeral measure that is never in the document, the history or the events | | Right-click a drawing | Selects it and fires 'contextmenu' (the browser's menu opens only while nobody is subscribed, and on empty chart) | | Shift while placing | Constrains to 0°, 45° or 90° on screen | | Alt while placing | Free: no snap at all | | Ctrl (Cmd on macOS) while placing | Inverts the magnet for that gesture |

On a mouse, pressing an unlocked drawing selects it and claims the press, so a drawing can be picked up and dragged in one motion. A locked drawing is never claimed, so the chart pans through it; a click still selects it, so it can be unlocked. Cursors and handle sizes follow the pointer type.

Drawing on a phone

A finger cannot hover and a phone plot is mostly fill once a box is selected, so the touch rules differ on purpose:

| Gesture | Action | |---|---| | Tap a drawing | Selects it. It does not move, and does not place the crosshair | | Drag a selected drawing by a handle, its border, its label or its move handle | Reshapes or moves it | | Swipe inside a selected box's fill | Pans, exactly as before. A fill never claims a one-finger press | | Tool armed, one finger | Draws. One-finger pan is off while a tool is armed | | Tool armed, two fingers | Pans and pinch-zooms, and cancels the in-flight press without leaving a mark | | Tap empty chart | Deselects |

Area tools and trend lines get a move handle at their centre, drawn as a four-way glyph, because on touch the body of a fill is not a place you can drag. A handle's hit target is 44 pt, and the point being placed sits under the finger without jumping. Tap-to-select-then-drag, rather than press-and-drag straight away, is what keeps a chart with a few boxes on it scrollable with one finger.

From the keyboard

Keys reach drawings only while the chart has focus, which setTool and select give it (pass { focus: false } if you select from a list with its own keyboard handling). keyboard: false turns all of this off.

| Key | Action | |---|---| | Esc | One layer at a time: revert a drag, then cancel a creation, then close the editor, then close a quick measure, then deselect, then disarm | | Delete / Backspace | Remove the selection; the last placed point while placing a channel. A locked drawing shakes instead | | Ctrl/Cmd + Z | Undo (during a gesture it only cancels the gesture) | | Ctrl/Cmd + Shift + Z, Ctrl + Y | Redo | | Ctrl/Cmd + D | Duplicate, five bars to the right | | ← → | Nudge one bar (Shift: ten). Without a selection they pan, as always | | ↑ ↓ | Nudge one pixel of price (Shift: ten) | | Enter on a text | Edit |

Consecutive nudges of one drawing are one undo step, coalesced without a clock, so it does not depend on how fast anybody types.

Snapping and magnet

While placing or dragging a point, the first of these that applies wins:

  1. Alt: free, no snap.
  2. Shift: a screen angle of 0°, 45° or 90° from the fixed end. At 0° the price is copied bit for bit, so a horizontal trend line is exactly horizontal.
  3. Another drawing's anchor: an exact copy of that point, offsets included.
  4. Levels: horizontal lines, fib levels, position entry/target/stop, your priceLines, and the channel's auto-fit. An alignment guide is drawn when another anchor's price is within 4 px.
  5. The OHLC magnet: a candle's open, high, low or close, ordered by what the tool prefers (a fib wants highs and lows).
  6. The bar slot.

magnet is 'inherit' by default: it follows chart.options.magnet, where true means weak, so the crosshair and the drawings agree with one setting and one reach. 'weak' snaps within 22 px (28 on touch) and 'strong' always takes the nearest. The platform modifier (Cmd on macOS and iOS, Ctrl elsewhere; not Ctrl on macOS, where Ctrl+click is a secondary click) inverts the magnet for the current gesture. On a sub-pane the magnet reads that pane's visible series values instead of candles.

Once a snap has engaged it releases only past 1.5 times its reach, so the point does not flicker at the boundary. When the kind of snap changes the point glides and a ring pops; a change of bar slot never glides (see How the motion works).

Saving and loading

const rows = drawings.getDrawings()       // z-ascending deep clones, plain JSON
const report = drawings.setDrawings(rows) // a load: see below

A drawing looks like this. getDrawings() returns the keys in this order, and add() needs only type and points:

{
  v: 1,                          // schema version, PER DRAWING
  id: 'dkq1z0xm04ab',
  type: 'trendLine',
  pane: 'price',
  points: [
    { time: 1717070400000, price: 148.2 },
    { time: 1717077600000, price: 151.05 },
  ],
  style: { color: null, lineWidth: 1.5, lineStyle: 'solid', fill: null,
           fillOpacity: 0.12, textColor: null, fontSize: 12 },
  options: { extend: 'right', startCap: 'none', endCap: 'none', stats: 'active', text: '' },
  locked: false,
  visible: true,
  z: 4,                          // sparse; bringToFront is max + 1
  meta: { ... },                 // yours: JSON-cloned, never read
}
  • time is in your bars' unit and epoch, and is never converted. If your bars are milliseconds, so is this. If they are IST read as if it were UTC (see Time zones), so is this. Store what you were given.
  • color: null follows the theme, so a light/dark switch recolours the drawing. A stored colour is used as is.
  • options is the full normalised set, not a sparse diff, so changing a default later never restyles what was already saved.
  • One row per drawing, one v per row. If you keep a table of drawings, each row carries its own schema version. Additive fields keep v: 1; only a change that an older build would misread bumps it.
  • Unknown keys are carried. Keys this build does not know, at the top level and inside style and options, are kept and written back by getDrawings(), and survive an update(). So a build that nudges a drawing saved by a newer one writes back everything the newer one wrote.
  • z is sparse and explicit, so reordering rewrites one row rather than renumbering the table.

setDrawings accepts an array, { drawings: [...] }, a JSON string, or null/undefined (an empty document, not an error). A string that does not parse throws and applies nothing.

setDrawings is a load, not an edit. It clears the undo history and the selection, aborts any gesture, and fires no change event. That is the point of it: if it did, a save-on-change host would write the half-loaded document straight back over the stored one. It does emit the select and history state events if those changed, and it returns a LoadReport, also kept as drawings.lastLoadReport:

{
  loaded: 12,                     // drawings of a known type accepted
  carried: ['d7'],                // ids of rows this build cannot read (below)
  rejected: [{ index: 3, id: 'd9', reason: 'too few points' }],
  renamed: [{ index: 5, from: 'd2', to: 'd2:2' }],   // duplicate ids get a suffix
  truncated: [],                  // extra points or oversized unknown keys dropped
  orphaned: [],                   // drawings on a pane that does not exist (yet)
}

What comes back always accounts for every row it kept: getDrawings().length === report.loaded + report.carried.length. The drawings option loads the same way, and logs one console.warn if anything was rejected.

Inert drawings. A row with an unknown type, or a v newer than this build, is carried verbatim: never drawn, hit, selected or edited, but always returned by getDrawings() in its z slot. remove() and clear() skip it and do not count it. A save-on-change host can therefore never delete a newer build's data by opening its document in an older one.

Orphans. Removing a pane does not delete the drawings on it: they are kept, serialized, not drawn and not hit, and listed by drawings.orphans(). They come back when a pane with that id is added again.

Editing through the API:

| Method | Notes | |---|---| | add(input, { history, select, animate }) | Returns the id. Throws on invalid input, on a duplicate id (carried rows included) and on an unknown pane | | update(id, patch, { history, animate }) | points are replaced; style and options merge per key (a fib's levels are replaced as a whole). Throws on id/type/v in the patch. A patch that changes nothing is a no-op returning false, with no history and no events | | remove(idOrIds) / clear() | clear is one undo step | | duplicate(id, { offsetBars }) | null with no bars or for a carried row | | bringToFront(id) / sendToBack(id) | | | setLocked(id, on) / setVisible(id, on) | Sugar over update | | setHidden(on) | Hide every drawing: a view setting, not a change to the document | | batch(fn) | Atomic. One history entry and one change for everything fn does. If fn throws, the drawings and the selection are restored, nothing is recorded or emitted, and the error is rethrown | | priceAt(id, time) | The price of a trend line, channel baseline or horizontal line at a time, extension included. The building block for alerts | | drawingAt(x, y), screenBox(id) | Hit-test in container pixels, for your own context menu; the last painted box |

Mutating the drawing that is being dragged or created (or anything that could, like undo() or setDrawings()) first ends the gesture and reverts it, and only then applies. Mutating another drawing leaves the gesture alone.

The symbol-switch caveat. The chart has no idea what symbol it shows, so it cannot key drawings by symbol. When your host switches symbol, call drawings.setDrawings(saved[symbol] ?? []) yourself. Until you do, the old symbol's drawings sit on the new symbol's candles, which is the one hazard this API leaves to you.

normalizeDrawings(input, tools?) validates a stored document with no chart, is pure, and is safe to import on a server, so a backend can vet rows before it stores them. Carried rows come back verbatim.

How anchors follow your data

Drawings are stored as { time, price } and resolved to a fractional bar index through the same scales as the candles. No new scale system is involved.

| What happens | What the drawings do | |---|---| | Pan, zoom, inertia, autoscale, following live | Re-projected every frame from the same values as the candles. Nothing lags | | A live tick, an append | Stay put. A held magnet-snapped point is re-derived | | setData, with the same bars or a reshuffled interior | Re-resolved by time, including when an interior session gap moved | | History paged in on the left | Stay under the same candles: every index shifts, and the view shifts with it | | A point after the newest bar | Stored as { time: lastBarTime, price, offset: k, tf }, "k bars after". It keeps its distance in bars across session gaps, and a bar-count point cannot know a session calendar | | A point before the oldest bar | The mirror image, with a negative offset. Load older history and it lands on its real bar | | A timeframe change | In-data points map exactly (or fractionally when unaligned); offsets rescale by tf | | startReplay, seek, step, loop | Resolved against the replay's whole dataset, so nothing moves. Drawings past the cursor stay visible: they are your marks. Anything that reads price or volume sees revealed bars only | | setPriceMode('log') | Anchors unchanged; lines stay straight on screen. A drawing with any price at or below zero is kept but not drawn | | A sub-pane (an RSI band) | Projects through that pane's scale and is clipped to it; the magnet reads that pane's series | | A sub-pane whose series has not loaded | A press there is not claimed, so no 0.43 "price" is ever stored | | Zero bars (for example setFeed swapping symbols) | Nothing is drawn or created; drawings and selection are kept, and nudge and duplicate are unconsumed no-ops | | setTheme, setTimeZone | Colours and labels follow. Times are formatted in your zone; stored times are untouched |

Drawings never take part in autoscale. A drag near an edge would otherwise be a feedback loop between the thing you are moving and the scale you are moving it on.

Undo and redo

Undo is per drawing. Each history entry records only the ids it changed, as frozen before and after states, so undoing one drawing's edit never clobbers another drawing you changed since, or an edit you made with { history: false } to a different one. (An edit made with { history: false } to the same drawing is overwritten by an undo across it, which is the documented cost.)

undo(), redo(), canUndo, canRedo and clearHistory() do what they say. A new commit clears the redo stack; the oldest entry is dropped beyond historyLimit. User gestures and API edits are recorded; selection, tool choice, hover, quick measure, text drafts and setDrawings are not.

Undo and redo select the surviving drawing, glide it back and give it a brief "this changed" halo.

Events

const off = drawings.subscribe('change', ({ source, reason, created, updated, removed }) => {
  // source: 'user' | 'api' | 'history'
  save(drawings.getDrawings())
})

| Event | Kind | Payload | Fires | |---|---|---|---| | 'change' | notification | { source, reason, created, updated: [{ before, after }], removed } | Once per commit: the end of a gesture, an API call, an undo or a redo. Never per drag frame, never on setDrawings, and never for a text draft until its first non-empty commit. Carried rows never appear | | 'drawing' | stream | { phase: 'create' \| 'move' \| 'reshape', drawing, snap, stats } | At most once per frame while a draft changes, and only if subscribed. Live readouts, such as R:R while dragging. A creation draft has id: null | | 'select' | state | { ids, drawings } | On change, and on subscribe | | 'box' | state | { id, box } for the selected drawing | Whenever its painted box moves half a pixel, and on subscribe | | 'tool' | state | { tool, sticky } | On change (including the auto-disarm after a non-sticky create), and on subscribe | | 'history' | state | { canUndo, canRedo, undoSize, redoSize } | On change, and on subscribe | | 'edit' | notification | { id, drawing, box } | A double-click on a non-text drawing, or text creation with textEditor: false | | 'contextmenu' | notification | { id, drawing, part, x, y, event } | A right-click or long-press on a drawing, which is selected first |

change reasons are create, move, reshape, style, options, text, lock, visibility, order, remove, clear, duplicate, nudge, batch, undo and redo.

Two of these exist for a specific job, and it is worth being clear which:

  • change is for saving. It is a notification, it is coarse on purpose, and it is safe to debounce.
  • drawing is for showing. It is a stream at frame rate for a readout, and it is not a substitute for change: a gesture that is cancelled emits drawing and never change.
  • box is for a floating toolbar. It fires from the chart's own frame, so a toolbar that moves inside the callback stays glued to the drawing through a pan, a zoom and an autoscale ease, none of which change visibleRange. Write the position straight to the element rather than through framework state, or the toolbar trails by a frame. The Playground does this.

Every listener runs in a try/catch (a throw is logged as [Emberwick] 'drawings:<event>' listener threw), and all state is committed before the first event fires, so a listener may call back into the controller. Within one operation the order is change, history, select, tool.

Styling

drawings.setDefaults('trendLine', { style: { color: '#c084fc', lineWidth: 2 } })
drawings.update(id, { style: { lineStyle: 'dashed' } })

A drawing's style is color, lineWidth (0.5 to 8), lineStyle ('solid' | 'dashed' | 'dotted', the same names and dashes as price lines and series), fill, fillOpacity, textColor and fontSize (9 to 32). What each tool reads is its own business: a position takes its zones from the theme's up and down colours, so its color does nothing.

The drawing theme is derived from the chart theme and nothing is added to defaultTheme. Override any key with the theme option:

line, accent, handleFill, halo, labelBg, labelText, tagText, up, down, warn, guide, font, textColor and fib (an array of level colours).

Three optional keys on the chart theme itself feed it, so one setTheme can carry them: drawingLine, drawingAccent and drawingFib. The accent is an ember amber, deliberately unlike the up and down colours. The wrong-side position colour (warn) is a magenta, distinct from the accent, up and down on both backgrounds; the R:R — label still says it in words.

Motion

Drawings are held to the same rule as the rest of the chart: motion that is glued to the data.

  • Anchors never move by themselves, and nothing eases the data-to-pixel mapping. Every frame projects a drawing through the same scales as the candles.
  • What does glide is a residual, kept in data space (bars, and price) and settling to zero. A snap changing kind, an undo or redo, a nudge, an API update(..., { animate: true }), the slop catch-up on touch and a cancelled drag all seed one. Because it is in data units, a residual stays glued to the candles even if the view zooms mid-glide, and it stops the loop as soon as it is under 0.1 px.
  • Dragging is crisp. A change of bar slot, or of the magnet's target from one bar to the next, never glides, because a glide would trail the pointer. The house rule that a drag jumps applies here too.
  • Selection handles pop in staggered, hover fades, fib levels stagger in, a new horizontal line grows from where you clicked, a deleted drawing fades out, and a locked drawing shakes when you try to change it.

motion: 'auto' follows chart.options.animate (setAnimate(false) turns it off at runtime) and the browser's prefers-reduced-motion. 'reduced' drops residuals, overshoot, ripples, stagger, reveals, shakes and pulses, and keeps short fades of 120 ms or less. 'none' finishes everything at once and keeps no frames alive. Colours change instantly in every mode.

An armed tool under a still mouse costs zero frames, and so does a selected drawing once its handles have popped in. Only the plugins canvas repaints for a hover or a selection, so the candles are never redrawn for it.

Custom tools

A tool is a plain object, a ToolDef. The nine standard ones are written against exactly this contract and get no private access. It is experimental: this is the part of the drawings API most likely to change before 1.0.

const pin = {
  type: 'pin', label: 'Pin', anchors: 1, creation: 'single',
  snapPrefs: ['close', 'high', 'low', 'open'], prefBoost: 1,
  defaultStyle: { color: null, lineWidth: 1.5, lineStyle: 'solid', fill: null,
                  fillOpacity: 0, textColor: null, fontSize: 12 },
  defaultOptions: { label: '' },
  // a NEW object with known keys only, and idempotent: the keys of what this
  // returns are the "known" options; anything else is carried
  normalizeOptions: (raw) => ({ label: typeof raw?.label === 'string' ? raw.label.slice(0, 40) : '' }),

  // anchors arrive already projected through the pane's scales
  project(d, anchors, ctx, out) {
    const a = anchors[0]
    if (!Number.isFinite(a.x) || !Number.isFinite(a.y)) return false   // nothing visible
    out.x = a.x; out.y = a.y
    out.infinite = false
    out.bbox = { x0: a.x - 6, y0: a.y - 6, x1: a.x + 6, y1: a.y + 6 }
    return true
  },
  // assign absolute values: never read canvas state back
  draw(c, g, d, look, ctx) {
    c.globalAlpha = look.alpha
    c.fillStyle = d.style.color || ctx.theme.line
    c.beginPath(); c.arc(g.x, g.y, 4 + 2 * look.hover, 0, Math.PI * 2); c.fill()
    if (d.options.label) { c.font = ctx.theme.font; c.fillText(d.options.label, g.x + 8, g.y + 4) }
  },
  hit: (g, x, y, tol) => (Math.hypot(x - g.x, y - g.y) <= tol + 4 ? { part: 'body' } : null),
  handles: (g, d, out) => { out[0] = { x: g.x, y: g.y, index: 0, axis: 'xy', cursor: 'grab' }; return 1 },
  dragHandle: (d0, index, snapped) => [snapped.point],
}

const drawings = createDrawings(chart, { tools: [trendLine, pin] })
drawings.add({ type: 'pin', points: [{ time, price }], options: { label: 'entry' } })

project is the single source of geometry for draw, hit, handles and the export, so what is drawn is what is hit. Optional members add complete (expand a click into more points), dragBody, bleed (how far your pixels reach past the anchors, so culling never pops you out mid-pan), angleOrigin, snapTargets, axisTags, stats and priceAt. A custom tool has no preset: arm it with setTool('pin'). A drawing whose tool throws is marked broken and skipped, reported once through the chart's 'error' event, and never blanks the others; the reader can still select and delete it. See src/drawings/index.d.ts for the full contract.

Framework recipes

There is no adapter code: importing drawings from the React or web-component entry would ship them to everyone who uses those. Each recipe enables synchronously and loads afterwards, so an unmount during the load can never destroy a chart and then enable drawings on it, and the LoadReport stays reachable.

React

import { EmberwickChart } from 'emberwick/react'
import { enableDrawings } from 'emberwick/drawings'

useEffect(() => {
  const d = enableDrawings(ref.current.chart)
  let live = true
  api.load(id).then((rows) => { if (live && !d.destroyed) setReport(d.setDrawings(rows)) })
  const off = d.subscribe('change', save)
  return () => { live = false; off(); d.destroy() }
}, [id])

The parent's effect runs after the child adapter's, so chart already exists. StrictMode's second run gets a fresh controller because destroy() released the first.

Web component. Call enableDrawings(el.chart) after the element connects. Disconnecting and reconnecting re-creates the chart, so persist through change and enable again.

Vue 3 (as Traed does):

onMounted(async () => {
  drawings = enableDrawings(chart)                    // synchronous: exists before any await
  drawings.subscribe('change', debounce(() => api.save(id, drawings.getDrawings()), 400))
  const rows = await api.load(id)
  if (!drawings.destroyed) report.value = drawings.setDrawings(rows)
})
onBeforeUnmount(() => { drawings?.destroy(); chart?.destroy() })

The controller carries __v_skip, so Vue never deep-proxies it, and drawings on an RSI pane work as they are.

Size and opt-in

Stated plainly, with numbers measured by npm run size (gzipped):

  • No drawing code is in the core. A chart that never imports emberwick/drawings carries none of it, and CI builds the core with and without the drawings entry and fails unless index.js and its source map come out byte-identical.
  • The plugin seam is generic and cost the core +4.2 KB in the unminified ESM (36.0 KB, from 31.8) and +2.6 KB in the minified UMD (20.3 KB, from 17.7) when it arrived in 0.12. It is larger than the ~2.6 KB first estimated because the ESM keeps the comments, and the gesture handling is where the comments are. With no plugin attached it costs nothing at runtime: three canvases, and one length check per input hook. 0.13 added a layer under the candles and three small helpers to it for another +1.28 KB (37.3 KB) and +0.86 KB (21.2 KB); see Volume profiles.
  • The drawings cost 71.3 KB as unminified ESM and 52.4 KB as the minified UMD, only when you import them. That is a large number next to a 20 KB core, and it is the honest one: nine tools, a snap pipeline, a state machine, an undo history, a motion system and a schema loader are not small. createDrawings with the tools you name lets a bundler drop the ones you do not.

Volume profiles

A volume profile is a horizontal histogram of how much volume traded at each price, usually one per session, drawn against the price axis and marked with the point of control (POC, the busiest price) and the value area (VAH to VAL, the band around the POC that holds 70% of the session's volume). They are a separate entry, emberwick/profiles, so a chart that never imports it carries none of the code.

Emberwick draws the profiles you give it. It never computes one from the chart's bars. The bars on a chart are often 5-minute bars or longer, and for some instruments (an index) they carry no real volume at all; a profile binned from them would look plausible and be wrong, and the library has no way to know. A host computes profiles where the fine data lives, typically on a server from 1-minute bars or ticks, and hands Emberwick the result in a small versioned contract. Data in, pixels out.

Enabling

import { createChart } from 'emberwick'
import { createVolumeProfile } from 'emberwick/profiles'

const chart = createChart(el)
const profile = createVolumeProfile(chart, { data, mode: 'both' })

profile.on('hover', (bin) => showReadout(bin))   // null when the pointer is over no bin
profile.destroy()                                // detach; idempotent

The profile is a plugin on the below layer: it is painted under the candles, over the grid, and clipped to the price pane above the volume strip, so it never covers price action, an oscillator pane or the strip's own bars. It claims no gesture. Pan, zoom, drawings, markers and the crosshair behave exactly as they do without it, and a marker over a profile bin is still hovered and clicked.

The data contract

interface ProfileData {
  version: 1
  source?: string            // shown in the caption: "Vol: <source>"
  step