codec_eval/stats/
chart.rs

1//! SVG chart generation for rate-distortion analysis.
2//!
3//! Generates Pareto front plots comparing codecs across quality metrics.
4//! All charts support light and dark mode via CSS media queries.
5
6use std::fmt::Write as _;
7
8/// Data point for a chart series.
9#[derive(Debug, Clone)]
10pub struct ChartPoint {
11    /// X-axis value (typically BPP or file size).
12    pub x: f64,
13    /// Y-axis value (typically quality metric).
14    pub y: f64,
15    /// Optional label (e.g., quality level).
16    pub label: Option<String>,
17}
18
19/// A series of data points with styling.
20#[derive(Debug, Clone)]
21pub struct ChartSeries {
22    /// Series identifier (used in legend).
23    pub name: String,
24    /// CSS color for the series.
25    pub color: String,
26    /// Data points sorted by X.
27    pub points: Vec<ChartPoint>,
28}
29
30/// Chart configuration.
31#[derive(Debug, Clone)]
32pub struct ChartConfig {
33    /// Chart title.
34    pub title: String,
35    /// X-axis label.
36    pub x_label: String,
37    /// Y-axis label.
38    pub y_label: String,
39    /// Whether lower Y values are better (affects axis direction).
40    pub lower_is_better: bool,
41    /// Chart width in pixels.
42    pub width: u32,
43    /// Chart height in pixels.
44    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    /// Creates a new chart configuration with the given title.
62    #[must_use]
63    pub fn new(title: impl Into<String>) -> Self {
64        Self {
65            title: title.into(),
66            ..Default::default()
67        }
68    }
69
70    /// Sets the X-axis label.
71    #[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    /// Sets the Y-axis label.
78    #[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    /// Sets whether lower Y values are better.
85    #[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    /// Sets the chart dimensions.
92    #[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/// Generates an SVG chart from the given series.
101///
102/// # Example
103///
104/// ```rust
105/// use codec_eval::stats::chart::{generate_svg, ChartConfig, ChartSeries, ChartPoint};
106///
107/// let series = vec![
108///     ChartSeries {
109///         name: "Codec A".to_string(),
110///         color: "#e74c3c".to_string(),
111///         points: vec![
112///             ChartPoint { x: 0.5, y: 80.0, label: None },
113///             ChartPoint { x: 1.0, y: 90.0, label: None },
114///         ],
115///     },
116/// ];
117///
118/// let config = ChartConfig::new("Quality vs Size")
119///     .with_x_label("BPP →")
120///     .with_y_label("← SSIMULACRA2");
121///
122/// let svg = generate_svg(&series, &config);
123/// // svg contains valid SVG content
124/// ```
125#[must_use]
126pub fn generate_svg(series: &[ChartSeries], config: &ChartConfig) -> String {
127    let mut svg = String::with_capacity(8192);
128
129    // Filter empty series and find bounds
130    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    // SVG header
169    let _ = writeln!(
170        svg,
171        r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {} {}">"#,
172        width, height
173    );
174
175    // CSS with dark mode support
176    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    // Background
209    let _ = writeln!(
210        svg,
211        r#"<rect class="background" width="{}" height="{}"/>"#,
212        width, height
213    );
214
215    // Title
216    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    // Grid lines
224    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    // Axes
248    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    // Tick labels
266    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        // Format Y label based on magnitude
282        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    // X axis label
299    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    // Y axis label (rotated)
308    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    // Plot series
317    for s in &non_empty {
318        if s.points.is_empty() {
319            continue;
320        }
321
322        // Line
323        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        // Points
335        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    // Legend
347    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
381/// Calculates min/max bounds with padding.
382fn 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
389/// Standard color palette for codec comparison.
390pub mod colors {
391    /// Red - typically for the primary/new implementation.
392    pub const RED: &str = "#e74c3c";
393    /// Blue - typically for the reference implementation.
394    pub const BLUE: &str = "#3498db";
395    /// Green - for a third codec.
396    pub const GREEN: &str = "#27ae60";
397    /// Orange - for a fourth codec.
398    pub const ORANGE: &str = "#e67e22";
399    /// Purple - for a fifth codec.
400    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")); // legend
433    }
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}