1use std::sync::{Arc, OnceLock};
8
9use color_eyre::Result;
10use resvg::{tiny_skia, usvg};
11
12use crate::chart_data::{
13 AxisFormat, AxisNumbers, BarData, BoxPlotData, HeatmapData, HistogramData, KdeData,
14 XAxisTemporalKind, segments, x_axis_label_at,
15};
16use crate::widgets::ticks;
17
18const FONT_REGULAR: &[u8] = include_bytes!("../assets/fonts/IBMPlexSans-Regular.ttf");
19const FONT_SEMIBOLD: &[u8] = include_bytes!("../assets/fonts/IBMPlexSans-SemiBold.ttf");
20const FONT_FAMILY: &str = "IBM Plex Sans";
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum ChartExportFormat {
26 Png,
27 Svg,
28 Pdf,
29}
30
31impl ChartExportFormat {
32 pub const ALL: [Self; 3] = [Self::Png, Self::Svg, Self::Pdf];
33
34 pub fn extension(self) -> &'static str {
35 match self {
36 Self::Png => "png",
37 Self::Svg => "svg",
38 Self::Pdf => "pdf",
39 }
40 }
41
42 pub fn as_str(self) -> &'static str {
43 match self {
44 Self::Png => "PNG",
45 Self::Svg => "SVG",
46 Self::Pdf => "PDF",
47 }
48 }
49
50 pub fn from_extension(path: &std::path::Path) -> Option<Self> {
52 let ext = path.extension()?.to_str()?.to_ascii_lowercase();
53 Self::ALL.into_iter().find(|f| f.extension() == ext)
54 }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum ExportStyle {
61 Light,
63 Dark,
65 Transparent,
67}
68
69impl ExportStyle {
70 pub const ALL: [Self; 3] = [Self::Light, Self::Dark, Self::Transparent];
71
72 pub fn label(self) -> &'static str {
73 match self {
74 Self::Light => "Light",
75 Self::Dark => "Dark",
76 Self::Transparent => "Transparent",
77 }
78 }
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum SizePreset {
86 Slide,
87 Document,
88 Square,
89 SingleColumn,
91 DoubleColumn,
93 Custom,
94}
95
96impl SizePreset {
97 pub const ALL: [Self; 6] = [
98 Self::Slide,
99 Self::Document,
100 Self::Square,
101 Self::SingleColumn,
102 Self::DoubleColumn,
103 Self::Custom,
104 ];
105
106 pub fn label(self) -> &'static str {
107 match self {
108 Self::Slide => "Slide 16:9",
109 Self::Document => "Document",
110 Self::Square => "Square",
111 Self::SingleColumn => "Single column",
112 Self::DoubleColumn => "Double column",
113 Self::Custom => "Custom",
114 }
115 }
116
117 pub fn size(self) -> Option<(u32, u32)> {
119 match self {
120 Self::Slide => Some((1920, 1080)),
121 Self::Document => Some((1600, 1000)),
122 Self::Square => Some((1200, 1200)),
123 Self::SingleColumn => Some((1050, 788)),
124 Self::DoubleColumn => Some((2100, 1300)),
125 Self::Custom => None,
126 }
127 }
128
129 pub fn dpi(self) -> f32 {
132 match self {
133 Self::SingleColumn | Self::DoubleColumn => 300.0,
134 Self::Slide => 192.0,
135 Self::Document => 160.0,
136 Self::Square => 150.0,
137 Self::Custom => 96.0,
138 }
139 }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
144#[serde(rename_all = "snake_case")]
145pub enum LegendPlace {
146 LineEnds,
149 TopRight,
150 TopLeft,
151 BottomRight,
152 BottomLeft,
153 Off,
154}
155
156impl LegendPlace {
157 pub const ALL: [Self; 6] = [
158 Self::LineEnds,
159 Self::TopRight,
160 Self::TopLeft,
161 Self::BottomRight,
162 Self::BottomLeft,
163 Self::Off,
164 ];
165
166 pub fn label(self) -> &'static str {
167 match self {
168 Self::LineEnds => "Line ends",
169 Self::TopRight => "Top right",
170 Self::TopLeft => "Top left",
171 Self::BottomRight => "Bottom right",
172 Self::BottomLeft => "Bottom left",
173 Self::Off => "Off",
174 }
175 }
176}
177
178#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
180#[serde(rename_all = "snake_case")]
181pub enum PointOpacity {
182 #[default]
184 Auto,
185 Full,
186 Half,
187 Fifth,
188}
189
190impl PointOpacity {
191 pub const ALL: [Self; 4] = [Self::Auto, Self::Full, Self::Half, Self::Fifth];
192
193 pub fn label(self) -> &'static str {
194 match self {
195 Self::Auto => "Auto",
196 Self::Full => "100%",
197 Self::Half => "50%",
198 Self::Fifth => "20%",
199 }
200 }
201
202 pub fn of(self, points: usize) -> f64 {
204 match self {
205 Self::Auto => auto_opacity(points),
206 Self::Full => 1.0,
207 Self::Half => 0.5,
208 Self::Fifth => 0.2,
209 }
210 }
211}
212
213pub fn auto_opacity(points: usize) -> f64 {
216 const FAINTEST: f64 = 0.15;
217 let t = (((points.max(1) as f64).log10() - 3.0) / 2.0).clamp(0.0, 1.0);
218 1.0 + t * (FAINTEST - 1.0)
219}
220
221#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum PointSize {
225 Small,
226 #[default]
227 Medium,
228 Large,
229}
230
231impl PointSize {
232 pub const ALL: [Self; 3] = [Self::Small, Self::Medium, Self::Large];
233
234 pub fn label(self) -> &'static str {
235 match self {
236 Self::Small => "Small",
237 Self::Medium => "Medium",
238 Self::Large => "Large",
239 }
240 }
241
242 pub fn pt(self) -> f64 {
244 match self {
245 Self::Small => 1.6,
246 Self::Medium => 2.4,
247 Self::Large => 3.6,
248 }
249 }
250}
251
252#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
254#[serde(rename_all = "snake_case")]
255pub enum LineWidth {
256 Thin,
257 #[default]
258 Normal,
259 Bold,
260}
261
262impl LineWidth {
263 pub const ALL: [Self; 3] = [Self::Thin, Self::Normal, Self::Bold];
264
265 pub fn label(self) -> &'static str {
266 match self {
267 Self::Thin => "Thin",
268 Self::Normal => "Normal",
269 Self::Bold => "Bold",
270 }
271 }
272
273 pub fn pt(self) -> f64 {
275 match self {
276 Self::Thin => 1.0,
277 Self::Normal => 1.5,
278 Self::Bold => 2.5,
279 }
280 }
281}
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub struct Rgb(pub u8, pub u8, pub u8);
286
287impl Rgb {
288 fn hex(self) -> String {
289 format!("#{:02x}{:02x}{:02x}", self.0, self.1, self.2)
290 }
291
292 pub fn of(color: ratatui::style::Color) -> Option<Self> {
295 use ratatui::style::Color;
296 const ANSI: [(u8, u8, u8); 16] = [
297 (0, 0, 0),
298 (205, 0, 0),
299 (0, 205, 0),
300 (205, 205, 0),
301 (0, 0, 238),
302 (205, 0, 205),
303 (0, 205, 205),
304 (229, 229, 229),
305 (127, 127, 127),
306 (255, 0, 0),
307 (0, 255, 0),
308 (255, 255, 0),
309 (92, 92, 255),
310 (255, 0, 255),
311 (0, 255, 255),
312 (255, 255, 255),
313 ];
314 let ansi = |i: usize| {
315 let (r, g, b) = ANSI[i];
316 Some(Rgb(r, g, b))
317 };
318 match color {
319 Color::Rgb(r, g, b) => Some(Rgb(r, g, b)),
320 Color::Reset => None,
321 Color::Black => ansi(0),
322 Color::Red => ansi(1),
323 Color::Green => ansi(2),
324 Color::Yellow => ansi(3),
325 Color::Blue => ansi(4),
326 Color::Magenta => ansi(5),
327 Color::Cyan => ansi(6),
328 Color::Gray => ansi(7),
329 Color::DarkGray => ansi(8),
330 Color::LightRed => ansi(9),
331 Color::LightGreen => ansi(10),
332 Color::LightYellow => ansi(11),
333 Color::LightBlue => ansi(12),
334 Color::LightMagenta => ansi(13),
335 Color::LightCyan => ansi(14),
336 Color::White => ansi(15),
337 Color::Indexed(i) if i < 16 => ansi(i as usize),
338 Color::Indexed(i) if i < 232 => {
339 let i = i - 16;
340 let level = |v: u8| if v == 0 { 0 } else { 55 + v * 40 };
341 Some(Rgb(level(i / 36), level((i / 6) % 6), level(i % 6)))
342 }
343 Color::Indexed(i) => {
344 let v = 8 + (i - 232) * 10;
345 Some(Rgb(v, v, v))
346 }
347 }
348 }
349}
350
351#[derive(Debug, Clone, PartialEq)]
353pub struct Palette {
354 pub background: Option<Rgb>,
356 pub text: Rgb,
357 pub text_secondary: Rgb,
358 pub grid: Rgb,
359 pub series: Vec<Rgb>,
362 pub other: Rgb,
364 pub ramp: [Rgb; 7],
366 pub dark: bool,
368}
369
370const LIGHT_SERIES: [Rgb; SERIES] = [
377 Rgb(0x2a, 0x78, 0xd6),
378 Rgb(0xeb, 0x68, 0x34),
379 Rgb(0x1b, 0xaf, 0x7a),
380 Rgb(0xed, 0xa1, 0x00),
381 Rgb(0xe8, 0x7b, 0xa4),
382 Rgb(0x00, 0x83, 0x00),
383 Rgb(0x4a, 0x3a, 0xa7),
384 Rgb(0x0f, 0x8a, 0x96),
385 Rgb(0x9c, 0x1f, 0x6e),
386 Rgb(0x5c, 0x4a, 0x00),
387];
388
389pub const SERIES: usize = 10;
391
392const BLUE_RAMP: [Rgb; 7] = [
394 Rgb(0xcd, 0xe2, 0xfb),
395 Rgb(0x9e, 0xc5, 0xf4),
396 Rgb(0x6d, 0xa7, 0xec),
397 Rgb(0x39, 0x87, 0xe5),
398 Rgb(0x25, 0x6a, 0xbf),
399 Rgb(0x18, 0x4f, 0x95),
400 Rgb(0x0d, 0x36, 0x6b),
401];
402
403impl Palette {
404 pub fn light() -> Self {
405 Self {
406 background: Some(Rgb(0xff, 0xff, 0xff)),
407 text: Rgb(0x1f, 0x24, 0x30),
408 text_secondary: Rgb(0x5b, 0x61, 0x70),
409 grid: Rgb(0xe3, 0xe5, 0xea),
410 series: LIGHT_SERIES.to_vec(),
411 other: Rgb(0xa8, 0xad, 0xb8),
412 ramp: BLUE_RAMP,
413 dark: false,
414 }
415 }
416
417 pub fn transparent() -> Self {
418 Self {
419 background: None,
420 ..Self::light()
421 }
422 }
423
424 pub fn dark(colors: &crate::config::ColorConfig) -> Self {
428 let parser = crate::config::ColorParser::new();
429 let get = |value: &str, fallback: Rgb| {
432 let value = value.trim();
433 let hex = value
434 .strip_prefix('#')
435 .filter(|h| h.len() == 6)
436 .and_then(|h| u32::from_str_radix(h, 16).ok())
437 .map(|n| Rgb((n >> 16) as u8, (n >> 8) as u8, n as u8));
438 hex.or_else(|| parser.parse(value).ok().and_then(Rgb::of))
439 .unwrap_or(fallback)
440 };
441 let defaults = [
442 Rgb(0x7d, 0xcf, 0xff),
443 Rgb(0xbb, 0x9a, 0xf7),
444 Rgb(0x9e, 0xce, 0x6a),
445 Rgb(0xe0, 0xaf, 0x68),
446 Rgb(0x7a, 0xa2, 0xf7),
447 Rgb(0xf7, 0x76, 0x8e),
448 Rgb(0xff, 0x9e, 0x64),
449 Rgb(0x1a, 0xbc, 0x9c),
450 Rgb(0xff, 0x5f, 0xd2),
451 Rgb(0xf4, 0xef, 0x8a),
452 ];
453 let configured = [
454 &colors.chart_1,
455 &colors.chart_2,
456 &colors.chart_3,
457 &colors.chart_4,
458 &colors.chart_5,
459 &colors.chart_6,
460 &colors.chart_7,
461 &colors.chart_8,
462 &colors.chart_9,
463 &colors.chart_10,
464 ];
465 let mut series: Vec<Rgb> = Vec::with_capacity(SERIES);
467 for (fallback, value) in defaults.into_iter().zip(configured) {
468 let color = get(value, fallback);
469 if !series.contains(&color) {
470 series.push(color);
471 }
472 }
473 let mut ramp = BLUE_RAMP;
474 ramp.reverse();
475 Self {
476 background: Some(get(&colors.background, Rgb(0x1a, 0x1b, 0x26))),
477 text: get(&colors.text_primary, Rgb(0xc0, 0xca, 0xf5)),
478 text_secondary: get(&colors.text_secondary, Rgb(0x9a, 0xa5, 0xce)),
479 grid: get(&colors.chart_grid, Rgb(0x3d, 0x47, 0x85)),
480 series,
481 other: get(&colors.dimmed, Rgb(0x56, 0x5f, 0x89)),
482 ramp,
483 dark: true,
484 }
485 }
486
487 pub fn for_style(style: ExportStyle, colors: &crate::config::ColorConfig) -> Self {
488 match style {
489 ExportStyle::Light => Self::light(),
490 ExportStyle::Dark => Self::dark(colors),
491 ExportStyle::Transparent => Self::transparent(),
492 }
493 }
494}
495
496#[derive(Debug, Clone, PartialEq)]
498pub struct ExportOptions {
499 pub width: u32,
500 pub height: u32,
501 pub dpi: f32,
502 pub palette: Palette,
503 pub legend: LegendPlace,
504 pub title: String,
505 pub description: String,
506 pub notes: String,
507 pub source: String,
508 pub byline: String,
509 pub point_opacity: PointOpacity,
510 pub point_size: PointSize,
511 pub line_width: LineWidth,
512 pub y_from_zero: Option<bool>,
515 pub recipe: Option<String>,
519}
520
521impl Default for ExportOptions {
522 fn default() -> Self {
523 let preset = SizePreset::Document;
524 let (width, height) = preset.size().unwrap_or((1600, 1000));
525 Self {
526 width,
527 height,
528 dpi: preset.dpi(),
529 palette: Palette::light(),
530 legend: LegendPlace::LineEnds,
531 title: String::new(),
532 description: String::new(),
533 notes: String::new(),
534 source: String::new(),
535 byline: String::new(),
536 point_opacity: PointOpacity::default(),
537 point_size: PointSize::default(),
538 line_width: LineWidth::default(),
539 y_from_zero: None,
540 recipe: None,
541 }
542 }
543}
544
545#[derive(Debug, Clone)]
547pub struct ChartExportRequest {
548 pub path: std::path::PathBuf,
549 pub format: ChartExportFormat,
550 pub options: ExportOptions,
551 pub overwrite: crate::output_file::Overwrite,
553 pub recipe: bool,
556}
557
558#[derive(Debug, Clone, Default)]
560pub struct Axis {
561 pub title: String,
562 pub numbers: AxisNumbers,
563 pub kind: XAxisTemporalKind,
564 pub log: bool,
566}
567
568#[derive(Debug, Clone)]
570pub struct Series {
571 pub name: String,
572 pub points: Vec<(f64, f64)>,
573 pub breaks: Vec<usize>,
575 pub other: bool,
577}
578
579#[derive(Debug, Clone)]
581pub enum Plot {
582 Lines {
583 series: Vec<Series>,
584 scatter: bool,
585 x: Axis,
586 y: Axis,
587 y_from_zero: bool,
588 },
589 Bars {
590 data: BarData,
591 value: Axis,
592 },
593 Histogram {
594 data: HistogramData,
595 x: Axis,
596 y: Axis,
597 },
598 Kde {
599 data: KdeData,
600 x: Axis,
601 y: Axis,
602 },
603 Box {
604 data: BoxPlotData,
605 x_title: String,
606 y: Axis,
607 },
608 Heatmap {
609 data: HeatmapData,
610 x: Axis,
611 y: Axis,
612 },
613}
614
615#[derive(Debug, Clone)]
618pub struct Figure {
619 pub plot: Plot,
620 pub chart_notes: Vec<String>,
621 pub grid: bool,
622}
623
624pub fn render(
626 figure: &Figure,
627 options: &ExportOptions,
628 format: ChartExportFormat,
629) -> Result<Vec<u8>> {
630 let tree = tree(&svg(figure, options)?)?;
631 let recipe = options.recipe.as_deref();
632 Ok(match format {
633 ChartExportFormat::Svg => with_svg_recipe(
635 tree.to_string(&usvg::WriteOptions::default()).replacen(
636 &format!("width=\"{}\" height=\"{}\"", options.width, options.height),
637 &format!(
638 "width=\"{:.3}in\" height=\"{:.3}in\" viewBox=\"0 0 {} {}\"",
639 f64::from(options.width) / f64::from(options.dpi),
640 f64::from(options.height) / f64::from(options.dpi),
641 options.width,
642 options.height,
643 ),
644 1,
645 ),
646 recipe,
647 )
648 .into_bytes(),
649 ChartExportFormat::Png => {
650 let mut pixmap = tiny_skia::Pixmap::new(options.width, options.height)
651 .ok_or_else(|| color_eyre::eyre::eyre!("cannot draw a chart of that size"))?;
652 resvg::render(
653 &tree,
654 tiny_skia::Transform::identity(),
655 &mut pixmap.as_mut(),
656 );
657 let png = pixmap
658 .encode_png()
659 .map_err(|e| color_eyre::eyre::eyre!("PNG: {e}"))?;
660 let png = with_resolution(png, options.dpi);
661 match recipe {
662 Some(recipe) => with_png_recipe(png, recipe),
663 None => png,
664 }
665 }
666 ChartExportFormat::Pdf => {
667 crate::chart_pdf::write(&tree, (options.width, options.height), options.dpi, recipe)?
668 }
669 })
670}
671
672pub const RECIPE_KEY: &str = "datui-recipe";
675
676const RECIPE_NAMESPACE: &str = "https://derekwisong.github.io/datui/recipe";
678
679fn with_png_recipe(mut png: Vec<u8>, recipe: &str) -> Vec<u8> {
681 const IEND: usize = 12;
683 if png.len() < IEND || &png[png.len() - 8..png.len() - 4] != b"IEND" {
684 return png;
685 }
686 let mut chunk = b"iTXt".to_vec();
687 chunk.extend_from_slice(RECIPE_KEY.as_bytes());
688 chunk.extend_from_slice(&[0, 0, 0, 0, 0]);
690 chunk.extend_from_slice(recipe.as_bytes());
691 let crc = crc::Crc::<u32>::new(&crc::CRC_32_ISO_HDLC).checksum(&chunk);
692 let mut bytes = u32::try_from(chunk.len() - 4)
693 .unwrap_or(u32::MAX)
694 .to_be_bytes()
695 .to_vec();
696 bytes.extend_from_slice(&chunk);
697 bytes.extend_from_slice(&crc.to_be_bytes());
698 let end = png.len() - IEND;
699 png.splice(end..end, bytes);
700 png
701}
702
703fn with_svg_recipe(svg: String, recipe: Option<&str>) -> String {
705 let Some(recipe) = recipe else {
706 return svg;
707 };
708 let Some(open) = svg
709 .find("<svg")
710 .and_then(|at| svg[at..].find('>').map(|end| at + end + 1))
711 else {
712 return svg;
713 };
714 let element = format!(
715 "<metadata><{RECIPE_KEY} xmlns=\"{RECIPE_NAMESPACE}\">{}</{RECIPE_KEY}></metadata>",
716 esc(recipe)
717 );
718 let mut out = svg;
719 out.insert_str(open, &element);
720 out
721}
722
723pub fn recipe_in(bytes: &[u8]) -> Option<String> {
725 if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
726 let mut at = 8;
727 while at + 12 <= bytes.len() {
728 let len = u32::from_be_bytes(bytes[at..at + 4].try_into().ok()?) as usize;
729 let kind = &bytes[at + 4..at + 8];
730 let data = bytes.get(at + 8..at + 8 + len)?;
731 if kind == b"iTXt" && data.starts_with(RECIPE_KEY.as_bytes()) {
732 let text = data.get(RECIPE_KEY.len() + 5..)?;
733 return String::from_utf8(text.to_vec()).ok();
734 }
735 at += 12 + len;
736 }
737 return None;
738 }
739 if bytes.starts_with(b"%PDF") {
740 return crate::chart_pdf::recipe_in(bytes);
741 }
742 let text = std::str::from_utf8(bytes).ok()?;
743 let open = format!("<{RECIPE_KEY} xmlns=\"{RECIPE_NAMESPACE}\">");
744 let start = text.find(&open)? + open.len();
745 let end = start + text[start..].find(&format!("</{RECIPE_KEY}>"))?;
746 Some(unescape(&text[start..end]))
747}
748
749fn unescape(s: &str) -> String {
751 s.replace("<", "<")
752 .replace(">", ">")
753 .replace(""", "\"")
754 .replace("'", "'")
755 .replace("&", "&")
756}
757
758fn with_resolution(mut png: Vec<u8>, dpi: f32) -> Vec<u8> {
761 const AFTER_IHDR: usize = 8 + 4 + 4 + 13 + 4;
763 if png.len() < AFTER_IHDR || &png[12..16] != b"IHDR" {
764 return png;
765 }
766 let per_meter = (f64::from(dpi) / 0.0254).round() as u32;
767 let mut chunk = b"pHYs".to_vec();
768 chunk.extend_from_slice(&per_meter.to_be_bytes());
769 chunk.extend_from_slice(&per_meter.to_be_bytes());
770 chunk.push(1); let crc = crc::Crc::<u32>::new(&crc::CRC_32_ISO_HDLC).checksum(&chunk);
772 let mut bytes = 9u32.to_be_bytes().to_vec();
773 bytes.extend_from_slice(&chunk);
774 bytes.extend_from_slice(&crc.to_be_bytes());
775 png.splice(AFTER_IHDR..AFTER_IHDR, bytes);
776 png
777}
778
779fn fonts() -> Arc<usvg::fontdb::Database> {
782 static FONTS: OnceLock<Arc<usvg::fontdb::Database>> = OnceLock::new();
783 FONTS
784 .get_or_init(|| {
785 let mut db = usvg::fontdb::Database::new();
786 db.load_font_data(FONT_REGULAR.to_vec());
787 db.load_font_data(FONT_SEMIBOLD.to_vec());
788 db.load_system_fonts();
789 db.set_sans_serif_family(FONT_FAMILY);
790 Arc::new(db)
791 })
792 .clone()
793}
794
795fn tree(svg: &str) -> Result<usvg::Tree> {
797 let options = usvg::Options {
798 font_family: FONT_FAMILY.to_string(),
799 fontdb: fonts(),
800 ..Default::default()
801 };
802 usvg::Tree::from_str(svg, &options).map_err(|e| color_eyre::eyre::eyre!("chart SVG: {e}"))
803}
804
805fn esc(s: &str) -> String {
807 let mut out = String::with_capacity(s.len());
808 for c in s.chars() {
809 match c {
810 '&' => out.push_str("&"),
811 '<' => out.push_str("<"),
812 '>' => out.push_str(">"),
813 '"' => out.push_str("""),
814 c if c.is_control() => out.push(' '),
816 c => out.push(c),
817 }
818 }
819 out
820}
821
822fn text_width(text: &str, size: f64) -> f64 {
824 text.chars()
825 .map(|c| match c {
826 'i' | 'l' | 'j' | '.' | ',' | ':' | ';' | '\'' | '|' | '!' | ' ' => 0.3,
827 'm' | 'w' | 'M' | 'W' => 0.85,
828 c if c.is_ascii_uppercase() || c.is_ascii_digit() => 0.62,
829 c if c.is_ascii() => 0.53,
830 _ => 0.95,
832 })
833 .sum::<f64>()
834 * size
835}
836
837fn wrap(text: &str, size: f64, width: f64) -> Vec<String> {
839 let mut lines = Vec::new();
840 for paragraph in text.lines() {
841 let mut line = String::new();
842 for word in paragraph.split_whitespace() {
843 let candidate = if line.is_empty() {
844 word.to_string()
845 } else {
846 format!("{line} {word}")
847 };
848 if !line.is_empty() && text_width(&candidate, size) > width {
849 lines.push(std::mem::take(&mut line));
850 line = word.to_string();
851 } else {
852 line = candidate;
853 }
854 }
855 if !line.is_empty() {
856 lines.push(line);
857 }
858 }
859 lines
860}
861
862struct Canvas<'a> {
864 out: String,
865 palette: &'a Palette,
866 pt: f64,
868 body: f64,
870 other: Option<usize>,
872 swatch_stroke: f64,
874}
875
876impl Canvas<'_> {
877 fn text(
879 &mut self,
880 (x, y): (f64, f64),
881 size: f64,
882 color: Rgb,
883 (anchor, weight): (&str, u16),
884 text: &str,
885 ) {
886 self.out.push_str(&format!(
887 "<text x=\"{x:.1}\" y=\"{y:.1}\" font-size=\"{size:.1}\" font-weight=\"{weight}\" \
888 text-anchor=\"{anchor}\" fill=\"{}\">{}</text>\n",
889 color.hex(),
890 esc(text)
891 ));
892 }
893
894 fn line(&mut self, (x1, y1): (f64, f64), (x2, y2): (f64, f64), color: Rgb, width: f64) {
895 self.out.push_str(&format!(
896 "<line x1=\"{x1:.1}\" y1=\"{y1:.1}\" x2=\"{x2:.1}\" y2=\"{y2:.1}\" stroke=\"{}\" \
897 stroke-width=\"{width:.2}\"/>\n",
898 color.hex()
899 ));
900 }
901
902 fn rect(&mut self, x: f64, y: f64, w: f64, h: f64, fill: Rgb, opacity: f64) {
903 if w <= 0.0 || h <= 0.0 {
904 return;
905 }
906 self.out.push_str(&format!(
907 "<rect x=\"{x:.2}\" y=\"{y:.2}\" width=\"{w:.2}\" height=\"{h:.2}\" fill=\"{}\" \
908 fill-opacity=\"{opacity:.2}\"/>\n",
909 fill.hex()
910 ));
911 }
912
913 fn polyline(&mut self, points: &[(f64, f64)], color: Rgb, width: f64) {
914 if points.len() < 2 {
915 if let Some(&(x, y)) = points.first() {
916 self.dot(x, y, width, color, 1.0);
917 }
918 return;
919 }
920 let pts: Vec<String> = points
921 .iter()
922 .map(|(x, y)| format!("{x:.1},{y:.1}"))
923 .collect();
924 self.out.push_str(&format!(
925 "<polyline points=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{width:.2}\" \
926 stroke-linejoin=\"round\" stroke-linecap=\"round\"/>\n",
927 pts.join(" "),
928 color.hex()
929 ));
930 }
931
932 fn dot(&mut self, x: f64, y: f64, r: f64, color: Rgb, opacity: f64) {
933 let opacity = if opacity < 1.0 {
934 format!(" fill-opacity=\"{opacity:.2}\"")
935 } else {
936 String::new()
937 };
938 self.out.push_str(&format!(
939 "<circle cx=\"{x:.1}\" cy=\"{y:.1}\" r=\"{r:.2}\" fill=\"{}\"{opacity}/>\n",
940 color.hex()
941 ));
942 }
943
944 fn color(&self, i: usize) -> Rgb {
945 if self.other == Some(i) {
946 return self.palette.other;
947 }
948 self.palette.series[i % self.palette.series.len()]
949 }
950}
951
952#[derive(Clone, Copy, Debug)]
954struct Area {
955 left: f64,
956 top: f64,
957 right: f64,
958 bottom: f64,
959}
960
961impl Area {
962 fn width(&self) -> f64 {
963 self.right - self.left
964 }
965 fn height(&self) -> f64 {
966 self.bottom - self.top
967 }
968}
969
970#[derive(Clone, Copy, Debug)]
972struct Scale {
973 lo: f64,
974 hi: f64,
975 from: f64,
976 to: f64,
977}
978
979impl Scale {
980 fn at(&self, v: f64) -> f64 {
981 let span = self.hi - self.lo;
982 if span.abs() < f64::EPSILON {
983 return (self.from + self.to) / 2.0;
984 }
985 self.from + (v - self.lo) / span * (self.to - self.from)
986 }
987}
988
989fn axis_ticks(lo: f64, hi: f64, axis: &Axis, most: usize) -> Vec<(f64, String)> {
992 let most = most.max(2);
993 if axis.kind != XAxisTemporalKind::Numeric
994 && axis.kind != XAxisTemporalKind::Time
995 && let (Some(a), Some(b)) = (
996 ticks::to_datetime(lo, axis.kind),
997 ticks::to_datetime(hi, axis.kind),
998 )
999 {
1000 for step in ticks::calendar_steps(axis.kind) {
1001 let at = ticks::calendar_ticks(a, b, step);
1002 if at.is_empty() || at.len() > most {
1003 continue;
1004 }
1005 let labels = ticks::calendar_labels(&at, step.unit, axis.kind, true);
1006 return at
1007 .iter()
1008 .zip(labels)
1009 .filter_map(|(t, label)| Some((ticks::from_datetime(*t, axis.kind)?, label)))
1010 .collect();
1011 }
1012 }
1013 let (show_lo, show_hi) = if axis.log {
1014 (lo.exp_m1(), hi.exp_m1())
1015 } else {
1016 (lo, hi)
1017 };
1018 let whole = axis.numbers.whole && !axis.log;
1019 let steps = ticks::nice_steps(show_lo, show_hi, 0.0, whole);
1020 let step = steps
1021 .iter()
1022 .copied()
1023 .find(|s| (((show_hi - show_lo) / s) as usize) < most)
1024 .or_else(|| steps.last().copied());
1025 let values = match step {
1026 Some(step) => ticks::multiples(show_lo, show_hi, step),
1027 None => vec![show_lo],
1028 };
1029 let format = AxisFormat::new(&values, &axis.numbers);
1030 values
1031 .iter()
1032 .filter_map(|&v| {
1033 let label = x_axis_label_at(v, axis.kind, (show_lo, show_hi), 0, &format)?;
1034 let at = if axis.log { v.max(0.0).ln_1p() } else { v };
1035 Some((at, label))
1036 })
1037 .collect()
1038}
1039
1040fn span(lo: f64, hi: f64) -> (f64, f64) {
1042 if !(lo.is_finite() && hi.is_finite()) {
1043 return (0.0, 1.0);
1044 }
1045 if hi > lo {
1046 (lo, hi)
1047 } else {
1048 (lo - 0.5, hi + 0.5)
1049 }
1050}
1051
1052pub fn svg(figure: &Figure, options: &ExportOptions) -> Result<String> {
1054 let (w, h) = (f64::from(options.width), f64::from(options.height));
1055 if options.width == 0 || options.height == 0 {
1056 return Err(color_eyre::eyre::eyre!(
1057 "a chart needs a width and a height"
1058 ));
1059 }
1060 let palette = &options.palette;
1061 let pt = f64::from(options.dpi) / 72.0;
1062 let width_in = w / f64::from(options.dpi);
1064 let base_pt = (width_in * 1.25).clamp(7.0, 13.0);
1065 let body = base_pt * pt;
1066 let mut c = Canvas {
1067 out: String::new(),
1068 palette,
1069 pt,
1070 body,
1071 other: None,
1072 swatch_stroke: 1.75 * pt,
1073 };
1074 let margin = (body * 2.0).min(w / 10.0);
1075 if let Some(bg) = palette.background {
1076 c.rect(0.0, 0.0, w, h, bg, 1.0);
1077 }
1078
1079 let title_size = body * 1.45;
1081 let small = body * 0.82;
1082 let text_width_max = w - 2.0 * margin;
1083 let mut y = margin;
1084 for line in wrap(&options.title, title_size, text_width_max) {
1085 y += title_size;
1086 c.text((margin, y), title_size, palette.text, ("start", 600), &line);
1087 y += title_size * 0.25;
1088 }
1089 for line in wrap(&options.description, body, text_width_max) {
1090 y += body * 1.1;
1091 c.text(
1092 (margin, y),
1093 body,
1094 palette.text_secondary,
1095 ("start", 400),
1096 &line,
1097 );
1098 }
1099 if y > margin {
1100 y += body * 0.9;
1101 }
1102
1103 let mut footer: Vec<(String, Rgb)> = Vec::new();
1106 if !figure.chart_notes.is_empty() {
1107 footer.push((figure.chart_notes.join(" · "), palette.text_secondary));
1108 }
1109 for line in wrap(&options.notes, small, text_width_max) {
1110 footer.push((line, palette.text_secondary));
1111 }
1112 let mut credit = Vec::new();
1113 if !options.source.trim().is_empty() {
1114 credit.push(format!("Source: {}", options.source.trim()));
1115 }
1116 if !options.byline.trim().is_empty() {
1117 credit.push(options.byline.trim().to_string());
1118 }
1119 if !credit.is_empty() {
1120 for line in wrap(&credit.join(" · "), small, text_width_max) {
1121 footer.push((line, palette.text_secondary));
1122 }
1123 }
1124 let line_h = small * 1.35;
1125 let footer_top = h - margin - footer.len() as f64 * line_h;
1126 for (i, (line, color)) in footer.iter().enumerate() {
1127 let baseline = footer_top + (i as f64 + 1.0) * line_h - small * 0.3;
1128 c.text((margin, baseline), small, *color, ("start", 400), line);
1129 }
1130 let bottom = if footer.is_empty() {
1131 h - margin
1132 } else {
1133 footer_top - body * 0.8
1134 };
1135 let frame = Area {
1136 left: margin,
1137 top: y,
1138 right: w - margin,
1139 bottom,
1140 };
1141 if frame.height() < body * 4.0 || frame.width() < body * 6.0 {
1142 return Err(color_eyre::eyre::eyre!(
1143 "the chart does not fit at {}x{}: make it larger or the text shorter",
1144 options.width,
1145 options.height
1146 ));
1147 }
1148 draw_plot(&mut c, figure, options, frame);
1149 Ok(format!(
1150 "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" \
1151 viewBox=\"0 0 {w} {h}\" font-family=\"{FONT_FAMILY}\">\n{}</svg>\n",
1152 c.out,
1153 ))
1154}
1155
1156fn legend_names(figure: &Figure) -> Vec<String> {
1158 match &figure.plot {
1159 Plot::Lines { series, .. } => series.iter().map(|s| s.name.clone()).collect(),
1160 Plot::Bars { data, .. } => data.groups.clone(),
1161 Plot::Histogram { data, .. } => data.groups.iter().map(|g| g.name.clone()).collect(),
1162 Plot::Kde { data, .. } => data.series.iter().map(|s| s.name.clone()).collect(),
1163 Plot::Box { .. } | Plot::Heatmap { .. } => Vec::new(),
1164 }
1165}
1166
1167fn other_at(figure: &Figure) -> Option<usize> {
1169 let (other, n) = match &figure.plot {
1170 Plot::Lines { series, .. } => return series.iter().position(|s| s.other),
1171 Plot::Bars { data, .. } => (data.other, data.groups.len()),
1172 Plot::Histogram { data, .. } => (data.other, data.groups.len()),
1173 Plot::Kde { data, .. } => (data.other, data.series.len()),
1174 Plot::Box { .. } | Plot::Heatmap { .. } => (false, 0),
1175 };
1176 (other && n > 0).then(|| n - 1)
1177}
1178
1179fn draw_plot(c: &mut Canvas<'_>, figure: &Figure, options: &ExportOptions, frame: Area) {
1181 let names = legend_names(figure);
1182 c.other = other_at(figure);
1183 let is_lines = matches!(
1184 figure.plot,
1185 Plot::Lines { scatter: false, .. } | Plot::Kde { .. }
1186 );
1187 if matches!(figure.plot, Plot::Lines { scatter: false, .. }) {
1189 c.swatch_stroke = 1.75 / 1.5 * options.line_width.pt() * c.pt;
1190 }
1191 let legend = if names.len() < 2 {
1193 LegendPlace::Off
1194 } else if options.legend == LegendPlace::LineEnds && !is_lines {
1195 LegendPlace::TopRight
1196 } else {
1197 options.legend
1198 };
1199 let tick = c.body * 0.9;
1200 let mut frame = frame;
1201 if legend == LegendPlace::LineEnds {
1202 let widest = names
1203 .iter()
1204 .map(|n| text_width(n, tick))
1205 .fold(0.0, f64::max);
1206 frame.right -= (widest + tick).min(frame.width() / 3.0);
1207 }
1208 match &figure.plot {
1209 Plot::Lines {
1210 series,
1211 scatter,
1212 x,
1213 y,
1214 y_from_zero,
1215 } => {
1216 let all = series.iter().flat_map(|s| s.points.iter());
1217 let (x_lo, x_hi) = all
1218 .clone()
1219 .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), p| {
1220 (a.min(p.0), b.max(p.0))
1221 });
1222 let (y_lo, y_hi) = all.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), p| {
1223 (a.min(p.1), b.max(p.1))
1224 });
1225 let (mut y_lo, mut y_hi) = (y_lo, y_hi);
1226 if options.y_from_zero.unwrap_or(*y_from_zero) {
1227 y_lo = y_lo.min(0.0);
1228 y_hi = y_hi.max(0.0);
1229 }
1230 let (sx, sy, plot) = axes(
1231 c,
1232 frame,
1233 span(x_lo, x_hi),
1234 span(y_lo, y_hi),
1235 x,
1236 y,
1237 figure.grid,
1238 );
1239 let width = options.line_width.pt() * c.pt;
1240 let radius = options.point_size.pt() * c.pt;
1241 let opacity = options
1242 .point_opacity
1243 .of(series.iter().map(|s| s.points.len()).sum());
1244 let mut ends = Vec::new();
1245 let mut order: Vec<(usize, &Series)> = series.iter().enumerate().collect();
1247 order.sort_by_key(|(_, s)| !s.other);
1248 for (i, s) in order {
1249 let color = c.color(i);
1250 if *scatter {
1251 for &(px, py) in &s.points {
1252 c.dot(sx.at(px), sy.at(py), radius, color, opacity);
1253 }
1254 } else {
1255 for run in segments(&s.points, &s.breaks) {
1256 let pts: Vec<(f64, f64)> =
1257 run.iter().map(|&(px, py)| (sx.at(px), sy.at(py))).collect();
1258 c.polyline(&pts, color, width);
1259 }
1260 }
1261 if let Some(&(px, py)) = s.points.last() {
1262 ends.push((sy.at(py), sx.at(px), i));
1263 }
1264 }
1265 if legend == LegendPlace::LineEnds {
1266 line_end_labels(c, &names, ends, plot);
1267 }
1268 }
1269 Plot::Kde { data, x, y } => {
1270 let (sx, sy, plot) = axes(
1271 c,
1272 frame,
1273 span(data.x_min, data.x_max),
1274 (0.0, data.y_max),
1275 x,
1276 y,
1277 figure.grid,
1278 );
1279 let mut ends = Vec::new();
1280 for (i, s) in data.series.iter().enumerate() {
1281 let pts: Vec<(f64, f64)> = s
1282 .points
1283 .iter()
1284 .map(|&(px, py)| (sx.at(px), sy.at(py)))
1285 .collect();
1286 if let Some(&(px, py)) = pts.last() {
1287 ends.push((py, px, i));
1288 }
1289 c.polyline(&pts, c.color(i), 1.5 * c.pt);
1290 }
1291 if legend == LegendPlace::LineEnds {
1292 line_end_labels(c, &names, ends, plot);
1293 }
1294 }
1295 Plot::Histogram { data, x, y } => {
1296 let max = if data.max_count > 0.0 {
1297 data.max_count
1298 } else {
1299 1.0
1300 };
1301 let (sx, sy, _) = axes(
1302 c,
1303 frame,
1304 span(data.x_min, data.x_max),
1305 (0.0, max),
1306 x,
1307 y,
1308 figure.grid,
1309 );
1310 let n = data.bins.len().max(1);
1311 let bin = (data.x_max - data.x_min) / n as f64;
1312 if data.groups.is_empty() {
1313 let gap = 1.0 * c.pt;
1315 for (i, b) in data.bins.iter().enumerate() {
1316 let x0 = sx.at(data.x_min + i as f64 * bin);
1317 let x1 = sx.at(data.x_min + (i + 1) as f64 * bin);
1318 let top = sy.at(b.count);
1319 c.rect(
1320 x0 + gap / 2.0,
1321 top,
1322 (x1 - x0 - gap).max(0.5),
1323 sy.at(0.0) - top,
1324 c.color(0),
1325 1.0,
1326 );
1327 }
1328 } else {
1329 for (g, group) in data.groups.iter().enumerate() {
1331 let mut pts = vec![(sx.at(data.x_min), sy.at(0.0))];
1332 for (i, &count) in group.counts.iter().enumerate() {
1333 let x0 = sx.at(data.x_min + i as f64 * bin);
1334 let x1 = sx.at(data.x_min + (i + 1) as f64 * bin);
1335 pts.push((x0, sy.at(count)));
1336 pts.push((x1, sy.at(count)));
1337 }
1338 pts.push((sx.at(data.x_max), sy.at(0.0)));
1339 c.polyline(&pts, c.color(g), 1.5 * c.pt);
1340 }
1341 }
1342 }
1343 Plot::Box { data, x_title, y } => {
1344 let n = data.stats.len().max(1);
1345 let x_axis = Axis {
1346 title: x_title.clone(),
1347 ..Default::default()
1348 };
1349 let (lo, hi) = span(data.y_min, data.y_max);
1350 let pad = (hi - lo) * 0.04;
1351 let (_, sy, plot) = category_axes(
1352 c,
1353 frame,
1354 &data
1355 .stats
1356 .iter()
1357 .map(|s| s.name.clone())
1358 .collect::<Vec<_>>(),
1359 (lo - pad, hi + pad),
1360 &x_axis,
1361 y,
1362 figure.grid,
1363 );
1364 let slot = plot.width() / n as f64;
1365 for (i, s) in data.stats.iter().enumerate() {
1366 let color = c.color(i);
1367 let mid = plot.left + slot * (i as f64 + 0.5);
1368 let half = (slot * 0.3).min(c.body * 3.0);
1369 let stroke = 1.25 * c.pt;
1370 c.line((mid, sy.at(s.max)), (mid, sy.at(s.q3)), color, stroke);
1371 c.line((mid, sy.at(s.q1)), (mid, sy.at(s.min)), color, stroke);
1372 c.line(
1373 (mid - half / 2.0, sy.at(s.max)),
1374 (mid + half / 2.0, sy.at(s.max)),
1375 color,
1376 stroke,
1377 );
1378 c.line(
1379 (mid - half / 2.0, sy.at(s.min)),
1380 (mid + half / 2.0, sy.at(s.min)),
1381 color,
1382 stroke,
1383 );
1384 let top = sy.at(s.q3);
1385 c.rect(mid - half, top, half * 2.0, sy.at(s.q1) - top, color, 0.18);
1386 c.out.push_str(&format!(
1387 "<rect x=\"{:.2}\" y=\"{top:.2}\" width=\"{:.2}\" height=\"{:.2}\" \
1388 fill=\"none\" stroke=\"{}\" stroke-width=\"{stroke:.2}\"/>\n",
1389 mid - half,
1390 half * 2.0,
1391 (sy.at(s.q1) - top).max(0.0),
1392 color.hex()
1393 ));
1394 c.line(
1395 (mid - half, sy.at(s.median)),
1396 (mid + half, sy.at(s.median)),
1397 color,
1398 stroke * 2.0,
1399 );
1400 }
1401 }
1402 Plot::Heatmap { data, x, y } => {
1403 let (sx, sy, _) = axes(
1404 c,
1405 frame,
1406 span(data.x_min, data.x_max),
1407 span(data.y_min, data.y_max),
1408 x,
1409 y,
1410 false,
1411 );
1412 let xw = (data.x_max - data.x_min) / data.x_bins.max(1) as f64;
1413 let yh = (data.y_max - data.y_min) / data.y_bins.max(1) as f64;
1414 let ramp = c.palette.ramp;
1415 for (yi, row) in data.counts.iter().enumerate() {
1416 for (xi, &count) in row.iter().enumerate() {
1417 if count <= 0.0 || data.max_count <= 0.0 {
1418 continue;
1419 }
1420 let level =
1421 ((count / data.max_count) * (ramp.len() - 1) as f64).round() as usize;
1422 let x0 = sx.at(data.x_min + xi as f64 * xw);
1423 let x1 = sx.at(data.x_min + (xi + 1) as f64 * xw);
1424 let y0 = sy.at(data.y_min + (yi + 1) as f64 * yh);
1425 let y1 = sy.at(data.y_min + yi as f64 * yh);
1426 c.rect(
1427 x0,
1428 y0,
1429 x1 - x0,
1430 y1 - y0,
1431 ramp[level.min(ramp.len() - 1)],
1432 1.0,
1433 );
1434 }
1435 }
1436 }
1437 Plot::Bars { data, value } => draw_bars(c, frame, data, value, figure.grid),
1438 }
1439 match legend {
1440 LegendPlace::Off | LegendPlace::LineEnds => {}
1441 LegendPlace::TopRight | LegendPlace::TopLeft => {
1443 legend_row(c, &names, legend, frame, axis_title(figure), is_lines)
1444 }
1445 LegendPlace::BottomRight | LegendPlace::BottomLeft => {
1446 legend_box(c, &names, legend, frame, is_lines)
1447 }
1448 }
1449}
1450
1451fn axis_title(figure: &Figure) -> &str {
1454 match &figure.plot {
1455 Plot::Lines { y, .. }
1456 | Plot::Histogram { y, .. }
1457 | Plot::Kde { y, .. }
1458 | Plot::Box { y, .. }
1459 | Plot::Heatmap { y, .. } => &y.title,
1460 Plot::Bars { data, .. } => &data.category,
1461 }
1462}
1463
1464fn legend_row(
1467 c: &mut Canvas<'_>,
1468 names: &[String],
1469 place: LegendPlace,
1470 frame: Area,
1471 title: &str,
1472 lines: bool,
1473) {
1474 let tick = c.body * 0.85;
1475 let swatch = tick * 1.2;
1476 let gap = tick * 1.2;
1477 let item = |name: &str| swatch + tick * 0.4 + text_width(name, tick);
1478 let width: f64 =
1479 names.iter().map(|n| item(n)).sum::<f64>() + gap * names.len().saturating_sub(1) as f64;
1480 let mut x = match place {
1481 LegendPlace::TopLeft => frame.left + text_width(title, c.body * 0.9) + gap * 1.5,
1482 _ => (frame.right - width).max(frame.left),
1483 };
1484 let baseline = frame.top + c.body * 0.9;
1485 let middle = baseline - tick * 0.35;
1486 for (i, name) in names.iter().enumerate() {
1487 let color = c.color(i);
1488 if lines {
1489 c.line((x, middle), (x + swatch, middle), color, c.swatch_stroke);
1490 } else {
1491 c.rect(x, middle - tick * 0.35, swatch, tick * 0.7, color, 1.0);
1492 }
1493 c.text(
1494 (x + swatch + tick * 0.4, baseline),
1495 tick,
1496 c.palette.text,
1497 ("start", 400),
1498 name,
1499 );
1500 x += item(name) + gap;
1501 }
1502}
1503
1504fn axes(
1507 c: &mut Canvas<'_>,
1508 frame: Area,
1509 xs: (f64, f64),
1510 ys: (f64, f64),
1511 x: &Axis,
1512 y: &Axis,
1513 grid: bool,
1514) -> (Scale, Scale, Area) {
1515 axes_with(c, frame, xs, ys, (x, true), y, grid)
1516}
1517
1518fn axes_with(
1521 c: &mut Canvas<'_>,
1522 frame: Area,
1523 xs: (f64, f64),
1524 ys: (f64, f64),
1525 (x, x_ticked): (&Axis, bool),
1526 y: &Axis,
1527 grid: bool,
1528) -> (Scale, Scale, Area) {
1529 let tick = c.body * 0.9;
1530 let palette = c.palette.clone();
1531 let top = frame.top + tick * 2.2;
1533 let x_title_h = if x.title.is_empty() { 0.0 } else { tick * 1.5 };
1534 let bottom = frame.bottom - tick * 1.6 - x_title_h;
1535 let y_ticks = axis_ticks(ys.0, ys.1, y, ((bottom - top) / (tick * 3.0)) as usize);
1536 let y_label_w = y_ticks
1537 .iter()
1538 .map(|(_, l)| text_width(l, tick))
1539 .fold(0.0, f64::max);
1540 let plot = Area {
1541 left: frame.left + y_label_w + tick * 0.8,
1542 top,
1543 right: frame.right,
1544 bottom,
1545 };
1546 let sx = Scale {
1547 lo: xs.0,
1548 hi: xs.1,
1549 from: plot.left,
1550 to: plot.right,
1551 };
1552 let sy = Scale {
1553 lo: ys.0,
1554 hi: ys.1,
1555 from: plot.bottom,
1556 to: plot.top,
1557 };
1558 let widest_x = |labels: &[(f64, String)]| {
1559 labels
1560 .iter()
1561 .map(|(_, l)| text_width(l, tick))
1562 .fold(0.0, f64::max)
1563 };
1564 let mut most = (plot.width() / (tick * 6.0)) as usize;
1565 let mut x_ticks = if x_ticked {
1566 axis_ticks(xs.0, xs.1, x, most)
1567 } else {
1568 Vec::new()
1569 };
1570 while x_ticked && most > 2 && widest_x(&x_ticks) * x_ticks.len() as f64 * 1.4 > plot.width() {
1571 most -= 1;
1572 x_ticks = axis_ticks(xs.0, xs.1, x, most);
1573 }
1574 let hair = 0.6 * c.pt;
1575 for (v, label) in &y_ticks {
1576 let py = sy.at(*v);
1577 if grid {
1578 c.line((plot.left, py), (plot.right, py), palette.grid, hair);
1579 }
1580 c.text(
1581 (plot.left - tick * 0.5, py + tick * 0.35),
1582 tick,
1583 palette.text_secondary,
1584 ("end", 400),
1585 label,
1586 );
1587 }
1588 for (v, label) in &x_ticks {
1589 let px = sx.at(*v);
1590 if grid {
1591 c.line((px, plot.top), (px, plot.bottom), palette.grid, hair);
1592 }
1593 c.line(
1594 (px, plot.bottom),
1595 (px, plot.bottom + tick * 0.35),
1596 palette.text_secondary,
1597 hair,
1598 );
1599 c.text(
1600 (px, plot.bottom + tick * 1.35),
1601 tick,
1602 palette.text_secondary,
1603 ("middle", 400),
1604 label,
1605 );
1606 }
1607 c.line(
1608 (plot.left, plot.bottom),
1609 (plot.right, plot.bottom),
1610 palette.text_secondary,
1611 hair,
1612 );
1613 if !y.title.is_empty() {
1614 c.text(
1615 (frame.left, frame.top + tick),
1616 tick,
1617 palette.text,
1618 ("start", 600),
1619 &y.title,
1620 );
1621 }
1622 if !x.title.is_empty() {
1623 c.text(
1624 ((plot.left + plot.right) / 2.0, frame.bottom - tick * 0.2),
1625 tick,
1626 palette.text,
1627 ("middle", 600),
1628 &x.title,
1629 );
1630 }
1631 (sx, sy, plot)
1632}
1633
1634fn category_axes(
1636 c: &mut Canvas<'_>,
1637 frame: Area,
1638 names: &[String],
1639 ys: (f64, f64),
1640 x: &Axis,
1641 y: &Axis,
1642 grid: bool,
1643) -> (Scale, Scale, Area) {
1644 let n = names.len().max(1) as f64;
1645 let (sx, sy, plot) = axes_with(c, frame, (0.0, n), ys, (x, false), y, grid);
1647 let tick = c.body * 0.9;
1648 let slot = plot.width() / n;
1649 let max_chars = (slot / (tick * 0.55)).max(3.0) as usize;
1650 for (i, name) in names.iter().enumerate() {
1651 let label = if name.chars().count() > max_chars {
1652 let kept: String = name.chars().take(max_chars.saturating_sub(1)).collect();
1653 format!("{kept}…")
1654 } else {
1655 name.clone()
1656 };
1657 c.text(
1658 (
1659 plot.left + slot * (i as f64 + 0.5),
1660 plot.bottom + tick * 1.35,
1661 ),
1662 tick,
1663 c.palette.text_secondary,
1664 ("middle", 400),
1665 &label,
1666 );
1667 }
1668 (sx, sy, plot)
1669}
1670
1671fn line_end_labels(
1674 c: &mut Canvas<'_>,
1675 names: &[String],
1676 mut ends: Vec<(f64, f64, usize)>,
1677 plot: Area,
1678) {
1679 let tick = c.body * 0.9;
1680 ends.sort_by(|a, b| a.0.total_cmp(&b.0));
1681 let mut last = f64::NEG_INFINITY;
1682 for (y, _, _) in &mut ends {
1683 *y = y.max(last + tick * 1.15).max(plot.top);
1684 last = *y;
1685 }
1686 if let Some(over) = ends
1688 .last()
1689 .map(|(y, _, _)| *y - plot.bottom)
1690 .filter(|o| *o > 0.0)
1691 {
1692 for (y, _, _) in &mut ends {
1693 *y -= over;
1694 }
1695 }
1696 for (y, _, i) in ends {
1697 c.text(
1698 (plot.right + tick * 0.5, y + tick * 0.35),
1699 tick,
1700 c.color(i),
1701 ("start", 600),
1702 &names[i],
1703 );
1704 }
1705}
1706
1707fn legend_box(c: &mut Canvas<'_>, names: &[String], place: LegendPlace, frame: Area, lines: bool) {
1710 let tick = c.body * 0.85;
1711 let row = tick * 1.4;
1712 let swatch = tick * 1.2;
1713 let width = names
1714 .iter()
1715 .map(|n| text_width(n, tick))
1716 .fold(0.0, f64::max)
1717 + swatch
1718 + tick * 1.5;
1719 let height = row * names.len() as f64 + tick * 0.6;
1720 let pad = tick * 0.6;
1721 let plot_top = frame.top + tick * 2.4;
1722 let plot_bottom = frame.bottom - tick * 3.3;
1723 let (x, y) = match place {
1724 LegendPlace::TopLeft => (frame.left + tick * 4.0, plot_top + pad),
1725 LegendPlace::BottomRight => (frame.right - width - pad, plot_bottom - height - pad),
1726 LegendPlace::BottomLeft => (frame.left + tick * 4.0, plot_bottom - height - pad),
1727 _ => (frame.right - width - pad, plot_top + pad),
1728 };
1729 if let Some(bg) = c.palette.background {
1730 c.rect(x, y, width, height, bg, 0.9);
1731 }
1732 for (i, name) in names.iter().enumerate() {
1733 let cy = y + tick * 0.3 + row * (i as f64 + 0.5);
1734 let color = c.color(i);
1735 if lines {
1736 c.line(
1737 (x + pad, cy),
1738 (x + pad + swatch, cy),
1739 color,
1740 c.swatch_stroke,
1741 );
1742 } else {
1743 c.rect(x + pad, cy - tick * 0.35, swatch, tick * 0.7, color, 1.0);
1744 }
1745 c.text(
1746 (x + pad + swatch + tick * 0.5, cy + tick * 0.35),
1747 tick,
1748 c.palette.text,
1749 ("start", 400),
1750 name,
1751 );
1752 }
1753}
1754
1755fn draw_bars(c: &mut Canvas<'_>, frame: Area, data: &BarData, value: &Axis, grid: bool) {
1758 let tick = c.body * 0.9;
1759 let palette = c.palette.clone();
1760 let top = frame.top + tick * 2.2;
1761 let x_title_h = if value.title.is_empty() {
1762 0.0
1763 } else {
1764 tick * 1.5
1765 };
1766 let bottom = frame.bottom - tick * 1.6 - x_title_h;
1767 let groups = data.groups.len().max(1);
1768 let row_min = (tick * 1.3).max(tick * 0.5 * groups as f64);
1771 let fits = (((bottom - top) / row_min) as usize).max(1);
1772 let mut bars: Vec<&crate::chart_data::Bar> = data.bars.iter().collect();
1773 let mut more = data.more;
1774 if bars.len() > fits {
1775 more += bars.len() - (fits - 1);
1776 bars.truncate(fits - 1);
1777 }
1778 let null = "null".to_string();
1779 let label_of = |b: &crate::chart_data::Bar| b.label.clone().unwrap_or_else(|| null.clone());
1780 let more_label = format!("+ {} more", crate::numfmt::group_chrome(more));
1781 let label_w = bars
1782 .iter()
1783 .map(|b| text_width(&label_of(b), tick))
1784 .chain((more > 0).then(|| text_width(&more_label, tick)))
1785 .fold(0.0, f64::max)
1786 .min(frame.width() * 0.35);
1787 let values = || {
1788 bars.iter().flat_map(|b| {
1789 if b.by_group.is_empty() {
1790 vec![b.value]
1791 } else {
1792 b.by_group.iter().flatten().copied().collect()
1793 }
1794 })
1795 };
1796 let lo = values().fold(0.0_f64, f64::min);
1797 let hi = values().fold(0.0_f64, f64::max);
1798 let (lo, hi) = if hi > lo { (lo, hi) } else { (lo, lo + 1.0) };
1799 let plot = Area {
1800 left: frame.left + label_w + tick,
1801 top,
1802 right: frame.right,
1803 bottom,
1804 };
1805 let sx = Scale {
1806 lo,
1807 hi,
1808 from: plot.left,
1809 to: plot.right,
1810 };
1811 let x_ticks = axis_ticks(lo, hi, value, (plot.width() / (tick * 6.0)) as usize);
1812 let hair = 0.6 * c.pt;
1813 for (v, label) in &x_ticks {
1814 let px = sx.at(*v);
1815 if grid {
1816 c.line((px, plot.top), (px, plot.bottom), palette.grid, hair);
1817 }
1818 c.text(
1819 (px, plot.bottom + tick * 1.35),
1820 tick,
1821 palette.text_secondary,
1822 ("middle", 400),
1823 label,
1824 );
1825 }
1826 if !value.title.is_empty() {
1827 c.text(
1828 ((plot.left + plot.right) / 2.0, frame.bottom - tick * 0.2),
1829 tick,
1830 palette.text,
1831 ("middle", 600),
1832 &value.title,
1833 );
1834 }
1835 c.text(
1836 (frame.left, frame.top + tick),
1837 tick,
1838 palette.text,
1839 ("start", 600),
1840 &data.category,
1841 );
1842 let rows = bars.len() + usize::from(more > 0);
1843 let row_h = (plot.height() / rows.max(1) as f64).min(tick * 2.5 * groups as f64);
1844 let zero = sx.at(0.0);
1845 let gap = 1.0 * c.pt;
1846 for (i, bar) in bars.iter().enumerate() {
1847 let y0 = plot.top + row_h * i as f64;
1848 let label = label_of(bar);
1849 let max_chars = (label_w / (tick * 0.5)).max(3.0) as usize;
1850 let label = if label.chars().count() > max_chars {
1851 let kept: String = label.chars().take(max_chars.saturating_sub(1)).collect();
1852 format!("{kept}…")
1853 } else {
1854 label
1855 };
1856 c.text(
1857 (plot.left - tick * 0.5, y0 + row_h / 2.0 + tick * 0.35),
1858 tick,
1859 palette.text,
1860 ("end", 400),
1861 &label,
1862 );
1863 let body = row_h * 0.75;
1864 let pieces: Vec<(usize, f64)> = if bar.by_group.is_empty() {
1865 vec![(0, bar.value)]
1866 } else {
1867 bar.by_group
1868 .iter()
1869 .enumerate()
1870 .filter_map(|(g, v)| v.map(|v| (g, v)))
1871 .collect()
1872 };
1873 let each = body / groups as f64;
1874 for (g, v) in pieces {
1875 let slot = if bar.by_group.is_empty() { 0 } else { g };
1876 let y = y0 + (row_h - body) / 2.0 + each * slot as f64;
1877 let end = sx.at(v);
1878 let (x, w) = if end >= zero {
1879 (zero, end - zero)
1880 } else {
1881 (end, zero - end)
1882 };
1883 c.rect(
1884 x,
1885 y + gap / 2.0,
1886 w.max(hair),
1887 (each - gap).max(hair),
1888 c.color(g),
1889 1.0,
1890 );
1891 }
1892 }
1893 if more > 0 {
1894 let y0 = plot.top + row_h * bars.len() as f64;
1895 c.text(
1896 (plot.left - tick * 0.5, y0 + row_h / 2.0 + tick * 0.35),
1897 tick,
1898 palette.text_secondary,
1899 ("end", 400),
1900 &more_label,
1901 );
1902 }
1903 c.line(
1904 (zero, plot.top),
1905 (zero, plot.bottom),
1906 palette.text_secondary,
1907 hair,
1908 );
1909}
1910
1911#[cfg(test)]
1912mod tests {
1913 use super::*;
1914 use crate::chart_data::{Bar, BoxPlotStats, HistogramBin, HistogramGroup, OTHER, RowsRead};
1915
1916 fn lines(names: &[&str]) -> Figure {
1917 Figure {
1918 plot: Plot::Lines {
1919 series: names
1920 .iter()
1921 .enumerate()
1922 .map(|(i, n)| Series {
1923 name: n.to_string(),
1924 points: (0..10).map(|x| (x as f64, (x * (i + 1)) as f64)).collect(),
1925 breaks: Vec::new(),
1926 other: *n == OTHER,
1927 })
1928 .collect(),
1929 scatter: false,
1930 x: Axis {
1931 title: "x".to_string(),
1932 ..Default::default()
1933 },
1934 y: Axis {
1935 title: "value".to_string(),
1936 ..Default::default()
1937 },
1938 y_from_zero: false,
1939 },
1940 chart_notes: vec!["sample of 1,000 of 50k rows".to_string()],
1941 grid: true,
1942 }
1943 }
1944
1945 fn options() -> ExportOptions {
1946 ExportOptions {
1947 title: "Cumulative return by symbol".to_string(),
1948 description: "Mean monthly return, compounded".to_string(),
1949 notes: "Illustrative values".to_string(),
1950 source: "NYC flights, public domain".to_string(),
1951 byline: "Chart: datui".to_string(),
1952 ..Default::default()
1953 }
1954 }
1955
1956 #[test]
1959 fn the_svg_carries_the_words_and_names_line_ends() {
1960 let svg = svg(&lines(&["AAPL", "MSFT"]), &options()).unwrap();
1961 for text in [
1962 "Cumulative return by symbol",
1963 "Mean monthly return, compounded",
1964 "Illustrative values",
1965 "Source: NYC flights, public domain · Chart: datui",
1966 "sample of 1,000 of 50k rows",
1967 ">AAPL<",
1968 ">MSFT<",
1969 ] {
1970 assert!(svg.contains(text), "{text} missing:\n{svg}");
1971 }
1972 assert!(svg.contains("fill=\"#2a78d6\""), "light palette: {svg}");
1973 assert!(svg.contains("fill=\"#ffffff\""), "a white background");
1974 roxmltree_ok(&svg);
1975 }
1976
1977 #[test]
1980 fn an_export_names_other_last_in_its_neutral_color() {
1981 let mut figure = lines(&["AAPL", "MSFT", OTHER]);
1982 if let Plot::Lines { scatter, .. } = &mut figure.plot {
1983 *scatter = true;
1984 }
1985 let svg = svg(
1986 &figure,
1987 &ExportOptions {
1988 legend: LegendPlace::TopRight,
1989 ..options()
1990 },
1991 )
1992 .unwrap();
1993 let (aapl, other) = (svg.find(">AAPL<").unwrap(), svg.find(">Other<").unwrap());
1994 assert!(aapl < other, "Other last: {svg}");
1995 let grey = format!("fill=\"{}\"", Palette::light().other.hex());
1996 let third = format!("fill=\"{}\"", Palette::light().series[2].hex());
1997 assert!(svg.contains(&grey), "{svg}");
1998 assert!(!svg.contains(&third), "Other takes no series color: {svg}");
1999 let blue = format!("fill=\"{}\"", Palette::light().series[0].hex());
2001 assert!(svg.find(&grey).unwrap() < svg.find(&blue).unwrap(), "{svg}");
2002 roxmltree_ok(&svg);
2003 }
2004
2005 fn roxmltree_ok(svg: &str) {
2006 tree(svg).expect("usvg reads the SVG");
2007 }
2008
2009 #[test]
2011 fn legend_off_draws_no_legend() {
2012 let figure = lines(&["AAPL", "MSFT"]);
2013 let off = svg(
2014 &figure,
2015 &ExportOptions {
2016 legend: LegendPlace::Off,
2017 ..options()
2018 },
2019 )
2020 .unwrap();
2021 assert!(!off.contains(">AAPL<") && !off.contains(">MSFT<"), "{off}");
2022 let boxed = svg(
2023 &figure,
2024 &ExportOptions {
2025 legend: LegendPlace::BottomLeft,
2026 ..options()
2027 },
2028 )
2029 .unwrap();
2030 assert_eq!(boxed.matches(">AAPL<").count(), 1);
2031 let one = svg(&lines(&["AAPL"]), &options()).unwrap();
2033 assert!(!one.contains(">AAPL<"));
2034 }
2035
2036 #[test]
2037 fn transparent_has_no_background_and_dark_uses_the_theme() {
2038 let clear = svg(
2039 &lines(&["a", "b"]),
2040 &ExportOptions {
2041 palette: Palette::transparent(),
2042 ..options()
2043 },
2044 )
2045 .unwrap();
2046 assert!(!clear.contains("fill=\"#ffffff\" fill-opacity=\"1.00\"/>\n<text"));
2047 assert!(!clear.contains("width=\"1600.00\""), "no full-page rect");
2048 let config = crate::config::AppConfig::default();
2049 let dark = Palette::dark(&config.theme.colors);
2050 assert_eq!(dark.series[0], Rgb(0x7d, 0xcf, 0xff), "chart_1");
2051 assert!(dark.dark);
2052 }
2053
2054 #[test]
2059 fn palettes_have_ten_series_and_a_repeat_counts_once() {
2060 let mut colors = crate::config::ColorConfig::default();
2061 assert_eq!(Palette::light().series.len(), SERIES);
2062 assert_eq!(Palette::dark(&colors).series.len(), SERIES);
2063 colors.chart_5 = colors.chart_1.clone();
2064 let dark = Palette::dark(&colors);
2065 assert_eq!(dark.series.len(), SERIES - 1);
2066 assert_eq!(dark.series[4], Rgb(0xf7, 0x76, 0x8e), "chart_6 moves up");
2067 let theme = crate::config::Theme::from_config(&crate::config::ThemeConfig {
2069 colors,
2070 ..Default::default()
2071 })
2072 .unwrap();
2073 let shown = theme.series_colors();
2074 for (i, color) in shown.iter().enumerate() {
2075 assert!(!shown[..i].contains(color), "{shown:?}");
2076 }
2077 assert!(shown.len() < SERIES, "{shown:?}");
2078 }
2079
2080 #[test]
2081 fn png_svg_and_pdf_are_what_they_say() {
2082 let figure = lines(&["AAPL", "MSFT"]);
2083 let options = ExportOptions {
2084 width: 600,
2085 height: 400,
2086 dpi: 96.0,
2087 ..options()
2088 };
2089 let png = render(&figure, &options, ChartExportFormat::Png).unwrap();
2090 assert!(png.starts_with(b"\x89PNG\r\n\x1a\n"));
2091 assert_eq!(&png[16..20], &600u32.to_be_bytes());
2093 assert_eq!(&png[20..24], &400u32.to_be_bytes());
2094 assert_eq!(&png[37..41], b"pHYs");
2096 assert_eq!(&png[41..45], &3780u32.to_be_bytes());
2097 let decoded = resvg::tiny_skia::Pixmap::decode_png(&png).expect("a valid PNG");
2098 assert_eq!((decoded.width(), decoded.height()), (600, 400));
2099
2100 let svg =
2101 String::from_utf8(render(&figure, &options, ChartExportFormat::Svg).unwrap()).unwrap();
2102 assert!(svg.starts_with("<svg"), "{svg}");
2103 assert!(svg.contains("width=\"6.250in\""), "printed size: {svg}");
2104 assert!(!svg.contains("<text"), "text set as outlines");
2105 usvg::Tree::from_str(&svg, &usvg::Options::default()).expect("valid SVG");
2106
2107 let pdf = render(&figure, &options, ChartExportFormat::Pdf).unwrap();
2108 assert!(pdf.starts_with(b"%PDF-"));
2109 assert!(String::from_utf8_lossy(&pdf).contains("/MediaBox [0 0 450 300]"));
2111 }
2112
2113 #[test]
2116 fn the_recipe_rides_in_each_format_and_omit_writes_none() {
2117 let figure = lines(&["AAPL", "MSFT"]);
2118 let recipe = "{\"datui\": \"0.4.0\", \"settings\": {\"query\": \"select where a < 3 & b > \\\"x\\\"\"}, \"note\": \"Zürich\"}";
2119 let with = ExportOptions {
2120 width: 300,
2121 height: 200,
2122 dpi: 96.0,
2123 recipe: Some(recipe.to_string()),
2124 ..ExportOptions::default()
2125 };
2126 let without = ExportOptions {
2127 recipe: None,
2128 ..with.clone()
2129 };
2130 for format in ChartExportFormat::ALL {
2131 let bytes = render(&figure, &with, format).unwrap();
2132 assert_eq!(recipe_in(&bytes).as_deref(), Some(recipe), "{format:?}");
2133 let bare = render(&figure, &without, format).unwrap();
2134 assert_eq!(recipe_in(&bare), None, "{format:?}");
2135 assert!(
2136 !bare.windows(5).any(|w| w.eq_ignore_ascii_case(b"datui")),
2137 "{format:?}: no datui metadata with Omit"
2138 );
2139 }
2140 let png = render(&figure, &with, ChartExportFormat::Png).unwrap();
2141 let decoded = resvg::tiny_skia::Pixmap::decode_png(&png).expect("a valid PNG");
2142 assert_eq!((decoded.width(), decoded.height()), (300, 200));
2143 let svg =
2144 String::from_utf8(render(&figure, &with, ChartExportFormat::Svg).unwrap()).unwrap();
2145 assert!(svg.contains("<metadata>"), "{svg}");
2146 usvg::Tree::from_str(&svg, &usvg::Options::default()).expect("valid SVG");
2147 }
2148
2149 #[test]
2150 fn presets_set_sizes() {
2151 assert_eq!(SizePreset::Slide.size(), Some((1920, 1080)));
2152 assert_eq!(SizePreset::Document.size(), Some((1600, 1000)));
2153 assert_eq!(SizePreset::Square.size(), Some((1200, 1200)));
2154 let (w, _) = SizePreset::SingleColumn.size().unwrap();
2156 assert_eq!(
2157 f64::from(w) / f64::from(SizePreset::SingleColumn.dpi()),
2158 3.5
2159 );
2160 let (w, _) = SizePreset::DoubleColumn.size().unwrap();
2161 assert_eq!(
2162 f64::from(w) / f64::from(SizePreset::DoubleColumn.dpi()),
2163 7.0
2164 );
2165 assert_eq!(SizePreset::Custom.size(), None);
2166 }
2167
2168 #[test]
2170 fn every_plot_draws() {
2171 let rows = RowsRead::default();
2172 let bars = BarData {
2173 category: "carrier".to_string(),
2174 value_column: "mean delay".to_string(),
2175 bars: vec![
2176 Bar {
2177 label: Some("UA".to_string()),
2178 value: 12.0,
2179 by_group: vec![Some(5.0), Some(7.0)],
2180 },
2181 Bar {
2182 label: None,
2183 value: -3.0,
2184 by_group: vec![Some(-3.0), None],
2185 },
2186 ],
2187 more: 3,
2188 no_value: 0,
2189 rows,
2190 value_dtype: polars::prelude::DataType::Float64,
2191 counted: None,
2192 groups: vec!["EWR".to_string(), "Other".to_string()],
2193 other: true,
2194 rows_note: None,
2195 };
2196 let histogram = HistogramData {
2197 column: "delay".to_string(),
2198 bins: (0..4)
2199 .map(|i| HistogramBin {
2200 center: i as f64 + 0.5,
2201 count: i as f64,
2202 })
2203 .collect(),
2204 groups: vec![
2205 HistogramGroup {
2206 name: "a".to_string(),
2207 counts: vec![0.1, 0.2, 0.3, 0.4],
2208 },
2209 HistogramGroup {
2210 name: "b".to_string(),
2211 counts: vec![0.4, 0.3, 0.2, 0.1],
2212 },
2213 ],
2214 other: true,
2215 share: true,
2216 x_min: 0.0,
2217 x_max: 4.0,
2218 max_count: 0.4,
2219 rows,
2220 clipped: None,
2221 };
2222 let boxes = BoxPlotData {
2223 stats: vec![BoxPlotStats {
2224 name: "UA".to_string(),
2225 min: 0.0,
2226 q1: 1.0,
2227 median: 2.0,
2228 q3: 3.0,
2229 max: 4.0,
2230 }],
2231 y_min: 0.0,
2232 y_max: 4.0,
2233 rows,
2234 clipped: None,
2235 of: 0,
2236 };
2237 let heatmap = HeatmapData {
2238 x_column: "a".to_string(),
2239 y_column: "b".to_string(),
2240 x_min: 0.0,
2241 x_max: 1.0,
2242 y_min: 0.0,
2243 y_max: 1.0,
2244 x_bins: 2,
2245 y_bins: 2,
2246 counts: vec![vec![1.0, 2.0], vec![0.0, 4.0]],
2247 max_count: 4.0,
2248 rows,
2249 };
2250 let plots = [
2251 Plot::Bars {
2252 data: bars,
2253 value: Axis::default(),
2254 },
2255 Plot::Histogram {
2256 data: histogram,
2257 x: Axis::default(),
2258 y: Axis::default(),
2259 },
2260 Plot::Box {
2261 data: boxes,
2262 x_title: "carrier".to_string(),
2263 y: Axis::default(),
2264 },
2265 Plot::Heatmap {
2266 data: heatmap,
2267 x: Axis::default(),
2268 y: Axis::default(),
2269 },
2270 ];
2271 let (w, h) = SizePreset::SingleColumn.size().unwrap();
2272 for plot in plots {
2273 let figure = Figure {
2274 plot,
2275 chart_notes: Vec::new(),
2276 grid: true,
2277 };
2278 let options = ExportOptions {
2279 width: w,
2280 height: h,
2281 dpi: SizePreset::SingleColumn.dpi(),
2282 ..options()
2283 };
2284 let svg = svg(&figure, &options).unwrap();
2285 roxmltree_ok(&svg);
2286 }
2287 }
2288
2289 #[test]
2290 fn auto_opacity_fades_with_the_points() {
2291 assert_eq!(auto_opacity(0), 1.0);
2292 assert_eq!(auto_opacity(1_000), 1.0);
2293 assert!((auto_opacity(10_000) - 0.575).abs() < 1e-9);
2294 assert!((auto_opacity(100_000) - 0.15).abs() < 1e-9);
2295 assert!((auto_opacity(5_000_000) - 0.15).abs() < 1e-9);
2296 assert!(auto_opacity(3_000) < 1.0 && auto_opacity(3_000) > auto_opacity(30_000));
2297 assert_eq!(PointOpacity::Half.of(1), 0.5);
2298 }
2299
2300 #[test]
2303 fn mark_options_change_the_marks() {
2304 let pt = f64::from(options().dpi) / 72.0;
2305 let stroke = |w: f64| format!("stroke-width=\"{:.2}\"", w * pt);
2306 let figure = lines(&["AAPL", "MSFT"]);
2307 let draw = |options: ExportOptions| svg(&figure, &options).unwrap();
2308 let normal = draw(ExportOptions {
2309 legend: LegendPlace::TopRight,
2310 ..options()
2311 });
2312 let bold = draw(ExportOptions {
2313 legend: LegendPlace::TopRight,
2314 line_width: LineWidth::Bold,
2315 ..options()
2316 });
2317 assert!(normal.contains(&stroke(1.5)) && !normal.contains(&stroke(2.5)));
2318 assert!(bold.contains(&stroke(2.5)), "{bold}");
2319 assert!(
2320 normal.contains(&stroke(1.75)),
2321 "the swatch, a little heavier"
2322 );
2323 assert!(
2324 bold.contains(&stroke(1.75 / 1.5 * 2.5)),
2325 "the swatch follows"
2326 );
2327
2328 let mut scatter = lines(&["AAPL"]);
2329 if let Plot::Lines {
2330 scatter: is_scatter,
2331 ..
2332 } = &mut scatter.plot
2333 {
2334 *is_scatter = true;
2335 }
2336 let radius = |r: f64| format!("r=\"{:.2}\"", r * pt);
2337 let dots = |options: ExportOptions| svg(&scatter, &options).unwrap();
2338 let medium = dots(options());
2339 assert!(medium.contains(&radius(2.4)), "{medium}");
2340 assert!(
2341 !medium.contains("fill-opacity=\"0.50\""),
2342 "ten points: opaque"
2343 );
2344 let large_half = dots(ExportOptions {
2345 point_size: PointSize::Large,
2346 point_opacity: PointOpacity::Half,
2347 ..options()
2348 });
2349 assert!(large_half.contains(&radius(3.6)), "{large_half}");
2350 assert!(
2351 large_half.contains("fill-opacity=\"0.50\"/>"),
2352 "{large_half}"
2353 );
2354 assert!(
2355 dots(ExportOptions {
2356 point_size: PointSize::Small,
2357 ..options()
2358 })
2359 .contains(&radius(1.6))
2360 );
2361 let pdf = render(
2363 &scatter,
2364 &ExportOptions {
2365 point_opacity: PointOpacity::Half,
2366 ..options()
2367 },
2368 ChartExportFormat::Pdf,
2369 )
2370 .unwrap();
2371 assert!(
2372 String::from_utf8_lossy(&pdf).contains("/ca 0.5 "),
2373 "the PDF's fill opacity"
2374 );
2375
2376 let mut high = lines(&["AAPL"]);
2378 if let Plot::Lines { series, .. } = &mut high.plot {
2379 series[0].points = (0..10).map(|x| (x as f64, 100.0 + x as f64)).collect();
2380 }
2381 let y_zero = "text-anchor=\"end\" fill=\"#5b6170\">0</text>";
2382 let off = svg(&high, &options()).unwrap();
2383 assert!(!off.contains(y_zero), "{off}");
2384 let on = svg(
2385 &high,
2386 &ExportOptions {
2387 y_from_zero: Some(true),
2388 ..options()
2389 },
2390 )
2391 .unwrap();
2392 assert!(on.contains(y_zero), "{on}");
2393
2394 roxmltree_ok(&on);
2395 }
2396
2397 #[test]
2398 fn dates_tick_on_the_calendar() {
2399 let axis = Axis {
2401 kind: XAxisTemporalKind::Date,
2402 ..Default::default()
2403 };
2404 let ticks = axis_ticks(19723.0, 20454.0, &axis, 6);
2405 let labels: Vec<&str> = ticks.iter().map(|(_, l)| l.as_str()).collect();
2406 assert!(labels.contains(&"2025"), "{labels:?}");
2407 assert!(ticks.len() <= 6);
2408 }
2409
2410 #[test]
2411 fn too_small_says_so() {
2412 let err = svg(
2413 &lines(&["a"]),
2414 &ExportOptions {
2415 width: 60,
2416 height: 40,
2417 dpi: 96.0,
2418 ..options()
2419 },
2420 )
2421 .unwrap_err();
2422 assert!(err.to_string().contains("does not fit"), "{err}");
2423 }
2424}