1use 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('&', "&")
19 .replace('<', "<")
20 .replace('>', ">")
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum VAlign {
27 Top,
28 Bottom,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct LabelStyle {
34 pub text: String,
35 pub color: Rgb,
36 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#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct Annotation {
48 pub label: String,
49 pub x: f64,
51 pub y: f64,
52 pub x_offset: f64,
54 pub y_offset: f64,
55 pub label_size_pt: f64,
56 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 pub linewidth_pt: f64,
69 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#[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
103fn 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
113pub 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 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 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 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 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 match (style.kind, &line) {
176 (ColorKind::Elevation, LineColor::Map(_)) => {
177 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 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\"")); assert!(svg.contains("<path"));
363 assert!(svg.contains("The White"));
364 assert!(svg.contains("Mountains"));
365 assert!(svg.contains("Cinzel"));
366 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 assert!(svg.matches("<path").count() > sc.rows.len());
399 }
400}