1use std::fmt::Write as _;
7
8#[derive(Debug, Clone)]
10pub struct ChartPoint {
11 pub x: f64,
13 pub y: f64,
15 pub label: Option<String>,
17}
18
19#[derive(Debug, Clone)]
21pub struct ChartSeries {
22 pub name: String,
24 pub color: String,
26 pub points: Vec<ChartPoint>,
28}
29
30#[derive(Debug, Clone)]
32pub struct ChartConfig {
33 pub title: String,
35 pub x_label: String,
37 pub y_label: String,
39 pub lower_is_better: bool,
41 pub width: u32,
43 pub height: u32,
45}
46
47impl Default for ChartConfig {
48 fn default() -> Self {
49 Self {
50 title: "Quality vs Size".to_string(),
51 x_label: "Bits per Pixel (BPP) →".to_string(),
52 y_label: "Quality Score".to_string(),
53 lower_is_better: false,
54 width: 700,
55 height: 450,
56 }
57 }
58}
59
60impl ChartConfig {
61 #[must_use]
63 pub fn new(title: impl Into<String>) -> Self {
64 Self {
65 title: title.into(),
66 ..Default::default()
67 }
68 }
69
70 #[must_use]
72 pub fn with_x_label(mut self, label: impl Into<String>) -> Self {
73 self.x_label = label.into();
74 self
75 }
76
77 #[must_use]
79 pub fn with_y_label(mut self, label: impl Into<String>) -> Self {
80 self.y_label = label.into();
81 self
82 }
83
84 #[must_use]
86 pub fn with_lower_is_better(mut self, lower_is_better: bool) -> Self {
87 self.lower_is_better = lower_is_better;
88 self
89 }
90
91 #[must_use]
93 pub fn with_dimensions(mut self, width: u32, height: u32) -> Self {
94 self.width = width;
95 self.height = height;
96 self
97 }
98}
99
100#[must_use]
126pub fn generate_svg(series: &[ChartSeries], config: &ChartConfig) -> String {
127 let mut svg = String::with_capacity(8192);
128
129 let non_empty: Vec<_> = series.iter().filter(|s| !s.points.is_empty()).collect();
131 if non_empty.is_empty() {
132 return String::new();
133 }
134
135 let all_x: Vec<f64> = non_empty
136 .iter()
137 .flat_map(|s| s.points.iter().map(|p| p.x))
138 .collect();
139 let all_y: Vec<f64> = non_empty
140 .iter()
141 .flat_map(|s| s.points.iter().map(|p| p.y))
142 .collect();
143
144 let (min_x, max_x) = bounds_with_padding(&all_x, 0.05);
145 let (min_y, max_y) = bounds_with_padding(&all_y, 0.05);
146
147 let width = config.width;
148 let height = config.height;
149 let margin_top = 50;
150 let margin_right = 140;
151 let margin_bottom = 70;
152 let margin_left = 90;
153 let plot_width = width - margin_left - margin_right;
154 let plot_height = height - margin_top - margin_bottom;
155
156 let scale_x = |v: f64| -> f64 {
157 f64::from(margin_left) + (v - min_x) / (max_x - min_x) * f64::from(plot_width)
158 };
159
160 let scale_y = |v: f64| -> f64 {
161 if config.lower_is_better {
162 f64::from(margin_top) + (v - min_y) / (max_y - min_y) * f64::from(plot_height)
163 } else {
164 f64::from(margin_top) + (1.0 - (v - min_y) / (max_y - min_y)) * f64::from(plot_height)
165 }
166 };
167
168 let _ = writeln!(
170 svg,
171 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {} {}">"#,
172 width, height
173 );
174
175 svg.push_str(
177 r#"<style>
178 :root {
179 --bg-color: #ffffff;
180 --text-color: #1a1a1a;
181 --grid-color: #e0e0e0;
182 --axis-color: #333333;
183 --legend-bg: #ffffff;
184 --legend-border: #cccccc;
185 }
186 @media (prefers-color-scheme: dark) {
187 :root {
188 --bg-color: #1a1a1a;
189 --text-color: #e0e0e0;
190 --grid-color: #404040;
191 --axis-color: #b0b0b0;
192 --legend-bg: #2a2a2a;
193 --legend-border: #505050;
194 }
195 }
196 .background { fill: var(--bg-color); }
197 .title { font: bold 18px system-ui, sans-serif; fill: var(--text-color); }
198 .axis-label { font: 13px system-ui, sans-serif; fill: var(--text-color); }
199 .tick-label { font: 11px system-ui, sans-serif; fill: var(--text-color); }
200 .legend { font: 13px system-ui, sans-serif; fill: var(--text-color); }
201 .grid { stroke: var(--grid-color); stroke-width: 1; }
202 .axis { stroke: var(--axis-color); stroke-width: 1.5; }
203 .legend-bg { fill: var(--legend-bg); stroke: var(--legend-border); }
204</style>
205"#,
206 );
207
208 let _ = writeln!(
210 svg,
211 r#"<rect class="background" width="{}" height="{}"/>"#,
212 width, height
213 );
214
215 let _ = writeln!(
217 svg,
218 r#"<text x="{}" y="30" text-anchor="middle" class="title">{}</text>"#,
219 f64::from(width) / 2.0,
220 config.title
221 );
222
223 for i in 0..=5 {
225 let frac = f64::from(i) / 5.0;
226 let x = scale_x(min_x + frac * (max_x - min_x));
227 let y = scale_y(min_y + frac * (max_y - min_y));
228
229 let _ = writeln!(
230 svg,
231 r#"<line x1="{:.2}" y1="{}" x2="{:.2}" y2="{}" class="grid"/>"#,
232 x,
233 margin_top,
234 x,
235 height - margin_bottom
236 );
237 let _ = writeln!(
238 svg,
239 r#"<line x1="{}" y1="{:.2}" x2="{}" y2="{:.2}" class="grid"/>"#,
240 margin_left,
241 y,
242 width - margin_right,
243 y
244 );
245 }
246
247 let _ = writeln!(
249 svg,
250 r#"<line x1="{}" y1="{}" x2="{}" y2="{}" class="axis"/>"#,
251 margin_left,
252 height - margin_bottom,
253 width - margin_right,
254 height - margin_bottom
255 );
256 let _ = writeln!(
257 svg,
258 r#"<line x1="{}" y1="{}" x2="{}" y2="{}" class="axis"/>"#,
259 margin_left,
260 margin_top,
261 margin_left,
262 height - margin_bottom
263 );
264
265 for i in 0..=5 {
267 let frac = f64::from(i) / 5.0;
268 let x_val = min_x + frac * (max_x - min_x);
269 let y_val = min_y + frac * (max_y - min_y);
270 let x = scale_x(x_val);
271 let y = scale_y(y_val);
272
273 let _ = writeln!(
274 svg,
275 r#"<text x="{:.2}" y="{}" text-anchor="middle" class="tick-label">{:.2}</text>"#,
276 x,
277 height - margin_bottom + 20,
278 x_val
279 );
280
281 let y_label = if y_val.abs() < 0.0001 {
283 format!("{:.6}", y_val)
284 } else if y_val.abs() < 0.1 {
285 format!("{:.4}", y_val)
286 } else {
287 format!("{:.2}", y_val)
288 };
289 let _ = writeln!(
290 svg,
291 r#"<text x="{}" y="{:.2}" text-anchor="end" class="tick-label">{}</text>"#,
292 margin_left - 10,
293 y + 4.0,
294 y_label
295 );
296 }
297
298 let _ = writeln!(
300 svg,
301 r#"<text x="{}" y="{}" text-anchor="middle" class="axis-label">{}</text>"#,
302 f64::from(width) / 2.0,
303 height - 20,
304 config.x_label
305 );
306
307 let _ = writeln!(
309 svg,
310 r#"<text x="25" y="{}" text-anchor="middle" class="axis-label" transform="rotate(-90 25 {})">{}</text>"#,
311 f64::from(height) / 2.0,
312 f64::from(height) / 2.0,
313 config.y_label
314 );
315
316 for s in &non_empty {
318 if s.points.is_empty() {
319 continue;
320 }
321
322 let mut path = String::new();
324 for (i, p) in s.points.iter().enumerate() {
325 let prefix = if i == 0 { "M" } else { " L" };
326 let _ = write!(path, "{} {:.2},{:.2}", prefix, scale_x(p.x), scale_y(p.y));
327 }
328 let _ = writeln!(
329 svg,
330 r#"<path d="{}" stroke="{}" stroke-width="2.5" fill="none"/>"#,
331 path, s.color
332 );
333
334 for p in &s.points {
336 let _ = writeln!(
337 svg,
338 r#"<circle cx="{:.2}" cy="{:.2}" r="5" fill="{}"/>"#,
339 scale_x(p.x),
340 scale_y(p.y),
341 s.color
342 );
343 }
344 }
345
346 let legend_x = width - margin_right + 15;
348 let legend_y = margin_top + 20;
349 let legend_height = 20 + non_empty.len() as u32 * 25;
350
351 let _ = writeln!(
352 svg,
353 r#"<rect x="{}" y="{}" width="115" height="{}" rx="4" class="legend-bg"/>"#,
354 legend_x,
355 legend_y - 15,
356 legend_height
357 );
358
359 for (i, s) in non_empty.iter().enumerate() {
360 let y_offset = legend_y + i as u32 * 25;
361 let _ = writeln!(
362 svg,
363 r#"<circle cx="{}" cy="{}" r="5" fill="{}"/>"#,
364 legend_x + 15,
365 y_offset + 5,
366 s.color
367 );
368 let _ = writeln!(
369 svg,
370 r#"<text x="{}" y="{}" class="legend">{}</text>"#,
371 legend_x + 28,
372 y_offset + 9,
373 s.name
374 );
375 }
376
377 svg.push_str("</svg>\n");
378 svg
379}
380
381fn bounds_with_padding(values: &[f64], padding: f64) -> (f64, f64) {
383 let min = values.iter().cloned().fold(f64::INFINITY, f64::min);
384 let max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
385 let range = max - min;
386 (min - range * padding, max + range * padding)
387}
388
389pub mod colors {
391 pub const RED: &str = "#e74c3c";
393 pub const BLUE: &str = "#3498db";
395 pub const GREEN: &str = "#27ae60";
397 pub const ORANGE: &str = "#e67e22";
399 pub const PURPLE: &str = "#9b59b6";
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 #[test]
408 fn test_generate_svg_basic() {
409 let series = vec![ChartSeries {
410 name: "Test".to_string(),
411 color: colors::RED.to_string(),
412 points: vec![
413 ChartPoint {
414 x: 0.5,
415 y: 80.0,
416 label: None,
417 },
418 ChartPoint {
419 x: 1.0,
420 y: 90.0,
421 label: None,
422 },
423 ],
424 }];
425
426 let config = ChartConfig::new("Test Chart");
427 let svg = generate_svg(&series, &config);
428
429 assert!(svg.contains("<svg"));
430 assert!(svg.contains("</svg>"));
431 assert!(svg.contains("Test Chart"));
432 assert!(svg.contains("Test")); }
434
435 #[test]
436 fn test_empty_series() {
437 let series: Vec<ChartSeries> = vec![];
438 let config = ChartConfig::default();
439 let svg = generate_svg(&series, &config);
440 assert!(svg.is_empty());
441 }
442
443 #[test]
444 fn test_lower_is_better() {
445 let series = vec![ChartSeries {
446 name: "Test".to_string(),
447 color: colors::BLUE.to_string(),
448 points: vec![
449 ChartPoint {
450 x: 0.5,
451 y: 0.01,
452 label: None,
453 },
454 ChartPoint {
455 x: 1.0,
456 y: 0.005,
457 label: None,
458 },
459 ],
460 }];
461
462 let config = ChartConfig::new("DSSIM Chart").with_lower_is_better(true);
463 let svg = generate_svg(&series, &config);
464
465 assert!(svg.contains("<svg"));
466 }
467}