use egui::{Color32, Pos2, Rect, Sense, Stroke, Vec2, pos2};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DataRect {
pub left: f64,
pub top: f64,
pub width: f64,
pub height: f64,
}
impl DataRect {
pub fn new(left: f64, top: f64, width: f64, height: f64) -> Self {
Self {
left,
top,
width,
height,
}
}
pub fn from_bounds(x_min: f64, x_max: f64, y_min: f64, y_max: f64) -> Self {
let (left, right) = if x_min <= x_max {
(x_min, x_max)
} else {
(x_max, x_min)
};
let (top, bottom) = if y_min <= y_max {
(y_min, y_max)
} else {
(y_max, y_min)
};
Self {
left,
top,
width: right - left,
height: bottom - top,
}
}
pub fn right(&self) -> f64 {
self.left + self.width
}
pub fn bottom(&self) -> f64 {
self.top + self.height
}
pub fn union(&self, other: &DataRect) -> DataRect {
let left = self.left.min(other.left);
let top = self.top.min(other.top);
let right = self.right().max(other.right());
let bottom = self.bottom().max(other.bottom());
DataRect {
left,
top,
width: right - left,
height: bottom - top,
}
}
pub fn limits(&self) -> (f64, f64, f64, f64) {
(self.left, self.right(), self.top, self.bottom())
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RadarMapping {
pub scale: f64,
pub offset_x: f64,
pub offset_y: f64,
}
impl RadarMapping {
pub fn fit(bounds: &DataRect, widget: Rect) -> RadarMapping {
let w = widget.width() as f64;
let h = widget.height() as f64;
let bw = bounds.width;
let bh = bounds.height;
let scale = if bw.is_finite() && bh.is_finite() && bw > 0.0 && bh > 0.0 {
(w / bw).min(h / bh)
} else if bw.is_finite() && bw > 0.0 {
w / bw
} else if bh.is_finite() && bh > 0.0 {
h / bh
} else {
1.0
};
let scale = if scale.is_finite() && scale > 0.0 {
scale
} else {
1.0
};
let content_w = bw.max(0.0) * scale;
let content_h = bh.max(0.0) * scale;
let pad_x = (w - content_w) * 0.5;
let pad_y = (h - content_h) * 0.5;
let left = widget.left() as f64;
let top = widget.top() as f64;
let offset_x = left + pad_x - bounds.left * scale;
let offset_y = top + pad_y - bounds.top * scale;
RadarMapping {
scale,
offset_x,
offset_y,
}
}
pub fn data_to_widget(&self, x: f64, y: f64) -> Pos2 {
pos2(
(x * self.scale + self.offset_x) as f32,
(y * self.scale + self.offset_y) as f32,
)
}
pub fn widget_to_data(&self, p: Pos2) -> (f64, f64) {
(
(p.x as f64 - self.offset_x) / self.scale,
(p.y as f64 - self.offset_y) / self.scale,
)
}
pub fn data_rect_to_widget(&self, r: &DataRect) -> Rect {
let min = self.data_to_widget(r.left, r.top);
let max = self.data_to_widget(r.right(), r.bottom());
Rect::from_min_max(min, max)
}
}
pub fn clamp_viewport(viewport: DataRect, data_extent: &DataRect) -> DataRect {
let x_min = data_extent.left;
let x_max = data_extent.right();
let y_min = data_extent.top;
let y_max = data_extent.bottom();
let left = clamp_axis(viewport.left, viewport.width, x_min, x_max);
let top = clamp_axis(viewport.top, viewport.height, y_min, y_max);
DataRect {
left,
top,
width: viewport.width,
height: viewport.height,
}
}
fn clamp_axis(pos: f64, size: f64, lo: f64, hi: f64) -> f64 {
if size <= (hi - lo) {
if pos < lo {
lo
} else if pos > hi - size {
hi - size
} else {
pos
}
} else {
if pos > lo {
lo
} else if pos < hi - size {
hi - size
} else {
pos
}
}
}
pub fn point_in_rect(rect: Rect, p: Pos2) -> bool {
rect.contains(p)
}
const DATA_FILL: Color32 = Color32::from_rgb(0xD3, 0xD3, 0xD3);
const DATA_STROKE: Color32 = Color32::WHITE;
const VISIBLE_STROKE: Color32 = Color32::BLUE;
pub const DEFAULT_SIZE: f32 = 256.0;
#[derive(Clone, Debug)]
pub struct RadarView {
pub data_extent: DataRect,
pub viewport: DataRect,
}
impl Default for RadarView {
fn default() -> Self {
Self {
data_extent: DataRect::new(0.0, 0.0, 1.0, 1.0),
viewport: DataRect::new(0.0, 0.0, 1.0, 1.0),
}
}
}
impl RadarView {
pub fn new(data_extent: DataRect) -> Self {
Self {
data_extent,
viewport: data_extent,
}
}
pub fn set_data_extent(&mut self, data_extent: DataRect) {
self.data_extent = data_extent;
}
pub fn set_data_bounds(&mut self, x_min: f64, x_max: f64, y_min: f64, y_max: f64) {
self.data_extent = DataRect::from_bounds(x_min, x_max, y_min, y_max);
}
pub fn set_viewport(&mut self, viewport: DataRect) {
self.viewport = viewport;
}
pub fn set_viewport_limits(&mut self, x_min: f64, x_max: f64, y_min: f64, y_max: f64) {
self.viewport = DataRect::from_bounds(x_min, x_max, y_min, y_max);
}
pub fn mapping(&self, widget: Rect) -> RadarMapping {
let bounds = self.data_extent.union(&self.viewport);
RadarMapping::fit(&bounds, widget)
}
pub fn ui(&mut self, ui: &mut egui::Ui, desired_size: Vec2) -> RadarResponse {
let (rect, response) = ui.allocate_exact_size(desired_size, Sense::drag());
let mut dragged_limits = None;
if response.dragged() {
let mapping = self.mapping(rect);
let delta = response.drag_delta();
let data_dx = delta.x as f64 / mapping.scale;
let data_dy = delta.y as f64 / mapping.scale;
let moved = DataRect {
left: self.viewport.left + data_dx,
top: self.viewport.top + data_dy,
width: self.viewport.width,
height: self.viewport.height,
};
let clamped = clamp_viewport(moved, &self.data_extent);
if clamped != self.viewport {
self.viewport = clamped;
dragged_limits = Some(clamped.limits());
}
}
if ui.is_rect_visible(rect) {
let mapping = self.mapping(rect);
self.paint(ui, rect, &mapping);
}
RadarResponse {
response,
dragged_limits,
}
}
fn paint(&self, ui: &egui::Ui, rect: Rect, mapping: &RadarMapping) {
let painter = ui.painter_at(rect);
let data_px = mapping.data_rect_to_widget(&self.data_extent);
painter.rect_filled(data_px, 0.0, DATA_FILL);
painter.rect_stroke(
data_px,
0.0,
Stroke::new(1.0, DATA_STROKE),
egui::StrokeKind::Inside,
);
let view_px = mapping.data_rect_to_widget(&self.viewport);
painter.rect_stroke(
view_px,
0.0,
Stroke::new(2.0, VISIBLE_STROKE),
egui::StrokeKind::Inside,
);
}
}
pub struct RadarResponse {
pub response: egui::Response,
pub dragged_limits: Option<(f64, f64, f64, f64)>,
}
#[cfg(test)]
mod tests {
use super::*;
use egui::vec2;
const EPS: f64 = 1e-9;
fn widget_rect() -> Rect {
Rect::from_min_size(pos2(0.0, 0.0), vec2(200.0, 200.0))
}
#[test]
fn square_extent_fills_square_widget() {
let extent = DataRect::new(0.0, 0.0, 10.0, 10.0);
let view = RadarView::new(extent);
let mapping = view.mapping(widget_rect());
assert!((mapping.scale - 20.0).abs() < EPS); let r = mapping.data_rect_to_widget(&extent);
assert!((r.min.x - 0.0).abs() < 1e-4);
assert!((r.min.y - 0.0).abs() < 1e-4);
assert!((r.max.x - 200.0).abs() < 1e-4);
assert!((r.max.y - 200.0).abs() < 1e-4);
}
#[test]
fn data_widget_data_round_trip() {
let extent = DataRect::new(-3.0, 7.0, 5.0, 9.0);
let view = RadarView::new(extent);
let mapping = view.mapping(widget_rect());
for &(x, y) in &[(-3.0, 7.0), (2.0, 16.0), (-1.0, 10.5), (-3.0, 16.0)] {
let px = mapping.data_to_widget(x, y);
let (bx, by) = mapping.widget_to_data(px);
assert!((bx - x).abs() < 1e-3, "x round-trip {x} -> {bx}");
assert!((by - y).abs() < 1e-3, "y round-trip {y} -> {by}");
}
}
#[test]
fn viewport_equal_to_extent_fills_box() {
let extent = DataRect::new(0.0, 0.0, 4.0, 4.0);
let mut view = RadarView::new(extent);
view.set_viewport(extent);
let mapping = view.mapping(widget_rect());
let data_px = mapping.data_rect_to_widget(&extent);
let view_px = mapping.data_rect_to_widget(&view.viewport);
assert!((data_px.min.x - view_px.min.x).abs() < 1e-4);
assert!((data_px.min.y - view_px.min.y).abs() < 1e-4);
assert!((data_px.max.x - view_px.max.x).abs() < 1e-4);
assert!((data_px.max.y - view_px.max.y).abs() < 1e-4);
}
#[test]
fn aspect_fit_centers_non_matching_extent() {
let extent = DataRect::new(0.0, 0.0, 20.0, 10.0);
let view = RadarView::new(extent);
let mapping = view.mapping(widget_rect());
assert!((mapping.scale - 10.0).abs() < EPS);
let r = mapping.data_rect_to_widget(&extent);
assert!((r.min.x - 0.0).abs() < 1e-4);
assert!((r.max.x - 200.0).abs() < 1e-4);
assert!((r.min.y - 50.0).abs() < 1e-4);
assert!((r.max.y - 150.0).abs() < 1e-4);
assert!(((r.min.y - 0.0) - (200.0 - r.max.y)).abs() < 1e-4);
}
#[test]
fn aspect_fit_centers_tall_extent() {
let extent = DataRect::new(0.0, 0.0, 10.0, 20.0);
let view = RadarView::new(extent);
let mapping = view.mapping(widget_rect());
assert!((mapping.scale - 10.0).abs() < EPS);
let r = mapping.data_rect_to_widget(&extent);
assert!((r.min.y - 0.0).abs() < 1e-4);
assert!((r.max.y - 200.0).abs() < 1e-4);
assert!((r.min.x - 50.0).abs() < 1e-4);
assert!((r.max.x - 150.0).abs() < 1e-4);
}
#[test]
fn clamp_past_min_edge() {
let extent = DataRect::new(0.0, 0.0, 10.0, 10.0);
let moved = DataRect::new(-5.0, -3.0, 2.0, 2.0);
let clamped = clamp_viewport(moved, &extent);
assert!((clamped.left - 0.0).abs() < EPS);
assert!((clamped.top - 0.0).abs() < EPS);
assert!((clamped.width - 2.0).abs() < EPS);
assert!((clamped.height - 2.0).abs() < EPS);
}
#[test]
fn clamp_past_max_edge() {
let extent = DataRect::new(0.0, 0.0, 10.0, 10.0);
let moved = DataRect::new(20.0, 15.0, 2.0, 2.0);
let clamped = clamp_viewport(moved, &extent);
assert!((clamped.left - 8.0).abs() < EPS);
assert!((clamped.top - 8.0).abs() < EPS);
}
#[test]
fn clamp_in_bounds_is_identity() {
let extent = DataRect::new(0.0, 0.0, 10.0, 10.0);
let v = DataRect::new(3.0, 4.0, 2.0, 2.0);
let clamped = clamp_viewport(v, &extent);
assert_eq!(clamped, v);
}
#[test]
fn clamp_viewport_larger_than_extent() {
let extent = DataRect::new(0.0, 0.0, 10.0, 10.0);
let below = DataRect::new(-50.0, -50.0, 20.0, 20.0);
let c1 = clamp_viewport(below, &extent);
assert!((c1.left - (-10.0)).abs() < EPS);
assert!((c1.top - (-10.0)).abs() < EPS);
let above = DataRect::new(5.0, 5.0, 20.0, 20.0);
let c2 = clamp_viewport(above, &extent);
assert!((c2.left - 0.0).abs() < EPS);
assert!((c2.top - 0.0).abs() < EPS);
let inside = DataRect::new(-5.0, -2.0, 20.0, 20.0);
let c3 = clamp_viewport(inside, &extent);
assert!((c3.left - (-5.0)).abs() < EPS);
assert!((c3.top - (-2.0)).abs() < EPS);
}
#[test]
fn clamp_preserves_size() {
let extent = DataRect::new(0.0, 0.0, 10.0, 10.0);
for moved in [
DataRect::new(-5.0, -5.0, 3.0, 4.0),
DataRect::new(50.0, 50.0, 3.0, 4.0),
DataRect::new(-50.0, -50.0, 30.0, 40.0),
] {
let c = clamp_viewport(moved, &extent);
assert!((c.width - moved.width).abs() < EPS);
assert!((c.height - moved.height).abs() < EPS);
}
}
#[test]
fn point_in_rect_hit_test() {
let extent = DataRect::new(0.0, 0.0, 10.0, 10.0);
let mut view = RadarView::new(extent);
view.set_viewport(DataRect::new(2.0, 2.0, 4.0, 4.0));
let mapping = view.mapping(widget_rect());
let view_px = mapping.data_rect_to_widget(&view.viewport);
let center = mapping.data_to_widget(4.0, 4.0);
assert!(point_in_rect(view_px, center));
let corner_outside = mapping.data_to_widget(0.5, 0.5);
assert!(!point_in_rect(view_px, corner_outside));
}
#[test]
fn from_bounds_normalizes_reversed() {
let r = DataRect::from_bounds(7.0, -3.0, 16.0, 7.0);
assert!((r.left - (-3.0)).abs() < EPS);
assert!((r.top - 7.0).abs() < EPS);
assert!((r.width - 10.0).abs() < EPS);
assert!((r.height - 9.0).abs() < EPS);
}
#[test]
fn limits_matches_setlimits_order() {
let r = DataRect::new(2.0, 3.0, 4.0, 5.0);
assert_eq!(r.limits(), (2.0, 6.0, 3.0, 8.0));
}
#[test]
fn union_covers_both() {
let a = DataRect::new(0.0, 0.0, 10.0, 10.0);
let b = DataRect::new(-5.0, 5.0, 20.0, 3.0);
let u = a.union(&b);
assert!((u.left - (-5.0)).abs() < EPS);
assert!((u.top - 0.0).abs() < EPS);
assert!((u.right() - 15.0).abs() < EPS);
assert!((u.bottom() - 10.0).abs() < EPS);
}
#[test]
fn degenerate_extent_falls_back_to_unit_scale() {
let extent = DataRect::new(0.0, 0.0, 0.0, 0.0);
let view = RadarView::new(extent);
let mapping = view.mapping(widget_rect());
assert!(mapping.scale.is_finite());
assert!(mapping.scale > 0.0);
assert!((mapping.scale - 1.0).abs() < EPS);
}
}