use egui::{Color32, FontId, Rect, Sense, Shape, Stroke, Vec2, pos2};
use crate::core::colormap::{Colormap, Normalization};
use crate::widget::colorbar::format_end_label;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Handle {
Min,
Max,
}
const AXIS_PAD_FRAC: f64 = 0.05;
const BAR_THICKNESS: f32 = 25.0;
const MIN_LABEL_WIDTH: f32 = 32.0;
const GAP: f32 = 4.0;
const HANDLE_TRI: f32 = 5.0;
const HANDLE_GRAB_HALF: f32 = 5.0;
const FONT_SIZE: f32 = 11.0;
const V_INSET: f32 = 6.0;
pub fn axis_range(data_range: (f64, f64), norm: Normalization) -> (f64, f64) {
let (mut lo, mut hi) = data_range;
if hi < lo {
std::mem::swap(&mut lo, &mut hi);
}
if norm == Normalization::Log {
if hi <= 0.0 || !hi.is_finite() {
return (1.0, 10.0);
}
if lo <= 0.0 || !lo.is_finite() {
lo = hi * 1e-6;
}
}
let tl = norm.transform(lo);
let th = norm.transform(hi);
if !tl.is_finite() || !th.is_finite() {
return (lo, if hi > lo { hi } else { lo + 1.0 });
}
let span = th - tl;
let pad = if span > 0.0 {
span * AXIS_PAD_FRAC
} else {
if tl.abs() > 0.0 { tl.abs() * 0.5 } else { 0.5 }
};
(
norm.inverse_transform(tl - pad),
norm.inverse_transform(th + pad),
)
}
pub fn value_to_frac(v: f64, lo: f64, hi: f64, norm: Normalization) -> f64 {
let tl = norm.transform(lo);
let th = norm.transform(hi);
if !tl.is_finite() || !th.is_finite() || th <= tl {
return 0.0;
}
let tv = norm.transform(v);
((tv - tl) / (th - tl)).clamp(0.0, 1.0)
}
pub fn frac_to_value(frac: f64, lo: f64, hi: f64, norm: Normalization) -> f64 {
let tl = norm.transform(lo);
let th = norm.transform(hi);
if !tl.is_finite() || !th.is_finite() || th <= tl {
return lo;
}
let t = tl + frac.clamp(0.0, 1.0) * (th - tl);
norm.inverse_transform(t)
}
pub fn hit_handle(pointer_frac: f64, vmin_frac: f64, vmax_frac: f64, tol: f64) -> Option<Handle> {
let dmin = (pointer_frac - vmin_frac).abs();
let dmax = (pointer_frac - vmax_frac).abs();
match (dmin <= tol, dmax <= tol) {
(true, true) => Some(if dmin <= dmax {
Handle::Min
} else {
Handle::Max
}),
(true, false) => Some(Handle::Min),
(false, true) => Some(Handle::Max),
(false, false) => None,
}
}
pub fn auto_range_levels(data_range: (f64, f64)) -> Option<(f64, f64)> {
let (mut lo, mut hi) = data_range;
if hi < lo {
std::mem::swap(&mut lo, &mut hi);
}
(lo.is_finite() && hi.is_finite() && hi > lo).then_some((lo, hi))
}
pub fn apply_handle_drag(
handle: Handle,
value: f64,
vmin: f64,
vmax: f64,
lo: f64,
hi: f64,
min_sep: f64,
) -> (f64, f64) {
match handle {
Handle::Min => {
let upper = (vmax - min_sep).max(lo);
(value.clamp(lo, upper), vmax)
}
Handle::Max => {
let lower = (vmin + min_sep).min(hi);
(vmin, value.clamp(lower, hi))
}
}
}
#[derive(Clone, Debug)]
pub struct HistogramColorBar {
pub colormap: Colormap,
pub data_range: (f64, f64),
pub histogram: Option<(Vec<u64>, Vec<f64>)>,
pub vmin: f64,
pub vmax: f64,
pub bar_bounds: Option<(f32, f32)>,
}
pub struct HistogramColorBarResponse {
pub response: egui::Response,
pub dragged_levels: Option<(f64, f64)>,
}
impl HistogramColorBar {
pub fn new(colormap: Colormap) -> Self {
let (vmin, vmax) = (colormap.vmin, colormap.vmax);
Self {
colormap,
data_range: (vmin, vmax),
histogram: None,
vmin,
vmax,
bar_bounds: None,
}
}
pub fn with_data_range(mut self, range: (f64, f64)) -> Self {
self.data_range = range;
self
}
pub fn with_histogram(mut self, histogram: Option<(Vec<u64>, Vec<f64>)>) -> Self {
self.histogram = histogram;
self
}
pub fn with_levels(mut self, vmin: f64, vmax: f64) -> Self {
self.vmin = vmin;
self.vmax = vmax;
self
}
pub fn with_bar_bounds(mut self, top: f32, bottom: f32) -> Self {
self.bar_bounds = Some((top, bottom));
self
}
pub fn ui(&self, ui: &mut egui::Ui, desired: Vec2) -> HistogramColorBarResponse {
let (rect, response) = ui.allocate_exact_size(desired, Sense::click());
self.show_in(ui, rect, response)
}
pub fn ui_at(&self, ui: &egui::Ui, rect: Rect) -> HistogramColorBarResponse {
let response = ui.interact(rect, ui.id().with("histogram_colorbar"), Sense::click());
self.show_in(ui, rect, response)
}
fn show_in(
&self,
ui: &egui::Ui,
rect: Rect,
response: egui::Response,
) -> HistogramColorBarResponse {
let norm = self.colormap.normalization;
let (lo, hi) = axis_range(self.data_range, norm);
let (bar_top, bar_bottom) = match self.bar_bounds {
Some((t, b)) => (t.max(rect.top()), b.min(rect.bottom())),
None => (rect.top() + V_INSET, rect.bottom() - V_INSET),
};
let bar_height = (bar_bottom - bar_top).max(1.0);
let vmin_frac = value_to_frac(self.vmin, lo, hi, norm);
let vmax_frac = value_to_frac(self.vmax, lo, hi, norm);
let y_of_frac = |f: f64| bar_bottom - (f as f32) * bar_height;
let grab = |y: f32| {
Rect::from_min_max(
pos2(rect.left(), y - HANDLE_GRAB_HALF),
pos2(rect.right(), y + HANDLE_GRAB_HALF),
)
};
let min_resp = ui.interact(
grab(y_of_frac(vmin_frac)),
response.id.with("hcb_min"),
Sense::drag(),
);
let max_resp = ui.interact(
grab(y_of_frac(vmax_frac)),
response.id.with("hcb_max"),
Sense::drag(),
);
let min_sep = ((hi - lo).abs() * 0.005).max(f64::MIN_POSITIVE);
let mut dragged_levels = None;
let frac_at = |y: f32| ((bar_bottom - y) / bar_height).clamp(0.0, 1.0) as f64;
if min_resp.dragged()
&& let Some(p) = min_resp.interact_pointer_pos()
{
let v = frac_to_value(frac_at(p.y), lo, hi, norm);
dragged_levels = Some(apply_handle_drag(
Handle::Min,
v,
self.vmin,
self.vmax,
lo,
hi,
min_sep,
));
} else if max_resp.dragged()
&& let Some(p) = max_resp.interact_pointer_pos()
{
let v = frac_to_value(frac_at(p.y), lo, hi, norm);
dragged_levels = Some(apply_handle_drag(
Handle::Max,
v,
self.vmin,
self.vmax,
lo,
hi,
min_sep,
));
}
if min_resp.hovered() || max_resp.hovered() || min_resp.dragged() || max_resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
}
response.context_menu(|ui| {
if ui.button("Auto range").clicked() {
if let Some(levels) = auto_range_levels(self.data_range) {
dragged_levels = Some(levels);
}
ui.close();
}
});
let (draw_vmin, draw_vmax) = dragged_levels.unwrap_or((self.vmin, self.vmax));
let font = FontId::proportional(FONT_SIZE);
let measure = |s: String| {
ui.painter()
.layout_no_wrap(s, font.clone(), Color32::WHITE)
.size()
.x
};
let label_reserve = measure("-8.88e-88".to_owned())
.max(measure(format_end_label(draw_vmin)))
.max(measure(format_end_label(draw_vmax)))
.max(MIN_LABEL_WIDTH)
+ GAP;
let bar_left = (rect.right() - label_reserve - GAP - BAR_THICKNESS).max(rect.left());
let bar_rect = Rect::from_min_max(
pos2(bar_left, bar_top),
pos2(bar_left + BAR_THICKNESS, bar_bottom),
);
let hist_rect = Rect::from_min_max(pos2(rect.left(), bar_top), pos2(bar_left, bar_bottom));
if ui.is_rect_visible(rect) {
self.paint(ui, rect, bar_rect, hist_rect, lo, hi, draw_vmin, draw_vmax);
}
HistogramColorBarResponse {
response,
dragged_levels,
}
}
#[allow(clippy::too_many_arguments)]
fn paint(
&self,
ui: &egui::Ui,
rect: Rect,
bar_rect: Rect,
hist_rect: Rect,
lo: f64,
hi: f64,
draw_vmin: f64,
draw_vmax: f64,
) {
let norm = self.colormap.normalization;
let fg = ui.visuals().text_color();
let painter = ui.painter_at(rect);
let y_of = |v: f64| {
bar_rect.bottom() - (value_to_frac(v, lo, hi, norm) as f32) * bar_rect.height()
};
if let Some((counts, edges)) = &self.histogram {
let max_count = counts.iter().copied().max().unwrap_or(0);
if max_count > 0 && hist_rect.width() > 0.0 {
let hist_fill = fg.gamma_multiply(0.40);
let w = hist_rect.width();
for (i, &c) in counts.iter().enumerate() {
if c == 0 {
continue;
}
let (e0, e1) = (edges[i], edges[i + 1]);
let (ya, yb) = (y_of(e0), y_of(e1));
let (top, bot) = (ya.min(yb), ya.max(yb));
let len = (c as f32 / max_count as f32) * w;
let bar = Rect::from_min_max(
pos2(hist_rect.right() - len, top),
pos2(hist_rect.right(), bot),
);
painter.rect_filled(bar, egui::CornerRadius::ZERO, hist_fill);
}
}
}
let height = bar_rect.height();
let steps = height.ceil().max(1.0) as usize;
for i in 0..steps {
let y0 = bar_rect.top() + i as f32;
let frac = ((height - i as f32 - 0.5) / height).clamp(0.0, 1.0) as f64;
let value = frac_to_value(frac, lo, hi, norm);
let idx =
((self.colormap.normalize(value) * 255.0).round() as i32).clamp(0, 255) as usize;
let c = self.colormap.lut[idx];
painter.rect_filled(
Rect::from_min_max(pos2(bar_rect.left(), y0), pos2(bar_rect.right(), y0 + 1.0)),
egui::CornerRadius::ZERO,
Color32::from_rgb(c[0], c[1], c[2]),
);
}
painter.rect_stroke(
bar_rect,
egui::CornerRadius::ZERO,
Stroke::new(1.0, fg),
egui::StrokeKind::Inside,
);
let columns = HandleColumns {
bar_rect,
hist_left: hist_rect.left(),
label_right: rect.right() - GAP,
};
self.paint_handle(&painter, &columns, y_of(draw_vmax), draw_vmax, fg);
self.paint_handle(&painter, &columns, y_of(draw_vmin), draw_vmin, fg);
}
fn paint_handle(
&self,
painter: &egui::Painter,
columns: &HandleColumns,
y: f32,
value: f64,
fg: Color32,
) {
let bar_rect = columns.bar_rect;
let accent = Color32::from_rgb(20, 130, 240);
let outline = Color32::WHITE;
painter.line_segment(
[pos2(columns.hist_left, y), pos2(bar_rect.right(), y)],
Stroke::new(1.5, accent),
);
let tri = vec![
pos2(bar_rect.left() - 2.0 * HANDLE_TRI, y - HANDLE_TRI),
pos2(bar_rect.left() - 2.0 * HANDLE_TRI, y + HANDLE_TRI),
pos2(bar_rect.left(), y),
];
painter.add(Shape::convex_polygon(
tri,
accent,
Stroke::new(1.0, outline),
));
let font = FontId::proportional(FONT_SIZE);
let galley = painter.layout_no_wrap(format_end_label(value), font, fg);
let half_h = galley.size().y * 0.5;
let cy =
crate::widget::chrome::clamp_label_center(y, bar_rect.top(), bar_rect.bottom(), half_h);
painter.galley(
pos2(columns.label_right - galley.size().x, cy - half_h),
galley,
fg,
);
}
}
struct HandleColumns {
bar_rect: Rect,
hist_left: f32,
label_right: f32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn axis_range_pads_linear_span_symmetrically() {
let (lo, hi) = axis_range((0.0, 100.0), Normalization::Linear);
assert!((lo - (-5.0)).abs() < 1e-9, "{lo}");
assert!((hi - 105.0).abs() < 1e-9, "{hi}");
}
#[test]
fn axis_range_swaps_reversed_input() {
let (lo, hi) = axis_range((10.0, 2.0), Normalization::Linear);
assert!(lo < hi);
assert!(lo < 2.0 && hi > 10.0);
}
#[test]
fn axis_range_degenerate_opens_a_window() {
let (lo, hi) = axis_range((7.0, 7.0), Normalization::Linear);
assert!(lo < 7.0 && hi > 7.0, "({lo}, {hi})");
}
#[test]
fn axis_range_log_floors_nonpositive_lo_positive() {
let (lo, hi) = axis_range((-5.0, 1000.0), Normalization::Log);
assert!(lo > 0.0, "log lo must be positive, got {lo}");
assert!(hi > 1000.0);
}
#[test]
fn axis_range_log_all_nonpositive_falls_back() {
let (lo, hi) = axis_range((-5.0, -1.0), Normalization::Log);
assert_eq!((lo, hi), (1.0, 10.0));
}
#[test]
fn value_to_frac_linear_endpoints_and_mid() {
let n = Normalization::Linear;
assert!((value_to_frac(0.0, 0.0, 10.0, n) - 0.0).abs() < 1e-12);
assert!((value_to_frac(10.0, 0.0, 10.0, n) - 1.0).abs() < 1e-12);
assert!((value_to_frac(5.0, 0.0, 10.0, n) - 0.5).abs() < 1e-12);
}
#[test]
fn value_to_frac_clamps_outside_axis() {
let n = Normalization::Linear;
assert_eq!(value_to_frac(-5.0, 0.0, 10.0, n), 0.0);
assert_eq!(value_to_frac(15.0, 0.0, 10.0, n), 1.0);
}
#[test]
fn value_frac_round_trips_linear_log_sqrt() {
for (n, lo, hi, v) in [
(Normalization::Linear, 0.0, 10.0, 3.0),
(Normalization::Log, 1.0, 1000.0, 50.0),
(Normalization::Sqrt, 0.0, 100.0, 25.0),
] {
let f = value_to_frac(v, lo, hi, n);
let back = frac_to_value(f, lo, hi, n);
assert!((back - v).abs() < 1e-6 * v.max(1.0), "{n:?}: {back} vs {v}");
}
}
#[test]
fn value_to_frac_log_is_geometric_midpoint() {
let f = value_to_frac(10.0, 1.0, 100.0, Normalization::Log);
assert!((f - 0.5).abs() < 1e-9, "{f}");
}
#[test]
fn frac_to_value_degenerate_axis_returns_lo() {
assert_eq!(frac_to_value(0.7, 5.0, 5.0, Normalization::Linear), 5.0);
}
#[test]
fn hit_handle_picks_nearest_within_tol() {
assert_eq!(hit_handle(0.21, 0.2, 0.8, 0.05), Some(Handle::Min));
assert_eq!(hit_handle(0.79, 0.2, 0.8, 0.05), Some(Handle::Max));
assert_eq!(hit_handle(0.5, 0.2, 0.8, 0.05), None);
}
#[test]
fn hit_handle_both_in_tol_takes_closer() {
assert_eq!(hit_handle(0.52, 0.48, 0.53, 0.1), Some(Handle::Max));
assert_eq!(hit_handle(0.49, 0.48, 0.53, 0.1), Some(Handle::Min));
}
#[test]
fn auto_range_levels_returns_data_extremes() {
assert_eq!(auto_range_levels((-0.45, 1.02)), Some((-0.45, 1.02)));
}
#[test]
fn auto_range_levels_swaps_reversed_range() {
assert_eq!(auto_range_levels((9.0, 1.0)), Some((1.0, 9.0)));
}
#[test]
fn auto_range_levels_rejects_degenerate_and_non_finite() {
assert_eq!(auto_range_levels((5.0, 5.0)), None);
assert_eq!(auto_range_levels((f64::NAN, 1.0)), None);
assert_eq!(auto_range_levels((0.0, f64::INFINITY)), None);
}
#[test]
fn apply_handle_drag_min_clamps_to_lo_and_below_vmax() {
let (a, b) = apply_handle_drag(Handle::Min, -100.0, 2.0, 8.0, 0.0, 10.0, 0.5);
assert_eq!((a, b), (0.0, 8.0));
let (a, b) = apply_handle_drag(Handle::Min, 20.0, 2.0, 8.0, 0.0, 10.0, 0.5);
assert!((a - 7.5).abs() < 1e-12, "{a}");
assert_eq!(b, 8.0);
}
#[test]
fn apply_handle_drag_max_clamps_to_hi_and_above_vmin() {
let (a, b) = apply_handle_drag(Handle::Max, 100.0, 2.0, 8.0, 0.0, 10.0, 0.5);
assert_eq!((a, b), (2.0, 10.0));
let (a, b) = apply_handle_drag(Handle::Max, -5.0, 2.0, 8.0, 0.0, 10.0, 0.5);
assert_eq!(a, 2.0);
assert!((b - 2.5).abs() < 1e-12, "{b}");
}
#[test]
fn apply_handle_drag_does_not_panic_when_levels_touch_lo() {
let (a, b) = apply_handle_drag(Handle::Min, 5.0, 0.0, 0.0, 0.0, 10.0, 0.5);
assert_eq!(a, 0.0);
assert_eq!(b, 0.0);
}
#[test]
fn new_takes_levels_from_colormap() {
let w = HistogramColorBar::new(Colormap::viridis(2.0, 9.0));
assert_eq!((w.vmin, w.vmax), (2.0, 9.0));
assert_eq!(w.data_range, (2.0, 9.0));
assert!(w.histogram.is_none());
}
#[test]
fn builders_set_range_histogram_levels() {
let w = HistogramColorBar::new(Colormap::viridis(0.0, 1.0))
.with_data_range((-1.0, 5.0))
.with_histogram(Some((vec![1, 2], vec![0.0, 1.0, 2.0])))
.with_levels(0.5, 4.0);
assert_eq!(w.data_range, (-1.0, 5.0));
assert_eq!(w.vmin, 0.5);
assert_eq!(w.vmax, 4.0);
assert!(w.histogram.is_some());
assert!(w.bar_bounds.is_none());
}
#[test]
fn with_bar_bounds_sets_strip_span() {
let w = HistogramColorBar::new(Colormap::viridis(0.0, 1.0)).with_bar_bounds(120.0, 480.0);
assert_eq!(w.bar_bounds, Some((120.0, 480.0)));
}
#[test]
fn ui_paints_without_panicking() {
let ctx = egui::Context::default();
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
let bar = HistogramColorBar::new(Colormap::viridis(0.0, 1.0))
.with_data_range((0.0, 1.0))
.with_histogram(Some((vec![3, 7, 2], vec![0.0, 0.33, 0.66, 1.0])))
.with_levels(0.2, 0.8);
let resp = bar.ui(ui, egui::vec2(150.0, 300.0));
assert!(resp.dragged_levels.is_none());
});
}
#[test]
fn ui_with_pinned_bounds_paints_within_box() {
let ctx = egui::Context::default();
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
let origin = ui.max_rect().top();
let bar = HistogramColorBar::new(Colormap::viridis(0.0, 1.0))
.with_data_range((0.0, 1.0))
.with_histogram(Some((vec![3, 7, 2], vec![0.0, 0.33, 0.66, 1.0])))
.with_levels(0.2, 0.8)
.with_bar_bounds(origin + 40.0, origin + 260.0);
let _ = bar.ui(ui, egui::vec2(170.0, 300.0));
});
}
#[test]
fn ui_at_renders_at_explicit_rect_without_panic() {
let ctx = egui::Context::default();
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
let origin = ui.max_rect().left_top();
let gutter = Rect::from_min_max(
pos2(origin.x + 300.0, origin.y + 20.0),
pos2(origin.x + 470.0, origin.y + 320.0),
);
let bar = HistogramColorBar::new(Colormap::viridis(0.0, 1.0))
.with_data_range((0.0, 1.0))
.with_histogram(Some((vec![3, 7, 2], vec![0.0, 0.33, 0.66, 1.0])))
.with_levels(0.2, 0.8)
.with_bar_bounds(gutter.top(), gutter.bottom());
let _ = bar.ui_at(ui, gutter);
});
}
#[test]
fn ui_scientific_notation_levels_paint_within_box() {
let ctx = egui::Context::default();
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
let bar = HistogramColorBar::new(Colormap::viridis(0.062, 1.0))
.with_data_range((0.0, 1.0))
.with_histogram(Some((vec![9, 4, 1], vec![0.0, 0.33, 0.66, 1.0])))
.with_levels(0.062, 1.0);
let _ = bar.ui(ui, egui::vec2(175.0, 320.0));
});
}
#[test]
fn labels_never_pass_the_column_right_edge() {
fn text_right_edges(shape: &egui::Shape, out: &mut Vec<f32>) {
match shape {
egui::Shape::Text(t) => out.push(t.pos.x + t.galley.size().x),
egui::Shape::Vec(v) => v.iter().for_each(|s| text_right_edges(s, out)),
_ => {}
}
}
let ctx = egui::Context::default();
let mut gutter = Rect::NOTHING;
let output = ctx.run_ui(egui::RawInput::default(), |ui| {
let origin = ui.max_rect().left_top();
gutter = Rect::from_min_max(
pos2(origin.x + 10.0, origin.y + 10.0),
pos2(origin.x + 70.0, origin.y + 310.0),
);
let bar = HistogramColorBar::new(Colormap::viridis(0.0574, 1.0))
.with_data_range((0.0, 1.0))
.with_histogram(Some((vec![9, 4, 1], vec![0.0, 0.33, 0.66, 1.0])))
.with_levels(0.0574, 1.0);
let _ = bar.ui_at(ui, gutter);
});
let mut rights = Vec::new();
for clipped in &output.shapes {
text_right_edges(&clipped.shape, &mut rights);
}
assert!(
rights.len() >= 2,
"expected at least the two level labels, got {}",
rights.len()
);
for r in rights {
assert!(
r <= gutter.right() + 0.5,
"label right edge {r} passes the column edge {}",
gutter.right()
);
}
}
#[test]
fn ui_handles_degenerate_inputs_without_panic() {
let ctx = egui::Context::default();
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
let bar = HistogramColorBar::new(Colormap::viridis(5.0, 5.0))
.with_data_range((5.0, 5.0))
.with_histogram(None)
.with_levels(5.0, 5.0);
let _ = bar.ui(ui, egui::vec2(80.0, 50.0));
});
}
#[test]
fn ui_log_normalization_paints_without_panic() {
let ctx = egui::Context::default();
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
let cmap = Colormap::viridis(1.0, 1000.0).with_normalization(Normalization::Log);
let bar = HistogramColorBar::new(cmap)
.with_data_range((-2.0, 1000.0))
.with_histogram(Some((vec![1, 5, 9], vec![1.0, 10.0, 100.0, 1000.0])))
.with_levels(10.0, 500.0);
let _ = bar.ui(ui, egui::vec2(150.0, 300.0));
});
}
}