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
//! Tier-2 miscellaneous plotting methods on [`Axes`]:
//! [`eventplot`](Axes::eventplot), [`fill_betweenx`](Axes::fill_betweenx), and
//! [`ecdf`](Axes::ecdf).
//!
//! These mirror matplotlib's `eventplot`, `fill_betweenx`, and `ecdf` helpers.
//! Each translates its arguments into [`Line2D`] / [`Patch`] artists stored on
//! the [`Axes`], so their extents feed autoscaling via
//! [`data_limits`](Axes::data_limits). They live alongside the other plotting
//! helpers but are split out to keep [`plotting`](super::plotting) focused on
//! the Tier-1 line/patch methods.
use crate::artist::{Line2D, Patch};
use crate::core::color::Rgba;
use crate::figure::Axes;
/// Total height (in data units) of an [`eventplot`](Axes::eventplot) tick,
/// matching matplotlib's default `linelength` of `1.0` scaled to leave a small
/// gap between adjacent rows.
const EVENT_LINE_LENGTH: f64 = 0.8;
/// Default fill opacity for [`fill_betweenx`](Axes::fill_betweenx) regions,
/// matching [`fill_between`](Axes::fill_between).
const FILL_BETWEENX_ALPHA: f64 = 0.4;
impl Axes {
/// Draw a raster / event plot: a short vertical tick at each event position.
///
/// Mirrors matplotlib's `eventplot` in its default vertical orientation. Each
/// row `i` of `positions` is drawn at integer `y = i`: every event x-value in
/// that row becomes a short vertical [`Line2D`] tick of total height
/// `0.8` centered on `y = i`, i.e. spanning
/// `i - 0.4 ..= i + 0.4`. All ticks are black. Rows stack upward, so the
/// first row sits at `y = 0`.
///
/// Each tick is a plain [`Line2D`], so the ticks fold into autoscaling via
/// [`data_limits`](Axes::data_limits).
///
/// Only the vertical orientation is supported today; a horizontal
/// orientation (ticks stacked along the x-axis) is a follow-up.
///
/// ```
/// use rizzma::core::Bbox;
/// use rizzma::figure::Axes;
///
/// let mut ax = Axes::new(Bbox::from_extents(0.0, 0.0, 1.0, 1.0));
/// let row0 = [0.0, 1.0, 2.0];
/// let row1 = [0.5, 1.5];
/// ax.eventplot(&[&row0, &row1]);
/// let limits = ax.data_limits().expect("eventplot contributes data limits");
/// assert_eq!((limits.xmin(), limits.xmax()), (0.0, 2.0));
/// // Two rows centered at y = 0 and y = 1, each 0.8 tall.
/// assert_eq!((limits.ymin(), limits.ymax()), (-0.4, 1.4));
/// ```
pub fn eventplot(&mut self, positions: &[&[f64]]) {
let half = EVENT_LINE_LENGTH / 2.0;
for (i, row) in positions.iter().enumerate() {
let y = i as f64;
for &xp in row.iter() {
self.add_line(
Line2D::new(vec![xp, xp], vec![y - half, y + half]).with_color(Rgba::BLACK),
);
}
}
}
/// Fill the region between the curves `(x1, y)` and `(x2, y)`.
///
/// The x/y transpose of [`fill_between`](Axes::fill_between): builds one
/// closed [`Patch::polygon`] tracing `(x1, y)` forward (bottom-to-top in `y`)
/// then `(x2, y)` in reverse. The default face is a semi-transparent C0 blue
/// (alpha `0.4`) with no edge.
///
/// The patch folds into autoscaling via [`data_limits`](Axes::data_limits).
///
/// # Panics
///
/// Panics if `y`, `x1`, and `x2` do not all have the same length.
///
/// ```
/// use rizzma::core::Bbox;
/// use rizzma::figure::Axes;
///
/// let mut ax = Axes::new(Bbox::from_extents(0.0, 0.0, 1.0, 1.0));
/// let y = [0.0, 1.0, 2.0];
/// let x1 = [0.0, 0.5, 0.0];
/// let x2 = [1.0, 1.5, 1.0];
/// ax.fill_betweenx(&y, &x1, &x2);
/// let limits = ax.data_limits().expect("fill_betweenx contributes data limits");
/// assert_eq!((limits.xmin(), limits.xmax()), (0.0, 1.5));
/// assert_eq!((limits.ymin(), limits.ymax()), (0.0, 2.0));
/// ```
pub fn fill_betweenx(&mut self, y: &[f64], x1: &[f64], x2: &[f64]) {
assert!(
y.len() == x1.len() && y.len() == x2.len(),
"fill_betweenx requires equal lengths (got y: {}, x1: {}, x2: {})",
y.len(),
x1.len(),
x2.len()
);
let n = y.len();
if n == 0 {
return;
}
let mut points: Vec<[f64; 2]> = Vec::with_capacity(2 * n);
for i in 0..n {
points.push([x1[i], y[i]]);
}
for i in (0..n).rev() {
points.push([x2[i], y[i]]);
}
let face = self
.cycle_color(self.prop_cycle_index)
.with_alpha(FILL_BETWEENX_ALPHA);
let patch = Patch::polygon(&points)
.facecolor(Some(face))
.edgecolor(None);
self.add_patch(patch);
}
/// Draw the empirical cumulative distribution function (ECDF) of `data`.
///
/// Mirrors matplotlib's `ecdf`. A sorted copy of `data` produces a rising
/// staircase: the cumulative probability starts at `y = 0` and steps up by
/// `1/n` at each sorted sample, reaching `y = 1` at the largest sample. The
/// staircase uses a `"post"` step convention — the value `k/n` holds from
/// sample `k` up to (but not including) sample `k + 1`, so each vertical
/// riser sits at a sample's x-value. The line takes the next property-cycle
/// color (advancing it once).
///
/// The staircase folds into autoscaling via
/// [`data_limits`](Axes::data_limits); empty `data` draws nothing.
///
/// ```
/// use rizzma::core::Bbox;
/// use rizzma::figure::Axes;
///
/// let mut ax = Axes::new(Bbox::from_extents(0.0, 0.0, 1.0, 1.0));
/// ax.ecdf(&[3.0, 1.0, 2.0]);
/// let limits = ax.data_limits().expect("ecdf contributes data limits");
/// // Steps span the sorted samples and the CDF reaches 1.
/// assert_eq!((limits.xmin(), limits.xmax()), (1.0, 3.0));
/// assert_eq!((limits.ymin(), limits.ymax()), (0.0, 1.0));
/// ```
pub fn ecdf(&mut self, data: &[f64]) {
let n = data.len();
if n == 0 {
return;
}
let mut sorted = data.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).expect("ecdf data must be comparable"));
// "post" staircase: start at the first sample at y = 0, then at each
// sample step up by 1/n and hold across to the next sample.
let mut sx: Vec<f64> = Vec::with_capacity(2 * n);
let mut sy: Vec<f64> = Vec::with_capacity(2 * n);
sx.push(sorted[0]);
sy.push(0.0);
for (k, &x) in sorted.iter().enumerate() {
let y = (k + 1) as f64 / n as f64;
// Riser at this sample, then hold across to the next sample.
sx.push(x);
sy.push(y);
if k + 1 < n {
sx.push(sorted[k + 1]);
sy.push(y);
}
}
let color = self.next_cycle_color();
self.add_line(Line2D::new(sx, sy).with_color(color));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::artist::Artist;
use crate::core::{Affine2D, Bbox, Path};
use crate::render::{GraphicsContext, Renderer};
fn approx(a: f64, b: f64) {
assert!((a - b).abs() < 1e-9, "expected {b}, got {a}");
}
/// A [`Renderer`] that records the vertices of each stroked path, used to
/// read back a [`Line2D`]'s outline.
#[derive(Default)]
struct VertexRecorder {
paths: Vec<Vec<[f64; 2]>>,
}
impl Renderer for VertexRecorder {
fn draw_path(
&mut self,
_gc: &GraphicsContext,
path: &Path,
_transform: &Affine2D,
_fill: Option<Rgba>,
) {
self.paths.push(path.vertices().to_vec());
}
fn canvas_size(&self) -> (f64, f64) {
(100.0, 100.0)
}
}
fn line_vertices(line: &Line2D) -> Vec<[f64; 2]> {
let mut r = VertexRecorder::default();
line.draw(&mut r, &Affine2D::identity());
r.paths.into_iter().next().expect("line draws one path")
}
#[test]
fn eventplot_adds_one_tick_per_event() {
let mut ax = Axes::new(Bbox::from_extents(0.0, 0.0, 1.0, 1.0));
let row0 = [0.0, 1.0, 2.0];
let row1 = [0.5, 1.5];
let row2 = [3.0];
ax.eventplot(&[&row0, &row1, &row2]);
// One tick line per event across all rows.
assert_eq!(ax.lines.len(), row0.len() + row1.len() + row2.len());
}
#[test]
fn eventplot_data_limits_cover_ticks() {
let mut ax = Axes::new(Bbox::from_extents(0.0, 0.0, 1.0, 1.0));
let row0 = [0.0, 1.0, 2.0];
let row1 = [0.5, 1.5];
ax.eventplot(&[&row0, &row1]);
let e = ax.data_limits().expect("eventplot contributes data limits");
approx(e.xmin(), 0.0);
approx(e.xmax(), 2.0);
// Row 0 centered at y = 0, row 1 at y = 1, each 0.8 tall.
approx(e.ymin(), -0.4);
approx(e.ymax(), 1.4);
}
#[test]
fn fill_betweenx_adds_one_polygon_covering_the_data() {
let mut ax = Axes::new(Bbox::from_extents(0.0, 0.0, 1.0, 1.0));
let y = [0.0, 1.0, 2.0];
let x1 = [0.0, 0.5, 0.0];
let x2 = [1.0, 1.5, 1.0];
ax.fill_betweenx(&y, &x1, &x2);
assert_eq!(ax.patches.len(), 1);
let p = &ax.patches[0];
// The polygon is closed (first vertex repeated at the end).
let verts = p.path().vertices();
assert_eq!(verts.first(), verts.last());
let e = p.data_extents().expect("polygon has extents");
approx(e.xmin(), 0.0);
approx(e.xmax(), 1.5);
approx(e.ymin(), 0.0);
approx(e.ymax(), 2.0);
}
#[test]
#[should_panic(expected = "fill_betweenx requires equal lengths")]
fn fill_betweenx_length_mismatch_panics() {
let mut ax = Axes::new(Bbox::from_extents(0.0, 0.0, 1.0, 1.0));
ax.fill_betweenx(&[0.0, 1.0], &[0.0], &[1.0, 2.0]);
}
#[test]
fn ecdf_builds_staircase_reaching_one() {
let mut ax = Axes::new(Bbox::from_extents(0.0, 0.0, 1.0, 1.0));
ax.ecdf(&[3.0, 1.0, 2.0]);
assert_eq!(ax.lines.len(), 1);
let pts = line_vertices(&ax.lines[0]);
// "post" staircase over sorted [1, 2, 3] at increments of 1/3:
// (1,0)-(1,1/3)-(2,1/3)-(2,2/3)-(3,2/3)-(3,1).
let third = 1.0 / 3.0;
let expected = [
[1.0, 0.0],
[1.0, third],
[2.0, third],
[2.0, 2.0 * third],
[3.0, 2.0 * third],
[3.0, 1.0],
];
assert_eq!(pts.len(), expected.len());
for (p, ex) in pts.iter().zip(expected.iter()) {
approx(p[0], ex[0]);
approx(p[1], ex[1]);
}
// The CDF tops out at y = 1.
let e = ax.data_limits().expect("ecdf contributes data limits");
approx(e.ymin(), 0.0);
approx(e.ymax(), 1.0);
approx(e.xmin(), 1.0);
approx(e.xmax(), 3.0);
}
#[test]
fn ecdf_empty_input_draws_nothing() {
let mut ax = Axes::new(Bbox::from_extents(0.0, 0.0, 1.0, 1.0));
ax.ecdf(&[]);
assert!(ax.lines.is_empty());
}
}