Plugins (Weave)

Weave answers a question ApexCharts got for years: how do I draw my own thing on the chart without forking it or hacking the SVG? A Weave plugin is the supported answer. It registers once by name, activates per chart, subscribes to lifecycle hooks like draw, and paints into its own sandboxed layer. It can read the chart's scales, data, and theme, but it never touches internal state, so it cannot break rendering and it survives version upgrades.

That last part is the point. Because a plugin only ever draws into its own layer and only reads a fixed API, it is safe to publish to npm and drop into any chart. Weave is what makes a real third-party plugin ecosystem for ApexCharts possible.

See it live

The dashed mean line below is not part of the chart config. A plugin computes it on every draw pass: it reads the series, averages the values, converts the mean to a pixel, paints a line and label into its own layer, and emits the value back to the page. Press New data and the plugin recomputes and redraws.

Series with a plugin-drawn mean lineplugin: mean-line

The dashed mean line is not part of the chart config. A Weave plugin computes it on every draw pass and paints it into its own layer.

Where Weave is useful

  • Reference overlays. A mean, median, or target line, control limits for statistical process control, a moving average, an SLA threshold band. The shipped example is the dashed mean-line plugin above, which recomputes on every redraw.
  • Domain overlays. Support and resistance levels or forecast cones in trading, capacity lines in ops dashboards.
  • Branding. A watermark or logo layer applied consistently across every chart in an app.
  • External event markers. Deploy markers, incidents, or release dates pulled from another system and painted on top of a time series.
  • Org-wide reusable behaviors. Publish one plugin to npm so every team's charts get the same overlay or instrumentation by adding a single name to plugins.
  • Analytics instrumentation. Emit namespaced events (a hover, a threshold crossing) for product analytics without modifying chart internals.

Enable the feature

Weave is in the default bundle, so import ApexCharts from 'apexcharts' already has it and there is nothing to enable. If you are assembling from the lean core, or you want the import to state the dependency explicitly, add the feature entry point:

import ApexCharts from 'apexcharts'
import 'apexcharts/features/weave'   // already included in the default bundle

See the tree-shaking guide for every entry point and what each one costs.

Register a plugin

ApexCharts.registerPlugin(def) registers a plugin globally, once. Each plugin declares a name, an apiVersion, and a setup(api) function that subscribes to hooks. Call api.layer() inside each draw handler (layers are wiped at the start of every draw pass, so a handle cached across draws points at a detached node):

ApexCharts.registerPlugin({
  name: 'watermark',
  apiVersion: 1,
  setup(api) {
    api.on('draw', () => {
      const layer = api.layer()
      layer.text({ x: 12, y: 22, text: 'ACME', color: '#c8ccd4', size: '12px' })
    })
  },
})

ApexCharts.unregisterPlugin(name) removes it again, which is useful in tests and hot-reload.

Activate a plugin per chart

Registering makes a plugin available; a chart opts in through the plugins array. Registration is global, activation is per chart, so the same plugin can be on some charts and not others. You can pass per-chart options and an order:

const options = {
  plugins: [{ name: 'watermark', options: { text: 'ACME' } }],
}

At runtime, chart.updateOptions({ plugins: [{ name, options }] }) reconfigures an active plugin in place; the new options arrive live as api.options.

The plugin API

setup(api) receives a stable facade, never the chart's internals:

