Menu
Public repository / 439f0dcabf5cdf2b120c7724be895cf1df30507f
AkurAI-Framework / site/frontend/ai/components/artifacts/chart.js
Latest change 8847e6f4a315fb27eba8cb69b87397dd08a068eb - feat(ai/artifacts): add Chart, DataTable, CodeBlock, CodeDiff ES modules by Ólafur Búi Ólafsson
/**
* Artifact: Chart
* Payload: { type: 'bar'|'line'|'pie', labels: string[], series: number[]|{label,data}[] }
* Renders as native inline SVG — no charting library.
*/
const SVG_NS = 'http://www.w3.org/2000/svg';
const PALETTE = ['#5b8cff', '#3ddc84', '#f5b945', '#8a6bff', '#58b2ff', '#ff6b6b'];
function svgEl(tag, attrs) {
const el = document.createElementNS(SVG_NS, tag);
if (attrs) {
for (const k of Object.keys(attrs)) el.setAttribute(k, String(attrs[k]));
}
return el;
}
function svgText(x, y, content, attrs) {
const el = svgEl('text', Object.assign(
{ x, y, fill: '#8a93a8', 'font-size': 11, 'font-family': 'ui-sans-serif,system-ui,sans-serif' },
attrs
));
el.textContent = String(content);
return el;
}
/** Accept series as number[] or {label, data[]}[] — normalize to the latter. */
function normSeries(series) {
if (!Array.isArray(series) || series.length === 0) return [{ label: '', data: [] }];
if (typeof series[0] === 'number') return [{ label: '', data: series }];
return series.map(s => ({ label: String(s.label ?? ''), data: Array.isArray(s.data) ? s.data : [] }));
}
function clampLabel(s, max) {
s = String(s);
return s.length > max ? s.slice(0, max - 1) + '…' : s;
}
function polarXY(cx, cy, r, deg) {
const a = (deg - 90) * Math.PI / 180;
return [+(cx + r * Math.cos(a)).toFixed(3), +(cy + r * Math.sin(a)).toFixed(3)];
}
/* ── Bar ──────────────────────────────────────────────────────────── */
function renderBar(labels, series) {
const W = 360, H = 220, ML = 50, MR = 16, MT = 16, MB = 40;
const PW = W - ML - MR, PH = H - MT - MB;
const n = labels.length || 1;
const allVals = series.flatMap(s => s.data.map(Number));
const scale = (Math.max(...allVals, 0) || 1) * 1.1;
const svg = svgEl('svg', { viewBox: `0 0 ${W} ${H}`, style: 'width:100%;height:auto;display:block' });
// Gridlines + Y labels
for (let i = 0; i <= 4; i++) {
const y = +(MT + PH * (1 - i / 4)).toFixed(1);
svg.appendChild(svgEl('line', { x1: ML, y1: y, x2: ML + PW, y2: y, stroke: '#232b3b', 'stroke-width': 1 }));
svg.appendChild(svgText(ML - 6, y + 4, Math.round(scale * i / 4), { 'text-anchor': 'end', 'font-family': 'ui-monospace,monospace' }));
}
const groupW = PW / n;
const nseries = series.length;
const barW = (groupW / nseries) * 0.72;
const gutter = (groupW - barW * nseries) / 2;
series.forEach((s, si) => {
s.data.forEach((v, i) => {
const val = Number(v) || 0;
const bh = +(val / scale * PH).toFixed(2);
const x = +(ML + i * groupW + gutter + si * barW).toFixed(2);
const y = +(MT + PH - bh).toFixed(2);
svg.appendChild(svgEl('rect', { x, y, width: +barW.toFixed(2), height: bh, fill: PALETTE[si % PALETTE.length], rx: 2 }));
});
});
labels.forEach((lbl, i) => {
const x = +(ML + i * groupW + groupW / 2).toFixed(1);
svg.appendChild(svgText(x, H - MB + 16, clampLabel(lbl, 10), { 'text-anchor': 'middle' }));
});
return svg;
}
/* ── Line ─────────────────────────────────────────────────────────── */
function renderLine(labels, series) {
const W = 360, H = 220, ML = 50, MR = 16, MT = 16, MB = 40;
const PW = W - ML - MR, PH = H - MT - MB;
const n = labels.length || 1;
const allVals = series.flatMap(s => s.data.map(Number));
const scale = (Math.max(...allVals, 0) || 1) * 1.1;
const svg = svgEl('svg', { viewBox: `0 0 ${W} ${H}`, style: 'width:100%;height:auto;display:block' });
for (let i = 0; i <= 4; i++) {
const y = +(MT + PH * (1 - i / 4)).toFixed(1);
svg.appendChild(svgEl('line', { x1: ML, y1: y, x2: ML + PW, y2: y, stroke: '#232b3b', 'stroke-width': 1 }));
svg.appendChild(svgText(ML - 6, y + 4, Math.round(scale * i / 4), { 'text-anchor': 'end', 'font-family': 'ui-monospace,monospace' }));
}
series.forEach((s, si) => {
if (!s.data.length) return;
const xOf = i => +(ML + (n > 1 ? i / (n - 1) : 0.5) * PW).toFixed(2);
const yOf = v => +(MT + PH * (1 - Number(v) / scale)).toFixed(2);
const pts = s.data.map((v, i) => `${xOf(i)},${yOf(v)}`).join(' ');
svg.appendChild(svgEl('polyline', { points: pts, fill: 'none', stroke: PALETTE[si % PALETTE.length], 'stroke-width': 2.5, 'stroke-linejoin': 'round', 'stroke-linecap': 'round' }));
s.data.forEach((v, i) => {
svg.appendChild(svgEl('circle', { cx: xOf(i), cy: yOf(v), r: 4, fill: PALETTE[si % PALETTE.length] }));
});
});
labels.forEach((lbl, i) => {
const x = +(ML + (n > 1 ? i / (n - 1) : 0.5) * PW).toFixed(1);
svg.appendChild(svgText(x, H - MB + 16, clampLabel(lbl, 10), { 'text-anchor': 'middle' }));
});
return svg;
}
/* ── Pie ──────────────────────────────────────────────────────────── */
function renderPie(labels, series) {
const W = 300, H = 200, CX = 100, CY = 100, R = 80;
const data = series[0].data.map(Number);
const total = data.reduce((a, b) => a + b, 0) || 1;
const svg = svgEl('svg', { viewBox: `0 0 ${W} ${H}`, style: 'width:100%;height:auto;display:block' });
let angle = 0;
data.forEach((val, i) => {
const sweep = (val / total) * 360;
const [x1, y1] = polarXY(CX, CY, R, angle);
const [x2, y2] = polarXY(CX, CY, R, angle + sweep);
const large = sweep > 180 ? 1 : 0;
const d = `M${CX},${CY} L${x1},${y1} A${R},${R},0,${large},1,${x2},${y2} Z`;
svg.appendChild(svgEl('path', { d, fill: PALETTE[i % PALETTE.length], stroke: '#0a0c11', 'stroke-width': 1.5 }));
angle += sweep;
});
// Legend
labels.forEach((lbl, i) => {
const ly = 20 + i * 22;
if (ly > H - 6) return;
const pct = Math.round(data[i] / total * 100);
svg.appendChild(svgEl('rect', { x: 195, y: ly - 10, width: 11, height: 11, fill: PALETTE[i % PALETTE.length], rx: 2 }));
svg.appendChild(svgText(210, ly, clampLabel(lbl, 11), { fill: '#e7ebf3', 'font-size': 12 }));
svg.appendChild(svgText(295, ly, `${pct}%`, { 'text-anchor': 'end', 'font-family': 'ui-monospace,monospace' }));
});
return svg;
}
/* ── Export ───────────────────────────────────────────────────────── */
export const def = {
name: 'chart',
category: 'Artifacts',
title: 'Chart',
match(p) {
return p != null
&& (p.type === 'bar' || p.type === 'line' || p.type === 'pie')
&& Array.isArray(p.labels)
&& Array.isArray(p.series);
},
render(payload) {
const wrap = document.createElement('div');
wrap.className = 'card';
wrap.style.padding = '16px';
const labels = payload.labels.map(String);
const series = normSeries(payload.series);
let svg;
if (payload.type === 'bar') svg = renderBar(labels, series);
else if (payload.type === 'line') svg = renderLine(labels, series);
else svg = renderPie(labels, series);
wrap.appendChild(svg);
return wrap;
},
example: {
type: 'bar',
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May'],
series: [12, 19, 8, 24, 15]
}
};