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
/// Builder for a Pareto chart: a bar chart of category values, sorted descending
/// by default, with a superimposed cumulative-percentage line on a secondary
/// Y-axis (fixed 0-100%). Common in QC, variant analysis, and error-categorisation
/// work — the "80/20 rule" chart.
///
/// # Example
///
/// ```rust,no_run
/// use kuva::plot::ParetoPlot;
/// use kuva::backend::svg::SvgBackend;
/// use kuva::render::render::render_multiple;
/// use kuva::render::layout::Layout;
/// use kuva::render::plots::Plot;
///
/// let plot = ParetoPlot::new()
/// .with_categories(vec![
/// ("Missing field", 42.0),
/// ("Typo", 31.0),
/// ("Timeout", 18.0),
/// ("Other", 9.0),
/// ])
/// .with_legend("Count", "Cumulative %");
///
/// let plots = vec![Plot::Pareto(plot)];
/// let layout = Layout::auto_from_plots(&plots)
/// .with_title("Error Categories")
/// .with_y_label("Count");
///
/// let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
/// std::fs::write("pareto.svg", svg).unwrap();
/// ```
#[derive(Clone, Debug)]
pub struct ParetoPlot {
pub categories: Vec<ParetoCategory>,
/// Bar fill color. Default `"steelblue"`.
pub color: String,
/// Cumulative-percentage line color. Default `"firebrick"`.
pub line_color: String,
/// Bar width as a fraction of the category slot (default `0.8`).
pub width: f64,
/// Sort categories descending by value before rendering (default `true`,
/// the classic Pareto presentation). Set `false` to preserve insertion order.
pub sorted: bool,
/// Draw a percentage label above each cumulative-line point. Default `false`.
pub show_cumulative_labels: bool,
/// Draw a horizontal dashed reference line at `threshold`. Default `true`.
pub show_threshold: bool,
/// Reference line value, as a cumulative percentage (default `80.0`).
pub threshold: f64,
/// Legend label for the bars. Defaults to `Some("Value")` — bars and the
/// cumulative line are two encodings that always coexist, so unlike most
/// other plot types a legend shows by default (see `show_legend`).
pub bar_legend_label: Option<String>,
/// Legend label for the cumulative line. Defaults to `Some("Cumulative %")`.
pub line_legend_label: Option<String>,
/// Whether to show the legend at all (default `true`).
pub show_legend: bool,
/// Collapse categories beyond this count into one stacked "Other" bar (see
/// [`with_max_categories`](Self::with_max_categories)). `None` (default)
/// disables bucketing.
pub max_categories: Option<usize>,
/// Label for the bucketed bar when `max_categories` is set (default `"Other"`).
pub other_label: String,
/// Render categories on the Y-axis and values on the X-axis (default `false`).
/// The cumulative-% line moves to a secondary X-axis drawn on top, since the
/// secondary axis always pairs with whichever axis carries *values*.
pub horizontal: bool,
}
/// A single Pareto chart category: a label and its (non-cumulative) value.
#[derive(Clone, Debug)]
pub struct ParetoCategory {
pub label: String,
pub value: f64,
}
/// One rendered bar slot: either a single category, or a bucketed "Other" made
/// from the categories beyond `max_categories`. The bucket renders as a
/// *stacked* bar of its constituent categories (decoded via legend entries)
/// rather than silently collapsing them into one opaque total — see
/// [`ParetoPlot::with_max_categories`].
#[derive(Clone, Debug)]
pub enum ParetoBar {
Single(ParetoCategory),
Bucketed {
label: String,
segments: Vec<ParetoCategory>,
},
}
impl ParetoBar {
pub fn label(&self) -> &str {
match self {
ParetoBar::Single(c) => &c.label,
ParetoBar::Bucketed { label, .. } => label,
}
}
/// Total height of this bar slot (sum of its segments for a bucketed bar).
pub fn value(&self) -> f64 {
match self {
ParetoBar::Single(c) => c.value,
ParetoBar::Bucketed { segments, .. } => segments.iter().map(|c| c.value).sum(),
}
}
}
impl Default for ParetoPlot {
fn default() -> Self {
Self::new()
}
}
impl ParetoPlot {
/// Create a Pareto chart with default settings.
pub fn new() -> Self {
Self {
categories: vec![],
color: "steelblue".into(),
line_color: "firebrick".into(),
width: 0.8,
sorted: true,
show_cumulative_labels: false,
show_threshold: true,
threshold: 80.0,
bar_legend_label: Some("Value".to_string()),
line_legend_label: Some("Cumulative %".to_string()),
show_legend: true,
max_categories: None,
other_label: "Other".to_string(),
horizontal: false,
}
}
/// Add a single category.
pub fn with_category<T: Into<String>>(mut self, label: T, value: impl Into<f64>) -> Self {
self.categories.push(ParetoCategory {
label: label.into(),
value: value.into(),
});
self
}
/// Add multiple categories at once. Each item is a `(label, value)` pair.
pub fn with_categories<T, V, I>(mut self, data: I) -> Self
where
T: Into<String>,
V: Into<f64>,
I: IntoIterator<Item = (T, V)>,
{
for (label, value) in data {
self.categories.push(ParetoCategory {
label: label.into(),
value: value.into(),
});
}
self
}
/// Set the bar fill color (default `"steelblue"`).
pub fn with_color<S: Into<String>>(mut self, color: S) -> Self {
self.color = color.into();
self
}
/// Set the cumulative-line color (default `"firebrick"`).
pub fn with_line_color<S: Into<String>>(mut self, color: S) -> Self {
self.line_color = color.into();
self
}
/// Set the bar width as a fraction of the category slot (default `0.8`).
pub fn with_width(mut self, width: f64) -> Self {
self.width = width;
self
}
/// Preserve insertion order instead of sorting descending by value (default sorts).
pub fn with_sorted(mut self, sorted: bool) -> Self {
self.sorted = sorted;
self
}
/// Show a percentage label above each cumulative-line point (default `false`).
pub fn with_cumulative_labels(mut self, show: bool) -> Self {
self.show_cumulative_labels = show;
self
}
/// Set the reference-line threshold as a cumulative percentage (default `80.0`).
/// Implies `.with_show_threshold(true)`.
pub fn with_threshold(mut self, threshold: f64) -> Self {
self.threshold = threshold;
self.show_threshold = true;
self
}
/// Show or hide the threshold reference line (default `true`).
pub fn with_show_threshold(mut self, show: bool) -> Self {
self.show_threshold = show;
self
}
/// Set the legend labels for the bars and the cumulative line (shown by
/// default as "Value" / "Cumulative %"; use this to customize them).
pub fn with_legend<S1: Into<String>, S2: Into<String>>(
mut self,
bar_label: S1,
line_label: S2,
) -> Self {
self.bar_legend_label = Some(bar_label.into());
self.line_legend_label = Some(line_label.into());
self.show_legend = true;
self
}
/// Show or hide the legend (default `true` — bars and the cumulative line
/// are two encodings that always coexist, so the legend is on by default
/// unlike most other plot types).
pub fn with_show_legend(mut self, show: bool) -> Self {
self.show_legend = show;
self
}
/// Collapse categories beyond `max` into one stacked "Other" bar instead of
/// cluttering the x-axis with a long tail of tiny bars — common in
/// real-world Pareto data (defect logs, error taxonomies) that can have
/// dozens of categories after the top few.
///
/// The bucketed bar renders as a *stack* of its constituent categories
/// (like a mini stacked-bar-within-a-bar), each given a legend entry, so
/// the hidden categories are still identifiable rather than silently
/// disappearing into one opaque total. It contributes exactly one point
/// to the cumulative line, matching its one slot on the x-axis.
///
/// `max` counts the bucket itself: `.with_max_categories(5)` keeps the top
/// 4 categories as their own bars plus one "Other" bar for the rest. No
/// effect if there are `max` or fewer categories to begin with.
pub fn with_max_categories(mut self, max: usize) -> Self {
self.max_categories = Some(max);
self
}
/// Set the label for the bucketed bar (default `"Other"`). No effect unless
/// [`.with_max_categories()`](Self::with_max_categories) is also set.
pub fn with_other_label<S: Into<String>>(mut self, label: S) -> Self {
self.other_label = label.into();
self
}
/// Render categories on the Y-axis and values on the X-axis (default `false`).
pub fn with_horizontal(mut self, horizontal: bool) -> Self {
self.horizontal = horizontal;
self
}
/// Total of all category values.
pub fn total(&self) -> f64 {
self.categories.iter().map(|c| c.value).sum()
}
/// Categories in render order: sorted descending by value when `sorted` is
/// set (default), otherwise insertion order. Ignores `max_categories`
/// bucketing — see [`render_bars`](Self::render_bars) for the bucketed view
/// actually used for drawing.
pub fn ordered_categories(&self) -> Vec<&ParetoCategory> {
let mut ordered: Vec<&ParetoCategory> = self.categories.iter().collect();
if self.sorted {
ordered.sort_by(|a, b| {
b.value
.partial_cmp(&a.value)
.unwrap_or(std::cmp::Ordering::Equal)
});
}
ordered
}
/// Bars in render order, after sorting and (if `max_categories` is set)
/// bucketing the tail into one "Other" bar. This is what's actually drawn —
/// [`cumulative_percentages`](Self::cumulative_percentages) is computed over
/// these, not over raw per-category values, so a bucketed bar contributes
/// exactly one point to the cumulative line.
pub fn render_bars(&self) -> Vec<ParetoBar> {
let ordered = self.ordered_categories();
match self.max_categories {
Some(max) if max > 0 && ordered.len() > max => {
let keep = &ordered[..max - 1];
let tail = &ordered[max - 1..];
let mut bars: Vec<ParetoBar> = keep
.iter()
.map(|c| ParetoBar::Single((*c).clone()))
.collect();
bars.push(ParetoBar::Bucketed {
label: self.other_label.clone(),
segments: tail.iter().map(|c| (*c).clone()).collect(),
});
bars
}
_ => ordered
.into_iter()
.cloned()
.map(ParetoBar::Single)
.collect(),
}
}
/// Cumulative percentage at each rendered bar (parallel to
/// [`render_bars`](Self::render_bars)). Empty if there's no data or the
/// total is zero.
pub fn cumulative_percentages(&self) -> Vec<f64> {
let total = self.total();
if total <= 0.0 {
return vec![];
}
let mut running = 0.0;
self.render_bars()
.iter()
.map(|b| {
running += b.value();
(running / total) * 100.0
})
.collect()
}
}