use crate::color::Color;
use crate::core::{Bounds, Canvas, DataSeries, Drawable};
use crate::error::Result;
use crate::legend::LegendEntry;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BarOrientation {
Vertical,
Horizontal,
}
pub struct BarPlot {
data: Box<dyn DataSeries>,
color: Color,
orientation: BarOrientation,
bar_width: f64,
label: Option<String>,
}
impl BarPlot {
#[must_use]
pub fn new(data: impl DataSeries + 'static) -> Self {
Self {
data: Box::new(data),
color: Color::from_hex("#3498db").unwrap_or(Color::BLUE),
orientation: BarOrientation::Vertical,
bar_width: 0.8,
label: None,
}
}
#[must_use]
pub fn color(mut self, color: Color) -> Self {
self.color = color;
self
}
#[must_use]
pub fn orientation(mut self, orientation: BarOrientation) -> Self {
self.orientation = orientation;
self
}
#[must_use]
pub fn bar_width(mut self, width: f64) -> Self {
self.bar_width = width.clamp(0.1, 2.0);
self
}
#[must_use]
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
#[must_use]
pub fn bounds(&self) -> Option<Bounds> {
if self.data.is_empty() {
return None;
}
let points: Vec<_> = self.data.points().collect();
let mut bounds = Bounds::from_points(points);
match self.orientation {
BarOrientation::Vertical => {
if bounds.y_min > 0.0 {
bounds.y_min = 0.0;
}
if bounds.y_max < 0.0 {
bounds.y_max = 0.0;
}
}
BarOrientation::Horizontal => {
if bounds.x_min > 0.0 {
bounds.x_min = 0.0;
}
if bounds.x_max < 0.0 {
bounds.x_max = 0.0;
}
}
}
Some(bounds)
}
#[must_use]
pub fn legend_entry(&self) -> Option<LegendEntry> {
self.label
.as_ref()
.map(|label| LegendEntry::new(label).color(self.color).line_width(2.0))
}
fn draw_bar(&self, canvas: &mut dyn Canvas, x1: f32, y1: f32, x2: f32, y2: f32) -> Result<()> {
let color = self.color.to_rgba();
let left = x1.min(x2);
let right = x1.max(x2);
let top = y1.min(y2);
let bottom = y1.max(y2);
let height = (bottom - top).abs();
let steps = (height.ceil() as i32).max(1);
for i in 0..steps {
let y = top + i as f32;
if y <= bottom {
canvas.draw_line_pixels(left, y, right, y, &color, 1.0)?;
}
}
Ok(())
}
}
impl Drawable for BarPlot {
fn draw(&self, canvas: &mut dyn Canvas) -> Result<()> {
let bounds = canvas.bounds();
let (width, height) = canvas.dimensions();
let margin_left = 60.0;
let margin_right = 20.0;
let margin_top = 40.0;
let margin_bottom = 40.0;
let pixel_min_x = margin_left;
let pixel_max_x = width as f32 - margin_right;
let pixel_min_y = margin_top;
let pixel_max_y = height as f32 - margin_bottom;
match self.orientation {
BarOrientation::Vertical => {
let zero_y =
value_to_pixel_y(0.0, bounds.y_min, bounds.y_max, pixel_min_y, pixel_max_y);
for point in self.data.points() {
let x_pixel = value_to_pixel_x(
point.x,
bounds.x_min,
bounds.x_max,
pixel_min_x,
pixel_max_x,
);
let y_pixel = value_to_pixel_y(
point.y,
bounds.y_min,
bounds.y_max,
pixel_min_y,
pixel_max_y,
);
let spacing = (bounds.x_max - bounds.x_min) / (self.data.len() as f64).max(1.0);
let bar_width_pixels = (spacing * self.bar_width)
* f64::from(pixel_max_x - pixel_min_x)
/ (bounds.x_max - bounds.x_min);
let half_width = (bar_width_pixels / 2.0) as f32;
self.draw_bar(
canvas,
x_pixel - half_width,
zero_y,
x_pixel + half_width,
y_pixel,
)?;
}
}
BarOrientation::Horizontal => {
let zero_x =
value_to_pixel_x(0.0, bounds.x_min, bounds.x_max, pixel_min_x, pixel_max_x);
for point in self.data.points() {
let x_pixel = value_to_pixel_x(
point.x,
bounds.x_min,
bounds.x_max,
pixel_min_x,
pixel_max_x,
);
let y_pixel = value_to_pixel_y(
point.y,
bounds.y_min,
bounds.y_max,
pixel_min_y,
pixel_max_y,
);
let spacing = (bounds.y_max - bounds.y_min) / (self.data.len() as f64).max(1.0);
let bar_height_pixels = (spacing * self.bar_width)
* f64::from(pixel_max_y - pixel_min_y)
/ (bounds.y_max - bounds.y_min);
let half_height = (bar_height_pixels / 2.0) as f32;
self.draw_bar(
canvas,
zero_x,
y_pixel - half_height,
x_pixel,
y_pixel + half_height,
)?;
}
}
}
Ok(())
}
}
#[allow(clippy::cast_precision_loss)]
fn value_to_pixel_x(value: f64, min: f64, max: f64, pixel_min: f32, pixel_max: f32) -> f32 {
let range = max - min;
let pixel_range = pixel_max - pixel_min;
let normalized = (value - min) / range;
pixel_min + normalized as f32 * pixel_range
}
#[allow(clippy::cast_precision_loss)]
fn value_to_pixel_y(value: f64, min: f64, max: f64, pixel_min: f32, pixel_max: f32) -> f32 {
let range = max - min;
let pixel_range = pixel_max - pixel_min;
let normalized = (value - min) / range;
pixel_max - normalized as f32 * pixel_range }
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Series;
#[test]
fn test_bar_creation() {
let data = Series::from_tuples(&[(1.0, 10.0), (2.0, 20.0), (3.0, 15.0)]);
let bar = BarPlot::new(data).color(Color::RED).bar_width(0.5);
assert_eq!(bar.bar_width, 0.5);
assert_eq!(bar.color, Color::RED);
}
#[test]
fn test_bar_bounds_includes_zero() {
let data = Series::from_tuples(&[(1.0, 5.0), (2.0, 10.0)]);
let bar = BarPlot::new(data);
let bounds = bar.bounds().unwrap();
assert_eq!(bounds.y_min, 0.0); assert_eq!(bounds.y_max, 10.0);
}
#[test]
fn test_horizontal_orientation() {
let data = Series::from_tuples(&[(1.0, 10.0), (2.0, 20.0)]);
let bar = BarPlot::new(data).orientation(BarOrientation::Horizontal);
assert_eq!(bar.orientation, BarOrientation::Horizontal);
}
#[test]
fn test_bar_width_clamping() {
let data = Series::from_tuples(&[(1.0, 10.0)]);
let bar = BarPlot::new(data).bar_width(5.0);
assert_eq!(bar.bar_width, 2.0); }
}