Skip to content

Commit

Permalink
Introduce a new blueprint archetype for AxisY configuration in a plot. (
Browse files Browse the repository at this point in the history
#5028)

### What

Part of 
* #4818

Introduce a new archetype for AxisY configuration in a plot.

This contains 2 new components:
- Range: defines a min / max value for the y-axis of the plot.
- LockRangeDuringZoom: a boolean indicating whether the range should be
locked during zoom

In "LockRange" mode, the Y-axis of the plot will always be locked to the
defined range, regardless of whether the range is Auto or a specified
range.

In the default mode, the user can zoom arbitrarily in a way that
preserves the aspect ratio of the data. When the user **resets** the
view, it will return to the defined range.


![image](https://github.com/rerun-io/rerun/assets/3312232/85778c80-295d-402b-8317-c5a4a7c7b21f)

### Checklist
* [x] I have read and agree to [Contributor
Guide](https://github.com/rerun-io/rerun/blob/main/CONTRIBUTING.md) and
the [Code of
Conduct](https://github.com/rerun-io/rerun/blob/main/CODE_OF_CONDUCT.md)
* [x] I've included a screenshot or gif (if applicable)
* [x] I have tested the web demo (if applicable):
* Using newly built examples:
[app.rerun.io](https://app.rerun.io/pr/5028/index.html)
* Using examples from latest `main` build:
[app.rerun.io](https://app.rerun.io/pr/5028/index.html?manifest_url=https://app.rerun.io/version/main/examples_manifest.json)
* Using full set of examples from `nightly` build:
[app.rerun.io](https://app.rerun.io/pr/5028/index.html?manifest_url=https://app.rerun.io/version/nightly/examples_manifest.json)
* [x] The PR title and labels are set such as to maximize their
usefulness for the next release's CHANGELOG

- [PR Build Summary](https://build.rerun.io/pr/5028)
- [Docs
preview](https://rerun.io/preview/4087df686e3211027cd6b2ae8dcc2633d8993f1b/docs)
<!--DOCS-PREVIEW-->
- [Examples
preview](https://rerun.io/preview/4087df686e3211027cd6b2ae8dcc2633d8993f1b/examples)
<!--EXAMPLES-PREVIEW-->
- [Recent benchmark results](https://build.rerun.io/graphs/crates.html)
- [Wasm size tracking](https://build.rerun.io/graphs/sizes.html)

---------

Co-authored-by: Andreas Reich <[email protected]>
  • Loading branch information
jleibs and Wumpf authored Feb 7, 2024
1 parent f780e7e commit 38a7d20
Show file tree
Hide file tree
Showing 41 changed files with 1,536 additions and 6 deletions.
179 changes: 174 additions & 5 deletions crates/re_space_view_time_series/src/space_view_class.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use egui::ahash::{HashMap, HashSet};
use egui::NumExt as _;
use egui_plot::{Legend, Line, Plot, PlotPoint, Points};

use re_data_store::TimeType;
Expand All @@ -7,6 +8,7 @@ use re_log_types::{EntityPath, EntityPathFilter, TimeZone};
use re_query::query_archetype;
use re_space_view::controls;
use re_types::blueprint::components::Corner2D;
use re_types::components::Range1D;
use re_types::Archetype;
use re_viewer_context::external::re_entity_db::{
EditableAutoValue, EntityProperties, EntityTree, TimeSeriesAggregator,
Expand All @@ -25,7 +27,7 @@ use crate::PlotSeriesKind;

// ---

#[derive(Clone, Default)]
#[derive(Clone)]
pub struct TimeSeriesSpaceViewState {
/// Is the user dragging the cursor this frame?
is_dragging_time_cursor: bool,
Expand All @@ -35,6 +37,24 @@ pub struct TimeSeriesSpaceViewState {

/// State of egui_plot's auto bounds before the user started dragging the time cursor.
saved_auto_bounds: egui::Vec2b,

/// State of egui_plot's bounds
saved_y_axis_range: [f64; 2],

/// To track when the range has been edited
last_range: Option<Range1D>,
}

impl Default for TimeSeriesSpaceViewState {
fn default() -> Self {
Self {
is_dragging_time_cursor: false,
was_dragging_time_cursor: false,
saved_auto_bounds: Default::default(),
saved_y_axis_range: [0.0, 1.0],
last_range: None,
}
}
}

impl SpaceViewState for TimeSeriesSpaceViewState {
Expand Down Expand Up @@ -75,7 +95,7 @@ impl SpaceViewClass for TimeSeriesSpaceView {

layout.add("Scroll + ");
layout.add(controls::ASPECT_SCROLL_MODIFIER);
layout.add(" to change the aspect ratio.\n");
layout.add(" to zoom only the temporal axis while holding the y-range fixed.\n");

layout.add("Drag ");
layout.add(controls::SELECTION_RECT_ZOOM_BUTTON);
Expand Down Expand Up @@ -113,7 +133,7 @@ impl SpaceViewClass for TimeSeriesSpaceView {
&self,
ctx: &ViewerContext<'_>,
ui: &mut egui::Ui,
_state: &mut Self::State,
state: &mut Self::State,
_space_origin: &EntityPath,
space_view_id: SpaceViewId,
root_entity_properties: &mut EntityProperties,
Expand Down Expand Up @@ -146,6 +166,7 @@ It can greatly improve performance (and readability) in such situations as it pr
});

legend_ui(ctx, space_view_id, ui);
axis_ui(ctx, space_view_id, ui, state);
}

fn spawn_heuristics(&self, ctx: &ViewerContext<'_>) -> SpaceViewSpawnHeuristics {
Expand Down Expand Up @@ -280,6 +301,14 @@ It can greatly improve performance (and readability) in such situations as it pr
_,
) = query_space_view_sub_archetype(ctx, query.space_view_id);

let (
re_types::blueprint::archetypes::ScalarAxis {
range: y_range,
lock_range_during_zoom: y_lock_range_during_zoom,
},
_,
) = query_space_view_sub_archetype(ctx, query.space_view_id);

let (current_time, time_type, timeline) = {
// Avoid holding the lock for long
let time_ctrl = ctx.rec_cfg.time_ctrl.read();
Expand Down Expand Up @@ -332,12 +361,25 @@ It can greatly improve performance (and readability) in such situations as it pr
// use timeline_name as part of id, so that egui stores different pan/zoom for different timelines
let plot_id_src = ("plot", &timeline_name);

let zoom_both_axis = !ui.input(|i| i.modifiers.contains(controls::ASPECT_SCROLL_MODIFIER));
let lock_y_during_zoom = y_lock_range_during_zoom.map_or(false, |v| v.0)
|| ui.input(|i| i.modifiers.contains(controls::ASPECT_SCROLL_MODIFIER));

let auto_y = y_range.is_none();

// We don't want to allow vertical when y is locked or else the view "bounces" when we scroll and
// then reset to the locked range.
if lock_y_during_zoom {
ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0);
}

// TODO(jleibs): Would be nice to disable vertical drag instead of just resetting.

// TODO(#5075): Boxed-zoom should be fixed to accommodate the locked range.
let time_zone_for_timestamps = ctx.app_options.time_zone_for_timestamps;
let mut plot = Plot::new(plot_id_src)
.id(crate::plot_id(query.space_view_id))
.allow_zoom([true, zoom_both_axis])
.auto_bounds([true, auto_y].into())
.allow_zoom([true, !lock_y_during_zoom])
.x_axis_formatter(move |time, _, _| {
format_time(
time_type,
Expand Down Expand Up @@ -400,6 +442,36 @@ It can greatly improve performance (and readability) in such situations as it pr
time_ctrl_write.pause();
}

let range_was_edited = state.last_range != y_range;
state.last_range = y_range;
let is_resetting = plot_ui.response().double_clicked();
let current_auto = plot_ui.auto_bounds();

if let Some(y_range) = y_range {
// If we have a y_range, there are a few cases where we want to adjust the bounds.
// - The range was just edited
// - The zoom behavior is in LockToRange
// - The user double-clicked
if range_was_edited || lock_y_during_zoom || is_resetting {
let current_bounds = plot_ui.plot_bounds();
let mut min = current_bounds.min();
let mut max = current_bounds.max();

// Pad the range by 5% on each side.
min[1] = y_range.0[0];
max[1] = y_range.0[1];
let new_bounds = egui_plot::PlotBounds::from_min_max(min, max);
plot_ui.set_plot_bounds(new_bounds);
// If we are resetting, we still want the X value to be auto for
// this frame.
plot_ui.set_auto_bounds([current_auto[0] || is_resetting, false].into());
}
} else if lock_y_during_zoom || range_was_edited {
// If we are using auto range, but the range is locked, always
// force the y-bounds to be auto to prevent scrolling / zooming in y.
plot_ui.set_auto_bounds([current_auto[0] || is_resetting, true].into());
}

for series in all_plot_series {
let points = series
.points
Expand Down Expand Up @@ -444,6 +516,8 @@ It can greatly improve performance (and readability) in such situations as it pr
}

state.was_dragging_time_cursor = state.is_dragging_time_cursor;
let bounds = plot_ui.plot_bounds().range_y();
state.saved_y_axis_range = [*bounds.start(), *bounds.end()];
});

// Decide if the time cursor should be displayed, and if so where:
Expand Down Expand Up @@ -565,6 +639,101 @@ fn legend_ui(ctx: &ViewerContext<'_>, space_view_id: SpaceViewId, ui: &mut egui:
});
}

fn axis_ui(
ctx: &ViewerContext<'_>,
space_view_id: SpaceViewId,
ui: &mut egui::Ui,
state: &TimeSeriesSpaceViewState,
) {
// TODO(jleibs): use editors

let (
re_types::blueprint::archetypes::ScalarAxis {
range: y_range,
lock_range_during_zoom: y_lock_range_during_zoom,
},
blueprint_path,
) = query_space_view_sub_archetype(ctx, space_view_id);

ctx.re_ui.collapsing_header(ui, "Y Axis", true, |ui| {
ctx.re_ui
.selection_grid(ui, "time_series_selection_ui_y_axis_range")
.show(ui, |ui| {
ctx.re_ui.grid_left_hand_label(ui, "Range");

ui.vertical(|ui| {
let mut auto_range = y_range.is_none();

ui.horizontal(|ui| {
ctx.re_ui
.radio_value(ui, &mut auto_range, true, "Auto")
.on_hover_text("Automatically adjust the Y axis to fit the data.");
ctx.re_ui
.radio_value(ui, &mut auto_range, false, "Manual")
.on_hover_text("Manually specify a min and max Y value. This will define the range when resetting or locking the view range.");
});

if !auto_range {
let mut range_edit = y_range
.unwrap_or_else(|| y_range.unwrap_or(Range1D(state.saved_y_axis_range)));

ui.horizontal(|ui| {
// Max < Min is not supported.
// Also, egui_plot doesn't handle min==max (it ends up picking a default range instead then)
let prev_min = crate::util::next_up_f64(range_edit.0[0]);
let prev_max = range_edit.0[1];
// Scale the speed to the size of the range
let speed = ((prev_max - prev_min) * 0.01).at_least(0.001);
ui.label("Min");
ui.add(
egui::DragValue::new(&mut range_edit.0[0])
.speed(speed)
.clamp_range(std::f64::MIN..=prev_max),
);
ui.label("Max");
ui.add(
egui::DragValue::new(&mut range_edit.0[1])
.speed(speed)
.clamp_range(prev_min..=std::f64::MAX),
);
});

if y_range != Some(range_edit) {
ctx.save_blueprint_component(&blueprint_path, range_edit);
}
} else if y_range.is_some() {
ctx.save_empty_blueprint_component::<Range1D>(&blueprint_path);
}
});

ui.end_row();
});

ctx.re_ui
.selection_grid(ui, "time_series_selection_ui_y_axis_zoom")
.show(ui, |ui| {
ctx.re_ui.grid_left_hand_label(ui, "Zoom Behavior");

ui.vertical(|ui| {
ui.horizontal(|ui| {
let y_lock_zoom = y_lock_range_during_zoom.unwrap_or(false.into());
let mut edit_locked = y_lock_zoom;
ctx.re_ui
.checkbox(ui, &mut edit_locked.0, "Lock Range")
.on_hover_text(
"If set, when zooming, the Y axis range will remain locked to the specified range.",
);
if y_lock_zoom != edit_locked {
ctx.save_blueprint_component(&blueprint_path, edit_locked);
}
})
});

ui.end_row();
});
});
}

fn format_time(time_type: TimeType, time_int: i64, time_zone_for_timestamps: TimeZone) -> String {
if time_type == TimeType::Time {
let time = re_log_types::Time::from_ns_since_epoch(time_int);
Expand Down
50 changes: 50 additions & 0 deletions crates/re_space_view_time_series/src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,3 +264,53 @@ fn add_series_runs(
all_series.push(series);
}
}

/// Returns the least number greater than `self`.
///
/// Unstable feature in Rust. This is a copy of the implementation from the standard library.
///
/// Let `TINY` be the smallest representable positive `f64`. Then,
/// - if `self.is_nan()`, this returns `self`;
/// - if `self` is [`NEG_INFINITY`], this returns [`MIN`];
/// - if `self` is `-TINY`, this returns -0.0;
/// - if `self` is -0.0 or +0.0, this returns `TINY`;
/// - if `self` is [`MAX`] or [`INFINITY`], this returns [`INFINITY`];
/// - otherwise the unique least value greater than `self` is returned.
///
/// The identity `x.next_up() == -(-x).next_down()` holds for all non-NaN `x`. When `x`
/// is finite `x == x.next_up().next_down()` also holds.
///
/// ```rust
/// #![feature(float_next_up_down)]
/// // f64::EPSILON is the difference between 1.0 and the next number up.
/// assert_eq!(1.0f64.next_up(), 1.0 + f64::EPSILON);
/// // But not for most numbers.
/// assert!(0.1f64.next_up() < 0.1 + f64::EPSILON);
/// assert_eq!(9007199254740992f64.next_up(), 9007199254740994.0);
/// ```
///
/// [`NEG_INFINITY`]: f64::NEG_INFINITY
/// [`INFINITY`]: f64::INFINITY
/// [`MIN`]: f64::MIN
/// [`MAX`]: f64::MAX
pub fn next_up_f64(this: f64) -> f64 {
// We must use strictly integer arithmetic to prevent denormals from
// flushing to zero after an arithmetic operation on some platforms.
const TINY_BITS: u64 = 0x1; // Smallest positive f64.
const CLEAR_SIGN_MASK: u64 = 0x7fff_ffff_ffff_ffff;

let bits = this.to_bits();
if this.is_nan() || bits == f64::INFINITY.to_bits() {
return this;
}

let abs = bits & CLEAR_SIGN_MASK;
let next_bits = if abs == 0 {
TINY_BITS
} else if bits == abs {
bits + 1
} else {
bits - 1
};
f64::from_bits(next_bits)
}
2 changes: 2 additions & 0 deletions crates/re_types/definitions/rerun/blueprint.fbs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ include "./blueprint/components/space_view_origin.fbs";
include "./blueprint/components/viewport_layout.fbs";
include "./blueprint/components/visible.fbs";
include "./blueprint/components/corner_2d.fbs";
include "./blueprint/components/lock_range_during_zoom.fbs";

include "./blueprint/archetypes/container_blueprint.fbs";
include "./blueprint/archetypes/space_view_blueprint.fbs";
include "./blueprint/archetypes/viewport_blueprint.fbs";

include "./blueprint/archetypes/plot_legend.fbs";
include "./blueprint/archetypes/scalar_axis.fbs";
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
include "arrow/attributes.fbs";
include "python/attributes.fbs";
include "rust/attributes.fbs";

include "rerun/datatypes.fbs";
include "rerun/attributes.fbs";

namespace rerun.blueprint.archetypes;


// ---

/// Configuration for the scalar axis of a plot.
table ScalarAxis (
"attr.docs.unreleased",
"attr.rerun.scope": "blueprint",
"attr.rust.derive": "Default"
) {
// --- Optional ---

/// The range of the axis.
///
/// If unset, the range well be automatically determined based on the queried data.
range: rerun.components.Range1D ("attr.rerun.component_optional", nullable, order: 2100);

/// Whether to lock the range of the axis during zoom.
lock_range_during_zoom: rerun.blueprint.components.LockRangeDuringZoom ("attr.rerun.component_optional", nullable, order: 2200);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
include "arrow/attributes.fbs";
include "python/attributes.fbs";
include "rust/attributes.fbs";

include "rerun/datatypes.fbs";
include "rerun/attributes.fbs";

namespace rerun.blueprint.components;

// ---

/// Indicate whether the range should be locked when zooming in on the data.
///
/// Default is `false`, i.e. zoom will change the visualized range.
struct LockRangeDuringZoom (
"attr.docs.unreleased",
"attr.arrow.transparent",
"attr.rerun.scope": "blueprint",
"attr.rust.derive": "Copy, PartialEq, Eq",
"attr.rust.repr": "transparent",
"attr.rust.tuple_struct"
) {
lock_range: bool (order: 100);
}
1 change: 1 addition & 0 deletions crates/re_types/definitions/rerun/components.fbs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ include "./components/pinhole_projection.fbs";
include "./components/position2d.fbs";
include "./components/position3d.fbs";
include "./components/radius.fbs";
include "./components/range1d.fbs";
include "./components/resolution.fbs";
include "./components/rotation3d.fbs";
include "./components/scalar_scattering.fbs";
Expand Down
Loading

0 comments on commit 38a7d20

Please sign in to comment.