Bonsai-Chat-WebGPU / src /lib /chart-tools.ts
WaveCut's picture
Add agentic artifact tools and live diagnostics
897170b verified
Raw
History Blame Contribute Delete
5.78 kB
const CHART_PALETTE = ['#6f9f48', '#d07b50', '#7d8c72', '#b7d889', '#53684b', '#d7ad77', '#8a6f58', '#9eb0a0'];
export interface ChartInput {
title: string;
labels: string[];
values: number[];
colors?: string[];
}
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function normalizedInput(input: ChartInput): Required<ChartInput> {
const title = input.title.trim().slice(0, 120) || 'Untitled chart';
if (!Array.isArray(input.labels) || !Array.isArray(input.values)) {
throw new Error('chart labels and values must be arrays');
}
if (input.labels.length === 0 || input.labels.length > 32) {
throw new Error('chart requires 1–32 data points');
}
if (input.labels.length !== input.values.length) {
throw new Error('chart labels and values must have the same length');
}
const labels = input.labels.map((label) => String(label).trim().slice(0, 80));
const values = input.values.map((value) => {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric < 0) {
throw new Error('chart values must be finite non-negative numbers');
}
return numeric;
});
const colors = labels.map((_, index) => {
const candidate = input.colors?.[index];
return typeof candidate === 'string' && /^#[0-9a-f]{6}$/iu.test(candidate)
? candidate
: CHART_PALETTE[index % CHART_PALETTE.length]!;
});
return { title, labels, values, colors };
}
function chartShell(title: string, body: string): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${escapeHtml(title)}</title>
<style>
:root { color-scheme: light; font-family: ui-sans-serif, system-ui, sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; padding: clamp(20px, 4vw, 44px); color: #182018; background: #f4f7f2; }
main { max-width: 960px; margin: 0 auto; }
h1 { margin: 0 0 26px; font-size: clamp(24px, 4vw, 42px); line-height: 1.05; letter-spacing: -.035em; }
.chart-note { margin: 20px 0 0; color: #647064; font-size: 13px; }
</style>
</head>
<body><main><h1>${escapeHtml(title)}</h1>${body}</main></body>
</html>`;
}
export function generateBarChartHtml(input: ChartInput): string {
const { title, labels, values, colors } = normalizedInput(input);
const maximum = Math.max(...values, 1);
const rows = labels.map((label, index) => {
const value = values[index]!;
const percentage = value / maximum * 100;
return `<li>
<span class="label">${escapeHtml(label)}</span>
<span class="track"><i style="width:${percentage.toFixed(4)}%;background:${colors[index]!}"></i></span>
<strong>${escapeHtml(value.toLocaleString('en-US'))}</strong>
</li>`;
}).join('');
return chartShell(title, `<style>
ol { display: grid; gap: 13px; margin: 0; padding: 0; list-style: none; }
li { display: grid; grid-template-columns: minmax(90px, 180px) minmax(120px, 1fr) auto; align-items: center; gap: 12px; }
.label { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
.track { height: 26px; overflow: hidden; border: 1px solid #c9d1c8; background: #fff; }
.track i { display: block; min-width: 2px; height: 100%; }
strong { min-width: 64px; font-variant-numeric: tabular-nums; text-align: right; }
@media (max-width: 560px) { li { grid-template-columns: minmax(70px, 1fr) 2fr; } li strong { grid-column: 2; text-align: left; } }
</style><ol aria-label="${escapeHtml(title)}">${rows}</ol><p class="chart-note">Scale maximum: ${escapeHtml(maximum.toLocaleString('en-US'))}</p>`);
}
export function generatePieChartHtml(input: ChartInput): string {
const { title, labels, values, colors } = normalizedInput(input);
const total = values.reduce((sum, value) => sum + value, 0);
if (total <= 0) throw new Error('pie_chart requires a positive total');
let cursor = 0;
const stops = values.map((value, index) => {
const start = cursor;
cursor += value / total * 100;
return `${colors[index]!} ${start.toFixed(4)}% ${cursor.toFixed(4)}%`;
}).join(', ');
const legend = labels.map((label, index) => {
const value = values[index]!;
const percentage = value / total * 100;
return `<li><i style="background:${colors[index]!}"></i><span>${escapeHtml(label)}</span><strong>${percentage.toFixed(1)}%</strong><small>${escapeHtml(value.toLocaleString('en-US'))}</small></li>`;
}).join('');
return chartShell(title, `<style>
.layout { display: grid; grid-template-columns: minmax(220px, 360px) minmax(260px, 1fr); align-items: center; gap: clamp(26px, 6vw, 70px); }
.pie { width: min(100%, 360px); aspect-ratio: 1; border: 1px solid #b9c3b8; border-radius: 50%; background: conic-gradient(${stops}); }
ul { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }
li { display: grid; grid-template-columns: 12px minmax(0, 1fr) auto auto; align-items: center; gap: 10px; padding-bottom: 9px; border-bottom: 1px solid #d8ded7; }
li i { width: 12px; height: 12px; }
li span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
li strong, li small { font-variant-numeric: tabular-nums; }
li small { min-width: 58px; color: #687368; text-align: right; }
@media (max-width: 680px) { .layout { grid-template-columns: 1fr; } .pie { justify-self: center; } }
</style><div class="layout"><div class="pie" role="img" aria-label="${escapeHtml(title)}"></div><ul>${legend}</ul></div><p class="chart-note">Total: ${escapeHtml(total.toLocaleString('en-US'))}</p>`);
}