pub mod legend;
pub mod loess;
pub mod text;
pub mod types;
pub mod util;
pub use types::{DEFAULT_LEGEND_MODE, LegendMode, PlotKind};
pub use crate::viz_style as style;
use crate::viz_plotters_adapter::{fill_style, line_style, rgb_color};
use crate::viz_style;
use crate::viz_style::{MarkerShape, SeriesStyle};
use crate::models::DataPoint;
use anyhow::{Result, anyhow};
use num_format::ToFormattedString;
use plotters::backend::DrawingBackend;
use plotters::coord::Shift;
use plotters::prelude::*;
use plotters::series::AreaSeries;
use plotters::style::FontFamily;
use plotters_bitmap::BitMapBackend;
use plotters_svg::SVGBackend;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::Path;
use std::sync::Once;
use legend::{draw_legend_panel, estimate_top_bottom_legend_height_px};
use util::{
choose_axis_scale, compute_left_label_area_px, derive_axis_unit, is_percentage_like,
map_locale, office_color,
};
static INIT_FONTS: Once = Once::new();
fn ensure_fonts_registered() {
INIT_FONTS.call_once(|| {
let _ = plotters::style::register_font(
"sans-serif",
plotters::style::FontStyle::Normal,
include_bytes!("../../assets/DejaVuSans.ttf"),
);
});
}
pub fn plot_lines<P: AsRef<Path>>(
points: &[DataPoint],
out_path: P,
width: u32,
height: u32,
) -> Result<()> {
plot_chart(
points,
out_path,
width,
height,
"en",
DEFAULT_LEGEND_MODE,
"World Bank Indicator(s)",
PlotKind::Line,
0.3, None, )
}
pub fn plot_lines_locale<P: AsRef<Path>>(
points: &[DataPoint],
out_path: P,
width: u32,
height: u32,
locale_tag: &str,
) -> Result<()> {
plot_chart(
points,
out_path,
width,
height,
locale_tag,
DEFAULT_LEGEND_MODE,
"World Bank Indicator(s)",
PlotKind::Line,
0.3,
None, )
}
pub fn plot_lines_locale_with_legend<P: AsRef<Path>>(
points: &[DataPoint],
out_path: P,
width: u32,
height: u32,
locale_tag: &str,
legend: LegendMode,
) -> Result<()> {
plot_chart(
points,
out_path,
width,
height,
locale_tag,
legend,
"World Bank Indicator(s)",
PlotKind::Line,
0.3,
None, )
}
pub fn plot_lines_locale_with_legend_title<P: AsRef<Path>>(
points: &[DataPoint],
out_path: P,
width: u32,
height: u32,
locale_tag: &str,
legend: LegendMode,
title: &str,
) -> Result<()> {
plot_chart(
points,
out_path,
width,
height,
locale_tag,
legend,
title,
PlotKind::Line,
0.3,
None, )
}
#[allow(clippy::too_many_arguments)]
pub fn plot_chart<P: AsRef<Path>>(
points: &[DataPoint],
out_path: P,
width: u32,
height: u32,
locale_tag: &str,
legend: LegendMode,
title: &str,
kind: PlotKind,
loess_span: f64, country_styles: Option<bool>, ) -> Result<()> {
if points.is_empty() {
return Err(anyhow!("no data to plot"));
}
ensure_fonts_registered();
let out_path = out_path.as_ref();
let path_string = out_path.to_string_lossy().into_owned();
let years: Vec<i32> = points.iter().map(|p| p.year).filter(|y| *y != 0).collect();
let (mut min_year, mut max_year) = (
*years
.iter()
.min()
.ok_or_else(|| anyhow!("no valid years"))?,
*years
.iter()
.max()
.ok_or_else(|| anyhow!("no valid years"))?,
);
if min_year == max_year {
min_year -= 1;
max_year += 1;
}
let values: Vec<f64> = points.iter().filter_map(|p| p.value).collect();
if values.is_empty() {
return Err(anyhow!("no numeric values to plot"));
}
let (mut min_val, mut max_val) = (
values.iter().cloned().fold(f64::INFINITY, f64::min),
values.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
);
if (max_val - min_val).abs() < f64::EPSILON {
min_val -= 1.0;
max_val += 1.0;
}
let (_num_locale, _dec_sep) = map_locale(locale_tag);
if out_path.extension().and_then(|s| s.to_str()) == Some("svg") {
let root = SVGBackend::new(path_string.as_str(), (width, height)).into_drawing_area();
draw_chart(
root,
points,
min_year,
max_year,
min_val,
max_val,
locale_tag,
legend,
title,
kind,
loess_span,
country_styles,
)?;
} else {
let root = BitMapBackend::new(path_string.as_str(), (width, height)).into_drawing_area();
draw_chart(
root,
points,
min_year,
max_year,
min_val,
max_val,
locale_tag,
legend,
title,
kind,
loess_span,
country_styles,
)?;
}
Ok(())
}
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
fn draw_chart<DB>(
root: DrawingArea<DB, Shift>,
points: &[DataPoint],
min_year: i32,
max_year: i32,
min_val: f64,
max_val: f64,
locale_tag: &str,
legend: LegendMode,
title: &str,
kind: PlotKind,
loess_span: f64,
country_styles: Option<bool>,
) -> Result<()>
where
DB: DrawingBackend,
<DB as DrawingBackend>::ErrorType: 'static,
{
const MARGIN: i32 = 16; let x_min = min_year as f64;
let x_max = max_year as f64;
let base_unit = derive_axis_unit(points); let max_abs = min_val.abs().max(max_val.abs());
let (yscale, scale_word) = if let Some(ref unit) = base_unit {
if is_percentage_like(unit) {
(1.0, "") } else {
choose_axis_scale(max_abs) }
} else {
choose_axis_scale(max_abs)
};
let y_axis_title = match (base_unit.as_deref(), scale_word) {
(Some(u), "") => u.to_string(), (Some(u), sw) => format!("{u} ({sw})"), (None, "") => "Value".to_string(),
(None, sw) => format!("Value ({sw})"),
};
let (num_locale, dec_sep) = map_locale(locale_tag);
let x_label_fmt = |x: &f64| (x.round() as i32).to_string();
let y_label_fmt_scaled = |v: &f64| {
let a = v.abs();
let prec = if a >= 100.0 {
0
} else if a >= 10.0 {
1
} else {
2
};
if prec == 0 {
let int_val = v.round() as i64;
int_val.to_formatted_string(num_locale)
} else {
let s = format!("{:.*}", prec, *v);
if let Some((int_part, frac_part)) = s.split_once('.') {
let sign = if int_part.starts_with('-') { "-" } else { "" };
let digits = int_part.trim_start_matches('-');
let int_num: i64 = digits.parse().unwrap_or(0);
let grouped = int_num.to_formatted_string(num_locale);
format!("{}{}{}{}", sign, grouped, dec_sep, frac_part)
} else {
let int_val = v.round() as i64;
int_val.to_formatted_string(num_locale)
}
}
};
let x_label_count = ((max_year - min_year + 1) as usize).min(12);
let y_label_count = 10usize;
let mut indicator_name_by_id: HashMap<String, String> = HashMap::new();
let mut country_name_by_iso3: HashMap<String, String> = HashMap::new();
for p in points {
indicator_name_by_id
.entry(p.indicator_id.clone())
.or_insert_with(|| p.indicator_name.clone());
country_name_by_iso3
.entry(p.country_iso3.clone())
.or_insert_with(|| p.country_name.clone());
}
let mut groups: BTreeMap<(String, String), Vec<(i32, f64)>> = BTreeMap::new();
for p in points {
if let (y, Some(v)) = (p.year, p.value)
&& y != 0
{
groups
.entry((p.country_iso3.clone(), p.indicator_id.clone()))
.or_default()
.push((y, v));
}
}
for ((_country, _indicator), series) in groups.iter_mut() {
series.sort_by_key(|(y, _)| *y);
}
let mut series_list: Vec<(String, String, String, String, Vec<(i32, f64)>)> = Vec::new();
for ((iso3, indicator_id), series) in groups.iter() {
let country_label = country_name_by_iso3
.get(iso3)
.cloned()
.unwrap_or_else(|| iso3.clone());
let indicator_label = indicator_name_by_id
.get(indicator_id)
.cloned()
.unwrap_or_else(|| indicator_id.clone());
series_list.push((
iso3.clone(),
indicator_id.clone(),
country_label,
indicator_label,
series.clone(),
));
}
series_list.sort_by(|a, b| a.2.cmp(&b.2).then(a.3.cmp(&b.3)));
let unique_indicators: BTreeSet<&str> =
points.iter().map(|p| p.indicator_id.as_str()).collect();
let unique_countries: BTreeSet<&str> = points.iter().map(|p| p.country_iso3.as_str()).collect();
let one_indicator = unique_indicators.len() == 1;
let one_country = unique_countries.len() == 1;
let make_label = |country_label: &str, indicator_label: &str| -> String {
if one_indicator && !one_country {
country_label.to_string()
} else if one_country && !one_indicator {
indicator_label.to_string()
} else {
format!("{} — {}", country_label, indicator_label)
}
};
let left_label_width_px =
compute_left_label_area_px(min_val / yscale, max_val / yscale, y_label_count, 12);
let axis_x_start_px: i32 = MARGIN + left_label_width_px as i32;
let legend_texts: Vec<String> = series_list
.iter()
.map(|(_iso3, _ind, country_label, indicator_label, _s)| {
make_label(country_label, indicator_label)
})
.collect();
let (root_w_u32, root_h_u32) = root.dim_in_pixel();
let root_w = root_w_u32 as i32;
let root_h = root_h_u32 as i32;
let _has_title = false;
let _title_font_px: u32 = 16;
let _font_px: u32 = 14;
let legend_needed_h = if matches!(legend, LegendMode::Top | LegendMode::Bottom) {
estimate_top_bottom_legend_height_px(
&legend_texts,
axis_x_start_px,
root_w,
false, 16,
14,
)
} else {
0
};
let (plot_area, legend_area_opt): (DrawingArea<DB, Shift>, Option<DrawingArea<DB, Shift>>) =
match legend {
LegendMode::Right => {
let (plot, legend) = root.split_horizontally((85).percent_width());
(plot, Some(legend))
}
LegendMode::Top => {
let h = legend_needed_h.max(40);
let (legend, plot) = root.split_vertically(h);
(plot, Some(legend))
}
LegendMode::Bottom => {
let h = legend_needed_h.max(40);
let (plot, legend) = root.split_vertically((root_h - h).max(40));
(plot, Some(legend))
}
LegendMode::Inside => (root, None),
};
plot_area
.fill(&WHITE)
.map_err(|e| anyhow::anyhow!("{:?}", e))?;
if let Some(ref legend_area) = legend_area_opt {
legend_area
.fill(&WHITE)
.map_err(|e| anyhow::anyhow!("{:?}", e))?;
}
let mut chart = ChartBuilder::on(&plot_area)
.margin(MARGIN as u32)
.caption(
{
let t = title.trim();
if t.is_empty() || t == "World Bank Indicator(s)" {
let names: BTreeSet<&str> =
points.iter().map(|p| p.indicator_name.as_str()).collect();
if names.is_empty() {
"World Bank Series".to_string()
} else if names.len() == 1 {
names.iter().next().unwrap().to_string()
} else if names.len() <= 3 {
names.into_iter().collect::<Vec<_>>().join(", ")
} else {
let first = names.iter().next().unwrap();
let more = names.len() - 1;
format!("{first} + {more} more")
}
} else {
t.to_string()
}
},
(FontFamily::SansSerif, 24),
)
.set_label_area_size(LabelAreaPosition::Left, left_label_width_px)
.set_label_area_size(LabelAreaPosition::Bottom, 56)
.build_cartesian_2d(x_min..x_max, (min_val / yscale)..(max_val / yscale))
.map_err(|e| anyhow::anyhow!("{:?}", e))?;
chart
.configure_mesh()
.x_desc("Year")
.y_desc(y_axis_title)
.x_labels(x_label_count)
.y_labels(y_label_count)
.x_label_formatter(&x_label_fmt)
.y_label_formatter(&y_label_fmt_scaled)
.label_style((FontFamily::SansSerif, 12))
.axis_desc_style((FontFamily::SansSerif, 16))
.draw()
.map_err(|e| anyhow::anyhow!("{:?}", e))?;
let mut legend_items: Vec<(String, SeriesStyle)> = Vec::new();
let inside_mode = matches!(legend, LegendMode::Inside);
let use_country_styles = country_styles.unwrap_or(false);
let make_label_for_legend = |country_label: &str, indicator_label: &str| -> String {
make_label(country_label, indicator_label)
};
let mut seen_legend_labels: BTreeSet<String> = BTreeSet::new();
match kind {
PlotKind::Line
| PlotKind::Scatter
| PlotKind::LinePoints
| PlotKind::Area
| PlotKind::Loess => {
for (idx, (iso3, indicator_id, country_label, indicator_label, series)) in
series_list.iter().enumerate()
{
let legend_label = make_label_for_legend(country_label, indicator_label);
let color = office_color(idx);
let style = if use_country_styles {
viz_style::SeriesStyle::for_series(iso3, indicator_id)
} else {
SeriesStyle {
country: iso3.clone(),
indicator: indicator_id.clone(),
hsl: viz_style::Hsl {
h_deg: 0.0,
s: 0.0,
l: 0.0,
},
rgb: viz_style::Rgb8 {
r: color.0,
g: color.1,
b: color.2,
},
hex: String::new(),
marker: MarkerShape::Circle,
line_dash: viz_style::LineDash::Solid,
marker_size: 6,
line_width: 2,
}
};
let series_f: Vec<(f64, f64)> = series
.iter()
.map(|(x, y)| (*x as f64, *y / yscale))
.collect();
match kind {
PlotKind::Line | PlotKind::Loess => {
let pts: Vec<(f64, f64)> = if matches!(kind, PlotKind::Loess) {
let xs: Vec<f64> = series.iter().map(|(x, _)| *x as f64).collect();
let ys: Vec<f64> = series.iter().map(|(_, y)| *y).collect();
let yhat = loess::loess_series(&xs, &ys, loess_span);
xs.into_iter()
.zip(yhat.into_iter().map(|v| v / yscale))
.collect()
} else {
series_f.clone()
};
let final_label = if matches!(kind, PlotKind::Loess) {
format!("{legend_label} (LOESS)")
} else {
legend_label.clone()
};
let elem = chart.draw_series(std::iter::once(PathElement::new(
pts,
line_style(&style),
)))?;
if inside_mode && seen_legend_labels.insert(legend_label.clone()) {
let text = final_label.clone();
let style_clone = style.clone();
let color = rgb_color(&style);
elem.label(text.clone()).legend(move |(x, y)| {
EmptyElement::at((x, y))
+ PathElement::new(
vec![(x - 14, y), (x + 14, y)],
line_style(&style_clone),
)
+ Circle::new((x, y), 4, color.filled())
+ Text::new(text.clone(), (x + 20, y), ("sans-serif", 14))
});
}
if !inside_mode && seen_legend_labels.insert(legend_label.clone()) {
legend_items.push((final_label, style.clone()));
}
}
PlotKind::Scatter => {
chart.draw_series(series_f.iter().map(|(x, y)| {
Circle::new((*x, *y), style.marker_size as i32, fill_style(&style))
}))?;
if inside_mode && seen_legend_labels.insert(legend_label.clone()) {
let text = legend_label.clone();
let color = rgb_color(&style);
let empty =
chart.draw_series(std::iter::empty::<Circle<(f64, f64), i32>>())?;
empty.label(text.clone()).legend(move |(x, y)| {
EmptyElement::at((x, y))
+ Circle::new((x + 8, y), 4, color.filled())
+ Text::new(text.clone(), (x + 20, y), ("sans-serif", 14))
});
}
if !inside_mode && seen_legend_labels.insert(legend_label.clone()) {
legend_items.push((legend_label.clone(), style.clone()));
}
}
PlotKind::LinePoints => {
let pts: Vec<(f64, f64)> = series_f.clone();
chart.draw_series(std::iter::once(PathElement::new(
pts,
line_style(&style),
)))?;
chart.draw_series(series_f.iter().map(|(x, y)| {
Circle::new((*x, *y), style.marker_size as i32, fill_style(&style))
}))?;
if inside_mode && seen_legend_labels.insert(legend_label.clone()) {
let text = legend_label.clone();
let style_clone = style.clone();
let color = rgb_color(&style);
let empty =
chart.draw_series(std::iter::empty::<Circle<(f64, f64), i32>>())?;
empty.label(text.clone()).legend(move |(x, y)| {
EmptyElement::at((x, y))
+ PathElement::new(
vec![(x - 14, y), (x + 14, y)],
line_style(&style_clone),
)
+ Circle::new((x, y), 4, color.filled())
+ Text::new(text.clone(), (x + 20, y), ("sans-serif", 14))
});
}
if !inside_mode && seen_legend_labels.insert(legend_label.clone()) {
legend_items.push((legend_label.clone(), style.clone()));
}
}
PlotKind::Area => {
let area_pts: Vec<(f64, f64)> = series_f;
let elem = chart.draw_series(AreaSeries::new(
area_pts,
0.0,
fill_style(&style),
))?;
if inside_mode && seen_legend_labels.insert(legend_label.clone()) {
let text = legend_label.clone();
let color = rgb_color(&style);
elem.label(text.clone()).legend(move |(x, y)| {
EmptyElement::at((x, y))
+ Circle::new((x + 8, y), 4, color.filled())
+ Text::new(text.clone(), (x + 20, y), ("sans-serif", 14))
});
}
if !inside_mode && seen_legend_labels.insert(legend_label.clone()) {
legend_items.push((legend_label, style));
}
}
_ => {}
}
}
}
PlotKind::StackedArea => {
let years_all: Vec<i32> = (min_year..=max_year).collect();
let mut cum: Vec<f64> = vec![0.0; years_all.len()];
for (idx, (iso3, indicator_id, country_label, indicator_label, series)) in
series_list.iter().enumerate()
{
let color = office_color(idx);
let legend_label = make_label(country_label, indicator_label);
let mut vals: Vec<f64> = vec![0.0; years_all.len()];
for (y, v) in series.iter() {
if *y >= min_year && *y <= max_year {
vals[(*y - min_year) as usize] = (*v).max(0.0);
}
}
let mut upper: Vec<(f64, f64)> = Vec::with_capacity(vals.len());
let mut lower: Vec<(f64, f64)> = Vec::with_capacity(vals.len());
for (i, v) in vals.iter().enumerate() {
let x = (min_year + i as i32) as f64;
lower.push((x, cum[i]));
cum[i] += *v;
upper.push((x, cum[i]));
}
let mut poly: Vec<(f64, f64)> = Vec::with_capacity(upper.len() * 2);
poly.extend(lower.iter().map(|(x, y)| (*x, *y / yscale)));
poly.extend(upper.iter().rev().map(|(x, y)| (*x, *y / yscale)));
let fill = color.clone().mix(0.30).filled();
let border = color.clone().stroke_width(1);
chart
.draw_series(std::iter::once(Polygon::new(poly, fill)))
.map_err(|e| anyhow::anyhow!("{:?}", e))?;
chart
.draw_series(std::iter::once(PathElement::new(
upper
.iter()
.map(|(x, y)| (*x, *y / yscale))
.collect::<Vec<_>>(),
border,
)))
.map_err(|e| anyhow::anyhow!("{:?}", e))?;
legend_items.push((
legend_label,
SeriesStyle {
country: iso3.clone(),
indicator: indicator_id.clone(),
hsl: viz_style::Hsl {
h_deg: 0.0,
s: 0.0,
l: 0.0,
},
rgb: viz_style::Rgb8 {
r: color.0,
g: color.1,
b: color.2,
},
hex: String::new(),
marker: MarkerShape::Circle,
line_dash: viz_style::LineDash::Solid,
marker_size: 6,
line_width: 2,
},
));
}
}
PlotKind::GroupedBar => {
let n_series = series_list.len().max(1);
let group_width = 0.8f64;
let bar_w = group_width / n_series as f64;
for (idx, (iso3, indicator_id, country_label, indicator_label, series)) in
series_list.iter().enumerate()
{
let color = office_color(idx);
let legend_label = make_label(country_label, indicator_label);
for (y, v) in series.iter() {
let x_center = *y as f64;
let x0 = x_center - group_width / 2.0 + idx as f64 * bar_w;
let x1 = x0 + bar_w;
let y0 = 0.0f64.min(*v) / yscale;
let y1 = 0.0f64.max(*v) / yscale;
let rect = Rectangle::new([(x0, y0), (x1, y1)], color.clone().filled());
chart
.draw_series(std::iter::once(rect))
.map_err(|e| anyhow::anyhow!("{:?}", e))?;
}
legend_items.push((
legend_label,
SeriesStyle {
country: iso3.clone(),
indicator: indicator_id.clone(),
hsl: viz_style::Hsl {
h_deg: 0.0,
s: 0.0,
l: 0.0,
},
rgb: viz_style::Rgb8 {
r: color.0,
g: color.1,
b: color.2,
},
hex: String::new(),
marker: MarkerShape::Circle,
line_dash: viz_style::LineDash::Solid,
marker_size: 6,
line_width: 2,
},
));
}
}
}
if inside_mode {
chart
.configure_series_labels()
.border_style(BLACK)
.position(SeriesLabelPosition::UpperLeft)
.background_style(WHITE.mix(0.85))
.label_font((FontFamily::SansSerif, 14))
.draw()
.map_err(|e| anyhow::anyhow!("{:?}", e))?;
} else if let Some(ref legend_area) = legend_area_opt {
let legend_items_rgb: Vec<(String, RGBAColor)> = legend_items
.iter()
.map(|(label, style)| (label.clone(), rgb_color(style)))
.collect();
draw_legend_panel(legend_area, &legend_items_rgb, "", legend, axis_x_start_px)?;
}
plot_area
.present()
.map_err(|e| anyhow::anyhow!("{:?}", e))?;
if let Some(ref legend_area) = legend_area_opt {
legend_area
.present()
.map_err(|e| anyhow::anyhow!("{:?}", e))?;
}
Ok(())
}