diff --git a/CHANGELOG.md b/CHANGELOG.md index bc20c273..723d1e14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.0] - 2024-10-29 +### Added +- [[#239](https://github.com/plotly/plotly.rs/pull/239)] Add Categorical Axis Ordering and Axis Category Array. + +### Fixed +- [[#237](https://github.com/plotly/plotly.rs/issues/237)] Add Categorical Axis ordering. + ## [0.10.0] - 2024-09-16 ### Added - [[#231](https://github.com/plotly/plotly.rs/pull/231)] Added new `plotly_embed_js` feature to reduce binary sizes by not embedding `plotly.min.js` in the library unless explicitly enabled via the feature flag. Deprecates `use_local_plotly` in favor of explicit opt-in via the feature flag and introduce method `use_cdn_plotly` to allow users to use CDN version even behind the `plotly_embed_js` feature flag. diff --git a/examples/basic_charts/src/main.rs b/examples/basic_charts/src/main.rs index 43d30911..3e91f5d9 100644 --- a/examples/basic_charts/src/main.rs +++ b/examples/basic_charts/src/main.rs @@ -7,7 +7,7 @@ use plotly::{ ColorScale, ColorScalePalette, DashType, Fill, Font, Line, LineShape, Marker, Mode, Orientation, }, - layout::{Axis, BarMode, Layout, Legend, TicksDirection, TraceOrder}, + layout::{Axis, BarMode, CategoryOrder, Layout, Legend, TicksDirection, TraceOrder}, sankey::{Line as SankeyLine, Link, Node}, traces::table::{Cells, Header}, Bar, Plot, Sankey, Scatter, ScatterPolar, Table, @@ -523,6 +523,49 @@ fn filled_lines() { plot.show(); } +/// Scatter plot showing y axis categories and category ordering. +fn categories_scatter_chart() { + // Categories are ordered on the y axis from bottom to top. + let categories = vec!["Unknown", "Off", "On"]; + + let x = vec![ + "2024-10-30T08:30:05.05Z", + "2024-10-30T08:35:05.05Z", + "2024-10-30T08:50:05.05Z", + "2024-10-30T08:50:20.05Z", + "2024-10-30T09:00:05.05Z", + "2024-10-30T09:05:05.05Z", + "2024-10-30T09:10:05.05Z", + "2024-10-30T09:10:20.05Z", + ]; + let y = vec![ + "On", + "Off", + "Unknown", + "Off", + "On", + "Off", + // Categories that aren't in the category_array follow the Trace order. + "NewCategory", + "Off", + ]; + + let trace = Scatter::new(x, y).line(Line::new().shape(LineShape::Hv)); + + let layout = Layout::new().y_axis( + Axis::new() + .category_order(CategoryOrder::Array) + .category_array(categories), + ); + + let mut plot = Plot::new(); + plot.add_trace(trace); + + plot.set_layout(layout); + + plot.show(); +} + // Bar Charts fn basic_bar_chart() { let animals = vec!["giraffes", "orangutans", "monkeys"]; @@ -567,6 +610,29 @@ fn stacked_bar_chart() { plot.show(); } +/// Graph a bar chart that orders the x axis categories by the total number +/// of animals in each category. +fn category_order_bar_chart() { + let animals1 = vec!["giraffes", "orangutans", "monkeys"]; + let trace1 = Bar::new(animals1, vec![10, 14, 23]).name("SF Zoo"); + + let animals2 = vec!["giraffes", "orangutans", "monkeys"]; + let trace2 = Bar::new(animals2, vec![12, 18, 29]).name("LA Zoo"); + + let layout = Layout::new() + .bar_mode(BarMode::Stack) + // Order the x axis categories so the category with the most animals + // appears first. + .x_axis(Axis::new().category_order(CategoryOrder::TotalDescending)); + + let mut plot = Plot::new(); + plot.add_trace(trace1); + plot.add_trace(trace2); + plot.set_layout(layout); + + plot.show(); +} + // Sankey Diagrams fn basic_sankey_diagram() { // https://plotly.com/javascript/sankey-diagram/#basic-sankey-diagram @@ -627,6 +693,7 @@ fn main() { // data_labels_on_the_plot(); // colored_and_styled_scatter_plot(); // large_data_sets(); + // categories_scatter_chart(); // Line Charts // adding_names_to_line_and_scatter_plot(); @@ -641,6 +708,7 @@ fn main() { // grouped_bar_chart(); // stacked_bar_chart(); // table_chart(); + // category_order_bar_chart(); // Sankey Diagrams // basic_sankey_diagram(); diff --git a/plotly/src/layout/mod.rs b/plotly/src/layout/mod.rs index 7a70c23e..c4c3c87a 100644 --- a/plotly/src/layout/mod.rs +++ b/plotly/src/layout/mod.rs @@ -418,10 +418,72 @@ pub enum SpikeSnap { HoveredData, } +#[derive(Serialize, Debug, Clone)] +pub enum CategoryOrder { + #[serde(rename = "trace")] + Trace, + #[serde(rename = "category ascending")] + CategoryAscending, + #[serde(rename = "category descending")] + CategoryDescending, + #[serde(rename = "array")] + Array, + #[serde(rename = "total ascending")] + TotalAscending, + #[serde(rename = "total descending")] + TotalDescending, + #[serde(rename = "min ascending")] + MinAscending, + #[serde(rename = "min descending")] + MinDescending, + #[serde(rename = "max ascending")] + MaxAscending, + #[serde(rename = "max descending")] + MaxDescending, + #[serde(rename = "sum ascending")] + SumAscending, + #[serde(rename = "sum descending")] + SumDescending, + #[serde(rename = "mean ascending")] + MeanAscending, + #[serde(rename = "mean descending")] + MeanDescending, + #[serde(rename = "geometric mean ascending")] + GeometricMeanAscending, + #[serde(rename = "geometric mean descending")] + GeometricMeanDescending, + #[serde(rename = "median ascending")] + MedianAscending, + #[serde(rename = "median descending")] + MedianDescending, +} + #[serde_with::skip_serializing_none] #[derive(Serialize, Debug, Clone, FieldSetter)] pub struct Axis { visible: Option, + /// Sets the order in which categories on this axis appear. Only has an + /// effect if `category_order` is set to [`CategoryOrder::Array`]. + /// Used with `category_order`. + #[serde(rename = "categoryarray")] + category_array: Option, + /// Specifies the ordering logic for the case of categorical variables. + /// By default, plotly uses [`CategoryOrder::Trace`], which specifies + /// the order that is present in the data supplied. Set `category_order` to + /// [`CategoryOrder::CategoryAscending`] or + /// [`CategoryOrder::CategoryDescending`] if order should be determined + /// by the alphanumerical order of the category names. Set `category_order` + /// to [`CategoryOrder::Array`] to derive the ordering from the attribute + /// `category_array`. If a category is not found in the `category_array` + /// array, the sorting behavior for that attribute will be identical to the + /// [`CategoryOrder::Trace`] mode. The unspecified categories will follow + /// the categories in `category_array`. Set `category_order` to + /// [`CategoryOrder::TotalAscending`] or + /// [`CategoryOrder::TotalDescending`] if order should be determined by the + /// numerical order of the values. Similarly, the order can be determined + /// by the min, max, sum, mean, geometric mean or median of all the values. + #[serde(rename = "categoryorder")] + category_order: Option, color: Option>, title: Option, #[field_setter(skip)] @@ -2341,6 +2403,29 @@ mod tests { assert_eq!(to_value(SpikeSnap::HoveredData).unwrap(), json!("hovered data")); } + #[test] + #[rustfmt::skip] + fn test_serialize_category_order() { + assert_eq!(to_value(CategoryOrder::Trace).unwrap(), json!("trace")); + assert_eq!(to_value(CategoryOrder::CategoryAscending).unwrap(), json!("category ascending")); + assert_eq!(to_value(CategoryOrder::CategoryDescending).unwrap(), json!("category descending")); + assert_eq!(to_value(CategoryOrder::Array).unwrap(), json!("array")); + assert_eq!(to_value(CategoryOrder::TotalAscending).unwrap(), json!("total ascending")); + assert_eq!(to_value(CategoryOrder::TotalDescending).unwrap(), json!("total descending")); + assert_eq!(to_value(CategoryOrder::MinAscending).unwrap(), json!("min ascending")); + assert_eq!(to_value(CategoryOrder::MinDescending).unwrap(), json!("min descending")); + assert_eq!(to_value(CategoryOrder::MaxAscending).unwrap(), json!("max ascending")); + assert_eq!(to_value(CategoryOrder::MaxDescending).unwrap(), json!("max descending")); + assert_eq!(to_value(CategoryOrder::SumAscending).unwrap(), json!("sum ascending")); + assert_eq!(to_value(CategoryOrder::SumDescending).unwrap(), json!("sum descending")); + assert_eq!(to_value(CategoryOrder::MeanAscending).unwrap(), json!("mean ascending")); + assert_eq!(to_value(CategoryOrder::MeanDescending).unwrap(), json!("mean descending")); + assert_eq!(to_value(CategoryOrder::GeometricMeanAscending).unwrap(), json!("geometric mean ascending")); + assert_eq!(to_value(CategoryOrder::GeometricMeanDescending).unwrap(), json!("geometric mean descending")); + assert_eq!(to_value(CategoryOrder::MedianAscending).unwrap(), json!("median ascending")); + assert_eq!(to_value(CategoryOrder::MedianDescending).unwrap(), json!("median descending")); + } + #[test] fn test_serialize_selector_button() { let selector_button = SelectorButton::new() @@ -2490,7 +2575,9 @@ mod tests { .position(0.6) .range_slider(RangeSlider::new()) .range_selector(RangeSelector::new()) - .calendar(Calendar::Coptic); + .calendar(Calendar::Coptic) + .category_order(CategoryOrder::Array) + .category_array(vec!["Category0", "Category1"]); let expected = json!({ "visible": false, @@ -2556,6 +2643,8 @@ mod tests { "rangeslider": {}, "rangeselector": {}, "calendar": "coptic", + "categoryorder": "array", + "categoryarray": ["Category0", "Category1"] }); assert_eq!(to_value(axis).unwrap(), expected);