diff --git a/CHANGELOG.md b/CHANGELOG.md index 480e84b9..9e79004b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a - [[#414](https://github.com/plotly/plotly.rs/issues/414)] Add `DensityMap` (MapLibre `map` subplot) trace type — density heatmaps with full color-scale and hover support - [[#417](https://github.com/plotly/plotly.rs/issues/417)] Add `ScatterMap` (MapLibre `map` subplot) trace type — the modern counterpart to `ScatterMapbox` - [[#418](https://github.com/plotly/plotly.rs/issues/418)] Add native point clustering to `ScatterMap` via a `Cluster` option +- Add `Splom` trace type (scatter-plot matrix) with `SplomDimension`/`SplomAxis`/`SplomDiagonal` config and `showupperhalf`/`showlowerhalf` controls +- Add `Parcats` trace type (parallel categories) with `ParcatsDimension`/`ParcatsLine` config and `ParcatsLineShape`/`ParcatsArrangement`/`ParcatsHoverOn`/`ParcatsSortPaths` enums ### Changed diff --git a/plotly/src/common/mod.rs b/plotly/src/common/mod.rs index db2fa08e..d54e2570 100644 --- a/plotly/src/common/mod.rs +++ b/plotly/src/common/mod.rs @@ -228,7 +228,9 @@ pub enum PlotType { Image, Mesh3D, Ohlc, + Parcats, Sankey, + Splom, Surface, DensityMapbox, DensityMap, diff --git a/plotly/src/lib.rs b/plotly/src/lib.rs index 7086da40..e4237d27 100644 --- a/plotly/src/lib.rs +++ b/plotly/src/lib.rs @@ -61,13 +61,14 @@ pub use plot::{Plot, Trace, Traces}; // Also provide easy access to modules which contain additional trace-specific types pub use traces::{ box_plot, choropleth, choropleth_map, contour, density_map, heat_map, histogram, image, mesh3d, - sankey, scatter, scatter3d, scatter_map, scatter_mapbox, sunburst, surface, treemap, violin, + parcats, sankey, scatter, scatter3d, scatter_map, scatter_mapbox, splom, sunburst, surface, + treemap, violin, }; // Bring the different trace types into the top-level scope pub use traces::{ Bar, BoxPlot, Candlestick, Choropleth, ChoroplethMap, Contour, DensityMap, DensityMapbox, - HeatMap, Histogram, Image, Mesh3D, Ohlc, Pie, Sankey, Scatter, Scatter3D, ScatterGeo, - ScatterMap, ScatterMapbox, ScatterPolar, Sunburst, Surface, Table, Treemap, Violin, + HeatMap, Histogram, Image, Mesh3D, Ohlc, Parcats, Pie, Sankey, Scatter, Scatter3D, ScatterGeo, + ScatterMap, ScatterMapbox, ScatterPolar, Splom, Sunburst, Surface, Table, Treemap, Violin, }; pub trait Restyle: serde::Serialize {} diff --git a/plotly/src/traces/mod.rs b/plotly/src/traces/mod.rs index 850dfe13..fb7de03a 100644 --- a/plotly/src/traces/mod.rs +++ b/plotly/src/traces/mod.rs @@ -13,6 +13,7 @@ pub mod histogram; pub mod image; pub mod mesh3d; mod ohlc; +pub mod parcats; pub mod pie; pub mod sankey; pub mod scatter; @@ -21,6 +22,7 @@ pub mod scatter_geo; pub mod scatter_map; pub mod scatter_mapbox; mod scatter_polar; +pub mod splom; pub mod sunburst; pub mod surface; pub mod table; @@ -39,6 +41,7 @@ pub use heat_map::HeatMap; pub use histogram::Histogram; pub use mesh3d::Mesh3D; pub use ohlc::Ohlc; +pub use parcats::Parcats; pub use pie::Pie; pub use sankey::Sankey; pub use scatter::Scatter; @@ -47,6 +50,7 @@ pub use scatter_geo::ScatterGeo; pub use scatter_map::ScatterMap; pub use scatter_mapbox::ScatterMapbox; pub use scatter_polar::ScatterPolar; +pub use splom::Splom; pub use sunburst::Sunburst; pub use surface::Surface; pub use table::Table; diff --git a/plotly/src/traces/parcats.rs b/plotly/src/traces/parcats.rs new file mode 100644 index 00000000..142d7d64 --- /dev/null +++ b/plotly/src/traces/parcats.rs @@ -0,0 +1,300 @@ +//! Parallel categories (parcats) trace + +use plotly_derive::FieldSetter; +use serde::Serialize; + +use crate::{ + color::Color, + common::{ColorScale, Dim, Domain, HoverInfo, PlotType}, + layout::CategoryOrder, + Trace, +}; + +/// Sets the shape of the paths connecting the categories. +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "lowercase")] +pub enum ParcatsLineShape { + Linear, + Hspline, +} + +/// Sets the drag interaction mode for the categories and dimensions. +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "lowercase")] +pub enum ParcatsArrangement { + Perpendicular, + Freeform, + Fixed, +} + +/// Sets the hover interaction mode for the parcats diagram. +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "lowercase")] +pub enum ParcatsHoverOn { + Category, + Color, + Dimension, +} + +/// Sets the path sorting algorithm. +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "lowercase")] +pub enum ParcatsSortPaths { + Forward, + Backward, +} + +/// A single dimension (column) of a [`Parcats`] trace. +#[serde_with::skip_serializing_none] +#[derive(Serialize, Debug, Clone, FieldSetter)] +pub struct ParcatsDimension +where + V: Serialize + Clone, +{ + /// The shown name of the dimension. + label: Option, + /// Sets the category values, one per data point. + values: Option>, + /// Specifies the ordering logic for the categories in the dimension. + #[serde(rename = "categoryorder")] + category_order: Option, + /// Sets the order in which categories in this dimension appear, only used if + /// `category_order` is set to "array". + #[serde(rename = "categoryarray")] + category_array: Option>, + /// Sets alternative tick labels for the categories in this dimension. + ticktext: Option>, + /// The display index of the dimension, from left to right, zero indexed, + /// defaults to dimension index. + #[serde(rename = "displayindex")] + display_index: Option, + /// Determines whether or not this dimension is visible. + visible: Option, +} + +impl ParcatsDimension +where + V: Serialize + Clone, +{ + pub fn new() -> Self { + Default::default() + } +} + +/// Styles the paths (lines) connecting the categories of a [`Parcats`] trace. +#[serde_with::skip_serializing_none] +#[derive(Serialize, Debug, Clone, FieldSetter)] +pub struct ParcatsLine { + /// Sets the line color. It accepts either a specific color or an array of + /// numbers that are mapped to the colorscale. + color: Option>>, + /// Sets the colorscale used to map the `color` values to colors. + #[serde(rename = "colorscale")] + color_scale: Option, + /// Sets the shape of the paths. + shape: Option, + /// Sets the lower bound of the color domain. + cmin: Option, + /// Sets the upper bound of the color domain. + cmax: Option, + /// Sets the mid-point of the color domain by scaling `cmin` and/or `cmax`. + cmid: Option, + /// Determines whether or not a colorbar is displayed for this trace. + #[serde(rename = "showscale")] + show_scale: Option, +} + +impl ParcatsLine { + pub fn new() -> Self { + Default::default() + } +} + +/// Construct a parallel categories (parcats) trace. +/// +/// A `Parcats` trace visualizes multi-dimensional categorical data as a set of +/// parallel axes, one per [`ParcatsDimension`], with ribbons flowing between +/// the categories. It is domain-based (like Sankey), so it does not share the +/// cartesian subplot grid. +/// +/// # Examples +/// +/// ``` +/// use plotly::Parcats; +/// use plotly::parcats::{ParcatsArrangement, ParcatsDimension}; +/// +/// let trace = Parcats::new() +/// .dimensions(vec![ +/// ParcatsDimension::new().label("A").values(vec!["x", "y", "x"]), +/// ParcatsDimension::new().label("B").values(vec!["p", "q", "p"]), +/// ]) +/// .counts_array(vec![1.0, 2.0, 3.0]) +/// .arrangement(ParcatsArrangement::Perpendicular); +/// +/// let expected = serde_json::json!({ +/// "type": "parcats", +/// "dimensions": [ +/// {"label": "A", "values": ["x", "y", "x"]}, +/// {"label": "B", "values": ["p", "q", "p"]} +/// ], +/// "counts": [1.0, 2.0, 3.0], +/// "arrangement": "perpendicular" +/// }); +/// +/// assert_eq!(serde_json::to_value(trace).unwrap(), expected); +/// ``` +#[serde_with::skip_serializing_none] +#[derive(Serialize, Debug, Clone, FieldSetter)] +#[field_setter(box_self, kind = "trace")] +pub struct Parcats +where + V: Serialize + Clone, +{ + #[field_setter(default = "PlotType::Parcats")] + r#type: PlotType, + /// Sets the trace name. The trace name appears as the legend item and on + /// hover. + name: Option, + /// The dimensions (columns) of the parallel-categories diagram. + dimensions: Option>>, + /// Styles the paths connecting the categories. + line: Option, + /// The number of observations represented by each state. Defaults to 1 so + /// that each state represents one observation. This lets callers pass + /// aggregated per-path weights instead of one row per record. + counts: Option>, + /// Sets the drag interaction mode for categories and dimensions. + arrangement: Option, + /// Sort paths so that like colors are bundled together within each category. + #[serde(rename = "bundlecolors")] + bundle_colors: Option, + /// Sets the hover interaction mode for the parcats diagram. + #[serde(rename = "hoveron")] + hover_on: Option, + /// Sets the path sorting algorithm. + #[serde(rename = "sortpaths")] + sort_paths: Option, + /// Template string used for rendering the information that appear on hover + /// box. + // NOTE: parcats also accepts `line.hovertemplate`; trace-level placement is + // the more general form and is used here. + #[serde(rename = "hovertemplate")] + hover_template: Option, + /// Determines which trace information appears on hover. + #[serde(rename = "hoverinfo")] + hover_info: Option, + /// Sets the domain within which this parcats trace is drawn. + domain: Option, +} + +impl Parcats +where + V: Serialize + Clone, +{ + /// Creates a new empty parcats trace. + pub fn new() -> Box { + Box::default() + } +} + +impl Trace for Parcats +where + V: Serialize + Clone, +{ + fn to_json(&self) -> String { + serde_json::to_string(self).unwrap() + } +} + +#[cfg(test)] +mod tests { + use serde_json::{json, to_value}; + + use super::*; + use crate::color::NamedColor; + + #[test] + fn serialize_default_parcats() { + let trace = Parcats::::default(); + let expected = json!({"type": "parcats"}).to_string(); + + assert_eq!(trace.to_json(), expected); + } + + #[test] + fn serialize_basic_parcats_trace() { + let trace = Parcats::new() + .name("parcats") + .dimensions(vec![ + ParcatsDimension::new() + .label("Sex") + .values(vec!["M", "F", "F"]), + ParcatsDimension::new() + .label("Survived") + .values(vec!["yes", "no", "yes"]), + ]) + .counts_array(vec![1.0, 2.0, 3.0]) + .arrangement(ParcatsArrangement::Perpendicular) + .bundle_colors(true) + .hover_on(ParcatsHoverOn::Category) + .sort_paths(ParcatsSortPaths::Forward); + + let expected = json!({ + "type": "parcats", + "name": "parcats", + "dimensions": [ + {"label": "Sex", "values": ["M", "F", "F"]}, + {"label": "Survived", "values": ["yes", "no", "yes"]} + ], + "counts": [1.0, 2.0, 3.0], + "arrangement": "perpendicular", + "bundlecolors": true, + "hoveron": "category", + "sortpaths": "forward" + }); + + assert_eq!(to_value(trace).unwrap(), expected); + } + + #[test] + fn serialize_parcats_line() { + let line = ParcatsLine::new() + .color(NamedColor::Blue) + .shape(ParcatsLineShape::Hspline) + .cmin(0.0) + .cmax(1.0) + .show_scale(true); + let expected = json!({ + "color": "blue", + "shape": "hspline", + "cmin": 0.0, + "cmax": 1.0, + "showscale": true + }); + + assert_eq!(to_value(line).unwrap(), expected); + } + + #[test] + fn serialize_parcats_dimension() { + let dim = ParcatsDimension::new() + .label("A") + .values(vec![0, 1, 0]) + .category_order(CategoryOrder::CategoryAscending) + .category_array(vec![0, 1]) + .ticktext(vec!["zero", "one"]) + .display_index(2) + .visible(true); + let expected = json!({ + "label": "A", + "values": [0, 1, 0], + "categoryorder": "category ascending", + "categoryarray": [0, 1], + "ticktext": ["zero", "one"], + "displayindex": 2, + "visible": true + }); + + assert_eq!(to_value(dim).unwrap(), expected); + } +} diff --git a/plotly/src/traces/splom.rs b/plotly/src/traces/splom.rs new file mode 100644 index 00000000..0b87fa7b --- /dev/null +++ b/plotly/src/traces/splom.rs @@ -0,0 +1,236 @@ +//! Scatter-plot matrix (SPLOM) trace + +use plotly_derive::FieldSetter; +use serde::Serialize; + +use crate::{ + common::{Dim, HoverInfo, LegendGroupTitle, Marker, PlotType, Visible}, + layout::AxisType, + Trace, +}; + +/// Per-dimension axis settings for a [`Splom`] dimension. +#[serde_with::skip_serializing_none] +#[derive(Serialize, Debug, Clone, FieldSetter)] +pub struct SplomAxis { + /// Sets the axis type for this dimension's row/column. + #[serde(rename = "type")] + r#type: Option, + /// Determines whether or not the x & y axes generated by this dimension + /// match their corresponding axes on the other dimensions. + matches: Option, +} + +impl SplomAxis { + pub fn new() -> Self { + Default::default() + } +} + +/// A single dimension (column) of a [`Splom`] trace. +#[serde_with::skip_serializing_none] +#[derive(Serialize, Debug, Clone, FieldSetter)] +pub struct SplomDimension +where + V: Serialize + Clone, +{ + /// Sets the label corresponding to this splom dimension. + label: Option, + /// Sets the dimension values to be plotted. + values: Option>, + /// Determines whether or not this dimension is shown on the graph. Note that + /// even visible false dimensions contribute to the default grid generation. + visible: Option, + /// Per-dimension axis settings. + axis: Option, +} + +impl SplomDimension +where + V: Serialize + Clone, +{ + pub fn new() -> Self { + Default::default() + } +} + +/// Controls the rendering of the SPLOM diagonal (where a dimension is plotted +/// against itself). +#[serde_with::skip_serializing_none] +#[derive(Serialize, Debug, Clone, FieldSetter)] +pub struct SplomDiagonal { + /// Determines whether or not subplots on the diagonal are displayed. + visible: Option, +} + +impl SplomDiagonal { + pub fn new() -> Self { + Default::default() + } +} + +/// Construct a scatter-plot matrix (SPLOM) trace. +/// +/// A `Splom` renders an N×N grid of scatter subplots, plotting every pair of +/// the supplied [`SplomDimension`]s against each other. Plotly.js generates the +/// axis grid automatically, so — unlike cartesian traces — you do not assign +/// explicit `xaxis`/`yaxis` ids. +/// +/// # Examples +/// +/// ``` +/// use plotly::Splom; +/// use plotly::splom::SplomDimension; +/// +/// let trace = Splom::new().dimensions(vec![ +/// SplomDimension::new().label("A").values(vec![1, 2, 3]), +/// SplomDimension::new().label("B").values(vec![4, 5, 6]), +/// ]); +/// +/// let expected = serde_json::json!({ +/// "type": "splom", +/// "dimensions": [ +/// {"label": "A", "values": [1, 2, 3]}, +/// {"label": "B", "values": [4, 5, 6]} +/// ] +/// }); +/// +/// assert_eq!(serde_json::to_value(trace).unwrap(), expected); +/// ``` +#[serde_with::skip_serializing_none] +#[derive(Serialize, Debug, Clone, FieldSetter)] +#[field_setter(box_self, kind = "trace")] +pub struct Splom +where + V: Serialize + Clone, +{ + #[field_setter(default = "PlotType::Splom")] + r#type: PlotType, + /// Sets the trace name. The trace name appears as the legend item and on + /// hover. + name: Option, + /// Determines whether or not this trace is visible. If "legendonly", the + /// trace is not drawn, but can appear as a legend item. + visible: Option, + /// Determines whether or not an item corresponding to this trace is shown in + /// the legend. + #[serde(rename = "showlegend")] + show_legend: Option, + /// Sets the legend group for this trace. Traces part of the same legend + /// group show/hide at the same time when toggling legend items. + #[serde(rename = "legendgroup")] + legend_group: Option, + /// Set and style the title to appear for the legend group. + #[serde(rename = "legendgrouptitle")] + legend_group_title: Option, + /// Sets the opacity of the trace. + opacity: Option, + /// The dimensions (columns) plotted against each other in the matrix. + dimensions: Option>>, + /// Controls the rendering of the subplots on the diagonal. + diagonal: Option, + /// Determines whether or not subplots on the upper half of the matrix are + /// displayed. + #[serde(rename = "showupperhalf")] + show_upper_half: Option, + /// Determines whether or not subplots on the lower half of the matrix are + /// displayed. + #[serde(rename = "showlowerhalf")] + show_lower_half: Option, + /// Sets the marker style for the scatter points. + marker: Option, + /// Sets the list of x axes corresponding to dimensions of this splom trace. + /// Usually left unset so Plotly.js auto-lays-out the grid. + xaxes: Option>, + /// Sets the list of y axes corresponding to dimensions of this splom trace. + /// Usually left unset so Plotly.js auto-lays-out the grid. + yaxes: Option>, + /// Sets text elements associated with each (x,y) pair to appear on hover. + text: Option>, + /// Determines which trace information appears on hover. + #[serde(rename = "hoverinfo")] + hover_info: Option, + /// Template string used for rendering the information that appear on hover + /// box. + #[serde(rename = "hovertemplate")] + hover_template: Option>, +} + +impl Splom +where + V: Serialize + Clone, +{ + /// Creates a new empty SPLOM trace. + pub fn new() -> Box { + Box::default() + } +} + +impl Trace for Splom +where + V: Serialize + Clone, +{ + fn to_json(&self) -> String { + serde_json::to_string(self).unwrap() + } +} + +#[cfg(test)] +mod tests { + use serde_json::{json, to_value}; + + use super::*; + + #[test] + fn serialize_default_splom() { + let trace = Splom::::default(); + let expected = json!({"type": "splom"}).to_string(); + + assert_eq!(trace.to_json(), expected); + } + + #[test] + fn serialize_basic_splom_trace() { + let trace = Splom::new() + .name("splom") + .dimensions(vec![ + SplomDimension::new().label("A").values(vec![1.0, 2.0, 3.0]), + SplomDimension::new().label("B").values(vec![4.0, 5.0, 6.0]), + ]) + .diagonal(SplomDiagonal::new().visible(false)) + .show_upper_half(false) + .marker(Marker::new().size(4).opacity(0.6)); + + let expected = json!({ + "type": "splom", + "name": "splom", + "dimensions": [ + {"label": "A", "values": [1.0, 2.0, 3.0]}, + {"label": "B", "values": [4.0, 5.0, 6.0]} + ], + "diagonal": {"visible": false}, + "showupperhalf": false, + "marker": {"size": 4, "opacity": 0.6} + }); + + assert_eq!(to_value(trace).unwrap(), expected); + } + + #[test] + fn serialize_splom_dimension_axis() { + let dim = SplomDimension::new() + .label("A") + .values(vec![1, 2, 3]) + .visible(true) + .axis(SplomAxis::new().r#type(AxisType::Log).matches(true)); + + let expected = json!({ + "label": "A", + "values": [1, 2, 3], + "visible": true, + "axis": {"type": "log", "matches": true} + }); + + assert_eq!(to_value(dim).unwrap(), expected); + } +}