use egui::epaint::TextShape;
use egui::{Align2, Color32, FontId, Painter, Pos2, Rect, Stroke, Visuals, pos2, vec2};
use crate::core::colormap::{Colormap, Normalization};
use crate::core::dtime_ticks;
use crate::core::items::LineStyle;
use crate::core::marker::{Marker, MarkerKind, MarkerSymbol};
use crate::core::plot::{GraphGrid, TickMode};
use crate::core::roi::{HandleKind, ManagedRoi, Roi};
use crate::core::shape::{Line, Shape, ShapeKind};
use crate::core::transform::{Axis, Scale, Transform, YAxis};
use crate::core::triangles::Triangles;
pub struct Style {
pub axis: Color32,
pub grid: Color32,
pub text: Color32,
pub readout_bg: Color32,
}
impl Style {
pub fn from_visuals(v: &Visuals) -> Self {
let text = v.text_color();
let fill = v.window_fill();
Self {
axis: text,
grid: crate::core::color::with_alpha(text, 28),
text,
readout_bg: crate::core::color::with_alpha(fill, 210),
}
}
pub fn with_overrides(mut self, fg: Option<Color32>, grid: Option<Color32>) -> Self {
if let Some(c) = fg {
self.axis = c;
self.text = c;
self.grid = crate::core::color::with_alpha(c, 28);
}
if let Some(g) = grid {
self.grid = g;
}
self
}
}
pub struct ChromeLayout {
pub data_area: Rect,
pub colorbar: Option<Rect>,
}
const GUTTER_LEFT: f32 = 52.0;
const GUTTER_BOTTOM: f32 = 30.0;
const GUTTER_TOP: f32 = 12.0;
const GUTTER_RIGHT: f32 = 12.0;
const GUTTER_Y2: f32 = 52.0;
const CBAR_WIDTH: f32 = 16.0;
const CBAR_LABELS: f32 = 46.0;
const TITLE_H: f32 = 18.0;
const LABEL_H: f32 = 16.0;
#[derive(Clone, Copy, Default)]
pub struct ChromeRequest {
pub colorbar: bool,
pub y2: bool,
pub title: bool,
pub x_label: bool,
pub y_label: bool,
pub y2_label: bool,
pub axes_hidden: bool,
}
pub fn layout(full: Rect, req: &ChromeRequest) -> ChromeLayout {
if req.axes_hidden {
let right = if req.colorbar {
GUTTER_RIGHT + CBAR_WIDTH + CBAR_LABELS
} else {
0.0
};
let data_area = Rect::from_min_max(
pos2(full.left(), full.top()),
pos2(full.right() - right, full.bottom()),
);
let colorbar = req.colorbar.then(|| {
let x0 = data_area.right() + GUTTER_RIGHT;
Rect::from_min_max(
pos2(x0, data_area.top()),
pos2(x0 + CBAR_WIDTH, data_area.bottom()),
)
});
return ChromeLayout {
data_area,
colorbar,
};
}
let right_axis = if req.colorbar {
GUTTER_RIGHT + CBAR_WIDTH + CBAR_LABELS
} else if req.y2 {
GUTTER_Y2
} else {
GUTTER_RIGHT
};
let right = right_axis + if req.y2 && req.y2_label { LABEL_H } else { 0.0 };
let left = GUTTER_LEFT + if req.y_label { LABEL_H } else { 0.0 };
let top = GUTTER_TOP + if req.title { TITLE_H } else { 0.0 };
let bottom = GUTTER_BOTTOM + if req.x_label { LABEL_H } else { 0.0 };
let data_area = Rect::from_min_max(
pos2(full.left() + left, full.top() + top),
pos2(full.right() - right, full.bottom() - bottom),
);
let colorbar = req.colorbar.then(|| {
let x0 = data_area.right() + GUTTER_RIGHT;
Rect::from_min_max(
pos2(x0, data_area.top()),
pos2(x0 + CBAR_WIDTH, data_area.bottom()),
)
});
ChromeLayout {
data_area,
colorbar,
}
}
fn nice_num(range: f64, round: bool) -> f64 {
if range <= 0.0 {
return 1.0;
}
let exp = range.log10().floor();
let frac = range / 10f64.powf(exp);
let nice = if round {
if frac < 1.5 {
1.0
} else if frac < 3.0 {
2.0
} else if frac < 7.0 {
5.0
} else {
10.0
}
} else if frac <= 1.0 {
1.0
} else if frac <= 2.0 {
2.0
} else if frac <= 5.0 {
5.0
} else {
10.0
};
nice * 10f64.powf(exp)
}
pub fn nice_ticks(min: f64, max: f64, max_ticks: usize) -> (Vec<f64>, f64) {
let ascending = matches!(max.partial_cmp(&min), Some(std::cmp::Ordering::Greater));
if !ascending || max_ticks < 2 {
return (Vec::new(), 1.0);
}
let range = nice_num(max - min, false);
let step = nice_num(range / (max_ticks - 1) as f64, true);
let start = (min / step).floor() * step;
let end = (max / step).ceil() * step;
let n = ((end - start) / step).round() as i64;
let mut ticks = Vec::new();
for i in 0..=n {
let v = start + i as f64 * step;
if v >= min - step * 1e-6 && v <= max + step * 1e-6 {
ticks.push(v);
}
}
(ticks, step)
}
fn format_tick(v: f64, step: f64) -> String {
let decimals = (-step.log10().floor()).clamp(0.0, 6.0) as usize;
format!("{v:.decimals$}")
}
fn log_decade_ticks(min: f64, max: f64) -> Vec<f64> {
let valid = min.is_finite() && max.is_finite() && min > 0.0 && max > min;
if !valid {
return Vec::new();
}
let k0 = min.log10().ceil() as i32;
let k1 = max.log10().floor() as i32;
(k0..=k1).map(|k| 10f64.powi(k)).collect()
}
fn format_log_tick(v: f64) -> String {
if (1e-4..1e6).contains(&v) {
format!("{v}")
} else {
format!("{v:e}")
}
}
const TIME_SERIES_NUM_TICKS: usize = 5;
fn axis_ticks(axis: &Axis, max_ticks: usize) -> Vec<(f64, String)> {
axis_ticks_with_mode(axis, max_ticks, TickMode::Numeric)
}
fn axis_ticks_with_mode(axis: &Axis, max_ticks: usize, tick_mode: TickMode) -> Vec<(f64, String)> {
if tick_mode == TickMode::TimeSeries && axis.scale == Scale::Linear {
let (lo, hi) = if axis.max >= axis.min {
(axis.min, axis.max)
} else {
(axis.max, axis.min)
};
let (ticks, spacing, unit) = dtime_ticks::calc_ticks(lo, hi, TIME_SERIES_NUM_TICKS);
let labels = dtime_ticks::format_ticks(&ticks, spacing, unit);
return ticks.into_iter().zip(labels).collect();
}
match axis.scale {
Scale::Linear => {
let (ticks, step) = nice_ticks(axis.min, axis.max, max_ticks);
ticks
.into_iter()
.map(|v| (v, format_tick(v, step)))
.collect()
}
Scale::Log10 => log_decade_ticks(axis.min, axis.max)
.into_iter()
.map(|v| (v, format_log_tick(v)))
.collect(),
}
}
fn linear_minor_ticks(axis: &Axis, major: &[(f64, String)]) -> Vec<f64> {
if major.len() < 2 {
return Vec::new();
}
let major_step = (major[1].0 - major[0].0).abs();
if !major_step.is_finite() || major_step <= 0.0 {
return Vec::new();
}
let minor_step = major_step / 5.0;
let start = (axis.min / minor_step).ceil() as i64 - 1;
let end = (axis.max / minor_step).floor() as i64 + 1;
let major_eps = minor_step * 1e-6;
let mut ticks = Vec::new();
for i in start..=end {
let v = i as f64 * minor_step;
if v <= axis.min || v >= axis.max {
continue;
}
let major_multiple = ((v - major[0].0) / major_step).round();
let nearest_major = major[0].0 + major_multiple * major_step;
if (v - nearest_major).abs() <= major_eps {
continue;
}
ticks.push(v);
}
ticks
}
fn log_minor_ticks(axis: &Axis) -> Vec<f64> {
let valid =
axis.min.is_finite() && axis.max.is_finite() && axis.min > 0.0 && axis.max > axis.min;
if !valid {
return Vec::new();
}
let k0 = axis.min.log10().floor() as i32;
let k1 = axis.max.log10().ceil() as i32;
let mut ticks = Vec::new();
for k in k0..=k1 {
let decade = 10f64.powi(k);
for m in 2..10 {
let v = m as f64 * decade;
if v > axis.min && v < axis.max {
ticks.push(v);
}
}
}
ticks
}
fn minor_ticks(axis: &Axis, major: &[(f64, String)]) -> Vec<f64> {
match axis.scale {
Scale::Linear => linear_minor_ticks(axis, major),
Scale::Log10 => log_minor_ticks(axis),
}
}
pub fn draw_axes(
painter: &Painter,
t: &Transform,
style: &Style,
grid_mode: GraphGrid,
x_max_ticks: Option<usize>,
y_max_ticks: Option<usize>,
) {
draw_axes_with_x_tick_mode(
painter,
t,
style,
grid_mode,
x_max_ticks,
y_max_ticks,
TickMode::Numeric,
);
}
#[allow(clippy::too_many_arguments)]
pub fn draw_axes_with_x_tick_mode(
painter: &Painter,
t: &Transform,
style: &Style,
grid_mode: GraphGrid,
x_max_ticks: Option<usize>,
y_max_ticks: Option<usize>,
x_tick_mode: TickMode,
) {
let area = t.area;
let axis = Stroke::new(1.0, style.axis);
let grid = Stroke::new(1.0, style.grid);
let minor_grid = Stroke::new(
1.0,
crate::core::color::with_alpha(style.grid, style.grid.a() / 2),
);
let font = FontId::proportional(11.0);
let tick_len = 4.0;
let xticks = axis_ticks_with_mode(&t.x, x_max_ticks.unwrap_or(8), x_tick_mode);
let yticks = axis_ticks_with_mode(&t.y, y_max_ticks.unwrap_or(6), TickMode::Numeric);
if grid_mode.minor() {
for xv in minor_ticks(&t.x, &xticks) {
let px = t.data_to_pixel(xv, t.y.min).x;
painter.vline(px, area.y_range(), minor_grid);
}
for yv in minor_ticks(&t.y, &yticks) {
let py = t.data_to_pixel(t.x.min, yv).y;
painter.hline(area.x_range(), py, minor_grid);
}
}
if grid_mode.major() {
for (xv, _) in &xticks {
let px = t.data_to_pixel(*xv, t.y.min).x;
painter.vline(px, area.y_range(), grid);
}
for (yv, _) in &yticks {
let py = t.data_to_pixel(t.x.min, *yv).y;
painter.hline(area.x_range(), py, grid);
}
}
painter.rect_stroke(
area,
egui::CornerRadius::ZERO,
axis,
egui::StrokeKind::Inside,
);
for (xv, label) in &xticks {
let px = t.data_to_pixel(*xv, t.y.min).x;
painter.line_segment(
[pos2(px, area.bottom()), pos2(px, area.bottom() + tick_len)],
axis,
);
painter.text(
pos2(px, area.bottom() + tick_len + 2.0),
Align2::CENTER_TOP,
label,
font.clone(),
style.text,
);
}
for (yv, label) in &yticks {
let py = t.data_to_pixel(t.x.min, *yv).y;
painter.line_segment(
[pos2(area.left() - tick_len, py), pos2(area.left(), py)],
axis,
);
painter.text(
pos2(area.left() - tick_len - 3.0, py),
Align2::RIGHT_CENTER,
label,
font.clone(),
style.text,
);
}
}
pub fn draw_y2_ticks(painter: &Painter, t: &Transform, style: &Style) {
let area = t.area;
let axis = Stroke::new(1.0, style.axis);
let font = FontId::proportional(11.0);
let tick_len = 4.0;
for (yv, label) in axis_ticks(&t.y, 6) {
let py = t.data_to_pixel(t.x.min, yv).y;
painter.line_segment(
[pos2(area.right(), py), pos2(area.right() + tick_len, py)],
axis,
);
painter.text(
pos2(area.right() + tick_len + 3.0, py),
Align2::LEFT_CENTER,
label,
font.clone(),
style.text,
);
}
}
#[derive(Clone, Copy, Default)]
pub struct Labels<'a> {
pub title: Option<&'a str>,
pub x: Option<&'a str>,
pub y: Option<&'a str>,
pub y2: Option<&'a str>,
}
pub fn draw_labels(
painter: &Painter,
full: Rect,
area: Rect,
labels: &Labels,
with_y2: bool,
style: &Style,
) {
let title_font = FontId::proportional(14.0);
let label_font = FontId::proportional(12.0);
if let Some(t) = labels.title {
painter.text(
pos2(area.center().x, full.top() + 2.0),
Align2::CENTER_TOP,
t,
title_font,
style.text,
);
}
if let Some(t) = labels.x {
painter.text(
pos2(area.center().x, full.bottom() - 2.0),
Align2::CENTER_BOTTOM,
t,
label_font.clone(),
style.text,
);
}
if let Some(t) = labels.y {
draw_rotated_label(
painter,
pos2(full.left() + LABEL_H * 0.5, area.center().y),
-std::f32::consts::FRAC_PI_2,
t,
label_font.clone(),
style.text,
);
}
if with_y2 && let Some(t) = labels.y2 {
draw_rotated_label(
painter,
pos2(full.right() - LABEL_H * 0.5, area.center().y),
std::f32::consts::FRAC_PI_2,
t,
label_font,
style.text,
);
}
}
fn draw_rotated_label(
painter: &Painter,
center: Pos2,
angle: f32,
text: &str,
font: FontId,
color: Color32,
) {
let galley = painter.layout_no_wrap(text.to_owned(), font, color);
let pos = center - galley.rect.center().to_vec2();
painter.add(egui::Shape::Text(
TextShape::new(pos, galley, color).with_angle_and_anchor(angle, Align2::CENTER_CENTER),
));
}
fn format_coord(v: f64, lo: f64, hi: f64) -> String {
let span = (hi - lo).abs();
let decimals = if span > 0.0 {
(2.0 - span.log10().floor()).clamp(0.0, 6.0) as usize
} else {
3
};
format!("{v:.decimals$}")
}
#[derive(Clone, Default)]
pub struct RoiAppearance<'a> {
pub color: Option<Color32>,
pub name: Option<&'a str>,
pub selected: bool,
pub line_width: Option<f32>,
pub line_style: Option<LineStyle>,
pub fill: Option<bool>,
}
pub fn draw_rois(
painter: &Painter,
t: &Transform,
rois: &[ManagedRoi],
default_color: Color32,
style: &Style,
) {
for r in rois {
let appearance = roi_appearance(r, default_color);
draw_roi(painter, t, &r.roi, &appearance, style);
}
}
fn roi_appearance(managed: &ManagedRoi, default_color: Color32) -> RoiAppearance<'_> {
RoiAppearance {
color: Some(managed.color.unwrap_or(default_color)),
name: (!managed.name.is_empty()).then_some(managed.name.as_str()),
selected: managed.selected,
line_width: Some(managed.line_width),
line_style: Some(managed.line_style.to_line_style()),
fill: Some(managed.fill),
}
}
fn band_corners_data(begin: (f64, f64), end: (f64, f64), width: f64) -> [(f64, f64); 4] {
let (vx, vy) = (end.0 - begin.0, end.1 - begin.1);
let len = (vx * vx + vy * vy).sqrt();
let n = if len == 0.0 {
(0.0, 0.0)
} else {
(-vy / len, vx / len)
};
let off = (0.5 * width * n.0, 0.5 * width * n.1);
[
(begin.0 - off.0, begin.1 - off.1),
(begin.0 + off.0, begin.1 + off.1),
(end.0 + off.0, end.1 + off.1),
(end.0 - off.0, end.1 - off.1),
]
}
fn arc_outline(
center: (f64, f64),
inner_radius: f64,
outer_radius: f64,
start_angle: f64,
end_angle: f64,
) -> Vec<(f64, f64)> {
let sweep = end_angle - start_angle;
let steps = ((sweep.abs() / std::f64::consts::TAU * 100.0).ceil() as usize).clamp(2, 100);
let at = |r: f64, a: f64| (center.0 + r * a.cos(), center.1 + r * a.sin());
let mut pts = Vec::with_capacity(steps * 2 + 2);
for i in 0..=steps {
let a = start_angle + sweep * (i as f64 / steps as f64);
pts.push(at(outer_radius, a));
}
if inner_radius <= 0.0 {
pts.push(center);
} else {
for i in 0..=steps {
let a = end_angle - sweep * (i as f64 / steps as f64);
pts.push(at(inner_radius, a));
}
}
pts
}
fn draw_roi_handles(painter: &Painter, t: &Transform, roi: &Roi, color: Color32) {
for handle in roi.handles() {
let p = t.data_to_pixel(handle.pos[0], handle.pos[1]);
match handle.kind {
HandleKind::Translate | HandleKind::Center => {
let r = 4.0;
let stroke = Stroke::new(1.5, color);
painter.line_segment([pos2(p.x - r, p.y), pos2(p.x + r, p.y)], stroke);
painter.line_segment([pos2(p.x, p.y - r), pos2(p.x, p.y + r)], stroke);
}
HandleKind::Vertex | HandleKind::Edge => {
let h = Rect::from_center_size(p, vec2(6.0, 6.0));
painter.rect_filled(h, egui::CornerRadius::ZERO, color);
}
}
}
}
pub fn draw_roi(
painter: &Painter,
t: &Transform,
roi: &Roi,
appearance: &RoiAppearance,
style: &Style,
) {
let color = appearance.color.unwrap_or(style.axis);
let base_width = appearance.line_width.unwrap_or(1.0);
let width = if appearance.selected {
base_width.max(2.0)
} else {
base_width
};
let fill_enabled = appearance.fill.unwrap_or(true);
let fill = fill_enabled.then(|| crate::core::color::with_alpha(color, 24));
let line_style = appearance.line_style.clone().unwrap_or(LineStyle::Solid);
let outline = |mut path: Vec<Pos2>| {
if let Some(&first) = path.first() {
path.push(first);
draw_styled_line(painter, path, color, width, &line_style, None);
}
};
let label_anchor: Option<Pos2> = match roi {
Roi::Point { x, y } => {
let p = t.data_to_pixel(*x, *y);
if let Some(fc) = fill {
painter.circle_filled(p, 5.0, fc);
}
painter.circle_stroke(p, 5.0, Stroke::new(width, color));
Some(p)
}
Roi::Cross { center } => {
let p = t.data_to_pixel(center.0, center.1);
let area = t.area;
draw_styled_line(
painter,
vec![pos2(p.x, area.top()), pos2(p.x, area.bottom())],
color,
width,
&line_style,
None,
);
draw_styled_line(
painter,
vec![pos2(area.left(), p.y), pos2(area.right(), p.y)],
color,
width,
&line_style,
None,
);
Some(p)
}
Roi::Line { start, end } => {
let a = t.data_to_pixel(start.0, start.1);
let b = t.data_to_pixel(end.0, end.1);
draw_styled_line(painter, vec![a, b], color, width, &line_style, None);
Some(a)
}
Roi::Polygon { vertices } if !vertices.is_empty() => {
let pts: Vec<Pos2> = vertices
.iter()
.map(|&(x, y)| t.data_to_pixel(x, y))
.collect();
if let Some(fc) = fill {
painter.add(egui::Shape::convex_polygon(pts.clone(), fc, Stroke::NONE));
}
let anchor = pts.first().copied();
outline(pts);
anchor
}
Roi::Polygon { .. } => None, Roi::Circle { center, radius } => {
let c = t.data_to_pixel(center.0, center.1);
let edge = t.data_to_pixel(center.0 + radius, center.1);
let rpx = (edge.x - c.x).abs();
if let Some(fc) = fill {
painter.circle_filled(c, rpx, fc);
}
let n = 64usize;
let pts: Vec<Pos2> = (0..n)
.map(|i| {
let a = i as f32 * std::f32::consts::TAU / n as f32;
egui::pos2(c.x + rpx * a.cos(), c.y + rpx * a.sin())
})
.collect();
outline(pts);
Some(egui::pos2(c.x, c.y - rpx))
}
Roi::Ellipse { center, radii } => {
let c = t.data_to_pixel(center.0, center.1);
let ex = t.data_to_pixel(center.0 + radii.0, center.1);
let ey = t.data_to_pixel(center.0, center.1 + radii.1);
let rx = (ex.x - c.x).abs();
let ry = (ey.y - c.y).abs();
let n = 27usize;
let pts: Vec<Pos2> = (0..n)
.map(|i| {
let a = i as f32 * std::f32::consts::TAU / n as f32;
egui::pos2(c.x + rx * a.cos(), c.y + ry * a.sin())
})
.collect();
if let Some(fc) = fill {
painter.add(egui::Shape::convex_polygon(pts.clone(), fc, Stroke::NONE));
}
outline(pts);
Some(egui::pos2(c.x, c.y - ry))
}
Roi::Arc {
center,
inner_radius,
outer_radius,
start_angle,
end_angle,
} => {
let pts: Vec<Pos2> = arc_outline(
*center,
*inner_radius,
*outer_radius,
*start_angle,
*end_angle,
)
.into_iter()
.map(|(x, y)| t.data_to_pixel(x, y))
.collect();
outline(pts);
Some(t.data_to_pixel(center.0, center.1 + outer_radius))
}
Roi::Band {
begin,
end,
width: bw,
} => {
let corners = band_corners_data(*begin, *end, *bw);
let pts: Vec<Pos2> = corners
.iter()
.map(|&(x, y)| t.data_to_pixel(x, y))
.collect();
if let Some(fc) = fill {
painter.add(egui::Shape::convex_polygon(pts.clone(), fc, Stroke::NONE));
}
let anchor = pts.first().copied();
outline(pts);
anchor
}
_ => {
let r = roi.screen_rect(t);
if let Some(fc) = fill {
painter.rect_filled(r, egui::CornerRadius::ZERO, fc);
}
outline(vec![
r.left_top(),
r.right_top(),
r.right_bottom(),
r.left_bottom(),
]);
Some(egui::pos2(r.center().x, r.top()))
}
};
if !matches!(roi, Roi::Point { .. }) {
draw_roi_handles(painter, t, roi, color);
}
if let (Some(name), Some(anchor)) = (appearance.name.filter(|s| !s.is_empty()), label_anchor) {
draw_marker_label(
painter,
anchor + vec2(0.0, -3.0),
Align2::CENTER_BOTTOM,
name,
color,
Some(style.readout_bg),
);
}
}
fn draw_styled_line(
painter: &Painter,
path: Vec<Pos2>,
color: Color32,
width: f32,
line_style: &LineStyle,
gap_color: Option<Color32>,
) {
if path.len() < 2 || !line_style.draws_line() {
return;
}
let stroke = Stroke::new(width, color);
match line_style.painter_dashes(width) {
None => {
painter.add(egui::Shape::line(path, stroke));
}
Some((dashes, gaps, offset)) => {
if let Some(gc) = gap_color {
painter.add(egui::Shape::line(path.clone(), Stroke::new(width, gc)));
}
for shape in egui::Shape::dashed_line_with_offset(&path, stroke, &dashes, &gaps, offset)
{
painter.add(shape);
}
}
}
}
fn draw_marker_symbol(painter: &Painter, c: Pos2, symbol: MarkerSymbol, size: f32, color: Color32) {
let r = (size * 0.5).max(1.0);
let stroke = Stroke::new((size * 0.18).max(1.0), color);
match symbol {
MarkerSymbol::Circle => {
painter.add(egui::Shape::circle_filled(c, r, color));
}
MarkerSymbol::Point => {
painter.add(egui::Shape::circle_filled(c, (r * 0.4).max(1.5), color));
}
MarkerSymbol::Pixel => {
painter.add(egui::Shape::rect_filled(
Rect::from_center_size(c, vec2(1.0, 1.0)),
egui::CornerRadius::ZERO,
color,
));
}
MarkerSymbol::Square => {
painter.add(egui::Shape::rect_filled(
Rect::from_center_size(c, vec2(size, size)),
egui::CornerRadius::ZERO,
color,
));
}
MarkerSymbol::Diamond => {
let pts = vec![
pos2(c.x, c.y - r),
pos2(c.x + r, c.y),
pos2(c.x, c.y + r),
pos2(c.x - r, c.y),
];
painter.add(egui::Shape::convex_polygon(pts, color, Stroke::NONE));
}
MarkerSymbol::Plus => {
painter.line_segment([pos2(c.x - r, c.y), pos2(c.x + r, c.y)], stroke);
painter.line_segment([pos2(c.x, c.y - r), pos2(c.x, c.y + r)], stroke);
}
MarkerSymbol::Cross => {
painter.line_segment([pos2(c.x - r, c.y - r), pos2(c.x + r, c.y + r)], stroke);
painter.line_segment([pos2(c.x - r, c.y + r), pos2(c.x + r, c.y - r)], stroke);
}
}
}
fn draw_marker_label(
painter: &Painter,
pos: Pos2,
anchor: Align2,
text: &str,
color: Color32,
bg: Option<Color32>,
) {
let font = FontId::proportional(11.0);
let galley = painter.layout_no_wrap(text.to_owned(), font, color);
let rect = anchor.anchor_size(pos, galley.size());
if let Some(bg) = bg {
painter.rect_filled(
rect.expand2(vec2(3.0, 1.0)),
egui::CornerRadius::same(2),
bg,
);
}
painter.galley(rect.min, galley, color);
}
pub fn draw_markers(
painter: &Painter,
t_left: &Transform,
t_right: Option<&Transform>,
markers: &[Marker],
) {
for m in markers {
let t = match (m.y_axis, t_right) {
(YAxis::Right, Some(tr)) => tr,
_ => t_left,
};
let area = t.area;
match m.kind {
MarkerKind::Point { symbol, size, .. } => {
let pos = m.screen_point(t).expect("point marker has a screen point");
if !area.contains(pos) {
continue;
}
draw_marker_symbol(painter, pos, symbol, size, m.color);
if let Some(text) = &m.text {
draw_marker_label(
painter,
pos + vec2(size * 0.5 + 3.0, 0.0),
Align2::LEFT_CENTER,
text,
m.color,
m.bgcolor,
);
}
}
MarkerKind::VLine { .. } => {
let px = m.screen_x(t).expect("vline marker has a screen x");
if px < area.left() || px > area.right() {
continue;
}
draw_styled_line(
painter,
vec![pos2(px, area.top()), pos2(px, area.bottom())],
m.color,
m.line_width,
&m.line_style,
None,
);
if let Some(text) = &m.text {
draw_marker_label(
painter,
pos2(px + 3.0, area.top() + 2.0),
Align2::LEFT_TOP,
text,
m.color,
m.bgcolor,
);
}
}
MarkerKind::HLine { .. } => {
let py = m.screen_y(t).expect("hline marker has a screen y");
if py < area.top() || py > area.bottom() {
continue;
}
draw_styled_line(
painter,
vec![pos2(area.left(), py), pos2(area.right(), py)],
m.color,
m.line_width,
&m.line_style,
None,
);
if let Some(text) = &m.text {
draw_marker_label(
painter,
pos2(area.left() + 3.0, py - 2.0),
Align2::LEFT_BOTTOM,
text,
m.color,
m.bgcolor,
);
}
}
}
}
}
pub fn draw_triangles(painter: &Painter, t: &Transform, triangles: &[Triangles]) {
let painter = painter.with_clip_rect(t.area);
for tris in triangles {
if tris.indices.is_empty() {
continue;
}
painter.add(egui::Shape::mesh(tris.mesh(t)));
}
}
pub fn draw_shapes(painter: &Painter, t: &Transform, shapes: &[Shape]) {
let painter = painter.with_clip_rect(t.area);
let area = t.area;
for s in shapes {
match s.kind {
ShapeKind::Polygon | ShapeKind::Rectangle => {
let pts = s.screen_points(t);
if pts.len() < 2 {
continue;
}
if s.fill {
painter.add(egui::Shape::convex_polygon(
pts.clone(),
s.color,
Stroke::NONE,
));
}
let mut path = pts;
path.push(path[0]);
draw_styled_line(
&painter,
path,
s.color,
s.line_width,
&s.line_style,
s.gap_color,
);
}
ShapeKind::Polyline => {
draw_styled_line(
&painter,
s.screen_points(t),
s.color,
s.line_width,
&s.line_style,
s.gap_color,
);
}
ShapeKind::HLine => {
for &yv in &s.y {
let py = t.data_to_pixel(t.x.min, yv).y;
if py < area.top() || py > area.bottom() {
continue;
}
draw_styled_line(
&painter,
vec![pos2(area.left(), py), pos2(area.right(), py)],
s.color,
s.line_width,
&s.line_style,
s.gap_color,
);
}
}
ShapeKind::VLine => {
for &xv in &s.x {
let px = t.data_to_pixel(xv, t.y.min).x;
if px < area.left() || px > area.right() {
continue;
}
draw_styled_line(
&painter,
vec![pos2(px, area.top()), pos2(px, area.bottom())],
s.color,
s.line_width,
&s.line_style,
s.gap_color,
);
}
}
}
}
}
pub fn draw_lines(painter: &Painter, t: &Transform, lines: &[Line]) {
let painter = painter.with_clip_rect(t.area);
let bounds = Rect::from_min_max(
pos2(t.x.min as f32, t.y.min as f32),
pos2(t.x.max as f32, t.y.max as f32),
);
for line in lines {
if let Some((a, b)) = line.clipped_segment(bounds) {
let pa = t.data_to_pixel(a.x as f64, a.y as f64);
let pb = t.data_to_pixel(b.x as f64, b.y as f64);
draw_styled_line(
&painter,
vec![pa, pb],
line.color,
line.line_width,
&line.line_style,
line.gap_color,
);
}
}
}
pub fn draw_crosshair(painter: &Painter, t: &Transform, pos: Pos2, style: &Style) {
let area = t.area;
let line = Stroke::new(1.0, style.axis);
painter.vline(pos.x, area.y_range(), line);
painter.hline(area.x_range(), pos.y, line);
let (x, y) = t.pixel_to_data(pos);
let label = format!(
"{}, {}",
format_coord(x, t.x.min, t.x.max),
format_coord(y, t.y.min, t.y.max),
);
let font = FontId::proportional(11.0);
let galley = painter.layout_no_wrap(label, font, style.text);
let pad = egui::vec2(4.0, 2.0);
let size = galley.size() + pad * 2.0;
let mut min = pos + egui::vec2(10.0, 10.0);
if min.x + size.x > area.right() {
min.x = pos.x - 10.0 - size.x;
}
if min.y + size.y > area.bottom() {
min.y = pos.y - 10.0 - size.y;
}
painter.rect_filled(
Rect::from_min_size(min, size),
egui::CornerRadius::same(2),
style.readout_bg,
);
painter.galley(min + pad, galley, style.text);
}
pub fn draw_colorbar(painter: &Painter, rect: Rect, cmap: &Colormap, style: &Style) {
let n = 64usize;
let strip_h = rect.height() / n as f32;
for i in 0..n {
let lut_idx = (255 * (n - 1 - i) / (n - 1)).min(255);
let c = cmap.lut[lut_idx];
let y0 = rect.top() + i as f32 * strip_h;
let strip = Rect::from_min_max(
pos2(rect.left(), y0),
pos2(rect.right(), y0 + strip_h + 0.5),
);
painter.rect_filled(
strip,
egui::CornerRadius::ZERO,
Color32::from_rgb(c[0], c[1], c[2]),
);
}
painter.rect_stroke(
rect,
egui::CornerRadius::ZERO,
Stroke::new(1.0, style.axis),
egui::StrokeKind::Inside,
);
let font = FontId::proportional(11.0);
let axis = Stroke::new(1.0, style.axis);
if cmap.vmax <= cmap.vmin {
return;
}
let labeled: Vec<(f64, String)> = match cmap.normalization {
Normalization::Log => log_decade_ticks(cmap.vmin, cmap.vmax)
.into_iter()
.map(|v| (v, format_log_tick(v)))
.collect(),
_ => {
let (ticks, step) = nice_ticks(cmap.vmin, cmap.vmax, 6);
ticks
.into_iter()
.map(|v| (v, format_tick(v, step)))
.collect()
}
};
for (v, label) in labeled {
let frac = cmap.normalize(v); let py = rect.bottom() - frac * rect.height(); painter.line_segment([pos2(rect.right(), py), pos2(rect.right() + 3.0, py)], axis);
painter.text(
pos2(rect.right() + 5.0, py),
Align2::LEFT_CENTER,
label,
font.clone(),
style.text,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roi_appearance_resolves_color_fallback_and_name() {
use crate::core::roi::{ManagedRoi, RoiLineStyle};
let roi = Roi::Point { x: 0.0, y: 0.0 };
let bare = ManagedRoi::new(roi.clone());
let a = roi_appearance(&bare, Color32::RED);
assert_eq!(a.color, Some(Color32::RED));
assert_eq!(a.name, None);
assert!(!a.selected);
assert_eq!(a.line_width, Some(1.0));
assert_eq!(a.line_style, Some(LineStyle::Solid));
assert_eq!(a.fill, Some(false));
let mut styled = ManagedRoi::new(roi);
styled.color = Some(Color32::GREEN);
styled.name = "band".to_string();
styled.selected = true;
styled.line_width = 3.5;
styled.line_style = RoiLineStyle::Dashed;
styled.fill = true;
let b = roi_appearance(&styled, Color32::RED);
assert_eq!(b.color, Some(Color32::GREEN));
assert_eq!(b.name, Some("band"));
assert!(b.selected);
assert_eq!(b.line_width, Some(3.5));
assert_eq!(b.line_style, Some(LineStyle::Dashed));
assert_eq!(b.fill, Some(true));
}
#[test]
fn nice_ticks_lie_within_range_and_are_evenly_spaced() {
let (ticks, step) = nice_ticks(0.0, 256.0, 8);
assert!(!ticks.is_empty());
for &t in &ticks {
assert!((-1e-6..=256.0 + 1e-6).contains(&t), "{t} out of range");
}
for w in ticks.windows(2) {
assert!((w[1] - w[0] - step).abs() <= step * 1e-6, "uneven spacing");
}
}
#[test]
fn degenerate_or_inverted_range_yields_no_ticks() {
assert!(nice_ticks(5.0, 5.0, 8).0.is_empty());
assert!(nice_ticks(5.0, 1.0, 8).0.is_empty());
}
#[test]
fn format_tick_uses_step_appropriate_decimals() {
assert_eq!(format_tick(2.0, 1.0), "2");
assert_eq!(format_tick(0.5, 0.5), "0.5");
assert_eq!(format_tick(0.25, 0.05), "0.25");
}
#[test]
fn log_decade_ticks_are_one_per_power_of_ten() {
assert_eq!(
log_decade_ticks(1.0, 1000.0),
vec![1.0, 10.0, 100.0, 1000.0]
);
assert!(log_decade_ticks(2.0, 9.0).is_empty());
assert!(log_decade_ticks(0.0, 100.0).is_empty());
assert!(log_decade_ticks(-1.0, 100.0).is_empty());
assert!(log_decade_ticks(100.0, 1.0).is_empty());
}
#[test]
fn format_coord_scales_decimals_to_span() {
assert_eq!(format_coord(123.456, 0.0, 1000.0), "123");
assert_eq!(format_coord(1.2345, 0.0, 10.0), "1.2");
assert_eq!(format_coord(0.012345, 0.0, 0.1), "0.012");
assert_eq!(format_coord(1.5, 5.0, 5.0), "1.500");
}
#[test]
fn format_log_tick_plain_in_range_scientific_outside() {
assert_eq!(format_log_tick(10.0), "10");
assert_eq!(format_log_tick(0.01), "0.01");
assert_eq!(format_log_tick(1e8), "1e8");
}
#[test]
fn axis_ticks_time_series_yields_datetime_labels() {
let min = crate::core::dtime_ticks::DateTime::from_civil(2021, 1, 4, 0, 0, 0, 0)
.to_epoch_seconds();
let max = crate::core::dtime_ticks::DateTime::from_civil(2021, 1, 11, 0, 0, 0, 0)
.to_epoch_seconds();
let axis = Axis {
min,
max,
scale: Scale::Linear,
inverted: false,
};
let ticks = axis_ticks_with_mode(&axis, 8, TickMode::TimeSeries);
assert!(!ticks.is_empty(), "time-series ticks empty");
for (_, label) in &ticks {
assert_eq!(label.len(), 10, "label {label:?} not an ISO date");
let parts: Vec<&str> = label.split('-').collect();
assert_eq!(parts.len(), 3, "label {label:?} not Y-M-D");
assert_eq!(parts[0], "2021", "year wrong in {label:?}");
}
assert!(ticks.first().unwrap().0 <= min + 1e-6);
assert!(ticks.last().unwrap().0 >= max - 1e-6);
}
#[test]
fn axis_ticks_numeric_mode_matches_default_path() {
let axis = Axis {
min: 0.0,
max: 256.0,
scale: Scale::Linear,
inverted: false,
};
let numeric = axis_ticks_with_mode(&axis, 8, TickMode::Numeric);
let default_path = axis_ticks(&axis, 8);
assert_eq!(numeric, default_path);
for (_, label) in &numeric {
assert!(
!label.trim_start_matches('-').contains('-'),
"numeric label {label:?} looks like a date"
);
}
}
#[test]
fn axis_ticks_time_series_log_axis_falls_back_to_numeric_decades() {
let axis = Axis {
min: 1.0,
max: 1000.0,
scale: Scale::Log10,
inverted: false,
};
let ts = axis_ticks_with_mode(&axis, 8, TickMode::TimeSeries);
let log = axis_ticks_with_mode(&axis, 8, TickMode::Numeric);
assert_eq!(ts, log, "log axis should ignore TimeSeries");
}
#[test]
fn draw_lines_clips_vertical_and_horizontal_to_viewport() {
let area = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0));
let t = Transform::new(0.0, 10.0, 0.0, 10.0, area);
let bounds = Rect::from_min_max(
pos2(t.x.min as f32, t.y.min as f32),
pos2(t.x.max as f32, t.y.max as f32),
);
let vline = Line::new(f64::INFINITY, 4.0);
let (a, b) = vline
.clipped_segment(bounds)
.expect("vline crosses viewport");
assert_eq!(a.x, 4.0);
assert_eq!(b.x, 4.0);
assert_eq!(a.y.min(b.y), 0.0); assert_eq!(a.y.max(b.y), 10.0);
let hline = Line::new(0.0, 7.0);
let (a, b) = hline
.clipped_segment(bounds)
.expect("hline crosses viewport");
assert_eq!(a.x.min(b.x), 0.0); assert_eq!(a.x.max(b.x), 10.0); assert_eq!(a.y, 7.0);
assert_eq!(b.y, 7.0);
let outside = Line::new(f64::INFINITY, 99.0);
assert!(outside.clipped_segment(bounds).is_none());
}
#[test]
fn layout_axes_hidden_zeroes_all_gutters() {
let full = Rect::from_min_max(pos2(0.0, 0.0), pos2(400.0, 300.0));
let shown = layout(full, &ChromeRequest::default());
assert!(shown.data_area.left() > full.left());
assert!(shown.data_area.bottom() < full.bottom());
let hidden = layout(
full,
&ChromeRequest {
axes_hidden: true,
..Default::default()
},
);
assert_eq!(hidden.data_area, full);
assert!(hidden.colorbar.is_none());
}
#[test]
fn layout_axes_hidden_still_reserves_colorbar_strip() {
let full = Rect::from_min_max(pos2(0.0, 0.0), pos2(400.0, 300.0));
let hidden_cbar = layout(
full,
&ChromeRequest {
axes_hidden: true,
colorbar: true,
..Default::default()
},
);
let bar = hidden_cbar.colorbar.expect("colorbar rect");
assert_eq!(hidden_cbar.data_area.left(), full.left());
assert_eq!(hidden_cbar.data_area.top(), full.top());
assert_eq!(hidden_cbar.data_area.bottom(), full.bottom());
assert!(hidden_cbar.data_area.right() < full.right());
assert!(bar.left() >= hidden_cbar.data_area.right());
}
#[test]
fn layout_reserves_right_gutter_only_with_colorbar() {
let full = Rect::from_min_max(pos2(0.0, 0.0), pos2(400.0, 300.0));
let no_bar = layout(full, &ChromeRequest::default());
assert!(no_bar.colorbar.is_none());
let with_bar = layout(
full,
&ChromeRequest {
colorbar: true,
..Default::default()
},
);
let bar = with_bar.colorbar.expect("colorbar rect");
assert!(bar.left() >= with_bar.data_area.right());
assert!(with_bar.data_area.right() < no_bar.data_area.right());
}
#[test]
fn layout_reserves_right_gutter_for_y2_without_colorbar() {
let full = Rect::from_min_max(pos2(0.0, 0.0), pos2(400.0, 300.0));
let plain = layout(
full,
&ChromeRequest {
..Default::default()
},
);
let with_y2 = layout(
full,
&ChromeRequest {
y2: true,
..Default::default()
},
);
assert!(with_y2.colorbar.is_none());
assert!(with_y2.data_area.right() < plain.data_area.right());
}
#[test]
fn layout_grows_each_gutter_for_its_label() {
let full = Rect::from_min_max(pos2(0.0, 0.0), pos2(400.0, 300.0));
let base = layout(full, &ChromeRequest::default()).data_area;
let titled = layout(
full,
&ChromeRequest {
title: true,
..Default::default()
},
)
.data_area;
assert!(titled.top() > base.top(), "title grows the top gutter");
let xlab = layout(
full,
&ChromeRequest {
x_label: true,
..Default::default()
},
)
.data_area;
assert!(
xlab.bottom() < base.bottom(),
"x label grows the bottom gutter"
);
let ylab = layout(
full,
&ChromeRequest {
y_label: true,
..Default::default()
},
)
.data_area;
assert!(ylab.left() > base.left(), "y label grows the left gutter");
let y2lab = layout(
full,
&ChromeRequest {
y2: true,
y2_label: true,
..Default::default()
},
)
.data_area;
let y2_only = layout(
full,
&ChromeRequest {
y2: true,
..Default::default()
},
)
.data_area;
assert!(
y2lab.right() < y2_only.right(),
"y2 label grows the right gutter"
);
}
#[test]
fn rotated_label_visual_center_lands_at_target() {
use egui::epaint::TextShape;
let ctx = egui::Context::default();
let mut galley = None;
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
galley = Some(ui.painter().layout_no_wrap(
"Temperature [\u{b0}C]".to_owned(),
FontId::proportional(12.0),
Color32::WHITE,
));
});
let galley = galley.expect("run closure executes once");
assert!(galley.rect.width() > 40.0, "fixture label should be wide");
let target = Pos2::new(1124.0, 456.0);
for angle in [std::f32::consts::FRAC_PI_2, -std::f32::consts::FRAC_PI_2] {
let pos = target - 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 - target.x).abs() < 1.0 && (c.y - target.y).abs() < 1.0,
"angle={angle}: corrected center {c:?} should equal target {target:?}"
);
let naive = TextShape::new(target, galley.clone(), Color32::WHITE)
.with_angle_and_anchor(angle, Align2::CENTER_CENTER);
let nc = naive.visual_bounding_rect().center();
assert!(
(nc - target).length() > 10.0,
"angle={angle}: naive center {nc:?} must be offset from target {target:?}"
);
}
}
}