api.on(hook, fn)Subscribe to a lifecycle hook: afterParse, afterScales, draw, afterUpdate, or destroy.
api.layer(opts?)A plugin-owned SVG group with line, path, rect, circle, text, and clear. Pass { z: 'front' } or 'behind' to stack it. Wiped at the start of every draw pass.
api.scalesConvert data values to pixels for the current view: scales.x(v), scales.y(v), scales.point(x, y), plus transposed, gridWidth and gridHeight. Details below.
api.radialScalesThe dial on a radialBar or gauge: centre, ring and band radii, angles, and angle(v) / point(v, r). null on other chart types. Details below. v7, from 8.5.0
api.dataRead-only access to the resolved series: name, color, hidden, points, raw, and observations (the sample behind a histogram, boxPlot or violin).
api.themeThe resolved theme: mode, foreColor, seriesColor(i), and token(name).
api.storePer-plugin state that survives redraws.
api.emit(name, detail)Send an event back to the page. It fires on the chart's bus as plugin:<name>:<event>.
api.infoWhat kind of chart this is, so a plugin can decide whether it applies. Details below. v2
api.categoriesThe display labels per x position, resolved so they survive every render path. v2
api.markDerived(names)Declare which series on the chart are the plugin's own rather than the caller's. v2
api.reserve(box)Reserve space inside the chart's container for the plugin's own UI. v3
api.pointer(fn)Subscribe to the data point the viewer is pointing at or has selected. Returns an unsubscribe. v4
api.can(name)Whether this host supports a named capability. Details below. v6
api.capabilitiesThe same list as a frozen array, for logging and support. v6
api.claim(option, entries)Set a positional option for your own series without writing the caller's config. Details below. v6
api.highlight(entries)Draw a part of each value inside the slot it already occupies, instead of adding a series. Details below. v7
api.drawn()Everything on the chart, including other features' output. Read only. Details below. v6
api.declare(item)Say what this plugin has drawn, so it appears in drawn(). v6

Two properties of this API are what make plugins safe. First, the layer is sandboxed and cleared before each draw, so a plugin can never corrupt the chart's own output. Second, api.emit is namespaced as plugin:<name>:<event>, so a plugin's events can never trigger the chart's internal lifecycle subscribers.

Versions, and why you should not declare the newest one

The contract is at v7 as of ApexCharts 8.1. Every change so far has been additive:

VersionShipped inAdded
v16.0on, layer, scales, data, theme, store, emit, options, chart
v27.2data[].raw, api.info, api.categories, api.markDerived()
v37.2api.reserve()
v47.3api.pointer()
v57.4api.info.stroke.dashArray
v67.5api.can(), api.capabilities, api.claim(), api.drawn(), api.declare(), api.info.title, modifiers on the pointer payload
v68.0.1data[].observations, scales.transposed, scales.point(), with no version bump: detect them by capability
v78.1api.highlight()
v78.5.0api.radialScales and payload.radialScales, with no version bump: detect them by capability. The same release lets a layer's dash take a pattern

The gate is forward-compatible: a host serves a plugin that declares an older version, and skips only a plugin that needs a newer host than itself. So the version you declare is a minimum requirement, not a statement of what you were built against.

That makes declaring the newest version actively harmful. A plugin declaring apiVersion: 3 is skipped outright by a 7.1 host rather than served a smaller API, and a skipped plugin is silent. Declare the oldest version your plugin genuinely cannot work without, and ask for the rest.

Before 7.2 the gate demanded an exact match, which would have disabled every v1 plugin the moment v2 landed. Plugins written for v1 run unchanged on a v7 host.

Asking what the host supports

Declaring a low apiVersion and feature-detecting the rest was always the right shape, but until v6 the only way to do the detection was to sniff the facade yourself:

if (typeof api.reserve === 'function') { /* v3, optional */ }

A version integer never answered "does this host have X". api.can(name) does, and api.capabilities is the same list as a frozen array for logging and bug reports:

ApexCharts.registerPlugin({
  name: 'panel',
  apiVersion: 2,                    // the minimum this plugin needs
  setup(api) {
    if (api.can?.('reserve')) {      // optional-chained: v5 hosts have no `can`
      api.reserve({ right: 220 })
    }
  },
})

Note the ?.. A host older than 7.5 has no can at all, so a plugin that supports both has to guard the guard. Once you require 7.5 or newer, api.can('reserve') reads plainly.

The names are the capability, not the member: one name can cover a group of related members.

NameCoversSince
layerapi.layer()v1
derivedapi.markDerived()v2
reserveapi.reserve()v3
pointerapi.pointer()v4
stroke-infoapi.info.strokev5
claimapi.claim()v6
drawnapi.drawn(), api.declare()v6
observationsapi.data[].observationsv6, from 8.0.1
scale-orientationscales.transposed, scales.point()v6, from 8.0.1
highlightapi.highlight()v7
radial-scalesapi.radialScales, payload.radialScalesv7, from 8.5.0

observations, scale-orientation and radial-scales show why a version number cannot answer the question: the first two arrived in 8.0.1 with the contract still at v6, and radial-scales in 8.5.0 at v7, so a host on either version may or may not have them, and only api.can() can tell.

