1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-27 23:13:13 -04:00

plot: Tick placement of opposite axes and digit constraints

This commit is contained in:
Johannes Schiffer
2022-11-14 23:16:18 +01:00
committed by JohannesProgrammiert
parent ffb1a45651
commit 9d76be1131
3 changed files with 114 additions and 39 deletions

View File

@@ -12,7 +12,7 @@ use crate::{Response, Sense, TextStyle, Ui, Widget, WidgetText};
use super::{transform::PlotTransform, GridMark, MIN_LINE_SPACING_IN_POINTS};
pub(super) type AxisFormatterFn = fn(f64, &RangeInclusive<f64>) -> String;
pub(super) type AxisFormatterFn = fn(f64, usize, &RangeInclusive<f64>) -> String;
/// Axis specifier.
///
@@ -28,7 +28,7 @@ pub enum Axis {
/// `Default` means bottom for x, left for y.
/// `Opposite` means top for x, right for y.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Placement {
pub enum AxisPlacement {
Default,
Opposite,
}
@@ -38,9 +38,10 @@ pub enum Placement {
/// Used to configure axis label and ticks.
#[derive(Clone)]
pub struct AxisConfig {
pub(super) placement: Placement,
pub(super) placement: AxisPlacement,
label: String,
pub(super) formatter: AxisFormatterFn,
digits: usize,
pub(super) axis: Axis,
}
@@ -53,6 +54,8 @@ impl Debug for AxisConfig {
)
}
}
// TODO: this just a guess. It might cease to work if a user changes font size.
const LINE_HEIGHT: f32 = 12.0;
impl AxisConfig {
@@ -63,9 +66,10 @@ impl AxisConfig {
/// `formatter` is default float to string formatter
pub const fn default(axis: Axis) -> Self {
Self {
placement: Placement::Default,
placement: AxisPlacement::Default,
label: String::new(),
formatter: Self::default_formatter,
digits: 5,
axis,
}
}
@@ -79,36 +83,55 @@ impl AxisConfig {
/// Specify custom formatter for ticks.
///
/// The first parameter of `formatter` is the raw tick value as `f64`.
/// The second paramter is the maximum number of characters that fit into y-labels.
/// The second paramter of `formatter` is the currently shown range on this axis.
pub fn tick_formatter(mut self, formatter: fn(f64, &RangeInclusive<f64>) -> String) -> Self {
pub fn tick_formatter(
mut self,
formatter: fn(f64, usize, &RangeInclusive<f64>) -> String,
) -> Self {
self.formatter = formatter;
self
}
/// Specify the placement for this axis.
pub fn placement(mut self, placement: Placement) -> Self {
pub fn placement(mut self, placement: AxisPlacement) -> Self {
self.placement = placement;
self
}
fn default_formatter(tick: f64, _range: &RangeInclusive<f64>) -> String {
const MAX_DECIMALS: usize = 5;
if tick.abs() > 1e6 {
let tick_rounded = round_to_decimals(tick, MAX_DECIMALS);
format!("{:+e}", tick_rounded)
} else if tick.abs() < 1e-6 && tick != 0.0 {
format!("{:+e}", tick)
} else {
let tick_rounded = round_to_decimals(tick, MAX_DECIMALS);
format!("{}", tick_rounded)
fn default_formatter(tick: f64, max_digits: usize, _range: &RangeInclusive<f64>) -> String {
if tick.abs() > 10.0_f64.powf(max_digits as f64) {
let tick_rounded = tick as isize;
return format!("{:+e}", tick_rounded);
}
let tick_rounded = round_to_decimals(tick, max_digits);
if tick.abs() < 10.0_f64.powf(-(max_digits as f64)) && tick != 0.0 {
return format!("{:+e}", tick_rounded);
}
format!("{}", tick_rounded)
}
pub fn max_digits(mut self, digits: usize) -> Self {
self.digits = digits;
self
}
pub(super) fn thickness(&self) -> f32 {
if self.label.is_empty() {
LINE_HEIGHT
} else {
2.0 * LINE_HEIGHT
match self.axis {
Axis::X => {
if self.label.is_empty() {
1.0 * LINE_HEIGHT
} else {
3.0 * LINE_HEIGHT
}
}
Axis::Y => {
if self.label.is_empty() {
(self.digits as f32) * LINE_HEIGHT
} else {
(self.digits as f32 + 1.0) * LINE_HEIGHT
}
}
}
}
}
@@ -152,12 +175,12 @@ impl Widget for AxisWidget {
};
// select text_pos and angle depending on placement and orientation of widget
let text_pos = match self.config.placement {
Placement::Default => match self.config.axis {
AxisPlacement::Default => match self.config.axis {
Axis::X => {
let pos = response.rect.center_bottom();
Pos2 {
x: pos.x - galley.size().x / 2.0,
y: pos.y - galley.size().y,
y: pos.y - galley.size().y * 1.25,
}
}
Axis::Y => {
@@ -168,18 +191,18 @@ impl Widget for AxisWidget {
}
}
},
Placement::Opposite => match self.config.axis {
AxisPlacement::Opposite => match self.config.axis {
Axis::X => {
let pos = response.rect.center_top();
Pos2 {
x: pos.x - galley.size().x / 2.0,
y: pos.y + galley.size().y / 2.0,
y: pos.y + galley.size().y * 0.25,
}
}
Axis::Y => {
let pos = response.rect.right_center();
Pos2 {
x: pos.x - galley.size().y,
x: pos.x - galley.size().y * 1.5,
y: pos.y + galley.size().x / 2.0,
}
}
@@ -202,7 +225,7 @@ impl Widget for AxisWidget {
};
for step in self.steps {
let text = (self.config.formatter)(step.value, &self.range);
let text = (self.config.formatter)(step.value, self.config.digits, &self.range);
if !text.is_empty() {
let spacing_in_points = (transform.dpos_dvalue()[self.config.axis as usize]
* step.step_size)
@@ -221,17 +244,25 @@ impl Widget for AxisWidget {
.layout_no_wrap(text, font_id.clone(), line_color);
let text_pos = match self.config.axis {
Axis::X => {
let y = match self.config.placement {
AxisPlacement::Default => self.rect.min.y,
AxisPlacement::Opposite => self.rect.max.y - galley.size().y,
};
let projected_point = super::PlotPoint::new(step.value, 0.0);
Pos2 {
x: transform.position_from_point(&projected_point).x
- galley.size().x / 2.0,
y: self.rect.min.y,
y,
}
}
Axis::Y => {
let x = match self.config.placement {
AxisPlacement::Default => self.rect.max.x - galley.size().x,
AxisPlacement::Opposite => self.rect.min.x,
};
let projected_point = super::PlotPoint::new(0.0, step.value);
Pos2 {
x: self.rect.max.x - galley.size().x,
x,
y: transform.position_from_point(&projected_point).y
- galley.size().y / 2.0,
}

View File

@@ -6,6 +6,7 @@ use crate::*;
use epaint::util::FloatOrd;
use epaint::Hsva;
pub use axis::{AxisPlacement, Axis, AxisConfig};
use axis::AxisWidget;
use items::PlotItem;
use legend::LegendWidget;
@@ -17,11 +18,7 @@ pub use items::{
pub use legend::{Corner, Legend};
pub use transform::{PlotBounds, PlotTransform};
use self::{
axis::Axis,
axis::AxisConfig,
items::{horizontal_line, rulers_color, vertical_line},
};
use items::{horizontal_line, rulers_color, vertical_line};
pub mod axis;
mod items;
@@ -674,7 +671,7 @@ impl Plot {
let mut d = 0.0;
for cfg in &axis_config {
match cfg.placement {
axis::Placement::Default => match cfg.axis {
AxisPlacement::Default => match cfg.axis {
Axis::X => {
a += cfg.thickness();
}
@@ -682,7 +679,7 @@ impl Plot {
b += cfg.thickness();
}
},
axis::Placement::Opposite => match cfg.axis {
AxisPlacement::Opposite => match cfg.axis {
Axis::X => {
c += cfg.thickness();
}
@@ -722,7 +719,7 @@ impl Plot {
y: cfg.thickness(),
};
let rect: Rect = match cfg.placement {
axis::Placement::Default => match cfg.axis {
AxisPlacement::Default => match cfg.axis {
Axis::X => {
let off = widget_cnt.bottom as f32;
widget_cnt.bottom += 1;
@@ -740,7 +737,7 @@ impl Plot {
}
}
},
axis::Placement::Opposite => match cfg.axis {
AxisPlacement::Opposite => match cfg.axis {
Axis::X => {
let off = widget_cnt.top as f32;
widget_cnt.top += 1;

View File

@@ -1,6 +1,7 @@
use std::f64::consts::TAU;
use std::ops::RangeInclusive;
use egui::plot::{AxisBools, GridInput, GridMark, PlotResponse};
use egui::plot::{AxisBools, AxisConfig, GridInput, GridMark, PlotResponse};
use egui::*;
use plot::{
Arrows, Bar, BarChart, BoxElem, BoxPlot, BoxSpread, CoordinatesFormatter, Corner, HLine,
@@ -263,7 +264,12 @@ impl LineDemo {
ui.ctx().request_repaint();
self.time += ui.input(|i| i.unstable_dt).at_most(1.0 / 30.0) as f64;
};
let mut plot = Plot::new("lines_demo").legend(Legend::default());
let mut plot = Plot::new("lines_demo")
.axes(vec![
AxisConfig::default(plot::Axis::X).label("x".to_string()),
AxisConfig::default(plot::Axis::Y).label("y".to_string()),
])
.legend(Legend::default());
if self.square {
plot = plot.view_aspect(1.0);
}
@@ -514,6 +520,28 @@ impl CustomAxisDemo {
100.0 * y
}
let x_fmt = |x, _digits, _range: &RangeInclusive<f64>| {
if x < 0.0 * MINS_PER_DAY || x >= 5.0 * MINS_PER_DAY {
// No labels outside value bounds
String::new()
} else if is_approx_integer(x / MINS_PER_DAY) {
// Days
format!("Day {}", day(x))
} else {
// Hours and minutes
format!("{h}:{m:02}", h = hour(x), m = minute(x))
}
};
let y_fmt = |y, _digits, _range: &RangeInclusive<f64>| {
// Display only integer percentages
if !is_approx_zero(y) && is_approx_integer(100.0 * y) {
format!("{:.0}%", percent(y))
} else {
String::new()
}
};
let label_fmt = |_s: &str, val: &PlotPoint| {
format!(
"Day {d}, {h}:{m:02}\n{p:.2}%",
@@ -526,8 +554,19 @@ impl CustomAxisDemo {
ui.label("Zoom in on the X-axis to see hours and minutes");
let axes = vec![
AxisConfig::default(plot::Axis::X)
.tick_formatter(x_fmt)
.label("Percent".to_string()),
AxisConfig::default(plot::Axis::Y)
.tick_formatter(y_fmt)
.max_digits(4)
.label("Time".to_string()),
];
Plot::new("custom_axes")
.data_aspect(2.0 * MINS_PER_DAY as f32)
.axes(axes)
.x_grid_spacer(CustomAxisDemo::x_grid)
.label_formatter(label_fmt)
.show(ui, |plot_ui| {
@@ -973,3 +1012,11 @@ impl ChartsDemo {
.response
}
}
fn is_approx_zero(val: f64) -> bool {
val.abs() < 1e-6
}
fn is_approx_integer(val: f64) -> bool {
val.fract().abs() < 1e-6
}