Skip to main content

r/interpreter/graphics/
svg_device.rs

1//! SVG renderer — converts a `PlotState` into an SVG string.
2
3use svg::node::element::{Circle, Group, Line, Polyline, Rectangle, Text as SvgText};
4use svg::Document;
5
6use super::plot_data::{PlotItem, PlotState};
7
8const DPI: f64 = 96.0;
9const MARGIN_LEFT: f64 = 70.0;
10const MARGIN_RIGHT: f64 = 30.0;
11const MARGIN_TOP: f64 = 50.0;
12const MARGIN_BOTTOM: f64 = 60.0;
13
14/// Render a PlotState to an SVG string.
15pub fn render_svg(state: &PlotState, width_in: f64, height_in: f64) -> String {
16    let w = width_in * DPI;
17    let h = height_in * DPI;
18    let plot_x0 = MARGIN_LEFT;
19    let plot_x1 = w - MARGIN_RIGHT;
20    let plot_y0 = MARGIN_TOP;
21    let plot_y1 = h - MARGIN_BOTTOM;
22
23    // Compute data bounds from all items
24    let (dx0, dx1, dy0, dy1) = data_bounds(state);
25
26    let map_x = |x: f64| plot_x0 + (x - dx0) / (dx1 - dx0) * (plot_x1 - plot_x0);
27    let map_y = |y: f64| plot_y1 - (y - dy0) / (dy1 - dy0) * (plot_y1 - plot_y0);
28
29    let mut doc = Document::new()
30        .set("width", w)
31        .set("height", h)
32        .set("viewBox", (0.0, 0.0, w, h));
33
34    // White background
35    doc = doc.add(
36        Rectangle::new()
37            .set("width", w)
38            .set("height", h)
39            .set("fill", "white"),
40    );
41
42    // Plot area border
43    doc = doc.add(
44        Rectangle::new()
45            .set("x", plot_x0)
46            .set("y", plot_y0)
47            .set("width", plot_x1 - plot_x0)
48            .set("height", plot_y1 - plot_y0)
49            .set("fill", "none")
50            .set("stroke", "#cccccc"),
51    );
52
53    // Axes
54    doc = add_axes(&doc, plot_x0, plot_x1, plot_y0, plot_y1, dx0, dx1, dy0, dy1);
55
56    // Title
57    if let Some(title) = &state.title {
58        doc = doc.add(
59            SvgText::new(title.clone())
60                .set("x", w / 2.0)
61                .set("y", 25.0)
62                .set("text-anchor", "middle")
63                .set("font-size", 16)
64                .set("font-weight", "bold"),
65        );
66    }
67
68    // Axis labels
69    if let Some(xlab) = &state.x_label {
70        doc = doc.add(
71            SvgText::new(xlab.clone())
72                .set("x", (plot_x0 + plot_x1) / 2.0)
73                .set("y", h - 10.0)
74                .set("text-anchor", "middle")
75                .set("font-size", 12),
76        );
77    }
78    if let Some(ylab) = &state.y_label {
79        doc = doc.add(
80            SvgText::new(ylab.clone())
81                .set("x", 15.0)
82                .set("y", (plot_y0 + plot_y1) / 2.0)
83                .set("text-anchor", "middle")
84                .set("font-size", 12)
85                .set(
86                    "transform",
87                    format!("rotate(-90, 15, {})", (plot_y0 + plot_y1) / 2.0),
88                ),
89        );
90    }
91
92    // Render items
93    let mut items_group = Group::new();
94    for item in &state.items {
95        items_group = render_item(items_group, item, &map_x, &map_y);
96    }
97    doc = doc.add(items_group);
98
99    doc.to_string()
100}
101
102fn rgba_to_svg(c: [u8; 4]) -> String {
103    if c[3] == 255 {
104        format!("rgb({},{},{})", c[0], c[1], c[2])
105    } else {
106        format!(
107            "rgba({},{},{},{})",
108            c[0],
109            c[1],
110            c[2],
111            f64::from(c[3]) / 255.0
112        )
113    }
114}
115
116fn data_bounds(state: &PlotState) -> (f64, f64, f64, f64) {
117    let mut xmin = f64::INFINITY;
118    let mut xmax = f64::NEG_INFINITY;
119    let mut ymin = f64::INFINITY;
120    let mut ymax = f64::NEG_INFINITY;
121
122    for item in &state.items {
123        match item {
124            PlotItem::Points { x, y, .. } | PlotItem::Line { x, y, .. } => {
125                for &v in x {
126                    if v < xmin {
127                        xmin = v;
128                    }
129                    if v > xmax {
130                        xmax = v;
131                    }
132                }
133                for &v in y {
134                    if v < ymin {
135                        ymin = v;
136                    }
137                    if v > ymax {
138                        ymax = v;
139                    }
140                }
141            }
142            PlotItem::Bars { x, heights, .. } => {
143                for &v in x {
144                    if v < xmin {
145                        xmin = v;
146                    }
147                    if v > xmax {
148                        xmax = v;
149                    }
150                }
151                for &v in heights {
152                    if v > ymax {
153                        ymax = v;
154                    }
155                }
156                if 0.0 < ymin {
157                    ymin = 0.0;
158                }
159            }
160            PlotItem::HLine { y, .. } => {
161                if *y < ymin {
162                    ymin = *y;
163                }
164                if *y > ymax {
165                    ymax = *y;
166                }
167            }
168            PlotItem::VLine { x, .. } => {
169                if *x < xmin {
170                    xmin = *x;
171                }
172                if *x > xmax {
173                    xmax = *x;
174                }
175            }
176            PlotItem::Text { x, y, .. } => {
177                if *x < xmin {
178                    xmin = *x;
179                }
180                if *x > xmax {
181                    xmax = *x;
182                }
183                if *y < ymin {
184                    ymin = *y;
185                }
186                if *y > ymax {
187                    ymax = *y;
188                }
189            }
190            PlotItem::BoxPlot {
191                positions, spreads, ..
192            } => {
193                for &p in positions {
194                    if p < xmin {
195                        xmin = p;
196                    }
197                    if p > xmax {
198                        xmax = p;
199                    }
200                }
201                for s in spreads {
202                    if s.lower_whisker < ymin {
203                        ymin = s.lower_whisker;
204                    }
205                    if s.upper_whisker > ymax {
206                        ymax = s.upper_whisker;
207                    }
208                }
209            }
210        }
211    }
212
213    if let Some((lo, hi)) = state.x_lim {
214        xmin = lo;
215        xmax = hi;
216    }
217    if let Some((lo, hi)) = state.y_lim {
218        ymin = lo;
219        ymax = hi;
220    }
221
222    // Add 4% padding
223    let xpad = (xmax - xmin).abs() * 0.04;
224    let ypad = (ymax - ymin).abs() * 0.04;
225    if xmin == xmax {
226        xmin -= 1.0;
227        xmax += 1.0;
228    }
229    if ymin == ymax {
230        ymin -= 1.0;
231        ymax += 1.0;
232    }
233
234    (xmin - xpad, xmax + xpad, ymin - ypad, ymax + ypad)
235}
236
237fn render_item(
238    mut group: Group,
239    item: &PlotItem,
240    map_x: &dyn Fn(f64) -> f64,
241    map_y: &dyn Fn(f64) -> f64,
242) -> Group {
243    match item {
244        PlotItem::Points {
245            x, y, color, size, ..
246        } => {
247            let fill = rgba_to_svg(*color);
248            for (&xi, &yi) in x.iter().zip(y.iter()) {
249                group = group.add(
250                    Circle::new()
251                        .set("cx", map_x(xi))
252                        .set("cy", map_y(yi))
253                        .set("r", *size as f64)
254                        .set("fill", fill.as_str()),
255                );
256            }
257        }
258        PlotItem::Line {
259            x, y, color, width, ..
260        } => {
261            let points: String = x
262                .iter()
263                .zip(y.iter())
264                .map(|(&xi, &yi)| format!("{},{}", map_x(xi), map_y(yi)))
265                .collect::<Vec<_>>()
266                .join(" ");
267            group = group.add(
268                Polyline::new()
269                    .set("points", points)
270                    .set("fill", "none")
271                    .set("stroke", rgba_to_svg(*color))
272                    .set("stroke-width", *width as f64),
273            );
274        }
275        PlotItem::Bars {
276            x,
277            heights,
278            color,
279            width,
280            ..
281        } => {
282            let fill = rgba_to_svg(*color);
283            for (&xi, &hi) in x.iter().zip(heights.iter()) {
284                let sx = map_x(xi - width / 2.0);
285                let sy = map_y(hi);
286                let sw = map_x(xi + width / 2.0) - sx;
287                let sh = map_y(0.0) - sy;
288                group = group.add(
289                    Rectangle::new()
290                        .set("x", sx)
291                        .set("y", sy)
292                        .set("width", sw.abs())
293                        .set("height", sh.abs())
294                        .set("fill", fill.as_str())
295                        .set("stroke", "black")
296                        .set("stroke-width", 0.5),
297                );
298            }
299        }
300        PlotItem::HLine { y, color, width } => {
301            let sy = map_y(*y);
302            group = group.add(
303                Line::new()
304                    .set("x1", map_x(f64::NEG_INFINITY).max(0.0))
305                    .set("x2", map_x(f64::INFINITY).min(10000.0))
306                    .set("y1", sy)
307                    .set("y2", sy)
308                    .set("stroke", rgba_to_svg(*color))
309                    .set("stroke-width", *width as f64),
310            );
311        }
312        PlotItem::VLine { x, color, width } => {
313            let sx = map_x(*x);
314            group = group.add(
315                Line::new()
316                    .set("x1", sx)
317                    .set("x2", sx)
318                    .set("y1", map_y(f64::NEG_INFINITY).min(10000.0))
319                    .set("y2", map_y(f64::INFINITY).max(0.0))
320                    .set("stroke", rgba_to_svg(*color))
321                    .set("stroke-width", *width as f64),
322            );
323        }
324        PlotItem::Text { x, y, text, color } => {
325            group = group.add(
326                SvgText::new(text.clone())
327                    .set("x", map_x(*x))
328                    .set("y", map_y(*y))
329                    .set("fill", rgba_to_svg(*color))
330                    .set("font-size", 12),
331            );
332        }
333        PlotItem::BoxPlot { .. } => {
334            // Box plots in SVG are complex — defer to future work
335        }
336    }
337    group
338}
339
340#[allow(clippy::too_many_arguments)]
341fn add_axes(
342    doc: &Document,
343    px0: f64,
344    px1: f64,
345    py0: f64,
346    py1: f64,
347    dx0: f64,
348    dx1: f64,
349    dy0: f64,
350    dy1: f64,
351) -> Document {
352    let mut d = doc.clone();
353    let x_ticks = nice_ticks(dx0, dx1, 6);
354    let y_ticks = nice_ticks(dy0, dy1, 6);
355    let map_x = |v: f64| px0 + (v - dx0) / (dx1 - dx0) * (px1 - px0);
356    let map_y = |v: f64| py1 - (v - dy0) / (dy1 - dy0) * (py1 - py0);
357
358    for &t in &x_ticks {
359        let sx = map_x(t);
360        d = d.add(
361            Line::new()
362                .set("x1", sx)
363                .set("x2", sx)
364                .set("y1", py1)
365                .set("y2", py1 + 5.0)
366                .set("stroke", "black"),
367        );
368        d = d.add(
369            SvgText::new(format_tick(t))
370                .set("x", sx)
371                .set("y", py1 + 18.0)
372                .set("text-anchor", "middle")
373                .set("font-size", 10),
374        );
375    }
376    for &t in &y_ticks {
377        let sy = map_y(t);
378        d = d.add(
379            Line::new()
380                .set("x1", px0 - 5.0)
381                .set("x2", px0)
382                .set("y1", sy)
383                .set("y2", sy)
384                .set("stroke", "black"),
385        );
386        d = d.add(
387            SvgText::new(format_tick(t))
388                .set("x", px0 - 8.0)
389                .set("y", sy + 4.0)
390                .set("text-anchor", "end")
391                .set("font-size", 10),
392        );
393    }
394    d
395}
396
397fn nice_ticks(lo: f64, hi: f64, target: usize) -> Vec<f64> {
398    let range = hi - lo;
399    if range <= 0.0 {
400        return vec![lo];
401    }
402    let rough = range / target as f64;
403    let mag = 10f64.powf(rough.log10().floor());
404    let step = if rough / mag < 1.5 {
405        mag
406    } else if rough / mag < 3.5 {
407        2.0 * mag
408    } else if rough / mag < 7.5 {
409        5.0 * mag
410    } else {
411        10.0 * mag
412    };
413    let start = (lo / step).ceil() * step;
414    let mut ticks = Vec::new();
415    let mut t = start;
416    while t <= hi + step * 0.01 {
417        ticks.push(t);
418        t += step;
419    }
420    ticks
421}
422
423fn format_tick(v: f64) -> String {
424    if v == v.floor() && v.abs() < 1e6 {
425        format!("{}", v as i64)
426    } else {
427        format!("{:.2}", v)
428    }
429}