use std::sync::{Arc, OnceLock};
use color_eyre::Result;
use resvg::{tiny_skia, usvg};
use crate::chart::chart_data::{BarData, segments};
use crate::chart::chart_plot::{Axis, Plot, PlotData};
use crate::widgets::axes::{AxisSpec, TickSet};
const FONT_REGULAR: &[u8] = include_bytes!("../../assets/fonts/IBMPlexSans-Regular.ttf");
const FONT_SEMIBOLD: &[u8] = include_bytes!("../../assets/fonts/IBMPlexSans-SemiBold.ttf");
const FONT_FAMILY: &str = "IBM Plex Sans";
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChartExportFormat {
Png,
Svg,
Pdf,
}
impl ChartExportFormat {
pub const ALL: [Self; 3] = [Self::Png, Self::Svg, Self::Pdf];
pub fn extension(self) -> &'static str {
match self {
Self::Png => "png",
Self::Svg => "svg",
Self::Pdf => "pdf",
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Png => "PNG",
Self::Svg => "SVG",
Self::Pdf => "PDF",
}
}
pub fn from_extension(path: &std::path::Path) -> Option<Self> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
Self::ALL.into_iter().find(|f| f.extension() == ext)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExportStyle {
Light,
Dark,
Transparent,
}
impl ExportStyle {
pub const ALL: [Self; 3] = [Self::Light, Self::Dark, Self::Transparent];
pub fn label(self) -> &'static str {
match self {
Self::Light => "Light",
Self::Dark => "Dark",
Self::Transparent => "Transparent",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SizePreset {
Slide,
Document,
Square,
SingleColumn,
DoubleColumn,
Custom,
}
impl SizePreset {
pub const ALL: [Self; 6] = [
Self::Slide,
Self::Document,
Self::Square,
Self::SingleColumn,
Self::DoubleColumn,
Self::Custom,
];
pub fn label(self) -> &'static str {
match self {
Self::Slide => "Slide 16:9",
Self::Document => "Document",
Self::Square => "Square",
Self::SingleColumn => "Single column",
Self::DoubleColumn => "Double column",
Self::Custom => "Custom",
}
}
pub fn size(self) -> Option<(u32, u32)> {
match self {
Self::Slide => Some((1920, 1080)),
Self::Document => Some((1600, 1000)),
Self::Square => Some((1200, 1200)),
Self::SingleColumn => Some((1050, 788)),
Self::DoubleColumn => Some((2100, 1300)),
Self::Custom => None,
}
}
pub fn dpi(self) -> f32 {
match self {
Self::SingleColumn | Self::DoubleColumn => 300.0,
Self::Slide => 192.0,
Self::Document => 160.0,
Self::Square => 150.0,
Self::Custom => 96.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LegendPlace {
LineEnds,
TopRight,
TopLeft,
BottomRight,
BottomLeft,
Off,
}
impl LegendPlace {
pub const ALL: [Self; 6] = [
Self::LineEnds,
Self::TopRight,
Self::TopLeft,
Self::BottomRight,
Self::BottomLeft,
Self::Off,
];
pub fn label(self) -> &'static str {
match self {
Self::LineEnds => "Line ends",
Self::TopRight => "Top right",
Self::TopLeft => "Top left",
Self::BottomRight => "Bottom right",
Self::BottomLeft => "Bottom left",
Self::Off => "Off",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PointOpacity {
#[default]
Auto,
Full,
Half,
Fifth,
}
impl PointOpacity {
pub const ALL: [Self; 4] = [Self::Auto, Self::Full, Self::Half, Self::Fifth];
pub fn label(self) -> &'static str {
match self {
Self::Auto => "Auto",
Self::Full => "100%",
Self::Half => "50%",
Self::Fifth => "20%",
}
}
pub fn of(self, points: usize) -> f64 {
match self {
Self::Auto => auto_opacity(points),
Self::Full => 1.0,
Self::Half => 0.5,
Self::Fifth => 0.2,
}
}
}
pub fn auto_opacity(points: usize) -> f64 {
const FAINTEST: f64 = 0.15;
let t = (((points.max(1) as f64).log10() - 3.0) / 2.0).clamp(0.0, 1.0);
1.0 + t * (FAINTEST - 1.0)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PointSize {
Small,
#[default]
Medium,
Large,
}
impl PointSize {
pub const ALL: [Self; 3] = [Self::Small, Self::Medium, Self::Large];
pub fn label(self) -> &'static str {
match self {
Self::Small => "Small",
Self::Medium => "Medium",
Self::Large => "Large",
}
}
pub fn pt(self) -> f64 {
match self {
Self::Small => 1.6,
Self::Medium => 2.4,
Self::Large => 3.6,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LineWidth {
Thin,
#[default]
Normal,
Bold,
}
impl LineWidth {
pub const ALL: [Self; 3] = [Self::Thin, Self::Normal, Self::Bold];
pub fn label(self) -> &'static str {
match self {
Self::Thin => "Thin",
Self::Normal => "Normal",
Self::Bold => "Bold",
}
}
pub fn pt(self) -> f64 {
match self {
Self::Thin => 1.0,
Self::Normal => 1.5,
Self::Bold => 2.5,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rgb(pub u8, pub u8, pub u8);
impl Rgb {
fn hex(self) -> String {
format!("#{:02x}{:02x}{:02x}", self.0, self.1, self.2)
}
pub fn of(color: ratatui::style::Color) -> Option<Self> {
use ratatui::style::Color;
const ANSI: [(u8, u8, u8); 16] = [
(0, 0, 0),
(205, 0, 0),
(0, 205, 0),
(205, 205, 0),
(0, 0, 238),
(205, 0, 205),
(0, 205, 205),
(229, 229, 229),
(127, 127, 127),
(255, 0, 0),
(0, 255, 0),
(255, 255, 0),
(92, 92, 255),
(255, 0, 255),
(0, 255, 255),
(255, 255, 255),
];
let ansi = |i: usize| {
let (r, g, b) = ANSI[i];
Some(Rgb(r, g, b))
};
match color {
Color::Rgb(r, g, b) => Some(Rgb(r, g, b)),
Color::Reset => None,
Color::Black => ansi(0),
Color::Red => ansi(1),
Color::Green => ansi(2),
Color::Yellow => ansi(3),
Color::Blue => ansi(4),
Color::Magenta => ansi(5),
Color::Cyan => ansi(6),
Color::Gray => ansi(7),
Color::DarkGray => ansi(8),
Color::LightRed => ansi(9),
Color::LightGreen => ansi(10),
Color::LightYellow => ansi(11),
Color::LightBlue => ansi(12),
Color::LightMagenta => ansi(13),
Color::LightCyan => ansi(14),
Color::White => ansi(15),
Color::Indexed(i) if i < 16 => ansi(i as usize),
Color::Indexed(i) if i < 232 => {
let i = i - 16;
let level = |v: u8| if v == 0 { 0 } else { 55 + v * 40 };
Some(Rgb(level(i / 36), level((i / 6) % 6), level(i % 6)))
}
Color::Indexed(i) => {
let v = 8 + (i - 232) * 10;
Some(Rgb(v, v, v))
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Palette {
pub background: Option<Rgb>,
pub text: Rgb,
pub text_secondary: Rgb,
pub grid: Rgb,
pub series: Vec<Rgb>,
pub other: Rgb,
pub ramp: [Rgb; 7],
pub dark: bool,
}
const LIGHT_SERIES: [Rgb; SERIES] = [
Rgb(0x2a, 0x78, 0xd6),
Rgb(0xeb, 0x68, 0x34),
Rgb(0x1b, 0xaf, 0x7a),
Rgb(0xed, 0xa1, 0x00),
Rgb(0xe8, 0x7b, 0xa4),
Rgb(0x00, 0x83, 0x00),
Rgb(0x4a, 0x3a, 0xa7),
Rgb(0x0f, 0x8a, 0x96),
Rgb(0x9c, 0x1f, 0x6e),
Rgb(0x5c, 0x4a, 0x00),
];
pub const SERIES: usize = 10;
const BLUE_RAMP: [Rgb; 7] = [
Rgb(0xcd, 0xe2, 0xfb),
Rgb(0x9e, 0xc5, 0xf4),
Rgb(0x6d, 0xa7, 0xec),
Rgb(0x39, 0x87, 0xe5),
Rgb(0x25, 0x6a, 0xbf),
Rgb(0x18, 0x4f, 0x95),
Rgb(0x0d, 0x36, 0x6b),
];
impl Palette {
pub fn light() -> Self {
Self {
background: Some(Rgb(0xff, 0xff, 0xff)),
text: Rgb(0x1f, 0x24, 0x30),
text_secondary: Rgb(0x5b, 0x61, 0x70),
grid: Rgb(0xe3, 0xe5, 0xea),
series: LIGHT_SERIES.to_vec(),
other: Rgb(0xa8, 0xad, 0xb8),
ramp: BLUE_RAMP,
dark: false,
}
}
pub fn transparent() -> Self {
Self {
background: None,
..Self::light()
}
}
pub fn dark(colors: &crate::config::ColorConfig) -> Self {
let parser = crate::config::ColorParser::new();
let get = |value: &str, fallback: Rgb| {
let value = value.trim();
let hex = value
.strip_prefix('#')
.filter(|h| h.len() == 6)
.and_then(|h| u32::from_str_radix(h, 16).ok())
.map(|n| Rgb((n >> 16) as u8, (n >> 8) as u8, n as u8));
hex.or_else(|| parser.parse(value).ok().and_then(Rgb::of))
.unwrap_or(fallback)
};
fn slot<'a>(slots: &'a [(String, String)], name: &str) -> &'a str {
slots.iter().find(|(n, _)| n == name).map_or("", |(_, v)| v)
}
let ours = colors.slots();
let builtin = crate::config::ColorConfig::dark().slots();
let mut series: Vec<Rgb> = Vec::with_capacity(SERIES);
for i in 1..=SERIES {
let name = format!("chart_{i}");
let color = get(slot(&ours, &name), get(slot(&builtin, &name), Rgb(0, 0, 0)));
if !series.contains(&color) {
series.push(color);
}
}
let mut ramp = BLUE_RAMP;
ramp.reverse();
Self {
background: Some(get(&colors.background, Rgb(0x1a, 0x1b, 0x26))),
text: get(&colors.text_primary, Rgb(0xc0, 0xca, 0xf5)),
text_secondary: get(&colors.text_secondary, Rgb(0x9a, 0xa5, 0xce)),
grid: get(&colors.chart_grid, Rgb(0x3d, 0x47, 0x85)),
series,
other: get(&colors.dimmed, Rgb(0x56, 0x5f, 0x89)),
ramp,
dark: true,
}
}
pub fn for_style(style: ExportStyle, colors: &crate::config::ColorConfig) -> Self {
match style {
ExportStyle::Light => Self::light(),
ExportStyle::Dark => Self::dark(colors),
ExportStyle::Transparent => Self::transparent(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ExportOptions {
pub width: u32,
pub height: u32,
pub dpi: f32,
pub palette: Palette,
pub legend: LegendPlace,
pub title: String,
pub description: String,
pub notes: String,
pub source: String,
pub byline: String,
pub point_opacity: PointOpacity,
pub point_size: PointSize,
pub line_width: LineWidth,
pub y_from_zero: Option<bool>,
pub recipe: Option<String>,
}
impl Default for ExportOptions {
fn default() -> Self {
let preset = SizePreset::Document;
let (width, height) = preset.size().unwrap_or((1600, 1000));
Self {
width,
height,
dpi: preset.dpi(),
palette: Palette::light(),
legend: LegendPlace::LineEnds,
title: String::new(),
description: String::new(),
notes: String::new(),
source: String::new(),
byline: String::new(),
point_opacity: PointOpacity::default(),
point_size: PointSize::default(),
line_width: LineWidth::default(),
y_from_zero: None,
recipe: None,
}
}
}
#[derive(Debug, Clone)]
pub struct ChartExportRequest {
pub path: std::path::PathBuf,
pub format: ChartExportFormat,
pub options: ExportOptions,
pub overwrite: crate::export::output_file::Overwrite,
pub recipe: bool,
}
#[derive(Debug, Clone)]
pub struct Figure {
pub plot: Plot<'static>,
pub chart_notes: Vec<String>,
pub grid: bool,
}
pub fn render(
figure: &Figure,
options: &ExportOptions,
format: ChartExportFormat,
) -> Result<Vec<u8>> {
let tree = tree(&svg(figure, options)?)?;
let recipe = options.recipe.as_deref();
Ok(match format {
ChartExportFormat::Svg => with_svg_recipe(
tree.to_string(&usvg::WriteOptions::default()).replacen(
&format!("width=\"{}\" height=\"{}\"", options.width, options.height),
&format!(
"width=\"{:.3}in\" height=\"{:.3}in\" viewBox=\"0 0 {} {}\"",
f64::from(options.width) / f64::from(options.dpi),
f64::from(options.height) / f64::from(options.dpi),
options.width,
options.height,
),
1,
),
recipe,
)
.into_bytes(),
ChartExportFormat::Png => {
let mut pixmap = tiny_skia::Pixmap::new(options.width, options.height)
.ok_or_else(|| color_eyre::eyre::eyre!("cannot draw a chart of that size"))?;
resvg::render(
&tree,
tiny_skia::Transform::identity(),
&mut pixmap.as_mut(),
);
let png = pixmap
.encode_png()
.map_err(|e| color_eyre::eyre::eyre!("PNG: {e}"))?;
let png = with_resolution(png, options.dpi);
match recipe {
Some(recipe) => with_png_recipe(png, recipe),
None => png,
}
}
ChartExportFormat::Pdf => crate::chart::chart_pdf::write(
&tree,
(options.width, options.height),
options.dpi,
recipe,
)?,
})
}
pub const RECIPE_KEY: &str = "datui-recipe";
const RECIPE_NAMESPACE: &str = "https://derekwisong.github.io/datui/recipe";
fn with_png_recipe(mut png: Vec<u8>, recipe: &str) -> Vec<u8> {
const IEND: usize = 12;
if png.len() < IEND || &png[png.len() - 8..png.len() - 4] != b"IEND" {
return png;
}
let mut chunk = b"iTXt".to_vec();
chunk.extend_from_slice(RECIPE_KEY.as_bytes());
chunk.extend_from_slice(&[0, 0, 0, 0, 0]);
chunk.extend_from_slice(recipe.as_bytes());
let crc = crc::Crc::<u32>::new(&crc::CRC_32_ISO_HDLC).checksum(&chunk);
let mut bytes = u32::try_from(chunk.len() - 4)
.unwrap_or(u32::MAX)
.to_be_bytes()
.to_vec();
bytes.extend_from_slice(&chunk);
bytes.extend_from_slice(&crc.to_be_bytes());
let end = png.len() - IEND;
png.splice(end..end, bytes);
png
}
fn with_svg_recipe(svg: String, recipe: Option<&str>) -> String {
let Some(recipe) = recipe else {
return svg;
};
let Some(open) = svg
.find("<svg")
.and_then(|at| svg[at..].find('>').map(|end| at + end + 1))
else {
return svg;
};
let element = format!(
"<metadata><{RECIPE_KEY} xmlns=\"{RECIPE_NAMESPACE}\">{}</{RECIPE_KEY}></metadata>",
esc(recipe)
);
let mut out = svg;
out.insert_str(open, &element);
out
}
pub fn recipe_in(bytes: &[u8]) -> Option<String> {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
let mut at = 8;
while at + 12 <= bytes.len() {
let len = u32::from_be_bytes(bytes[at..at + 4].try_into().ok()?) as usize;
let kind = &bytes[at + 4..at + 8];
let data = bytes.get(at + 8..at + 8 + len)?;
if kind == b"iTXt" && data.starts_with(RECIPE_KEY.as_bytes()) {
let text = data.get(RECIPE_KEY.len() + 5..)?;
return String::from_utf8(text.to_vec()).ok();
}
at += 12 + len;
}
return None;
}
if bytes.starts_with(b"%PDF") {
return crate::chart::chart_pdf::recipe_in(bytes);
}
let text = std::str::from_utf8(bytes).ok()?;
let open = format!("<{RECIPE_KEY} xmlns=\"{RECIPE_NAMESPACE}\">");
let start = text.find(&open)? + open.len();
let end = start + text[start..].find(&format!("</{RECIPE_KEY}>"))?;
Some(unescape(&text[start..end]))
}
fn unescape(s: &str) -> String {
s.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("&", "&")
}
fn with_resolution(mut png: Vec<u8>, dpi: f32) -> Vec<u8> {
const AFTER_IHDR: usize = 8 + 4 + 4 + 13 + 4;
if png.len() < AFTER_IHDR || &png[12..16] != b"IHDR" {
return png;
}
let per_meter = (f64::from(dpi) / 0.0254).round() as u32;
let mut chunk = b"pHYs".to_vec();
chunk.extend_from_slice(&per_meter.to_be_bytes());
chunk.extend_from_slice(&per_meter.to_be_bytes());
chunk.push(1); let crc = crc::Crc::<u32>::new(&crc::CRC_32_ISO_HDLC).checksum(&chunk);
let mut bytes = 9u32.to_be_bytes().to_vec();
bytes.extend_from_slice(&chunk);
bytes.extend_from_slice(&crc.to_be_bytes());
png.splice(AFTER_IHDR..AFTER_IHDR, bytes);
png
}
fn fonts() -> Arc<usvg::fontdb::Database> {
static FONTS: OnceLock<Arc<usvg::fontdb::Database>> = OnceLock::new();
FONTS
.get_or_init(|| {
let mut db = usvg::fontdb::Database::new();
db.load_font_data(FONT_REGULAR.to_vec());
db.load_font_data(FONT_SEMIBOLD.to_vec());
db.load_system_fonts();
db.set_sans_serif_family(FONT_FAMILY);
Arc::new(db)
})
.clone()
}
fn tree(svg: &str) -> Result<usvg::Tree> {
let options = usvg::Options {
font_family: FONT_FAMILY.to_string(),
fontdb: fonts(),
..Default::default()
};
usvg::Tree::from_str(svg, &options).map_err(|e| color_eyre::eyre::eyre!("chart SVG: {e}"))
}
fn esc(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
c if c.is_control() => out.push(' '),
c => out.push(c),
}
}
out
}
fn text_width(text: &str, size: f64) -> f64 {
text.chars()
.map(|c| match c {
'i' | 'l' | 'j' | '.' | ',' | ':' | ';' | '\'' | '|' | '!' | ' ' => 0.3,
'm' | 'w' | 'M' | 'W' => 0.85,
c if c.is_ascii_uppercase() || c.is_ascii_digit() => 0.62,
c if c.is_ascii() => 0.53,
_ => 0.95,
})
.sum::<f64>()
* size
}
fn wrap(text: &str, size: f64, width: f64) -> Vec<String> {
let mut lines = Vec::new();
for paragraph in text.lines() {
let mut line = String::new();
for word in paragraph.split_whitespace() {
let candidate = if line.is_empty() {
word.to_string()
} else {
format!("{line} {word}")
};
if !line.is_empty() && text_width(&candidate, size) > width {
lines.push(std::mem::take(&mut line));
line = word.to_string();
} else {
line = candidate;
}
}
if !line.is_empty() {
lines.push(line);
}
}
lines
}
struct Canvas<'a> {
out: String,
palette: &'a Palette,
pt: f64,
body: f64,
other: Option<usize>,
swatch_stroke: f64,
}
impl Canvas<'_> {
fn text(
&mut self,
(x, y): (f64, f64),
size: f64,
color: Rgb,
(anchor, weight): (&str, u16),
text: &str,
) {
self.out.push_str(&format!(
"<text x=\"{x:.1}\" y=\"{y:.1}\" font-size=\"{size:.1}\" font-weight=\"{weight}\" \
text-anchor=\"{anchor}\" fill=\"{}\">{}</text>\n",
color.hex(),
esc(text)
));
}
fn line(&mut self, (x1, y1): (f64, f64), (x2, y2): (f64, f64), color: Rgb, width: f64) {
self.out.push_str(&format!(
"<line x1=\"{x1:.1}\" y1=\"{y1:.1}\" x2=\"{x2:.1}\" y2=\"{y2:.1}\" stroke=\"{}\" \
stroke-width=\"{width:.2}\"/>\n",
color.hex()
));
}
fn rect(&mut self, x: f64, y: f64, w: f64, h: f64, fill: Rgb, opacity: f64) {
if w <= 0.0 || h <= 0.0 {
return;
}
self.out.push_str(&format!(
"<rect x=\"{x:.2}\" y=\"{y:.2}\" width=\"{w:.2}\" height=\"{h:.2}\" fill=\"{}\" \
fill-opacity=\"{opacity:.2}\"/>\n",
fill.hex()
));
}
fn polyline(&mut self, points: &[(f64, f64)], color: Rgb, width: f64) {
if points.len() < 2 {
if let Some(&(x, y)) = points.first() {
self.dot(x, y, width, color, 1.0);
}
return;
}
let pts: Vec<String> = points
.iter()
.map(|(x, y)| format!("{x:.1},{y:.1}"))
.collect();
self.out.push_str(&format!(
"<polyline points=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{width:.2}\" \
stroke-linejoin=\"round\" stroke-linecap=\"round\"/>\n",
pts.join(" "),
color.hex()
));
}
fn dot(&mut self, x: f64, y: f64, r: f64, color: Rgb, opacity: f64) {
let opacity = if opacity < 1.0 {
format!(" fill-opacity=\"{opacity:.2}\"")
} else {
String::new()
};
self.out.push_str(&format!(
"<circle cx=\"{x:.1}\" cy=\"{y:.1}\" r=\"{r:.2}\" fill=\"{}\"{opacity}/>\n",
color.hex()
));
}
fn color(&self, i: usize) -> Rgb {
if self.other == Some(i) {
return self.palette.other;
}
self.palette.series[i % self.palette.series.len()]
}
}
#[derive(Clone, Copy, Debug)]
struct Area {
left: f64,
top: f64,
right: f64,
bottom: f64,
}
impl Area {
fn width(&self) -> f64 {
self.right - self.left
}
fn height(&self) -> f64 {
self.bottom - self.top
}
}
#[derive(Clone, Copy, Debug)]
struct Scale {
lo: f64,
hi: f64,
from: f64,
to: f64,
}
impl Scale {
fn at(&self, v: f64) -> f64 {
let span = self.hi - self.lo;
if span.abs() < f64::EPSILON {
return (self.from + self.to) / 2.0;
}
self.from + (v - self.lo) / span * (self.to - self.from)
}
}
fn axis_ticks(
(lo, hi): (f64, f64),
axis: &Axis,
length: f64,
tick: f64,
across: bool,
) -> ([f64; 2], Vec<(f64, String)>) {
let bounds = [lo, hi];
let spec = match (axis.log, across) {
(true, _) => AxisSpec::y_log(bounds, &axis.numbers, ""),
(false, true) => AxisSpec::calendar(bounds, axis.kind, &axis.numbers, ""),
(false, false) => AxisSpec::y_numbers(bounds, &axis.numbers, ""),
};
let (spacing, least) = if across {
(tick * 6.0, tick * 2.5)
} else {
(tick * 3.0, tick * 1.5)
};
let groups = spec.tick_sets(length, spacing, least, tick);
let placed = |set: &TickSet, labels: &[String]| {
(
set.bounds,
set.ticks
.iter()
.copied()
.zip(labels.iter().cloned())
.collect(),
)
};
let distinct = |labels: &[String]| labels.windows(2).all(|w| w[0] != w[1]);
for set in groups.iter().flatten() {
for labels in &set.levels {
let fits = if across {
let mut next_free = f64::NEG_INFINITY;
set.ticks.iter().zip(labels).all(|(&v, label)| {
let w = text_width(label, tick);
let at = if set.bounds[1] > set.bounds[0] {
(v - set.bounds[0]) / (set.bounds[1] - set.bounds[0]) * length
} else {
0.0
};
let x = (at - w / 2.0).clamp(-w / 2.0, length - w / 2.0);
let clear = x >= next_free;
next_free = x + w + tick;
clear
})
} else {
true
};
if fits && distinct(labels) {
return placed(set, labels);
}
}
}
match groups.iter().flatten().next() {
Some(set) if !across => placed(set, set.levels.first().map_or(&[][..], |l| &l[..])),
_ => (bounds, Vec::new()),
}
}
fn span(lo: f64, hi: f64) -> (f64, f64) {
if !(lo.is_finite() && hi.is_finite()) {
return (0.0, 1.0);
}
if hi > lo {
(lo, hi)
} else {
(lo - 0.5, hi + 0.5)
}
}
pub fn svg(figure: &Figure, options: &ExportOptions) -> Result<String> {
let (w, h) = (f64::from(options.width), f64::from(options.height));
if options.width == 0 || options.height == 0 {
return Err(color_eyre::eyre::eyre!(
"a chart needs a width and a height"
));
}
let palette = &options.palette;
let pt = f64::from(options.dpi) / 72.0;
let width_in = w / f64::from(options.dpi);
let base_pt = (width_in * 1.25).clamp(7.0, 13.0);
let body = base_pt * pt;
let mut c = Canvas {
out: String::new(),
palette,
pt,
body,
other: None,
swatch_stroke: 1.75 * pt,
};
let margin = (body * 2.0).min(w / 10.0);
if let Some(bg) = palette.background {
c.rect(0.0, 0.0, w, h, bg, 1.0);
}
let title_size = body * 1.45;
let small = body * 0.82;
let text_width_max = w - 2.0 * margin;
let mut y = margin;
for line in wrap(&options.title, title_size, text_width_max) {
y += title_size;
c.text((margin, y), title_size, palette.text, ("start", 600), &line);
y += title_size * 0.25;
}
for line in wrap(&options.description, body, text_width_max) {
y += body * 1.1;
c.text(
(margin, y),
body,
palette.text_secondary,
("start", 400),
&line,
);
}
if y > margin {
y += body * 0.9;
}
let mut footer: Vec<(String, Rgb)> = Vec::new();
if !figure.chart_notes.is_empty() {
footer.push((figure.chart_notes.join(" · "), palette.text_secondary));
}
for line in wrap(&options.notes, small, text_width_max) {
footer.push((line, palette.text_secondary));
}
let mut credit = Vec::new();
if !options.source.trim().is_empty() {
credit.push(format!("Source: {}", options.source.trim()));
}
if !options.byline.trim().is_empty() {
credit.push(options.byline.trim().to_string());
}
if !credit.is_empty() {
for line in wrap(&credit.join(" · "), small, text_width_max) {
footer.push((line, palette.text_secondary));
}
}
let line_h = small * 1.35;
let footer_top = h - margin - footer.len() as f64 * line_h;
for (i, (line, color)) in footer.iter().enumerate() {
let baseline = footer_top + (i as f64 + 1.0) * line_h - small * 0.3;
c.text((margin, baseline), small, *color, ("start", 400), line);
}
let bottom = if footer.is_empty() {
h - margin
} else {
footer_top - body * 0.8
};
let frame = Area {
left: margin,
top: y,
right: w - margin,
bottom,
};
if frame.height() < body * 4.0 || frame.width() < body * 6.0 {
return Err(color_eyre::eyre::eyre!(
"the chart does not fit at {}x{}: make it larger or the text shorter",
options.width,
options.height
));
}
draw_plot(&mut c, figure, options, frame);
Ok(format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" \
viewBox=\"0 0 {w} {h}\" font-family=\"{FONT_FAMILY}\">\n{}</svg>\n",
c.out,
))
}
fn draw_plot(c: &mut Canvas<'_>, figure: &Figure, options: &ExportOptions, frame: Area) {
let names: Vec<String> = figure
.plot
.legend()
.into_iter()
.map(str::to_string)
.collect();
c.other = figure.plot.other_at();
let is_lines = matches!(
(&*figure.plot.data, figure.plot.scatter),
(PlotData::Lines(_), false) | (PlotData::Kde(_), _)
);
if matches!(*figure.plot.data, PlotData::Lines(_)) && !figure.plot.scatter {
c.swatch_stroke = 1.75 / 1.5 * options.line_width.pt() * c.pt;
}
let legend = if names.len() < 2 {
LegendPlace::Off
} else if options.legend == LegendPlace::LineEnds && !is_lines {
LegendPlace::TopRight
} else {
options.legend
};
let tick = c.body * 0.9;
let mut frame = frame;
if legend == LegendPlace::LineEnds {
let widest = names
.iter()
.map(|n| text_width(n, tick))
.fold(0.0, f64::max);
frame.right -= (widest + tick).min(frame.width() / 3.0);
}
let Plot {
x,
y,
scatter,
y_from_zero,
..
} = &figure.plot;
let data = &*figure.plot.data;
match data {
PlotData::Lines(_) | PlotData::Kde(_) | PlotData::Histogram(_) => {
let ((x_lo, x_hi), (y_lo, y_hi)) = match data {
PlotData::Kde(data) => (span(data.x_min, data.x_max), (0.0, data.y_max)),
PlotData::Histogram(data) => (
span(data.x_min, data.x_max),
(
0.0,
if data.max_count > 0.0 {
data.max_count
} else {
1.0
},
),
),
PlotData::Lines(lines) => {
let [x_lo, x_hi, mut y_lo, mut y_hi] = lines.shown_bounds(y.log).unwrap_or([
f64::INFINITY,
f64::NEG_INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
]);
if options.y_from_zero.unwrap_or(*y_from_zero) {
y_lo = y_lo.min(0.0);
y_hi = y_hi.max(0.0);
}
(span(x_lo, x_hi), span(y_lo, y_hi))
}
_ => unreachable!("one of the three kinds matched above"),
};
let (sx, sy, plot) = axes(c, frame, (x_lo, x_hi), (y_lo, y_hi), x, y, figure.grid);
if let PlotData::Histogram(data) = data
&& data.groups.is_empty()
{
let n = data.bins.len().max(1);
let bin = (data.x_max - data.x_min) / n as f64;
let gap = 1.0 * c.pt;
for (i, b) in data.bins.iter().enumerate() {
let x0 = sx.at(data.x_min + i as f64 * bin);
let x1 = sx.at(data.x_min + (i + 1) as f64 * bin);
let top = sy.at(b.count);
c.rect(
x0 + gap / 2.0,
top,
(x1 - x0 - gap).max(0.5),
sy.at(0.0) - top,
c.color(0),
1.0,
);
}
}
let curves = figure.plot.curves();
let (width, dots) = match data {
PlotData::Lines(_) => (options.line_width.pt() * c.pt, *scatter),
_ => (1.5 * c.pt, false),
};
let radius = options.point_size.pt() * c.pt;
let opacity = options
.point_opacity
.of(curves.iter().map(|curve| curve.points.len()).sum());
let mut ends = Vec::new();
for curve in &curves {
let color = c.color(curve.slot);
let to_page = |&(px, py): &(f64, f64)| (sx.at(px), sy.at(py));
if dots {
for point in curve.points.iter() {
let (px, py) = to_page(point);
c.dot(px, py, radius, color, opacity);
}
} else {
for run in segments(&curve.points, curve.breaks) {
let pts: Vec<(f64, f64)> = run.iter().map(to_page).collect();
c.polyline(&pts, color, width);
}
}
if let Some(end) = curve.points.last() {
let (px, py) = to_page(end);
ends.push((py, px, curve.slot));
}
}
if legend == LegendPlace::LineEnds {
line_end_labels(c, &names, ends, plot);
}
}
PlotData::Box(data) => {
let n = data.stats.len().max(1);
let (lo, hi) = span(data.y_min, data.y_max);
let pad = (hi - lo) * 0.04;
let (_, sy, plot) = category_axes(
c,
frame,
&data
.stats
.iter()
.map(|s| s.name.clone())
.collect::<Vec<_>>(),
(lo - pad, hi + pad),
x,
y,
figure.grid,
);
let slot = plot.width() / n as f64;
for (i, s) in data.stats.iter().enumerate() {
let color = c.color(i);
let mid = plot.left + slot * (i as f64 + 0.5);
let half = (slot * 0.3).min(c.body * 3.0);
let stroke = 1.25 * c.pt;
let marks = s.marks(mid, half, half / 2.0);
let at = |[a, b]: [(f64, f64); 2]| ((a.0, sy.at(a.1)), (b.0, sy.at(b.1)));
for segment in [marks.high, marks.low, marks.high_cap, marks.low_cap] {
let (a, b) = at(segment);
c.line(a, b, color, stroke);
}
let top = sy.at(s.q3);
c.rect(mid - half, top, half * 2.0, sy.at(s.q1) - top, color, 0.18);
c.out.push_str(&format!(
"<rect x=\"{:.2}\" y=\"{top:.2}\" width=\"{:.2}\" height=\"{:.2}\" \
fill=\"none\" stroke=\"{}\" stroke-width=\"{stroke:.2}\"/>\n",
mid - half,
half * 2.0,
(sy.at(s.q1) - top).max(0.0),
color.hex()
));
let (a, b) = at(marks.median);
c.line(a, b, color, stroke * 2.0);
}
}
PlotData::Heatmap(data) => {
let (sx, sy, _) = axes(
c,
frame,
span(data.x_min, data.x_max),
span(data.y_min, data.y_max),
x,
y,
false,
);
let xw = (data.x_max - data.x_min) / data.x_bins.max(1) as f64;
let yh = (data.y_max - data.y_min) / data.y_bins.max(1) as f64;
let ramp = c.palette.ramp;
for (yi, row) in data.counts.iter().enumerate() {
for (xi, &count) in row.iter().enumerate() {
if count <= 0.0 || data.max_count <= 0.0 {
continue;
}
let level =
((count / data.max_count) * (ramp.len() - 1) as f64).round() as usize;
let x0 = sx.at(data.x_min + xi as f64 * xw);
let x1 = sx.at(data.x_min + (xi + 1) as f64 * xw);
let y0 = sy.at(data.y_min + (yi + 1) as f64 * yh);
let y1 = sy.at(data.y_min + yi as f64 * yh);
c.rect(
x0,
y0,
x1 - x0,
y1 - y0,
ramp[level.min(ramp.len() - 1)],
1.0,
);
}
}
}
PlotData::Bars(data) => draw_bars(c, frame, data, x, figure.grid),
PlotData::XRange(_) => {}
}
match legend {
LegendPlace::Off | LegendPlace::LineEnds => {}
LegendPlace::TopRight | LegendPlace::TopLeft => {
legend_row(c, &names, legend, frame, axis_title(figure), is_lines)
}
LegendPlace::BottomRight | LegendPlace::BottomLeft => {
legend_box(c, &names, legend, frame, is_lines)
}
}
}
fn axis_title(figure: &Figure) -> &str {
match &*figure.plot.data {
PlotData::Bars(data) => &data.category,
_ => &figure.plot.y.title,
}
}
fn legend_row(
c: &mut Canvas<'_>,
names: &[String],
place: LegendPlace,
frame: Area,
title: &str,
lines: bool,
) {
let tick = c.body * 0.85;
let swatch = tick * 1.2;
let gap = tick * 1.2;
let item = |name: &str| swatch + tick * 0.4 + text_width(name, tick);
let width: f64 =
names.iter().map(|n| item(n)).sum::<f64>() + gap * names.len().saturating_sub(1) as f64;
let mut x = match place {
LegendPlace::TopLeft => frame.left + text_width(title, c.body * 0.9) + gap * 1.5,
_ => (frame.right - width).max(frame.left),
};
let baseline = frame.top + c.body * 0.9;
let middle = baseline - tick * 0.35;
for (i, name) in names.iter().enumerate() {
let color = c.color(i);
if lines {
c.line((x, middle), (x + swatch, middle), color, c.swatch_stroke);
} else {
c.rect(x, middle - tick * 0.35, swatch, tick * 0.7, color, 1.0);
}
c.text(
(x + swatch + tick * 0.4, baseline),
tick,
c.palette.text,
("start", 400),
name,
);
x += item(name) + gap;
}
}
fn axes(
c: &mut Canvas<'_>,
frame: Area,
xs: (f64, f64),
ys: (f64, f64),
x: &Axis,
y: &Axis,
grid: bool,
) -> (Scale, Scale, Area) {
axes_with(c, frame, xs, ys, (x, true), y, grid)
}
fn axes_with(
c: &mut Canvas<'_>,
frame: Area,
xs: (f64, f64),
ys: (f64, f64),
(x, x_ticked): (&Axis, bool),
y: &Axis,
grid: bool,
) -> (Scale, Scale, Area) {
let tick = c.body * 0.9;
let palette = c.palette.clone();
let top = frame.top + tick * 2.2;
let x_title_h = if x.title.is_empty() { 0.0 } else { tick * 1.5 };
let bottom = frame.bottom - tick * 1.6 - x_title_h;
let (y_bounds, y_ticks) = axis_ticks(ys, y, bottom - top, tick, false);
let y_label_w = y_ticks
.iter()
.map(|(_, l)| text_width(l, tick))
.fold(0.0, f64::max);
let plot = Area {
left: frame.left + y_label_w + tick * 0.8,
top,
right: frame.right,
bottom,
};
let sx = Scale {
lo: xs.0,
hi: xs.1,
from: plot.left,
to: plot.right,
};
let sy = Scale {
lo: y_bounds[0],
hi: y_bounds[1],
from: plot.bottom,
to: plot.top,
};
let x_ticks = if x_ticked {
axis_ticks(xs, x, plot.width(), tick, true).1
} else {
Vec::new()
};
let hair = 0.6 * c.pt;
for (v, label) in &y_ticks {
let py = sy.at(*v);
if grid {
c.line((plot.left, py), (plot.right, py), palette.grid, hair);
}
c.text(
(plot.left - tick * 0.5, py + tick * 0.35),
tick,
palette.text_secondary,
("end", 400),
label,
);
}
draw_x_ticks(c, plot, &sx, &x_ticks, (grid, true));
c.line(
(plot.left, plot.bottom),
(plot.right, plot.bottom),
palette.text_secondary,
hair,
);
draw_y_title(c, frame, &y.title);
draw_x_title(c, (frame, plot), &x.title);
(sx, sy, plot)
}
fn draw_x_ticks(
c: &mut Canvas<'_>,
plot: Area,
sx: &Scale,
ticks: &[(f64, String)],
(grid, marks): (bool, bool),
) {
let tick = c.body * 0.9;
let hair = 0.6 * c.pt;
let palette = c.palette.clone();
for (v, label) in ticks {
let px = sx.at(*v);
if grid {
c.line((px, plot.top), (px, plot.bottom), palette.grid, hair);
}
if marks {
c.line(
(px, plot.bottom),
(px, plot.bottom + tick * 0.35),
palette.text_secondary,
hair,
);
}
c.text(
(px, plot.bottom + tick * 1.35),
tick,
palette.text_secondary,
("middle", 400),
label,
);
}
}
fn draw_x_title(c: &mut Canvas<'_>, (frame, plot): (Area, Area), title: &str) {
if title.is_empty() {
return;
}
let tick = c.body * 0.9;
let color = c.palette.text;
c.text(
((plot.left + plot.right) / 2.0, frame.bottom - tick * 0.2),
tick,
color,
("middle", 600),
title,
);
}
fn draw_y_title(c: &mut Canvas<'_>, frame: Area, title: &str) {
if title.is_empty() {
return;
}
let tick = c.body * 0.9;
let color = c.palette.text;
c.text(
(frame.left, frame.top + tick),
tick,
color,
("start", 600),
title,
);
}
fn category_axes(
c: &mut Canvas<'_>,
frame: Area,
names: &[String],
ys: (f64, f64),
x: &Axis,
y: &Axis,
grid: bool,
) -> (Scale, Scale, Area) {
let n = names.len().max(1) as f64;
let (sx, sy, plot) = axes_with(c, frame, (0.0, n), ys, (x, false), y, grid);
let tick = c.body * 0.9;
let slot = plot.width() / n;
let max_chars = (slot / (tick * 0.55)).max(3.0) as usize;
for (i, name) in names.iter().enumerate() {
let label = if name.chars().count() > max_chars {
let kept: String = name.chars().take(max_chars.saturating_sub(1)).collect();
format!("{kept}…")
} else {
name.clone()
};
c.text(
(
plot.left + slot * (i as f64 + 0.5),
plot.bottom + tick * 1.35,
),
tick,
c.palette.text_secondary,
("middle", 400),
&label,
);
}
(sx, sy, plot)
}
fn line_end_labels(
c: &mut Canvas<'_>,
names: &[String],
mut ends: Vec<(f64, f64, usize)>,
plot: Area,
) {
let tick = c.body * 0.9;
ends.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut last = f64::NEG_INFINITY;
for (y, _, _) in &mut ends {
*y = y.max(last + tick * 1.15).max(plot.top);
last = *y;
}
if let Some(over) = ends
.last()
.map(|(y, _, _)| *y - plot.bottom)
.filter(|o| *o > 0.0)
{
for (y, _, _) in &mut ends {
*y -= over;
}
}
for (y, _, i) in ends {
c.text(
(plot.right + tick * 0.5, y + tick * 0.35),
tick,
c.color(i),
("start", 600),
&names[i],
);
}
}
fn legend_box(c: &mut Canvas<'_>, names: &[String], place: LegendPlace, frame: Area, lines: bool) {
let tick = c.body * 0.85;
let row = tick * 1.4;
let swatch = tick * 1.2;
let width = names
.iter()
.map(|n| text_width(n, tick))
.fold(0.0, f64::max)
+ swatch
+ tick * 1.5;
let height = row * names.len() as f64 + tick * 0.6;
let pad = tick * 0.6;
let plot_top = frame.top + tick * 2.4;
let plot_bottom = frame.bottom - tick * 3.3;
let (x, y) = match place {
LegendPlace::TopLeft => (frame.left + tick * 4.0, plot_top + pad),
LegendPlace::BottomRight => (frame.right - width - pad, plot_bottom - height - pad),
LegendPlace::BottomLeft => (frame.left + tick * 4.0, plot_bottom - height - pad),
_ => (frame.right - width - pad, plot_top + pad),
};
if let Some(bg) = c.palette.background {
c.rect(x, y, width, height, bg, 0.9);
}
for (i, name) in names.iter().enumerate() {
let cy = y + tick * 0.3 + row * (i as f64 + 0.5);
let color = c.color(i);
if lines {
c.line(
(x + pad, cy),
(x + pad + swatch, cy),
color,
c.swatch_stroke,
);
} else {
c.rect(x + pad, cy - tick * 0.35, swatch, tick * 0.7, color, 1.0);
}
c.text(
(x + pad + swatch + tick * 0.5, cy + tick * 0.35),
tick,
c.palette.text,
("start", 400),
name,
);
}
}
fn draw_bars(c: &mut Canvas<'_>, frame: Area, data: &BarData, value: &Axis, grid: bool) {
let tick = c.body * 0.9;
let palette = c.palette.clone();
let top = frame.top + tick * 2.2;
let x_title_h = if value.title.is_empty() {
0.0
} else {
tick * 1.5
};
let bottom = frame.bottom - tick * 1.6 - x_title_h;
let groups = data.groups.len().max(1);
let row_min = (tick * 1.3).max(tick * 0.5 * groups as f64);
let fits = (((bottom - top) / row_min) as usize).max(1);
let mut bars: Vec<&crate::chart::chart_data::Bar> = data.bars.iter().collect();
let mut more = data.more;
if bars.len() > fits {
more += bars.len() - (fits - 1);
bars.truncate(fits - 1);
}
let null = "null".to_string();
let label_of =
|b: &crate::chart::chart_data::Bar| b.label.clone().unwrap_or_else(|| null.clone());
let more_label = format!("+ {} more", crate::numfmt::group_chrome(more));
let label_w = bars
.iter()
.map(|b| text_width(&label_of(b), tick))
.chain((more > 0).then(|| text_width(&more_label, tick)))
.fold(0.0, f64::max)
.min(frame.width() * 0.35);
let values = || {
bars.iter().flat_map(|b| {
if b.by_group.is_empty() {
vec![b.value]
} else {
b.by_group.iter().flatten().copied().collect()
}
})
};
let lo = values().fold(0.0_f64, f64::min);
let hi = values().fold(0.0_f64, f64::max);
let (lo, hi) = if hi > lo { (lo, hi) } else { (lo, lo + 1.0) };
let plot = Area {
left: frame.left + label_w + tick,
top,
right: frame.right,
bottom,
};
let sx = Scale {
lo,
hi,
from: plot.left,
to: plot.right,
};
let (_, x_ticks) = axis_ticks((lo, hi), value, plot.width(), tick, true);
let hair = 0.6 * c.pt;
draw_x_ticks(c, plot, &sx, &x_ticks, (grid, false));
draw_x_title(c, (frame, plot), &value.title);
draw_y_title(c, frame, &data.category);
let rows = bars.len() + usize::from(more > 0);
let row_h = (plot.height() / rows.max(1) as f64).min(tick * 2.5 * groups as f64);
let zero = sx.at(0.0);
let gap = 1.0 * c.pt;
for (i, bar) in bars.iter().enumerate() {
let y0 = plot.top + row_h * i as f64;
let label = label_of(bar);
let max_chars = (label_w / (tick * 0.5)).max(3.0) as usize;
let label = if label.chars().count() > max_chars {
let kept: String = label.chars().take(max_chars.saturating_sub(1)).collect();
format!("{kept}…")
} else {
label
};
c.text(
(plot.left - tick * 0.5, y0 + row_h / 2.0 + tick * 0.35),
tick,
palette.text,
("end", 400),
&label,
);
let body = row_h * 0.75;
let pieces: Vec<(usize, f64)> = if bar.by_group.is_empty() {
vec![(0, bar.value)]
} else {
bar.by_group
.iter()
.enumerate()
.filter_map(|(g, v)| v.map(|v| (g, v)))
.collect()
};
let each = body / groups as f64;
for (g, v) in pieces {
let slot = if bar.by_group.is_empty() { 0 } else { g };
let y = y0 + (row_h - body) / 2.0 + each * slot as f64;
let end = sx.at(v);
let (x, w) = if end >= zero {
(zero, end - zero)
} else {
(end, zero - end)
};
c.rect(
x,
y + gap / 2.0,
w.max(hair),
(each - gap).max(hair),
c.color(g),
1.0,
);
}
}
if more > 0 {
let y0 = plot.top + row_h * bars.len() as f64;
c.text(
(plot.left - tick * 0.5, y0 + row_h / 2.0 + tick * 0.35),
tick,
palette.text_secondary,
("end", 400),
&more_label,
);
}
c.line(
(zero, plot.top),
(zero, plot.bottom),
palette.text_secondary,
hair,
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chart::chart_data::{
Bar, BoxPlotData, BoxPlotStats, HeatmapData, HistogramBin, HistogramData, HistogramGroup,
OTHER, RowsRead, XAxisTemporalKind,
};
use crate::chart::chart_plot::LinesData;
use std::borrow::Cow;
fn figure_of(data: PlotData) -> Figure {
Figure {
plot: Plot {
data: Cow::Owned(data),
x: Axis::default(),
y: Axis::default(),
scatter: false,
y_from_zero: false,
},
chart_notes: Vec::new(),
grid: true,
}
}
fn lines_of(figure: &mut Figure) -> &mut LinesData {
match figure.plot.data.to_mut() {
PlotData::Lines(lines) => lines,
_ => unreachable!("a line chart"),
}
}
fn lines(names: &[&str]) -> Figure {
Figure {
plot: Plot {
data: Cow::Owned(PlotData::Lines(LinesData {
series: (0..names.len())
.map(|i| (0..10).map(|x| (x as f64, (x * (i + 1)) as f64)).collect())
.collect(),
names: names.iter().map(|n| n.to_string()).collect(),
other: names.last() == Some(&OTHER),
..LinesData::default()
})),
scatter: false,
x: Axis {
title: "x".to_string(),
..Default::default()
},
y: Axis {
title: "value".to_string(),
..Default::default()
},
y_from_zero: false,
},
chart_notes: vec!["sample of 1,000 of 50k rows".to_string()],
grid: true,
}
}
fn options() -> ExportOptions {
ExportOptions {
title: "Cumulative return by symbol".to_string(),
description: "Mean monthly return, compounded".to_string(),
notes: "Illustrative values".to_string(),
source: "NYC flights, public domain".to_string(),
byline: "Chart: datui".to_string(),
..Default::default()
}
}
#[test]
fn the_svg_carries_the_words_and_names_line_ends() {
let svg = svg(&lines(&["AAPL", "MSFT"]), &options()).unwrap();
for text in [
"Cumulative return by symbol",
"Mean monthly return, compounded",
"Illustrative values",
"Source: NYC flights, public domain · Chart: datui",
"sample of 1,000 of 50k rows",
">AAPL<",
">MSFT<",
] {
assert!(svg.contains(text), "{text} missing:\n{svg}");
}
assert!(svg.contains("fill=\"#2a78d6\""), "light palette: {svg}");
assert!(svg.contains("fill=\"#ffffff\""), "a white background");
roxmltree_ok(&svg);
}
#[test]
fn an_export_names_other_last_in_its_neutral_color() {
let mut figure = lines(&["AAPL", "MSFT", OTHER]);
figure.plot.scatter = true;
let svg = svg(
&figure,
&ExportOptions {
legend: LegendPlace::TopRight,
..options()
},
)
.unwrap();
let (aapl, other) = (svg.find(">AAPL<").unwrap(), svg.find(">Other<").unwrap());
assert!(aapl < other, "Other last: {svg}");
let grey = format!("fill=\"{}\"", Palette::light().other.hex());
let third = format!("fill=\"{}\"", Palette::light().series[2].hex());
assert!(svg.contains(&grey), "{svg}");
assert!(!svg.contains(&third), "Other takes no series color: {svg}");
let blue = format!("fill=\"{}\"", Palette::light().series[0].hex());
assert!(svg.find(&grey).unwrap() < svg.find(&blue).unwrap(), "{svg}");
roxmltree_ok(&svg);
}
fn roxmltree_ok(svg: &str) {
tree(svg).expect("usvg reads the SVG");
}
#[test]
fn legend_off_draws_no_legend() {
let figure = lines(&["AAPL", "MSFT"]);
let off = svg(
&figure,
&ExportOptions {
legend: LegendPlace::Off,
..options()
},
)
.unwrap();
assert!(!off.contains(">AAPL<") && !off.contains(">MSFT<"), "{off}");
let boxed = svg(
&figure,
&ExportOptions {
legend: LegendPlace::BottomLeft,
..options()
},
)
.unwrap();
assert_eq!(boxed.matches(">AAPL<").count(), 1);
let one = svg(&lines(&["AAPL"]), &options()).unwrap();
assert!(!one.contains(">AAPL<"));
}
#[test]
fn transparent_has_no_background_and_dark_uses_the_theme() {
let clear = svg(
&lines(&["a", "b"]),
&ExportOptions {
palette: Palette::transparent(),
..options()
},
)
.unwrap();
assert!(!clear.contains("fill=\"#ffffff\" fill-opacity=\"1.00\"/>\n<text"));
assert!(!clear.contains("width=\"1600.00\""), "no full-page rect");
let config = crate::config::AppConfig::default();
let dark = Palette::dark(&config.theme.colors);
assert_eq!(dark.series[0], Rgb(0x7d, 0xcf, 0xff), "chart_1");
assert!(dark.dark);
}
#[test]
fn palettes_have_ten_series_and_a_repeat_counts_once() {
let mut colors = crate::config::ColorConfig::default();
assert_eq!(Palette::light().series.len(), SERIES);
assert_eq!(Palette::dark(&colors).series.len(), SERIES);
colors.chart_5 = colors.chart_1.clone();
let dark = Palette::dark(&colors);
assert_eq!(dark.series.len(), SERIES - 1);
assert_eq!(dark.series[4], Rgb(0xf7, 0x76, 0x8e), "chart_6 moves up");
let theme = crate::config::Theme::from_config(&crate::config::ThemeConfig {
colors,
..Default::default()
})
.unwrap();
let shown = theme.series_colors();
for (i, color) in shown.iter().enumerate() {
assert!(!shown[..i].contains(color), "{shown:?}");
}
assert!(shown.len() < SERIES, "{shown:?}");
}
#[test]
fn png_svg_and_pdf_are_what_they_say() {
let figure = lines(&["AAPL", "MSFT"]);
let options = ExportOptions {
width: 600,
height: 400,
dpi: 96.0,
..options()
};
let png = render(&figure, &options, ChartExportFormat::Png).unwrap();
assert!(png.starts_with(b"\x89PNG\r\n\x1a\n"));
assert_eq!(&png[16..20], &600u32.to_be_bytes());
assert_eq!(&png[20..24], &400u32.to_be_bytes());
assert_eq!(&png[37..41], b"pHYs");
assert_eq!(&png[41..45], &3780u32.to_be_bytes());
let decoded = resvg::tiny_skia::Pixmap::decode_png(&png).expect("a valid PNG");
assert_eq!((decoded.width(), decoded.height()), (600, 400));
let svg =
String::from_utf8(render(&figure, &options, ChartExportFormat::Svg).unwrap()).unwrap();
assert!(svg.starts_with("<svg"), "{svg}");
assert!(svg.contains("width=\"6.250in\""), "printed size: {svg}");
assert!(!svg.contains("<text"), "text set as outlines");
usvg::Tree::from_str(&svg, &usvg::Options::default()).expect("valid SVG");
let pdf = render(&figure, &options, ChartExportFormat::Pdf).unwrap();
assert!(pdf.starts_with(b"%PDF-"));
assert!(String::from_utf8_lossy(&pdf).contains("/MediaBox [0 0 450 300]"));
}
#[test]
fn the_recipe_rides_in_each_format_and_omit_writes_none() {
let figure = lines(&["AAPL", "MSFT"]);
let recipe = "{\"datui\": \"0.4.0\", \"settings\": {\"query\": \"select where a < 3 & b > \\\"x\\\"\"}, \"note\": \"Zürich\"}";
let with = ExportOptions {
width: 300,
height: 200,
dpi: 96.0,
recipe: Some(recipe.to_string()),
..ExportOptions::default()
};
let without = ExportOptions {
recipe: None,
..with.clone()
};
for format in ChartExportFormat::ALL {
let bytes = render(&figure, &with, format).unwrap();
assert_eq!(recipe_in(&bytes).as_deref(), Some(recipe), "{format:?}");
let bare = render(&figure, &without, format).unwrap();
assert_eq!(recipe_in(&bare), None, "{format:?}");
assert!(
!bare.windows(5).any(|w| w.eq_ignore_ascii_case(b"datui")),
"{format:?}: no datui metadata with Omit"
);
}
let png = render(&figure, &with, ChartExportFormat::Png).unwrap();
let decoded = resvg::tiny_skia::Pixmap::decode_png(&png).expect("a valid PNG");
assert_eq!((decoded.width(), decoded.height()), (300, 200));
let svg =
String::from_utf8(render(&figure, &with, ChartExportFormat::Svg).unwrap()).unwrap();
assert!(svg.contains("<metadata>"), "{svg}");
usvg::Tree::from_str(&svg, &usvg::Options::default()).expect("valid SVG");
}
#[test]
fn presets_set_sizes() {
assert_eq!(SizePreset::Slide.size(), Some((1920, 1080)));
assert_eq!(SizePreset::Document.size(), Some((1600, 1000)));
assert_eq!(SizePreset::Square.size(), Some((1200, 1200)));
let (w, _) = SizePreset::SingleColumn.size().unwrap();
assert_eq!(
f64::from(w) / f64::from(SizePreset::SingleColumn.dpi()),
3.5
);
let (w, _) = SizePreset::DoubleColumn.size().unwrap();
assert_eq!(
f64::from(w) / f64::from(SizePreset::DoubleColumn.dpi()),
7.0
);
assert_eq!(SizePreset::Custom.size(), None);
}
#[test]
fn every_plot_draws() {
let rows = RowsRead::default();
let bars = BarData {
category: "carrier".to_string(),
value_column: "mean delay".to_string(),
bars: vec![
Bar {
label: Some("UA".to_string()),
value: 12.0,
by_group: vec![Some(5.0), Some(7.0)],
},
Bar {
label: None,
value: -3.0,
by_group: vec![Some(-3.0), None],
},
],
more: 3,
no_value: 0,
rows,
value_dtype: polars::prelude::DataType::Float64,
counted: None,
groups: vec!["EWR".to_string(), "Other".to_string()],
other: true,
rows_note: None,
};
let histogram = HistogramData {
column: "delay".to_string(),
bins: (0..4)
.map(|i| HistogramBin {
center: i as f64 + 0.5,
count: i as f64,
})
.collect(),
groups: vec![
HistogramGroup {
name: "a".to_string(),
counts: vec![0.1, 0.2, 0.3, 0.4],
},
HistogramGroup {
name: "b".to_string(),
counts: vec![0.4, 0.3, 0.2, 0.1],
},
],
other: true,
share: true,
x_min: 0.0,
x_max: 4.0,
max_count: 0.4,
rows,
clipped: None,
};
let boxes = BoxPlotData {
stats: vec![BoxPlotStats {
name: "UA".to_string(),
min: 0.0,
q1: 1.0,
median: 2.0,
q3: 3.0,
max: 4.0,
}],
y_min: 0.0,
y_max: 4.0,
rows,
clipped: None,
of: 0,
};
let heatmap = HeatmapData {
x_column: "a".to_string(),
y_column: "b".to_string(),
x_min: 0.0,
x_max: 1.0,
y_min: 0.0,
y_max: 1.0,
x_bins: 2,
y_bins: 2,
counts: vec![vec![1.0, 2.0], vec![0.0, 4.0]],
max_count: 4.0,
rows,
};
let plots = [
figure_of(PlotData::Bars(bars)).plot,
figure_of(PlotData::Histogram(histogram)).plot,
Plot {
x: Axis {
title: "carrier".to_string(),
..Axis::default()
},
..figure_of(PlotData::Box(boxes)).plot
},
figure_of(PlotData::Heatmap(heatmap)).plot,
];
let (w, h) = SizePreset::SingleColumn.size().unwrap();
for plot in plots {
let figure = Figure {
plot,
chart_notes: Vec::new(),
grid: true,
};
let options = ExportOptions {
width: w,
height: h,
dpi: SizePreset::SingleColumn.dpi(),
..options()
};
let svg = svg(&figure, &options).unwrap();
roxmltree_ok(&svg);
}
}
#[test]
fn auto_opacity_fades_with_the_points() {
assert_eq!(auto_opacity(0), 1.0);
assert_eq!(auto_opacity(1_000), 1.0);
assert!((auto_opacity(10_000) - 0.575).abs() < 1e-9);
assert!((auto_opacity(100_000) - 0.15).abs() < 1e-9);
assert!((auto_opacity(5_000_000) - 0.15).abs() < 1e-9);
assert!(auto_opacity(3_000) < 1.0 && auto_opacity(3_000) > auto_opacity(30_000));
assert_eq!(PointOpacity::Half.of(1), 0.5);
}
#[test]
fn mark_options_change_the_marks() {
let pt = f64::from(options().dpi) / 72.0;
let stroke = |w: f64| format!("stroke-width=\"{:.2}\"", w * pt);
let figure = lines(&["AAPL", "MSFT"]);
let draw = |options: ExportOptions| svg(&figure, &options).unwrap();
let normal = draw(ExportOptions {
legend: LegendPlace::TopRight,
..options()
});
let bold = draw(ExportOptions {
legend: LegendPlace::TopRight,
line_width: LineWidth::Bold,
..options()
});
assert!(normal.contains(&stroke(1.5)) && !normal.contains(&stroke(2.5)));
assert!(bold.contains(&stroke(2.5)), "{bold}");
assert!(
normal.contains(&stroke(1.75)),
"the swatch, a little heavier"
);
assert!(
bold.contains(&stroke(1.75 / 1.5 * 2.5)),
"the swatch follows"
);
let mut scatter = lines(&["AAPL"]);
scatter.plot.scatter = true;
let radius = |r: f64| format!("r=\"{:.2}\"", r * pt);
let dots = |options: ExportOptions| svg(&scatter, &options).unwrap();
let medium = dots(options());
assert!(medium.contains(&radius(2.4)), "{medium}");
assert!(
!medium.contains("fill-opacity=\"0.50\""),
"ten points: opaque"
);
let large_half = dots(ExportOptions {
point_size: PointSize::Large,
point_opacity: PointOpacity::Half,
..options()
});
assert!(large_half.contains(&radius(3.6)), "{large_half}");
assert!(
large_half.contains("fill-opacity=\"0.50\"/>"),
"{large_half}"
);
assert!(
dots(ExportOptions {
point_size: PointSize::Small,
..options()
})
.contains(&radius(1.6))
);
let pdf = render(
&scatter,
&ExportOptions {
point_opacity: PointOpacity::Half,
..options()
},
ChartExportFormat::Pdf,
)
.unwrap();
assert!(
String::from_utf8_lossy(&pdf).contains("/ca 0.5 "),
"the PDF's fill opacity"
);
let mut high = lines(&["AAPL"]);
lines_of(&mut high).series[0] = (0..10).map(|x| (x as f64, 100.0 + x as f64)).collect();
let y_zero = "text-anchor=\"end\" fill=\"#5b6170\">0</text>";
let off = svg(&high, &options()).unwrap();
assert!(!off.contains(y_zero), "{off}");
let on = svg(
&high,
&ExportOptions {
y_from_zero: Some(true),
..options()
},
)
.unwrap();
assert!(on.contains(y_zero), "{on}");
roxmltree_ok(&on);
}
#[test]
fn dates_tick_on_the_calendar() {
let axis = Axis {
kind: XAxisTemporalKind::Date,
..Default::default()
};
let (_, ticks) = axis_ticks((19723.0, 20454.0), &axis, 400.0, 12.0, true);
let labels: Vec<&str> = ticks.iter().map(|(_, l)| l.as_str()).collect();
assert!(labels.contains(&"2025"), "{labels:?}");
assert!(ticks.len() <= 6);
}
#[test]
fn too_small_says_so() {
let err = svg(
&lines(&["a"]),
&ExportOptions {
width: 60,
height: 40,
dpi: 96.0,
..options()
},
)
.unwrap_err();
assert!(err.to_string().contains("does not fit"), "{err}");
}
}