Skip to main content

heuropt_plot/
lib.rs

1//! Lightweight SVG plotting helpers for `heuropt` results.
2//!
3//! Two core primitives:
4//!
5//! - [`pareto_front_svg`] — render a 2-objective Pareto front as an
6//!   SVG scatter plot with axes and labels.
7//! - [`convergence_svg`] — render a per-generation "best-fitness so
8//!   far" trace as an SVG line plot.
9//!
10//! Hand-rolled SVG output (no `plotters` / `tiny-skia` dep) so the
11//! crate stays a tiny optional dependency. Output is a `String` of
12//! valid SVG — write it to a file, embed it in HTML, or pipe it to a
13//! browser.
14//!
15//! # Example
16//!
17//! ```
18//! use heuropt::prelude::*;
19//! use heuropt_plot::pareto_front_svg;
20//!
21//! let space = ObjectiveSpace::new(vec![
22//!     Objective::minimize("f1"),
23//!     Objective::minimize("f2"),
24//! ]);
25//! let front = vec![
26//!     Candidate::new((), Evaluation::new(vec![0.0, 1.0])),
27//!     Candidate::new((), Evaluation::new(vec![0.5, 0.5])),
28//!     Candidate::new((), Evaluation::new(vec![1.0, 0.0])),
29//! ];
30//! let svg = pareto_front_svg(&front, &space, 600, 400, "Sample front");
31//! assert!(svg.starts_with("<svg"));
32//! assert!(svg.contains("</svg>"));
33//! ```
34
35use std::fmt::Write as _;
36
37use heuropt::core::candidate::Candidate;
38use heuropt::core::objective::ObjectiveSpace;
39
40/// Render a 2-objective Pareto front as an SVG scatter plot.
41///
42/// `width` and `height` are the SVG viewport dimensions in pixels.
43/// `title` is rendered at the top.
44///
45/// Points are plotted in minimization-oriented coordinates.
46///
47/// # Panics
48///
49/// If `objectives.len() != 2`.
50pub fn pareto_front_svg<D>(
51    front: &[Candidate<D>],
52    objectives: &ObjectiveSpace,
53    width: u32,
54    height: u32,
55    title: &str,
56) -> String {
57    assert_eq!(
58        objectives.len(),
59        2,
60        "pareto_front_svg requires exactly 2 objectives",
61    );
62    let oriented: Vec<[f64; 2]> = front
63        .iter()
64        .map(|c| {
65            let m = objectives.as_minimization(&c.evaluation.objectives);
66            [m[0], m[1]]
67        })
68        .collect();
69    let (xs_label, ys_label) = (
70        objectives.objectives[0].name.as_str(),
71        objectives.objectives[1].name.as_str(),
72    );
73
74    let (xmin, xmax) = bounds(oriented.iter().map(|p| p[0]));
75    let (ymin, ymax) = bounds(oriented.iter().map(|p| p[1]));
76    let xspan = (xmax - xmin).max(1e-12);
77    let yspan = (ymax - ymin).max(1e-12);
78
79    // Margins so axes/labels have room.
80    let m_left = 60.0_f64;
81    let m_right = 20.0_f64;
82    let m_top = 40.0_f64;
83    let m_bot = 50.0_f64;
84    let plot_w = width as f64 - m_left - m_right;
85    let plot_h = height as f64 - m_top - m_bot;
86
87    let to_x = |v: f64| m_left + (v - xmin) / xspan * plot_w;
88    // Y is inverted: lower minimization value → higher pixel.
89    let to_y = |v: f64| m_top + plot_h - (v - ymin) / yspan * plot_h;
90
91    let mut out = String::new();
92    let _ = writeln!(
93        out,
94        "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {width} {height}\" \
95        font-family=\"system-ui, sans-serif\" font-size=\"12\">",
96    );
97    let _ = writeln!(
98        out,
99        "  <rect x=\"0\" y=\"0\" width=\"{width}\" height=\"{height}\" fill=\"white\"/>",
100    );
101    let _ = writeln!(
102        out,
103        "  <text x=\"{x}\" y=\"22\" font-size=\"16\" font-weight=\"bold\">{title}</text>",
104        x = m_left,
105        title = escape_xml(title),
106    );
107
108    // Axes box.
109    let _ = writeln!(
110        out,
111        "  <rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"none\" stroke=\"#888\" />",
112        m_left, m_top, plot_w, plot_h,
113    );
114
115    // X-axis ticks (3 ticks).
116    for i in 0..=3 {
117        let t = i as f64 / 3.0;
118        let v = xmin + t * xspan;
119        let x = to_x(v);
120        let _ = writeln!(
121            out,
122            "  <line x1=\"{x}\" y1=\"{y0}\" x2=\"{x}\" y2=\"{y1}\" stroke=\"#888\" />",
123            y0 = m_top + plot_h,
124            y1 = m_top + plot_h + 5.0,
125        );
126        let _ = writeln!(
127            out,
128            "  <text x=\"{x}\" y=\"{y}\" text-anchor=\"middle\">{v:.3}</text>",
129            y = m_top + plot_h + 18.0,
130        );
131    }
132    // Y-axis ticks.
133    for i in 0..=3 {
134        let t = i as f64 / 3.0;
135        let v = ymin + t * yspan;
136        let y = to_y(v);
137        let _ = writeln!(
138            out,
139            "  <line x1=\"{x0}\" y1=\"{y}\" x2=\"{x1}\" y2=\"{y}\" stroke=\"#888\" />",
140            x0 = m_left - 5.0,
141            x1 = m_left,
142        );
143        let _ = writeln!(
144            out,
145            "  <text x=\"{x}\" y=\"{y}\" text-anchor=\"end\" dominant-baseline=\"middle\">{v:.3}</text>",
146            x = m_left - 8.0,
147        );
148    }
149
150    // Axis labels.
151    let _ = writeln!(
152        out,
153        "  <text x=\"{x}\" y=\"{y}\" text-anchor=\"middle\">{xs_label}</text>",
154        x = m_left + plot_w / 2.0,
155        y = height as f64 - 12.0,
156        xs_label = escape_xml(xs_label),
157    );
158    let _ = writeln!(
159        out,
160        "  <text x=\"15\" y=\"{y}\" text-anchor=\"middle\" \
161         transform=\"rotate(-90 15 {y})\">{ys_label}</text>",
162        y = m_top + plot_h / 2.0,
163        ys_label = escape_xml(ys_label),
164    );
165
166    // Points.
167    for p in &oriented {
168        let cx = to_x(p[0]);
169        let cy = to_y(p[1]);
170        let _ = writeln!(
171            out,
172            "  <circle cx=\"{cx:.2}\" cy=\"{cy:.2}\" r=\"3\" fill=\"#1f77b4\" \
173             stroke=\"#0d4a8a\" stroke-width=\"0.5\" />",
174        );
175    }
176
177    out.push_str("</svg>");
178    out
179}
180
181/// Render a per-generation "best fitness so far" trace as an SVG line
182/// plot. `bests[i]` is the best fitness *after* generation `i`.
183///
184/// `direction_minimize` controls which way is "improvement": `true`
185/// for minimize problems, `false` for maximize.
186pub fn convergence_svg(
187    bests: &[f64],
188    width: u32,
189    height: u32,
190    title: &str,
191    y_axis_label: &str,
192    _direction_minimize: bool,
193) -> String {
194    let n = bests.len();
195    if n == 0 {
196        return format!(
197            "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {width} {height}\">\
198             <text x=\"10\" y=\"20\">{}</text></svg>",
199            escape_xml(title)
200        );
201    }
202
203    let (ymin, ymax) = bounds(bests.iter().copied());
204    let yspan = (ymax - ymin).max(1e-12);
205    let xspan = (n - 1).max(1) as f64;
206
207    let m_left = 70.0_f64;
208    let m_right = 20.0_f64;
209    let m_top = 40.0_f64;
210    let m_bot = 50.0_f64;
211    let plot_w = width as f64 - m_left - m_right;
212    let plot_h = height as f64 - m_top - m_bot;
213
214    let to_x = |i: usize| m_left + (i as f64) / xspan * plot_w;
215    let to_y = |v: f64| m_top + plot_h - (v - ymin) / yspan * plot_h;
216
217    let mut out = String::new();
218    let _ = writeln!(
219        out,
220        "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {width} {height}\" \
221         font-family=\"system-ui, sans-serif\" font-size=\"12\">",
222    );
223    let _ = writeln!(
224        out,
225        "  <rect x=\"0\" y=\"0\" width=\"{width}\" height=\"{height}\" fill=\"white\"/>",
226    );
227    let _ = writeln!(
228        out,
229        "  <text x=\"{x}\" y=\"22\" font-size=\"16\" font-weight=\"bold\">{title}</text>",
230        x = m_left,
231        title = escape_xml(title),
232    );
233    let _ = writeln!(
234        out,
235        "  <rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"none\" stroke=\"#888\" />",
236        m_left, m_top, plot_w, plot_h,
237    );
238
239    // X axis: generation index.
240    for i in 0..=4 {
241        let t = i as f64 / 4.0;
242        let g = (t * (n - 1) as f64).round() as usize;
243        let x = to_x(g);
244        let _ = writeln!(
245            out,
246            "  <line x1=\"{x}\" y1=\"{y0}\" x2=\"{x}\" y2=\"{y1}\" stroke=\"#888\" />",
247            y0 = m_top + plot_h,
248            y1 = m_top + plot_h + 5.0,
249        );
250        let _ = writeln!(
251            out,
252            "  <text x=\"{x}\" y=\"{y}\" text-anchor=\"middle\">{g}</text>",
253            y = m_top + plot_h + 18.0,
254        );
255    }
256    // Y ticks.
257    for i in 0..=3 {
258        let t = i as f64 / 3.0;
259        let v = ymin + t * yspan;
260        let y = to_y(v);
261        let _ = writeln!(
262            out,
263            "  <line x1=\"{x0}\" y1=\"{y}\" x2=\"{x1}\" y2=\"{y}\" stroke=\"#888\" />",
264            x0 = m_left - 5.0,
265            x1 = m_left,
266        );
267        let _ = writeln!(
268            out,
269            "  <text x=\"{x}\" y=\"{y}\" text-anchor=\"end\" dominant-baseline=\"middle\">{v:.3e}</text>",
270            x = m_left - 8.0,
271        );
272    }
273
274    // Axis labels.
275    let _ = writeln!(
276        out,
277        "  <text x=\"{x}\" y=\"{y}\" text-anchor=\"middle\">generation</text>",
278        x = m_left + plot_w / 2.0,
279        y = height as f64 - 12.0,
280    );
281    let _ = writeln!(
282        out,
283        "  <text x=\"15\" y=\"{y}\" text-anchor=\"middle\" \
284         transform=\"rotate(-90 15 {y})\">{label}</text>",
285        y = m_top + plot_h / 2.0,
286        label = escape_xml(y_axis_label),
287    );
288
289    // Polyline.
290    let mut points = String::new();
291    for (i, &v) in bests.iter().enumerate() {
292        if i > 0 {
293            points.push(' ');
294        }
295        let _ = write!(points, "{:.2},{:.2}", to_x(i), to_y(v));
296    }
297    let _ = writeln!(
298        out,
299        "  <polyline points=\"{points}\" fill=\"none\" stroke=\"#1f77b4\" stroke-width=\"1.5\" />",
300    );
301
302    out.push_str("</svg>");
303    out
304}
305
306fn bounds<I: IntoIterator<Item = f64>>(it: I) -> (f64, f64) {
307    let mut lo = f64::INFINITY;
308    let mut hi = f64::NEG_INFINITY;
309    for v in it {
310        if v.is_finite() {
311            if v < lo {
312                lo = v;
313            }
314            if v > hi {
315                hi = v;
316            }
317        }
318    }
319    if lo.is_infinite() {
320        (0.0, 1.0)
321    } else if (hi - lo).abs() < f64::EPSILON {
322        // All points equal — give a small artificial span.
323        (lo - 0.5, hi + 0.5)
324    } else {
325        (lo, hi)
326    }
327}
328
329fn escape_xml(s: &str) -> String {
330    s.replace('&', "&amp;")
331        .replace('<', "&lt;")
332        .replace('>', "&gt;")
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use heuropt::core::evaluation::Evaluation;
339    use heuropt::core::objective::Objective;
340
341    #[test]
342    fn pareto_svg_well_formed() {
343        let space = ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")]);
344        let front = vec![
345            Candidate::new((), Evaluation::new(vec![0.0, 1.0])),
346            Candidate::new((), Evaluation::new(vec![1.0, 0.0])),
347        ];
348        let svg = pareto_front_svg(&front, &space, 400, 300, "test");
349        assert!(svg.starts_with("<svg"));
350        assert!(svg.contains("</svg>"));
351        assert!(svg.contains("<circle"));
352    }
353
354    #[test]
355    fn convergence_svg_well_formed() {
356        let bests = vec![10.0, 5.0, 2.0, 1.0, 0.5];
357        let svg = convergence_svg(&bests, 400, 300, "convergence", "best", true);
358        assert!(svg.starts_with("<svg"));
359        assert!(svg.contains("polyline"));
360    }
361
362    #[test]
363    fn convergence_empty_returns_valid_svg() {
364        let svg = convergence_svg(&[], 200, 100, "empty", "y", true);
365        assert!(svg.contains("<svg"));
366        assert!(svg.contains("</svg>"));
367    }
368}