Charts
The charts a model writes in an echarts or chartjs fence, and a tool's result, drawn with ECharts or Chart.js in the chat's colours while the reply streams.
On this page
<aparte-chat-viewport style="height: 40rem"></aparte-chat-viewport>
<script>
// In an app: import { setupECharts } from '@aparte/plugin-echarts'; setupECharts();
// The model writes the library's own option in an echarts fence: each fence is a code segment.
const option = {
title: { text: 'Revenue by quarter (k€)' }, tooltip: { trigger: 'axis' }, legend: { bottom: 0 },
xAxis: { type: 'category', data: ['Q1', 'Q2', 'Q3', 'Q4'] }, yAxis: { type: 'value' },
series: [
{ name: '2025', type: 'bar', data: [120, 132, 101, 134] },
{ name: '2026', type: 'bar', data: [140, 162, 151, 190] },
],
};
const viewport = document.querySelector('aparte-chat-viewport');
viewport.appendMessage({ id: 'u1', role: 'user', content: 'Compare our revenue by quarter with last year.', timestamp: Date.now() });
viewport.appendMessage({ id: 'a1', role: 'assistant', timestamp: Date.now(), segments: [
{ id: 's1', type: 'text', content: 'Every quarter is up on last year, and **Q4 by 42%**:' },
{ id: 's2', type: 'code', language: 'echarts', content: JSON.stringify(option, null, 2) },
] });
</script>A model already knows how to configure a chart in ECharts or Chart.js. Let it write the library’s own config in a fenced block, or have a tool return it, and the chart is drawn in the reply: in the chat’s colours, recoloured when the theme switches, with a hidden table of its numbers for a screen reader. Two plugins, one per library: pick the one your app uses.
Variants
Section titled “Variants”Chart.js
Section titled “Chart.js”<aparte-chat-bubble message-id="a1" data-role="assistant" name="Assistant"></aparte-chat-bubble>
<script>
// In an app: import { setupChartjs } from '@aparte/plugin-chartjs'; setupChartjs();
// A chartjs fence holds the library's own { type, data, options }.
const config = {
type: 'line',
data: {
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
datasets: [
{ label: 'This week', data: [820, 932, 901, 934, 1290, 1330, 1320], fill: true, tension: 0.35 },
{ label: 'Last week', data: [760, 810, 870, 880, 1010, 1150, 1090], tension: 0.35 },
],
},
options: { plugins: { title: { display: true, text: 'Visitors per day' } } },
};
document.querySelector('aparte-chat-bubble').setSegments([
{ id: 's1', type: 'code', language: 'chartjs', content: JSON.stringify(config, null, 2) },
]);
</script>A ```chartjs fence holds Chart.js’s own { type, data, options }, drawn on a canvas.
More chart types
Section titled “More chart types”<aparte-chat-bubble message-id="a1" data-role="assistant" name="Assistant"></aparte-chat-bubble>
<script>
// In an app: import { setupECharts } from '@aparte/plugin-echarts'; setupECharts();
const donut = {
title: { text: 'Where visitors come from' }, tooltip: { trigger: 'item' }, legend: { bottom: 0 },
series: [{ type: 'pie', radius: ['45%', '70%'], data: [
{ name: 'Search', value: 4820 }, { name: 'Direct', value: 2310 }, { name: 'Newsletter', value: 1240 },
{ name: 'Social', value: 980 }, { name: 'Referral', value: 610 },
] }],
};
// Open, close, low, high: a month of made-up daily prices.
const days = Array.from({ length: 22 }, (_, i) => 'Day ' + (i + 1));
const prices = days.map((_, i) => {
const open = 170 + Math.sin(i / 3) * 6 + i * 0.4;
const close = open + Math.sin(i * 1.7) * 3;
return [open, close, Math.min(open, close) - 1.5, Math.max(open, close) + 1.5].map((n) => Math.round(n * 100) / 100);
});
const candles = {
title: { text: 'ACME, last month' }, tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: days }, yAxis: { type: 'value', scale: true },
series: [{ type: 'candlestick', data: prices }],
};
document.querySelector('aparte-chat-bubble').setSegments([
{ id: 's1', type: 'code', language: 'echarts', content: JSON.stringify(donut, null, 2) },
{ id: 's2', type: 'code', language: 'echarts', content: JSON.stringify(candles, null, 2) },
]);
</script>ECharts draws in SVG, crisp at any zoom, and knows many more types: a donut, candlesticks, and maps, heatmaps, sankeys and gauges.
While the reply streams
Section titled “While the reply streams”<aparte-chat-bubble message-id="a1" data-role="assistant" streaming></aparte-chat-bubble>
<script>
// An echarts fence and a chartjs fence: each library's own config, as JSON, drawn as it arrives.
const bubble = document.querySelector('aparte-chat-bubble');
const echarts = JSON.stringify({
title: { text: 'Revenue by quarter (k€)' }, tooltip: { trigger: 'axis' }, legend: {},
xAxis: { type: 'category', data: ['Q1', 'Q2', 'Q3', 'Q4'] }, yAxis: { type: 'value' },
series: [{ name: '2025', type: 'bar', data: [120, 132, 101, 134] }, { name: '2026', type: 'bar', data: [140, 162, 151, 190] }],
}, null, 2);
const chartjs = JSON.stringify({
type: 'line',
data: { labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
datasets: [{ label: 'Visitors', data: [820, 932, 901, 934, 1290, 1330, 1320], fill: true, tension: 0.35 }] },
options: { plugins: { title: { display: true, text: 'This week' } } },
}, null, 2);
bubble.setSegments([
{ id: 's1', type: 'code', language: 'echarts', content: '', isStreaming: true },
{ id: 's2', type: 'code', language: 'chartjs', content: '', isStreaming: true },
]);
const stream = (id, source, done) => {
let i = 0;
const tick = setInterval(() => {
bubble.appendToSegment(id, source.slice(i, i + 12));
i += 12;
if (i >= source.length) {
clearInterval(tick);
bubble.updateSegment(id, { isStreaming: false });
done?.();
}
}, 50);
};
stream('s1', echarts, () => stream('s2', chartjs, () => bubble.removeAttribute('streaming')));
</script>A chart is drawn from the part of its JSON that has arrived, at most every 300 ms, so the bars appear as the model writes their numbers; each frame updates the same chart.
npm install @aparte/plugin-echarts echarts
# or
npm install @aparte/plugin-chartjs chart.jsimport { setupECharts } from '@aparte/plugin-echarts';
import { setupChartjs } from '@aparte/plugin-chartjs';
setupECharts();
setupChartjs({
tool: 'get_revenue',
map: (result) => ({ type: 'bar', data: { labels: result.months, datasets: [{ label: 'Revenue', data: result.values }] } }),
});With a tool, the tool’s result is drawn in place of its row. The library loads on the first
chart, and a config from a model is read as untrusted data: nothing in it becomes HTML and no
request leaves the page. All of it is in the charts guide.
Related
Section titled “Related”- Stats card — a few figures with sparklines, without a chart library.
- Stock card — a price and its chart.
- Code block — every other fence.