tova 0.7.0 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/bin/tova.js +1312 -139
  2. package/package.json +8 -1
  3. package/src/analyzer/analyzer.js +539 -11
  4. package/src/analyzer/browser-analyzer.js +56 -8
  5. package/src/analyzer/deploy-analyzer.js +44 -0
  6. package/src/analyzer/scope.js +7 -0
  7. package/src/analyzer/server-analyzer.js +33 -1
  8. package/src/codegen/base-codegen.js +1296 -23
  9. package/src/codegen/browser-codegen.js +725 -20
  10. package/src/codegen/codegen.js +87 -5
  11. package/src/codegen/deploy-codegen.js +49 -0
  12. package/src/codegen/server-codegen.js +54 -6
  13. package/src/codegen/shared-codegen.js +5 -0
  14. package/src/codegen/theme-codegen.js +69 -0
  15. package/src/codegen/wasm-codegen.js +6 -0
  16. package/src/config/edit-toml.js +6 -2
  17. package/src/config/git-resolver.js +128 -0
  18. package/src/config/lock-file.js +57 -0
  19. package/src/config/module-cache.js +58 -0
  20. package/src/config/module-entry.js +37 -0
  21. package/src/config/module-path.js +63 -0
  22. package/src/config/pkg-errors.js +62 -0
  23. package/src/config/resolve.js +26 -0
  24. package/src/config/resolver.js +139 -0
  25. package/src/config/search.js +28 -0
  26. package/src/config/semver.js +72 -0
  27. package/src/config/toml.js +61 -6
  28. package/src/deploy/deploy.js +217 -0
  29. package/src/deploy/infer.js +218 -0
  30. package/src/deploy/provision.js +315 -0
  31. package/src/diagnostics/security-scorecard.js +111 -0
  32. package/src/lexer/lexer.js +18 -3
  33. package/src/lsp/server.js +482 -0
  34. package/src/parser/animate-ast.js +45 -0
  35. package/src/parser/ast.js +39 -0
  36. package/src/parser/browser-ast.js +19 -1
  37. package/src/parser/browser-parser.js +221 -4
  38. package/src/parser/concurrency-ast.js +15 -0
  39. package/src/parser/concurrency-parser.js +236 -0
  40. package/src/parser/deploy-ast.js +37 -0
  41. package/src/parser/deploy-parser.js +132 -0
  42. package/src/parser/parser.js +42 -5
  43. package/src/parser/select-ast.js +39 -0
  44. package/src/parser/theme-ast.js +29 -0
  45. package/src/parser/theme-parser.js +70 -0
  46. package/src/registry/plugins/concurrency-plugin.js +32 -0
  47. package/src/registry/plugins/deploy-plugin.js +33 -0
  48. package/src/registry/plugins/theme-plugin.js +20 -0
  49. package/src/registry/register-all.js +6 -0
  50. package/src/runtime/charts.js +547 -0
  51. package/src/runtime/embedded.js +6 -2
  52. package/src/runtime/reactivity.js +60 -0
  53. package/src/runtime/router.js +703 -295
  54. package/src/runtime/table.js +606 -33
  55. package/src/stdlib/inline.js +365 -10
  56. package/src/stdlib/runtime-bridge.js +152 -0
  57. package/src/stdlib/string.js +84 -2
  58. package/src/stdlib/validation.js +1 -1
  59. package/src/version.js +1 -1
