Spaces:
Running
Running
| 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('<', '<') | |
| .replaceAll('>', '>') | |
| .replaceAll('"', '"') | |
| .replaceAll("'", '''); | |
| } | |
| 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>`); | |
| } | |