@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 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
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAClD,YAAY,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC"}
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
@@ -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,CA4D/E;AAED,wBAAgB,MAAM,CAAC,EAAE,EAAE,UAAU,EAAE,IAAI,GAAE,aAAkB,GAAG,MAAM,CAYvE"}
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; // Reserve space for title
294
+ const chartHeight = height - padding * 2 - 30;
34
295
  const maxValue = Math.max(...ir.values);
35
- const barCount = ir.values.length;
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 (4 horizontal)
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
- // Bars
60
- ir.values.forEach((value, i) => {
61
- const barHeight = (value / maxValue) * chartHeight;
62
- const x = padding + (chartWidth / barCount) * i + barGap / 2;
63
- const y = chartBottom - barHeight;
64
- const color = colors[i % colors.length];
65
- // Bar
66
- lines.push(` <rect x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" rx="4" fill="${color}"/>`);
67
- // Value label
68
- lines.push(` <text x="${x + barWidth / 2}" y="${y - 8}" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="${textColor}">${value}</text>`);
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 + barWidth / 2}" y="${chartBottom + 18}" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#6c7086">${escapeXml(ir.labels[i])}</text>`);
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
- // TODO: implement other chart types
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.1.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": "workspace:*"
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, colors, backgroundColor, textColor, gridColor } = options;
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; // Reserve space for title
51
- const maxValue = Math.max(...ir.values);
52
- const barCount = ir.values.length;
53
- const barWidth = (chartWidth / barCount) * 0.6;
54
- const barGap = (chartWidth / barCount) * 0.4;
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
- const chartTop = padding;
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 (4 horizontal)
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
- // Y-axis labels
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
- // Bars
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 barHeight = (value / maxValue) * chartHeight;
88
- const x = padding + (chartWidth / barCount) * i + barGap / 2;
89
- const y = chartBottom - barHeight;
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
- // Bar
93
- lines.push(` <rect x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" rx="4" fill="${color}"/>`);
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
- // Value label
96
- lines.push(` <text x="${x + barWidth / 2}" y="${y - 8}" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="${textColor}">${value}</text>`);
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 + barWidth / 2}" y="${chartBottom + 18}" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#6c7086">${escapeXml(ir.labels[i])}</text>`);
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
- // TODO: implement other chart types
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
  }