The list is probed off the facade that was actually built, rather than copied from a constant. A member that is ever made conditional drops out of capabilities instead of being advertised and then missing, which is the failure mode a hand-maintained list eventually has.

All of v6 and v7 is reachable from a plugin still declaring apiVersion: 2. That is the point of having capabilities at all.

TypeScript authors: you want 7.6.0

api.info, api.markDerived(), api.reserve() and api.pointer() were implemented from v2 to v4 but were missing from the ApexPluginAPI type until 7.6.0. They worked at runtime the whole time; a plugin written in TypeScript simply could not call them without a cast. If you are writing against the types, require 7.6.0 even though the runtime had them earlier. The members added since, observations, scales.transposed, scales.point() and api.highlight(), were typed in the same release that shipped them.

api.info: what kind of chart is this?

A plugin that adds a computed series or draws an analysis overlay cannot work on every chart type, and the alternative to asking is adding a series and letting the core warn at the user. api.info is a frozen snapshot, read fresh each time:

typeThe type the caller asked for. Survives the aliasing that rewrites e.g. raincloud to violin.
axisChartfalse for pie, donut and radialBar, where each entry is one number rather than a row of values.
datetimeXWhether the x axis is a datetime axis.
horizontalBarsThe core refuses to draw a horizontal bar in a combo, so a plugin must not add a derived series to one.
dataLabels.enabled / .enabledOnSeriesWhether the chart prints a value on each point, and for which series. There is no per-series data-label flag, so enabledOnSeries is the only way to keep labels off a computed series, and narrowing it without knowing the caller's own value would silently discard it.
stroke.dashArray (v5)The caller's own dashing. A number or a pattern string means every series, a top-level array means per series, and since 8.5.0 each entry may itself be a pattern ('8 3 2 3' or [8, 3, 2, 3]), so do not assume a number.
title (v6)The name the page already gave this chart, from config.title.text. An empty string rather than undefined when the chart is untitled, so it can be used directly. Useful for a readout that would otherwise head each row with a container id.

stroke.dashArray exists for the same reason and against the same trap as enabledOnSeries. The option is indexed by series position with no per-series escape hatch, so a plugin that wants its own derived series dashed has to write the whole array. Reporting the current value is what lets it put back what it found instead of flattening the caller's dashed lines. There is no "unset" to report: the option defaults to 0, and 0 already means no dashing, so restoring it restores exactly what was there.

api.scales: placing a mark

api.scales converts data to pixels from the same ratios the series were drawn with, so a mark a plugin places lands on the chart's own. It is null in setup and in every afterParse handler, because the scales are computed after the data is parsed, and on pie, donut and radialBar. Read it in afterScales or draw, as the examples here do.

  • scales.x(v) takes a datum's x exactly as api.data[].points[j].x hands it out: a category position, a date, a number, or a rangeBar's row label. On a chart laid out in bands, such as category columns, it returns the centre of that row's band.
  • scales.y(v, axis) takes a value and the index of the series it belongs to, and follows that series' own y axis, reversed or logarithmic included, the way the series' line does. axis defaults to 0, so on a chart with a second y axis, pass the series index or every value is placed against the first series' axis.
  • A horizontal bar is the same chart on its side. The scales stay keyed to the data, so x still takes a datum's x and y its value, but x now runs down the grid height and y across the width, and scales.transposed is true.

scales.point(x, y, axis) returns a datum's { x, y } on screen whichever way the chart is turned, so a plugin that places marks through it never has to branch on transposed. Its last argument is the series index, as for scales.y:

api.on('draw', ({ scales, data }) => {
  if (!scales) return
  const layer = api.layer()
  data.forEach((s, i) => {
    for (const p of s.points) {
      if (p.y == null) continue
      const at = scales.point(p.x, p.y, i)
      layer.circle({ cx: at.x, cy: at.y, r: 4, fill: api.theme.seriesColor(i) })
    }
  })
})

Require 8.0.1 for horizontal bars and 8.2.0 for reversed or logarithmic y axes. Before 8.0.1 the scales were built for the upright chart, so on a horizontal bar both coordinates went through the wrong axis; api.can('scale-orientation') tells you the host has the fix. Before 8.2.0 scales.y mapped every value linearly from the top, so an overlay came out mirrored on a reversed axis and off the line on a logarithmic one, and no capability names that fix.

