pyreon

@pyreon/charts is a reactive bridge to Apache ECharts for Pyreon. You write ECharts options as a function — signal reads inside are tracked, so the chart re-renders whenever any tracked signal changes. ECharts itself is lazy-loaded: chart types and components are auto-detected from your options and dynamically imported on first render, so there are zero ECharts bytes in your initial bundle until a chart actually mounts.

@pyreon/chartsstable

Installation

@pyreon/charts declares echarts as a peer dependency (>=5.6.0) — install it alongside the package.

npm install @pyreon/charts echarts
bun add @pyreon/charts echarts
pnpm add @pyreon/charts echarts
yarn add @pyreon/charts echarts

Required Vite setup (tslib alias)

ECharts imports tslib for its TypeScript helpers (__extends, __assign, …). tslib's package.json exports map points the import condition at ./modules/index.js, which destructures those helpers from a __toESM(require_tslib()) default — but the helpers live as top-level vars on the CJS factory, not as properties of the default export. The destructure reads undefined, and ECharts throws on first chart mount.

@pyreon/charts/vite ships chartsViteAlias(), which resolves tslib to the flat-ESM tslib.es6.js and sidesteps the broken indirection. Spread it into resolve.alias:

// vite.config.ts
import { defineConfig } from 'vite'
import { chartsViteAlias } from '@pyreon/charts/vite'

export default defineConfig({
  resolve: {
    alias: {
      ...chartsViteAlias(),
    },
  },
})

