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::{LineColor, Rgb};
12use crate::geometry::{ColorKind, FigureLayout, RidgeRow, 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/// The `L x y` segments through `cols` at their y values, in order.
104fn line_to(layout: &FigureLayout, row: &RidgeRow, cols: impl IntoIterator<Item = usize>) -> String {
105    let mut d = String::new();
106    for i in cols {
107        let (x, y) = layout.to_px(i as f64, row.y[i]);
108        let _ = write!(d, " L {x:.2} {y:.2}");
109    }
110    d
111}
112
113/// Render the scene to a standalone SVG document.
114pub fn render_svg(scene: &RidgeScene, style: &PlotStyle) -> String {
115    let layout = &scene.layout;
116    let line = style.line.to_line();
117    let lw_px = style.linewidth_pt / 72.0
118        * FIG_DPI
119        * (layout.width_px / (style.size_scale * FIG_DPI)).max(1e-9);
120    // ^ linewidth scales with the figure just like matplotlib points do.
121
122    let mut s = String::with_capacity(1 << 20);
123    let _ = writeln!(
124        s,
125        r#"<?xml version="1.0" encoding="UTF-8"?>
126<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">"#,
127        w = layout.width_px,
128        h = layout.height_px,
129    );
130
131    // Background.
132    let bg = hex3(style.background);
133    let _ = writeln!(
134        s,
135        r#"  <rect x="0" y="0" width="{w}" height="{h}" fill="{bg}" />"#,
136        w = layout.width_px,
137        h = layout.height_px
138    );
139
140    // Clip everything to the axes rect (matplotlib clips to the axes).
141    let _ = writeln!(
142        s,
143        r#"  <defs><clipPath id="axes"><rect x="{x}" y="{y}" width="{cw}" height="{ch}" /></clipPath></defs>"#,
144        x = layout.axes[0],
145        y = layout.axes[1],
146        cw = layout.axes[2] - layout.axes[0],
147        ch = layout.axes[3] - layout.axes[1],
148    );
149    let _ = writeln!(s, r#"  <g clip-path="url(#axes)">"#);
150
151    for (idx, row) in scene.rows.iter().enumerate() {
152        let runs = row.runs();
153        if runs.is_empty() {
154            continue;
155        }
156        // Fill polygons (baseline -> curve -> baseline), one per run.
157        let fill_path = runs
158            .iter()
159            .map(|&(a, b)| {
160                let (x0, y_base) = layout.to_px(a as f64, row.baseline);
161                let (x1, _) = layout.to_px((b - 1) as f64, row.baseline);
162                format!(
163                    "M {x0:.2} {y_base:.2}{} L {x1:.2} {y_base:.2} Z",
164                    line_to(layout, row, a..b)
165                )
166            })
167            .collect::<Vec<_>>()
168            .join(" ");
169        let _ = writeln!(
170            s,
171            r#"    <path d="{fill_path}" fill="{bg}" stroke="none" />"#
172        );
173
174        // Strokes.
175        match (style.kind, &line) {
176            (ColorKind::Elevation, LineColor::Map(_)) => {
177                // One path per segment, colored by the elevation at the
178                // segment's first point (upstream LineCollection semantics).
179                for &(a, b) in &runs {
180                    for i in (a + 1)..b {
181                        let color = scene.elevation_color(&line, row.y[i - 1] - row.baseline);
182                        let (x0, y0) = layout.to_px((i - 1) as f64, row.y[i - 1]);
183                        let (x1, y1) = layout.to_px(i as f64, row.y[i]);
184                        let _ = writeln!(
185                            s,
186                            r#"    <path d="M {x0:.2} {y0:.2} L {x1:.2} {y1:.2}" fill="none" stroke="{}" stroke-width="{lw:.3}" stroke-linecap="round" stroke-linejoin="round" />"#,
187                            hex3(color),
188                            lw = lw_px
189                        );
190                    }
191                }
192            }
193            _ => {
194                let color = scene.gradient_color(&line, idx);
195                let path = runs
196                    .iter()
197                    .filter(|&&(a, b)| b - a >= 2)
198                    .map(|&(a, b)| {
199                        let (x, y) = layout.to_px(a as f64, row.y[a]);
200                        format!("M {x:.2} {y:.2}{}", line_to(layout, row, (a + 1)..b))
201                    })
202                    .collect::<Vec<_>>()
203                    .join(" ");
204                if !path.is_empty() {
205                    let _ = writeln!(
206                        s,
207                        r#"    <path d="{path}" fill="none" stroke="{color}" stroke-width="{lw:.3}" stroke-linecap="round" stroke-linejoin="round" />"#,
208                        color = hex3(color),
209                        lw = lw_px
210                    );
211                }
212            }
213        }
214    }
215    let _ = writeln!(s, "  </g>");
216
217    if let Some(label) = &style.label {
218        render_label(&mut s, layout, label, &bg);
219    }
220    if let Some(ann) = &style.annotation {
221        render_annotation(&mut s, layout, ann, &bg);
222    }
223
224    let _ = writeln!(s, "</svg>");
225    s
226}
227
228fn text_block(text: &str, fs: f64) -> (Vec<String>, f64, f64) {
229    // (lines, width_px, height_px) with a chunky estimate for Cinzel-like fonts.
230    let lines: Vec<String> = text.split('\n').map(|l| l.to_string()).collect();
231    let maxlen = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as f64;
232    let width = maxlen * fs * 0.62;
233    let height = lines.len() as f64 * fs * 1.2;
234    (lines, width, height)
235}
236
237fn render_label(s: &mut String, layout: &FigureLayout, label: &LabelStyle, bg_hex: &str) {
238    let fs = label.size_pt / 72.0 * FIG_DPI;
239    let (lines, tw, th) = text_block(&label.text, fs);
240    if lines.iter().all(|l| l.is_empty()) {
241        return;
242    }
243    let (ax, ay) = layout.frac_to_px(label.x, label.y);
244    let pad = fs * 0.25;
245    let (rect_top, first_baseline) = match label.vertical_alignment {
246        VAlign::Top => (ay, ay + 0.9 * fs),
247        VAlign::Bottom => (ay - th, ay - 0.15 * fs),
248    };
249    if label.background {
250        let _ = writeln!(
251            s,
252            r#"  <rect x="{x:.2}" y="{y:.2}" width="{w:.2}" height="{h:.2}" fill="{bg}" />"#,
253            x = ax - pad,
254            y = rect_top - pad,
255            w = tw + 2.0 * pad,
256            h = th + 1.4 * pad,
257            bg = bg_hex
258        );
259    }
260    let _ = writeln!(
261        s,
262        r#"  <text x="{ax:.2}" y="{y:.2}" font-family="{family}, serif" font-size="{fs:.2}" fill="{color}" xml:space="preserve">"#,
263        y = first_baseline,
264        family = esc(&label.font_family),
265        color = hex3(label.color)
266    );
267    for (k, line_text) in lines.iter().enumerate() {
268        let dy = if k == 0 { 0.0 } else { 1.2 * fs };
269        let _ = writeln!(
270            s,
271            r#"    <tspan x="{ax:.2}" dy="{dy:.2}">{}</tspan>"#,
272            esc(line_text)
273        );
274    }
275    let _ = writeln!(s, "  </text>");
276}
277
278fn render_annotation(s: &mut String, layout: &FigureLayout, ann: &Annotation, bg_hex: &str) {
279    let (dx, dy) = layout.frac_to_px(ann.x, ann.y);
280    let r_px = ann.dot_pt / 72.0 * FIG_DPI / 2.0;
281    let _ = writeln!(
282        s,
283        r#"  <circle cx="{dx:.2}" cy="{dy:.2}" r="{r:.2}" fill="{color}" />"#,
284        r = r_px,
285        color = hex3(ann.color)
286    );
287    if ann.label.is_empty() {
288        return;
289    }
290    let fs = ann.label_size_pt / 72.0 * FIG_DPI;
291    let (lx, ly) = layout.frac_to_px(ann.x + ann.x_offset, ann.y + ann.y_offset);
292    let (lines, tw, th) = text_block(&ann.label, fs);
293    let pad = fs * 0.25;
294    if ann.background {
295        let _ = writeln!(
296            s,
297            r#"  <rect x="{x:.2}" y="{y:.2}" width="{w:.2}" height="{h:.2}" fill="{bg}" />"#,
298            x = lx - pad,
299            y = ly - 0.15 * fs - pad,
300            w = tw + 2.0 * pad,
301            h = th + 1.4 * pad,
302            bg = bg_hex
303        );
304    }
305    let _ = writeln!(
306        s,
307        r#"  <text x="{lx:.2}" y="{y:.2}" font-family="Cinzel, serif" font-size="{fs:.2}" fill="{color}" xml:space="preserve">{}</text>"#,
308        esc(&lines.join(" ")),
309        y = ly - 0.15 * fs,
310        color = hex3(ann.color)
311    );
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::srtm::SyntheticSource;
318
319    fn scene() -> RidgeScene {
320        let src = SyntheticSource { side: 1201 };
321        crate::geometry::build_scene(
322            &src,
323            &crate::DEFAULT_BBOX,
324            12,
325            16,
326            0.0,
327            false,
328            0,
329            false,
330            10.0,
331            3,
332            40.0,
333            20.0,
334        )
335        .unwrap()
336    }
337
338    #[test]
339    fn svg_contains_lines_and_background() {
340        let sc = scene();
341        let style = PlotStyle {
342            line: LineColorSpec::Solid { rgb: [0, 0, 0] },
343            kind: ColorKind::Gradient,
344            background: [236, 232, 236],
345            linewidth_pt: 2.0,
346            size_scale: 20.0,
347            label: Some(LabelStyle {
348                text: "The White\nMountains".into(),
349                color: [0, 0, 0],
350                x: 0.62,
351                y: 0.15,
352                size_pt: 60.0,
353                vertical_alignment: VAlign::Bottom,
354                font_family: "Cinzel".into(),
355                background: true,
356            }),
357            annotation: None,
358        };
359        let svg = render_svg(&sc, &style);
360        assert!(svg.starts_with("<?xml"));
361        assert!(svg.contains("fill=\"#ece8ec\"")); // upstream default background
362        assert!(svg.contains("<path"));
363        assert!(svg.contains("The White"));
364        assert!(svg.contains("Mountains"));
365        assert!(svg.contains("Cinzel"));
366        // One fill + one stroke path per row with data.
367        assert!(svg.matches("<path").count() >= sc.rows.len());
368    }
369
370    #[test]
371    fn svg_elevation_kind() {
372        let sc = scene();
373        let style = PlotStyle {
374            line: LineColorSpec::Map {
375                name: crate::colormap::Colormap::Ocean,
376            },
377            kind: ColorKind::Elevation,
378            background: [236, 232, 236],
379            linewidth_pt: 2.0,
380            size_scale: 20.0,
381            label: None,
382            annotation: Some(Annotation {
383                label: "SUMMIT".into(),
384                x: 0.5,
385                y: 0.5,
386                x_offset: 0.01,
387                y_offset: 0.01,
388                label_size_pt: 20.0,
389                dot_pt: 8.0,
390                color: [0, 0, 0],
391                background: false,
392            }),
393        };
394        let svg = render_svg(&sc, &style);
395        assert!(svg.contains("<circle"));
396        assert!(svg.contains("SUMMIT"));
397        // Per-segment strokes produce more paths than rows.
398        assert!(svg.matches("<path").count() > sc.rows.len());
399    }
400}