@plainviz/render-svg 0.1.0 → 0.2.0
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/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/render.d.ts +4 -0
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +288 -19
- package/package.json +2 -2
- package/src/index.ts +2 -2
- package/src/render.ts +357 -20
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { render, renderBarChart } from './render';
|
|
2
|
-
export type { RenderOptions } from './render';
|
|
1
|
+
export { render, renderBarChart } from './render.js';
|
|
2
|
+
export type { RenderOptions } from './render.js';
|
|
3
3
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AACrD,YAAY,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { render, renderBarChart } from './render';
|
|
1
|
+
export { render, renderBarChart } from './render.js';
|
package/dist/render.d.ts
CHANGED
|
@@ -13,5 +13,9 @@ export interface RenderOptions {
|
|
|
13
13
|
gridColor?: string;
|
|
14
14
|
}
|
|
15
15
|
export declare function renderBarChart(ir: PlainVizIR, opts?: RenderOptions): string;
|
|
16
|
+
export declare function renderLineChart(ir: PlainVizIR, opts?: RenderOptions): string;
|
|
17
|
+
export declare function renderPieChart(ir: PlainVizIR, opts?: RenderOptions): string;
|
|
18
|
+
export declare function renderDonutChart(ir: PlainVizIR, opts?: RenderOptions): string;
|
|
19
|
+
export declare function renderAreaChart(ir: PlainVizIR, opts?: RenderOptions): string;
|
|
16
20
|
export declare function render(ir: PlainVizIR, opts?: RenderOptions): string;
|
|
17
21
|
//# sourceMappingURL=render.d.ts.map
|
package/dist/render.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEjD,MAAM,WAAW,aAAa;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AA6BD,wBAAgB,cAAc,CAAC,EAAE,EAAE,UAAU,EAAE,IAAI,GAAE,aAAkB,GAAG,MAAM,
|
|
1
|
+
{"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEjD,MAAM,WAAW,aAAa;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AA6BD,wBAAgB,cAAc,CAAC,EAAE,EAAE,UAAU,EAAE,IAAI,GAAE,aAAkB,GAAG,MAAM,CAqG/E;AAED,wBAAgB,eAAe,CAAC,EAAE,EAAE,UAAU,EAAE,IAAI,GAAE,aAAkB,GAAG,MAAM,CA2GhF;AAED,wBAAgB,cAAc,CAAC,EAAE,EAAE,UAAU,EAAE,IAAI,GAAE,aAAkB,GAAG,MAAM,CAqD/E;AAED,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,UAAU,EAAE,IAAI,GAAE,aAAkB,GAAG,MAAM,CA6DjF;AAED,wBAAgB,eAAe,CAAC,EAAE,EAAE,UAAU,EAAE,IAAI,GAAE,aAAkB,GAAG,MAAM,CAgEhF;AAED,wBAAgB,MAAM,CAAC,EAAE,EAAE,UAAU,EAAE,IAAI,GAAE,aAAkB,GAAG,MAAM,CAevE"}
|
package/dist/render.js
CHANGED
|
@@ -27,14 +27,273 @@ function escapeXml(str) {
|
|
|
27
27
|
.replace(/"/g, '"');
|
|
28
28
|
}
|
|
29
29
|
export function renderBarChart(ir, opts = {}) {
|
|
30
|
+
const options = { ...DEFAULT_OPTIONS, ...opts };
|
|
31
|
+
const { width, height, padding, backgroundColor, textColor, gridColor } = options;
|
|
32
|
+
const colors = ir.meta?.colors || options.colors;
|
|
33
|
+
const isMultiSeries = ir.series && ir.series.length > 1;
|
|
34
|
+
const seriesCount = isMultiSeries ? ir.series.length : 1;
|
|
35
|
+
const legendHeight = isMultiSeries ? 25 : 0;
|
|
36
|
+
const chartWidth = width - padding * 2;
|
|
37
|
+
const chartHeight = height - padding * 2 - 30 - legendHeight;
|
|
38
|
+
// Calculate max value across all series
|
|
39
|
+
let maxValue;
|
|
40
|
+
if (isMultiSeries) {
|
|
41
|
+
maxValue = Math.max(...ir.series.flatMap(s => s.values));
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
maxValue = Math.max(...ir.values);
|
|
45
|
+
}
|
|
46
|
+
const labelCount = ir.labels.length;
|
|
47
|
+
const groupWidth = chartWidth / labelCount;
|
|
48
|
+
const barWidth = (groupWidth * 0.7) / seriesCount;
|
|
49
|
+
const groupPadding = groupWidth * 0.15;
|
|
50
|
+
const lines = [];
|
|
51
|
+
// SVG header
|
|
52
|
+
lines.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" style="background-color: ${backgroundColor};">`);
|
|
53
|
+
// Title
|
|
54
|
+
if (ir.title) {
|
|
55
|
+
lines.push(` <text x="${width / 2}" y="25" text-anchor="middle" font-family="system-ui, sans-serif" font-size="16" font-weight="bold" fill="${textColor}">${escapeXml(ir.title)}</text>`);
|
|
56
|
+
}
|
|
57
|
+
// Legend for multi-series
|
|
58
|
+
if (isMultiSeries) {
|
|
59
|
+
const legendY = 45;
|
|
60
|
+
const legendItemWidth = 80;
|
|
61
|
+
const legendStartX = (width - ir.series.length * legendItemWidth) / 2;
|
|
62
|
+
ir.series.forEach((series, i) => {
|
|
63
|
+
const x = legendStartX + i * legendItemWidth;
|
|
64
|
+
const color = series.color || colors[i % colors.length];
|
|
65
|
+
lines.push(` <rect x="${x}" y="${legendY - 8}" width="12" height="12" rx="2" fill="${color}"/>`);
|
|
66
|
+
lines.push(` <text x="${x + 16}" y="${legendY}" font-family="system-ui, sans-serif" font-size="11" fill="${textColor}">${escapeXml(series.name)}</text>`);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const chartTop = padding + legendHeight;
|
|
70
|
+
const chartBottom = height - padding;
|
|
71
|
+
// Y-axis
|
|
72
|
+
lines.push(` <line x1="${padding}" y1="${chartTop}" x2="${padding}" y2="${chartBottom}" stroke="${gridColor}" stroke-width="1"/>`);
|
|
73
|
+
// X-axis
|
|
74
|
+
lines.push(` <line x1="${padding}" y1="${chartBottom}" x2="${width - padding}" y2="${chartBottom}" stroke="${gridColor}" stroke-width="1"/>`);
|
|
75
|
+
// Grid lines
|
|
76
|
+
for (let i = 1; i <= 4; i++) {
|
|
77
|
+
const y = chartBottom - (chartHeight / 4) * i;
|
|
78
|
+
lines.push(` <line x1="${padding}" y1="${y}" x2="${width - padding}" y2="${y}" stroke="${gridColor}" stroke-width="1" stroke-dasharray="3,3"/>`);
|
|
79
|
+
const label = Math.round((maxValue / 4) * i);
|
|
80
|
+
lines.push(` <text x="${padding - 8}" y="${y + 4}" text-anchor="end" font-family="system-ui, sans-serif" font-size="11" fill="#6c7086">${label}</text>`);
|
|
81
|
+
}
|
|
82
|
+
// Draw bars
|
|
83
|
+
ir.labels.forEach((label, labelIndex) => {
|
|
84
|
+
const groupX = padding + groupWidth * labelIndex + groupPadding;
|
|
85
|
+
if (isMultiSeries) {
|
|
86
|
+
// Multi-series: grouped bars
|
|
87
|
+
ir.series.forEach((series, seriesIndex) => {
|
|
88
|
+
const value = series.values[labelIndex] ?? 0;
|
|
89
|
+
const barHeight = (value / maxValue) * chartHeight;
|
|
90
|
+
const x = groupX + seriesIndex * barWidth;
|
|
91
|
+
const y = chartBottom - barHeight;
|
|
92
|
+
const color = series.color || colors[seriesIndex % colors.length];
|
|
93
|
+
lines.push(` <rect x="${x}" y="${y}" width="${barWidth - 2}" height="${barHeight}" rx="2" fill="${color}"/>`);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
// Single series
|
|
98
|
+
const value = ir.values[labelIndex];
|
|
99
|
+
const barHeight = (value / maxValue) * chartHeight;
|
|
100
|
+
const x = groupX;
|
|
101
|
+
const y = chartBottom - barHeight;
|
|
102
|
+
const color = colors[labelIndex % colors.length];
|
|
103
|
+
lines.push(` <rect x="${x}" y="${y}" width="${groupWidth * 0.7}" height="${barHeight}" rx="4" fill="${color}"/>`);
|
|
104
|
+
lines.push(` <text x="${x + groupWidth * 0.35}" y="${y - 8}" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="${textColor}">${value}</text>`);
|
|
105
|
+
}
|
|
106
|
+
// X-axis label
|
|
107
|
+
const labelX = groupX + (groupWidth * 0.7) / 2;
|
|
108
|
+
lines.push(` <text x="${labelX}" y="${chartBottom + 18}" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#6c7086">${escapeXml(label)}</text>`);
|
|
109
|
+
});
|
|
110
|
+
lines.push('</svg>');
|
|
111
|
+
return lines.join('\n');
|
|
112
|
+
}
|
|
113
|
+
export function renderLineChart(ir, opts = {}) {
|
|
114
|
+
const options = { ...DEFAULT_OPTIONS, ...opts };
|
|
115
|
+
const { width, height, padding, backgroundColor, textColor, gridColor } = options;
|
|
116
|
+
const colors = ir.meta?.colors || options.colors;
|
|
117
|
+
const isMultiSeries = ir.series && ir.series.length > 1;
|
|
118
|
+
const legendHeight = isMultiSeries ? 25 : 0;
|
|
119
|
+
const chartWidth = width - padding * 2;
|
|
120
|
+
const chartHeight = height - padding * 2 - 30 - legendHeight;
|
|
121
|
+
const pointCount = ir.labels.length;
|
|
122
|
+
// Calculate max value across all series
|
|
123
|
+
let maxValue;
|
|
124
|
+
if (isMultiSeries) {
|
|
125
|
+
maxValue = Math.max(...ir.series.flatMap(s => s.values));
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
maxValue = Math.max(...ir.values);
|
|
129
|
+
}
|
|
130
|
+
const lines = [];
|
|
131
|
+
// SVG header
|
|
132
|
+
lines.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" style="background-color: ${backgroundColor};">`);
|
|
133
|
+
// Title
|
|
134
|
+
if (ir.title) {
|
|
135
|
+
lines.push(` <text x="${width / 2}" y="25" text-anchor="middle" font-family="system-ui, sans-serif" font-size="16" font-weight="bold" fill="${textColor}">${escapeXml(ir.title)}</text>`);
|
|
136
|
+
}
|
|
137
|
+
// Legend for multi-series
|
|
138
|
+
if (isMultiSeries) {
|
|
139
|
+
const legendY = 45;
|
|
140
|
+
const legendItemWidth = 80;
|
|
141
|
+
const legendStartX = (width - ir.series.length * legendItemWidth) / 2;
|
|
142
|
+
ir.series.forEach((series, i) => {
|
|
143
|
+
const x = legendStartX + i * legendItemWidth;
|
|
144
|
+
const color = series.color || colors[i % colors.length];
|
|
145
|
+
lines.push(` <rect x="${x}" y="${legendY - 8}" width="12" height="12" rx="2" fill="${color}"/>`);
|
|
146
|
+
lines.push(` <text x="${x + 16}" y="${legendY}" font-family="system-ui, sans-serif" font-size="11" fill="${textColor}">${escapeXml(series.name)}</text>`);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
const chartTop = padding + legendHeight;
|
|
150
|
+
const chartBottom = height - padding;
|
|
151
|
+
// Y-axis
|
|
152
|
+
lines.push(` <line x1="${padding}" y1="${chartTop}" x2="${padding}" y2="${chartBottom}" stroke="${gridColor}" stroke-width="1"/>`);
|
|
153
|
+
// X-axis
|
|
154
|
+
lines.push(` <line x1="${padding}" y1="${chartBottom}" x2="${width - padding}" y2="${chartBottom}" stroke="${gridColor}" stroke-width="1"/>`);
|
|
155
|
+
// Grid lines
|
|
156
|
+
for (let i = 1; i <= 4; i++) {
|
|
157
|
+
const y = chartBottom - (chartHeight / 4) * i;
|
|
158
|
+
lines.push(` <line x1="${padding}" y1="${y}" x2="${width - padding}" y2="${y}" stroke="${gridColor}" stroke-width="1" stroke-dasharray="3,3"/>`);
|
|
159
|
+
const label = Math.round((maxValue / 4) * i);
|
|
160
|
+
lines.push(` <text x="${padding - 8}" y="${y + 4}" text-anchor="end" font-family="system-ui, sans-serif" font-size="11" fill="#6c7086">${label}</text>`);
|
|
161
|
+
}
|
|
162
|
+
if (isMultiSeries) {
|
|
163
|
+
// Draw multiple lines
|
|
164
|
+
ir.series.forEach((series, seriesIndex) => {
|
|
165
|
+
const color = series.color || colors[seriesIndex % colors.length];
|
|
166
|
+
const points = series.values.map((value, i) => ({
|
|
167
|
+
x: padding + (chartWidth / (pointCount - 1 || 1)) * i,
|
|
168
|
+
y: chartBottom - (value / maxValue) * chartHeight,
|
|
169
|
+
}));
|
|
170
|
+
// Draw line path
|
|
171
|
+
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ');
|
|
172
|
+
lines.push(` <path d="${pathD}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`);
|
|
173
|
+
// Draw points
|
|
174
|
+
points.forEach((p) => {
|
|
175
|
+
lines.push(` <circle cx="${p.x}" cy="${p.y}" r="4" fill="${color}" stroke="${backgroundColor}" stroke-width="2"/>`);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
// X-axis labels (only once)
|
|
179
|
+
ir.labels.forEach((label, i) => {
|
|
180
|
+
const x = padding + (chartWidth / (pointCount - 1 || 1)) * i;
|
|
181
|
+
lines.push(` <text x="${x}" y="${chartBottom + 18}" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#6c7086">${escapeXml(label)}</text>`);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
// Single series
|
|
186
|
+
const points = ir.values.map((value, i) => ({
|
|
187
|
+
x: padding + (chartWidth / (pointCount - 1 || 1)) * i,
|
|
188
|
+
y: chartBottom - (value / maxValue) * chartHeight,
|
|
189
|
+
}));
|
|
190
|
+
// Draw line path
|
|
191
|
+
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ');
|
|
192
|
+
lines.push(` <path d="${pathD}" fill="none" stroke="${colors[0]}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>`);
|
|
193
|
+
// Draw points and labels
|
|
194
|
+
points.forEach((p, i) => {
|
|
195
|
+
lines.push(` <circle cx="${p.x}" cy="${p.y}" r="5" fill="${colors[0]}" stroke="${backgroundColor}" stroke-width="2"/>`);
|
|
196
|
+
lines.push(` <text x="${p.x}" y="${p.y - 12}" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="${textColor}">${ir.values[i]}</text>`);
|
|
197
|
+
lines.push(` <text x="${p.x}" y="${chartBottom + 18}" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#6c7086">${escapeXml(ir.labels[i])}</text>`);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
lines.push('</svg>');
|
|
201
|
+
return lines.join('\n');
|
|
202
|
+
}
|
|
203
|
+
export function renderPieChart(ir, opts = {}) {
|
|
204
|
+
const options = { ...DEFAULT_OPTIONS, ...opts };
|
|
205
|
+
const { width, height, colors, backgroundColor, textColor } = options;
|
|
206
|
+
const centerX = width / 2;
|
|
207
|
+
const centerY = height / 2 + 10;
|
|
208
|
+
const radius = Math.min(width, height) / 2 - 60;
|
|
209
|
+
const total = ir.values.reduce((a, b) => a + b, 0);
|
|
210
|
+
const lines = [];
|
|
211
|
+
// SVG header
|
|
212
|
+
lines.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" style="background-color: ${backgroundColor};">`);
|
|
213
|
+
// Title
|
|
214
|
+
if (ir.title) {
|
|
215
|
+
lines.push(` <text x="${width / 2}" y="25" text-anchor="middle" font-family="system-ui, sans-serif" font-size="16" font-weight="bold" fill="${textColor}">${escapeXml(ir.title)}</text>`);
|
|
216
|
+
}
|
|
217
|
+
// Draw pie slices
|
|
218
|
+
let currentAngle = -Math.PI / 2; // Start from top
|
|
219
|
+
ir.values.forEach((value, i) => {
|
|
220
|
+
const sliceAngle = (value / total) * 2 * Math.PI;
|
|
221
|
+
const endAngle = currentAngle + sliceAngle;
|
|
222
|
+
const x1 = centerX + radius * Math.cos(currentAngle);
|
|
223
|
+
const y1 = centerY + radius * Math.sin(currentAngle);
|
|
224
|
+
const x2 = centerX + radius * Math.cos(endAngle);
|
|
225
|
+
const y2 = centerY + radius * Math.sin(endAngle);
|
|
226
|
+
const largeArc = sliceAngle > Math.PI ? 1 : 0;
|
|
227
|
+
const color = colors[i % colors.length];
|
|
228
|
+
// Slice path
|
|
229
|
+
const pathD = `M ${centerX} ${centerY} L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} Z`;
|
|
230
|
+
lines.push(` <path d="${pathD}" fill="${color}" stroke="${backgroundColor}" stroke-width="2"/>`);
|
|
231
|
+
// Label position (middle of slice, outside)
|
|
232
|
+
const labelAngle = currentAngle + sliceAngle / 2;
|
|
233
|
+
const labelRadius = radius + 25;
|
|
234
|
+
const labelX = centerX + labelRadius * Math.cos(labelAngle);
|
|
235
|
+
const labelY = centerY + labelRadius * Math.sin(labelAngle);
|
|
236
|
+
const percent = Math.round((value / total) * 100);
|
|
237
|
+
lines.push(` <text x="${labelX}" y="${labelY}" text-anchor="middle" dominant-baseline="middle" font-family="system-ui, sans-serif" font-size="11" fill="${textColor}">${escapeXml(ir.labels[i])}: ${percent}%</text>`);
|
|
238
|
+
currentAngle = endAngle;
|
|
239
|
+
});
|
|
240
|
+
lines.push('</svg>');
|
|
241
|
+
return lines.join('\n');
|
|
242
|
+
}
|
|
243
|
+
export function renderDonutChart(ir, opts = {}) {
|
|
244
|
+
const options = { ...DEFAULT_OPTIONS, ...opts };
|
|
245
|
+
const { width, height, colors, backgroundColor, textColor } = options;
|
|
246
|
+
const centerX = width / 2;
|
|
247
|
+
const centerY = height / 2 + 10;
|
|
248
|
+
const outerRadius = Math.min(width, height) / 2 - 60;
|
|
249
|
+
const innerRadius = outerRadius * 0.6; // Donut hole
|
|
250
|
+
const total = ir.values.reduce((a, b) => a + b, 0);
|
|
251
|
+
const lines = [];
|
|
252
|
+
// SVG header
|
|
253
|
+
lines.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" style="background-color: ${backgroundColor};">`);
|
|
254
|
+
// Title
|
|
255
|
+
if (ir.title) {
|
|
256
|
+
lines.push(` <text x="${width / 2}" y="25" text-anchor="middle" font-family="system-ui, sans-serif" font-size="16" font-weight="bold" fill="${textColor}">${escapeXml(ir.title)}</text>`);
|
|
257
|
+
}
|
|
258
|
+
// Draw donut slices
|
|
259
|
+
let currentAngle = -Math.PI / 2; // Start from top
|
|
260
|
+
ir.values.forEach((value, i) => {
|
|
261
|
+
const sliceAngle = (value / total) * 2 * Math.PI;
|
|
262
|
+
const endAngle = currentAngle + sliceAngle;
|
|
263
|
+
// Outer arc points
|
|
264
|
+
const ox1 = centerX + outerRadius * Math.cos(currentAngle);
|
|
265
|
+
const oy1 = centerY + outerRadius * Math.sin(currentAngle);
|
|
266
|
+
const ox2 = centerX + outerRadius * Math.cos(endAngle);
|
|
267
|
+
const oy2 = centerY + outerRadius * Math.sin(endAngle);
|
|
268
|
+
// Inner arc points
|
|
269
|
+
const ix1 = centerX + innerRadius * Math.cos(currentAngle);
|
|
270
|
+
const iy1 = centerY + innerRadius * Math.sin(currentAngle);
|
|
271
|
+
const ix2 = centerX + innerRadius * Math.cos(endAngle);
|
|
272
|
+
const iy2 = centerY + innerRadius * Math.sin(endAngle);
|
|
273
|
+
const largeArc = sliceAngle > Math.PI ? 1 : 0;
|
|
274
|
+
const color = colors[i % colors.length];
|
|
275
|
+
// Donut slice path: outer arc -> line to inner -> inner arc (reverse) -> line back
|
|
276
|
+
const pathD = `M ${ox1} ${oy1} A ${outerRadius} ${outerRadius} 0 ${largeArc} 1 ${ox2} ${oy2} L ${ix2} ${iy2} A ${innerRadius} ${innerRadius} 0 ${largeArc} 0 ${ix1} ${iy1} Z`;
|
|
277
|
+
lines.push(` <path d="${pathD}" fill="${color}" stroke="${backgroundColor}" stroke-width="2"/>`);
|
|
278
|
+
// Label position (middle of slice, outside)
|
|
279
|
+
const labelAngle = currentAngle + sliceAngle / 2;
|
|
280
|
+
const labelRadius = outerRadius + 25;
|
|
281
|
+
const labelX = centerX + labelRadius * Math.cos(labelAngle);
|
|
282
|
+
const labelY = centerY + labelRadius * Math.sin(labelAngle);
|
|
283
|
+
const percent = Math.round((value / total) * 100);
|
|
284
|
+
lines.push(` <text x="${labelX}" y="${labelY}" text-anchor="middle" dominant-baseline="middle" font-family="system-ui, sans-serif" font-size="11" fill="${textColor}">${escapeXml(ir.labels[i])}: ${percent}%</text>`);
|
|
285
|
+
currentAngle = endAngle;
|
|
286
|
+
});
|
|
287
|
+
lines.push('</svg>');
|
|
288
|
+
return lines.join('\n');
|
|
289
|
+
}
|
|
290
|
+
export function renderAreaChart(ir, opts = {}) {
|
|
30
291
|
const options = { ...DEFAULT_OPTIONS, ...opts };
|
|
31
292
|
const { width, height, padding, colors, backgroundColor, textColor, gridColor } = options;
|
|
32
293
|
const chartWidth = width - padding * 2;
|
|
33
|
-
const chartHeight = height - padding * 2 - 30;
|
|
294
|
+
const chartHeight = height - padding * 2 - 30;
|
|
34
295
|
const maxValue = Math.max(...ir.values);
|
|
35
|
-
const
|
|
36
|
-
const barWidth = (chartWidth / barCount) * 0.6;
|
|
37
|
-
const barGap = (chartWidth / barCount) * 0.4;
|
|
296
|
+
const pointCount = ir.values.length;
|
|
38
297
|
const lines = [];
|
|
39
298
|
// SVG header
|
|
40
299
|
lines.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" style="background-color: ${backgroundColor};">`);
|
|
@@ -48,26 +307,33 @@ export function renderBarChart(ir, opts = {}) {
|
|
|
48
307
|
lines.push(` <line x1="${padding}" y1="${chartTop}" x2="${padding}" y2="${chartBottom}" stroke="${gridColor}" stroke-width="1"/>`);
|
|
49
308
|
// X-axis
|
|
50
309
|
lines.push(` <line x1="${padding}" y1="${chartBottom}" x2="${width - padding}" y2="${chartBottom}" stroke="${gridColor}" stroke-width="1"/>`);
|
|
51
|
-
// Grid lines
|
|
310
|
+
// Grid lines
|
|
52
311
|
for (let i = 1; i <= 4; i++) {
|
|
53
312
|
const y = chartBottom - (chartHeight / 4) * i;
|
|
54
313
|
lines.push(` <line x1="${padding}" y1="${y}" x2="${width - padding}" y2="${y}" stroke="${gridColor}" stroke-width="1" stroke-dasharray="3,3"/>`);
|
|
55
|
-
// Y-axis labels
|
|
56
314
|
const label = Math.round((maxValue / 4) * i);
|
|
57
315
|
lines.push(` <text x="${padding - 8}" y="${y + 4}" text-anchor="end" font-family="system-ui, sans-serif" font-size="11" fill="#6c7086">${label}</text>`);
|
|
58
316
|
}
|
|
59
|
-
//
|
|
60
|
-
ir.values.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
317
|
+
// Calculate points
|
|
318
|
+
const points = ir.values.map((value, i) => ({
|
|
319
|
+
x: padding + (chartWidth / (pointCount - 1 || 1)) * i,
|
|
320
|
+
y: chartBottom - (value / maxValue) * chartHeight,
|
|
321
|
+
}));
|
|
322
|
+
// Draw filled area
|
|
323
|
+
const areaPathD = [
|
|
324
|
+
`M ${padding} ${chartBottom}`,
|
|
325
|
+
...points.map((p) => `L ${p.x} ${p.y}`),
|
|
326
|
+
`L ${points[points.length - 1].x} ${chartBottom}`,
|
|
327
|
+
'Z'
|
|
328
|
+
].join(' ');
|
|
329
|
+
lines.push(` <path d="${areaPathD}" fill="${colors[0]}" fill-opacity="0.3"/>`);
|
|
330
|
+
// Draw line on top
|
|
331
|
+
const linePathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ');
|
|
332
|
+
lines.push(` <path d="${linePathD}" fill="none" stroke="${colors[0]}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`);
|
|
333
|
+
// Draw points and labels
|
|
334
|
+
points.forEach((p, i) => {
|
|
69
335
|
// X-axis label
|
|
70
|
-
lines.push(` <text x="${x
|
|
336
|
+
lines.push(` <text x="${p.x}" y="${chartBottom + 18}" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#6c7086">${escapeXml(ir.labels[i])}</text>`);
|
|
71
337
|
});
|
|
72
338
|
lines.push('</svg>');
|
|
73
339
|
return lines.join('\n');
|
|
@@ -77,10 +343,13 @@ export function render(ir, opts = {}) {
|
|
|
77
343
|
case 'bar':
|
|
78
344
|
return renderBarChart(ir, opts);
|
|
79
345
|
case 'line':
|
|
346
|
+
return renderLineChart(ir, opts);
|
|
80
347
|
case 'pie':
|
|
348
|
+
return renderPieChart(ir, opts);
|
|
349
|
+
case 'donut':
|
|
350
|
+
return renderDonutChart(ir, opts);
|
|
81
351
|
case 'area':
|
|
82
|
-
|
|
83
|
-
throw new Error(`Chart type '${ir.type}' not yet implemented`);
|
|
352
|
+
return renderAreaChart(ir, opts);
|
|
84
353
|
default:
|
|
85
354
|
throw new Error(`Unknown chart type: ${ir.type}`);
|
|
86
355
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plainviz/render-svg",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "PlainViz SVG renderer",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"dev": "tsc --watch"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@plainviz/core": "
|
|
23
|
+
"@plainviz/core": "^0.2.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"typescript": "^5.7.2"
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { render, renderBarChart } from './render';
|
|
2
|
-
export type { RenderOptions } from './render';
|
|
1
|
+
export { render, renderBarChart } from './render.js';
|
|
2
|
+
export type { RenderOptions } from './render.js';
|
package/src/render.ts
CHANGED
|
@@ -44,14 +44,28 @@ function escapeXml(str: string): string {
|
|
|
44
44
|
|
|
45
45
|
export function renderBarChart(ir: PlainVizIR, opts: RenderOptions = {}): string {
|
|
46
46
|
const options = { ...DEFAULT_OPTIONS, ...opts };
|
|
47
|
-
const { width, height, padding,
|
|
47
|
+
const { width, height, padding, backgroundColor, textColor, gridColor } = options;
|
|
48
|
+
const colors = ir.meta?.colors || options.colors;
|
|
49
|
+
|
|
50
|
+
const isMultiSeries = ir.series && ir.series.length > 1;
|
|
51
|
+
const seriesCount = isMultiSeries ? ir.series!.length : 1;
|
|
52
|
+
const legendHeight = isMultiSeries ? 25 : 0;
|
|
48
53
|
|
|
49
54
|
const chartWidth = width - padding * 2;
|
|
50
|
-
const chartHeight = height - padding * 2 - 30
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
const chartHeight = height - padding * 2 - 30 - legendHeight;
|
|
56
|
+
|
|
57
|
+
// Calculate max value across all series
|
|
58
|
+
let maxValue: number;
|
|
59
|
+
if (isMultiSeries) {
|
|
60
|
+
maxValue = Math.max(...ir.series!.flatMap(s => s.values));
|
|
61
|
+
} else {
|
|
62
|
+
maxValue = Math.max(...ir.values);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const labelCount = ir.labels.length;
|
|
66
|
+
const groupWidth = chartWidth / labelCount;
|
|
67
|
+
const barWidth = (groupWidth * 0.7) / seriesCount;
|
|
68
|
+
const groupPadding = groupWidth * 0.15;
|
|
55
69
|
|
|
56
70
|
const lines: string[] = [];
|
|
57
71
|
|
|
@@ -63,7 +77,21 @@ export function renderBarChart(ir: PlainVizIR, opts: RenderOptions = {}): string
|
|
|
63
77
|
lines.push(` <text x="${width / 2}" y="25" text-anchor="middle" font-family="system-ui, sans-serif" font-size="16" font-weight="bold" fill="${textColor}">${escapeXml(ir.title)}</text>`);
|
|
64
78
|
}
|
|
65
79
|
|
|
66
|
-
|
|
80
|
+
// Legend for multi-series
|
|
81
|
+
if (isMultiSeries) {
|
|
82
|
+
const legendY = 45;
|
|
83
|
+
const legendItemWidth = 80;
|
|
84
|
+
const legendStartX = (width - ir.series!.length * legendItemWidth) / 2;
|
|
85
|
+
|
|
86
|
+
ir.series!.forEach((series, i) => {
|
|
87
|
+
const x = legendStartX + i * legendItemWidth;
|
|
88
|
+
const color = series.color || colors[i % colors.length];
|
|
89
|
+
lines.push(` <rect x="${x}" y="${legendY - 8}" width="12" height="12" rx="2" fill="${color}"/>`);
|
|
90
|
+
lines.push(` <text x="${x + 16}" y="${legendY}" font-family="system-ui, sans-serif" font-size="11" fill="${textColor}">${escapeXml(series.name)}</text>`);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const chartTop = padding + legendHeight;
|
|
67
95
|
const chartBottom = height - padding;
|
|
68
96
|
|
|
69
97
|
// Y-axis
|
|
@@ -72,31 +100,337 @@ export function renderBarChart(ir: PlainVizIR, opts: RenderOptions = {}): string
|
|
|
72
100
|
// X-axis
|
|
73
101
|
lines.push(` <line x1="${padding}" y1="${chartBottom}" x2="${width - padding}" y2="${chartBottom}" stroke="${gridColor}" stroke-width="1"/>`);
|
|
74
102
|
|
|
75
|
-
// Grid lines
|
|
103
|
+
// Grid lines
|
|
76
104
|
for (let i = 1; i <= 4; i++) {
|
|
77
105
|
const y = chartBottom - (chartHeight / 4) * i;
|
|
78
106
|
lines.push(` <line x1="${padding}" y1="${y}" x2="${width - padding}" y2="${y}" stroke="${gridColor}" stroke-width="1" stroke-dasharray="3,3"/>`);
|
|
107
|
+
const label = Math.round((maxValue / 4) * i);
|
|
108
|
+
lines.push(` <text x="${padding - 8}" y="${y + 4}" text-anchor="end" font-family="system-ui, sans-serif" font-size="11" fill="#6c7086">${label}</text>`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Draw bars
|
|
112
|
+
ir.labels.forEach((label, labelIndex) => {
|
|
113
|
+
const groupX = padding + groupWidth * labelIndex + groupPadding;
|
|
114
|
+
|
|
115
|
+
if (isMultiSeries) {
|
|
116
|
+
// Multi-series: grouped bars
|
|
117
|
+
ir.series!.forEach((series, seriesIndex) => {
|
|
118
|
+
const value = series.values[labelIndex] ?? 0;
|
|
119
|
+
const barHeight = (value / maxValue) * chartHeight;
|
|
120
|
+
const x = groupX + seriesIndex * barWidth;
|
|
121
|
+
const y = chartBottom - barHeight;
|
|
122
|
+
const color = series.color || colors[seriesIndex % colors.length];
|
|
123
|
+
|
|
124
|
+
lines.push(` <rect x="${x}" y="${y}" width="${barWidth - 2}" height="${barHeight}" rx="2" fill="${color}"/>`);
|
|
125
|
+
});
|
|
126
|
+
} else {
|
|
127
|
+
// Single series
|
|
128
|
+
const value = ir.values[labelIndex];
|
|
129
|
+
const barHeight = (value / maxValue) * chartHeight;
|
|
130
|
+
const x = groupX;
|
|
131
|
+
const y = chartBottom - barHeight;
|
|
132
|
+
const color = colors[labelIndex % colors.length];
|
|
133
|
+
|
|
134
|
+
lines.push(` <rect x="${x}" y="${y}" width="${groupWidth * 0.7}" height="${barHeight}" rx="4" fill="${color}"/>`);
|
|
135
|
+
lines.push(` <text x="${x + groupWidth * 0.35}" y="${y - 8}" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="${textColor}">${value}</text>`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// X-axis label
|
|
139
|
+
const labelX = groupX + (groupWidth * 0.7) / 2;
|
|
140
|
+
lines.push(` <text x="${labelX}" y="${chartBottom + 18}" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#6c7086">${escapeXml(label)}</text>`);
|
|
141
|
+
});
|
|
79
142
|
|
|
80
|
-
|
|
143
|
+
lines.push('</svg>');
|
|
144
|
+
|
|
145
|
+
return lines.join('\n');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function renderLineChart(ir: PlainVizIR, opts: RenderOptions = {}): string {
|
|
149
|
+
const options = { ...DEFAULT_OPTIONS, ...opts };
|
|
150
|
+
const { width, height, padding, backgroundColor, textColor, gridColor } = options;
|
|
151
|
+
const colors = ir.meta?.colors || options.colors;
|
|
152
|
+
|
|
153
|
+
const isMultiSeries = ir.series && ir.series.length > 1;
|
|
154
|
+
const legendHeight = isMultiSeries ? 25 : 0;
|
|
155
|
+
|
|
156
|
+
const chartWidth = width - padding * 2;
|
|
157
|
+
const chartHeight = height - padding * 2 - 30 - legendHeight;
|
|
158
|
+
const pointCount = ir.labels.length;
|
|
159
|
+
|
|
160
|
+
// Calculate max value across all series
|
|
161
|
+
let maxValue: number;
|
|
162
|
+
if (isMultiSeries) {
|
|
163
|
+
maxValue = Math.max(...ir.series!.flatMap(s => s.values));
|
|
164
|
+
} else {
|
|
165
|
+
maxValue = Math.max(...ir.values);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const lines: string[] = [];
|
|
169
|
+
|
|
170
|
+
// SVG header
|
|
171
|
+
lines.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" style="background-color: ${backgroundColor};">`);
|
|
172
|
+
|
|
173
|
+
// Title
|
|
174
|
+
if (ir.title) {
|
|
175
|
+
lines.push(` <text x="${width / 2}" y="25" text-anchor="middle" font-family="system-ui, sans-serif" font-size="16" font-weight="bold" fill="${textColor}">${escapeXml(ir.title)}</text>`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Legend for multi-series
|
|
179
|
+
if (isMultiSeries) {
|
|
180
|
+
const legendY = 45;
|
|
181
|
+
const legendItemWidth = 80;
|
|
182
|
+
const legendStartX = (width - ir.series!.length * legendItemWidth) / 2;
|
|
183
|
+
|
|
184
|
+
ir.series!.forEach((series, i) => {
|
|
185
|
+
const x = legendStartX + i * legendItemWidth;
|
|
186
|
+
const color = series.color || colors[i % colors.length];
|
|
187
|
+
lines.push(` <rect x="${x}" y="${legendY - 8}" width="12" height="12" rx="2" fill="${color}"/>`);
|
|
188
|
+
lines.push(` <text x="${x + 16}" y="${legendY}" font-family="system-ui, sans-serif" font-size="11" fill="${textColor}">${escapeXml(series.name)}</text>`);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const chartTop = padding + legendHeight;
|
|
193
|
+
const chartBottom = height - padding;
|
|
194
|
+
|
|
195
|
+
// Y-axis
|
|
196
|
+
lines.push(` <line x1="${padding}" y1="${chartTop}" x2="${padding}" y2="${chartBottom}" stroke="${gridColor}" stroke-width="1"/>`);
|
|
197
|
+
|
|
198
|
+
// X-axis
|
|
199
|
+
lines.push(` <line x1="${padding}" y1="${chartBottom}" x2="${width - padding}" y2="${chartBottom}" stroke="${gridColor}" stroke-width="1"/>`);
|
|
200
|
+
|
|
201
|
+
// Grid lines
|
|
202
|
+
for (let i = 1; i <= 4; i++) {
|
|
203
|
+
const y = chartBottom - (chartHeight / 4) * i;
|
|
204
|
+
lines.push(` <line x1="${padding}" y1="${y}" x2="${width - padding}" y2="${y}" stroke="${gridColor}" stroke-width="1" stroke-dasharray="3,3"/>`);
|
|
81
205
|
const label = Math.round((maxValue / 4) * i);
|
|
82
206
|
lines.push(` <text x="${padding - 8}" y="${y + 4}" text-anchor="end" font-family="system-ui, sans-serif" font-size="11" fill="#6c7086">${label}</text>`);
|
|
83
207
|
}
|
|
84
208
|
|
|
85
|
-
|
|
209
|
+
if (isMultiSeries) {
|
|
210
|
+
// Draw multiple lines
|
|
211
|
+
ir.series!.forEach((series, seriesIndex) => {
|
|
212
|
+
const color = series.color || colors[seriesIndex % colors.length];
|
|
213
|
+
const points = series.values.map((value, i) => ({
|
|
214
|
+
x: padding + (chartWidth / (pointCount - 1 || 1)) * i,
|
|
215
|
+
y: chartBottom - (value / maxValue) * chartHeight,
|
|
216
|
+
}));
|
|
217
|
+
|
|
218
|
+
// Draw line path
|
|
219
|
+
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ');
|
|
220
|
+
lines.push(` <path d="${pathD}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`);
|
|
221
|
+
|
|
222
|
+
// Draw points
|
|
223
|
+
points.forEach((p) => {
|
|
224
|
+
lines.push(` <circle cx="${p.x}" cy="${p.y}" r="4" fill="${color}" stroke="${backgroundColor}" stroke-width="2"/>`);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// X-axis labels (only once)
|
|
229
|
+
ir.labels.forEach((label, i) => {
|
|
230
|
+
const x = padding + (chartWidth / (pointCount - 1 || 1)) * i;
|
|
231
|
+
lines.push(` <text x="${x}" y="${chartBottom + 18}" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#6c7086">${escapeXml(label)}</text>`);
|
|
232
|
+
});
|
|
233
|
+
} else {
|
|
234
|
+
// Single series
|
|
235
|
+
const points = ir.values.map((value, i) => ({
|
|
236
|
+
x: padding + (chartWidth / (pointCount - 1 || 1)) * i,
|
|
237
|
+
y: chartBottom - (value / maxValue) * chartHeight,
|
|
238
|
+
}));
|
|
239
|
+
|
|
240
|
+
// Draw line path
|
|
241
|
+
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ');
|
|
242
|
+
lines.push(` <path d="${pathD}" fill="none" stroke="${colors[0]}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>`);
|
|
243
|
+
|
|
244
|
+
// Draw points and labels
|
|
245
|
+
points.forEach((p, i) => {
|
|
246
|
+
lines.push(` <circle cx="${p.x}" cy="${p.y}" r="5" fill="${colors[0]}" stroke="${backgroundColor}" stroke-width="2"/>`);
|
|
247
|
+
lines.push(` <text x="${p.x}" y="${p.y - 12}" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="${textColor}">${ir.values[i]}</text>`);
|
|
248
|
+
lines.push(` <text x="${p.x}" y="${chartBottom + 18}" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#6c7086">${escapeXml(ir.labels[i])}</text>`);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
lines.push('</svg>');
|
|
253
|
+
|
|
254
|
+
return lines.join('\n');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function renderPieChart(ir: PlainVizIR, opts: RenderOptions = {}): string {
|
|
258
|
+
const options = { ...DEFAULT_OPTIONS, ...opts };
|
|
259
|
+
const { width, height, colors, backgroundColor, textColor } = options;
|
|
260
|
+
|
|
261
|
+
const centerX = width / 2;
|
|
262
|
+
const centerY = height / 2 + 10;
|
|
263
|
+
const radius = Math.min(width, height) / 2 - 60;
|
|
264
|
+
const total = ir.values.reduce((a, b) => a + b, 0);
|
|
265
|
+
|
|
266
|
+
const lines: string[] = [];
|
|
267
|
+
|
|
268
|
+
// SVG header
|
|
269
|
+
lines.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" style="background-color: ${backgroundColor};">`);
|
|
270
|
+
|
|
271
|
+
// Title
|
|
272
|
+
if (ir.title) {
|
|
273
|
+
lines.push(` <text x="${width / 2}" y="25" text-anchor="middle" font-family="system-ui, sans-serif" font-size="16" font-weight="bold" fill="${textColor}">${escapeXml(ir.title)}</text>`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Draw pie slices
|
|
277
|
+
let currentAngle = -Math.PI / 2; // Start from top
|
|
278
|
+
|
|
86
279
|
ir.values.forEach((value, i) => {
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
|
|
280
|
+
const sliceAngle = (value / total) * 2 * Math.PI;
|
|
281
|
+
const endAngle = currentAngle + sliceAngle;
|
|
282
|
+
|
|
283
|
+
const x1 = centerX + radius * Math.cos(currentAngle);
|
|
284
|
+
const y1 = centerY + radius * Math.sin(currentAngle);
|
|
285
|
+
const x2 = centerX + radius * Math.cos(endAngle);
|
|
286
|
+
const y2 = centerY + radius * Math.sin(endAngle);
|
|
287
|
+
|
|
288
|
+
const largeArc = sliceAngle > Math.PI ? 1 : 0;
|
|
90
289
|
const color = colors[i % colors.length];
|
|
91
290
|
|
|
92
|
-
//
|
|
93
|
-
|
|
291
|
+
// Slice path
|
|
292
|
+
const pathD = `M ${centerX} ${centerY} L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} Z`;
|
|
293
|
+
lines.push(` <path d="${pathD}" fill="${color}" stroke="${backgroundColor}" stroke-width="2"/>`);
|
|
294
|
+
|
|
295
|
+
// Label position (middle of slice, outside)
|
|
296
|
+
const labelAngle = currentAngle + sliceAngle / 2;
|
|
297
|
+
const labelRadius = radius + 25;
|
|
298
|
+
const labelX = centerX + labelRadius * Math.cos(labelAngle);
|
|
299
|
+
const labelY = centerY + labelRadius * Math.sin(labelAngle);
|
|
300
|
+
const percent = Math.round((value / total) * 100);
|
|
301
|
+
|
|
302
|
+
lines.push(` <text x="${labelX}" y="${labelY}" text-anchor="middle" dominant-baseline="middle" font-family="system-ui, sans-serif" font-size="11" fill="${textColor}">${escapeXml(ir.labels[i])}: ${percent}%</text>`);
|
|
303
|
+
|
|
304
|
+
currentAngle = endAngle;
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
lines.push('</svg>');
|
|
308
|
+
|
|
309
|
+
return lines.join('\n');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function renderDonutChart(ir: PlainVizIR, opts: RenderOptions = {}): string {
|
|
313
|
+
const options = { ...DEFAULT_OPTIONS, ...opts };
|
|
314
|
+
const { width, height, colors, backgroundColor, textColor } = options;
|
|
315
|
+
|
|
316
|
+
const centerX = width / 2;
|
|
317
|
+
const centerY = height / 2 + 10;
|
|
318
|
+
const outerRadius = Math.min(width, height) / 2 - 60;
|
|
319
|
+
const innerRadius = outerRadius * 0.6; // Donut hole
|
|
320
|
+
const total = ir.values.reduce((a, b) => a + b, 0);
|
|
321
|
+
|
|
322
|
+
const lines: string[] = [];
|
|
323
|
+
|
|
324
|
+
// SVG header
|
|
325
|
+
lines.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" style="background-color: ${backgroundColor};">`);
|
|
326
|
+
|
|
327
|
+
// Title
|
|
328
|
+
if (ir.title) {
|
|
329
|
+
lines.push(` <text x="${width / 2}" y="25" text-anchor="middle" font-family="system-ui, sans-serif" font-size="16" font-weight="bold" fill="${textColor}">${escapeXml(ir.title)}</text>`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Draw donut slices
|
|
333
|
+
let currentAngle = -Math.PI / 2; // Start from top
|
|
334
|
+
|
|
335
|
+
ir.values.forEach((value, i) => {
|
|
336
|
+
const sliceAngle = (value / total) * 2 * Math.PI;
|
|
337
|
+
const endAngle = currentAngle + sliceAngle;
|
|
338
|
+
|
|
339
|
+
// Outer arc points
|
|
340
|
+
const ox1 = centerX + outerRadius * Math.cos(currentAngle);
|
|
341
|
+
const oy1 = centerY + outerRadius * Math.sin(currentAngle);
|
|
342
|
+
const ox2 = centerX + outerRadius * Math.cos(endAngle);
|
|
343
|
+
const oy2 = centerY + outerRadius * Math.sin(endAngle);
|
|
344
|
+
|
|
345
|
+
// Inner arc points
|
|
346
|
+
const ix1 = centerX + innerRadius * Math.cos(currentAngle);
|
|
347
|
+
const iy1 = centerY + innerRadius * Math.sin(currentAngle);
|
|
348
|
+
const ix2 = centerX + innerRadius * Math.cos(endAngle);
|
|
349
|
+
const iy2 = centerY + innerRadius * Math.sin(endAngle);
|
|
350
|
+
|
|
351
|
+
const largeArc = sliceAngle > Math.PI ? 1 : 0;
|
|
352
|
+
const color = colors[i % colors.length];
|
|
353
|
+
|
|
354
|
+
// Donut slice path: outer arc -> line to inner -> inner arc (reverse) -> line back
|
|
355
|
+
const pathD = `M ${ox1} ${oy1} A ${outerRadius} ${outerRadius} 0 ${largeArc} 1 ${ox2} ${oy2} L ${ix2} ${iy2} A ${innerRadius} ${innerRadius} 0 ${largeArc} 0 ${ix1} ${iy1} Z`;
|
|
356
|
+
lines.push(` <path d="${pathD}" fill="${color}" stroke="${backgroundColor}" stroke-width="2"/>`);
|
|
357
|
+
|
|
358
|
+
// Label position (middle of slice, outside)
|
|
359
|
+
const labelAngle = currentAngle + sliceAngle / 2;
|
|
360
|
+
const labelRadius = outerRadius + 25;
|
|
361
|
+
const labelX = centerX + labelRadius * Math.cos(labelAngle);
|
|
362
|
+
const labelY = centerY + labelRadius * Math.sin(labelAngle);
|
|
363
|
+
const percent = Math.round((value / total) * 100);
|
|
364
|
+
|
|
365
|
+
lines.push(` <text x="${labelX}" y="${labelY}" text-anchor="middle" dominant-baseline="middle" font-family="system-ui, sans-serif" font-size="11" fill="${textColor}">${escapeXml(ir.labels[i])}: ${percent}%</text>`);
|
|
366
|
+
|
|
367
|
+
currentAngle = endAngle;
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
lines.push('</svg>');
|
|
371
|
+
|
|
372
|
+
return lines.join('\n');
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function renderAreaChart(ir: PlainVizIR, opts: RenderOptions = {}): string {
|
|
376
|
+
const options = { ...DEFAULT_OPTIONS, ...opts };
|
|
377
|
+
const { width, height, padding, colors, backgroundColor, textColor, gridColor } = options;
|
|
378
|
+
|
|
379
|
+
const chartWidth = width - padding * 2;
|
|
380
|
+
const chartHeight = height - padding * 2 - 30;
|
|
381
|
+
const maxValue = Math.max(...ir.values);
|
|
382
|
+
const pointCount = ir.values.length;
|
|
383
|
+
|
|
384
|
+
const lines: string[] = [];
|
|
385
|
+
|
|
386
|
+
// SVG header
|
|
387
|
+
lines.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" style="background-color: ${backgroundColor};">`);
|
|
388
|
+
|
|
389
|
+
// Title
|
|
390
|
+
if (ir.title) {
|
|
391
|
+
lines.push(` <text x="${width / 2}" y="25" text-anchor="middle" font-family="system-ui, sans-serif" font-size="16" font-weight="bold" fill="${textColor}">${escapeXml(ir.title)}</text>`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const chartTop = padding;
|
|
395
|
+
const chartBottom = height - padding;
|
|
396
|
+
|
|
397
|
+
// Y-axis
|
|
398
|
+
lines.push(` <line x1="${padding}" y1="${chartTop}" x2="${padding}" y2="${chartBottom}" stroke="${gridColor}" stroke-width="1"/>`);
|
|
399
|
+
|
|
400
|
+
// X-axis
|
|
401
|
+
lines.push(` <line x1="${padding}" y1="${chartBottom}" x2="${width - padding}" y2="${chartBottom}" stroke="${gridColor}" stroke-width="1"/>`);
|
|
402
|
+
|
|
403
|
+
// Grid lines
|
|
404
|
+
for (let i = 1; i <= 4; i++) {
|
|
405
|
+
const y = chartBottom - (chartHeight / 4) * i;
|
|
406
|
+
lines.push(` <line x1="${padding}" y1="${y}" x2="${width - padding}" y2="${y}" stroke="${gridColor}" stroke-width="1" stroke-dasharray="3,3"/>`);
|
|
407
|
+
const label = Math.round((maxValue / 4) * i);
|
|
408
|
+
lines.push(` <text x="${padding - 8}" y="${y + 4}" text-anchor="end" font-family="system-ui, sans-serif" font-size="11" fill="#6c7086">${label}</text>`);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Calculate points
|
|
412
|
+
const points: { x: number; y: number }[] = ir.values.map((value, i) => ({
|
|
413
|
+
x: padding + (chartWidth / (pointCount - 1 || 1)) * i,
|
|
414
|
+
y: chartBottom - (value / maxValue) * chartHeight,
|
|
415
|
+
}));
|
|
416
|
+
|
|
417
|
+
// Draw filled area
|
|
418
|
+
const areaPathD = [
|
|
419
|
+
`M ${padding} ${chartBottom}`,
|
|
420
|
+
...points.map((p) => `L ${p.x} ${p.y}`),
|
|
421
|
+
`L ${points[points.length - 1].x} ${chartBottom}`,
|
|
422
|
+
'Z'
|
|
423
|
+
].join(' ');
|
|
424
|
+
lines.push(` <path d="${areaPathD}" fill="${colors[0]}" fill-opacity="0.3"/>`);
|
|
94
425
|
|
|
95
|
-
|
|
96
|
-
|
|
426
|
+
// Draw line on top
|
|
427
|
+
const linePathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ');
|
|
428
|
+
lines.push(` <path d="${linePathD}" fill="none" stroke="${colors[0]}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`);
|
|
97
429
|
|
|
430
|
+
// Draw points and labels
|
|
431
|
+
points.forEach((p, i) => {
|
|
98
432
|
// X-axis label
|
|
99
|
-
lines.push(` <text x="${x
|
|
433
|
+
lines.push(` <text x="${p.x}" y="${chartBottom + 18}" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#6c7086">${escapeXml(ir.labels[i])}</text>`);
|
|
100
434
|
});
|
|
101
435
|
|
|
102
436
|
lines.push('</svg>');
|
|
@@ -109,10 +443,13 @@ export function render(ir: PlainVizIR, opts: RenderOptions = {}): string {
|
|
|
109
443
|
case 'bar':
|
|
110
444
|
return renderBarChart(ir, opts);
|
|
111
445
|
case 'line':
|
|
446
|
+
return renderLineChart(ir, opts);
|
|
112
447
|
case 'pie':
|
|
448
|
+
return renderPieChart(ir, opts);
|
|
449
|
+
case 'donut':
|
|
450
|
+
return renderDonutChart(ir, opts);
|
|
113
451
|
case 'area':
|
|
114
|
-
|
|
115
|
-
throw new Error(`Chart type '${ir.type}' not yet implemented`);
|
|
452
|
+
return renderAreaChart(ir, opts);
|
|
116
453
|
default:
|
|
117
454
|
throw new Error(`Unknown chart type: ${ir.type}`);
|
|
118
455
|
}
|