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;
#[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));
}
}