pub type Converter = Box<dyn Fn(f64, f64) -> String>;
pub struct PositionInfo {
converters: Vec<(String, Converter)>,
}
impl Default for PositionInfo {
fn default() -> Self {
Self::with_xy()
}
}
impl PositionInfo {
pub fn new(converters: Vec<(String, Converter)>) -> Self {
Self { converters }
}
pub fn with_xy() -> Self {
Self::new(vec![
("X".to_owned(), Box::new(|x: f64, _y: f64| format_value(x))),
("Y".to_owned(), Box::new(|_x: f64, y: f64| format_value(y))),
])
}
pub fn polar() -> Self {
Self::new(vec![
(
"Radius".to_owned(),
Box::new(|x: f64, y: f64| format_value(x.hypot(y))),
),
(
"Angle".to_owned(),
Box::new(|x: f64, y: f64| format_value(y.atan2(x).to_degrees())),
),
])
}
pub fn push(&mut self, label: impl Into<String>, converter: Converter) {
self.converters.push((label.into(), converter));
}
pub fn push_numeric(
&mut self,
label: impl Into<String>,
converter: impl Fn(f64, f64) -> f64 + 'static,
) {
self.converters.push((
label.into(),
Box::new(move |x, y| format_value(converter(x, y))),
));
}
pub fn len(&self) -> usize {
self.converters.len()
}
pub fn is_empty(&self) -> bool {
self.converters.is_empty()
}
pub fn values(&self, cursor: Option<[f64; 2]>) -> Vec<String> {
match cursor {
None => vec![EMPTY_PLACEHOLDER.to_owned(); self.converters.len()],
Some([x, y]) => self
.converters
.iter()
.map(|(_label, func)| func(x, y))
.collect(),
}
}
pub fn ui(&self, ui: &mut egui::Ui, cursor: Option<[f64; 2]>) {
ui.horizontal(|ui| {
let values = self.values(cursor);
for ((label, _func), value) in self.converters.iter().zip(values) {
ui.strong(format!("{label}:"));
ui.label(value);
ui.add_space(8.0);
}
});
}
pub fn ui_snapped(&self, ui: &mut egui::Ui, cursor: Option<[f64; 2]>, snapped: bool) {
ui.horizontal(|ui| {
let values = self.values(cursor);
for ((label, _func), value) in self.converters.iter().zip(values) {
ui.strong(format!("{label}:"));
if snapped {
ui.label(value);
} else {
ui.colored_label(egui::Color32::RED, value);
}
ui.add_space(8.0);
}
});
}
}
const EMPTY_PLACEHOLDER: &str = "------";
pub fn format_value(value: f64) -> String {
if value.is_nan() {
return "nan".to_owned();
}
if value.is_infinite() {
return if value < 0.0 {
"-inf".to_owned()
} else {
"inf".to_owned()
};
}
crate::widget::stats_widget::format_significant(value, 7)
}
pub const SNAP_THRESHOLD_DIST: f64 = 5.0;
pub const PICK_OFFSET: f64 = 3.0;
const PICK_TOP: u8 = 1 << 3;
const PICK_BOTTOM: u8 = 1 << 2;
const PICK_RIGHT: u8 = 1 << 1;
const PICK_LEFT: u8 = 1 << 0;
pub fn pick_polyline_indices(
xs: &[f64],
ys: &[f64],
has_line: bool,
x_min: f64,
x_max: f64,
y_min: f64,
y_max: f64,
) -> Vec<usize> {
let n = xs.len().min(ys.len());
let codes: Vec<u8> = (0..n)
.map(|i| {
u8::from(ys[i] > y_max) << 3
| u8::from(ys[i] < y_min) << 2
| u8::from(xs[i] > x_max) << 1
| u8::from(xs[i] < x_min)
})
.collect();
let mut indices: Vec<usize> = (0..n)
.filter(|&i| codes[i] == 0 && xs[i].is_finite() && ys[i].is_finite())
.collect();
if has_line {
for i in 0..n.saturating_sub(1) {
if codes[i] == 0 || codes[i + 1] == 0 || codes[i] & codes[i + 1] != 0 {
continue;
}
let (x0, y0, x1, y1) = (xs[i], ys[i], xs[i + 1], ys[i + 1]);
let code1 = codes[i + 1];
let x = if code1 & PICK_TOP != 0 {
Some(x0 + (x1 - x0) * (y_max - y0) / (y1 - y0))
} else if code1 & PICK_BOTTOM != 0 {
Some(x0 + (x1 - x0) * (y_min - y0) / (y1 - y0))
} else {
None
};
if let Some(x) = x
&& (x_min..=x_max).contains(&x)
{
indices.push(i);
continue;
}
let y = if code1 & PICK_RIGHT != 0 {
Some(y0 + (y1 - y0) * (x_max - x0) / (x1 - x0))
} else if code1 & PICK_LEFT != 0 {
Some(y0 + (y1 - y0) * (x_min - x0) / (x1 - x0))
} else {
None
};
if let Some(y) = y
&& (y_min..=y_max).contains(&y)
{
indices.push(i);
}
}
indices.sort_unstable();
}
indices
}
pub fn pick_filled_histogram(
edges: &[f64],
counts: &[f64],
baseline: f64,
x: f64,
y: f64,
) -> Option<usize> {
if counts.is_empty() || edges.len() != counts.len() + 1 {
return None;
}
let nan_fold = |acc: (f64, f64), &v: &f64| {
if v.is_nan() {
acc
} else {
(acc.0.min(v), acc.1.max(v))
}
};
let (x_min, x_max) = edges
.iter()
.fold((f64::INFINITY, f64::NEG_INFINITY), nan_fold);
let (v_min, v_max) = counts
.iter()
.fold((f64::INFINITY, f64::NEG_INFINITY), nan_fold);
let (y_min, y_max) = (v_min.min(0.0), v_max.max(0.0));
if !(x_min < x && x < x_max && y_min < y && y < y_max) {
return None; }
let index = edges
.partition_point(|&e| e < x)
.saturating_sub(1)
.min(counts.len() - 1);
let value = counts[index];
((baseline <= value && baseline <= y && y <= value)
|| (value < baseline && value <= y && y <= baseline))
.then_some(index)
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Snap {
pub index: usize,
pub data: [f64; 2],
}
pub fn snap_to_nearest(
cursor_px: [f64; 2],
candidates: &[([f64; 2], [f64; 2])],
threshold_px: f64,
) -> Option<Snap> {
let mut best: Option<Snap> = None;
let mut best_sq = threshold_px * threshold_px;
for (index, &(px, data)) in candidates.iter().enumerate() {
let dx = px[0] - cursor_px[0];
let dy = px[1] - cursor_px[1];
let sq = dx * dx + dy * dy;
if !sq.is_finite() {
continue;
}
if sq <= best_sq {
best = Some(Snap { index, data });
best_sq = sq;
}
}
best
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct SnappingMode(u8);
impl SnappingMode {
pub const DISABLED: Self = Self(0);
pub const CROSSHAIR: Self = Self(1 << 0);
pub const ACTIVE_ONLY: Self = Self(1 << 1);
pub const SYMBOLS_ONLY: Self = Self(1 << 2);
pub const CURVE: Self = Self(1 << 3);
pub const SCATTER: Self = Self(1 << 4);
#[must_use]
pub fn contains(self, flag: Self) -> bool {
self.0 & flag.0 == flag.0
}
#[must_use]
pub fn bits(self) -> u8 {
self.0
}
}
impl std::ops::BitOr for SnappingMode {
type Output = Self;
fn bitor(self, rhs: Self) -> Self {
Self(self.0 | rhs.0)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SnapItemKind {
Curve,
Histogram,
Scatter,
Other,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SnapItem {
pub kind: SnapItemKind,
pub visible: bool,
pub has_symbol: bool,
pub active: bool,
}
#[must_use]
pub fn snapping_candidates(mode: SnappingMode, items: &[SnapItem]) -> Vec<usize> {
let want_curve = mode.contains(SnappingMode::CURVE);
let want_scatter = mode.contains(SnappingMode::SCATTER);
if !want_curve && !want_scatter {
return Vec::new();
}
let active_only = mode.contains(SnappingMode::ACTIVE_ONLY);
let symbols_only = mode.contains(SnappingMode::SYMBOLS_ONLY);
items
.iter()
.enumerate()
.filter(|(_, item)| {
let kind_match = match item.kind {
SnapItemKind::Curve => want_curve,
SnapItemKind::Histogram => want_curve && !active_only,
SnapItemKind::Scatter => want_scatter,
SnapItemKind::Other => false,
};
if !kind_match {
return false;
}
if active_only {
if !item.active {
return false;
}
} else if !item.visible {
return false;
}
if symbols_only && !item.has_symbol {
return false;
}
true
})
.map(|(i, _)| i)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_xy() {
let p = PositionInfo::default();
assert_eq!(p.len(), 2);
let v = p.values(Some([3.0, 4.0]));
assert_eq!(v, vec!["3".to_owned(), "4".to_owned()]);
}
#[test]
fn no_cursor_shows_placeholder() {
let p = PositionInfo::default();
let v = p.values(None);
assert_eq!(v, vec!["------".to_owned(), "------".to_owned()]);
}
#[test]
fn polar_radius_is_hypot() {
let p = PositionInfo::polar();
let v = p.values(Some([3.0, 4.0]));
assert_eq!(v[0], "5");
assert_eq!(v[1], "53.1301");
}
#[test]
fn polar_angle_on_axes() {
let p = PositionInfo::polar();
let v = p.values(Some([1.0, 0.0]));
assert_eq!(v[0], "1");
assert_eq!(v[1], "0");
let v = p.values(Some([0.0, 1.0]));
assert_eq!(v[1], "90");
}
#[test]
fn custom_numeric_converter() {
let mut p = PositionInfo::new(vec![]);
p.push_numeric("Sum", |x, y| x + y);
assert!(!p.is_empty());
let v = p.values(Some([2.5, 1.5]));
assert_eq!(v, vec!["4".to_owned()]);
}
#[test]
fn custom_string_converter() {
let p = PositionInfo::new(vec![(
"Quad".to_owned(),
Box::new(|x: f64, y: f64| {
if x >= 0.0 && y >= 0.0 {
"I".to_owned()
} else {
"other".to_owned()
}
}),
)]);
assert_eq!(p.values(Some([1.0, 1.0])), vec!["I".to_owned()]);
assert_eq!(p.values(Some([-1.0, 1.0])), vec!["other".to_owned()]);
}
#[test]
fn format_value_seven_sig_figs() {
assert_eq!(format_value(1.0 / 3.0), "0.3333333");
assert_eq!(format_value(1234567.0), "1234567");
assert_eq!(format_value(12345678.0), "1.234568e+07");
}
#[test]
fn format_value_non_finite() {
assert_eq!(format_value(f64::NAN), "nan");
assert_eq!(format_value(f64::INFINITY), "inf");
assert_eq!(format_value(f64::NEG_INFINITY), "-inf");
}
#[test]
fn snap_picks_nearest_within_radius() {
let cursor = [10.0, 10.0];
let candidates = [
([13.0, 10.0], [1.0, 2.0]), ([10.0, 12.0], [3.0, 4.0]), ];
let snap = snap_to_nearest(cursor, &candidates, SNAP_THRESHOLD_DIST).unwrap();
assert_eq!(snap.index, 1);
assert_eq!(snap.data, [3.0, 4.0]);
}
#[test]
fn snap_returns_none_when_all_outside_radius() {
let cursor = [0.0, 0.0];
let candidates = [([6.0, 0.0], [1.0, 1.0])];
assert_eq!(
snap_to_nearest(cursor, &candidates, SNAP_THRESHOLD_DIST),
None
);
}
#[test]
fn snap_includes_point_exactly_on_radius() {
let cursor = [0.0, 0.0];
let candidates = [([5.0, 0.0], [7.0, 8.0])];
let snap = snap_to_nearest(cursor, &candidates, SNAP_THRESHOLD_DIST).unwrap();
assert_eq!(snap.data, [7.0, 8.0]);
}
#[test]
fn snap_skips_non_finite_candidates() {
let cursor = [0.0, 0.0];
let candidates = [([f64::NAN, 0.0], [9.0, 9.0]), ([2.0, 0.0], [1.0, 1.0])];
let snap = snap_to_nearest(cursor, &candidates, SNAP_THRESHOLD_DIST).unwrap();
assert_eq!(snap.index, 1);
assert_eq!(snap.data, [1.0, 1.0]);
}
#[test]
fn snap_empty_candidates_is_none() {
assert_eq!(snap_to_nearest([0.0, 0.0], &[], SNAP_THRESHOLD_DIST), None);
}
#[test]
fn snap_tie_resolves_to_later_candidate() {
let cursor = [0.0, 0.0];
let candidates = [([3.0, 0.0], [1.0, 1.0]), ([0.0, 3.0], [2.0, 2.0])];
let snap = snap_to_nearest(cursor, &candidates, SNAP_THRESHOLD_DIST).unwrap();
assert_eq!(snap.index, 1);
assert_eq!(snap.data, [2.0, 2.0]);
}
fn item(kind: SnapItemKind, visible: bool, has_symbol: bool, active: bool) -> SnapItem {
SnapItem {
kind,
visible,
has_symbol,
active,
}
}
#[test]
fn snapping_disabled_without_a_kind_flag() {
let items = [item(SnapItemKind::Curve, true, true, true)];
assert!(snapping_candidates(SnappingMode::DISABLED, &items).is_empty());
assert!(
snapping_candidates(
SnappingMode::ACTIVE_ONLY | SnappingMode::SYMBOLS_ONLY,
&items
)
.is_empty()
);
}
#[test]
fn snapping_curve_all_items_takes_visible_curves_and_histograms() {
let items = [
item(SnapItemKind::Curve, true, false, false),
item(SnapItemKind::Histogram, true, false, false),
item(SnapItemKind::Curve, false, false, false), item(SnapItemKind::Scatter, true, false, false), item(SnapItemKind::Other, true, false, false),
];
assert_eq!(snapping_candidates(SnappingMode::CURVE, &items), vec![0, 1]);
}
#[test]
fn snapping_scatter_all_items_takes_visible_scatters_only() {
let items = [
item(SnapItemKind::Scatter, true, false, false),
item(SnapItemKind::Curve, true, false, false),
item(SnapItemKind::Scatter, false, false, false), ];
assert_eq!(snapping_candidates(SnappingMode::SCATTER, &items), vec![0]);
assert_eq!(
snapping_candidates(SnappingMode::CURVE | SnappingMode::SCATTER, &items),
vec![0, 1]
);
}
#[test]
fn snapping_active_only_ignores_visibility_and_excludes_histograms() {
let items = [
item(SnapItemKind::Curve, false, false, true), item(SnapItemKind::Curve, true, false, false), item(SnapItemKind::Histogram, true, false, true), item(SnapItemKind::Scatter, false, false, true), ];
assert_eq!(
snapping_candidates(
SnappingMode::CURVE | SnappingMode::SCATTER | SnappingMode::ACTIVE_ONLY,
&items
),
vec![0, 3]
);
}
#[test]
fn snapping_symbols_only_drops_items_without_a_symbol() {
let items = [
item(SnapItemKind::Curve, true, true, false), item(SnapItemKind::Curve, true, false, false), ];
assert_eq!(
snapping_candidates(SnappingMode::CURVE | SnappingMode::SYMBOLS_ONLY, &items),
vec![0]
);
}
#[test]
fn snapping_mode_bits_and_contains() {
let mode = SnappingMode::CURVE | SnappingMode::ACTIVE_ONLY;
assert!(mode.contains(SnappingMode::CURVE));
assert!(mode.contains(SnappingMode::ACTIVE_ONLY));
assert!(!mode.contains(SnappingMode::SCATTER));
assert_eq!(mode.bits(), (1 << 3) | (1 << 1));
}
#[test]
fn pick_takes_vertices_inside_the_box() {
let xs = [0.0, 5.0, 10.0];
let ys = [0.0, 5.0, 10.0];
assert_eq!(
pick_polyline_indices(&xs, &ys, false, 4.0, 6.0, 4.0, 6.0),
vec![1]
);
assert_eq!(
pick_polyline_indices(&xs, &ys, false, 2.0, 3.0, 2.0, 3.0),
Vec::<usize>::new()
);
}
#[test]
fn pick_line_adds_the_lower_endpoint_of_a_crossing_segment() {
let xs = [0.0, 10.0];
let ys = [0.0, 10.0];
assert_eq!(
pick_polyline_indices(&xs, &ys, true, 2.0, 3.0, 2.0, 3.0),
vec![0]
);
assert_eq!(
pick_polyline_indices(&xs, &ys, false, 2.0, 3.0, 2.0, 3.0),
Vec::<usize>::new()
);
assert_eq!(
pick_polyline_indices(&[0.0, 10.0], &[2.5, 2.6], true, 2.0, 3.0, 2.0, 3.0),
vec![0]
);
}
#[test]
fn pick_line_skips_a_corner_missing_segment() {
let xs = [-10.0, 1.2];
let ys = [1.5, 20.0];
assert_eq!(
pick_polyline_indices(&xs, &ys, true, 1.0, 3.0, 1.0, 3.0),
Vec::<usize>::new()
);
}
#[test]
fn pick_excludes_nan_vertices_and_their_segments() {
let xs = [0.0, f64::NAN, 10.0];
let ys = [0.0, f64::NAN, 10.0];
assert_eq!(
pick_polyline_indices(&xs, &ys, true, 2.0, 3.0, 2.0, 3.0),
Vec::<usize>::new()
);
assert_eq!(
pick_polyline_indices(&xs, &ys, true, -1.0, 1.0, -1.0, 1.0),
vec![0]
);
}
#[test]
fn filled_histogram_picks_anywhere_inside_the_bar() {
let edges = [0.0, 2.0, 4.0, 6.0, 8.0];
let counts = [1.0, 3.0, 9.0, 2.0];
assert_eq!(
pick_filled_histogram(&edges, &counts, 0.0, 5.0, 4.5),
Some(2)
);
assert_eq!(pick_filled_histogram(&edges, &counts, 0.0, 7.0, 4.5), None);
}
#[test]
fn filled_histogram_bounds_are_strict_and_include_zero() {
let edges = [0.0, 2.0, 4.0];
let counts = [3.0, 5.0];
assert_eq!(pick_filled_histogram(&edges, &counts, 0.0, 1.0, 5.0), None);
assert_eq!(pick_filled_histogram(&edges, &counts, 0.0, 0.0, 1.0), None);
assert_eq!(pick_filled_histogram(&edges, &counts, 0.0, 4.0, 1.0), None);
}
#[test]
fn filled_histogram_interior_edge_belongs_to_the_left_bin() {
let edges = [0.0, 2.0, 4.0];
let counts = [3.0, 5.0];
assert_eq!(
pick_filled_histogram(&edges, &counts, 0.0, 2.0, 1.0),
Some(0)
);
}
#[test]
fn filled_histogram_downward_bar_picks_below_the_baseline() {
let edges = [0.0, 2.0, 4.0];
let counts = [-4.0, 3.0];
assert_eq!(
pick_filled_histogram(&edges, &counts, 0.0, 1.0, -2.0),
Some(0)
);
assert_eq!(pick_filled_histogram(&edges, &counts, 0.0, 1.0, 1.0), None);
}
}