Skip to main content

ggplot_rs/render/
svg_backend.rs

1//! A self-contained SVG `DrawBackend` — a *second* backend (no plotters),
2//! proving the `DrawBackend` abstraction: the same `PlotRenderer` drives it.
3//!
4//! It emits SVG elements directly, so SVG output needs no glyph rasterization
5//! (text is `<text>` with font attributes the viewer renders).
6
7use super::backend::{
8    DrawBackend, FontFace, LineStyle, PointStyle, RectStyle, TextAnchor, TextStyle,
9};
10use super::{Rect, RenderError};
11
12/// Accumulates SVG markup for a plot rendered via [`DrawBackend`].
13pub struct SvgBackend {
14    plot_area: Rect,
15    total_area: Rect,
16    body: String,
17    tooltip: Option<String>,
18    axis_key: Option<String>,
19}
20
21impl SvgBackend {
22    pub fn new(width: u32, height: u32, plot_area: Rect) -> Self {
23        SvgBackend {
24            plot_area,
25            total_area: Rect {
26                x: 0.0,
27                y: 0.0,
28                width: width as f64,
29                height: height as f64,
30            },
31            body: String::new(),
32            tooltip: None,
33            axis_key: None,
34        }
35    }
36
37    /// Emit `<tag attrs/>`, or `<tag attrs data-x=…><title>tip</title></tag>`
38    /// when a tooltip / axis key is set (native SVG hover + host grouping).
39    fn push_mark(&mut self, tag: &str, attrs: &str) {
40        let dx = match &self.axis_key {
41            Some(k) => format!(" data-x=\"{}\"", escape(k)),
42            None => String::new(),
43        };
44        let mark = match &self.tooltip {
45            Some(t) => format!("<{tag} {attrs}{dx}><title>{}</title></{tag}>", escape(t)),
46            None if !dx.is_empty() => format!("<{tag} {attrs}{dx}/>"),
47            None => format!("<{tag} {attrs}/>"),
48        };
49        self.body.push_str(&mark);
50    }
51
52    /// Wrap the accumulated elements in a complete `<svg>` document. The
53    /// `data-plot` attribute records the panel (data) area in viewBox units so a
54    /// host can map screen coordinates back to data (e.g. for zoom/crosshair).
55    pub fn finish(self) -> String {
56        let p = &self.plot_area;
57        format!(
58            "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\" \
59             viewBox=\"0 0 {0} {1}\" data-plot=\"{:.2} {:.2} {:.2} {:.2}\">{}</svg>",
60            self.total_area.width as i64,
61            self.total_area.height as i64,
62            p.x,
63            p.y,
64            p.width,
65            p.height,
66            self.body
67        )
68    }
69}
70
71fn rgb((r, g, b): (u8, u8, u8)) -> String {
72    format!("#{r:02X}{g:02X}{b:02X}")
73}
74
75fn escape(s: &str) -> String {
76    s.replace('&', "&amp;")
77        .replace('<', "&lt;")
78        .replace('>', "&gt;")
79}
80
81fn points(pts: &[(f64, f64)]) -> String {
82    pts.iter()
83        .map(|(x, y)| format!("{x:.2},{y:.2}"))
84        .collect::<Vec<_>>()
85        .join(" ")
86}
87
88impl DrawBackend for SvgBackend {
89    fn plot_area(&self) -> Rect {
90        self.plot_area.clone()
91    }
92    fn total_area(&self) -> Rect {
93        self.total_area.clone()
94    }
95
96    fn set_tooltip(&mut self, tooltip: Option<String>) {
97        self.tooltip = tooltip;
98    }
99
100    fn set_mark_axis(&mut self, key: Option<String>) {
101        self.axis_key = key;
102    }
103
104    fn draw_circle(
105        &mut self,
106        (cx, cy): (f64, f64),
107        radius: f64,
108        style: &PointStyle,
109    ) -> Result<(), RenderError> {
110        let attrs = format!(
111            "cx=\"{cx:.2}\" cy=\"{cy:.2}\" r=\"{radius:.2}\" fill=\"{}\" fill-opacity=\"{:.3}\"",
112            rgb(style.color),
113            style.alpha
114        );
115        self.push_mark("circle", &attrs);
116        Ok(())
117    }
118
119    fn draw_line(&mut self, pts: &[(f64, f64)], style: &LineStyle) -> Result<(), RenderError> {
120        let dash = match style
121            .linetype
122            .pattern()
123            .iter()
124            .flat_map(|(d, g)| [*d, *g])
125            .map(|v| format!("{v}"))
126            .collect::<Vec<_>>()
127            .join(",")
128        {
129            s if s.is_empty() => String::new(),
130            s => format!(" stroke-dasharray=\"{s}\""),
131        };
132        let attrs = format!(
133            "points=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{:.2}\" stroke-opacity=\"{:.3}\"{}",
134            points(pts), rgb(style.color), style.width, style.alpha, dash
135        );
136        self.push_mark("polyline", &attrs);
137        Ok(())
138    }
139
140    fn draw_rect(
141        &mut self,
142        (x0, y0): (f64, f64),
143        (x1, y1): (f64, f64),
144        style: &RectStyle,
145    ) -> Result<(), RenderError> {
146        let (x, y) = (x0.min(x1), y0.min(y1));
147        let (w, h) = ((x1 - x0).abs(), (y1 - y0).abs());
148        let fill = style.fill.map(rgb).unwrap_or_else(|| "none".into());
149        let stroke = style.stroke.map(rgb).unwrap_or_else(|| "none".into());
150        let attrs = format!(
151            "x=\"{x:.2}\" y=\"{y:.2}\" width=\"{w:.2}\" height=\"{h:.2}\" fill=\"{fill}\" \
152             fill-opacity=\"{:.3}\" stroke=\"{stroke}\" stroke-width=\"{:.2}\"",
153            style.alpha, style.stroke_width
154        );
155        self.push_mark("rect", &attrs);
156        Ok(())
157    }
158
159    fn draw_polygon(&mut self, pts: &[(f64, f64)], style: &RectStyle) -> Result<(), RenderError> {
160        let fill = style.fill.map(rgb).unwrap_or_else(|| "none".into());
161        let stroke = style.stroke.map(rgb).unwrap_or_else(|| "none".into());
162        let attrs = format!(
163            "points=\"{}\" fill=\"{fill}\" fill-opacity=\"{:.3}\" stroke=\"{stroke}\" stroke-width=\"{:.2}\"",
164            points(pts), style.alpha, style.stroke_width
165        );
166        self.push_mark("polygon", &attrs);
167        Ok(())
168    }
169
170    fn draw_text(
171        &mut self,
172        text: &str,
173        (x, y): (f64, f64),
174        style: &TextStyle,
175    ) -> Result<(), RenderError> {
176        let anchor = match style.anchor {
177            TextAnchor::Start => "start",
178            TextAnchor::Middle => "middle",
179            TextAnchor::End => "end",
180        };
181        let family = style.family.as_deref().unwrap_or("sans-serif");
182        let weight = if style.face == FontFace::Bold {
183            " font-weight=\"bold\""
184        } else {
185            ""
186        };
187        let fstyle = if style.face == FontFace::Italic {
188            " font-style=\"italic\""
189        } else {
190            ""
191        };
192        let transform = if style.angle.abs() > 0.01 {
193            format!(" transform=\"rotate({:.1} {x:.2} {y:.2})\"", style.angle)
194        } else {
195            String::new()
196        };
197        self.body.push_str(&format!(
198            "<text x=\"{x:.2}\" y=\"{y:.2}\" font-size=\"{:.2}\" text-anchor=\"{anchor}\" \
199             dominant-baseline=\"middle\" font-family=\"{family}\"{weight}{fstyle} fill=\"{}\"{transform}>{}</text>",
200            style.size, rgb(style.color), escape(text)
201        ));
202        Ok(())
203    }
204}