1use 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('&', "&")
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
103pub 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 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 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 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 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 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 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\"")); assert!(svg.contains("<path"));
371 assert!(svg.contains("The White"));
372 assert!(svg.contains("Mountains"));
373 assert!(svg.contains("Cinzel"));
374 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 assert!(svg.matches("<path").count() > sc.rows.len());
407 }
408}