File size: 5,776 Bytes
897170b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
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>`);
}