Source: /guide/getting-started.md # Getting started charton ships as eight packages: | Package | Use it for | | --- | --- | | `@chart-on/core` | The chart engine. Canvases only, no UI chrome. Bring your own controls. | | `@chart-on/ui` | Optional shell: top toolbar, drawing-tool rail and styles around a core chart. | | `@chart-on/indicators` | Optional [built-in indicators](./indicators#built-in-indicators). | | `@chart-on/ta` | Optional [technical-analysis helpers](./technical-analysis) for writing indicators. | | `@chart-on/locales` | Optional [translations](./localization#locale-packs) of the built-in text, one entry per language, applied through the `localization()` plugin. | | `@chart-on/react` | Optional [components and hooks](./component-wrapper) for apps built with a component-based UI framework. | | `@chart-on/vue` | Optional [components and composables](./template-wrapper) for apps built with a template-based component framework. | | `@chart-on/svelte` | Optional [components and an action](./compiled-wrapper) for apps built with a compiler-based component framework. | ## Install Install the engine, and the UI shell if you want it: ```bash pnpm add @chart-on/core # optional UI shell pnpm add @chart-on/ui ``` Use the same version for every `@chart-on/*` package ([versioning](./versioning)). Requires a browser with WebGL2. No bundler? Use the [script-tag build](./frameworks#with-a-script-tag). ## Core only The container needs a size. The chart fills it and follows resizes automatically. ```html
``` Every example in these docs is a module whose `mount(el)` draws into an element and returns a clean-up function; in your app, `el` is that `#chart` element. The chart below runs exactly the code under it. `sampleBars` makes up [sample data](https://github.com/chart-on/chart-on/blob/main/docs/examples/sample-data.ts) in the same shape as yours: Example `first-chart` (runs live on the docs page): ```ts import { createChart } from '@chart-on/core'; import type { ExampleContext } from './types'; import { sampleBars } from './sample-data'; export default function mount(el: HTMLElement, { theme }: ExampleContext) { const chart = createChart(el, { theme }); const candles = chart.addCandles({ title: 'SAMPLE' }); // Bars are { time (unix seconds), open, high, low, close, volume? }. candles.setData(sampleBars()); chart.fitContent(); return () => chart.destroy(); } ``` ## With the UI shell The shell adds a small toolbar (interval, chart style, indicators, price scale, settings and fullscreen) and the drawing-tool rail by default; a bottom bar is opt-in (`bottomBar: true`). `theme` picks the `'dark'`, `'light'` or `'system'` scheme (default `'system'`). See [Shell, toolbar and rail](./ui-shell). Example `shell` (runs live on the docs page): ```ts import { createChartShell } from '@chart-on/ui'; import '@chart-on/ui/style.css'; import type { ExampleContext } from './types'; import { sampleBars } from './sample-data'; export default function mount(el: HTMLElement, { dark }: ExampleContext) { // Toolbar, drawing-tool rail and bottom bar around the chart. const shell = createChartShell(el, { theme: dark ? 'dark' : 'light' }); shell.chart.addCandles({ title: 'SAMPLE' }).setData(sampleBars()); shell.chart.fitContent(); return () => shell.destroy(); } ``` ## Live updates ```ts candles.update({ time: 1767229200, open: 103, high: 106, low: 101, close: 105 }); // same time: replace candles.update({ time: 1767232800, open: 105, high: 107, low: 104, close: 106 }); // newer: append ``` ## Clean up ```ts chart.destroy(); // or shell.destroy() when using the UI shell ``` `destroy()` is safe to call twice. Code that still holds the chart afterwards (a live `update`, a late data response) does no harm: nothing is drawn; view changes (`scrollToRealtime`, `fitContent`, `setVisibleTimeRange`, `resetView`), `applyOptions` and removing series do nothing; `chart.data` is `null`; `captureImage` resolves to `null`. Calls that load data fail loudly instead: `setSymbol` rejects and `compare` throws a `ChartError` with code `'destroyed'`, and a `setSymbol` still loading when the chart is destroyed rejects the same way. ## Requirements and failure modes The chart draws its series with WebGL2, which every current desktop and mobile browser supports. There is no fallback renderer. Supported browsers: Chrome and Edge 111+, Firefox 114+, Safari 16.4+ (macOS and iOS). WebGL2 alone needs less (Safari has it from 15), but the packages are compiled for this set, so older browsers fail on the JavaScript before they reach WebGL. The list is the `browserslist` field of each package's `package.json`, so tools that read it (autoprefixers, transpilers) pick it up. Node.js 22+ is needed only for tooling and tests, for example `@chart-on/core/testing`. Check before creating a chart if you want to show your own message: ```ts import { createChart, isWebGL2Supported, ChartError } from '@chart-on/core'; if (!isWebGL2Supported()) showMessage('This chart needs WebGL2.'); try { createChart(el); } catch (error) { if (error instanceof ChartError) console.warn(error.code); // 'webgl2-unavailable' or 'webgl-shader' } ``` - Without WebGL2, `createChart` throws a `ChartError` with code `'webgl2-unavailable'` and leaves the container untouched. A shader that fails to compile or link (usually a driver bug) throws code `'webgl-shader'`, also leaving the container untouched. `createChartShell` removes its markup and rethrows the same error, so wrap it the same way and render your own fallback. - The browser can take the GPU context away, for example after a driver reset or when a page opens too many contexts. The chart emits `context:lost`. The frame loop keeps running: axes, labels, the crosshair and drawings keep drawing and responding, and only the GPU-drawn series are skipped, so they disappear until the browser restores the context. On `context:restored` the chart re-creates its GPU buffers and textures from the series data and redraws. Nothing you set is lost. Both events carry `reason`: `'browser'` here, `'budget'` when the chart gave the context up itself (next point). - Browsers cap live WebGL contexts per page (commonly 16 on desktop and 8 on mobile) and drop the oldest past that, so a page with many charts would blank its first ones. Each chart holds one context, and the charts share a page budget: 12 contexts, or 6 with a coarse pointer. Past it, the chart that has been out of view the longest gives its context up (`context:lost` with `reason: 'budget'`), or the least recently used one when every chart is in view. It takes a context back, evicting another, when it scrolls into view or gets a pointer, wheel or focus event. Set the budget with `setContextBudget(n)`, read it with `getContextBudget()`; lowering it evicts at once. Leave room for other WebGL content on the page, such as maps or 3D views. - If the restored context cannot be set up again, `context:restore-failed` fires with a `ChartError` (code `'webgl-restore-failed'`, the original error in `cause`) and the series stay blank. Destroy the chart and create a new one to recover. - While the context is lost, `captureImage()` resolves to `null`; other methods keep working. ## Using the docs with coding assistants Besides these pages, the docs site publishes plain-text copies that coding assistants read more reliably than HTML: - `/llms.txt`: an index of every guide page, with its URL and a one-line description. - `/llms-full.txt`: every guide page in one Markdown file, in sidebar order. Live examples appear as their source code. - Each page as Markdown at the same path with `.md`, such as `/guide/series.md`. The link sits at the bottom of each page. Point your assistant at the index, or at the full text when it can read a long file. ## Next - [Embedding the chart](./embedding): container size, lifecycle, resizing, several charts, focus and memory. - [Security and strict pages](./security): content security policy, Trusted Types and offline use. - [Core concepts](./concepts): time, panes, the main series. - [Series and data](./series): candles, lines, histograms, heatmaps. - [Events](./events): crosshair, visible range, loading older history. --- Source: /guide/concepts.md # Core concepts A few rules explain almost everything the chart does. ## Time is unix seconds Every `time` in the API (bars, points, drawing anchors, events) is a unix timestamp in **seconds**. Never milliseconds. In development builds the chart warns when times look like milliseconds or are not whole seconds (see [Errors and warnings](./errors)). ## The main series defines the time axis The first candle series you add becomes the **main series**. Its bars define the time axis: one slot per bar, evenly spaced, so gaps such as weekends do not leave empty space. Other series align to those bar times. A point whose time has no matching main bar is not drawn; after the newest bar, the projected bar times count as matches (see [Time after the last bar](./view-and-scale#time-after-the-last-bar)). ```ts const candles = chart.addCandles({ title: 'BTC' }); // main series const ema = chart.addLine({ title: 'EMA 21' }); // aligns to candle times ``` Past the newest bar the time axis keeps going on a projected calendar, so times in the future have slots too (see [Time after the last bar](./view-and-scale#time-after-the-last-bar)). Removing the main series clears the time axis. ## Data is ascending, updates only move forward - `setData` requires ascending times and throws otherwise. `setData(data, { normalize: true })` sorts slightly dirty data instead and keeps the last of equal times. - `update` with the **same** time as the last point replaces it. - `update` with a **newer** time appends. - `update` with an **older** time is dropped, and `data:dropped` reports it. Live bars from a data source follow the same rule. ## Bad values One rule for every way data comes in: - A **time** that is not a finite number throws, on every series, in `setData` (also with `normalize`), `update` and a heatmap's `updateColumn`. - An **invalid candle** throws in `setData` and `update`, with the index and field in the message. Fix the data, or pass `{ normalize: true }` to drop such bars. A candle is invalid when: - its time, open, high, low or close is not a finite number, - it has a `volume` that is not a finite number (leave `volume` out for none), or - its high is below its low. Anything else is drawn as given: an open or close outside low..high, and a negative volume, which volume candles draw at the normal width, like a bar without volume. Data sources and `isValidBar` use the same definition. - A line, histogram or heatmap **value** that is not finite (`NaN`, `Infinity`, `undefined`) is a **gap**: nothing is drawn there, a line with `breakGaps` breaks, and autoscale ignores it. This matches indicators, where `NaN` means no value. An `update` with no value for the last point's time removes that point. - A heatmap `updateColumn` older than the last column is dropped, like an older `update`. - A data source's bars are filtered instead: invalid bars, duplicates and live bars older than the last one are left out. Everything left out is reported once through the `data:dropped` event: ```ts chart.on('data:dropped', ({ reason, bar, series }) => { // reason: 'older' | 'invalid' | 'duplicate' }); ``` ## Panes stack vertically The chart starts with one pane (the main pane). `addPane()` adds another below it. Each pane has its own price scale. Series go into the main pane unless you pass a pane. See [Panes](./panes). ## Listeners return unsubscribe functions ```ts const off = chart.on('crosshair:move', (e) => console.log(e)); off(); // stop listening ``` There is no separate `off` method. ## No hidden state The chart never reads or writes storage on its own. To persist drawings, serialize them and save them yourself. See [Drawing tools](./drawings#save-and-restore). --- Source: /guide/embedding.md # Embedding the chart How the core chart fits into a page without a framework. The [UI shell](./ui-shell) and framework wrappers build on the same rules; [Frameworks and plain pages](./frameworks) covers the script-tag build, server rendering and the contract wrappers follow. ## The container `createChart(el)` adds one element, `chart.root`, inside `el`. It fills `el` (`width: 100%; height: 100%`) and holds the canvases and overlays. `el` must have a size of its own: ```html ``` - A percentage height needs every ancestor up to a sized one to have a height too (`html, body { height: 100% }` and so on), or it resolves to 0. - A flex or grid child needs `min-width: 0` and `min-height: 0`, or the canvases keep it from shrinking. - At 0 × 0 (`display: none`, a collapsed panel, a hidden tab) the chart draws nothing and keeps its state. It draws again as soon as the container has a size. The chart leaves `el` itself alone, apart from appending `chart.root`, so you can style and position `el` as you like. `chart.root` carries the class `pc-chart`. Its size, background, `touch-action`, `user-select` and focus outline come from a `:where(.pc-chart)` rule the chart adds to the page (to the shadow root, when the container is in one), not from inline styles. That rule has no specificity, so any rule of yours that targets the root wins, without `!important`: ```css #chart .pc-chart { background: transparent; /* let the page show through before the first frame */ } ``` Only `position: relative` and `overflow: hidden` stay inline, since the canvases are laid out against them. `touch-action` follows the chart's touch options (it lets vertical swipes scroll the page when touch panning is off); override it only if you handle touch yourself. Browsers that cannot add the rule (no constructed stylesheets), and containers in another document than the one the library loaded in (an iframe or a popup window), get the same styles inline. A container that is not yet in the page when the chart is created gets the rule on the page's document, so create the chart after attaching a container that lives in a shadow root. ## Lifecycle ```ts import { createChart, createMockDataSource, dataSource } from '@chart-on/core'; const el = document.getElementById('chart')!; // 1. Create: options can be set now or later. `dataSource()` lets it load through a source. const chart = createChart(el, { timezone: 'UTC', plugins: [dataSource()] }); // 2. Data: push bars yourself (`addCandles().setData(...)`), or attach a data source. chart.setDataSource(createMockDataSource()); await chart.setSymbol('DEMO', '1h'); // 3. Change options at any time; only the fields you pass change. chart.applyOptions({ crosshair: { mode: 'free' } }); // 4. Destroy when the element goes away. chart.destroy(); ``` `destroy()` stops the frame loop, timers, observers and the data source subscription, releases the WebGL context and removes `chart.root`. Call it once, before or after removing `el`; it is safe to call twice. Afterwards the chart draws nothing and calls that load data reject (see [failure modes](./getting-started#requirements-and-failure-modes)). ## Resizing A `ResizeObserver` on `chart.root` resizes the canvases and redraws in the same frame, so the chart follows its container through window resizes, layout changes and panels opening. It also follows changes of the device pixel ratio (moving to another screen, browser zoom). Call `chart.resize()` yourself only for changes the observer cannot see: - a CSS `transform: scale(...)` on an ancestor (the layout size does not change); - printing, if your print styles give the container another size. `resize()` measures and draws at once, so it is cheap to call when unsure. To keep the same time range on screen when the width changes, instead of the same bar spacing, set `lockVisibleRangeOnResize: true` (see [view and scale](./view-and-scale#resizing-and-new-bars)). ## Several charts on one page Each chart is independent: its own canvases, WebGL context, data and options. Create as many as you need and destroy each one. To keep their crosshair, view, symbol or interval in step, put them in a [link group](./link-groups). ```ts import { createChart, createMockDataSource, dataSource } from '@chart-on/core'; const source = createMockDataSource(); const charts = (['1h', '4h', '1d'] as const).map((interval, i) => { const chart = createChart(document.getElementById(`chart-${i}`)!, { plugins: [dataSource()] }); chart.setDataSource(source); void chart.setSymbol('DEMO', interval); return chart; }); // Later: for (const chart of charts) chart.destroy(); ``` - A chart scrolled out of view (seen through an `IntersectionObserver`), or on a page in a background tab (`document.visibilityState`), draws nothing. Its data, indicators and view keep updating and its events (`view:change`, history requests) keep firing; when it is shown again it draws once, before the browser paints, with any animation at its end rather than replayed. So a page with many streaming charts spends drawing time only on the ones in view. `captureImage()` and `resize()` still draw a hidden chart. Set `renderWhenHidden: true` to keep drawing anyway, e.g. for a host that captures the canvases of hidden charts itself. Where `IntersectionObserver` is missing the chart counts as in view. - Browsers limit the number of live WebGL contexts per page (commonly 16 on desktop and 8 on mobile) and take the oldest away past that; the chart then emits `context:lost`. Destroy charts you no longer show rather than hiding them. For more charts than that, use a shared context (below), or static charts (`interactive: false`) for thumbnails. ### One context for many charts `createChart(el, { gl: 'shared' })` draws through one WebGL context shared by every chart created that way, so a dashboard or a multi-chart layout can hold any number of interactive charts: ```ts import { createChart, sharedContext } from '@chart-on/core'; const charts = cells.map((cell) => createChart(cell, { gl: 'shared', plugins: [sharedContext()] })); ``` - The shared context comes with the [opt-in](./features#opt-in-features) `sharedContext()` plugin, which `builtins()` includes and `createChartGrid` adds to each cell. It is read when the chart is created, so pass it in `plugins` then. Without it a `gl: 'shared'` chart gets its own context (`chart.options.gl` reads `'own'`), and development builds warn once ([`shared-context-missing`](./errors#shared-context-missing)). - Each chart draws its series into a hidden shared canvas, then copies its part into its own canvas in the same frame. The copy costs little: eight charts panning together took about 1.1 ms per frame shared against 0.9 ms with their own contexts in our measurements. - The shared context is made by the first shared chart and released with the last one. It counts as one context in the [context budget](./performance), and stays while any shared chart is in view or used. - A lost context is restored for every shared chart: each emits `context:lost` and `context:restored`. Destroying one chart frees only its own GPU objects. - `gpuMemory()` reports the whole shared context, not one chart's part. - `gl` is fixed at creation; the default is `'own'`, one context per chart. - Keyboard shortcuts go to the chart that has focus by default, so charts do not fight over keys (see below). - A data source can be shared; each chart loads and caches its own bars. ## Keyboard and focus `chart.root` is focusable (`tabIndex = 0`), so it takes part in the page's Tab order, and clicking the chart focuses it. With the default `keyboard: { scope: 'focus' }` the chart handles its [shortcuts](./interaction#keyboard) (arrows and `PageUp` / `PageDown` to pan, `+` and `-` to zoom, `Escape`, `Delete`, copy, paste, undo and redo for drawings) only while it has focus. | You want | Set | | --- | --- | | No keyboard shortcuts at all | `features: { keyboard: false }`. The chart stays in the Tab order | | The chart out of the Tab order (a static or decorative chart) | `focusable: false`. A click still focuses it, so its shortcuts still work once clicked | | Shortcuts also while the pointer is over the chart, without a click | `keyboard: { scope: 'hover' }` | | Shortcuts anywhere on the page (one chart per page). Drawing keys still need the chart focused or hovered | `keyboard: { scope: 'global' }` | Keys typed into inputs, text areas, selects and editable elements, or used inside dialogs and other interactive widgets, never reach the chart. ## Pixel ratio The 2D layers (axes, labels, crosshair, drawings) render at the full device pixel ratio. The WebGL layer is capped at `maxGlDpr` (default 2): on a 3× screen it renders 2× and is scaled up, which fills 4 instead of 9 device pixels per css pixel, with no visible change for candles and lines. Lower it to 1 on weak GPUs with large charts; raise it for pixel-exact heatmap cells on very dense screens. It can be changed with `applyOptions` at any time. ## Memory - A data source keeps the bars of the last `cacheSize` symbol and interval combinations (default 8), so switching back is instant. Lower it with `setDataSource(source, { cacheSize: 2 })` for long histories, or drop entries with `chart.data?.clearCache(symbol?, interval?)`. - Each series keeps a copy of the data you pass to `setData`, plus its GPU form. - A heatmap's grid is capped by its `memoryBudget` (default 256 MiB). - `destroy()` releases everything above; a chart that is only hidden keeps it. ## Production checklist - The container has a size before or right after `createChart`, and the chart is destroyed when the container goes away (route changes, closed panels, removed dashboard tiles). - Check `isWebGL2Supported()` first, or catch the `ChartError` from `createChart`, and show your own fallback (see [failure modes](./getting-started#requirements-and-failure-modes)). - Listen for `data:error` and `context:restore-failed` to show a message instead of a blank chart. - Fewer live charts than the browser's WebGL context limit; destroy rather than hide. - `focusable: false` for charts that are not meant to be used with the keyboard, so Tab does not stop on them. - Pass a `timezone` if your users should see exchange time rather than their own. - On a page with a content security policy or Trusted Types, allow the directives in [Security and strict pages](./security); the chart needs no `unsafe-eval`, no `unsafe-inline` and no network access of its own. --- Source: /guide/frameworks.md # Frameworks and plain pages `@chart-on/core` and `@chart-on/ui` are plain TypeScript with no framework dependency. This page covers three things: - [Plain pages](#plain-pages): a bundler, or a single script tag with no build step. - [Server rendering](#server-rendering): what is safe to import and call on a server. - [The wrapper contract](#the-wrapper-contract): the rules every framework wrapper follows, so components behave the same whichever framework you use. Follow them too when you write your own wrapper. Ready-made wrappers ship as `@chart-on/react`, see [Component wrapper](./component-wrapper), `@chart-on/vue`, see [Template component wrapper](./template-wrapper), and `@chart-on/svelte`, see [Compiled component wrapper](./compiled-wrapper). ## Plain pages ### With a bundler Import the packages as ES modules. Any bundler that understands `package.json` `exports` works, with no plugin or configuration. ```ts import { createChart, createMockDataSource, dataSource } from '@chart-on/core'; const chart = createChart(document.getElementById('chart')!, { plugins: [dataSource()] }); chart.setDataSource(createMockDataSource()); await chart.setSymbol('DEMO', '1h'); ``` With the shell, also import its stylesheet once: ```ts import { createChartShell } from '@chart-on/ui'; import '@chart-on/ui/style.css'; const shell = createChartShell(document.getElementById('chart')!, { theme: 'system' }); ``` ### With a script tag The script-tag build bundles the engine, the shell and its icons into a single file. `pnpm build` writes it to `packages/ui/dist/browser/`: | File | Use | | --- | --- | | `charton.iife.js` | A classic script that defines the global `charton`. Works from any page, also opened straight from disk. | | `charton.js` | The same as one ES module, for ` ``` Or as a module: ```html ``` `charton` holds every export of `@chart-on/core` and `@chart-on/ui`, so the rest of this guide applies unchanged: `createChart(...)` becomes `charton.createChart(...)`. A complete page is in [`examples/script-tag/index.html`](https://github.com/chart-on/chart-on/blob/main/examples/script-tag/index.html). Browsers load module scripts only over `http(s)`, so `charton.js` needs a local server, while `charton.iife.js` also works from `file://`. Use the packages instead of this build when you have a bundler: they share one copy of the engine with `@chart-on/indicators` and plugins, and your bundler leaves out what you do not use. ## Server rendering Frameworks that render on a server import your components there, so importing charton must not need a browser. It doesn't: - Importing `@chart-on/core`, `@chart-on/core/testing`, `@chart-on/core/internal`, `@chart-on/ui`, `@chart-on/indicators` and `@chart-on/ta` reads no `window`, `document`, `navigator`, storage or WebGL. A test checks this for every entry. - Pure helpers (formatting, intervals, indicator math, `parseCsv`, layout migration) work on a server. - `isWebGL2Supported()` returns `false` on a server instead of throwing. - `createChart` and `createChartShell` need a DOM. Call them only in the browser, after the element exists: in the framework's mount hook, never during rendering. Importing `@chart-on/ui/style.css` is a stylesheet import; let your framework's CSS handling take care of it, as for any other package stylesheet. ## The wrapper contract A framework wrapper is a thin component around `createChart` (or `createChartShell`). Every wrapper follows these rules, so apps can move between frameworks and get the same behaviour. ### 1. Create on mount, destroy on unmount - Create the chart or shell when the component's element is in the page: the mount hook, never the render function, and never on a server. - Call `destroy()` when the component unmounts. It stops the frame loop, timers, observers and listeners, releases the WebGL context and removes the chart's element. - `destroy()` is idempotent on both `Chart` and `ChartShell`. Development modes that mount, unmount and mount again leave nothing behind: tests mount and destroy a chart and a shell 100 times each and check that no WebGL context, observer, global listener, timer or animation frame is left. - Do not keep a destroyed chart around for a later mount. Create a new one. ```ts // The shape of any wrapper, written without a framework. function mountChart(el: HTMLElement, props: ChartProps) { const chart = createChart(el, props.options); return { chart, update: (next: ChartProps) => updateChart(chart, props, next), unmount: () => chart.destroy(), }; } ``` ### 2. Update options in place Changing a prop never re-creates the chart. Re-creating would lose the view, drawings, indicators and data, and costs a new WebGL context. - Chart options go through `chart.applyOptions(patch)`. It is a deep-partial update, so pass only what changed. Passing the whole options object again is also fine. - Shell settings go through the shell's runtime methods: `setTheme`, `setPalette`, `applyFeatures`. - Symbol and interval go through `chart.setSymbol(symbol, interval)`; a data source through `chart.setDataSource(source)`. - A few choices are fixed when the chart is created, for example `gl: 'own' | 'shared'`. A wrapper reads them only at mount. If an app needs to change one, it remounts the component on purpose, for example by changing a key. ### 3. Update data through the series Data never travels through a remount. - Data you hold yourself: keep the series handle from `chart.addCandles()` (or `addLine`, `addHistogram`, …) and call `series.setData(bars)` for a new data set, or `series.update(bar)` for a live tick. - Data from a [data source](./data-sources): the chart loads, pages and streams it; the wrapper only passes the source and the symbol. ### 4. Expose the instance The component gives the app the `Chart` (or `ChartShell`) it created, through the framework's usual way of reaching a component instance. The wrapper never hides part of the imperative API: anything the app can do with a chart it created itself, it can do through a wrapper. The instance exists only between mount and unmount, so it is empty during server rendering and before mount. ### 5. Forward events as framework callbacks - Each event the app listens to is subscribed with `chart.on(name, listener)` (or `shell.on`), which returns an unsubscribe function. Call every unsubscribe on unmount; `destroy()` also drops the chart's listeners. - Expose events in the framework's own style (callback props or component events), named after the chart's `noun:verb` event names. The payload is passed on unchanged. - Subscribe only to events the app actually listens to: some, like `crosshair:move` and `frame:render`, fire on every pointer move or frame. - When the app passes a new callback for the same event, call the newest one without re-subscribing, for example by keeping the latest callback in a variable the listener reads. ```ts // Events, written without a framework: the listener always calls the newest handler. function forwardEvents(chart: Chart, handlers: () => Partial