diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d820f52..fd3d1544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a - [[#412](https://github.com/plotly/plotly.rs/pull/412)] Add `Violin` trace type with box, mean line, KDE span, and split/grouped support - [[#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` +- Add `Funnel` trace type (bar-like, cartesian) with `Connector` and `FunnelHoverInfo` options, plus the layout-level `funnelmode`/`funnelgap`/`funnelgroupgap` attributes and a `FunnelMode` enum +- Add `Waterfall` trace type (bar-like, cartesian) with a typed `Measure` enum (`absolute`/`relative`/`total`), per-class `increasing`/`decreasing`/`totals` styling via `MeasureStyle`, a `Connector` with `ConnectorMode`, and a `WaterfallHoverInfo` enum. The layout-level `waterfallmode`/`waterfallgap`/`waterfallgroupgap` attributes and the `WaterfallMode` enum already existed - [[#418](https://github.com/plotly/plotly.rs/issues/418)] Add native point clustering to `ScatterMap` via a `Cluster` option - [[#421](https://github.com/plotly/plotly.rs/pull/421)] Backfill trace attributes for trace types to bring them to parity with plotly.js 3.7 - [[#422](https://github.com/plotly/plotly.rs/issues/422)] Add `Indicator`, `Histogram2d`, `Icicle` trace types diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md index 27cd5a47..1e37325d 100644 --- a/docs/book/src/SUMMARY.md +++ b/docs/book/src/SUMMARY.md @@ -19,6 +19,8 @@ - [Parallel Categories](./recipes/basic_charts/parcats_charts.md) - [Treemap Charts](./recipes/basic_charts/treemap_charts.md) - [Sunburst Charts](./recipes/basic_charts/sunburst_charts.md) + - [Funnel Charts](./recipes/basic_charts/funnel_charts.md) + - [Waterfall Charts](./recipes/basic_charts/waterfall_charts.md) - [Icicle Charts](./recipes/basic_charts/icicle_charts.md) - [Indicator Charts](./recipes/basic_charts/indicator_charts.md) - [Statistical Charts](./recipes/statistical_charts.md) diff --git a/docs/book/src/recipes/basic_charts.md b/docs/book/src/recipes/basic_charts.md index 26e1e6ac..2b4af07d 100644 --- a/docs/book/src/recipes/basic_charts.md +++ b/docs/book/src/recipes/basic_charts.md @@ -14,3 +14,5 @@ Treemap Charts | [![Treemap Charts](./img/treemap.png)](./basic_charts/treemap_c Sunburst Charts | [![Sunburst Charts](./img/sunburst.png)](./basic_charts/sunburst_charts.md) Icicle Charts | [![Icicle Charts](./img/icicle.png)](./basic_charts/icicle_charts.md) Indicator Charts | [![Indicator Charts](./img/indicator.png)](./basic_charts/indicator_charts.md) +Funnel Charts | [![Funnel Charts](./img/funnel.png)](./basic_charts/funnel_charts.md) +Waterfall Charts | [![Waterfall Charts](./img/waterfall.png)](./basic_charts/waterfall_charts.md) diff --git a/docs/book/src/recipes/basic_charts/funnel_charts.md b/docs/book/src/recipes/basic_charts/funnel_charts.md new file mode 100644 index 00000000..e980a0aa --- /dev/null +++ b/docs/book/src/recipes/basic_charts/funnel_charts.md @@ -0,0 +1,33 @@ +# Funnel Charts + +A funnel chart shows how a quantity narrows as it passes through a sequence of stages. It is a +*containment* form: each stage is understood to be a subset of the one above it, and the band +widths carry that claim — so it suits pipelines that genuinely nest, such as a conversion funnel. + +Stages are fed **upstream-first**: plotly draws index 0 at the top, which is the opposite of a +plain category axis. + +`text_info` takes a `+`-joined flaglist, so a band can show its own value alongside its conversion +from the previous stage (`"value+percent previous"`), the share of the first stage +(`"percent initial"`), or the share of the total (`"percent total"`). + +For a sequence whose steps do *not* nest — independent totals, or contributions that can be +negative — see [Waterfall Charts](./waterfall_charts.md) instead. + +The following imports have been used to produce the plots below: + +```rust,no_run +use plotly::color::NamedColor; +use plotly::common::{Marker, Orientation}; +use plotly::funnel::Connector as FunnelConnector; +use plotly::{Funnel, Plot}; +``` + +The `to_inline_html` method is used to produce the html plot displayed in this page. + +## Basic Funnel +```rust,no_run +{{#include ../../../../../examples/basic_charts/src/main.rs:basic_funnel}} +``` + +{{#include ../../../../../examples/basic_charts/output/inline_basic_funnel.html}} diff --git a/docs/book/src/recipes/basic_charts/waterfall_charts.md b/docs/book/src/recipes/basic_charts/waterfall_charts.md new file mode 100644 index 00000000..99cf5e2e --- /dev/null +++ b/docs/book/src/recipes/basic_charts/waterfall_charts.md @@ -0,0 +1,28 @@ +# Waterfall Charts + +A waterfall chart shows how a running total is built up from a starting value and a +sequence of signed contributions. Each bar's role is set by its `Measure`: + +- `Measure::Absolute` resets the running total and draws a bar from zero to it, +- `Measure::Relative` adds a signed delta, drawn as a floating bar, +- `Measure::Total` draws the running total accumulated so far. + +The value supplied for a `Total` bar is ignored — plotly.js re-derives it — but the slot +must still be present, because the label, value and `measure` arrays are read positionally. + +The following imports have been used to produce the plots below: + +```rust,no_run +use plotly::color::NamedColor; +use plotly::waterfall::{Marker as WaterfallMarker, Measure, MeasureStyle}; +use plotly::{Plot, Waterfall}; +``` + +The `to_inline_html` method is used to produce the html plot displayed in this page. + +## Basic Waterfall +```rust,no_run +{{#include ../../../../../examples/basic_charts/src/main.rs:basic_waterfall}} +``` + +{{#include ../../../../../examples/basic_charts/output/inline_basic_waterfall.html}} diff --git a/docs/book/src/recipes/img/funnel.png b/docs/book/src/recipes/img/funnel.png new file mode 100644 index 00000000..7b510776 Binary files /dev/null and b/docs/book/src/recipes/img/funnel.png differ diff --git a/docs/book/src/recipes/img/waterfall.png b/docs/book/src/recipes/img/waterfall.png new file mode 100644 index 00000000..6fc5e872 Binary files /dev/null and b/docs/book/src/recipes/img/waterfall.png differ diff --git a/examples/basic_charts/src/main.rs b/examples/basic_charts/src/main.rs index a8e2ce0d..900d7ecb 100644 --- a/examples/basic_charts/src/main.rs +++ b/examples/basic_charts/src/main.rs @@ -7,6 +7,7 @@ use plotly::{ ColorScale, ColorScalePalette, DashType, Domain, Fill, Font, HoverInfo, Line, LineShape, Marker, Mode, Orientation, Pattern, PatternShape, }, + funnel::Connector as FunnelConnector, icicle::{ BranchValues as IcicleBranchValues, Leaf as IcicleLeaf, Marker as IcicleMarker, PathBar as IciclePathBar, Side as IcicleSide, Tiling as IcicleTiling, @@ -30,8 +31,9 @@ use plotly::{ Align as TableAlign, Cells, Fill as TableFill, Font as TableFont, Header, Line as TableLine, }, treemap::{BranchValues, Marker as TreemapMarker, Packing, PathBar, Side, Tiling}, - Bar, Icicle, Indicator, Parcats, Pie, Plot, Sankey, Scatter, ScatterPolar, Sunburst, Table, - Treemap, + waterfall::{Marker as WaterfallMarker, Measure, MeasureStyle}, + Bar, Funnel, Icicle, Indicator, Parcats, Pie, Plot, Sankey, Scatter, ScatterPolar, Sunburst, + Table, Treemap, Waterfall, }; use plotly_utils::write_example_to_html; use rand_distr::{Distribution, Normal, Uniform}; @@ -811,6 +813,59 @@ fn bar_chart_with_pattern_fills(show: bool, file_name: &str) { } // ANCHOR_END: bar_chart_with_pattern_fills +// Funnel Charts +// ANCHOR: basic_funnel +fn basic_funnel(show: bool, file_name: &str) { + let trace = Funnel::new( + vec![100, 60, 40, 25], + vec!["Visits", "Signups", "Trials", "Purchases"], + ) + .text_info("value+percent previous") + .connector(FunnelConnector::new().visible(true)) + .marker(Marker::new().color(NamedColor::SteelBlue)); + + let layout = Layout::new().title("Conversion Funnel"); + let mut plot = Plot::new(); + plot.set_layout(layout); + plot.add_trace(trace); + + let path = write_example_to_html(&plot, file_name); + if show { + plot.show_html(path); + } +} +// ANCHOR_END: basic_funnel + +// Waterfall Charts +// ANCHOR: basic_waterfall +fn basic_waterfall(show: bool, file_name: &str) { + let trace = Waterfall::new( + vec!["Start", "Revenue", "Costs", "End"], + vec![100.0, 40.0, -25.0, 0.0], + ) + .measure(vec![ + Measure::Absolute, + Measure::Relative, + Measure::Relative, + Measure::Total, + ]) + .text_info("label+delta") + .increasing(MeasureStyle::new().marker(WaterfallMarker::new().color(NamedColor::SeaGreen))) + .decreasing(MeasureStyle::new().marker(WaterfallMarker::new().color(NamedColor::Tomato))) + .totals(MeasureStyle::new().marker(WaterfallMarker::new().color(NamedColor::SteelBlue))); + + let layout = Layout::new().title("Basic Waterfall"); + let mut plot = Plot::new(); + plot.set_layout(layout); + plot.add_trace(trace); + + let path = write_example_to_html(&plot, file_name); + if show { + plot.show_html(path); + } +} +// ANCHOR_END: basic_waterfall + // Sankey Diagrams // ANCHOR: basic_sankey_diagram fn basic_sankey_diagram(show: bool, file_name: &str) { @@ -1507,6 +1562,12 @@ fn main() { bar_chart_with_pattern_fills(false, "bar_chart_with_pattern_fills"); + // Funnel Charts + basic_funnel(true, "basic_funnel"); + + // Waterfall Charts + basic_waterfall(true, "basic_waterfall"); + // Sankey Diagrams basic_sankey_diagram(false, "basic_sankey_diagram"); custom_node_sankey_diagram(false, "custom_node_sankey_diagram"); diff --git a/plotly/src/common/mod.rs b/plotly/src/common/mod.rs index 6739612d..5b0e8375 100644 --- a/plotly/src/common/mod.rs +++ b/plotly/src/common/mod.rs @@ -222,6 +222,7 @@ pub enum PlotType { Choropleth, ChoroplethMap, Contour, + Funnel, HeatMap, Histogram, Histogram2d, @@ -242,6 +243,7 @@ pub enum PlotType { Treemap, Sunburst, Violin, + Waterfall, } #[derive(Serialize, Clone, Debug)] diff --git a/plotly/src/layout/mod.rs b/plotly/src/layout/mod.rs index 027e3075..b46c9f65 100644 --- a/plotly/src/layout/mod.rs +++ b/plotly/src/layout/mod.rs @@ -45,7 +45,8 @@ pub use self::legend::{GroupClick, ItemClick, ItemSizing, Legend, TraceOrder}; pub use self::map::{LayoutMap, MapBounds, MapStyle}; pub use self::mapbox::{Center, Mapbox, MapboxStyle}; pub use self::modes::{ - AspectMode, BarMode, BarNorm, BoxMode, ClickMode, UniformTextMode, ViolinMode, WaterfallMode, + AspectMode, BarMode, BarNorm, BoxMode, ClickMode, FunnelMode, UniformTextMode, ViolinMode, + WaterfallMode, }; pub use self::polar::{ AngularAxis, AngularAxisType, AutoRange, AutoRangeOptions, AutoTypeNumbers, AxisLayer, @@ -386,6 +387,12 @@ pub struct LayoutFields { waterfall_gap: Option, #[serde(rename = "waterfallgroupgap")] waterfall_group_gap: Option, + #[serde(rename = "funnelmode")] + funnel_mode: Option, + #[serde(rename = "funnelgap")] + funnel_gap: Option, + #[serde(rename = "funnelgroupgap")] + funnel_group_gap: Option, #[serde(rename = "piecolorway")] pie_colorway: Option>>, #[serde(rename = "extendpiecolors")] diff --git a/plotly/src/layout/modes.rs b/plotly/src/layout/modes.rs index 6c15f65c..25b5d35b 100644 --- a/plotly/src/layout/modes.rs +++ b/plotly/src/layout/modes.rs @@ -71,6 +71,14 @@ pub enum WaterfallMode { Overlay, } +#[derive(Serialize, Debug, Clone)] +#[serde(rename_all = "lowercase")] +pub enum FunnelMode { + Stack, + Group, + Overlay, +} + #[derive(Debug, Clone)] pub enum UniformTextMode { False, @@ -146,6 +154,13 @@ mod tests { assert_eq!(to_value(WaterfallMode::Overlay).unwrap(), json!("overlay")); } + #[test] + fn serialize_funnel_mode() { + assert_eq!(to_value(FunnelMode::Stack).unwrap(), json!("stack")); + assert_eq!(to_value(FunnelMode::Group).unwrap(), json!("group")); + assert_eq!(to_value(FunnelMode::Overlay).unwrap(), json!("overlay")); + } + #[test] fn serialize_aspect_mode() { let aspect_mode = AspectMode::default(); diff --git a/plotly/src/lib.rs b/plotly/src/lib.rs index cd278dfd..884f8fe6 100644 --- a/plotly/src/lib.rs +++ b/plotly/src/lib.rs @@ -60,16 +60,16 @@ pub use layout::Layout; 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, histogram2d, + box_plot, choropleth, choropleth_map, contour, density_map, funnel, heat_map, histogram, histogram2d, icicle, image, indicator, mesh3d, parcats, sankey, scatter, scatter3d, scatter_map, - scatter_mapbox, splom, sunburst, surface, treemap, violin, + scatter_mapbox, splom, sunburst, surface, treemap, violin, waterfall, }; // Bring the different trace types into the top-level scope pub use traces::{ Bar, BoxPlot, Candlestick, Choropleth, ChoroplethMap, Contour, DensityMap, DensityMapbox, - HeatMap, Histogram, Histogram2d, Icicle, Image, Indicator, Mesh3D, Ohlc, Parcats, Pie, Sankey, + Funnel, HeatMap, Histogram, Histogram2d, Icicle, Image, Indicator, Mesh3D, Ohlc, Parcats, Pie, Sankey, Scatter, Scatter3D, ScatterGeo, ScatterMap, ScatterMapbox, ScatterPolar, Splom, Sunburst, - Surface, Table, Treemap, Violin, + Surface, Table, Treemap, Violin, Waterfall, }; pub trait Restyle: serde::Serialize {} diff --git a/plotly/src/traces/funnel.rs b/plotly/src/traces/funnel.rs new file mode 100644 index 00000000..32bee231 --- /dev/null +++ b/plotly/src/traces/funnel.rs @@ -0,0 +1,368 @@ +//! Funnel trace + +use plotly_derive::FieldSetter; +use serde::Serialize; + +use crate::{ + color::Color, + common::{ + ConstrainText, Dim, Font, Label, LegendGroupTitle, Line, Marker, Orientation, PlotType, + TextAnchor, TextPosition, Visible, XAxisId, YAxisId, + }, + Trace, +}; + +/// Determines which trace information appears on hover for a funnel trace. +/// +/// Unlike the generic [`HoverInfo`](crate::common::HoverInfo), the funnel +/// schema has no `z` flag and adds the funnel-specific `percent initial`, +/// `percent previous` and `percent total` flags. plotly.js accepts any +/// `+`-joined combination of these; the variants below cover the flags +/// individually plus the combinations that are useful in practice. +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "lowercase")] +pub enum FunnelHoverInfo { + Name, + X, + Y, + Text, + #[serde(rename = "x+y")] + XAndY, + #[serde(rename = "x+y+text")] + XAndYAndText, + #[serde(rename = "percent initial")] + PercentInitial, + #[serde(rename = "percent previous")] + PercentPrevious, + #[serde(rename = "percent total")] + PercentTotal, + #[serde(rename = "percent initial+percent previous+percent total")] + AllPercents, + All, + None, + Skip, +} + +/// Visually connects consecutive funnel stages. +#[serde_with::skip_serializing_none] +#[derive(Serialize, Clone, Debug, FieldSetter)] +pub struct Connector { + visible: Option, + line: Option, + #[serde(rename = "fillcolor")] + fill_color: Option>, +} + +impl Connector { + pub fn new() -> Self { + Default::default() + } +} + +/// Construct a funnel trace. +/// +/// # Examples +/// +/// ``` +/// use plotly::Funnel; +/// +/// let x = vec![100, 60, 40]; +/// let y = vec!["Visits", "Signups", "Purchases"]; +/// +/// let trace = Funnel::new(x, y) +/// .orientation(plotly::common::Orientation::Horizontal) +/// .text_info("value+percent previous"); +/// +/// let expected = serde_json::json!({ +/// "type": "funnel", +/// "x": [100, 60, 40], +/// "y": ["Visits", "Signups", "Purchases"], +/// "orientation": "h", +/// "textinfo": "value+percent previous" +/// }); +/// +/// 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 Funnel +where + X: Serialize + Clone, + Y: Serialize + Clone, +{ + #[field_setter(default = "PlotType::Funnel")] + r#type: PlotType, + x: Option>, + y: Option>, + name: Option, + visible: Option, + #[serde(rename = "showlegend")] + show_legend: Option, + #[serde(rename = "legendgroup")] + legend_group: Option, + #[serde(rename = "legendgrouptitle")] + legend_group_title: Option, + opacity: Option, + ids: Option>, + /// Sets the bar width (in position axis units). Unlike `Bar`, the funnel + /// trace does not accept a per-point array here. + width: Option, + /// Shifts the position where the bar is drawn (in position axis units). + /// Unlike `Bar`, the funnel trace does not accept a per-point array here. + offset: Option, + orientation: Option, + text: Option>, + #[serde(rename = "textposition")] + text_position: Option>, + /// Determines which trace information appears on the graph. Plotly.js + /// expects a `+`-joined flaglist built from any of `label`, `text`, + /// `percent initial`, `percent previous`, `percent total` and `value`, or + /// the single value `none`. + #[serde(rename = "textinfo")] + text_info: Option, + #[serde(rename = "texttemplate")] + text_template: Option>, + #[serde(rename = "texttemplatefallback")] + text_template_fallback: Option>, + #[serde(rename = "textangle")] + text_angle: Option, + #[serde(rename = "textfont")] + text_font: Option, + #[serde(rename = "insidetextfont")] + inside_text_font: Option, + #[serde(rename = "outsidetextfont")] + outside_text_font: Option, + #[serde(rename = "insidetextanchor")] + inside_text_anchor: Option, + #[serde(rename = "constraintext")] + constrain_text: Option, + #[serde(rename = "cliponaxis")] + clip_on_axis: Option, + #[serde(rename = "hovertext")] + hover_text: Option>, + #[serde(rename = "hoverinfo")] + hover_info: Option, + #[serde(rename = "hovertemplate")] + hover_template: Option>, + #[serde(rename = "hovertemplatefallback")] + hover_template_fallback: Option>, + #[serde(rename = "hoverlabel")] + hover_label: Option