use crate::artist::Line2D;
use crate::core::color::{Colormap, LinearNorm, Normalize, default_colormap};
use crate::figure::Axes;
const DEFAULT_N_LEVELS: usize = 7;
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ContourLabelCandidate {
pub(crate) line_index: usize,
pub(crate) position: (f64, f64),
pub(crate) level: f64,
pub(crate) color: crate::core::color::Rgba,
}
fn data_min_max(data: &[f64]) -> (f64, f64) {
let mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
for &v in data {
if v.is_finite() {
min = min.min(v);
max = max.max(v);
}
}
if min <= max { (min, max) } else { (0.0, 1.0) }
}
fn crossing(a: f64, b: f64, level: f64) -> f64 {
(level - a) / (b - a)
}
fn cell_segments(corners: [f64; 4], r: f64, c: f64, level: f64, segments: &mut Vec<[[f64; 2]; 2]>) {
let [tl, tr, bl, br] = corners;
let mut case = 0u8;
if tl > level {
case |= 8;
}
if tr > level {
case |= 4;
}
if br > level {
case |= 2;
}
if bl > level {
case |= 1;
}
let top = || [c + crossing(tl, tr, level), r];
let right = || [c + 1.0, r + crossing(tr, br, level)];
let bottom = || [c + crossing(bl, br, level), r + 1.0];
let left = || [c, r + crossing(tl, bl, level)];
match case {
0 | 15 => {}
1 | 14 => segments.push([left(), bottom()]),
2 | 13 => segments.push([bottom(), right()]),
4 | 11 => segments.push([top(), right()]),
8 | 7 => segments.push([left(), top()]),
3 | 12 => segments.push([left(), right()]),
6 | 9 => segments.push([top(), bottom()]),
5 | 10 => {
let avg = (tl + tr + bl + br) / 4.0;
if (case == 5) == (avg > level) {
segments.push([top(), right()]);
segments.push([left(), bottom()]);
} else {
segments.push([left(), top()]);
segments.push([bottom(), right()]);
}
}
_ => unreachable!("marching-squares case is a 4-bit value"),
}
}
fn level_segments(z: &[f64], nrows: usize, ncols: usize, level: f64) -> Vec<[[f64; 2]; 2]> {
let mut segments = Vec::new();
if nrows < 2 || ncols < 2 {
return segments;
}
for r in 0..nrows - 1 {
for c in 0..ncols - 1 {
let tl = z[r * ncols + c];
let tr = z[r * ncols + c + 1];
let bl = z[(r + 1) * ncols + c];
let br = z[(r + 1) * ncols + c + 1];
cell_segments([tl, tr, bl, br], r as f64, c as f64, level, &mut segments);
}
}
segments
}
impl Axes {
pub fn contour(&mut self, z: &[f64], nrows: usize, ncols: usize) {
self.contour_levels(z, nrows, ncols, DEFAULT_N_LEVELS);
}
pub fn contour_levels(&mut self, z: &[f64], nrows: usize, ncols: usize, n_levels: usize) {
assert_eq!(
z.len(),
nrows * ncols,
"contour: z length {} must equal nrows * ncols = {}",
z.len(),
nrows * ncols
);
self.contour_label_candidates.clear();
if ncols >= 1 && nrows >= 1 {
self.include_data_bbox(0.0, 0.0, (ncols - 1) as f64, (nrows - 1) as f64);
self.sticky_x.push(0.0);
self.sticky_x.push((ncols - 1) as f64);
self.sticky_y.push(0.0);
self.sticky_y.push((nrows - 1) as f64);
}
let (zmin, zmax) = data_min_max(z);
if zmax <= zmin || n_levels == 0 {
return;
}
let norm = LinearNorm::new(zmin, zmax);
let cmap = default_colormap();
let span = zmax - zmin;
for k in 0..n_levels {
let level = zmin + (k + 1) as f64 / (n_levels + 1) as f64 * span;
let color = cmap.sample(norm.normalize(level));
let segments = level_segments(z, nrows, ncols, level);
let center = ((ncols - 1) as f64 / 2.0, (nrows - 1) as f64 / 2.0);
let label_segment = segments
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| {
let distance = |segment: &&[[f64; 2]; 2]| {
let x = (segment[0][0] + segment[1][0]) / 2.0;
let y = (segment[0][1] + segment[1][1]) / 2.0;
(x - center.0).hypot(y - center.1)
};
distance(a).total_cmp(&distance(b))
})
.map(|(index, _)| index);
for (segment_index, [p0, p1]) in segments.into_iter().enumerate() {
let line_index = self.lines.len();
self.add_line(
Line2D::new(vec![p0[0], p1[0]], vec![p0[1], p1[1]]).with_color(color),
);
if Some(segment_index) == label_segment {
self.contour_label_candidates.push(ContourLabelCandidate {
line_index,
position: ((p0[0] + p1[0]) / 2.0, (p0[1] + p1[1]) / 2.0),
level,
color,
});
}
}
}
}
pub fn clabel(&mut self) -> &mut Self {
let mut candidates = std::mem::take(&mut self.contour_label_candidates);
candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.line_index));
for candidate in candidates {
if candidate.line_index < self.lines.len() {
self.lines.remove(candidate.line_index);
}
self.add_contour_label(
format_contour_level(candidate.level),
candidate.position,
candidate.color,
);
}
self
}
}
fn format_contour_level(level: f64) -> String {
let mut label = format!("{level:.3}");
while label.contains('.') && label.ends_with('0') {
label.pop();
}
if label.ends_with('.') {
label.pop();
}
label
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Bbox;
fn approx(a: f64, b: f64) {
assert!((a - b).abs() < 1e-9, "expected {b}, got {a}");
}
#[test]
fn ramp_field_produces_vertical_contours_at_expected_x() {
let z = [0.0, 1.0, 2.0, 0.0, 1.0, 2.0, 0.0, 1.0, 2.0];
for level in [1.0, 1.5] {
let segs = level_segments(&z, 3, 3, level);
assert!(!segs.is_empty(), "level {level} should cross the field");
for seg in &segs {
approx(seg[0][0], level);
approx(seg[1][0], level);
}
}
}
#[test]
fn single_corner_above_yields_one_segment() {
let z = [2.0, 0.0, 0.0, 0.0];
let segs = level_segments(&z, 2, 2, 1.0);
assert_eq!(segs.len(), 1);
let [a, b] = segs[0];
approx(a[0], 0.0);
approx(a[1], 0.5);
approx(b[0], 0.5);
approx(b[1], 0.0);
}
#[test]
fn flat_field_draws_no_lines_but_records_limits() {
let mut ax = Axes::new(Bbox::from_extents(0.0, 0.0, 1.0, 1.0));
ax.contour(&[3.0, 3.0, 3.0, 3.0], 2, 2);
assert!(ax.lines.is_empty());
let e = ax.data_limits().expect("grid extent is recorded");
approx(e.xmin(), 0.0);
approx(e.xmax(), 1.0);
approx(e.ymin(), 0.0);
approx(e.ymax(), 1.0);
}
#[test]
fn data_limits_cover_the_grid() {
let mut ax = Axes::new(Bbox::from_extents(0.0, 0.0, 1.0, 1.0));
let (nr, nc) = (4usize, 5usize);
let z: Vec<f64> = (0..nr * nc).map(|i| (i % nc) as f64).collect();
ax.contour(&z, nr, nc);
let e = ax.data_limits().expect("contour records data limits");
approx(e.xmin(), 0.0);
approx(e.xmax(), (nc - 1) as f64);
approx(e.ymin(), 0.0);
approx(e.ymax(), (nr - 1) as f64);
}
#[test]
fn default_contour_uses_seven_levels() {
let mut ax = Axes::new(Bbox::from_extents(0.0, 0.0, 1.0, 1.0));
let z: Vec<f64> = (0..2 * 9).map(|i| (i % 9) as f64).collect();
ax.contour(&z, 2, 9);
assert_eq!(ax.lines.len(), 7);
}
#[test]
fn clabel_adds_one_label_and_break_per_level() {
let mut ax = Axes::new(Bbox::unit());
let z: Vec<f64> = (0..2 * 9).map(|i| (i % 9) as f64).collect();
ax.contour_levels(&z, 2, 9, 3);
let lines_before = ax.lines.len();
assert_eq!(ax.contour_label_candidates.len(), 3);
ax.clabel();
assert_eq!(ax.lines.len(), lines_before - 3);
assert_eq!(ax.annotation_count(), 3);
assert!(ax.contour_label_candidates.is_empty());
}
#[test]
fn contour_level_format_is_compact() {
assert_eq!(format_contour_level(1.0), "1");
assert_eq!(format_contour_level(1.25), "1.25");
}
}