@@ -0,0 +1,547 @@
1
+ // SVG Charting — Pure JS SVG generators for Tova
2
+ // 6 chart types: bar, line, scatter, histogram, pie, heatmap
3
+
4
+ const PALETTE = ['#4f46e5', '#059669', '#d97706', '#dc2626', '#7c3aed', '#0891b2', '#be185d', '#65a30d'];
5
+
6
+ const DEFAULT_MARGIN = { top: 40, right: 20, bottom: 60, left: 70 };
7
+
8
+ function esc(s) {
9
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
10
+ }
11
+
12
+ function getRows(data) {
13
+ if (data && data._rows) return data._rows;
14
+ if (Array.isArray(data)) return data;
15
+ return [];
16
+ }
17
+
18
+ function niceTicks(min, max, count) {
19
+ if (count === undefined) count = 5;
20
+ if (min === max) { min = min - 1; max = max + 1; }
21
+ const range = max - min;
22
+ const roughStep = range / count;
23
+ const mag = Math.pow(10, Math.floor(Math.log10(roughStep)));
24
+ const candidates = [1, 2, 5, 10];
25
+ let step = mag;
26
+ for (let i = 0; i < candidates.length; i++) {
27
+ if (candidates[i] * mag >= roughStep) { step = candidates[i] * mag; break; }
28
+ }
29
+ const start = Math.floor(min / step) * step;
30
+ const ticks = [];
31
+ for (let v = start; v <= max + step * 0.5; v += step) {
32
+ ticks.push(Math.round(v * 1e10) / 1e10);
33
+ }
34
+ return ticks;
35
+ }
36
+
37
+ function formatNum(n) {
38
+ if (Number.isInteger(n)) return String(n);
39
+ if (Math.abs(n) >= 1000) return String(Math.round(n));
40
+ return n.toFixed(1);
41
+ }
42
+
43
+ function emptyChart(width, height, msg) {
44
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" style="font-family:system-ui,sans-serif"><text x="${width / 2}" y="${height / 2}" text-anchor="middle" fill="#888" font-size="14">${esc(msg || 'No data')}</text></svg>`;
45
+ }
46
+
47
+ // ── Bar Chart ─────────────────────────────────────────────
48
+ export function bar_chart(data, opts) {
49
+ if (!opts) opts = {};
50
+ const rows = getRows(data);
51
+ const width = opts.width || 600;
52
+ const height = opts.height || 400;
53
+
54
+ if (rows.length === 0) return emptyChart(width, height, 'No data');
55
+
56
+ const xFn = opts.x;
57
+ const yFn = opts.y;
58
+ const title = opts.title || '';
59
+ const color = opts.color || PALETTE[0];
60
+ const margin = { ...DEFAULT_MARGIN };
61
+ if (title) margin.top = 50;
62
+
63
+ const labels = rows.map(r => String(xFn(r)));
64
+ const values = rows.map(r => Number(yFn(r)));
65
+
66
+ const plotW = width - margin.left - margin.right;
67
+ const plotH = height - margin.top - margin.bottom;
68
+
69
+ const yMin = 0;
70
+ const yMax = Math.max(...values);
71
+ const ticks = niceTicks(yMin, yMax);
72
+ const scaleMax = ticks[ticks.length - 1];
73
+
74
+ const barGap = 0.15;
75
+ const barW = plotW / labels.length;
76
+ const innerW = barW * (1 - barGap);
77
+
78
+ const parts = [];
79
+ parts.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" style="font-family:system-ui,sans-serif">`);
80
+
81
+ // Title
82
+ if (title) {
83
+ parts.push(`<text x="${width / 2}" y="24" text-anchor="middle" font-size="16" font-weight="bold" fill="#111">${esc(title)}</text>`);
84
+ }
85
+
86
+ // Y-axis gridlines and labels
87
+ for (let i = 0; i < ticks.length; i++) {
88
+ const t = ticks[i];
89
+ if (t < yMin) continue;
90
+ const y = margin.top + plotH - (t / scaleMax) * plotH;
91
+ parts.push(`<line x1="${margin.left}" y1="${y}" x2="${margin.left + plotW}" y2="${y}" stroke="#e5e7eb" stroke-width="1"/>`);
92
+ parts.push(`<text x="${margin.left - 8}" y="${y + 4}" text-anchor="end" font-size="11" fill="#666">${formatNum(t)}</text>`);
93
+ }
94
+
95
+ // Bars
96
+ for (let i = 0; i < labels.length; i++) {
97
+ const barH = scaleMax > 0 ? (values[i] / scaleMax) * plotH : 0;
98
+ const x = margin.left + i * barW + (barW - innerW) / 2;
99
+ const y = margin.top + plotH - barH;
100
+ const c = Array.isArray(color) ? color[i % color.length] : (opts.colors ? opts.colors[i % opts.colors.length] : color);
101
+ parts.push(`<rect x="${x}" y="${y}" width="${innerW}" height="${barH}" fill="${c}" rx="2"/>`);
102
+ }
103
+
104
+ // X-axis baseline
105
+ parts.push(`<line x1="${margin.left}" y1="${margin.top + plotH}" x2="${margin.left + plotW}" y2="${margin.top + plotH}" stroke="#9ca3af" stroke-width="1"/>`);
106
+
107
+ // X-axis labels
108
+ const rotate = labels.length > 6;
109
+ for (let i = 0; i < labels.length; i++) {
110
+ const x = margin.left + i * barW + barW / 2;
111
+ const y = margin.top + plotH + 16;
112
+ if (rotate) {
113
+ parts.push(`<text x="${x}" y="${y}" text-anchor="end" font-size="11" fill="#666" transform="rotate(-45 ${x} ${y})">${esc(labels[i])}</text>`);
114
+ } else {
115
+ parts.push(`<text x="${x}" y="${y}" text-anchor="middle" font-size="11" fill="#666">${esc(labels[i])}</text>`);
116
+ }
117
+ }
118
+
119
+ // Y-axis line
120
+ parts.push(`<line x1="${margin.left}" y1="${margin.top}" x2="${margin.left}" y2="${margin.top + plotH}" stroke="#9ca3af" stroke-width="1"/>`);
121
+
122
+ parts.push('</svg>');
123
+ return parts.join('\n');
124
+ }
125
+
126
+ // ── Line Chart ────────────────────────────────────────────
127
+ export function line_chart(data, opts) {
128
+ if (!opts) opts = {};
129
+ const rows = getRows(data);
130
+ const width = opts.width || 600;
131
+ const height = opts.height || 400;
132
+
133
+ if (rows.length === 0) return emptyChart(width, height, 'No data');
134
+
135
+ const xFn = opts.x;
136
+ const yFn = opts.y;
137
+ const title = opts.title || '';
138
+ const color = opts.color || PALETTE[0];
139
+ const showPoints = opts.points || false;
140
+ const margin = { ...DEFAULT_MARGIN };
141
+ if (title) margin.top = 50;
142
+
143
+ const xValues = rows.map(r => xFn(r));
144
+ const yValues = rows.map(r => Number(yFn(r)));
145
+
146
+ const plotW = width - margin.left - margin.right;
147
+ const plotH = height - margin.top - margin.bottom;
148
+
149
+ // Determine if x is numeric
150
+ const xNumeric = xValues.every(v => typeof v === 'number' && !isNaN(v));
151
+
152
+ let xPositions;
153
+ if (xNumeric) {
154
+ const xMin = Math.min(...xValues);
155
+ const xMax = Math.max(...xValues);
156
+ const xRange = xMax - xMin || 1;
157
+ xPositions = xValues.map(v => margin.left + ((v - xMin) / xRange) * plotW);
158
+ } else {
159
+ // Categorical: evenly spaced
160
+ const n = xValues.length;
161
+ xPositions = xValues.map((_, i) => margin.left + (n > 1 ? (i / (n - 1)) * plotW : plotW / 2));
162
+ }
163
+
164
+ const yMin = Math.min(...yValues);
165
+ const yMax = Math.max(...yValues);
166
+ const ticks = niceTicks(yMin > 0 ? 0 : yMin, yMax);
167
+ const scaleMin = ticks[0];
168
+ const scaleMax = ticks[ticks.length - 1];
169
+ const scaleRange = scaleMax - scaleMin || 1;
170
+
171
+ const yPositions = yValues.map(v => margin.top + plotH - ((v - scaleMin) / scaleRange) * plotH);
172
+
173
+ const parts = [];
174
+ parts.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" style="font-family:system-ui,sans-serif">`);
175
+
176
+ // Title
177
+ if (title) {
178
+ parts.push(`<text x="${width / 2}" y="24" text-anchor="middle" font-size="16" font-weight="bold" fill="#111">${esc(title)}</text>`);
179
+ }
180
+
181
+ // Y-axis gridlines and labels
182
+ for (let i = 0; i < ticks.length; i++) {
183
+ const t = ticks[i];
184
+ const y = margin.top + plotH - ((t - scaleMin) / scaleRange) * plotH;
185
+ parts.push(`<line x1="${margin.left}" y1="${y}" x2="${margin.left + plotW}" y2="${y}" stroke="#e5e7eb" stroke-width="1"/>`);
186
+ parts.push(`<text x="${margin.left - 8}" y="${y + 4}" text-anchor="end" font-size="11" fill="#666">${formatNum(t)}</text>`);
187
+ }
188
+
189
+ // Line
190
+ const points = xPositions.map((x, i) => `${x},${yPositions[i]}`).join(' ');
191
+ parts.push(`<polyline points="${points}" fill="none" stroke="${color}" stroke-width="2" stroke-linejoin="round"/>`);
192
+
193
+ // Points
194
+ if (showPoints) {
195
+ for (let i = 0; i < xPositions.length; i++) {
196
+ parts.push(`<circle cx="${xPositions[i]}" cy="${yPositions[i]}" r="4" fill="${color}"/>`);
197
+ }
198
+ }
199
+
200
+ // X-axis baseline
201
+ parts.push(`<line x1="${margin.left}" y1="${margin.top + plotH}" x2="${margin.left + plotW}" y2="${margin.top + plotH}" stroke="#9ca3af" stroke-width="1"/>`);
202
+
203
+ // X-axis labels
204
+ const labelStep = Math.max(1, Math.floor(xValues.length / 8));
205
+ for (let i = 0; i < xValues.length; i += labelStep) {
206
+ const x = xPositions[i];
207
+ const y = margin.top + plotH + 16;
208
+ parts.push(`<text x="${x}" y="${y}" text-anchor="middle" font-size="11" fill="#666">${esc(String(xValues[i]))}</text>`);
209
+ }
210
+
211
+ // Y-axis line
212
+ parts.push(`<line x1="${margin.left}" y1="${margin.top}" x2="${margin.left}" y2="${margin.top + plotH}" stroke="#9ca3af" stroke-width="1"/>`);
213
+
214
+ parts.push('</svg>');
215
+ return parts.join('\n');
216
+ }
217
+
218
+ // ── Scatter Chart ─────────────────────────────────────────
219
+ export function scatter_chart(data, opts) {
220
+ if (!opts) opts = {};
221
+ const rows = getRows(data);
222
+ const width = opts.width || 600;
223
+ const height = opts.height || 400;
224
+
225
+ if (rows.length === 0) return emptyChart(width, height, 'No data');
226
+
227
+ const xFn = opts.x;
228
+ const yFn = opts.y;
229
+ const title = opts.title || '';
230
+ const color = opts.color || PALETTE[0];
231
+ const radius = opts.r || 5;
232
+ const margin = { ...DEFAULT_MARGIN };
233
+ if (title) margin.top = 50;
234
+
235
+ const xValues = rows.map(r => Number(xFn(r)));
236
+ const yValues = rows.map(r => Number(yFn(r)));
237
+
238
+ const plotW = width - margin.left - margin.right;
239
+ const plotH = height - margin.top - margin.bottom;
240
+
241
+ const xTicks = niceTicks(Math.min(...xValues), Math.max(...xValues));
242
+ const yTicks = niceTicks(Math.min(...yValues), Math.max(...yValues));
243
+
244
+ const xMin = xTicks[0];
245
+ const xMax = xTicks[xTicks.length - 1];
246
+ const yMin = yTicks[0];
247
+ const yMax = yTicks[yTicks.length - 1];
248
+ const xRange = xMax - xMin || 1;
249
+ const yRange = yMax - yMin || 1;
250
+
251
+ const parts = [];
252
+ parts.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" style="font-family:system-ui,sans-serif">`);
253
+
254
+ // Title
255
+ if (title) {
256
+ parts.push(`<text x="${width / 2}" y="24" text-anchor="middle" font-size="16" font-weight="bold" fill="#111">${esc(title)}</text>`);
257
+ }
258
+
259
+ // Y-axis gridlines and labels
260
+ for (let i = 0; i < yTicks.length; i++) {
261
+ const t = yTicks[i];
262
+ const y = margin.top + plotH - ((t - yMin) / yRange) * plotH;
263
+ parts.push(`<line x1="${margin.left}" y1="${y}" x2="${margin.left + plotW}" y2="${y}" stroke="#e5e7eb" stroke-width="1"/>`);
264
+ parts.push(`<text x="${margin.left - 8}" y="${y + 4}" text-anchor="end" font-size="11" fill="#666">${formatNum(t)}</text>`);
265
+ }
266
+
267
+ // X-axis gridlines and labels
268
+ for (let i = 0; i < xTicks.length; i++) {
269
+ const t = xTicks[i];
270
+ const x = margin.left + ((t - xMin) / xRange) * plotW;
271
+ parts.push(`<line x1="${x}" y1="${margin.top}" x2="${x}" y2="${margin.top + plotH}" stroke="#e5e7eb" stroke-width="1"/>`);
272
+ parts.push(`<text x="${x}" y="${margin.top + plotH + 16}" text-anchor="middle" font-size="11" fill="#666">${formatNum(t)}</text>`);
273
+ }
274
+
275
+ // Points
276
+ for (let i = 0; i < rows.length; i++) {
277
+ const cx = margin.left + ((xValues[i] - xMin) / xRange) * plotW;
278
+ const cy = margin.top + plotH - ((yValues[i] - yMin) / yRange) * plotH;
279
+ const c = Array.isArray(color) ? color[i % color.length] : color;
280
+ parts.push(`<circle cx="${cx}" cy="${cy}" r="${radius}" fill="${c}" opacity="0.7"/>`);
281
+ }
282
+
283
+ // Axes
284
+ parts.push(`<line x1="${margin.left}" y1="${margin.top + plotH}" x2="${margin.left + plotW}" y2="${margin.top + plotH}" stroke="#9ca3af" stroke-width="1"/>`);
285
+ parts.push(`<line x1="${margin.left}" y1="${margin.top}" x2="${margin.left}" y2="${margin.top + plotH}" stroke="#9ca3af" stroke-width="1"/>`);
286
+
287
+ parts.push('</svg>');
288
+ return parts.join('\n');
289
+ }
290
+
291
+ // ── Histogram ─────────────────────────────────────────────
292
+ export function histogram(data, opts) {
293
+ if (!opts) opts = {};
294
+ const rows = getRows(data);
295
+ const width = opts.width || 600;
296
+ const height = opts.height || 400;
297
+
298
+ if (rows.length === 0) return emptyChart(width, height, 'No data');
299
+
300
+ const colFn = opts.col;
301
+ const title = opts.title || '';
302
+ const color = opts.color || PALETTE[0];
303
+ const numBins = opts.bins || 20;
304
+ const margin = { ...DEFAULT_MARGIN };
305
+ if (title) margin.top = 50;
306
+
307
+ const values = rows.map(r => Number(colFn(r))).filter(v => !isNaN(v));
308
+ if (values.length === 0) return emptyChart(width, height, 'No data');
309
+
310
+ const dataMin = Math.min(...values);
311
+ const dataMax = Math.max(...values);
312
+ const binWidth = (dataMax - dataMin) / numBins || 1;
313
+
314
+ // Build bins
315
+ const bins = [];
316
+ for (let i = 0; i < numBins; i++) {
317
+ bins.push({ lo: dataMin + i * binWidth, hi: dataMin + (i + 1) * binWidth, count: 0 });
318
+ }
319
+
320
+ // Count values into bins
321
+ for (let i = 0; i < values.length; i++) {
322
+ let idx = Math.floor((values[i] - dataMin) / binWidth);
323
+ if (idx >= numBins) idx = numBins - 1;
324
+ if (idx < 0) idx = 0;
325
+ bins[idx].count++;
326
+ }
327
+
328
+ const maxCount = Math.max(...bins.map(b => b.count));
329
+ const ticks = niceTicks(0, maxCount);
330
+ const scaleMax = ticks[ticks.length - 1];
331
+
332
+ const plotW = width - margin.left - margin.right;
333
+ const plotH = height - margin.top - margin.bottom;
334
+ const barW = plotW / numBins;
335
+
336
+ const parts = [];
337
+ parts.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" style="font-family:system-ui,sans-serif">`);
338
+
339
+ // Title
340
+ if (title) {
341
+ parts.push(`<text x="${width / 2}" y="24" text-anchor="middle" font-size="16" font-weight="bold" fill="#111">${esc(title)}</text>`);
342
+ }
343
+
344
+ // Y-axis gridlines and labels
345
+ for (let i = 0; i < ticks.length; i++) {
346
+ const t = ticks[i];
347
+ const y = margin.top + plotH - (scaleMax > 0 ? (t / scaleMax) * plotH : 0);
348
+ parts.push(`<line x1="${margin.left}" y1="${y}" x2="${margin.left + plotW}" y2="${y}" stroke="#e5e7eb" stroke-width="1"/>`);
349
+ parts.push(`<text x="${margin.left - 8}" y="${y + 4}" text-anchor="end" font-size="11" fill="#666">${formatNum(t)}</text>`);
350
+ }
351
+
352
+ // Bars
353
+ for (let i = 0; i < bins.length; i++) {
354
+ const barH = scaleMax > 0 ? (bins[i].count / scaleMax) * plotH : 0;
355
+ const x = margin.left + i * barW;
356
+ const y = margin.top + plotH - barH;
357
+ parts.push(`<rect x="${x}" y="${y}" width="${barW}" height="${barH}" fill="${color}" stroke="#fff" stroke-width="0.5"/>`);
358
+ }
359
+
360
+ // X-axis baseline
361
+ parts.push(`<line x1="${margin.left}" y1="${margin.top + plotH}" x2="${margin.left + plotW}" y2="${margin.top + plotH}" stroke="#9ca3af" stroke-width="1"/>`);
362
+
363
+ // X-axis labels (show a subset)
364
+ const labelCount = Math.min(numBins + 1, 8);
365
+ const labelStep = Math.max(1, Math.floor(numBins / (labelCount - 1)));
366
+ for (let i = 0; i <= numBins; i += labelStep) {
367
+ const val = dataMin + i * binWidth;
368
+ const x = margin.left + i * barW;
369
+ const y = margin.top + plotH + 16;
370
+ parts.push(`<text x="${x}" y="${y}" text-anchor="middle" font-size="10" fill="#666">${formatNum(val)}</text>`);
371
+ }
372
+
373
+ // Y-axis line
374
+ parts.push(`<line x1="${margin.left}" y1="${margin.top}" x2="${margin.left}" y2="${margin.top + plotH}" stroke="#9ca3af" stroke-width="1"/>`);
375
+
376
+ parts.push('</svg>');
377
+ return parts.join('\n');
378
+ }
379
+
380
+ // ── Pie Chart ─────────────────────────────────────────────
381
+ export function pie_chart(data, opts) {
382
+ if (!opts) opts = {};
383
+ const rows = getRows(data);
384
+ const width = opts.width || 400;
385
+ const height = opts.height || 400;
386
+
387
+ if (rows.length === 0) return emptyChart(width, height, 'No data');
388
+
389
+ const labelFn = opts.label;
390
+ const valueFn = opts.value;
391
+ const title = opts.title || '';
392
+ const colors = opts.colors || PALETTE;
393
+
394
+ const labels = rows.map(r => String(labelFn(r)));
395
+ const values = rows.map(r => Number(valueFn(r)));
396
+ const total = values.reduce((a, b) => a + b, 0);
397
+
398
+ if (total === 0) return emptyChart(width, height, 'No data');
399
+
400
+ const cx = width / 2;
401
+ const cy = title ? (height + 30) / 2 : height / 2;
402
+ const r = Math.min(cx, cy) - 50;
403
+
404
+ const parts = [];
405
+ parts.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" style="font-family:system-ui,sans-serif">`);
406
+
407
+ // Title
408
+ if (title) {
409
+ parts.push(`<text x="${width / 2}" y="24" text-anchor="middle" font-size="16" font-weight="bold" fill="#111">${esc(title)}</text>`);
410
+ }
411
+
412
+ let startAngle = -Math.PI / 2; // Start from top
413
+
414
+ for (let i = 0; i < values.length; i++) {
415
+ const sliceAngle = (values[i] / total) * 2 * Math.PI;
416
+ const endAngle = startAngle + sliceAngle;
417
+
418
+ const x1 = cx + r * Math.cos(startAngle);
419
+ const y1 = cy + r * Math.sin(startAngle);
420
+ const x2 = cx + r * Math.cos(endAngle);
421
+ const y2 = cy + r * Math.sin(endAngle);
422
+
423
+ const largeArc = sliceAngle > Math.PI ? 1 : 0;
424
+ const c = colors[i % colors.length];
425
+
426
+ // Handle full circle (single slice)
427
+ if (values.length === 1) {
428
+ parts.push(`<circle cx="${cx}" cy="${cy}" r="${r}" fill="${c}"/>`);
429
+ } else {
430
+ parts.push(`<path d="M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z" fill="${c}" stroke="#fff" stroke-width="1.5"/>`);
431
+ }
432
+
433
+ // Label at midpoint of arc
434
+ const midAngle = startAngle + sliceAngle / 2;
435
+ const labelR = r * 0.7;
436
+ const lx = cx + labelR * Math.cos(midAngle);
437
+ const ly = cy + labelR * Math.sin(midAngle);
438
+ const pct = ((values[i] / total) * 100).toFixed(1);
439
+ parts.push(`<text x="${lx}" y="${ly}" text-anchor="middle" font-size="11" fill="#fff" font-weight="bold">${esc(labels[i])}</text>`);
440
+ parts.push(`<text x="${lx}" y="${ly + 13}" text-anchor="middle" font-size="10" fill="#fff">${pct}%</text>`);
441
+
442
+ startAngle = endAngle;
443
+ }
444
+
445
+ parts.push('</svg>');
446
+ return parts.join('\n');
447
+ }
448
+
449
+ // ── Heatmap ───────────────────────────────────────────────
450
+ export function heatmap(data, opts) {
451
+ if (!opts) opts = {};
452
+ const rows = getRows(data);
453
+ const width = opts.width || 600;
454
+ const height = opts.height || 400;
455
+
456
+ if (rows.length === 0) return emptyChart(width, height, 'No data');
457
+
458
+ const xFn = opts.x;
459
+ const yFn = opts.y;
460
+ const valueFn = opts.value;
461
+ const title = opts.title || '';
462
+ const margin = { top: title ? 50 : 40, right: 40, bottom: 60, left: 80 };
463
+
464
+ // Extract unique x and y categories
465
+ const xCats = [];
466
+ const yCats = [];
467
+ const xSet = new Set();
468
+ const ySet = new Set();
469
+
470
+ for (let i = 0; i < rows.length; i++) {
471
+ const xv = String(xFn(rows[i]));
472
+ const yv = String(yFn(rows[i]));
473
+ if (!xSet.has(xv)) { xSet.add(xv); xCats.push(xv); }
474
+ if (!ySet.has(yv)) { ySet.add(yv); yCats.push(yv); }
475
+ }
476
+
477
+ // Build value grid
478
+ const grid = {};
479
+ let vMin = Infinity;
480
+ let vMax = -Infinity;
481
+ for (let i = 0; i < rows.length; i++) {
482
+ const xv = String(xFn(rows[i]));
483
+ const yv = String(yFn(rows[i]));
484
+ const val = Number(valueFn(rows[i]));
485
+ grid[xv + '|' + yv] = val;
486
+ if (val < vMin) vMin = val;
487
+ if (val > vMax) vMax = val;
488
+ }
489
+
490
+ const vRange = vMax - vMin || 1;
491
+ const plotW = width - margin.left - margin.right;
492
+ const plotH = height - margin.top - margin.bottom;
493
+ const cellW = plotW / xCats.length;
494
+ const cellH = plotH / yCats.length;
495
+
496
+ // Color interpolation: white (low) to indigo (high)
497
+ function heatColor(val) {
498
+ const t = (val - vMin) / vRange;
499
+ const r = Math.round(255 - t * (255 - 79));
500
+ const g = Math.round(255 - t * (255 - 70));
501
+ const b = Math.round(255 - t * (255 - 229));
502
+ return `rgb(${r},${g},${b})`;
503
+ }
504
+
505
+ const parts = [];
506
+ parts.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" style="font-family:system-ui,sans-serif">`);
507
+
508
+ // Title
509
+ if (title) {
510
+ parts.push(`<text x="${width / 2}" y="24" text-anchor="middle" font-size="16" font-weight="bold" fill="#111">${esc(title)}</text>`);
511
+ }
512
+
513
+ // Cells
514
+ for (let xi = 0; xi < xCats.length; xi++) {
515
+ for (let yi = 0; yi < yCats.length; yi++) {
516
+ const key = xCats[xi] + '|' + yCats[yi];
517
+ const val = grid[key];
518
+ const x = margin.left + xi * cellW;
519
+ const y = margin.top + yi * cellH;
520
+ const fill = val !== undefined ? heatColor(val) : '#f3f4f6';
521
+ parts.push(`<rect x="${x}" y="${y}" width="${cellW}" height="${cellH}" fill="${fill}" stroke="#fff" stroke-width="1"/>`);
522
+
523
+ // Value text in cell
524
+ if (val !== undefined) {
525
+ const textColor = ((val - vMin) / vRange) > 0.5 ? '#fff' : '#111';
526
+ parts.push(`<text x="${x + cellW / 2}" y="${y + cellH / 2 + 4}" text-anchor="middle" font-size="11" fill="${textColor}">${formatNum(val)}</text>`);
527
+ }
528
+ }
529
+ }
530
+
531
+ // X-axis labels (bottom)
532
+ for (let xi = 0; xi < xCats.length; xi++) {
533
+ const x = margin.left + xi * cellW + cellW / 2;
534
+ const y = margin.top + plotH + 16;
535
+ parts.push(`<text x="${x}" y="${y}" text-anchor="middle" font-size="11" fill="#666">${esc(xCats[xi])}</text>`);
536
+ }
537
+
538
+ // Y-axis labels (left)
539
+ for (let yi = 0; yi < yCats.length; yi++) {
540
+ const x = margin.left - 8;
541
+ const y = margin.top + yi * cellH + cellH / 2 + 4;
542
+ parts.push(`<text x="${x}" y="${y}" text-anchor="end" font-size="11" fill="#666">${esc(yCats[yi])}</text>`);
543
+ }
544
+
545
+ parts.push('</svg>');
546
+ return parts.join('\n');
547
+ }