Reading the sample behind a histogram

On a histogram, points are the bin counts, and raw is the binning's output too. A plugin that averages them reports the mean bar height under the caller's series name, with nothing to say binning happened. The same holds where the chart computed a boxPlot summary or a violin density from the points the caller supplied.

api.data[].observations is the sample those rows were computed from:

api.on('draw', ({ data }) => {
  const obs = data[0].observations
  if (!obs) return   // the rows drawn are the caller's own values
  const mean = obs.values.reduce((a, b) => a + b, 0) / obs.values.length
  // obs.byPoint[j] is the sample behind points[j]
})
  • values is the whole series' sample, as finite numbers.
  • byPoint[j] is the sample behind points[j]: a bin's observations, or the points a box was summarised from. It is null for a datum the caller supplied already summarised.
  • observations itself is null where the rows drawn are the caller's own values, which covers a precomputed boxPlot or violin and every chart that summarises nothing.

It is a copy, so editing it cannot change what the next render bins. Detect it with api.can('observations'): on a host older than 8.0.1 the field is missing rather than null.

Adding a derived series

Three pieces of v2 exist for one job: computing a series from the caller's data and putting it on the chart without the chart mistaking it for the caller's own.

Copy the shape from data[].raw, not from points. points is normalised, and its x falls back to the ordinal position on render paths where the parsed x values are not populated. That is fine to read and wrong to write back, because the three accepted shapes ([1, 2], [{x, y}], [[x, y]]) are not interchangeable: hand back the wrong one and it parses to all-null and draws nothing, without an error. raw is the caller's own array, untouched by parsing.

Key on api.categories, not on an index. These are the resolved display labels, config-first. Reading globals.categoryLabels or globals.labels directly gives you real labels on first paint and ordinals after any updateSeries(), because both are populated on mount and emptied on update.

Declare what you added with api.markDerived(names). The core cannot tell a computed series from the caller's own, and several behaviours depend on the difference. The host uses it to keep your series out of the initial-series snapshot, so resetSeries() and the toolbar's reset restore the caller's data rather than your output. It is idempotent; pass an empty array when your series are gone. Since v6 it also decides the owner column in api.drawn(), so a series you computed is attributed to you rather than to the chart.

api.claim: setting an option for your own series

Some options are indexed by series position with no per-series alternative. stroke.dashArray is the clearest: a plugin that adds a projection and wants only the projection dashed has to write the array covering every series, and then put the caller's value back.

That restore has four failure modes, three of which no amount of care fixes:

  1. It flattens the caller's own dashes unless it first reads them (which is what api.info.stroke.dashArray was added in v5 to allow).
  2. It restores a stale value if the caller ran their own updateOptions meanwhile.
  3. It leaks the write permanently if the plugin throws before restoring.
  4. Two plugins doing it fight, and whichever releases last restores the other's write.

A claim is resolved where the option is read instead. Nothing is written to the caller's config, so releasing is a deletion rather than a restore, and there is nothing to put back or to get wrong. A caller's own updateOptions composes with the claim instead of reverting it.

const claim = api.claim('stroke.dashArray', [
  { series: 'Projection', value: 6 },
])

claim.update([{ series: 'Projection', value: 3 }])  // same place in the order
claim.release()                                      // idempotent

Name the series rather than its position where you can. The name is resolved each time the option is read, so the claim follows that series when others are added, removed or reordered. An index is resolved the same way but means whatever is in that slot at read time.

Two options are claimable today:

OptionValue per series
stroke.dashArraya number
dataLabels.enabledOnSeriesa boolean

dataLabels.enabledOnSeries is a membership list in the caller's config and a boolean in a claim on purpose: a claim answers "does this series print labels", which is the question the consumption site actually asks, rather than restating the caller's list shape.

The allowlist is the point rather than a limitation. An unbounded claim(path, value) would be "write anything to the caller's config", which is exactly what this platform refuses to do. An option earns a place only if it is positional, has no per-series alternative in the caller's own data, and is read in few enough places to resolve at all. colors fails the second test: a series carries its own color, so a plugin adding a series sets it there and needs nothing from the host.

