use crate::core::subplot::{FigureRect, SubplotFigure, figure};
use crate::core::{
DEFAULT_AUTOSCALE_MARGIN, IntoPlot, MarginConfig, Plot, PlottingError, Result, ShapeStyle,
};
use crate::data::NumericData1D;
use crate::plots::distribution::RugAxis;
use crate::render::{Color, LineStyle, Theme};
use crate::stats::kde::kde_1d;
#[derive(Debug, Clone)]
pub struct JointPlotConfig {
pub kind: JointKind,
pub marginal_hist: bool,
pub marginal_kde: bool,
pub rugplot: bool,
pub scatter_size: f32,
pub scatter_alpha: f32,
pub color: Option<Color>,
pub bins: usize,
pub marginal_ratio: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum JointKind {
#[default]
Scatter,
Reg,
Hex,
Kde,
Resid,
}
impl Default for JointPlotConfig {
fn default() -> Self {
Self {
kind: JointKind::Scatter,
marginal_hist: true,
marginal_kde: true,
rugplot: false,
scatter_size: 5.0,
scatter_alpha: 0.7,
color: None,
bins: 30,
marginal_ratio: 0.2,
}
}
}
impl JointPlotConfig {
pub fn new() -> Self {
Self::default()
}
pub fn kind(mut self, kind: JointKind) -> Self {
self.kind = kind;
self
}
pub fn marginal_hist(mut self, show: bool) -> Self {
self.marginal_hist = show;
self
}
pub fn marginal_kde(mut self, show: bool) -> Self {
self.marginal_kde = show;
self
}
pub fn rugplot(mut self, show: bool) -> Self {
self.rugplot = show;
self
}
pub fn color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
pub fn bins(mut self, bins: usize) -> Self {
self.bins = bins.max(2);
self
}
pub fn marginal_ratio(mut self, ratio: f64) -> Self {
self.marginal_ratio = ratio;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct JointPlotLayout {
pub main_bounds: FigureRect,
pub x_marginal_bounds: FigureRect,
pub y_marginal_bounds: FigureRect,
}
pub fn joint_plot_layout(marginal_ratio: f64) -> JointPlotLayout {
let ratio = if marginal_ratio.is_finite() {
marginal_ratio.clamp(0.1, 0.4)
} else {
0.2
};
let gap = 0.02;
JointPlotLayout {
main_bounds: FigureRect::new(0.0, 0.0, 1.0 - ratio - gap, 1.0 - ratio - gap),
x_marginal_bounds: FigureRect::new(0.0, 1.0 - ratio, 1.0 - ratio - gap, ratio),
y_marginal_bounds: FigureRect::new(1.0 - ratio, 0.0, ratio, 1.0 - ratio - gap),
}
}
#[derive(Debug, Clone)]
pub struct MarginalHistogram {
pub edges: Vec<f64>,
pub counts: Vec<usize>,
pub centers: Vec<f64>,
}
impl MarginalHistogram {
pub fn density(&self) -> Vec<f64> {
let total: usize = self.counts.iter().sum();
let bin_width = match (self.edges.first(), self.edges.get(1)) {
(Some(low), Some(high)) => high - low,
_ => return Vec::new(),
};
if total == 0 || bin_width <= 0.0 {
return vec![0.0; self.counts.len()];
}
let scale = 1.0 / (total as f64 * bin_width);
self.counts.iter().map(|&c| c as f64 * scale).collect()
}
pub fn staircase(&self) -> (Vec<f64>, Vec<f64>) {
let density = self.density();
if density.is_empty() {
return (Vec::new(), Vec::new());
}
let points = 2 * (density.len() + 1);
let mut values = Vec::with_capacity(points);
let mut densities = Vec::with_capacity(points);
values.push(self.edges[0]);
densities.push(0.0);
for (bin, height) in density.iter().enumerate() {
values.push(self.edges[bin]);
densities.push(*height);
values.push(self.edges[bin + 1]);
densities.push(*height);
}
values.push(self.edges[density.len()]);
densities.push(0.0);
(values, densities)
}
}
pub fn compute_marginal_histogram(data: &[f64], bins: usize) -> MarginalHistogram {
if data.is_empty() || bins == 0 {
return MarginalHistogram {
edges: vec![],
counts: vec![],
centers: vec![],
};
}
let min_val = data.iter().copied().fold(f64::INFINITY, f64::min);
let max_val = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let range = if (max_val - min_val).abs() < 1e-10 {
1.0
} else {
max_val - min_val
};
let bin_width = range / bins as f64;
let edges: Vec<f64> = (0..=bins).map(|i| min_val + i as f64 * bin_width).collect();
let mut counts = vec![0_usize; bins];
for &val in data {
let bin = ((val - min_val) / bin_width).floor() as usize;
let bin = bin.min(bins - 1); counts[bin] += 1;
}
let centers: Vec<f64> = (0..bins)
.map(|i| min_val + (i as f64 + 0.5) * bin_width)
.collect();
MarginalHistogram {
edges,
counts,
centers,
}
}
pub fn panel_config() -> crate::core::PlotConfig {
crate::core::PlotConfig {
margins: MarginConfig::proportional_custom(0.17, 0.05, 0.08, 0.17),
..crate::core::PlotConfig::default()
}
}
pub(crate) fn panel() -> Plot {
Plot::with_config(panel_config())
}
pub(crate) fn padded_range(values: &[f64]) -> (f64, f64) {
let (low, high) = values
.iter()
.copied()
.filter(|v| v.is_finite())
.fold((f64::INFINITY, f64::NEG_INFINITY), |(low, high), v| {
(low.min(v), high.max(v))
});
if !low.is_finite() || !high.is_finite() {
return (0.0, 1.0);
}
let span = high - low;
let pad = if span > 0.0 {
span * DEFAULT_AUTOSCALE_MARGIN
} else {
low.abs().max(1.0) * DEFAULT_AUTOSCALE_MARGIN
};
(low - pad, high + pad)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MarginalAxis {
X,
Y,
}
impl MarginalAxis {
fn place(self, values: Vec<f64>, density: Vec<f64>) -> (Vec<f64>, Vec<f64>) {
match self {
MarginalAxis::X => (values, density),
MarginalAxis::Y => (density, values),
}
}
fn rug_axis(self) -> RugAxis {
match self {
MarginalAxis::X => RugAxis::X,
MarginalAxis::Y => RugAxis::Y,
}
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct Marginal<'a> {
pub values: &'a [f64],
pub range: (f64, f64),
pub axis: MarginalAxis,
pub hist: bool,
pub kde: bool,
pub rug: bool,
pub bins: usize,
pub color: Color,
}
impl Marginal<'_> {
const KDE_POINTS: usize = 128;
const DENSITY_HEADROOM: f64 = 1.08;
pub(crate) fn axes(self) -> Option<Plot> {
if self.values.is_empty() || !(self.hist || self.kde || self.rug) {
return None;
}
let histogram = self
.hist
.then(|| compute_marginal_histogram(self.values, self.bins));
let kde = self
.kde
.then(|| kde_1d(self.values, None, Some(Self::KDE_POINTS)));
let peak = |values: &[f64]| values.iter().copied().fold(0.0_f64, f64::max);
let mut density_max = 0.0_f64;
if let Some(histogram) = &histogram {
density_max = density_max.max(peak(&histogram.density()));
}
if let Some(kde) = &kde {
density_max = density_max.max(peak(&kde.density));
}
if density_max <= 0.0 || !density_max.is_finite() {
density_max = 1.0;
}
let mut plot = panel().grid(false).ticks(false);
if let Some(histogram) = &histogram {
let fill = ShapeStyle {
fill_color: Some(self.color),
fill_alpha: 0.35,
edge_color: None,
edge_width: 0.0,
edge_style: LineStyle::Solid,
};
for (bin, density) in histogram.density().iter().enumerate() {
if *density <= 0.0 {
continue;
}
let (low, high) = (histogram.edges[bin], histogram.edges[bin + 1]);
let (x, y, width, height) = match self.axis {
MarginalAxis::X => (low, 0.0, high - low, *density),
MarginalAxis::Y => (0.0, low, *density, high - low),
};
plot = plot.rect_styled(x, y, width, height, fill.clone());
}
let (values, density) = histogram.staircase();
let (x, y) = self.axis.place(values, density);
plot = plot
.line(&x, &y)
.color(self.color)
.line_width(1.0)
.into_plot();
}
if let Some(kde) = kde {
let (x, y) = self.axis.place(kde.x, kde.density);
plot = plot
.line(&x, &y)
.color(self.color)
.line_width(1.5)
.into_plot();
}
if self.rug {
let values = self.values.to_vec();
plot = plot
.rug(&values)
.axis(self.axis.rug_axis())
.color(self.color)
.into_plot();
}
let density_limit = density_max * Self::DENSITY_HEADROOM;
Some(match self.axis {
MarginalAxis::X => plot
.xlim(self.range.0, self.range.1)
.ylim(0.0, density_limit),
MarginalAxis::Y => plot
.xlim(0.0, density_limit)
.ylim(self.range.0, self.range.1),
})
}
}
pub(crate) fn resolved_color(color: Option<Color>) -> Color {
color.unwrap_or_else(|| Theme::default().get_color(0))
}
pub fn jointplot<X, Y>(x: &X, y: &Y, width: u32, height: u32) -> Result<SubplotFigure>
where
X: NumericData1D,
Y: NumericData1D,
{
jointplot_with(x, y, width, height, JointPlotConfig::default())
}
pub fn jointplot_with<X, Y>(
x: &X,
y: &Y,
width: u32,
height: u32,
config: JointPlotConfig,
) -> Result<SubplotFigure>
where
X: NumericData1D,
Y: NumericData1D,
{
let xs = x.try_collect_f64()?;
let ys = y.try_collect_f64()?;
if xs.len() != ys.len() {
return Err(PlottingError::DataLengthMismatch {
x_len: xs.len(),
y_len: ys.len(),
series_index: None,
});
}
if xs.is_empty() {
return Err(PlottingError::EmptyDataSet);
}
let color = resolved_color(config.color);
let x_range = padded_range(&xs);
let y_range = padded_range(&ys);
let layout = joint_plot_layout(config.marginal_ratio);
let mut assembled = figure(width, height)?.add_axes(
layout.main_bounds,
main_panel(&xs, &ys, x_range, y_range, &config, color)?,
)?;
let marginal = |values: &[f64], range, axis| {
Marginal {
values,
range,
axis,
hist: config.marginal_hist,
kde: config.marginal_kde,
rug: config.rugplot,
bins: config.bins,
color,
}
.axes()
};
if let Some(top) = marginal(&xs, x_range, MarginalAxis::X) {
assembled = assembled.add_axes(layout.x_marginal_bounds, top)?;
}
if let Some(right) = marginal(&ys, y_range, MarginalAxis::Y) {
assembled = assembled.add_axes(layout.y_marginal_bounds, right)?;
}
Ok(assembled)
}
fn main_panel<D: NumericData1D>(
xs: &D,
ys: &D,
x_range: (f64, f64),
y_range: (f64, f64),
config: &JointPlotConfig,
color: Color,
) -> Result<Plot> {
let drawn: Plot = match config.kind {
JointKind::Scatter => panel()
.scatter(xs, ys)
.marker_size(config.scatter_size)
.alpha(config.scatter_alpha)
.color(color)
.into_plot(),
JointKind::Hex => panel().hexbin(xs, ys).into_plot(),
unsupported => {
return Err(PlottingError::InvalidInput(format!(
"JointKind::{unsupported:?} has no renderer yet, so a joint plot \
cannot draw it. Use JointKind::Scatter or JointKind::Hex."
)));
}
};
Ok(drawn.xlim(x_range.0, x_range.1).ylim(y_range.0, y_range.1))
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> (Vec<f64>, Vec<f64>) {
let x: Vec<f64> = (0..120).map(|i| (i as f64) * 0.1).collect();
let y: Vec<f64> = x.iter().map(|v| (v * 0.7).sin() * 3.0).collect();
(x, y)
}
#[test]
fn test_joint_plot_layout() {
let layout = joint_plot_layout(0.2);
assert!(layout.main_bounds.width > 0.5);
assert!(layout.main_bounds.height > 0.5);
assert!(layout.x_marginal_bounds.height < 0.3);
assert!(layout.y_marginal_bounds.width < 0.3);
}
#[test]
fn the_three_panels_line_up_and_stay_inside_the_figure() {
let layout = joint_plot_layout(0.2);
for rect in [
layout.main_bounds,
layout.x_marginal_bounds,
layout.y_marginal_bounds,
] {
rect.validate().expect("every panel must be drawable");
}
assert_eq!(layout.main_bounds.x, layout.x_marginal_bounds.x);
assert_eq!(layout.main_bounds.width, layout.x_marginal_bounds.width);
assert_eq!(layout.main_bounds.y, layout.y_marginal_bounds.y);
assert_eq!(layout.main_bounds.height, layout.y_marginal_bounds.height);
}
#[test]
fn test_marginal_histogram() {
let data = vec![1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0];
let hist = compute_marginal_histogram(&data, 3);
assert_eq!(hist.counts.len(), 3);
assert_eq!(hist.edges.len(), 4);
assert_eq!(hist.centers.len(), 3);
let total: usize = hist.counts.iter().sum();
assert_eq!(total, 7);
}
#[test]
fn test_marginal_histogram_empty() {
let data: Vec<f64> = vec![];
let hist = compute_marginal_histogram(&data, 10);
assert!(hist.edges.is_empty());
assert!(hist.counts.is_empty());
}
#[test]
fn marginal_histogram_density_integrates_to_one() {
let data: Vec<f64> = (0..100).map(|i| i as f64 * 0.25).collect();
let hist = compute_marginal_histogram(&data, 10);
let bin_width = hist.edges[1] - hist.edges[0];
let mass: f64 = hist.density().iter().map(|d| d * bin_width).sum();
assert!(
(mass - 1.0).abs() < 1e-9,
"a density-normalised histogram must integrate to 1, got {mass}"
);
}
#[test]
fn the_staircase_starts_and_ends_on_the_baseline() {
let data = vec![0.0, 1.0, 1.0, 2.0];
let hist = compute_marginal_histogram(&data, 2);
let (values, density) = hist.staircase();
assert_eq!(values.len(), density.len());
assert_eq!(values.len(), 2 * (hist.counts.len() + 1));
assert_eq!(density.first(), Some(&0.0));
assert_eq!(density.last(), Some(&0.0));
assert_eq!(values.first(), hist.edges.first());
assert_eq!(values.last(), hist.edges.last());
}
#[test]
fn a_marginal_places_the_same_geometry_on_whichever_axis_it_runs() {
let (x, _) = sample();
let spec = |axis| Marginal {
values: &x,
range: padded_range(&x),
axis,
hist: true,
kde: false,
rug: false,
bins: 12,
color: Color::from_rgb(1, 2, 3),
};
assert!(spec(MarginalAxis::X).axes().is_some());
assert!(spec(MarginalAxis::Y).axes().is_some());
let values = vec![1.0, 2.0];
let density = vec![10.0, 20.0];
assert_eq!(
MarginalAxis::X.place(values.clone(), density.clone()),
(values.clone(), density.clone())
);
assert_eq!(
MarginalAxis::Y.place(values.clone(), density.clone()),
(density, values)
);
}
#[test]
fn a_marginal_with_nothing_switched_on_is_no_panel_at_all() {
let (x, _) = sample();
let blank = Marginal {
values: &x,
range: padded_range(&x),
axis: MarginalAxis::X,
hist: false,
kde: false,
rug: false,
bins: 12,
color: Color::from_rgb(1, 2, 3),
};
assert!(blank.axes().is_none());
assert!(
Marginal { rug: true, ..blank }.axes().is_some(),
"rugplot alone must still produce a panel"
);
let no_observations: &[f64] = &[];
assert!(
Marginal {
values: no_observations,
hist: true,
..blank
}
.axes()
.is_none()
);
}
#[test]
fn jointplot_assembles_a_main_panel_and_two_marginals() {
let (x, y) = sample();
let figure = jointplot(&x, &y, 600, 600).unwrap();
assert_eq!(figure.axes_count(), 3);
assert_eq!(figure.subplot_count(), 0);
assert_eq!(figure.grid_spec().total_subplots(), 0);
}
#[test]
fn switching_the_marginals_off_drops_their_panels() {
let (x, y) = sample();
let config = JointPlotConfig::new()
.marginal_hist(false)
.marginal_kde(false);
let figure = jointplot_with(&x, &y, 600, 600, config).unwrap();
assert_eq!(
figure.axes_count(),
1,
"with no histogram, no KDE and no rug there is nothing to draw in a marginal"
);
}
#[test]
fn rugplot_is_no_longer_inert() {
let (x, y) = sample();
let quiet = JointPlotConfig::new()
.marginal_hist(false)
.marginal_kde(false);
let loud = quiet.clone().rugplot(true);
assert_eq!(
jointplot_with(&x, &y, 400, 400, quiet)
.unwrap()
.axes_count(),
1
);
assert_eq!(
jointplot_with(&x, &y, 400, 400, loud).unwrap().axes_count(),
3,
"`rugplot(true)` must bring both marginal panels back"
);
}
#[test]
fn jointplot_rejects_input_it_cannot_draw() {
let (x, y) = sample();
assert!(matches!(
jointplot(&x, &y[..10].to_vec(), 400, 400),
Err(PlottingError::DataLengthMismatch { .. })
));
assert!(matches!(
jointplot(&Vec::<f64>::new(), &Vec::<f64>::new(), 400, 400),
Err(PlottingError::EmptyDataSet)
));
for kind in [JointKind::Reg, JointKind::Kde, JointKind::Resid] {
let err = jointplot_with(&x, &y, 400, 400, JointPlotConfig::new().kind(kind))
.expect_err("an unrendered kind must be reported, not substituted");
assert!(matches!(err, PlottingError::InvalidInput(_)), "{kind:?}");
}
}
#[test]
fn jointplot_renders_all_three_panels() {
fn ink_rows(image: &image::RgbaImage) -> (usize, usize) {
let mut top = 0;
let mut bottom = 0;
for (_, y, pixel) in image.enumerate_pixels() {
if pixel.0[..3].iter().all(|channel| *channel > 245) {
continue;
}
if y < image.height() / 4 {
top += 1;
} else {
bottom += 1;
}
}
(top, bottom)
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("jointplot.png");
let (x, y) = sample();
jointplot_with(
&x,
&y,
600,
600,
JointPlotConfig::new().rugplot(true).bins(15),
)
.unwrap()
.save(&path)
.unwrap();
let image = image::open(&path).unwrap().to_rgba8();
assert_eq!(image.dimensions(), (600, 600));
let (top, bottom) = ink_rows(&image);
assert!(top > 200, "the top marginal must draw something, ink={top}");
assert!(
bottom > 2000,
"the main and right panels must draw something, ink={bottom}"
);
}
}