diff --git a/docs/book/src/recipes/basic_charts/line_charts.md b/docs/book/src/recipes/basic_charts/line_charts.md index 47911eb6..0162bb6d 100644 --- a/docs/book/src/recipes/basic_charts/line_charts.md +++ b/docs/book/src/recipes/basic_charts/line_charts.md @@ -56,4 +56,13 @@ The `to_inline_html` method is used to produce the html plot displayed in this p {{#include ../../../../../examples/basic_charts/src/main.rs:filled_lines}} ``` -{{#include ../../../../../examples/basic_charts/output/inline_filled_lines.html}} \ No newline at end of file +{{#include ../../../../../examples/basic_charts/output/inline_filled_lines.html}} + +## Setting Lower or Upper Bounds on Axis +This example demonstrates how to set partial axis ranges using both the new `AxisRange` API and the backward-compatible vector syntax. The x-axis uses both the new `AxisRange::upper()` method and the traditional `vec![None, Some(value)]` syntax to set only an upper bound, while the y-axis uses only the `vec![Some(value), None]` syntax to set a lower bound. + +```rust,no_run +{{#include ../../../../../examples/basic_charts/src/main.rs:set_lower_or_upper_bound_on_axis}} +``` + +{{#include ../../../../../examples/basic_charts/output/inline_set_lower_or_upper_bound_on_axis.html}} \ No newline at end of file diff --git a/examples/basic_charts/Cargo.toml b/examples/basic_charts/Cargo.toml index 78118001..5290eb31 100644 --- a/examples/basic_charts/Cargo.toml +++ b/examples/basic_charts/Cargo.toml @@ -10,3 +10,4 @@ plotly = { path = "../../plotly" } plotly_utils = { path = "../plotly_utils" } rand = "0.9" rand_distr = "0.5" +csv = "1.3" diff --git a/examples/basic_charts/assets/iris.csv b/examples/basic_charts/assets/iris.csv new file mode 100644 index 00000000..20bd6ee5 --- /dev/null +++ b/examples/basic_charts/assets/iris.csv @@ -0,0 +1,151 @@ +sepal_length,sepal_width,petal_length,petal_width,species +5.1,3.5,1.4,0.2,setosa +4.9,3.0,1.4,0.2,setosa +4.7,3.2,1.3,0.2,setosa +4.6,3.1,1.5,0.2,setosa +5.0,3.6,1.4,0.2,setosa +5.4,3.9,1.7,0.4,setosa +4.6,3.4,1.4,0.3,setosa +5.0,3.4,1.5,0.2,setosa +4.4,2.9,1.4,0.2,setosa +4.9,3.1,1.5,0.1,setosa +5.4,3.7,1.5,0.2,setosa +4.8,3.4,1.6,0.2,setosa +4.8,3.0,1.4,0.1,setosa +4.3,3.0,1.1,0.1,setosa +5.8,4.0,1.2,0.2,setosa +5.7,4.4,1.5,0.4,setosa +5.4,3.9,1.3,0.4,setosa +5.1,3.5,1.4,0.3,setosa +5.7,3.8,1.7,0.3,setosa +5.1,3.8,1.5,0.3,setosa +5.4,3.4,1.7,0.2,setosa +5.1,3.7,1.5,0.4,setosa +4.6,3.6,1.0,0.2,setosa +5.1,3.3,1.7,0.5,setosa +4.8,3.4,1.9,0.2,setosa +5.0,3.0,1.6,0.2,setosa +5.0,3.4,1.6,0.4,setosa +5.2,3.5,1.5,0.2,setosa +5.2,3.4,1.4,0.2,setosa +4.7,3.2,1.6,0.2,setosa +4.8,3.1,1.6,0.2,setosa +5.4,3.4,1.5,0.4,setosa +5.2,4.1,1.5,0.1,setosa +5.5,4.2,1.4,0.2,setosa +4.9,3.1,1.5,0.2,setosa +5.0,3.2,1.2,0.2,setosa +5.5,3.5,1.3,0.2,setosa +4.9,3.6,1.4,0.1,setosa +4.4,3.0,1.3,0.2,setosa +5.1,3.4,1.5,0.2,setosa +5.0,3.5,1.3,0.3,setosa +4.5,2.3,1.3,0.3,setosa +4.4,3.2,1.3,0.2,setosa +5.0,3.5,1.6,0.6,setosa +5.1,3.8,1.9,0.4,setosa +4.8,3.0,1.4,0.3,setosa +5.1,3.8,1.6,0.2,setosa +4.6,3.2,1.4,0.2,setosa +5.3,3.7,1.5,0.2,setosa +5.0,3.3,1.4,0.2,setosa +7.0,3.2,4.7,1.4,versicolor +6.4,3.2,4.5,1.5,versicolor +6.9,3.1,4.9,1.5,versicolor +5.5,2.3,4.0,1.3,versicolor +6.5,2.8,4.6,1.5,versicolor +5.7,2.8,4.5,1.3,versicolor +6.3,3.3,4.7,1.6,versicolor +4.9,2.4,3.3,1.0,versicolor +6.6,2.9,4.6,1.3,versicolor +5.2,2.7,3.9,1.4,versicolor +5.0,2.0,3.5,1.0,versicolor +5.9,3.0,4.2,1.5,versicolor +6.0,2.2,4.0,1.0,versicolor +6.1,2.9,4.7,1.4,versicolor +5.6,2.9,3.6,1.3,versicolor +6.7,3.1,4.4,1.4,versicolor +5.6,3.0,4.5,1.5,versicolor +5.8,2.7,4.1,1.0,versicolor +6.2,2.2,4.5,1.5,versicolor +5.6,2.5,3.9,1.1,versicolor +5.9,3.2,4.8,1.8,versicolor +6.1,2.8,4.0,1.3,versicolor +6.3,2.5,4.9,1.5,versicolor +6.1,2.8,4.7,1.2,versicolor +6.4,2.9,4.3,1.3,versicolor +6.6,3.0,4.4,1.4,versicolor +6.8,2.8,4.8,1.4,versicolor +6.7,3.0,5.0,1.7,versicolor +6.0,2.9,4.5,1.5,versicolor +5.7,2.6,3.5,1.0,versicolor +5.5,2.4,3.8,1.1,versicolor +5.5,2.4,3.7,1.0,versicolor +5.8,2.7,3.9,1.2,versicolor +6.0,2.7,5.1,1.6,versicolor +5.4,3.0,4.5,1.5,versicolor +6.0,3.4,4.5,1.6,versicolor +6.7,3.1,4.7,1.5,versicolor +6.3,2.3,4.4,1.3,versicolor +5.6,3.0,4.1,1.3,versicolor +5.5,2.5,4.0,1.3,versicolor +5.5,2.6,4.4,1.2,versicolor +6.1,3.0,4.6,1.4,versicolor +5.8,2.6,4.0,1.2,versicolor +5.0,2.3,3.3,1.0,versicolor +5.6,2.7,4.2,1.3,versicolor +5.7,3.0,4.2,1.2,versicolor +5.7,2.9,4.2,1.3,versicolor +6.2,2.9,4.3,1.3,versicolor +5.1,2.5,3.0,1.1,versicolor +5.7,2.8,4.1,1.3,versicolor +6.3,3.3,6.0,2.5,virginica +5.8,2.7,5.1,1.9,virginica +7.1,3.0,5.9,2.1,virginica +6.3,2.9,5.6,1.8,virginica +6.5,3.0,5.8,2.2,virginica +7.6,3.0,6.6,2.1,virginica +4.9,2.5,4.5,1.7,virginica +7.3,2.9,6.3,1.8,virginica +6.7,2.5,5.8,1.8,virginica +7.2,3.6,6.1,2.5,virginica +6.5,3.2,5.1,2.0,virginica +6.4,2.7,5.3,1.9,virginica +6.8,3.0,5.5,2.1,virginica +5.7,2.5,5.0,2.0,virginica +5.8,2.8,5.1,2.4,virginica +6.4,3.2,5.3,2.3,virginica +6.5,3.0,5.5,1.8,virginica +7.7,3.8,6.7,2.2,virginica +7.7,2.6,6.9,2.3,virginica +6.0,2.2,5.0,1.5,virginica +6.9,3.2,5.7,2.3,virginica +5.6,2.8,4.9,2.0,virginica +7.7,2.8,6.7,2.0,virginica +6.3,2.7,4.9,1.8,virginica +6.7,3.3,5.7,2.1,virginica +7.2,3.2,6.0,1.8,virginica +6.2,2.8,4.8,1.8,virginica +6.1,3.0,4.9,1.8,virginica +6.4,2.8,5.6,2.1,virginica +7.2,3.0,5.8,1.6,virginica +7.4,2.8,6.1,1.9,virginica +7.9,3.8,6.4,2.0,virginica +6.4,2.8,5.6,2.2,virginica +6.3,2.8,5.1,1.5,virginica +6.1,2.6,5.6,1.4,virginica +7.7,3.0,6.1,2.3,virginica +6.3,3.4,5.6,2.4,virginica +6.4,3.1,5.5,1.8,virginica +6.0,3.0,4.8,1.8,virginica +6.9,3.1,5.4,2.1,virginica +6.7,3.1,5.6,2.4,virginica +6.9,3.1,5.1,2.3,virginica +5.8,2.7,5.1,1.9,virginica +6.8,3.2,5.9,2.3,virginica +6.7,3.3,5.7,2.5,virginica +6.7,3.0,5.2,2.3,virginica +6.3,2.5,5.0,1.9,virginica +6.5,3.0,5.2,2.0,virginica +6.2,3.4,5.4,2.3,virginica +5.9,3.0,5.1,1.8,virginica diff --git a/examples/basic_charts/src/main.rs b/examples/basic_charts/src/main.rs index 3270dea1..89ba5c36 100644 --- a/examples/basic_charts/src/main.rs +++ b/examples/basic_charts/src/main.rs @@ -8,8 +8,8 @@ use plotly::{ Marker, Mode, Orientation, Pattern, PatternShape, }, layout::{ - Annotation, Axis, BarMode, CategoryOrder, Layout, LayoutGrid, Legend, TicksDirection, - TraceOrder, + Annotation, Axis, AxisRange, BarMode, CategoryOrder, Layout, LayoutGrid, Legend, + TicksDirection, TraceOrder, }, sankey::{Line as SankeyLine, Link, Node}, traces::table::{Cells, Header}, @@ -997,6 +997,119 @@ fn grouped_donout_pie_charts(show: bool, file_name: &str) { } // ANCHOR_END: grouped_donout_pie_charts +// ANCHOR: set_lower_or_upper_bound_on_axis +fn set_lower_or_upper_bound_on_axis(show: bool, file_name: &str) { + use std::fs::File; + use std::io::BufReader; + + // Read the iris dataset + let file = File::open("assets/iris.csv").expect("Failed to open iris.csv"); + let reader = BufReader::new(file); + let mut csv_reader = csv::Reader::from_reader(reader); + + // Parse the data + let mut sepal_width = Vec::new(); + let mut sepal_length = Vec::new(); + let mut species = Vec::new(); + + for result in csv_reader.records() { + let record = result.expect("Failed to read CSV record"); + sepal_width.push(record[1].parse::().unwrap()); + sepal_length.push(record[0].parse::().unwrap()); + species.push(record[4].to_string()); + } + + // Create separate traces for each species + let mut traces = Vec::new(); + let unique_species: Vec = species + .iter() + .cloned() + .collect::>() + .into_iter() + .collect(); + + for (i, species_name) in unique_species.iter().enumerate() { + let mut x = Vec::new(); + let mut y = Vec::new(); + + for (j, s) in species.iter().enumerate() { + if s == species_name { + x.push(sepal_width[j]); + y.push(sepal_length[j]); + } + } + + let trace = Scatter::new(x, y) + .name(species_name) + .mode(plotly::common::Mode::Markers) + .x_axis(format!("x{}", i + 1)) + .y_axis(format!("y{}", i + 1)); + traces.push(trace); + } + + let mut plot = Plot::new(); + for trace in traces { + plot.add_trace(trace); + } + + // Create layout with subplots + let mut layout = Layout::new() + .title("Iris Dataset - Subplots by Species") + .grid( + LayoutGrid::new() + .rows(1) + .columns(3) + .pattern(plotly::layout::GridPattern::Independent), + ); + + // Set x-axis range for all subplots: [None, 4.5] + layout = layout + .x_axis( + Axis::new() + .title("sepal_width") + // Can be set using a vec! of two optional values + .range(vec![None, Some(4.5)]), + ) + .x_axis2( + Axis::new() + .title("sepal_width") + // Or can be set using AxisRange::upper(4.5) + .range(AxisRange::upper(4.5)), + ) + .x_axis3( + Axis::new() + .title("sepal_width") + // Or can be set using AxisRange::upper(4.5) + .range(AxisRange::upper(4.5)), + ); + + // Set y-axis range for all subplots: [3, None] + layout = layout + .y_axis( + Axis::new() + .title("sepal_length") + .range(vec![Some(3.0), None]), + ) + .y_axis2( + Axis::new() + .title("sepal_length") + .range(vec![Some(3.0), None]), + ) + .y_axis3( + Axis::new() + .title("sepal_length") + .range(vec![Some(3.0), None]), + ); + + plot.set_layout(layout); + + let path = write_example_to_html(&plot, file_name); + if show { + plot.show_html(path); + } +} +// ANCHOR_END: set_lower_or_upper_bound_on_axis + fn main() { // Change false to true on any of these lines to display the example. @@ -1013,7 +1126,6 @@ fn main() { categories_scatter_chart(false, "categories_scatter_chart"); // Line Charts - adding_names_to_line_and_scatter_plot(false, "adding_names_to_line_and_scatter_plot"); line_and_scatter_styling(false, "line_and_scatter_styling"); styling_line_plot(false, "styling_line_plot"); @@ -1041,4 +1153,7 @@ fn main() { pie_chart_text_control(false, "pie_chart_text_control"); grouped_donout_pie_charts(false, "grouped_donout_pie_charts"); + + // Set Lower or Upper Bound on Axis + set_lower_or_upper_bound_on_axis(false, "set_lower_or_upper_bound_on_axis"); } diff --git a/plotly/src/layout/axis.rs b/plotly/src/layout/axis.rs index 5b59d074..f1a5b341 100644 --- a/plotly/src/layout/axis.rs +++ b/plotly/src/layout/axis.rs @@ -7,7 +7,108 @@ use crate::common::{ TickFormatStop, TickMode, Title, }; use crate::layout::RangeBreak; -use crate::private::NumOrStringCollection; +use crate::private::{NumOrString, NumOrStringCollection}; + +#[derive(Serialize, Clone, Debug, PartialEq)] +pub struct AxisRange(pub Vec>); + +impl AxisRange { + /// Create a new axis range with both upper and lower values (min, max) + pub fn new(min: impl Into, max: impl Into) -> Self { + Self(vec![Some(min.into()), Some(max.into())]) + } + + /// Create a range with only a lower bound, the upper bound is not set: + /// (min, None) + pub fn lower(min: impl Into) -> Self { + Self(vec![Some(min.into()), None]) + } + + /// Create a range with only an upper bound, the lower bound is not set: + /// (None, max) + pub fn upper(max: impl Into) -> Self { + Self(vec![None, Some(max.into())]) + } +} + +impl From>> for AxisRange { + fn from(values: Vec>) -> Self { + Self(values) + } +} + +impl From> for AxisRange { + fn from(values: Vec) -> Self { + Self(values.into_iter().map(Some).collect()) + } +} + +impl From> for AxisRange { + fn from(values: Vec) -> Self { + Self( + values + .into_iter() + .map(|v| Some(NumOrString::F(v))) + .collect(), + ) + } +} + +impl From> for AxisRange { + fn from(values: Vec) -> Self { + Self( + values + .into_iter() + .map(|v| Some(NumOrString::I(v))) + .collect(), + ) + } +} + +impl From> for AxisRange { + fn from(values: Vec) -> Self { + Self( + values + .into_iter() + .map(|v| Some(NumOrString::S(v))) + .collect(), + ) + } +} + +impl From> for AxisRange { + fn from(values: Vec<&str>) -> Self { + Self( + values + .into_iter() + .map(|v| Some(NumOrString::S(v.to_string()))) + .collect(), + ) + } +} + +impl From>> for AxisRange { + fn from(values: Vec>) -> Self { + Self(values.into_iter().map(|v| v.map(NumOrString::F)).collect()) + } +} + +impl From>> for AxisRange { + fn from(values: Vec>) -> Self { + Self(values.into_iter().map(|v| v.map(NumOrString::I)).collect()) + } +} + +impl From>> for AxisRange { + fn from(values: Vec>) -> Self { + Self( + values + .into_iter() + .map(|v| v.map(|s| NumOrString::S(s.to_string()))) + .collect(), + ) + } +} #[derive(Serialize, Debug, Clone)] #[serde(rename_all = "lowercase")] @@ -309,7 +410,9 @@ pub struct Axis { range_breaks: Option>, #[serde(rename = "rangemode")] range_mode: Option, - range: Option, + /// Set the range of the axis. + #[field_setter(skip)] + range: Option, #[serde(rename = "fixedrange")] fixed_range: Option, constrain: Option, @@ -441,6 +544,14 @@ impl Axis { self.domain = Some(domain.to_vec()); self } + + /// Set the range of the axis. This method accepts various types and + /// converts them to `AxisRange` while also keeping backward compatibility + /// with API prior to AxisRange introduction. + pub fn range(mut self, range: impl Into) -> Self { + self.range = Some(range.into()); + self + } } #[cfg(test)] @@ -845,4 +956,58 @@ mod tests { assert_eq!(to_value(axis).unwrap(), expected); } + + #[test] + fn serialize_axis_range_lower_only() { + let axis = Axis::new().range(AxisRange::lower(5.0)); + let expected = json!({ "range": [5.0, null] }); + assert_eq!(to_value(axis).unwrap(), expected); + + let axis = Axis::new().range(vec![Some(5.0), None]); + let expected = json!({ "range": [5.0, null] }); + assert_eq!(to_value(axis).unwrap(), expected); + } + + #[test] + fn serialize_axis_range_upper_only() { + let axis = Axis::new().range(AxisRange::upper(10.0)); + let expected = json!({ "range": [null, 10.0] }); + assert_eq!(to_value(axis).unwrap(), expected); + + let axis = Axis::new().range(vec![None, Some(10.0)]); + let expected = json!({ "range": [null, 10.0] }); + assert_eq!(to_value(axis).unwrap(), expected); + } + + #[test] + fn serialize_axis_range_both() { + let axis = Axis::new().range(AxisRange::new(1.0, 5.0)); + let expected = json!({ "range": [1.0, 5.0] }); + assert_eq!(to_value(axis).unwrap(), expected); + + let axis = Axis::new().range(vec![Some(1.0), Some(5.0)]); + let expected = json!({ "range": [1.0, 5.0] }); + assert_eq!(to_value(axis).unwrap(), expected); + + // Backward compatible range() setter version with both ends + let axis = Axis::new().range(vec![1.0, 5.0]); + let expected = json!({ "range": [1.0, 5.0] }); + assert_eq!(to_value(axis).unwrap(), expected); + } + + #[test] + fn serialize_axis_range_with_strings() { + let axis = Axis::new().range(AxisRange::lower("2020-01-01")); + let expected = json!({ "range": ["2020-01-01", null] }); + assert_eq!(to_value(axis).unwrap(), expected); + + let axis = Axis::new().range(vec![Some("2020-01-01"), None]); + let expected = json!({ "range": ["2020-01-01", null] }); + assert_eq!(to_value(axis).unwrap(), expected); + + // Backward compatible range() setter version with both ends + let axis = Axis::new().range(vec!["2020-01-01", "2020-01-02"]); + let expected = json!({ "range": ["2020-01-01", "2020-01-02"] }); + assert_eq!(to_value(axis).unwrap(), expected); + } } diff --git a/plotly/src/layout/mod.rs b/plotly/src/layout/mod.rs index 7701f83f..8c32a5e7 100644 --- a/plotly/src/layout/mod.rs +++ b/plotly/src/layout/mod.rs @@ -25,9 +25,9 @@ mod shape; // Re-export layout sub-module types pub use self::annotation::{Annotation, ArrowSide, ClickToShow}; pub use self::axis::{ - ArrayShow, Axis, AxisConstrain, AxisType, CategoryOrder, ColorAxis, ConstrainDirection, - RangeMode, RangeSelector, RangeSlider, RangeSliderYAxis, SelectorButton, SelectorStep, - SliderRangeMode, SpikeMode, SpikeSnap, StepMode, TicksDirection, TicksPosition, + ArrayShow, Axis, AxisConstrain, AxisRange, AxisType, CategoryOrder, ColorAxis, + ConstrainDirection, RangeMode, RangeSelector, RangeSlider, RangeSliderYAxis, SelectorButton, + SelectorStep, SliderRangeMode, SpikeMode, SpikeSnap, StepMode, TicksDirection, TicksPosition, }; pub use self::geo::LayoutGeo; pub use self::grid::{GridDomain, GridPattern, GridXSide, GridYSide, LayoutGrid, RowOrder};