1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
/// Line curve style connecting consecutive rank points.
#[derive(Debug, Clone, Default)]
pub enum CurveStyle {
/// S-curve (cubic Bézier with horizontal control tangents). **(default)**
/// Clearly shows crossing paths between ranks.
#[default]
Sigmoid,
/// Straight lines.
Straight,
}
/// Tie-breaking strategy when computing ranks from raw values.
#[derive(Debug, Clone, Default)]
pub enum BumpTieBreak {
/// Tied series share the average of the occupied rank positions (e.g. 2.5, 2.5). **(default)**
#[default]
Average,
/// All tied series receive the minimum (best) rank number.
Min,
/// All tied series receive the maximum (worst) rank number.
Max,
/// Maintain original insertion order among ties.
Stable,
}
/// One series in a [`BumpPlot`].
#[derive(Debug, Clone)]
pub struct BumpSeries {
pub name: String,
/// Rank at each time point. `None` = absent / did not qualify at that step.
/// Fractional ranks are allowed (average-tie convention produces e.g. 2.5).
pub ranks: Vec<Option<f64>>,
/// Optional explicit CSS color. Falls back to the category10 palette.
pub color: Option<String>,
}
/// Bump chart — rank of each series across discrete time points / conditions.
///
/// # Basic usage (pre-ranked)
///
/// ```rust,no_run
/// use kuva::plot::bump::BumpPlot;
/// use kuva::render::{plots::Plot, layout::Layout, render::render_multiple};
/// use kuva::backend::svg::SvgBackend;
///
/// let plot = BumpPlot::new()
/// .with_series("Alpha", vec![1, 3, 2, 1])
/// .with_series("Beta", vec![2, 1, 1, 3])
/// .with_series("Gamma", vec![3, 2, 3, 2])
/// .with_x_labels(["2021", "2022", "2023", "2024"]);
///
/// let plots = vec![Plot::Bump(plot)];
/// let layout = Layout::auto_from_plots(&plots).with_title("Rank over time");
/// let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
/// std::fs::write("bump.svg", svg).unwrap();
/// ```
#[derive(Debug, Clone)]
pub struct BumpPlot {
/// Pre-ranked series (added via `.with_series()` / `.with_ranked_series()`).
pub series: Vec<BumpSeries>,
/// Labels for each x-axis time point / condition.
pub x_labels: Vec<String>,
/// Line curve style. Default: [`CurveStyle::Sigmoid`].
pub curve_style: CurveStyle,
/// Show the rank number inside each dot. Default: `false`.
pub show_rank_labels: bool,
/// Show series name labels at the left and right edges. Default: `true`.
pub show_series_labels: bool,
/// Dot radius in pixels. Default: `6.0`.
pub dot_radius: f64,
/// Line stroke width in pixels. Default: `2.5`.
pub stroke_width: f64,
/// Name of the series to highlight; all others are muted. Default: `None`.
pub highlight: Option<String>,
/// Show a legend. Default: `true`.
pub legend: bool,
/// When `true`, a *lower* raw value maps to a *lower* (better) rank number.
/// When `false` (default), a *higher* raw value = rank 1.
pub rank_ascending: bool,
/// Tie-breaking mode used by `.with_raw_series()`.
pub tie_break: BumpTieBreak,
/// Raw-value series queued for deferred auto-ranking.
pub(crate) raw_values: Vec<(String, Vec<Option<f64>>, Option<String>)>,
}
impl Default for BumpPlot {
fn default() -> Self {
Self::new()
}
}
impl BumpPlot {
/// Create a [`BumpPlot`] with default settings.
pub fn new() -> Self {
BumpPlot {
series: vec![],
x_labels: vec![],
curve_style: CurveStyle::Sigmoid,
show_rank_labels: false,
show_series_labels: true,
dot_radius: 6.0,
stroke_width: 2.5,
highlight: None,
legend: true,
rank_ascending: false,
tie_break: BumpTieBreak::Average,
raw_values: vec![],
}
}
// ── Data input ────────────────────────────────────────────────────────────
/// Add a pre-ranked series using integer or float ranks.
/// Use `None` values (via `.with_ranked_series`) for time points where the series is absent.
pub fn with_series(
mut self,
name: impl Into<String>,
ranks: impl IntoIterator<Item = impl Into<f64>>,
) -> Self {
self.series.push(BumpSeries {
name: name.into(),
ranks: ranks.into_iter().map(|r| Some(r.into())).collect(),
color: None,
});
self
}
/// Add a pre-ranked series that may have missing time points.
/// `None` entries cause the line to break at that position.
pub fn with_ranked_series(
mut self,
name: impl Into<String>,
ranks: impl IntoIterator<Item = Option<f64>>,
) -> Self {
self.series.push(BumpSeries {
name: name.into(),
ranks: ranks.into_iter().collect(),
color: None,
});
self
}
/// Add a raw-value series; ranks are computed automatically across all raw-value
/// series once all series have been added.
///
/// By default (`.with_rank_ascending(false)`): higher value → rank 1.
/// Ties are handled by `.with_tie_break()` (default: average).
pub fn with_raw_series(
mut self,
name: impl Into<String>,
values: impl IntoIterator<Item = impl Into<f64>>,
) -> Self {
self.raw_values.push((
name.into(),
values.into_iter().map(|v| Some(v.into())).collect(),
None,
));
self
}
/// Add a raw-value series that may have missing time points.
pub fn with_raw_series_opt(
mut self,
name: impl Into<String>,
values: impl IntoIterator<Item = Option<f64>>,
) -> Self {
self.raw_values
.push((name.into(), values.into_iter().collect(), None));
self
}
/// Set the x-axis labels (one per time point / condition).
pub fn with_x_labels(mut self, labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.x_labels = labels.into_iter().map(|l| l.into()).collect();
self
}
// ── Appearance ────────────────────────────────────────────────────────────
/// Set the curve style (`Sigmoid` or `Straight`).
pub fn with_curve_style(mut self, style: CurveStyle) -> Self {
self.curve_style = style;
self
}
/// Show / hide the rank number inside each dot.
pub fn with_show_rank_labels(mut self, show: bool) -> Self {
self.show_rank_labels = show;
self
}
/// Show / hide series name labels at the left and right edges.
pub fn with_show_series_labels(mut self, show: bool) -> Self {
self.show_series_labels = show;
self
}
/// Set the dot radius in pixels.
pub fn with_dot_radius(mut self, r: f64) -> Self {
self.dot_radius = r;
self
}
/// Set the line stroke width in pixels.
pub fn with_stroke_width(mut self, w: f64) -> Self {
self.stroke_width = w;
self
}
/// Highlight one series by name. All other series are muted to 25 % opacity.
pub fn with_highlight(mut self, name: impl Into<String>) -> Self {
self.highlight = Some(name.into());
self
}
/// Show / hide the legend.
pub fn with_legend(mut self, show: bool) -> Self {
self.legend = show;
self
}
/// If `true`, lower raw value = lower (better) rank number.
/// If `false` (default), higher raw value = rank 1.
pub fn with_rank_ascending(mut self, asc: bool) -> Self {
self.rank_ascending = asc;
self
}
/// Set the tie-breaking mode for auto-ranking from raw values.
pub fn with_tie_break(mut self, mode: BumpTieBreak) -> Self {
self.tie_break = mode;
self
}
// ── Internal helpers ──────────────────────────────────────────────────────
/// Total number of series (pre-ranked + raw-value).
pub(crate) fn total_series_count(&self) -> usize {
self.series.len() + self.raw_values.len()
}
/// Number of time points derived from labels and series data.
pub(crate) fn n_time_points(&self) -> usize {
let from_labels = self.x_labels.len();
let from_series = self.series.iter().map(|s| s.ranks.len()).max().unwrap_or(0);
let from_raw = self
.raw_values
.iter()
.map(|(_, v, _)| v.len())
.max()
.unwrap_or(0);
from_labels.max(from_series).max(from_raw)
}
/// Resolve all series into ranked form, computing ranks from raw values if needed.
pub(crate) fn resolved_series(&self) -> Vec<BumpSeries> {
if self.raw_values.is_empty() {
return self.series.clone();
}
let n_raw = self.raw_values.len();
let n_time = self
.raw_values
.iter()
.map(|(_, v, _)| v.len())
.max()
.unwrap_or(0);
let mut ranked: Vec<Vec<Option<f64>>> = vec![vec![None; n_time]; n_raw];
#[allow(clippy::needless_range_loop)]
for t in 0..n_time {
// Collect (series_idx, value) for series present at this time point
let mut present: Vec<(usize, f64)> = (0..n_raw)
.filter_map(|s| self.raw_values[s].1.get(t).and_then(|v| *v).map(|v| (s, v)))
.collect();
if present.is_empty() {
continue;
}
// Sort: higher value = rank 1 when !ascending
if self.rank_ascending {
present.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
} else {
present.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
}
// Assign ranks with tie handling
let n = present.len();
let mut i = 0;
while i < n {
let val = present[i].1;
let mut j = i + 1;
// Find end of tie group
while j < n
&& (present[j].1 - val).abs() < f64::EPSILON * val.abs().max(1.0) * 1000.0
{
j += 1;
}
for k in i..j {
let rank = match self.tie_break {
BumpTieBreak::Average => {
let sum: f64 = ((i + 1)..=(j)).map(|r| r as f64).sum();
sum / (j - i) as f64
}
BumpTieBreak::Min => (i + 1) as f64,
BumpTieBreak::Max => j as f64,
BumpTieBreak::Stable => (k + 1) as f64,
};
ranked[present[k].0][t] = Some(rank);
}
i = j;
}
}
// Build BumpSeries from computed ranks
let mut result: Vec<BumpSeries> = self
.raw_values
.iter()
.enumerate()
.map(|(s, (name, _, color))| BumpSeries {
name: name.clone(),
ranks: ranked[s].clone(),
color: color.clone(),
})
.collect();
// Append any additional pre-ranked series
result.extend(self.series.clone());
result
}
}