Behaviour worth knowing before you rely on it:

  • It returns null for an option that is not claimable, so a plugin written against a newer host degrades instead of throwing. The warning names the claimable options.
  • A bad entry is dropped, not fatal. Claim ten series and get one wrong and the other nine still apply, with a warning naming the option and the value.
  • The last claim on a series wins, in the order plugins ran, which is deterministic and independent of render order.
  • Every claim is released on teardown, on destroy, when the plugin is removed from plugins, and if the host disables the plugin.

api.highlight: a part of each value

A plugin with a second number per datum (the realised share of a forecast, a target's attainment, the part of each month's spend inside a budget) has had one way to show it: add a series and mark it derived. A series is a large thing to put on someone else's chart. It takes a legend entry, a tooltip row and an export column, it rescales the value axis, and on a bar chart it takes half the width of every bar the caller had.

api.highlight() draws the second number as a part instead, the way the highlight filter does: each value faded at its full size, and the part solid in front of it, from the same baseline, in the same slot. Nothing is written to the caller's config, so releasing is a deletion rather than a restore.

This plugin shows how much of each month's spend fell inside a budget, so the overspend is the faded top of a column:

import ApexCharts from 'apexcharts'
import 'apexcharts/features/highlight-filter'

ApexCharts.registerPlugin({
  name: 'within-budget',
  apiVersion: 2,
  setup(api) {
    api.on('draw', () => {
      if (!api.can?.('highlight')) return
      const { budget } = api.options
      const entries = api.data.map((s) => ({
        series: s.name,
        values: s.points.map((p) => (p.y == null ? null : Math.min(p.y, budget))),
      }))

      // Each call re-renders the chart, so only call when the parts changed.
      const key = JSON.stringify(entries)
      if (key === api.store.key) return
      api.store.key = key

      if (api.store.parts) api.store.parts.update(entries)
      else api.store.parts = api.highlight(entries)
      // null: this chart cannot take parts, and the plugin draws nothing.
      // Forget the key so the next draw asks again.
      if (!api.store.parts) api.store.key = null
    })
  },
})

const chart = new ApexCharts(document.querySelector('#chart'), {
  chart: { type: 'bar', height: 300 },
  series: [{ name: 'Spend', data: [42, 58, 35, 71, 49] }],
  xaxis: { categories: ['Jan', 'Feb', 'Mar', 'Apr', 'May'] },
  plugins: [{ name: 'within-budget', options: { budget: 50 } }],
})
chart.render()

February and April draw solid to 50 with the rest faded above. Changing the budget through updateOptions({ plugins: [...] }), or the data through updateSeries(), recomputes the parts on the next draw.

Each entry names a series and gives values parallel to that series' data, with null where a datum has no part. Name the series rather than its position where you can: the name is resolved on every parse, so the parts follow it when the caller adds, removes or reorders series. A value that is not a finite number reads as null, and an entry with no series or no values array is dropped with a warning while the rest still apply. The handle's update(entries) replaces the parts, keeping this plugin's place in the order, and release() removes them; releasing twice is not an error.

Behaviour worth knowing before you rely on it:

  • It returns null when the parts were not taken: the highlight-filter add-on is not loaded on the page, the chart is not an axis chart, the type draws no parts (only bar, column, line, area and treemap do, and not as a combo, a range series or a histogram), the caller is already showing parts of their own through highlightData or a point's highlight, or no entry was usable (an empty array, or every entry dropped as invalid). A plugin cannot fix the first four, so it needs something else to do, or nothing. All but the missing add-on can change on the next update, so ask again rather than remembering a null, which is why the example forgets its key when nothing was taken.
  • api.can('highlight') is not the same question. It says the host has the method. The return value says whether the parts were registered, and even a handle does not mean they are drawn: on a stacked chart with more than one y axis or a logarithmic y axis, or with highlightFilter.enabled: false, the parts are registered but not drawn until that changes.
  • Registering, updating and releasing each re-render the chart, one task later and coalesced to one render per turn, so calling from a draw handler cannot re-enter the render. It does start another draw, which is why the example compares before it calls.
  • The last registration naming a series wins, in the order plugins ran.
  • Everything is released on teardown, on destroy, when the plugin is removed from plugins, and if the host disables the plugin. On 8.1 a plugin removed from plugins kept its parts; see when a plugin fails or leaves.

The plan. The parts are drawn by the highlight filter, which is a Premium feature. A chart drawing a plugin's parts without a valid Premium key or above carries the APEXCHARTS trial watermark while it does, exactly as it would with the caller's own parts. See pricing.

