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
use crate::Precision;
use crate::chart::Chart;
use crate::core::context::PanelContext;
use crate::core::layer::{MarkRenderer, PathConfig, RenderBackend};
use crate::core::utils::Parallelizable;
use crate::error::ChartonError;
use crate::mark::line::MarkLine;
use crate::visual::color::SingleColor;
#[cfg(feature = "parallel")]
use rayon::prelude::*;
/// Interpolation methods for line paths
#[derive(Debug, Clone, Default)]
pub enum PathInterpolation {
/// Straight line segments between points (default)
#[default]
Linear,
/// Step function that holds value until next point (appropriate for ECDF)
StepAfter,
/// Step function that jumps to next value immediately
StepBefore,
}
/// Implements conversion from string slices to `PathInterpolation`.
///
/// This enables a more ergonomic Fluent API, allowing users to pass string literals
/// like `.interpolation("step")` instead of the more verbose `PathInterpolation::StepAfter`.
impl From<&str> for PathInterpolation {
/// Performs the conversion.
///
/// # Arguments
/// * `s` - A string slice representing the interpolation method (case-insensitive).
fn from(s: &str) -> Self {
// Convert to lowercase to ensure the API is case-insensitive (e.g., "Linear" vs "linear").
match s.to_lowercase().as_str() {
// Step-after: The value changes at the next data point.
// Often used for step functions or ECDF visualizations.
"step" | "step-after" => PathInterpolation::StepAfter,
// Step-before: The value changes immediately at the current data point.
"step-before" => PathInterpolation::StepBefore,
// Linear: Simple straight line segments between data points (Standard).
"linear" => PathInterpolation::Linear,
// Fallback: If the input string is unrecognized, default to Linear interpolation
// to ensure the rendering pipeline does not fail.
_ => PathInterpolation::Linear,
}
}
}
// ============================================================================
// MARK RENDERING
// ============================================================================
impl MarkRenderer for Chart<MarkLine> {
/// Transforms grouped raw data into connected paths (lines).
/// Handles aesthetics, statistical smoothing (LOESS), and interpolation
/// while maintaining Z-index order based on data appearance.
fn render_marks(
&self,
backend: &mut dyn RenderBackend,
context: &PanelContext,
) -> Result<(), ChartonError> {
let ds = &self.data;
if ds.row_count == 0 {
return Ok(());
}
let mark_config = self
.mark
.as_ref()
.ok_or_else(|| ChartonError::Mark("MarkLine configuration is missing".into()))?;
// --- STEP 1: SPECIFICATION VALIDATION & SCALING ---
let x_enc = self
.encoding
.x
.as_ref()
.ok_or_else(|| ChartonError::Encoding("X is missing".into()))?;
let y_enc = self
.encoding
.y
.as_ref()
.ok_or_else(|| ChartonError::Encoding("Y is missing".into()))?;
let x_scale = context.coord.get_x_scale();
let y_scale = context.coord.get_y_scale();
// Vectorized normalization of primary coordinates
let x_norms = x_scale
.scale_type()
.normalize_column(x_scale, ds.column(&x_enc.field)?);
let y_norms = y_scale
.scale_type()
.normalize_column(y_scale, ds.column(&y_enc.field)?);
// Pre-normalize color column if a mapping exists (handles both Discrete and Continuous)
let color_norms = context.spec.aesthetics.color.as_ref().map(|m| {
let s = m.scale_impl.as_ref();
s.scale_type()
.normalize_column(s, ds.column(&m.field).unwrap())
});
// --- STEP 2: GROUPING (Determining Path Separation) ---
// Groups are sorted by "First Appearance" to ensure deterministic Z-indexing.
let group_field = context.spec.aesthetics.color.as_ref().map(|c| &c.field);
let grouped_indices = ds.group_by(group_field.map(|s| s.as_str()));
// --- STEP 3: PARALLEL PATH CALCULATION ---
let line_render_data: Vec<_> = grouped_indices
.groups
.maybe_par_iter()
.filter_map(|(_group_key, row_indices)| {
let first_idx = *row_indices.first()?;
// 3.1 Data Extraction: Filter out rows with missing X or Y values
let mut points: Vec<(f64, f64)> = row_indices
.iter()
.filter_map(|&idx| match (x_norms[idx], y_norms[idx]) {
(Some(xn), Some(yn)) => Some((xn, yn)),
_ => None,
})
.collect();
if points.is_empty() {
return None;
}
// 3.2 Sorting: Ensure line monotonicity along the X-axis
points.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
// 3.3 Statistical Smoothing: Optional LOESS processing
let proc_points = if mark_config.loess {
let xs: Vec<f64> = points.iter().map(|p| p.0).collect();
let ys: Vec<f64> = points.iter().map(|p| p.1).collect();
let (lx, ly) =
crate::stats::stat_loess::loess(&xs, &ys, mark_config.loess_bandwidth);
lx.into_iter().zip(ly).collect()
} else {
points
};
// 3.4 Projection: Convert normalized coordinates to pixel space
let projected: Vec<(f64, f64)> = proc_points
.into_iter()
.map(|(xn, yn)| context.coord.transform(xn, yn, &context.panel))
.collect();
// 3.5 Interpolation: Expand points for Step-before/after paths
let expanded = match mark_config.interpolation {
PathInterpolation::Linear => projected,
PathInterpolation::StepAfter => self.expand_step_after(projected),
PathInterpolation::StepBefore => self.expand_step_before(projected),
};
// 3.6 Unified Aesthetic Resolution:
// We resolve the color based on the first point's normalized value.
// This ensures symmetry with PointMark behavior.
let final_color = self.resolve_color_from_value(
color_norms.as_ref().and_then(|n| n[first_idx]),
context,
&mark_config.color,
);
Some((expanded, final_color))
})
.collect();
// --- STEP 4: SEQUENTIAL DRAW DISPATCH ---
// Lines are drawn in sequence to respect the Z-order established by grouping.
for (points, color) in line_render_data {
if points.is_empty() {
continue;
}
backend.draw_path(PathConfig {
points: points
.into_iter()
.map(|(px, py)| (px as Precision, py as Precision))
.collect(),
stroke: color,
stroke_width: mark_config.stroke_width as Precision,
opacity: mark_config.opacity as Precision,
dash: mark_config.dash.iter().map(|&d| d as Precision).collect(),
});
}
Ok(())
}
}
impl Chart<MarkLine> {
/// Injects corner points for Step-After interpolation.
/// Capacity is pre-allocated to avoid reallocations.
fn expand_step_after(&self, points: Vec<(f64, f64)>) -> Vec<(f64, f64)> {
if points.len() < 2 {
return points;
}
let mut expanded = Vec::with_capacity(points.len() * 2);
for i in 0..points.len() - 1 {
let (x1, y1) = points[i];
let (x2, _) = points[i + 1];
expanded.push((x1, y1));
expanded.push((x2, y1));
}
expanded.push(*points.last().unwrap());
expanded
}
fn expand_step_before(&self, points: Vec<(f64, f64)>) -> Vec<(f64, f64)> {
if points.len() < 2 {
return points;
}
let mut expanded = Vec::with_capacity(points.len() * 2);
for i in 0..points.len() - 1 {
let (x1, y1) = points[i];
let (_, y2) = points[i + 1];
expanded.push((x1, y1));
expanded.push((x1, y2));
}
expanded.push(*points.last().unwrap());
expanded
}
/// Optimized color resolution that maps a normalized value directly to a color.
///
/// # Arguments
/// * `val` - A normalized value in the range [0.0, 1.0].
/// For discrete data, this is the relative index of the category.
/// * `context` - The current rendering context containing scale mappings.
/// * `fallback` - Default color to use if no mapping is found or the value is null.
fn resolve_color_from_value(
&self,
val: Option<f64>,
context: &PanelContext,
fallback: &SingleColor,
) -> SingleColor {
// Only apply data-driven coloring if both a value and a mapping exist
if let (Some(v), Some(mapping)) = (val, &context.spec.aesthetics.color) {
let s_trait = mapping.scale_impl.as_ref();
// Note: 'v' is already normalized by the Scale, so we don't call normalize() again.
// We directly pass the normalized value to the mapper.
s_trait
.mapper()
.as_ref()
.map(|m| m.map_to_color(v, s_trait.logical_max()))
.unwrap_or(*fallback)
} else {
// Return static color from Mark configuration
*fallback
}
}
}