diff --git a/CHANGELOG.md b/CHANGELOG.md index 480e84b9..267fdbaf 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 ### Changed diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md index 02e3609f..319502af 100644 --- a/docs/book/src/SUMMARY.md +++ b/docs/book/src/SUMMARY.md @@ -18,6 +18,8 @@ - [Sankey Diagrams](./recipes/basic_charts/sankey_diagrams.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) - [Statistical Charts](./recipes/statistical_charts.md) - [Error Bars](./recipes/statistical_charts/error_bars.md) - [Box Plots](./recipes/statistical_charts/box_plots.md) diff --git a/docs/book/src/recipes/basic_charts.md b/docs/book/src/recipes/basic_charts.md index ca8204c9..2dc9e2d0 100644 --- a/docs/book/src/recipes/basic_charts.md +++ b/docs/book/src/recipes/basic_charts.md @@ -11,3 +11,5 @@ Pie Charts | [![Pie Charts](./img/pie_charts.png)](./basic_charts/pie_charts.md) Sankey Diagrams | [![Sankey Diagrams](./img/basic_sankey.png)](./basic_charts/sankey_diagrams.md) Treemap Charts | [Treemap Charts](./basic_charts/treemap_charts.md) Sunburst Charts | [Sunburst Charts](./basic_charts/sunburst_charts.md) +Funnel Charts | [Funnel Charts](./basic_charts/funnel_charts.md) +Waterfall Charts | [Waterfall Charts](./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/examples/basic_charts/src/main.rs b/examples/basic_charts/src/main.rs index e1b169fc..1c15d0d5 100644 --- a/examples/basic_charts/src/main.rs +++ b/examples/basic_charts/src/main.rs @@ -12,13 +12,15 @@ use plotly::{ LayoutPolar, Legend, PolarAxisAttributes, PolarAxisTicks, PolarDirection, RadialAxis, TicksDirection, TraceOrder, }, + funnel::Connector as FunnelConnector, sankey::{Line as SankeyLine, Link, Node}, sunburst::{InsideTextOrientation, Leaf}, traces::table::{ Align as TableAlign, Cells, Fill as TableFill, Font as TableFont, Header, Line as TableLine, }, treemap::{BranchValues, Marker as TreemapMarker, Packing, PathBar, Side, Tiling}, - Bar, Pie, Plot, Sankey, Scatter, ScatterPolar, Sunburst, Table, Treemap, + waterfall::{Marker as WaterfallMarker, Measure, MeasureStyle}, + Bar, Funnel, Pie, Plot, Sankey, Scatter, ScatterPolar, Sunburst, Table, Treemap, Waterfall, }; use plotly_utils::write_example_to_html; use rand_distr::{Distribution, Normal, Uniform}; @@ -1147,6 +1149,71 @@ fn styled_sunburst(show: bool, file_name: &str) { } // ANCHOR_END: styled_sunburst +// Funnel Charts +// ANCHOR: basic_funnel +fn basic_funnel(show: bool, file_name: &str) { + // A funnel is a CONTAINMENT form: each stage is a subset of the one above it, and the band + // widths carry that claim. Feed the stages upstream-first -- plotly draws index 0 at the top. + let stages = vec!["Visits", "Signups", "Trials", "Purchases"]; + let counts = vec![15200, 5100, 2400, 980]; + + let trace = Funnel::new(counts, stages) + .orientation(Orientation::Horizontal) + .text_info("value+percent previous") + .marker(Marker::new().color(NamedColor::SteelBlue)) + .connector(FunnelConnector::new().visible(true)); + + let mut plot = Plot::new(); + 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) { + // `Absolute` starts a running total, `Relative` adds a signed delta to it, + // and `Total` draws the running total so far -- the value supplied for a + // `Total` bar is ignored, but the slot must still be present because the + // label, value and measure arrays are read positionally. + let labels = vec![ + "Opening balance", + "Sales", + "Refunds", + "Net revenue", + "Operating costs", + "Closing balance", + ]; + let values = vec![120.0, 80.0, -15.0, 0.0, -45.0, 0.0]; + + let trace = Waterfall::new(labels, values) + .measure(vec![ + Measure::Absolute, + Measure::Relative, + Measure::Relative, + Measure::Total, + Measure::Relative, + Measure::Total, + ]) + .text_info("delta") + .increasing(MeasureStyle::new().marker(WaterfallMarker::new().color(NamedColor::SeaGreen))) + .decreasing(MeasureStyle::new().marker(WaterfallMarker::new().color(NamedColor::IndianRed))) + .totals(MeasureStyle::new().marker(WaterfallMarker::new().color(NamedColor::SteelBlue))); + + let mut plot = Plot::new(); + plot.add_trace(trace); + + let path = write_example_to_html(&plot, file_name); + if show { + plot.show_html(path); + } +} +// ANCHOR_END: basic_waterfall + // 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; @@ -1312,6 +1379,12 @@ fn main() { basic_sunburst(false, "basic_sunburst"); styled_sunburst(false, "styled_sunburst"); + // Funnel Charts + basic_funnel(false, "basic_funnel"); + + // Waterfall Charts + basic_waterfall(false, "basic_waterfall"); + // 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/common/mod.rs b/plotly/src/common/mod.rs index db2fa08e..fdd79764 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, Histogram2dContour, @@ -237,6 +238,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 1aef0bb0..4dce794b 100644 --- a/plotly/src/layout/mod.rs +++ b/plotly/src/layout/mod.rs @@ -44,7 +44,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, @@ -379,6 +380,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 7086da40..cb56864d 100644 --- a/plotly/src/lib.rs +++ b/plotly/src/lib.rs @@ -60,14 +60,15 @@ 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, image, mesh3d, - sankey, scatter, scatter3d, scatter_map, scatter_mapbox, sunburst, surface, treemap, violin, + box_plot, choropleth, choropleth_map, contour, density_map, funnel, heat_map, histogram, image, + mesh3d, sankey, scatter, scatter3d, scatter_map, scatter_mapbox, 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, Image, Mesh3D, Ohlc, Pie, Sankey, Scatter, Scatter3D, ScatterGeo, - ScatterMap, ScatterMapbox, ScatterPolar, Sunburst, Surface, Table, Treemap, Violin, + Funnel, HeatMap, Histogram, Image, Mesh3D, Ohlc, Pie, Sankey, Scatter, Scatter3D, ScatterGeo, + ScatterMap, ScatterMapbox, ScatterPolar, Sunburst, 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