api.drawn: what is on this chart

A plugin sees the chart through the facade, and until v6 the facade described the chart's series and nothing else: not the caller's annotations, not another feature's ink, not another plugin's overlay. So the panel everyone reaches for first, a list of what is on this chart, could only ever enumerate the asking plugin's own tools, which its own toolbar already shows.

api.drawn() returns that inventory, and api.declare() is how a plugin puts its own drawings into it:

api.on('draw', () => {
  const layer = api.layer()
  // ... paint the overlay ...
  api.declare({ id: 'trend', label: 'Trend line', visible: true })
})

api.drawn()
// [
//   { id: '…', kind: 'series',     label: 'Revenue',    owner: 'core',       visible: true },
//   { id: '…', kind: 'annotation', label: 'Launch',     owner: 'core',       visible: true },
//   { id: '…', kind: 'annotation', label: 'Check this', owner: 'ink',        visible: true },
//   { id: '…', kind: 'overlay',    label: 'Trend line', owner: 'my-plugin',  visible: true },
// ]

kind is 'series', 'annotation' or 'overlay'. Annotations cover the caller's xaxis, yaxis, points, texts and images buckets. For a series, visible reflects whether the viewer has switched it off in the legend, including a series on a secondary axis.

owner is 'core', the name of the plugin that declared it, or 'ink' for a note the viewer drew with Ink. That third case matters for the question this list is usually built to answer. Ink strokes are annotations, so before 7.6.1 they arrived through the annotation contributor and defaulted to 'core', reporting something a viewer had drawn as the page author's own. A readout asking "which of this is mine" got the wrong answer, which is worse than declining to answer.

Three properties of this API are deliberate and will not change without a version:

It is read only. Nothing here removes or hides anything. A list you can act on would mean letting a plugin change another feature's output, which is a much larger promise than this platform makes today.

It is partial, and it says so. Every contributor opts in. A feature that declares nothing is absent rather than approximated, which is exactly why each entry carries owner: a reader can tell what the list covers instead of assuming it is exhaustive.

Declarations cannot outlive what they describe. They are cleared with the layers at the start of every draw, so declare from your draw handler rather than once in setup. Emptying your layer also drops your declarations, which is how a plugin says "that is no longer on screen" for a drawing removed by an interaction rather than by a redraw.

If you consume this list, require 7.5.1 rather than 7.5.0. On 7.5.0 declarations were dropped only on a chart redraw, so an overlay switched off by an interaction (which empties its layer and repaints without a chart render) stayed in the list, and drawn() went on reporting something the viewer had just dismissed. A reader can see that a list covers only the features that opted in; they cannot see that a row in it is stale.

Making room for your own UI

A plugin that renders its own HTML beside the chart, a docked panel or a toolbar of its own, cannot make room for it. The chart sizes itself from the element the caller handed it, so a sibling inserted into that element does not narrow the chart: the chart is drawn at full width underneath. Every workaround is worse. Writing chart.width means owning config the caller owns and losing it on their next updateOptions. Positioning over the chart means guessing a size you cannot know and being clipped by any ancestor with overflow: hidden. Narrowing the container means writing to the caller's own element.

api.reserve() has the host do the arithmetic, in the one place that already does it. The container keeps its size and the chart draws inside what is left:

api.reserve({ right: 220 })   // a right-hand gutter for your panel
api.reserve(null)             // give the space back
  • Reservations are per plugin and summed, so two plugins each asking for a right-hand gutter get one each instead of overlapping.
  • The total is clamped to half the container on each axis. A plugin may not reduce the chart it is annotating to nothing. If your UI needs more room than that, render below the chart, which you can do without asking.
  • It is applied after the auto-height calculation, so a side panel narrows the chart without also shortening it and shifting the page below.
  • Calling it with an unchanged box does nothing, so calling it on every render is free. Changing it re-renders one task later, so calling it from inside a draw handler cannot re-enter the render.

Following the pointer

The chart already resolves the series and point under the pointer, for its own tooltip and for the dataPointMouseEnter, dataPointMouseLeave and dataPointSelection events. api.pointer(fn) forwards those three as one normalised payload, so a plugin gets the host's answer instead of hit-testing the SVG itself and then disagreeing with the tooltip on the same pixel:

