AkurAI Build
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]
  }
};