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