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.
- package/bin/tova.js +1312 -139
- package/package.json +8 -1
- package/src/analyzer/analyzer.js +539 -11
- package/src/analyzer/browser-analyzer.js +56 -8
- package/src/analyzer/deploy-analyzer.js +44 -0
- package/src/analyzer/scope.js +7 -0
- package/src/analyzer/server-analyzer.js +33 -1
- package/src/codegen/base-codegen.js +1296 -23
- package/src/codegen/browser-codegen.js +725 -20
- package/src/codegen/codegen.js +87 -5
- package/src/codegen/deploy-codegen.js +49 -0
- package/src/codegen/server-codegen.js +54 -6
- package/src/codegen/shared-codegen.js +5 -0
- package/src/codegen/theme-codegen.js +69 -0
- package/src/codegen/wasm-codegen.js +6 -0
- package/src/config/edit-toml.js +6 -2
- package/src/config/git-resolver.js +128 -0
- package/src/config/lock-file.js +57 -0
- package/src/config/module-cache.js +58 -0
- package/src/config/module-entry.js +37 -0
- package/src/config/module-path.js +63 -0
- package/src/config/pkg-errors.js +62 -0
- package/src/config/resolve.js +26 -0
- package/src/config/resolver.js +139 -0
- package/src/config/search.js +28 -0
- package/src/config/semver.js +72 -0
- package/src/config/toml.js +61 -6
- package/src/deploy/deploy.js +217 -0
- package/src/deploy/infer.js +218 -0
- package/src/deploy/provision.js +315 -0
- package/src/diagnostics/security-scorecard.js +111 -0
- package/src/lexer/lexer.js +18 -3
- package/src/lsp/server.js +482 -0
- package/src/parser/animate-ast.js +45 -0
- package/src/parser/ast.js +39 -0
- package/src/parser/browser-ast.js +19 -1
- package/src/parser/browser-parser.js +221 -4
- package/src/parser/concurrency-ast.js +15 -0
- package/src/parser/concurrency-parser.js +236 -0
- package/src/parser/deploy-ast.js +37 -0
- package/src/parser/deploy-parser.js +132 -0
- package/src/parser/parser.js +42 -5
- package/src/parser/select-ast.js +39 -0
- package/src/parser/theme-ast.js +29 -0
- package/src/parser/theme-parser.js +70 -0
- package/src/registry/plugins/concurrency-plugin.js +32 -0
- package/src/registry/plugins/deploy-plugin.js +33 -0
- package/src/registry/plugins/theme-plugin.js +20 -0
- package/src/registry/register-all.js +6 -0
- package/src/runtime/charts.js +547 -0
- package/src/runtime/embedded.js +6 -2
- package/src/runtime/reactivity.js +60 -0
- package/src/runtime/router.js +703 -295
- package/src/runtime/table.js +606 -33
- package/src/stdlib/inline.js +365 -10
- package/src/stdlib/runtime-bridge.js +152 -0
- package/src/stdlib/string.js +84 -2
- package/src/stdlib/validation.js +1 -1
- 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
+
}
|