Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce a new blueprint archetype for AxisY configuration in a plot. #5028

Merged
merged 28 commits into from
Feb 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
3dd0fc2
Allow python to handle mixed-scope imports
jleibs Jan 26, 2024
ebd1ee7
Update the python codegen
jleibs Feb 2, 2024
6a0878c
Fix imports
jleibs Feb 2, 2024
b654baa
Introduce new components for controlling range and zoom behavior
jleibs Feb 2, 2024
740db2a
codegen
jleibs Feb 2, 2024
5ff5f5d
UI for setting the new AxisY controls
jleibs Feb 2, 2024
44ebffd
Account for the new controls
jleibs Feb 2, 2024
befafd7
Make Range1D a double
jleibs Feb 2, 2024
3906408
Use the current state to set the range, and also track when we edit t…
jleibs Feb 2, 2024
a0f4daf
Clarify that default behavior for unset Range is Auto
jleibs Feb 2, 2024
ba52f31
Make the auto behavior more intuitive since we are tracking edited ex…
jleibs Feb 2, 2024
8b8c182
Merge remote-tracking branch 'origin/main' into jleibs/axis_y
Wumpf Feb 4, 2024
768d351
Merge remote-tracking branch 'origin/main' into jleibs/axis_y
Wumpf Feb 5, 2024
a2c45fc
prevent invalid ranges in ui
Wumpf Feb 5, 2024
3984088
Also use y_range when using ASPECT_SCROLL_MODIFIER
jleibs Feb 5, 2024
61f16e0
Don't allow scroll when range is locked
jleibs Feb 5, 2024
195a10e
Rename components and make lock range a boolean
jleibs Feb 5, 2024
099cbbc
codegen
jleibs Feb 5, 2024
c61b3a0
Updates for changed components
jleibs Feb 5, 2024
f4a2cd0
Put Y Axis in a collapsing header
jleibs Feb 6, 2024
a7eb4c6
Still allow shift+scroll for horizontal scrolling
jleibs Feb 6, 2024
69b6205
Lints
jleibs Feb 6, 2024
83720c7
Merge remote-tracking branch 'origin/main' into jleibs/axis_y
Wumpf Feb 6, 2024
7fea571
fix doc errors
Wumpf Feb 6, 2024
7094159
Merge branch 'main' into jleibs/axis_y
jleibs Feb 6, 2024
fce4063
Allow scroll and re-enable box-zoom
jleibs Feb 7, 2024
4e2ca76
Link issue
jleibs Feb 7, 2024
4087df6
Remove comment about drag zoom not workign
jleibs Feb 7, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
}
Comment on lines +371 to +373
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't that mean the scroll delta is zero everywhere?
hm, doesn't seem to affect anything else. okay


// 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
Loading