Charts (Chart.js, ECharts)
Chart.js draws charts on a <canvas>. It doesn’t know about Sygnal, and doesn’t need to: defineWidget turns it into a JSX tag. The data lives in your state, the chart is redrawn when it changes, and a click on a bar comes back as an event the intent reads.
Install
Section titled “Install”npm install chart.jsThe widget
Section titled “The widget”import { defineWidget } from 'sygnal'import { Chart, BarController, BarElement, CategoryScale, LinearScale, Tooltip, Colors } from 'chart.js'
Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Colors)
export const SalesChart = defineWidget({ name: 'SalesChart', tag: 'canvas', mount: (el, props, dispatch) => new Chart(el, { type: 'bar', data: { labels: props.labels, datasets: [{ label: props.series, data: props.values }] }, options: { maintainAspectRatio: false, onClick: (event, bars) => { if (bars.length) dispatch('bar-select', bars[0].index) }, }, }), update: (chart, props) => { chart.data.labels = props.labels chart.data.datasets[0].label = props.series chart.data.datasets[0].data = props.values chart.update() }, unmount: (chart) => chart.destroy(), events: ['bar-select'],})- The
Colorsplugin gives each dataset a colour from Chart.js’s palette. Without it, a dataset with nobackgroundColoris drawn in a 10% black that is hard to see on a dark page. mountcreates the chart on the<canvas>host and returns it: that is the instanceupdateandunmountget.updateruns when the props change. It changes the chart’s data in place and callschart.update(), so the chart animates to the new values instead of being rebuilt.unmountruns when the chart leaves the page and frees the canvas.- A click on a bar calls
dispatch('bar-select', index), a bubbling DOM event on the canvas with the bar’s index as itsdetail.
Using it
Section titled “Using it”import { SalesChart } from './SalesChart.js'
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr']
export function Sales({ state }) { const picked = state.selected === null ? 'Select a bar' : `${MONTHS[state.selected]}: ${state.values[state.selected]}` return ( <section> <div className="chart-box"> <SalesChart className="sales" role="img" aria-label="Sales by month" labels={MONTHS} series="Sales" values={state.values} /> </div> <p className="picked">{picked}</p> <button className="add-sale">Add a sale in April</button> </section> )}
Sales.initialState = { values: [12, 19, 7, 4], selected: null }
Sales.intent = ({ DOM }) => ({ SELECT: DOM.select('.sales').events('bar-select').detail(), ADD_SALE: DOM.click('.add-sale'),})
Sales.model = { SELECT: (state, index) => ({ ...state, selected: index }), ADD_SALE: (state) => ({ ...state, values: [...state.values.slice(0, 3), state.values[3] + 1] }),}.chart-box { position: relative; height: 240px; }Chart.js sizes the canvas to its parent, so the parent needs a size (and position: relative). With maintainAspectRatio: false the chart fills that box.
Testing
Section titled “Testing”renderComponent’s mock DOM doesn’t draw the chart. t.widget('.sales') sends the widget’s events and shows the props the view passed it, which is what the component is responsible for:
import { test, expect } from 'vitest'import { renderComponent } from 'sygnal'import { Sales } from './Sales.jsx'
test('a clicked bar is selected, and a new sale reaches the chart', async () => { const t = renderComponent(Sales) await t.ready() t.widget('.sales').dispatch('bar-select', 1) await t.next((state) => state.selected === 1) expect(t.query('.picked').textContent).toBe('Feb: 19')
t.simulateEvent('.add-sale', 'click') await t.next((state) => state.values[3] === 5) expect(t.widget('.sales').props.values).toEqual([12, 19, 7, 5]) t.dispose()})To test the chart itself (that a click on the second bar selects it), run the component in a real browser with renderComponent(Sales, { dom: 'real' }): t.widget('.sales').instance is the Chart.js object, and Chart.getChart(canvas) is undefined after t.dispose(). jsdom has no canvas, so Chart.js can’t draw there.
ECharts
Section titled “ECharts”Apache ECharts works the same way. It draws into a <div> (the default host), takes one option object for everything, and reports clicks through chart.on('click'):
import { defineWidget } from 'sygnal'import * as echarts from 'echarts/core'import { BarChart } from 'echarts/charts'import { GridComponent, TooltipComponent } from 'echarts/components'import { CanvasRenderer } from 'echarts/renderers'
echarts.use([BarChart, GridComponent, TooltipComponent, CanvasRenderer])
const option = (props) => ({ xAxis: { type: 'category', data: props.labels }, yAxis: { type: 'value' }, tooltip: {}, series: [{ type: 'bar', data: props.values }],})
export const SalesEChart = defineWidget({ name: 'SalesEChart', mount: (el, props, dispatch) => { const chart = echarts.init(el) chart.setOption(option(props)) chart.on('click', (params) => dispatch('bar-select', params.dataIndex)) // ECharts measures the element once: redraw at the new size when the host resizes const observer = new ResizeObserver(() => chart.resize()) observer.observe(el) return { chart, observer } }, update: ({ chart }, props) => chart.setOption(option(props)), unmount: ({ chart, observer }) => { observer.disconnect() chart.dispose() }, events: ['bar-select'],})setOption merges the new option into the chart, so update can pass the whole option every time. Give the host a height in CSS (.sales { height: 240px; }): ECharts measures the element when it starts, and only again on chart.resize(), which the ResizeObserver calls when the host changes size (a resized window, a sidebar that opens). The instance is { chart, observer }, so unmount can stop both. (Chart.js watches its canvas’s container itself; with maintainAspectRatio: false it follows the container’s height.)
Measured with Vite, minified and gzipped, Sygnal not included:
| Adds | |
|---|---|
| Chart.js bar chart (the registrations above) | 60 KB |
ECharts bar chart (the use list above) | 180 KB |
defineWidget (the first widget in an app) | 1.1 KB |
Register only what you draw: import Chart from 'chart.js/auto' registers every chart type, scale and plugin: 75 KB instead of 60.
Pitfalls
Section titled “Pitfalls”- Register what the chart uses. Chart.js and ECharts are tree-shaken: a chart type, scale or plugin you don’t register isn’t there (
"bar" is not a registered controller). - Pass new arrays.
updateruns when a prop is a different value. A reducer that changesstate.valuesin place passes the same array, and the chart doesn’t change. The usual immutable update ([...values],map) is enough. - A canvas has no text. Give the host
role="img"and anaria-label, and show the numbers somewhere a screen reader can reach (a table, a summary line), aspickeddoes here. - Clicks on bars are mouse-only. A keyboard user can’t click a bar. Offer the same choice with buttons or a
<select>that dispatch the same action. - Animations.
chart.update()animates andchart.update('none')doesn’t. To respectprefers-reduced-motion, setoptions.animation: falseinmountwhenmatchMedia('(prefers-reduced-motion: reduce)').matches.