Skip to content

Commit 9cf1d06

Browse files
jqnatividadclaude
authored andcommitted
feat(traces): add Indicator, Histogram2d, Icicle trace types + layout uirevision (plotly#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 plotly#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 9cf1d06

8 files changed

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

0 commit comments

Comments
 (0)