plotkit_core/charts/scatter.rs
1//! Scatter chart builder methods.
2//!
3//! This module extends [`ScatterArtist`] with a fluent API for configuring
4//! scatter plot properties. Since [`Axes::scatter`] returns
5//! `Result<&mut ScatterArtist>`, these builder methods can be chained
6//! directly on the return value:
7//!
8//! ```ignore
9//! ax.scatter(&x, &y)?
10//! .color(Color::TAB_ORANGE)
11//! .marker(Marker::Diamond)
12//! .size(8.0)
13//! .label("Observations")
14//! .alpha(0.7);
15//! ```
16
17use crate::artist::ScatterArtist;
18use crate::colormap::Colormap;
19use crate::decimate::{DecimateMethod, DecimateMode};
20use crate::primitives::Color;
21use crate::theme::Marker;
22
23impl ScatterArtist {
24 /// Sets the marker color.
25 ///
26 /// Applies the given [`Color`] to every marker rendered by this artist,
27 /// unless per-point colors have been set via [`colors`](Self::colors).
28 ///
29 /// # Arguments
30 ///
31 /// * `color` - The [`Color`] to fill each marker with.
32 ///
33 /// # Examples
34 ///
35 /// ```ignore
36 /// artist.color(Color::TAB_RED);
37 /// ```
38 pub fn color(&mut self, color: Color) -> &mut Self {
39 self.color = color;
40 self
41 }
42
43 /// Sets the marker shape.
44 ///
45 /// The [`Marker`] enum defines the available shapes (circle, square,
46 /// triangle, diamond, plus, cross, star, point). The default is
47 /// [`Marker::Circle`].
48 ///
49 /// # Arguments
50 ///
51 /// * `marker` - The [`Marker`] variant to use for every data point.
52 ///
53 /// # Examples
54 ///
55 /// ```ignore
56 /// artist.marker(Marker::Triangle);
57 /// ```
58 pub fn marker(&mut self, marker: Marker) -> &mut Self {
59 self.marker = marker;
60 self
61 }
62
63 /// Sets the marker size in pixels.
64 ///
65 /// This controls the diameter of each marker glyph. Larger values
66 /// produce more prominent data points; smaller values suit dense
67 /// scatter plots.
68 ///
69 /// # Arguments
70 ///
71 /// * `size` - The marker diameter in device-independent pixels.
72 ///
73 /// # Examples
74 ///
75 /// ```ignore
76 /// artist.size(10.0);
77 /// ```
78 pub fn size(&mut self, size: f64) -> &mut Self {
79 self.size = size;
80 self
81 }
82
83 /// Sets the legend label for this scatter series.
84 ///
85 /// When a label is set, the scatter series will appear in the legend
86 /// if one is displayed on the axes. Pass an empty string or omit this
87 /// call to exclude the series from the legend.
88 ///
89 /// # Arguments
90 ///
91 /// * `label` - A string slice that will be stored as the legend entry.
92 ///
93 /// # Examples
94 ///
95 /// ```ignore
96 /// artist.label("Measurements");
97 /// ```
98 pub fn label(&mut self, label: &str) -> &mut Self {
99 self.label = Some(label.to_string());
100 self
101 }
102
103 /// Sets the opacity (0.0 = fully transparent, 1.0 = fully opaque).
104 ///
105 /// The value is clamped to the `[0.0, 1.0]` range. The default opacity
106 /// is determined by the active theme (typically `0.8`).
107 ///
108 /// # Arguments
109 ///
110 /// * `alpha` - The desired opacity level.
111 ///
112 /// # Examples
113 ///
114 /// ```ignore
115 /// artist.alpha(0.5); // 50% transparent
116 /// ```
117 pub fn alpha(&mut self, alpha: f64) -> &mut Self {
118 self.alpha = alpha.clamp(0.0, 1.0);
119 self
120 }
121
122 /// Sets per-point colors, overriding the single uniform color.
123 ///
124 /// When set, each data point is rendered with its corresponding color
125 /// from the vector. The length of `colors` must equal the number of
126 /// data points (`x.len()` and `y.len()`). This is commonly used to
127 /// map a third variable to a colormap.
128 ///
129 /// Calling [`color`](Self::color) after this method does not clear the
130 /// per-point colors; the per-point colors take precedence during
131 /// rendering.
132 ///
133 /// # Arguments
134 ///
135 /// * `colors` - A vector of [`Color`] values, one per data point.
136 ///
137 /// # Examples
138 ///
139 /// ```ignore
140 /// artist.colors(vec![Color::TAB_BLUE, Color::TAB_RED, Color::TAB_GREEN]);
141 /// ```
142 pub fn colors(&mut self, colors: Vec<Color>) -> &mut Self {
143 self.colors = Some(colors);
144 self
145 }
146
147 /// Sets per-point scalar values for colormap-driven coloring.
148 ///
149 /// When combined with [`cmap`](Self::cmap), each scalar value is mapped
150 /// through the colormap to produce a per-point color. The length of `c`
151 /// must equal the number of data points. This takes precedence over
152 /// both the uniform [`color`](Self::color) and [`colors`](Self::colors).
153 ///
154 /// # Arguments
155 ///
156 /// * `c` - A vector of scalar values, one per data point.
157 ///
158 /// # Examples
159 ///
160 /// ```ignore
161 /// artist.c(vec![0.0, 0.5, 1.0]).cmap(Colormap::Viridis);
162 /// ```
163 pub fn c(&mut self, c: Vec<f64>) -> &mut Self {
164 self.c = Some(c);
165 self
166 }
167
168 /// Sets the colormap used to map `c` values to colors.
169 ///
170 /// Must be used together with [`c`](Self::c) to have any effect. When
171 /// both are set, the scatter plot renders each point with a color
172 /// determined by mapping its `c` value through the given colormap.
173 ///
174 /// # Arguments
175 ///
176 /// * `cmap` - The [`Colormap`] variant to use.
177 ///
178 /// # Examples
179 ///
180 /// ```ignore
181 /// artist.c(values).cmap(Colormap::Plasma);
182 /// ```
183 pub fn cmap(&mut self, cmap: Colormap) -> &mut Self {
184 self.cmap = Some(cmap);
185 self
186 }
187
188 /// Enables LTTB decimation with the given explicit point threshold.
189 ///
190 /// When the data series length exceeds `threshold`, the rendering pipeline
191 /// downsamples the points using the Largest Triangle Three Buckets
192 /// algorithm before drawing. Per-point styling (`colors`, `c`) stays
193 /// synchronized with the surviving points.
194 ///
195 /// This overrides the default [`DecimateMode::Auto`] behavior with an
196 /// explicit threshold. To disable decimation entirely, use
197 /// [`no_decimate`](Self::no_decimate).
198 ///
199 /// # Examples
200 ///
201 /// ```ignore
202 /// ax.scatter(&x, &y)?.decimate(2000);
203 /// ```
204 pub fn decimate(&mut self, threshold: usize) -> &mut Self {
205 self.decimate = DecimateMode::Explicit(threshold, DecimateMethod::Lttb);
206 self
207 }
208
209 /// Enables decimation with a specific method and explicit point threshold.
210 ///
211 /// Available methods:
212 /// - [`DecimateMethod::Lttb`] — best visual fidelity (default)
213 /// - [`DecimateMethod::MinMax`] — fastest, preserves peaks/troughs
214 ///
215 /// # Examples
216 ///
217 /// ```ignore
218 /// ax.scatter(&x, &y)?.decimate_with(2000, DecimateMethod::MinMax);
219 /// ```
220 pub fn decimate_with(&mut self, threshold: usize, method: DecimateMethod) -> &mut Self {
221 self.decimate = DecimateMode::Explicit(threshold, method);
222 self
223 }
224
225 /// Disables decimation entirely, drawing every point.
226 ///
227 /// By default large series are auto-decimated via [`DecimateMode::Auto`].
228 /// Call this when every marker must be rendered regardless of series size.
229 ///
230 /// # Examples
231 ///
232 /// ```ignore
233 /// ax.scatter(&x, &y)?.no_decimate();
234 /// ```
235 pub fn no_decimate(&mut self) -> &mut Self {
236 self.decimate = DecimateMode::Off;
237 self
238 }
239}
240
241// ---------------------------------------------------------------------------
242// Tests
243// ---------------------------------------------------------------------------
244
245#[cfg(test)]
246mod tests {
247 use crate::axes::Axes;
248 use crate::decimate::{DecimateMethod, DecimateMode, DEFAULT_DECIMATE_THRESHOLD};
249
250 fn make_axes() -> Axes {
251 Axes::new()
252 }
253
254 #[test]
255 fn scatter_defaults_to_auto_decimate() {
256 let mut ax = make_axes();
257 let artist = ax.scatter([0.0, 1.0, 2.0], [0.0, 1.0, 2.0]).unwrap();
258 assert_eq!(artist.decimate, DecimateMode::Auto);
259 }
260
261 #[test]
262 fn scatter_decimate_sets_explicit_lttb() {
263 let mut ax = make_axes();
264 let artist = ax.scatter([0.0, 1.0], [0.0, 1.0]).unwrap();
265 artist.decimate(2000);
266 assert_eq!(
267 artist.decimate,
268 DecimateMode::Explicit(2000, DecimateMethod::Lttb)
269 );
270 }
271
272 #[test]
273 fn scatter_decimate_with_sets_explicit_method() {
274 let mut ax = make_axes();
275 let artist = ax.scatter([0.0, 1.0], [0.0, 1.0]).unwrap();
276 artist.decimate_with(750, DecimateMethod::MinMax);
277 assert_eq!(
278 artist.decimate,
279 DecimateMode::Explicit(750, DecimateMethod::MinMax)
280 );
281 }
282
283 #[test]
284 fn scatter_no_decimate_disables() {
285 let mut ax = make_axes();
286 let artist = ax.scatter([0.0, 1.0], [0.0, 1.0]).unwrap();
287 artist.no_decimate();
288 assert_eq!(artist.decimate, DecimateMode::Off);
289 }
290
291 #[test]
292 fn scatter_auto_kicks_in_above_threshold() {
293 let n = DEFAULT_DECIMATE_THRESHOLD + 2500;
294 let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
295 let y: Vec<f64> = x.iter().map(|v| (v * 0.02).cos()).collect();
296 let indices = DecimateMode::Auto.resolve_indices(&x, &y);
297 assert_eq!(indices.len(), DEFAULT_DECIMATE_THRESHOLD);
298 assert_eq!(*indices.first().unwrap(), 0);
299 assert_eq!(*indices.last().unwrap(), n - 1);
300 }
301
302 #[test]
303 fn scatter_auto_no_op_below_threshold() {
304 let n = 100;
305 let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
306 let y = x.clone();
307 let indices = DecimateMode::Auto.resolve_indices(&x, &y);
308 assert_eq!(indices.len(), n);
309 assert_eq!(indices, (0..n).collect::<Vec<_>>());
310 }
311
312 #[test]
313 fn scatter_no_decimate_keeps_all_points() {
314 let n = DEFAULT_DECIMATE_THRESHOLD * 3;
315 let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
316 let y = x.clone();
317 let indices = DecimateMode::Off.resolve_indices(&x, &y);
318 assert_eq!(indices.len(), n);
319 assert_eq!(*indices.first().unwrap(), 0);
320 assert_eq!(*indices.last().unwrap(), n - 1);
321 }
322
323 #[test]
324 fn scatter_explicit_minmax_overrides_auto() {
325 let n = 8000;
326 let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
327 let y: Vec<f64> = x.iter().map(|v| (v * 0.03).sin()).collect();
328 let indices = DecimateMode::Explicit(300, DecimateMethod::MinMax).resolve_indices(&x, &y);
329 assert!(indices.len() <= 300);
330 assert_eq!(*indices.first().unwrap(), 0);
331 assert_eq!(*indices.last().unwrap(), n - 1);
332 }
333}