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
/// A single observation (row) in a parallel coordinates plot.
#[derive(Debug, Clone)]
pub struct ParallelRow {
/// Values for each axis, in axis order.
pub values: Vec<f64>,
/// Optional group label for coloring.
pub group: Option<String>,
}
/// A parallel coordinates plot.
///
/// Each row in the dataset is drawn as a polyline (or bezier curve) passing
/// through one vertical axis per dimension. Axes are independently normalised
/// to \[0, 1\] by default so that differently-scaled dimensions can be compared.
///
/// # Example
///
/// ```rust,no_run
/// use kuva::plot::parallel::ParallelPlot;
/// use kuva::render::plots::Plot;
/// use kuva::render::layout::Layout;
/// use kuva::render::render::render_multiple;
/// use kuva::backend::svg::SvgBackend;
///
/// let plot = ParallelPlot::new()
/// .with_axis_names(["Sepal.L", "Sepal.W", "Petal.L", "Petal.W"])
/// .with_row_group("setosa", vec![5.1, 3.5, 1.4, 0.2])
/// .with_row_group("versicolor", vec![7.0, 3.2, 4.7, 1.4])
/// .with_row_group("virginica", vec![6.3, 3.3, 6.0, 2.5])
/// .with_curved(true)
/// .with_mean(true);
///
/// let plots = vec![Plot::Parallel(plot)];
/// let layout = Layout::auto_from_plots(&plots).with_title("Iris");
/// let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
/// std::fs::write("parallel.svg", svg).unwrap();
/// ```
#[derive(Debug, Clone)]
pub struct ParallelPlot {
/// Name for each axis (column). Length must equal the number of values per row.
pub axis_names: Vec<String>,
/// All rows.
pub rows: Vec<ParallelRow>,
// Display options
/// Normalise each axis independently to \[0, 1\] before drawing (default: `true`).
/// Set to `false` when all axes share a common scale.
pub normalize: bool,
/// Draw smooth S-shaped bezier curves instead of straight polylines (default: `false`).
pub curved: bool,
/// Stroke width for polylines (default: `1.2`).
pub stroke_width: f64,
/// Global opacity for polylines (default: `0.6`).
pub opacity: f64,
/// Fallback color when no groups are provided (default: `"steelblue"`).
pub color: String,
/// Explicit per-group colors (CSS color strings).
/// Falls back to `category10` palette when `None` or shorter than number of groups.
pub group_colors: Option<Vec<String>>,
/// Whether to draw tick marks + value labels on each axis (default: `true`).
pub show_axis_ticks: bool,
/// Number of ticks per axis (default: `5`).
pub axis_ticks: usize,
/// Draw a bold mean line for each group (default: `false`).
///
/// One thick polyline (or curve when `curved = true`) is drawn per group at the
/// mean value on each axis, making group-level patterns clearly visible even when
/// individual lines are dense.
pub show_mean: bool,
/// Stroke width used for mean lines (default: `3.0`).
pub mean_stroke_width: f64,
/// Per-axis inversion flags. When `inverted_axes[i]` is `true`, axis `i` is drawn
/// bottom-to-top (high values at bottom). An inverted axis is indicated by a small
/// downward-pointing triangle beneath the axis label.
pub inverted_axes: Vec<bool>,
/// Legend group title. When set, a legend entry per group is added.
pub legend_label: Option<String>,
/// Draw a thin grey background band behind each axis line (default: `false`).
pub show_axis_bands: bool,
}
impl Default for ParallelPlot {
fn default() -> Self {
Self::new()
}
}
impl ParallelPlot {
/// Create a parallel coordinates plot with default settings.
pub fn new() -> Self {
Self {
axis_names: vec![],
rows: vec![],
normalize: true,
curved: false,
stroke_width: 1.2,
opacity: 0.6,
color: "steelblue".to_string(),
group_colors: None,
show_axis_ticks: true,
axis_ticks: 5,
show_mean: false,
mean_stroke_width: 3.0,
inverted_axes: vec![],
legend_label: None,
show_axis_bands: false,
}
}
/// Set the axis (column) names.
pub fn with_axis_names(mut self, names: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.axis_names = names.into_iter().map(|n| n.into()).collect();
self
}
/// Add a row with no group (uses the fallback color).
pub fn with_row(mut self, values: impl IntoIterator<Item = impl Into<f64>>) -> Self {
self.rows.push(ParallelRow {
values: values.into_iter().map(|v| v.into()).collect(),
group: None,
});
self
}
/// Add a row assigned to a named group.
pub fn with_row_group(
mut self,
group: impl Into<String>,
values: impl IntoIterator<Item = impl Into<f64>>,
) -> Self {
self.rows.push(ParallelRow {
values: values.into_iter().map(|v| v.into()).collect(),
group: Some(group.into()),
});
self
}
/// Add multiple ungrouped rows at once.
pub fn with_rows(mut self, rows: impl IntoIterator<Item = Vec<f64>>) -> Self {
for v in rows {
self.rows.push(ParallelRow {
values: v,
group: None,
});
}
self
}
/// Add multiple rows belonging to the same group.
pub fn with_group_rows(
mut self,
group: impl Into<String>,
rows: impl IntoIterator<Item = Vec<f64>>,
) -> Self {
let g = group.into();
for v in rows {
self.rows.push(ParallelRow {
values: v,
group: Some(g.clone()),
});
}
self
}
/// Enable or disable per-axis normalisation (default: `true`).
pub fn with_normalize(mut self, v: bool) -> Self {
self.normalize = v;
self
}
/// Draw smooth S-shaped bezier curves instead of straight polylines (default: `false`).
///
/// Curves are cubic bezier segments whose control points are at the horizontal midpoint
/// between each pair of adjacent axes, matching the y-coordinate of the start/end point.
/// This produces the classic "flow" look common in D3 parallel coordinates.
pub fn with_curved(mut self, v: bool) -> Self {
self.curved = v;
self
}
/// Set the polyline stroke width (default: `1.2`).
pub fn with_stroke_width(mut self, v: f64) -> Self {
self.stroke_width = v;
self
}
/// Set the global polyline opacity (default: `0.6`).
pub fn with_opacity(mut self, v: f64) -> Self {
self.opacity = v;
self
}
/// Set the fallback color when no groups are used.
pub fn with_color(mut self, c: impl Into<String>) -> Self {
self.color = c.into();
self
}
/// Set explicit per-group colors.
pub fn with_group_colors(
mut self,
colors: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.group_colors = Some(colors.into_iter().map(|c| c.into()).collect());
self
}
/// Show or hide per-axis tick marks and value labels (default: `true`).
pub fn with_axis_ticks(mut self, v: bool) -> Self {
self.show_axis_ticks = v;
self
}
/// Number of value ticks drawn on each axis (default: `5`).
pub fn with_tick_count(mut self, n: usize) -> Self {
self.axis_ticks = n.max(2);
self
}
/// Draw a bold group-mean line over the individual polylines (default: `false`).
///
/// One thick line per group passes through the mean value on each axis.
/// Useful when individual lines are dense or heavily overlapping.
pub fn with_mean(mut self, v: bool) -> Self {
self.show_mean = v;
self
}
/// Set the stroke width for mean lines (default: `3.0`).
pub fn with_mean_stroke_width(mut self, v: f64) -> Self {
self.mean_stroke_width = v;
self
}
/// Invert a single axis so that larger values appear at the bottom (default: none inverted).
///
/// An inverted axis is indicated by a small downward triangle beneath its label.
/// Calling this method multiple times inverts each named index independently.
pub fn with_invert_axis(mut self, axis_index: usize) -> Self {
if self.inverted_axes.len() <= axis_index {
self.inverted_axes.resize(axis_index + 1, false);
}
self.inverted_axes[axis_index] = true;
self
}
/// Invert a set of axes by index (see [`with_invert_axis`](Self::with_invert_axis)).
pub fn with_inverted_axes(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
for i in indices {
if self.inverted_axes.len() <= i {
self.inverted_axes.resize(i + 1, false);
}
self.inverted_axes[i] = true;
}
self
}
/// Attach a legend; the label is used as the legend group title.
pub fn with_legend(mut self, label: impl Into<String>) -> Self {
self.legend_label = Some(label.into());
self
}
/// Draw a light grey band behind each axis line (default: `false`).
pub fn with_axis_bands(mut self, v: bool) -> Self {
self.show_axis_bands = v;
self
}
// ── Internal helpers ─────────────────────────────────────────────────────
/// Returns the color for group index `i`.
pub(crate) fn color_for_group_idx(&self, i: usize) -> String {
use crate::render::palette::Palette;
if let Some(ref cv) = self.group_colors {
if let Some(c) = cv.get(i) {
if !c.is_empty() {
return c.clone();
}
}
}
let pal = Palette::category10();
pal[i % pal.len()].to_string()
}
/// Returns `true` if axis `i` is inverted.
pub(crate) fn is_inverted(&self, i: usize) -> bool {
self.inverted_axes.get(i).copied().unwrap_or(false)
}
/// Unique, ordered group names (preserves first-seen order).
pub(crate) fn groups(&self) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut result = vec![];
for row in &self.rows {
if let Some(ref g) = row.group {
if seen.insert(g.clone()) {
result.push(g.clone());
}
}
}
result
}
}