use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, Vec2, pos2};
use crate::core::colormap::{Colormap, Normalization};
use crate::core::ticklayout::{nice_numbers, number_of_digits};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ColorBarOrientation {
#[default]
Vertical,
Horizontal,
}
#[derive(Clone, Debug)]
pub struct ColorBarWidget {
pub colormap: Colormap,
pub orientation: ColorBarOrientation,
pub legend: String,
}
const LINE_WIDTH: f32 = 10.0;
const BAR_THICKNESS: f32 = 25.0;
const TICK_FONT_SIZE: f32 = 11.0;
const TICK_LABEL_GAP: f32 = 3.0;
const DEFAULT_TICK_DENSITY: f64 = 0.015;
impl ColorBarWidget {
pub fn new(colormap: Colormap) -> Self {
Self {
colormap,
orientation: ColorBarOrientation::Vertical,
legend: String::new(),
}
}
pub fn with_orientation(mut self, orientation: ColorBarOrientation) -> Self {
self.orientation = orientation;
self
}
pub fn with_legend(mut self, legend: impl Into<String>) -> Self {
self.legend = legend.into();
self
}
pub fn ui(&self, ui: &mut egui::Ui, desired: Vec2) -> egui::Response {
let (rect, response) = ui.allocate_exact_size(desired, Sense::hover());
if !ui.is_rect_visible(rect) {
return response;
}
let fg = ui.visuals().text_color();
let painter = ui.painter_at(rect);
self.paint(&painter, rect, fg);
response
}
pub fn paint(&self, painter: &egui::Painter, rect: Rect, fg: Color32) {
let stroke = Stroke::new(1.0, fg);
let (bar_area, legend_area) = self.split_legend(rect);
let bar_rect = self.bar_rect(bar_area);
self.paint_gradient(painter, bar_rect);
painter.rect_stroke(
bar_rect,
egui::CornerRadius::ZERO,
stroke,
egui::StrokeKind::Inside,
);
self.paint_ticks_and_labels(painter, bar_rect, fg);
self.paint_end_labels(painter, bar_rect, fg);
if let Some(legend_area) = legend_area {
self.paint_legend(painter, legend_area, fg);
}
}
fn bar_length(&self, bar_rect: Rect) -> f32 {
match self.orientation {
ColorBarOrientation::Vertical => bar_rect.height(),
ColorBarOrientation::Horizontal => bar_rect.width(),
}
}
fn split_legend(&self, rect: Rect) -> (Rect, Option<Rect>) {
if self.legend.is_empty() {
return (rect, None);
}
let strip = TICK_FONT_SIZE + 6.0;
match self.orientation {
ColorBarOrientation::Vertical => {
let split = rect.right() - strip;
(
Rect::from_min_max(rect.min, pos2(split, rect.bottom())),
Some(Rect::from_min_max(pos2(split, rect.top()), rect.max)),
)
}
ColorBarOrientation::Horizontal => {
let split = rect.bottom() - strip;
(
Rect::from_min_max(rect.min, pos2(rect.right(), split)),
Some(Rect::from_min_max(pos2(rect.left(), split), rect.max)),
)
}
}
}
fn bar_rect(&self, area: Rect) -> Rect {
let thickness = BAR_THICKNESS.min(match self.orientation {
ColorBarOrientation::Vertical => area.width(),
ColorBarOrientation::Horizontal => area.height(),
});
match self.orientation {
ColorBarOrientation::Vertical => {
Rect::from_min_max(area.min, pos2(area.left() + thickness, area.bottom()))
}
ColorBarOrientation::Horizontal => {
Rect::from_min_max(area.min, pos2(area.right(), area.top() + thickness))
}
}
}
fn pos_for_frac(&self, bar_rect: Rect, frac: f32) -> f32 {
match self.orientation {
ColorBarOrientation::Vertical => bar_rect.bottom() - frac * bar_rect.height(),
ColorBarOrientation::Horizontal => bar_rect.left() + frac * bar_rect.width(),
}
}
fn paint_gradient(&self, painter: &egui::Painter, bar_rect: Rect) {
let n = 256usize;
let length = self.bar_length(bar_rect);
let step = length / n as f32;
for i in 0..n {
let c = self.colormap.lut[i];
let color = Color32::from_rgb(c[0], c[1], c[2]);
let strip = match self.orientation {
ColorBarOrientation::Vertical => {
let y1 = bar_rect.bottom() - i as f32 * step;
Rect::from_min_max(
pos2(bar_rect.left(), y1 - step - 0.5),
pos2(bar_rect.right(), y1),
)
}
ColorBarOrientation::Horizontal => {
let x0 = bar_rect.left() + i as f32 * step;
Rect::from_min_max(
pos2(x0, bar_rect.top()),
pos2(x0 + step + 0.5, bar_rect.bottom()),
)
}
};
painter.rect_filled(strip, egui::CornerRadius::ZERO, color);
}
}
fn paint_ticks_and_labels(&self, painter: &egui::Painter, bar_rect: Rect, fg: Color32) {
if self.colormap.vmax <= self.colormap.vmin {
return;
}
let length = self.bar_length(bar_rect) as f64;
let nticks = optimal_nb_ticks(length, DEFAULT_TICK_DENSITY);
let layout = self.tick_layout(nticks);
let stroke = Stroke::new(1.0, fg);
let font = FontId::proportional(TICK_FONT_SIZE);
for &v in &layout.sub_ticks {
self.paint_tick(painter, bar_rect, v, None, stroke, &font, fg);
}
for &v in &layout.ticks {
let label = layout.format.format(v);
self.paint_tick(painter, bar_rect, v, Some(label), stroke, &font, fg);
}
}
#[allow(clippy::too_many_arguments)]
fn paint_tick(
&self,
painter: &egui::Painter,
bar_rect: Rect,
v: f64,
label: Option<String>,
stroke: Stroke,
font: &FontId,
fg: Color32,
) {
let frac = tick_frac(&self.colormap, v);
let in_bar = (0.0..=1.0).contains(&frac);
let line_width = if label.is_some() {
LINE_WIDTH
} else {
LINE_WIDTH / 2.0
};
match self.orientation {
ColorBarOrientation::Vertical => {
let y = self.pos_for_frac(bar_rect, frac);
painter.line_segment(
[
pos2(bar_rect.right() - line_width, y),
pos2(bar_rect.right(), y),
],
stroke,
);
if let Some(label) = label {
let galley = painter.layout_no_wrap(label, font.clone(), fg);
let half_h = galley.size().y * 0.5;
let cy = if in_bar {
crate::widget::chrome::clamp_label_center(
y,
bar_rect.top(),
bar_rect.bottom(),
half_h,
)
} else {
y
};
painter.galley(
pos2(bar_rect.right() + TICK_LABEL_GAP, cy - half_h),
galley,
fg,
);
}
}
ColorBarOrientation::Horizontal => {
let x = self.pos_for_frac(bar_rect, frac);
painter.line_segment(
[
pos2(x, bar_rect.bottom() - line_width),
pos2(x, bar_rect.bottom()),
],
stroke,
);
if let Some(label) = label {
let galley = painter.layout_no_wrap(label, font.clone(), fg);
let half_w = galley.size().x * 0.5;
let cx = if in_bar {
crate::widget::chrome::clamp_label_center(
x,
bar_rect.left(),
bar_rect.right(),
half_w,
)
} else {
x
};
painter.galley(
pos2(cx - half_w, bar_rect.bottom() + TICK_LABEL_GAP),
galley,
fg,
);
}
}
}
}
fn paint_end_labels(&self, painter: &egui::Painter, bar_rect: Rect, fg: Color32) {
let font = FontId::proportional(TICK_FONT_SIZE);
let max_text = format_end_label(self.colormap.vmax);
let min_text = format_end_label(self.colormap.vmin);
match self.orientation {
ColorBarOrientation::Vertical => {
painter.text(
pos2(bar_rect.right() + TICK_LABEL_GAP, bar_rect.top()),
Align2::LEFT_TOP,
max_text,
font.clone(),
fg,
);
painter.text(
pos2(bar_rect.right() + TICK_LABEL_GAP, bar_rect.bottom()),
Align2::LEFT_BOTTOM,
min_text,
font,
fg,
);
}
ColorBarOrientation::Horizontal => {
painter.text(
pos2(bar_rect.left(), bar_rect.bottom() + TICK_LABEL_GAP),
Align2::LEFT_TOP,
min_text,
font.clone(),
fg,
);
painter.text(
pos2(bar_rect.right(), bar_rect.bottom() + TICK_LABEL_GAP),
Align2::RIGHT_TOP,
max_text,
font,
fg,
);
}
}
}
fn paint_legend(&self, painter: &egui::Painter, area: Rect, fg: Color32) {
let font = FontId::proportional(TICK_FONT_SIZE);
let galley = painter.layout_no_wrap(self.legend.clone(), font, fg);
match self.orientation {
ColorBarOrientation::Vertical => {
let angle = -std::f32::consts::FRAC_PI_2;
let pos = area.center() - galley.rect.center().to_vec2();
painter.add(egui::Shape::Text(
egui::epaint::TextShape::new(pos, galley, fg)
.with_angle_and_anchor(angle, Align2::CENTER_CENTER),
));
}
ColorBarOrientation::Horizontal => {
let center = area.center();
let pos = center - galley.size() * 0.5;
painter.add(egui::epaint::TextShape::new(pos, galley, fg));
}
}
}
fn tick_layout(&self, nticks: usize) -> TickLayout {
tick_layout(
self.colormap.vmin,
self.colormap.vmax,
self.colormap.normalization,
nticks,
)
}
}
fn optimal_nb_ticks(length: f64, density: f64) -> usize {
(2.0_f64).max((density * length).round()) as usize
}
fn tick_frac(colormap: &Colormap, v: f64) -> f32 {
let (cmap_min, one_over_range) = colormap.norm_bounds();
let base = one_over_range * (colormap.normalization.transform(v) as f32 - cmap_min);
let frac = match colormap.normalization {
Normalization::Gamma => base.powf(colormap.gamma),
_ => base,
};
if frac.is_finite() { frac } else { 1.0 }
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TickFormat {
Standard(usize),
Scientific,
}
impl TickFormat {
fn format(self, v: f64) -> String {
match self {
TickFormat::Standard(nfrac) => format!("{v:.nfrac$}"),
TickFormat::Scientific => format!("{v:.0e}"),
}
}
}
#[derive(Clone, Debug, PartialEq)]
struct TickLayout {
ticks: Vec<f64>,
sub_ticks: Vec<f64>,
format: TickFormat,
}
fn tick_layout(vmin: f64, vmax: f64, norm: Normalization, nticks: usize) -> TickLayout {
if vmin == vmax {
return TickLayout {
ticks: Vec::new(),
sub_ticks: Vec::new(),
format: TickFormat::Standard(0),
};
}
let (ticks, sub_ticks, nfrac) = match norm {
Normalization::Log => compute_ticks_log(vmin, vmax, nticks),
_ => {
let (ticks, nfrac) = compute_ticks_lin(vmin, vmax, nticks);
(ticks, Vec::new(), nfrac)
}
};
let format = guess_format(&ticks, nfrac);
TickLayout {
ticks,
sub_ticks,
format,
}
}
fn compute_ticks_lin(vmin: f64, vmax: f64, nticks: usize) -> (Vec<f64>, usize) {
let (graphmin, graphmax, spacing, nfrac) = nice_numbers(vmin, vmax, nticks);
(arange(graphmin, graphmax, spacing), nfrac)
}
fn compute_ticks_log(vmin: f64, vmax: f64, nticks: usize) -> (Vec<f64>, Vec<f64>, usize) {
let log_min = vmin.log10();
let log_max = vmax.log10();
let (low, high, spacing, nfrac) = nice_numbers_for_log10(log_min, log_max, nticks);
let exps = arange(low as f64, high as f64, spacing as f64);
let ticks: Vec<f64> = exps.iter().map(|&e| 10f64.powf(e)).collect();
let sub_ticks = if spacing == 1 {
compute_log_sub_ticks(&ticks, 10f64.powi(low), 10f64.powi(high))
} else {
Vec::new()
};
(ticks, sub_ticks, nfrac)
}
fn guess_format(ticks: &[f64], nfrac: usize) -> TickFormat {
let standard = TickFormat::Standard(nfrac);
const MAX_CHARS: usize = 8;
let widest = ticks
.iter()
.map(|&t| standard.format(t).len())
.max()
.unwrap_or(0);
if widest > MAX_CHARS {
TickFormat::Scientific
} else {
standard
}
}
fn nice_numbers_for_log10(min_log: f64, max_log: f64, nticks: usize) -> (i32, i32, i32, usize) {
let mut graphminlog = min_log.floor();
let mut graphmaxlog = max_log.ceil();
let rangelog = graphmaxlog - graphminlog;
let spacing;
if rangelog <= nticks as f64 {
spacing = 1.0;
} else {
spacing = (rangelog / nticks as f64).floor();
graphminlog = (graphminlog / spacing).floor() * spacing;
graphmaxlog = (graphmaxlog / spacing).ceil() * spacing;
}
let nfrac = number_of_digits(spacing);
(
graphminlog as i32,
graphmaxlog as i32,
spacing as i32,
nfrac,
)
}
fn compute_log_sub_ticks(ticks: &[f64], low_bound: f64, high_bound: f64) -> Vec<f64> {
if ticks.is_empty() {
return Vec::new();
}
let mut res = Vec::new();
for &orig in ticks {
for index in 2..10 {
let data_pos = orig * index as f64;
if low_bound <= data_pos && data_pos <= high_bound {
res.push(data_pos);
}
}
}
res
}
fn arange(start: f64, stop: f64, step: f64) -> Vec<f64> {
let mut out = Vec::new();
if step <= 0.0 {
return out;
}
let n = ((stop - start) / step).ceil();
if !n.is_finite() || n <= 0.0 {
return out;
}
let n = n as i64;
for i in 0..n {
out.push(start + i as f64 * step);
}
out
}
pub(crate) fn format_end_label(v: f64) -> String {
if v == 0.0 {
return crate::widget::stats_widget::format_significant(0.0, 7);
}
let log = v.abs().log10();
if (0.0..7.0).contains(&log) {
crate::widget::stats_widget::format_significant(v, 7)
} else {
format!("{v:.2e}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::colormap::Colormap;
#[test]
fn tick_frac_is_unclamped_outside_the_range() {
let cm = Colormap::viridis(0.13, 0.87);
assert!(tick_frac(&cm, 0.0) < 0.0, "graphmin tick must extrapolate");
assert!(tick_frac(&cm, 1.0) > 1.0, "graphmax tick must extrapolate");
assert_eq!(cm.normalize(0.0), 0.0, "the shader mirror still clamps");
assert_eq!(cm.normalize(1.0), 1.0);
let mid = f64::from(cm.normalize(0.5));
assert!((f64::from(tick_frac(&cm, 0.5)) - mid).abs() < 1e-6);
}
#[test]
fn tick_frac_log_decade_below_vmin_extrapolates() {
let cm = Colormap::viridis(3.0, 300.0).with_normalization(Normalization::Log);
assert!(tick_frac(&cm, 1.0) < 0.0);
assert_eq!(cm.normalize(1.0), 0.0, "the shader mirror still clamps");
}
#[test]
fn tick_frac_non_finite_norm_lands_at_the_vmax_end() {
let log = Colormap::viridis(1.0, 100.0).with_normalization(Normalization::Log);
assert_eq!(tick_frac(&log, 0.0), 1.0); assert_eq!(tick_frac(&log, -5.0), 1.0); let gamma = Colormap::viridis(0.0, 1.0)
.with_normalization(Normalization::Gamma)
.with_gamma(0.5);
assert_eq!(tick_frac(&gamma, -1.0), 1.0);
}
#[test]
fn nice_numbers_log_small_range_unit_spacing() {
let (low, high, spacing, nfrac) = nice_numbers_for_log10(0.0, 3.0, 5);
assert_eq!((low, high, spacing), (0, 3, 1));
assert_eq!(nfrac, 0);
}
#[test]
fn nice_numbers_log_wide_range_spacing_above_one() {
let (low, high, spacing, _nfrac) = nice_numbers_for_log10(0.0, 12.0, 5);
assert_eq!(spacing, 2);
assert_eq!(low % 2, 0);
assert_eq!(high % 2, 0);
assert!(low <= 0 && high >= 12);
}
#[test]
fn arange_is_stop_exclusive() {
assert_eq!(arange(0.0, 10.0, 2.0), vec![0.0, 2.0, 4.0, 6.0, 8.0]);
}
#[test]
fn arange_zero_or_negative_step_is_empty() {
assert!(arange(0.0, 10.0, 0.0).is_empty());
assert!(arange(0.0, 10.0, -1.0).is_empty());
}
#[test]
fn arange_empty_when_start_at_or_above_stop() {
assert!(arange(5.0, 5.0, 1.0).is_empty());
assert!(arange(6.0, 5.0, 1.0).is_empty());
}
#[test]
fn log_sub_ticks_are_2_to_9_multiples_in_bounds() {
let subs = compute_log_sub_ticks(&[1.0, 10.0], 1.0, 100.0);
assert!(subs.contains(&2.0));
assert!(subs.contains(&9.0));
assert!(subs.contains(&20.0));
assert!(subs.contains(&90.0));
assert!(subs.iter().all(|&s| (1.0..=100.0).contains(&s)));
}
#[test]
fn log_sub_ticks_empty_for_no_ticks() {
assert!(compute_log_sub_ticks(&[], 1.0, 100.0).is_empty());
}
#[test]
fn tick_layout_equal_range_has_no_ticks() {
let layout = tick_layout(3.0, 3.0, Normalization::Linear, 5);
assert!(layout.ticks.is_empty());
assert!(layout.sub_ticks.is_empty());
}
#[test]
fn tick_layout_linear_ticks_within_padded_range() {
let layout = tick_layout(0.0, 10.0, Normalization::Linear, 5);
assert_eq!(layout.ticks, vec![0.0, 2.0, 4.0, 6.0, 8.0]);
assert!(layout.sub_ticks.is_empty());
assert_eq!(layout.format, TickFormat::Standard(0));
}
#[test]
fn tick_layout_log_produces_decades_and_subticks() {
let layout = tick_layout(1.0, 1000.0, Normalization::Log, 5);
assert_eq!(layout.ticks, vec![1.0, 10.0, 100.0]);
assert!(layout.ticks.iter().all(|&t| (1.0..=1000.0).contains(&t)));
assert!(!layout.sub_ticks.is_empty());
assert!(
layout
.sub_ticks
.iter()
.all(|&t| (1.0..=1000.0).contains(&t))
);
}
#[test]
fn tick_layout_non_log_norms_use_linear_layout() {
let linear = tick_layout(0.0, 10.0, Normalization::Linear, 5).ticks;
for norm in [
Normalization::Sqrt,
Normalization::Gamma,
Normalization::Arcsinh,
] {
assert_eq!(tick_layout(0.0, 10.0, norm, 5).ticks, linear, "{norm:?}");
}
}
#[test]
fn guess_format_switches_to_scientific_for_wide_labels() {
let short = guess_format(&[0.0, 2.0, 4.0], 0);
assert_eq!(short, TickFormat::Standard(0));
let wide = guess_format(&[0.0, 123456789.0], 0);
assert_eq!(wide, TickFormat::Scientific);
}
#[test]
fn tick_format_renders_each_style() {
assert_eq!(TickFormat::Standard(2).format(1.5), "1.50");
assert_eq!(TickFormat::Standard(0).format(3.0), "3");
assert_eq!(TickFormat::Scientific.format(12345.0), "1e4");
}
#[test]
fn end_label_uses_g_for_moderate_values() {
assert_eq!(format_end_label(0.0), "0");
assert_eq!(format_end_label(1.0), "1");
assert_eq!(format_end_label(123.456), "123.456");
assert_eq!(format_end_label(9_999_999.0), "9999999");
}
#[test]
fn end_label_uses_scientific_for_large_and_small() {
assert_eq!(format_end_label(1.0e8), "1.00e8");
assert_eq!(format_end_label(0.5), "5.00e-1");
assert_eq!(format_end_label(-2.0e9), "-2.00e9");
}
#[test]
fn end_label_gate_delegates_in_range_to_g_else_scientific() {
assert_eq!(format_end_label(0.0), "0");
assert_eq!(format_end_label(1.0), "1");
assert_eq!(format_end_label(1234.5), "1234.5");
assert_eq!(format_end_label(0.001), "1.00e-3");
assert_eq!(format_end_label(9999999.9), "1e+07");
}
#[test]
fn optimal_nb_ticks_floors_at_two() {
assert_eq!(optimal_nb_ticks(0.0, DEFAULT_TICK_DENSITY), 2); assert_eq!(optimal_nb_ticks(10.0, DEFAULT_TICK_DENSITY), 2); assert_eq!(optimal_nb_ticks(400.0, DEFAULT_TICK_DENSITY), 6);
}
#[test]
fn new_defaults_to_vertical_no_legend() {
let w = ColorBarWidget::new(Colormap::viridis(0.0, 1.0));
assert_eq!(w.orientation, ColorBarOrientation::Vertical);
assert!(w.legend.is_empty());
}
#[test]
fn builders_set_orientation_and_legend() {
let w = ColorBarWidget::new(Colormap::viridis(0.0, 1.0))
.with_orientation(ColorBarOrientation::Horizontal)
.with_legend("Intensity");
assert_eq!(w.orientation, ColorBarOrientation::Horizontal);
assert_eq!(w.legend, "Intensity");
}
#[test]
fn vertical_legend_visual_center_lands_at_strip_center() {
use egui::epaint::TextShape;
use egui::vec2;
let ctx = egui::Context::default();
let mut galley = None;
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
galley = Some(ui.painter().layout_no_wrap(
"Intensity [counts]".to_owned(),
FontId::proportional(TICK_FONT_SIZE),
Color32::WHITE,
));
});
let galley = galley.expect("run closure executes once");
assert!(
galley.rect.width() > 40.0,
"fixture legend should be wide so a sign error is visible"
);
let angle = -std::f32::consts::FRAC_PI_2;
let center = egui::Pos2::new(312.0, 480.0);
let pos = center - galley.rect.center().to_vec2();
let fixed = TextShape::new(pos, galley.clone(), Color32::WHITE)
.with_angle_and_anchor(angle, Align2::CENTER_CENTER);
let c = fixed.visual_bounding_rect().center();
assert!(
(c.x - center.x).abs() < 2.0 && (c.y - center.y).abs() < 2.0,
"fixed legend center {c:?} should sit on strip center {center:?}"
);
let half = vec2(galley.size().y * 0.5, -galley.size().x * 0.5);
let mut old = TextShape::new(center + half, galley, Color32::WHITE);
old.angle = angle;
let oc = old.visual_bounding_rect().center();
assert!(
(oc - center).length() > 10.0,
"old offset should be visibly off-center, got {oc:?} vs {center:?}"
);
}
}