Skip to content

Commit 524f41a

Browse files
jqnatividadclaude
authored andcommitted
feat(traces): add Indicator, Histogram2d, Icicle trace types + layout uirevision (#422)
Add three new trace types and the layout-level uirevision attribute, with field names verified against the plotly.js v3.7.0 plot-schema.json. - Indicator: KPI number/delta/gauge displays (Gauge, Delta, Number config) - Histogram2d: 2D histogram heatmap (histfunc/histnorm/nbinsx/nbinsy/xbins/ ybins), reusing histogram's Bins/HistFunc/HistNorm and heat_map's Smoothing - Icicle: hierarchical icicle chart (sibling of Treemap/Sunburst) - layout.uirevision: preserve UI state (zoom/pan/selection) across re-renders, complementing the trace-level uirevision added in #421 Includes PlotType variants, re-exports, exact-JSON round-trip tests, doctests, and CHANGELOG entries. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Andrei Gherghescu <8067229+andrei-ng@users.noreply.github.com>
1 parent 556e450 commit 524f41a

8 files changed

Lines changed: 1525 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a
1515
- [[#417](https://github.com/plotly/plotly.rs/issues/417)] Add `ScatterMap` (MapLibre `map` subplot) trace type — the modern counterpart to `ScatterMapbox`
1616
- [[#418](https://github.com/plotly/plotly.rs/issues/418)] Add native point clustering to `ScatterMap` via a `Cluster` option
1717
- [[#421](https://github.com/plotly/plotly.rs/pull/421)] Backfill trace attributes on trace types to bring them to parity with plotly.js 3.7
18+
- [[#422](https://github.com/plotly/plotly.rs/issues/422)] Add `Indicator` trace type (number / delta / gauge KPI displays) with `Gauge`, `Delta`, `Number` config
19+
- [[#422](https://github.com/plotly/plotly.rs/issues/422)] Add `Histogram2d` trace type (2D histogram heatmap) with `histfunc`/`histnorm`/`nbinsx`/`nbinsy`/`xbins`/`ybins`
20+
- [[#422](https://github.com/plotly/plotly.rs/issues/422)] Add `Icicle` trace type (hierarchical icicle chart; sibling of `Treemap`/`Sunburst`)
21+
- [[#422](https://github.com/plotly/plotly.rs/issues/422)] Add layout-level `uirevision` to preserve UI state (zoom/pan/selection) across re-renders (complements the trace-level `uirevision` added in #421)
1822

1923
### Changed
2024

‎plotly/src/common/mod.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,11 @@ pub enum PlotType {
224224
Contour,
225225
HeatMap,
226226
Histogram,
227+
Histogram2d,
227228
Histogram2dContour,
229+
Icicle,
228230
Image,
231+
Indicator,
229232
Mesh3D,
230233
Ohlc,
231234
Sankey,

‎plotly/src/layout/mod.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use update_menu::UpdateMenu;
77

88
use crate::color::Color;
99
use crate::common::{Calendar, ColorScale, Font, Label, Orientation, Title};
10+
use crate::private::NumOrString;
1011

1112
pub mod themes;
1213
pub mod update_menu;
@@ -257,6 +258,12 @@ pub struct LayoutFields {
257258
color_axis: Option<ColorAxis>,
258259
#[serde(rename = "modebar")]
259260
mode_bar: Option<ModeBar>,
261+
/// Controls persistence of user-driven UI changes (zoom, pan, selection,
262+
/// legend visibility) across `Plotly.react`/`newPlot` calls: state is kept
263+
/// while `uirevision` is unchanged. Trace-level `uirevision` overrides per
264+
/// trace.
265+
#[serde(rename = "uirevision")]
266+
ui_revision: Option<NumOrString>,
260267
/// Determines the mode of hover interactions. If "closest", a single
261268
/// hoverlabel will appear for the "closest" point within the
262269
/// `hoverdistance`. If "x" (or "y"), multiple hoverlabels will appear for
@@ -654,6 +661,12 @@ mod tests {
654661
assert_eq!(to_value(template).unwrap(), expected);
655662
}
656663

664+
#[test]
665+
fn serialize_layout_ui_revision() {
666+
let layout = Layout::new().ui_revision("forever");
667+
assert_eq!(to_value(layout).unwrap()["uirevision"], json!("forever"));
668+
}
669+
657670
#[test]
658671
fn serialize_layout() {
659672
let layout = Layout::new()

‎plotly/src/lib.rs‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,16 @@ pub use layout::Layout;
6060
pub use plot::{Plot, Trace, Traces};
6161
// Also provide easy access to modules which contain additional trace-specific types
6262
pub use traces::{
63-
box_plot, choropleth, choropleth_map, contour, density_map, heat_map, histogram, image, mesh3d,
64-
sankey, scatter, scatter3d, scatter_map, scatter_mapbox, sunburst, surface, treemap, violin,
63+
box_plot, choropleth, choropleth_map, contour, density_map, heat_map, histogram, histogram2d,
64+
icicle, image, indicator, mesh3d, sankey, scatter, scatter3d, scatter_map, scatter_mapbox,
65+
sunburst, surface, treemap, violin,
6566
};
6667
// Bring the different trace types into the top-level scope
6768
pub use traces::{
6869
Bar, BoxPlot, Candlestick, Choropleth, ChoroplethMap, Contour, DensityMap, DensityMapbox,
69-
HeatMap, Histogram, Image, Mesh3D, Ohlc, Pie, Sankey, Scatter, Scatter3D, ScatterGeo,
70-
ScatterMap, ScatterMapbox, ScatterPolar, Sunburst, Surface, Table, Treemap, Violin,
70+
HeatMap, Histogram, Histogram2d, Icicle, Image, Indicator, Mesh3D, Ohlc, Pie, Sankey, Scatter,
71+
Scatter3D, ScatterGeo, ScatterMap, ScatterMapbox, ScatterPolar, Sunburst, Surface, Table,
72+
Treemap, Violin,
7173
};
7274

7375
pub trait Restyle: serde::Serialize {}

‎plotly/src/traces/histogram2d.rs‎

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
//! 2D histogram trace
2+
3+
use plotly_derive::FieldSetter;
4+
use serde::Serialize;
5+
6+
use crate::{
7+
common::{
8+
Calendar, ColorBar, ColorScale, Dim, Font, HoverInfo, Label, LegendGroupTitle, PlotType,
9+
Visible, XAxisId, YAxisId,
10+
},
11+
private::{NumOrString, NumOrStringCollection},
12+
traces::heat_map::Smoothing,
13+
traces::histogram::{Bins, HistFunc, HistNorm},
14+
Trace,
15+
};
16+
17+
/// Construct a 2D histogram trace.
18+
///
19+
/// A `Histogram2d` bins raw `x`/`y` samples into a 2D grid and renders the
20+
/// binned counts as a heatmap. Provide raw `x`/`y` data and let Plotly.js bin
21+
/// it via `hist_func`/`n_bins_x`/`n_bins_y`/`x_bins`/`y_bins`. Optionally
22+
/// supply per-sample aggregation values via `z` (a 1D array aligned with
23+
/// `x`/`y`) and a `hist_func` such as [`HistFunc::Sum`] or
24+
/// [`HistFunc::Average`] to aggregate those values within each bin instead of
25+
/// counting samples.
26+
///
27+
/// Note: unlike [`HeatMap`](crate::HeatMap), `z` here is per-sample aggregation
28+
/// data aligned with `x`/`y`, not a pre-binned 2D matrix. For a pre-computed
29+
/// matrix, use [`HeatMap`](crate::HeatMap).
30+
///
31+
/// # Examples
32+
///
33+
/// ```
34+
/// use plotly::Histogram2d;
35+
///
36+
/// let trace = Histogram2d::new(vec![1.0, 2.0, 2.0], vec![1.0, 1.0, 3.0]);
37+
///
38+
/// let expected = serde_json::json!({
39+
/// "type": "histogram2d",
40+
/// "x": [1.0, 2.0, 2.0],
41+
/// "y": [1.0, 1.0, 3.0],
42+
/// });
43+
///
44+
/// assert_eq!(serde_json::to_value(trace).unwrap(), expected);
45+
/// ```
46+
#[serde_with::skip_serializing_none]
47+
#[derive(Serialize, Debug, Clone, FieldSetter)]
48+
#[field_setter(box_self, kind = "trace")]
49+
pub struct Histogram2d<X, Y, Z>
50+
where
51+
X: Serialize + Clone,
52+
Y: Serialize + Clone,
53+
Z: Serialize + Clone,
54+
{
55+
#[field_setter(default = "PlotType::Histogram2d")]
56+
r#type: PlotType,
57+
name: Option<String>,
58+
visible: Option<Visible>,
59+
#[serde(rename = "showlegend")]
60+
show_legend: Option<bool>,
61+
#[serde(rename = "legendgroup")]
62+
legend_group: Option<String>,
63+
#[serde(rename = "legendgrouptitle")]
64+
legend_group_title: Option<LegendGroupTitle>,
65+
opacity: Option<f64>,
66+
x: Option<Vec<X>>,
67+
y: Option<Vec<Y>>,
68+
z: Option<Vec<Z>>,
69+
#[serde(rename = "xaxis")]
70+
x_axis: Option<XAxisId>,
71+
#[serde(rename = "yaxis")]
72+
y_axis: Option<YAxisId>,
73+
/// Specifies the binning function used for this histogram trace.
74+
#[serde(rename = "histfunc")]
75+
hist_func: Option<HistFunc>,
76+
/// Specifies the type of normalization used for this histogram trace.
77+
#[serde(rename = "histnorm")]
78+
hist_norm: Option<HistNorm>,
79+
/// Determines whether or not the x-axis bin attributes are picked by an
80+
/// algorithm.
81+
#[serde(rename = "autobinx")]
82+
auto_bin_x: Option<bool>,
83+
/// Determines whether or not the y-axis bin attributes are picked by an
84+
/// algorithm.
85+
#[serde(rename = "autobiny")]
86+
auto_bin_y: Option<bool>,
87+
/// Specifies the maximum number of desired bins along the x axis.
88+
#[serde(rename = "nbinsx")]
89+
n_bins_x: Option<usize>,
90+
/// Specifies the maximum number of desired bins along the y axis.
91+
#[serde(rename = "nbinsy")]
92+
n_bins_y: Option<usize>,
93+
/// Sets the binning across the x axis.
94+
#[serde(rename = "xbins")]
95+
x_bins: Option<Bins>,
96+
/// Sets the binning across the y axis.
97+
#[serde(rename = "ybins")]
98+
y_bins: Option<Bins>,
99+
#[serde(rename = "autocolorscale")]
100+
auto_color_scale: Option<bool>,
101+
#[serde(rename = "colorbar")]
102+
color_bar: Option<ColorBar>,
103+
#[serde(rename = "colorscale")]
104+
color_scale: Option<ColorScale>,
105+
#[serde(rename = "reversescale")]
106+
reverse_scale: Option<bool>,
107+
#[serde(rename = "showscale")]
108+
show_scale: Option<bool>,
109+
zauto: Option<bool>,
110+
zmin: Option<f64>,
111+
zmax: Option<f64>,
112+
zmid: Option<f64>,
113+
#[serde(rename = "zhoverformat")]
114+
zhover_format: Option<String>,
115+
zsmooth: Option<Smoothing>,
116+
#[serde(rename = "xgap")]
117+
x_gap: Option<NumOrString>,
118+
#[serde(rename = "ygap")]
119+
y_gap: Option<NumOrString>,
120+
#[serde(rename = "xcalendar")]
121+
x_calendar: Option<Calendar>,
122+
#[serde(rename = "ycalendar")]
123+
y_calendar: Option<Calendar>,
124+
#[serde(rename = "xhoverformat")]
125+
x_hover_format: Option<String>,
126+
#[serde(rename = "yhoverformat")]
127+
y_hover_format: Option<String>,
128+
#[serde(rename = "textfont")]
129+
text_font: Option<Font>,
130+
#[serde(rename = "texttemplate")]
131+
text_template: Option<Dim<String>>,
132+
#[serde(rename = "texttemplatefallback")]
133+
text_template_fallback: Option<Dim<String>>,
134+
#[serde(rename = "hoverinfo")]
135+
hover_info: Option<HoverInfo>,
136+
#[serde(rename = "hoverlabel")]
137+
hover_label: Option<Label>,
138+
#[serde(rename = "hovertemplate")]
139+
hover_template: Option<Dim<String>>,
140+
#[serde(rename = "hovertemplatefallback")]
141+
hover_template_fallback: Option<Dim<String>>,
142+
/// Assigns extra meta information associated with this trace that can be
143+
/// used in various text attributes.
144+
meta: Option<NumOrString>,
145+
/// Assigns extra data to each datum that can be used in hover, click and
146+
/// selection events.
147+
#[serde(rename = "customdata")]
148+
custom_data: Option<NumOrStringCollection>,
149+
uid: Option<String>,
150+
/// Sets the legend rank for this trace. Items and groups with smaller ranks
151+
/// are presented on top/left side while with `"reversed"`
152+
/// `legend.trace_order` they are on bottom/right side. The default
153+
/// legendrank is 1000.
154+
#[serde(rename = "legendrank")]
155+
legend_rank: Option<usize>,
156+
/// Sets the width (in px or fraction) of the legend for this trace.
157+
#[serde(rename = "legendwidth")]
158+
legend_width: Option<f64>,
159+
/// Controls persistence of user-driven changes to the trace. Defaults to
160+
/// `layout.uirevision`.
161+
#[serde(rename = "uirevision")]
162+
ui_revision: Option<NumOrString>,
163+
}
164+
165+
impl<X, Y> Histogram2d<X, Y, f64>
166+
where
167+
X: Serialize + Clone,
168+
Y: Serialize + Clone,
169+
{
170+
/// Build a new 2D histogram from raw `x` and `y` samples. Plotly.js bins
171+
/// the samples into a 2D grid.
172+
pub fn new(x: Vec<X>, y: Vec<Y>) -> Box<Self> {
173+
Box::new(Self {
174+
x: Some(x),
175+
y: Some(y),
176+
..Default::default()
177+
})
178+
}
179+
}
180+
181+
impl<X, Y, Z> Histogram2d<X, Y, Z>
182+
where
183+
X: Serialize + Clone,
184+
Y: Serialize + Clone,
185+
Z: Serialize + Clone,
186+
{
187+
/// Build a new 2D histogram from raw `x`/`y` samples plus per-sample
188+
/// aggregation values `z`. All three vectors are aligned (one entry per
189+
/// sample); combine with a `hist_func` such as [`HistFunc::Sum`] to
190+
/// aggregate the `z` values within each `x`/`y` bin.
191+
pub fn new_xyz(x: Vec<X>, y: Vec<Y>, z: Vec<Z>) -> Box<Self> {
192+
Box::new(Self {
193+
x: Some(x),
194+
y: Some(y),
195+
z: Some(z),
196+
..Default::default()
197+
})
198+
}
199+
}
200+
201+
impl<X, Y, Z> Trace for Histogram2d<X, Y, Z>
202+
where
203+
X: Serialize + Clone,
204+
Y: Serialize + Clone,
205+
Z: Serialize + Clone,
206+
{
207+
fn to_json(&self) -> String {
208+
serde_json::to_string(self).unwrap()
209+
}
210+
}
211+
212+
#[cfg(test)]
213+
mod tests {
214+
use serde_json::{json, to_value};
215+
216+
use super::*;
217+
use crate::common::ColorScalePalette;
218+
219+
#[test]
220+
fn serialize_default_histogram2d() {
221+
let trace = Histogram2d::<f64, f64, f64>::default();
222+
let expected = json!({"type": "histogram2d"}).to_string();
223+
224+
assert_eq!(trace.to_json(), expected);
225+
}
226+
227+
#[test]
228+
fn serialize_histogram2d_raw() {
229+
let trace = Histogram2d::new(vec![1.0, 2.0, 2.0], vec![1.0, 1.0, 3.0])
230+
.hist_func(HistFunc::Count)
231+
.hist_norm(HistNorm::Probability)
232+
.auto_bin_x(false)
233+
.auto_bin_y(false)
234+
.n_bins_x(10)
235+
.n_bins_y(10)
236+
.x_bins(Bins::new(0.0, 4.0, 1.0))
237+
.y_bins(Bins::new(0.0, 4.0, 1.0))
238+
.color_scale(ColorScale::Palette(ColorScalePalette::Viridis))
239+
.show_scale(true)
240+
.name("hist2d");
241+
242+
let expected = json!({
243+
"type": "histogram2d",
244+
"x": [1.0, 2.0, 2.0],
245+
"y": [1.0, 1.0, 3.0],
246+
"histfunc": "count",
247+
"histnorm": "probability",
248+
"autobinx": false,
249+
"autobiny": false,
250+
"nbinsx": 10,
251+
"nbinsy": 10,
252+
"xbins": {"start": 0.0, "end": 4.0, "size": 1.0},
253+
"ybins": {"start": 0.0, "end": 4.0, "size": 1.0},
254+
"colorscale": "Viridis",
255+
"showscale": true,
256+
"name": "hist2d",
257+
});
258+
259+
assert_eq!(to_value(trace).unwrap(), expected);
260+
}
261+
262+
#[test]
263+
fn serialize_histogram2d_aggregation() {
264+
// `z` is per-sample aggregation data aligned with `x`/`y` (not a
265+
// pre-binned matrix); `hist_func` aggregates it within each bin.
266+
let trace = Histogram2d::new_xyz(
267+
vec![1.0, 2.0, 2.0],
268+
vec![1.0, 1.0, 3.0],
269+
vec![10.0, 20.0, 30.0],
270+
)
271+
.hist_func(HistFunc::Sum)
272+
.zauto(true)
273+
.zmin(0.0)
274+
.zmax(60.0)
275+
.zmid(30.0)
276+
.zsmooth(Smoothing::Best)
277+
.x_gap(1.0)
278+
.y_gap("10");
279+
280+
let expected = json!({
281+
"type": "histogram2d",
282+
"x": [1.0, 2.0, 2.0],
283+
"y": [1.0, 1.0, 3.0],
284+
"z": [10.0, 20.0, 30.0],
285+
"histfunc": "sum",
286+
"zauto": true,
287+
"zmin": 0.0,
288+
"zmax": 60.0,
289+
"zmid": 30.0,
290+
"zsmooth": "best",
291+
"xgap": 1.0,
292+
"ygap": "10",
293+
});
294+
295+
assert_eq!(to_value(trace).unwrap(), expected);
296+
}
297+
298+
#[test]
299+
fn serialize_histogram2d_legend_and_uirevision() {
300+
let trace = Histogram2d::new(vec![1.0], vec![2.0])
301+
.legend_rank(7)
302+
.legend_width(2.0)
303+
.ui_revision("rev");
304+
let v = to_value(trace).unwrap();
305+
assert_eq!(v["legendrank"], json!(7));
306+
assert_eq!(v["legendwidth"], json!(2.0));
307+
assert_eq!(v["uirevision"], json!("rev"));
308+
}
309+
}

0 commit comments

Comments
 (0)