Skip to main content

ridge_core/
svg.rs

1//! Server-side SVG rendering of a [`RidgeScene`] — the matplotlib-free
2//! replacement for `RidgeMap.plot_map` / `plot_annotation`.
3//!
4//! The same scene renders in the web frontend's `<canvas>`; this module is
5//! for final (vector, print-ready) artwork export.
6
7use std::fmt::Write;
8
9use serde::{Deserialize, Serialize};
10
11use crate::colormap::Rgb;
12use crate::geometry::{ColorKind, FigureLayout, RidgeScene, FIG_DPI};
13fn hex3(c: Rgb) -> String {
14    format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2])
15}
16
17fn esc(s: &str) -> String {
18    s.replace('&', "&amp;")
19        .replace('<', "&lt;")
20        .replace('>', "&gt;")
21}
22
23/// Upstream `label_verticalalignment`.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum VAlign {
27    Top,
28    Bottom,
29}
30
31/// The big `plot_map` label.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct LabelStyle {
34    pub text: String,
35    pub color: Rgb,
36    /// Axes-fraction coordinates (0..1), like upstream `label_x` / `label_y`.
37    pub x: f64,
38    pub y: f64,
39    pub size_pt: f64,
40    pub vertical_alignment: VAlign,
41    pub font_family: String,
42    pub background: bool,
43}
44
45/// A `plot_annotation` marker.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct Annotation {
48    pub label: String,
49    /// Axes-fraction coordinates of the dot.
50    pub x: f64,
51    pub y: f64,
52    /// Label offset in axes fractions, like upstream `x_offset` / `y_offset`.
53    pub x_offset: f64,
54    pub y_offset: f64,
55    pub label_size_pt: f64,
56    /// Marker size in points (`annotation_size`).
57    pub dot_pt: f64,
58    pub color: Rgb,
59    pub background: bool,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct PlotStyle {
64    pub line: LineColorSpec,
65    pub kind: ColorKind,
66    pub background: Rgb,
67    /// Line width in points (matplotlib `linewidth`).
68    pub linewidth_pt: f64,
69    /// Figure width in inches (upstream `size_scale`).
70    pub size_scale: f64,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub label: Option<LabelStyle>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub annotation: Option<Annotation>,
75}
76
77/// Serde-friendly mirror of `LineColor`.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79#[serde(rename_all = "lowercase", tag = "type")]
80pub enum LineColorSpec {
81    Solid { rgb: Rgb },
82    Map { name: crate::colormap::Colormap },
83}
84
85impl From<&crate::colormap::LineColor> for LineColorSpec {
86    fn from(l: &crate::colormap::LineColor) -> Self {
87        match l {
88            crate::colormap::LineColor::Solid(rgb) => LineColorSpec::Solid { rgb: *rgb },
89            crate::colormap::LineColor::Map(cm) => LineColorSpec::Map { name: *cm },
90        }
91    }
92}
93
94impl LineColorSpec {
95    fn to_line(&self) -> crate::colormap::LineColor {
96        match self {
97            LineColorSpec::Solid { rgb } => crate::colormap::LineColor::Solid(*rgb),
98            LineColorSpec::Map { name } => crate::colormap::LineColor::Map(*name),
99        }
100    }
101}
102
103/// Render the scene to a standalone SVG document.
104pub fn render_svg(scene: &RidgeScene, style: &PlotStyle) -> String {
105    let layout = &scene.layout;
106    let line = style.line.to_line();
107    let lw_px = style.linewidth_pt / 72.0
108        * FIG_DPI
109        * (layout.width_px / (style.size_scale * FIG_DPI)).max(1e-9);
110    // ^ linewidth scales with the figure just like matplotlib points do.
111
112    let mut s = String::with_capacity(1 << 20);
113    let _ = writeln!(
114        s,
115        r#"<?xml version="1.0" encoding="UTF-8"?>
116<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">"#,
117        w = layout.width_px,
118        h = layout.height_px,
119    );
120
121    // Background.
122    let bg = hex3(style.background);
123    let _ = writeln!(
124        s,
125        r#"  <rect x="0" y="0" width="{w}" height="{h}" fill="{bg}" />"#,
126        w = layout.width_px,
127        h = layout.height_px
128    );
129
130    // Clip everything to the axes rect (matplotlib clips to the axes).
131    let _ = writeln!(
132        s,
133        r#"  <defs><clipPath id="axes"><rect x="{x}" y="{y}" width="{cw}" height="{ch}" /></clipPath></defs>"#,
134        x = layout.axes[0],
135        y = layout.axes[1],
136        cw = layout.axes[2] - layout.axes[0],
137        ch = layout.axes[3] - layout.axes[1],
138    );
139    let _ = writeln!(s, r#"  <g clip-path="url(#axes)">"#);
140
141    for (idx, row) in scene.rows.iter().enumerate() {
142        let runs = row.runs();
143        if runs.is_empty() {
144            continue;
145        }
146        // Fill polygons (baseline -> curve -> baseline), one per run.
147        let fill_path = runs
148            .iter()
149            .map(|&(a, b)| {
150                let mut d = String::new();
151                let (x0, y_base) = layout.to_px(a as f64, row.baseline);
152                let _ = write!(d, "M {x0:.2} {y_base:.2}");
153                for i in a..b {
154                    let (x, y) = layout.to_px(i as f64, row.y[i]);
155                    let _ = write!(d, " L {x:.2} {y:.2}");
156                }
157                let (x1, _) = layout.to_px((b - 1) as f64, row.baseline);
158                let _ = write!(d, " L {x1:.2} {y_base:.2} Z");
159                d
160            })
161            .collect::<Vec<_>>()
162            .join(" ");
163        let _ = writeln!(
164            s,
165            r#"    <path d="{fill_path}" fill="{bg}" stroke="none" />"#
166        );
167
168        // Strokes.
169        match (style.kind, &line) {
170            (ColorKind::Elevation, crate::colormap::LineColor::Map(_)) => {
171                for &(a, b) in &runs {
172                    let mut d = String::new();
173                    let (mut px, mut py) = layout.to_px(a as f64, row.y[a]);
174                    let _ = write!(d, "M {px:.2} {py:.2}");
175                    for i in (a + 1)..b {
176                        let color = scene.elevation_color(&line, row.y[i - 1] - row.baseline);
177                        let (x, y) = layout.to_px(i as f64, row.y[i]);
178                        let _ = write!(d, " L {x:.2} {y:.2}");
179                        let _ = writeln!(
180                            s,
181                            r#"    <path d="{}" fill="none" stroke="{}" stroke-width="{lw:.3}" stroke-linecap="round" stroke-linejoin="round" />"#,
182                            d,
183                            hex3(color),
184                            lw = lw_px
185                        );
186                        d = format!("M {x:.2} {y:.2}");
187                        px = x;
188                        py = y;
189                    }
190                    let _ = (px, py);
191                }
192            }
193            _ => {
194                let color = scene.gradient_color(&line, idx);
195                let path = runs
196                    .iter()
197                    .filter_map(|&(a, b)| {
198                        if b - a < 2 {
199                            return None;
200                        }
201                        let mut d = String::new();
202                        let (x, y) = layout.to_px(a as f64, row.y[a]);
203                        let _ = write!(d, "M {x:.2} {y:.2}");
204                        for i in (a + 1)..b {
205                            let (x, y) = layout.to_px(i as f64, row.y[i]);
206                            let _ = write!(d, " L {x:.2} {y:.2}");
207                        }
208                        Some(d)
209                    })
210                    .collect::<Vec<_>>()
211                    .join(" ");
212                if !path.is_empty() {
213                    let _ = writeln!(
214                        s,
215                        r#"    <path d="{path}" fill="none" stroke="{color}" stroke-width="{lw:.3}" stroke-linecap="round" stroke-linejoin="round" />"#,
216                        color = hex3(color),
217                        lw = lw_px
218                    );
219                }
220            }
221        }
222    }
223    let _ = writeln!(s, "  </g>");
224
225    if let Some(label) = &style.label {
226        render_label(&mut s, layout, label, &bg);
227    }
228    if let Some(ann) = &style.annotation {
229        render_annotation(&mut s, layout, ann, &bg);
230    }
231
232    let _ = writeln!(s, "</svg>");
233    s
234}
235
236fn text_block(text: &str, fs: f64) -> (Vec<String>, f64, f64) {
237    // (lines, width_px, height_px) with a chunky estimate for Cinzel-like fonts.
238    let lines: Vec<String> = text.split('\n').map(|l| l.to_string()).collect();
239    let maxlen = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as f64;
240    let width = maxlen * fs * 0.62;
241    let height = lines.len() as f64 * fs * 1.2;
242    (lines, width, height)
243}
244
245fn render_label(s: &mut String, layout: &FigureLayout, label: &LabelStyle, bg_hex: &str) {
246    let fs = label.size_pt / 72.0 * FIG_DPI;
247    let (lines, tw, th) = text_block(&label.text, fs);
248    if lines.iter().all(|l| l.is_empty()) {
249        return;
250    }
251    let (ax, ay) = layout.frac_to_px(label.x, label.y);
252    let pad = fs * 0.25;
253    let (rect_top, first_baseline) = match label.vertical_alignment {
254        VAlign::Top => (ay, ay + 0.9 * fs),
255        VAlign::Bottom => (ay - th, ay - 0.15 * fs),
256    };
257    if label.background {
258        let _ = writeln!(
259            s,
260            r#"  <rect x="{x:.2}" y="{y:.2}" width="{w:.2}" height="{h:.2}" fill="{bg}" />"#,
261            x = ax - pad,
262            y = rect_top - pad,
263            w = tw + 2.0 * pad,
264            h = th + 1.4 * pad,
265            bg = bg_hex
266        );
267    }
268    let _ = writeln!(
269        s,
270        r#"  <text x="{ax:.2}" y="{y:.2}" font-family="{family}, serif" font-size="{fs:.2}" fill="{color}" xml:space="preserve">"#,
271        y = first_baseline,
272        family = esc(&label.font_family),
273        color = hex3(label.color)
274    );
275    for (k, line_text) in lines.iter().enumerate() {
276        let dy = if k == 0 { 0.0 } else { 1.2 * fs };
277        let _ = writeln!(
278            s,
279            r#"    <tspan x="{ax:.2}" dy="{dy:.2}">{}</tspan>"#,
280            esc(line_text)
281        );
282    }
283    let _ = writeln!(s, "  </text>");
284}
285
286fn render_annotation(s: &mut String, layout: &FigureLayout, ann: &Annotation, bg_hex: &str) {
287    let (dx, dy) = layout.frac_to_px(ann.x, ann.y);
288    let r_px = ann.dot_pt / 72.0 * FIG_DPI / 2.0;
289    let _ = writeln!(
290        s,
291        r#"  <circle cx="{dx:.2}" cy="{dy:.2}" r="{r:.2}" fill="{color}" />"#,
292        r = r_px,
293        color = hex3(ann.color)
294    );
295    if ann.label.is_empty() {
296        return;
297    }
298    let fs = ann.label_size_pt / 72.0 * FIG_DPI;
299    let (lx, ly) = layout.frac_to_px(ann.x + ann.x_offset, ann.y + ann.y_offset);
300    let (lines, tw, th) = text_block(&ann.label, fs);
301    let pad = fs * 0.25;
302    if ann.background {
303        let _ = writeln!(
304            s,
305            r#"  <rect x="{x:.2}" y="{y:.2}" width="{w:.2}" height="{h:.2}" fill="{bg}" />"#,
306            x = lx - pad,
307            y = ly - 0.15 * fs - pad,
308            w = tw + 2.0 * pad,
309            h = th + 1.4 * pad,
310            bg = bg_hex
311        );
312    }
313    let _ = writeln!(
314        s,
315        r#"  <text x="{lx:.2}" y="{y:.2}" font-family="Cinzel, serif" font-size="{fs:.2}" fill="{color}" xml:space="preserve">{}</text>"#,
316        esc(&lines.join(" ")),
317        y = ly - 0.15 * fs,
318        color = hex3(ann.color)
319    );
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::srtm::SyntheticSource;
326
327    fn scene() -> RidgeScene {
328        let src = SyntheticSource { side: 1201 };
329        crate::geometry::build_scene(
330            &src,
331            &crate::DEFAULT_BBOX,
332            12,
333            16,
334            0.0,
335            false,
336            0,
337            false,
338            10.0,
339            3,
340            40.0,
341            20.0,
342        )
343        .unwrap()
344    }
345
346    #[test]
347    fn svg_contains_lines_and_background() {
348        let sc = scene();
349        let style = PlotStyle {
350            line: LineColorSpec::Solid { rgb: [0, 0, 0] },
351            kind: ColorKind::Gradient,
352            background: [236, 232, 236],
353            linewidth_pt: 2.0,
354            size_scale: 20.0,
355            label: Some(LabelStyle {
356                text: "The White\nMountains".into(),
357                color: [0, 0, 0],
358                x: 0.62,
359                y: 0.15,
360                size_pt: 60.0,
361                vertical_alignment: VAlign::Bottom,
362                font_family: "Cinzel".into(),
363                background: true,
364            }),
365            annotation: None,
366        };
367        let svg = render_svg(&sc, &style);
368        assert!(svg.starts_with("<?xml"));
369        assert!(svg.contains("fill=\"#ece8ec\"")); // upstream default background
370        assert!(svg.contains("<path"));
371        assert!(svg.contains("The White"));
372        assert!(svg.contains("Mountains"));
373        assert!(svg.contains("Cinzel"));
374        // One fill + one stroke path per row with data.
375        assert!(svg.matches("<path").count() >= sc.rows.len());
376    }
377
378    #[test]
379    fn svg_elevation_kind() {
380        let sc = scene();
381        let style = PlotStyle {
382            line: LineColorSpec::Map {
383                name: crate::colormap::Colormap::Ocean,
384            },
385            kind: ColorKind::Elevation,
386            background: [236, 232, 236],
387            linewidth_pt: 2.0,
388            size_scale: 20.0,
389            label: None,
390            annotation: Some(Annotation {
391                label: "SUMMIT".into(),
392                x: 0.5,
393                y: 0.5,
394                x_offset: 0.01,
395                y_offset: 0.01,
396                label_size_pt: 20.0,
397                dot_pt: 8.0,
398                color: [0, 0, 0],
399                background: false,
400            }),
401        };
402        let svg = render_svg(&sc, &style);
403        assert!(svg.contains("<circle"));
404        assert!(svg.contains("SUMMIT"));
405        // Per-segment strokes produce more paths than rows.
406        assert!(svg.matches("<path").count() > sc.rows.len());
407    }
408}