const off = api.pointer((e) => {
  // e.type            'enter' | 'leave' | 'select'
  // e.seriesIndex     which series
  // e.dataPointIndex  which point
  // e.category        the resolved display label, e.g. 'Mar'
  // e.seriesName      undefined on a pie, where series carry bare numbers
  // e.selected        on 'select' only: is the point now in or out
  // e.modifiers       v6: { shift, ctrl, alt, meta }, the keys held
})

off()  // unsubscribe

category is the same string api.categories carries, because a plugin coordinating two charts keys on the label: an index means something different on each chart, and reading globals.labels directly reports 3 where the chart shows Mar. selected comes from the chart's own selection set, so a second click reads as a deselect rather than another select.

modifiers (v6) reports the keys held during the interaction, which is what makes shift-click to add to a selection expressible at all. The keys were on the DOM event the pointer handler had been discarding. All four are false when the interaction came from somewhere with no DOM event, such as the keyboard, so treat "no modifier" and "not a mouse" as the same case rather than inferring intent from them.

Nothing here lets a plugin intercept or cancel. The chart's tooltip, its selection state and the caller's own dataPoint* events are unaffected, and a handler that throws is contained rather than allowed to break the interaction it was watching. It still counts toward the plugin's three failures, like a hook that throws. Wiring is lazy and torn down with the chart, so a chart whose plugins never ask pays nothing.

When a plugin fails or leaves

Every call into a plugin is guarded: setup, each hook handler, destroy, and a pointer handler. A call that throws is logged with the plugin's name and where it threw, and the chart carries on. After three such failures the host disables the plugin, says so in the console, and stops calling it.

A plugin leaves a chart in three ways: the caller removes it from plugins, the host disables it, or the chart is destroyed. All three let go of everything the plugin held on that chart (its claims, highlight parts, pointer handlers, reserved space, derived-series names and declarations), so a plugin cannot leave a dashed series or an empty gutter behind, however badly it behaves. A chart that is still on the page redraws when a released claim, part or gutter changes what it shows. A series the plugin added with updateSeries() is the exception: once added it is part of the chart's series, and it stays on the chart after the plugin is removed. Only the record that it was derived goes.

If you rely on that cleanup, require 8.2.0. Before it, a plugin removed through updateOptions kept its claims, highlight parts, pointer handlers and derived-series names and went on receiving hovers after its destroy had run, a disabled one kept its reserved space and handlers, and a throwing pointer handler did not count toward the three failures.

The re-render that api.reserve() and api.highlight() schedule is the host's own, so when it fails the host logs the error and names what asked for the render, instead of leaving an unhandled promise rejection that appears to come from inside the library.

Pie, donut and radialBar

Weave works on non-axis charts. Before 7.2 every plugin silently did nothing on one: those charts hold one number per entry rather than a row of values, the data snapshot called .map on a number, and the per-plugin guard caught the exception and disabled the plugin, so the failure looked like the plugin's fault. Each slice is now presented as a one-point series, which is what it is. Check api.info.axisChart if your plugin needs a real axis.

api.scales is null on these charts. From 8.5.0 a radialBar or gauge has api.radialScales instead (also on the hook payload as radialScales), which describes the dial in the layer's own pixels: its centre, each ring's radius and thickness, the bands, the needle, and angle(value) and point(value, radius) to place a mark at a value. Angles are degrees from 12 o'clock, clockwise. Each render publishes a new frozen object, so read it in draw rather than caching it. Detect it with api.can('radial-scales'). Pages read the same object through chart.getRadialScales().

api.on('draw', ({ radialScales: dial }) => {
  if (!dial) return
  const layer = api.layer()
  const tip = dial.point(90, dial.outerRadius)
  layer.line({ x1: dial.cx, y1: dial.cy, x2: tip.x, y2: tip.y, stroke: '#DC2626', width: 2, dash: '4 2' })
})

Weave vs Marks

Both are extensibility features, but at different levels:

  • Weave adds overlays and cross-cutting behaviors that are not one-per-datum: reference lines, bands, watermarks, instrumentation. A Weave plugin decorates the whole chart.
  • Marks adds a new data-driven series type: one shape per datum, tied to your data. A Mark defines how a series is drawn.

For a one-off drawing on a single chart, annotations are simpler than either.