chartsViteAlias() walks common install layouts (bun's nested layout, hoisted node_modules) to locate tslib.es6.js. If it can't find tslib it returns {} — a no-op — so apps that don't actually use @pyreon/charts won't have their config broken by the spread.

Quick Start

The <Chart /> component is the simplest way to render a chart. Pass an options function that returns a standard ECharts configuration. Signal reads inside the function are tracked, so the chart updates reactively.

import { signal } from '@pyreon/reactivity'
import { Chart } from '@pyreon/charts'

function SalesChart() {
  const months = signal(['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'])
  const revenue = signal([120, 200, 150, 80, 70, 110])

  return (
    <Chart
      options={() => ({
        xAxis: { type: 'category', data: months() },
        yAxis: { type: 'value' },
        tooltip: { trigger: 'axis' },
        series: [{ name: 'Revenue', type: 'bar', data: revenue() }],
      })}
      style="height: 400px"
    />
  )
}

The options object

The value your options function returns is a standard ECharts option object@pyreon/charts does not invent its own option shape. For the full option reference (every series type, axis, component, and their fields), see the ECharts option docs. This page documents the Pyreon bridge around that object: how it's made reactive, how modules are lazy-loaded, and how the component/hook lifecycle works.

@pyreon/charts re-exports ECharts' option types so you can import them from one place (no direct echarts import needed for typing):

import type {
  EChartsOption,
  ComposeOption,
  // Series option types
  BarSeriesOption,
  LineSeriesOption,
  PieSeriesOption,
  ScatterSeriesOption,
  RadarSeriesOption,
  HeatmapSeriesOption,
  TreemapSeriesOption,
  TreeSeriesOption,
  SunburstSeriesOption,
  SankeySeriesOption,
  GaugeSeriesOption,
  FunnelSeriesOption,
  CandlestickSeriesOption,
  BoxplotSeriesOption,
  GraphSeriesOption,
  // Component option types
  TitleComponentOption,
  TooltipComponentOption,
  LegendComponentOption,
  GridComponentOption,
  ToolboxComponentOption,
  DataZoomComponentOption,
  VisualMapComponentOption,
} from '@pyreon/charts'

Reactive options

Because options are a function, the chart is fully reactive — write to a signal and the chart updates in place. There is no manual setOption call; the bridge runs it for you whenever a tracked signal changes.

import { signal } from '@pyreon/reactivity'
import { Chart } from '@pyreon/charts'

function LiveChart() {
  const data = signal([10, 20, 30])

  // Mutate the signal — the chart re-renders automatically
  setInterval(() => {
    data.update((d) => d.map((v) => v + Math.round(Math.random() * 10 - 5)))
  }, 1000)

  return (
    <Chart
      options={() => ({
        xAxis: { type: 'category', data: ['A', 'B', 'C'] },
        yAxis: { type: 'value' },
        series: [{ type: 'line', data: data() }],
      })}
      style="height: 300px"
    />
  )
}

By default the bridge calls ECharts' setOption with notMerge: false and lazyUpdate: true — updates are merged into the existing option and batched to the next frame. Set notMerge to replace the whole option, or lazyUpdate: false to update synchronously (see Configuration).

Event handling

<Chart /> binds handlers to the underlying ECharts instance. The onEventsmap is the general form — it binds any ECharts event by name; onClick,onMouseover, and onMouseout are convenience shorthands (they map toclick/mouseover/mouseout and win on a key collision).

<Chart
  options={() => ({
    legend: {},
    series: [{ type: 'pie', data: [
      { value: 60, name: 'A' },
      { value: 40, name: 'B' },
    ] }],
  })}
  style="height: 300px"
  onEvents={{
    legendselectchanged: (params) => console.log('legend', params.name),
    datazoom: (_params, instance) => syncOtherChart(instance.getOption()),
  }}
  onClick={(params) => console.log('clicked:', params.name, params.value)}
/>

Each handler receives (params, instance)params is a ChartEventParamsobject (a duck-typed shape carrying the common ECharts event fields name,value, seriesIndex, dataIndex, seriesName, componentType, color,event, … plus an index signature for anything else ECharts attaches), andinstance is the live ECharts instance (call instance.dispatchAction(...)without capturing a ref).

Binding is leak-safe: if a handler prop changes, the old listener is removed before the new one is bound (no pile-up), and every listener is removed on unmount.

Loading overlay — showLoading

showLoading reactively toggles ECharts' built-in loading spinner. It is distinct from useChart's loading signal, which tracks lazy moduleloading before the instance exists — showLoading controls the overlay on an already-mounted chart while your data is in flight.

<Chart
  options={() => data.chartOption()}
  showLoading={data.isFetching()}
  loadingOption={{ text: 'Loading…', color: '#5470c6' }}
  style="height: 300px"
/>

Theme is applied once (not reactive)

ECharts cannot hot-swap a theme — it must dispose and recreate the instance. Sotheme is read once at init. To switch themes at runtime (e.g. dark mode), remount the chart by keying it on the theme signal:

<Chart
  theme={() => (themeMode() === 'dark' ? 'dark' : null)}
  options={() => option()}
  style="height: 300px"
/>

Passing theme as an ACCESSOR makes the swap reactive: a flip disposes and re-creates the instance with the current option, group, and event handlers preserved (ECharts has no in-place theme swap — dispose + re-init is the mechanism, exactly what vue-echarts does). A plain string/object theme stays static. The old remount-by-keying workaround still works but is no longer needed.

useChart

useChart<TOption>(optionsFn, config?) is the low-level hook with full lifecycle control. <Chart /> is built on top of it. Use the hook when you need the raw ECharts instance, a manual resize(), or access to the loading / error signals.

import { useChart } from '@pyreon/charts'

function MyChart() {
  const chart = useChart(() => ({
    xAxis: { type: 'category', data: ['A', 'B', 'C'] },
    yAxis: { type: 'value' },
    series: [{ type: 'bar', data: [10, 20, 30] }],
  }))

  return (
    <div>
      {() => (chart.loading() ? <p>Loading chart…</p> : null)}
      <div ref={chart.ref} style="height: 400px" />
      <button onClick={() => chart.resize()}>Resize</button>
    </div>
  )
}

Return value

PropertyTypeDescription
ref(el: Element | null) => voidCallback ref — bind it to your container div: <div ref={chart.ref}>. Chart init defers until the element is attached.
instanceSignal<ECharts | null>The underlying ECharts instance. null until modules load and the chart initializes.
loadingSignal<boolean>true while ECharts modules are being dynamically imported and the chart is initializing.
errorSignal<Error | null>Set if module loading, init, or setOption throws. null once a render succeeds.
resize() => voidManually trigger an ECharts resize (also happens automatically — see Auto-resize).

ComposeOption — strict typed options

By default useChart (and <Chart />) accept the broad EChartsOption type. To get exact autocomplete for only the series types you use, pass a TOption built with ECharts' ComposeOption<> helper:

import { useChart } from '@pyreon/charts'
import type { ComposeOption, BarSeriesOption, LineSeriesOption } from '@pyreon/charts'

type DashboardOption = ComposeOption<BarSeriesOption | LineSeriesOption>

function Dashboard() {
  const chart = useChart<DashboardOption>(() => ({
    xAxis: { type: 'category', data: ['Q1', 'Q2', 'Q3', 'Q4'] },
    yAxis: { type: 'value' },
    series: [
      { type: 'bar', data: [100, 200, 150, 300] },
      { type: 'line', data: [80, 170, 130, 280] },
      // { type: 'pie', ... } would be a type error — not in the union
    ],
  }))

  return <div ref={chart.ref} style="height: 400px" />
}

The same generic works on the component via <Chart<DashboardOption> options={…} />.

<Chart />

The declarative component. It wraps useChart internally, renders the container div for you, binds events (onEvents + the mouse shorthands), toggles the loading overlay, and surfaces errors.

<Chart
  options={() => ({
    /* ECharts option */
  })}
  style="height: 300px"
  renderer="svg"
  onInit={(instance) => console.log('chart ready:', instance)}
  onClick={(params) => console.log('clicked:', params.name)}
/>

Props

PropTypeDefaultDescription
options() => EChartsOptionRequired. Function returning the ECharts option. Signal reads are tracked.
stylestringInline style for the container. Must include a height.
classstringCSS class for the container.
themestring | Record<string, unknown>ECharts theme — 'dark', a registered theme name, or a theme object.
renderer'canvas' | 'svg''canvas'Rendering mode.
localestring'EN'ECharts locale, e.g. 'ZH'.
notMergebooleanfalseReplace the option entirely on update instead of merging.
replaceMergestring | string[]Component types to REPLACE (not merge) per update, e.g. 'series'.
lazyUpdatebooleantrueBatch updates to the next frame.
onInit(instance: ECharts) => voidCalled once when the ECharts instance is created.
showLoadingbooleanfalseReactive — toggles ECharts' built-in loading overlay.
loadingOptionRecord<string, unknown>Options for the loading overlay (text, color, …).
ariaLabelstringText alternative → the container becomes role="img" with this label.
onEventsRecord<string, (params, instance) => void>Any ECharts event, keyed by name (legendselectchanged, datazoom, …).
onClick(params, instance) => voidShorthand for onEvents.click.
onMouseover(params, instance) => voidShorthand for onEvents.mouseover.
onMouseout(params, instance) => voidShorthand for onEvents.mouseout.

Configuration

useChart's second argument (UseChartConfig) — and the corresponding <Chart /> props — accept these options. They are read once when the chart is created (except the option-update flags, which apply to every reactive update).

OptionTypeDefaultDescription
themestring | Record<string, unknown>ECharts theme — 'dark', a registered theme name, or a theme object.
renderer'canvas' | 'svg''canvas'Rendering mode. Canvas has the best performance; SVG is crisp and printable.
localestring'EN'ECharts locale.
notMergebooleanfalseOn reactive update, replace the option instead of merging.
lazyUpdatebooleantrueOn reactive update, batch to the next frame.
devicePixelRationumberwindow.devicePixelRatioOverride the device pixel ratio.
widthnumbercontainer widthExplicit width override passed to init.
heightnumbercontainer heightExplicit height override passed to init.
onInit(instance: ECharts) => voidCalled once when the ECharts instance is created.
const chart = useChart(
  () => ({ series: [{ type: 'bar', data: values() }] }),
  {
    renderer: 'svg',
    theme: 'dark',
    notMerge: true, // replace, don't merge, on every update
    onInit: (instance) => console.log('created', instance),
  },
)

Lazy loading & auto-detection

@pyreon/charts ships zero ECharts bytes in your initial bundle. When a chart first renders, the bridge inspects the option object and dynamically imports only the modules it needs:

  • echarts/core itself is loaded on first render (not at import time).

  • Series renderers are detected from each series[].type (bar, line, pie, scatter, radar, heatmap, treemap, sunburst, sankey, funnel, gauge, graph, tree, boxplot, candlestick, parallel, themeRiver, effectScatter, lines, pictorialBar, custom, map).

  • Components are detected from top-level option keys (tooltip, legend, title, toolbox, dataZoom, visualMap, timeline, graphic, brush, calendar, dataset, aria, grid, polar, radar, geo). Note: xAxis / yAxis both pull in the GridComponent.

  • Series-level features are detected from series keys (markPoint, markLine, markArea).

  • The renderer (CanvasRenderer or SVGRenderer) is always loaded for the configured renderer.

// First render dynamically imports ONLY:
//   echarts/core, BarChart, GridComponent (from xAxis/yAxis),
//   TooltipComponent, CanvasRenderer
<Chart
  options={() => ({
    tooltip: { trigger: 'axis' },
    xAxis: { type: 'category', data: labels() },
    yAxis: { type: 'value' },
    series: [{ type: 'bar', data: values() }],
  })}
  style="height: 300px"
/>

Loaded modules are cached and deduplicated — a second chart of the same type registers instantly, and concurrent loads of the same module share one in-flight promise. A failed load (e.g. a network blip fetching an ECharts chunk) is not cached as a permanent rejection, so a later retry can succeed.

Manual registration (@pyreon/charts/manual)

For deterministic bundle sizes — or when building a library that shouldn't pull ECharts modules in automatically — use the @pyreon/charts/manual entry. It exposes the same useChart / Chart / types, plus a use() function. You import and register the ECharts modules yourself; the bundler tree-shakes everything you don't register.

import { useChart, Chart, use } from '@pyreon/charts/manual'
import { BarChart, LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'

// Register once at app startup — variadic, pass the modules directly
use(BarChart, LineChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer)

// Then use Chart / useChart exactly as with the default entry
function MyChart() {
  const chart = useChart(() => ({
    series: [{ type: 'bar', data: [1, 2, 3] }],
  }))

  return <div ref={chart.ref} style="height: 300px" />
}

Auto-resize and cleanup

useChart (and therefore <Chart />) observes the container with a ResizeObserver and calls chart.resize() whenever the container's size changes — no manual wiring needed. You can also trigger a resize yourself via the resize() return value (useful after a layout change a ResizeObserver won't catch, like a programmatic style swap).

On unmount the bridge disconnects the observer and calls chart.dispose() on the ECharts instance, so charts clean up after themselves with no leak.

Error handling

The bridge captures errors from module loading, init, and setOption into the error signal. With useChart, read it directly:

import { useChart } from '@pyreon/charts'

function SafeChart() {
  const chart = useChart(() => ({
    series: [{ type: 'bar', data: chartData() }],
  }))

  return (
    <div>
      {() =>
        chart.error() ? (
          <div class="chart-error">Failed to render chart: {chart.error()!.message}</div>
        ) : null
      }
      <div ref={chart.ref} style="height: 300px" />
    </div>
  )
}

The <Chart /> component surfaces errors for you: it logs every chart error to console.error (in both dev and production — so deployment-time failures reach ops via browser devtools), and in development it renders the error message inline in the chart container instead of a blank div. In production the container stays empty (the console log still fires), so internals aren't leaked to users.

Common error scenarios:

  • Missing tslib alias — the most common one. The bridge detects the canonical tslib symptom and re-throws with a message telling you to add chartsViteAlias() (see Required Vite setup).

  • Zero-height container — ECharts requires a sized container; with no height the chart is invisible.

  • Invalid option — a malformed option passed to setOption.

  • Network failure loading a lazy ECharts chunk — transient; not cached as a permanent failure, so a retry can recover.

API Reference

Exports — @pyreon/charts

ExportKindDescription
useCharthook<TOption extends EChartsOption = EChartsOption>(optionsFn: () => TOption, config?: UseChartConfig) => UseChartResult. Reactive ECharts instance with lazy module loading, signal-tracked options, auto-resize, error capture, and cleanup.
Chartcomponent<TOption extends EChartsOption = EChartsOption>(props: ChartProps<TOption>) => VNodeChild. Declarative chart component built on useChart with event binding and inline error display.
EChartsOptiontypeThe broad ECharts option type (re-export).
ComposeOptiontypeECharts helper to compose a strict option type from a series/component union (re-export).
EChartstypeThe ECharts instance type (re-export from echarts/core).
SetOptionOptstypeOptions for ECharts' setOption (re-export).
ChartPropstypeProps for <Chart /> (options, style, class, theme, renderer, locale, notMerge, replaceMerge, lazyUpdate, onInit, showLoading, loadingOption, ariaLabel, onEvents, onClick, onMouseover, onMouseout).
ChartEventParamstypeDuck-typed shape passed to event handlers (name, value, seriesIndex, dataIndex, …).
ChartEventHandlertype(params: ChartEventParams, instance: ECharts) => void — the onEvents handler shape.
UseChartConfigtypeSecond argument to useChart — see Configuration.
UseChartResulttypeReturn type of useChart (ref, instance, loading, error, resize).
BarSeriesOptiontypeECharts bar series option (re-export).
LineSeriesOptiontypeECharts line series option (re-export).
PieSeriesOptiontypeECharts pie series option (re-export).
ScatterSeriesOptiontypeECharts scatter series option (re-export).
RadarSeriesOptiontypeECharts radar series option (re-export).
HeatmapSeriesOptiontypeECharts heatmap series option (re-export).
TreemapSeriesOptiontypeECharts treemap series option (re-export).
TreeSeriesOptiontypeECharts tree series option (re-export).
SunburstSeriesOptiontypeECharts sunburst series option (re-export).
SankeySeriesOptiontypeECharts sankey series option (re-export).
GaugeSeriesOptiontypeECharts gauge series option (re-export).
FunnelSeriesOptiontypeECharts funnel series option (re-export).
CandlestickSeriesOptiontypeECharts candlestick series option (re-export).
BoxplotSeriesOptiontypeECharts boxplot series option (re-export).
GraphSeriesOptiontypeECharts graph series option (re-export).
TitleComponentOptiontypeECharts title component option (re-export).
TooltipComponentOptiontypeECharts tooltip component option (re-export).
LegendComponentOptiontypeECharts legend component option (re-export).
GridComponentOptiontypeECharts grid component option (re-export).
ToolboxComponentOptiontypeECharts toolbox component option (re-export).
DataZoomComponentOptiontypeECharts dataZoom component option (re-export).
VisualMapComponentOptiontypeECharts visualMap component option (re-export).

Exports — @pyreon/charts/manual

ExportKindDescription
useCharthookSame hook as the default entry, but with no auto-detection — you register modules via use().
ChartcomponentSame component as the default entry, with no auto-detection.
usefunction(...modules: unknown[]) => void. Variadic ECharts module registration (echarts/core's use). Call once at startup.
EChartsOption / UseChartConfig / UseChartResult / ChartPropstypeRe-exported for convenience.

Exports — @pyreon/charts/vite

ExportKindDescription
chartsViteAliasfunction(fromDir?: string) => Record<string, string>. Returns a resolve.alias entry mapping tslib to the flat-ESM tslib.es6.js. Spread into resolve.alias. Returns {} if tslib can't be located.
Charts