1use svg::node::element::{Circle, Group, Line, Polyline, Rectangle, Text as SvgText};
4use svg::Document;
5
6use super::plot_data::{PlotItem, PlotState};
7
8const DPI: f64 = 96.0;
9const MARGIN_LEFT: f64 = 70.0;
10const MARGIN_RIGHT: f64 = 30.0;
11const MARGIN_TOP: f64 = 50.0;
12const MARGIN_BOTTOM: f64 = 60.0;
13
14pub fn render_svg(state: &PlotState, width_in: f64, height_in: f64) -> String {
16 let w = width_in * DPI;
17 let h = height_in * DPI;
18 let plot_x0 = MARGIN_LEFT;
19 let plot_x1 = w - MARGIN_RIGHT;
20 let plot_y0 = MARGIN_TOP;
21 let plot_y1 = h - MARGIN_BOTTOM;
22
23 let (dx0, dx1, dy0, dy1) = data_bounds(state);
25
26 let map_x = |x: f64| plot_x0 + (x - dx0) / (dx1 - dx0) * (plot_x1 - plot_x0);
27 let map_y = |y: f64| plot_y1 - (y - dy0) / (dy1 - dy0) * (plot_y1 - plot_y0);
28
29 let mut doc = Document::new()
30 .set("width", w)
31 .set("height", h)
32 .set("viewBox", (0.0, 0.0, w, h));
33
34 doc = doc.add(
36 Rectangle::new()
37 .set("width", w)
38 .set("height", h)
39 .set("fill", "white"),
40 );
41
42 doc = doc.add(
44 Rectangle::new()
45 .set("x", plot_x0)
46 .set("y", plot_y0)
47 .set("width", plot_x1 - plot_x0)
48 .set("height", plot_y1 - plot_y0)
49 .set("fill", "none")
50 .set("stroke", "#cccccc"),
51 );
52
53 doc = add_axes(&doc, plot_x0, plot_x1, plot_y0, plot_y1, dx0, dx1, dy0, dy1);
55
56 if let Some(title) = &state.title {
58 doc = doc.add(
59 SvgText::new(title.clone())
60 .set("x", w / 2.0)
61 .set("y", 25.0)
62 .set("text-anchor", "middle")
63 .set("font-size", 16)
64 .set("font-weight", "bold"),
65 );
66 }
67
68 if let Some(xlab) = &state.x_label {
70 doc = doc.add(
71 SvgText::new(xlab.clone())
72 .set("x", (plot_x0 + plot_x1) / 2.0)
73 .set("y", h - 10.0)
74 .set("text-anchor", "middle")
75 .set("font-size", 12),
76 );
77 }
78 if let Some(ylab) = &state.y_label {
79 doc = doc.add(
80 SvgText::new(ylab.clone())
81 .set("x", 15.0)
82 .set("y", (plot_y0 + plot_y1) / 2.0)
83 .set("text-anchor", "middle")
84 .set("font-size", 12)
85 .set(
86 "transform",
87 format!("rotate(-90, 15, {})", (plot_y0 + plot_y1) / 2.0),
88 ),
89 );
90 }
91
92 let mut items_group = Group::new();
94 for item in &state.items {
95 items_group = render_item(items_group, item, &map_x, &map_y);
96 }
97 doc = doc.add(items_group);
98
99 doc.to_string()
100}
101
102fn rgba_to_svg(c: [u8; 4]) -> String {
103 if c[3] == 255 {
104 format!("rgb({},{},{})", c[0], c[1], c[2])
105 } else {
106 format!(
107 "rgba({},{},{},{})",
108 c[0],
109 c[1],
110 c[2],
111 f64::from(c[3]) / 255.0
112 )
113 }
114}
115
116fn data_bounds(state: &PlotState) -> (f64, f64, f64, f64) {
117 let mut xmin = f64::INFINITY;
118 let mut xmax = f64::NEG_INFINITY;
119 let mut ymin = f64::INFINITY;
120 let mut ymax = f64::NEG_INFINITY;
121
122 for item in &state.items {
123 match item {
124 PlotItem::Points { x, y, .. } | PlotItem::Line { x, y, .. } => {
125 for &v in x {
126 if v < xmin {
127 xmin = v;
128 }
129 if v > xmax {
130 xmax = v;
131 }
132 }
133 for &v in y {
134 if v < ymin {
135 ymin = v;
136 }
137 if v > ymax {
138 ymax = v;
139 }
140 }
141 }
142 PlotItem::Bars { x, heights, .. } => {
143 for &v in x {
144 if v < xmin {
145 xmin = v;
146 }
147 if v > xmax {
148 xmax = v;
149 }
150 }
151 for &v in heights {
152 if v > ymax {
153 ymax = v;
154 }
155 }
156 if 0.0 < ymin {
157 ymin = 0.0;
158 }
159 }
160 PlotItem::HLine { y, .. } => {
161 if *y < ymin {
162 ymin = *y;
163 }
164 if *y > ymax {
165 ymax = *y;
166 }
167 }
168 PlotItem::VLine { x, .. } => {
169 if *x < xmin {
170 xmin = *x;
171 }
172 if *x > xmax {
173 xmax = *x;
174 }
175 }
176 PlotItem::Text { x, y, .. } => {
177 if *x < xmin {
178 xmin = *x;
179 }
180 if *x > xmax {
181 xmax = *x;
182 }
183 if *y < ymin {
184 ymin = *y;
185 }
186 if *y > ymax {
187 ymax = *y;
188 }
189 }
190 PlotItem::BoxPlot {
191 positions, spreads, ..
192 } => {
193 for &p in positions {
194 if p < xmin {
195 xmin = p;
196 }
197 if p > xmax {
198 xmax = p;
199 }
200 }
201 for s in spreads {
202 if s.lower_whisker < ymin {
203 ymin = s.lower_whisker;
204 }
205 if s.upper_whisker > ymax {
206 ymax = s.upper_whisker;
207 }
208 }
209 }
210 }
211 }
212
213 if let Some((lo, hi)) = state.x_lim {
214 xmin = lo;
215 xmax = hi;
216 }
217 if let Some((lo, hi)) = state.y_lim {
218 ymin = lo;
219 ymax = hi;
220 }
221
222 let xpad = (xmax - xmin).abs() * 0.04;
224 let ypad = (ymax - ymin).abs() * 0.04;
225 if xmin == xmax {
226 xmin -= 1.0;
227 xmax += 1.0;
228 }
229 if ymin == ymax {
230 ymin -= 1.0;
231 ymax += 1.0;
232 }
233
234 (xmin - xpad, xmax + xpad, ymin - ypad, ymax + ypad)
235}
236
237fn render_item(
238 mut group: Group,
239 item: &PlotItem,
240 map_x: &dyn Fn(f64) -> f64,
241 map_y: &dyn Fn(f64) -> f64,
242) -> Group {
243 match item {
244 PlotItem::Points {
245 x, y, color, size, ..
246 } => {
247 let fill = rgba_to_svg(*color);
248 for (&xi, &yi) in x.iter().zip(y.iter()) {
249 group = group.add(
250 Circle::new()
251 .set("cx", map_x(xi))
252 .set("cy", map_y(yi))
253 .set("r", *size as f64)
254 .set("fill", fill.as_str()),
255 );
256 }
257 }
258 PlotItem::Line {
259 x, y, color, width, ..
260 } => {
261 let points: String = x
262 .iter()
263 .zip(y.iter())
264 .map(|(&xi, &yi)| format!("{},{}", map_x(xi), map_y(yi)))
265 .collect::<Vec<_>>()
266 .join(" ");
267 group = group.add(
268 Polyline::new()
269 .set("points", points)
270 .set("fill", "none")
271 .set("stroke", rgba_to_svg(*color))
272 .set("stroke-width", *width as f64),
273 );
274 }
275 PlotItem::Bars {
276 x,
277 heights,
278 color,
279 width,
280 ..
281 } => {
282 let fill = rgba_to_svg(*color);
283 for (&xi, &hi) in x.iter().zip(heights.iter()) {
284 let sx = map_x(xi - width / 2.0);
285 let sy = map_y(hi);
286 let sw = map_x(xi + width / 2.0) - sx;
287 let sh = map_y(0.0) - sy;
288 group = group.add(
289 Rectangle::new()
290 .set("x", sx)
291 .set("y", sy)
292 .set("width", sw.abs())
293 .set("height", sh.abs())
294 .set("fill", fill.as_str())
295 .set("stroke", "black")
296 .set("stroke-width", 0.5),
297 );
298 }
299 }
300 PlotItem::HLine { y, color, width } => {
301 let sy = map_y(*y);
302 group = group.add(
303 Line::new()
304 .set("x1", map_x(f64::NEG_INFINITY).max(0.0))
305 .set("x2", map_x(f64::INFINITY).min(10000.0))
306 .set("y1", sy)
307 .set("y2", sy)
308 .set("stroke", rgba_to_svg(*color))
309 .set("stroke-width", *width as f64),
310 );
311 }
312 PlotItem::VLine { x, color, width } => {
313 let sx = map_x(*x);
314 group = group.add(
315 Line::new()
316 .set("x1", sx)
317 .set("x2", sx)
318 .set("y1", map_y(f64::NEG_INFINITY).min(10000.0))
319 .set("y2", map_y(f64::INFINITY).max(0.0))
320 .set("stroke", rgba_to_svg(*color))
321 .set("stroke-width", *width as f64),
322 );
323 }
324 PlotItem::Text { x, y, text, color } => {
325 group = group.add(
326 SvgText::new(text.clone())
327 .set("x", map_x(*x))
328 .set("y", map_y(*y))
329 .set("fill", rgba_to_svg(*color))
330 .set("font-size", 12),
331 );
332 }
333 PlotItem::BoxPlot { .. } => {
334 }
336 }
337 group
338}
339
340#[allow(clippy::too_many_arguments)]
341fn add_axes(
342 doc: &Document,
343 px0: f64,
344 px1: f64,
345 py0: f64,
346 py1: f64,
347 dx0: f64,
348 dx1: f64,
349 dy0: f64,
350 dy1: f64,
351) -> Document {
352 let mut d = doc.clone();
353 let x_ticks = nice_ticks(dx0, dx1, 6);
354 let y_ticks = nice_ticks(dy0, dy1, 6);
355 let map_x = |v: f64| px0 + (v - dx0) / (dx1 - dx0) * (px1 - px0);
356 let map_y = |v: f64| py1 - (v - dy0) / (dy1 - dy0) * (py1 - py0);
357
358 for &t in &x_ticks {
359 let sx = map_x(t);
360 d = d.add(
361 Line::new()
362 .set("x1", sx)
363 .set("x2", sx)
364 .set("y1", py1)
365 .set("y2", py1 + 5.0)
366 .set("stroke", "black"),
367 );
368 d = d.add(
369 SvgText::new(format_tick(t))
370 .set("x", sx)
371 .set("y", py1 + 18.0)
372 .set("text-anchor", "middle")
373 .set("font-size", 10),
374 );
375 }
376 for &t in &y_ticks {
377 let sy = map_y(t);
378 d = d.add(
379 Line::new()
380 .set("x1", px0 - 5.0)
381 .set("x2", px0)
382 .set("y1", sy)
383 .set("y2", sy)
384 .set("stroke", "black"),
385 );
386 d = d.add(
387 SvgText::new(format_tick(t))
388 .set("x", px0 - 8.0)
389 .set("y", sy + 4.0)
390 .set("text-anchor", "end")
391 .set("font-size", 10),
392 );
393 }
394 d
395}
396
397fn nice_ticks(lo: f64, hi: f64, target: usize) -> Vec<f64> {
398 let range = hi - lo;
399 if range <= 0.0 {
400 return vec![lo];
401 }
402 let rough = range / target as f64;
403 let mag = 10f64.powf(rough.log10().floor());
404 let step = if rough / mag < 1.5 {
405 mag
406 } else if rough / mag < 3.5 {
407 2.0 * mag
408 } else if rough / mag < 7.5 {
409 5.0 * mag
410 } else {
411 10.0 * mag
412 };
413 let start = (lo / step).ceil() * step;
414 let mut ticks = Vec::new();
415 let mut t = start;
416 while t <= hi + step * 0.01 {
417 ticks.push(t);
418 t += step;
419 }
420 ticks
421}
422
423fn format_tick(v: f64) -> String {
424 if v == v.floor() && v.abs() < 1e6 {
425 format!("{}", v as i64)
426 } else {
427 format!("{:.2}", v)
428 }
429}