Skip to content

Commit

Permalink
Better time axis on plot view (#1356)
Browse files Browse the repository at this point in the history
* Pick number of decimals better when showing times

* Plot view: better handling of dates on X axis

* Increase default plot line thickness

* Add line to changelog

* Fix wrong time formatting
  • Loading branch information
emilk authored Feb 20, 2023
1 parent afd74ec commit dfd32bd
Show file tree
Hide file tree
Showing 6 changed files with 202 additions and 55 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Rerun changelog


## Unreleased

* 2023-02-19: Improve display of date-times in plots [#1356](https://github.com/rerun-io/rerun/pull/1356).


## Pre-release

A rough time-line of major user-facing things added, removed and changed. Newest on top.

* 2023-02-10: Add the ability to globally override logging-enabled with new env-var RERUN. [#1186](https://github.com/rerun-io/rerun/pull/1186).
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 22 additions & 4 deletions crates/re_log_types/src/time.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,19 +56,37 @@ impl Time {
);
match datetime {
chrono::LocalResult::Single(datetime) => {
let is_whole_second = nanos_since_epoch % 1_000_000_000 == 0;
let is_whole_millisecond = nanos_since_epoch % 1_000_000 == 0;

let time_format = if is_whole_second {
"%H:%M:%SZ"
} else if is_whole_millisecond {
"%H:%M:%S%.3fZ"
} else {
"%H:%M:%S%.6fZ"
};

if datetime.date_naive() == chrono::offset::Utc::now().date_naive() {
datetime.format("%H:%M:%S%.6fZ").to_string()
datetime.format(time_format).to_string()
} else {
datetime.format("%Y-%m-%d %H:%M:%S%.6fZ").to_string()
let date_format = format!("%Y-%m-%d {time_format}");
datetime.format(&date_format).to_string()
}
}
chrono::LocalResult::None => "Invalid timestamp".to_owned(),
chrono::LocalResult::Ambiguous(_, _) => "Ambiguous timestamp".to_owned(),
}
} else {
// Relative time
let secs = nanos_since_epoch as f64 * 1e-9;
// assume relative time
format!("{secs:+.03}s")

let is_whole_second = nanos_since_epoch % 1_000_000_000 == 0;
if is_whole_second {
format!("{secs:+.0}s")
} else {
format!("{secs:+.3}s")
}
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/re_viewer/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ re_analytics = { workspace = true, optional = true }
ahash = "0.8"
anyhow.workspace = true
bytemuck = { version = "1.11", features = ["extern_crate_alloc"] }
chrono = "0.4"
eframe = { workspace = true, default-features = false, features = [
"default_fonts",
"persistence",
Expand Down
8 changes: 5 additions & 3 deletions crates/re_viewer/src/ui/view_time_series/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,15 @@ impl SceneTimeSeries {
.color(color.map(|c| c.to_array()).as_ref(), default_color);
let label = annotation_info.label(label.map(|l| l.into()).as_ref());

const DEFAULT_RADIUS: f32 = 0.75;

points.push(PlotPoint {
time: time.unwrap().as_i64(), // scalars cannot be timeless
value: scalar.into(),
attrs: PlotPointAttrs {
label,
color,
radius: radius.map_or(1.0, |r| r.into()),
radius: radius.map_or(DEFAULT_RADIUS, |r| r.into()),
scattered: props.map_or(false, |props| props.scattered),
},
});
Expand Down Expand Up @@ -169,7 +171,7 @@ impl SceneTimeSeries {
let mut line: PlotSeries = PlotSeries {
label: line_label.to_owned(),
color: attrs.color,
width: attrs.radius,
width: 2.0 * attrs.radius,
kind: if attrs.scattered {
PlotSeriesKind::Scatter
} else {
Expand Down Expand Up @@ -199,7 +201,7 @@ impl SceneTimeSeries {
PlotSeries {
label: line_label.to_owned(),
color: attrs.color,
width: attrs.radius,
width: 2.0 * attrs.radius,
kind,
points: Vec::with_capacity(num_points - i),
},
Expand Down
213 changes: 165 additions & 48 deletions crates/re_viewer/src/ui/view_time_series/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ use egui::{
Color32,
};

use re_arrow_store::TimeType;

use crate::{ui::view_time_series::scene::PlotSeriesKind, ViewerContext};

use super::SceneTimeSeries;
Expand Down Expand Up @@ -42,70 +44,85 @@ pub(crate) fn view_time_series(
.unwrap_or(0);

// …then use that as an offset to avoid nasty precision issues with
// large times (nanos since epoch does not fit into a f64).
let time_offset = min_time;
// large times (nanos since epoch does not fit into an f64).
let time_offset = if timeline.typ() == TimeType::Time {
// In order to make the tick-marks on the time axis fall on whole days, hours, minutes etc,
// we need to round to a whole day:
round_ns_to_start_of_day(min_time)
} else {
min_time
};

// 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 egui::InnerResponse {
inner: time_x,
response,
} = Plot::new(plot_id_src)
let mut plot = Plot::new(plot_id_src)
.legend(Legend {
position: egui::plot::Corner::RightBottom,
..Default::default()
})
.x_axis_formatter(move |time, _| time_type.format((time as i64 + time_offset).into()))
.x_axis_formatter(move |time, _| format_time(time_type, time as i64 + time_offset))
.label_formatter(move |name, value| {
let name = if name.is_empty() { "y" } else { name };
let is_integer = value.y.round() == value.y;
let decimals = if is_integer { 0 } else { 5 };
format!(
"{timeline_name}: {}\n{name}: {:.5}",
"{timeline_name}: {}\n{name}: {:.*}",
time_type.format((value.x as i64 + time_offset).into()),
value.y
decimals,
value.y,
)
})
.show(ui, |plot_ui| {
if plot_ui.plot_secondary_clicked() {
let timeline = ctx.rec_cfg.time_ctrl.timeline();
ctx.rec_cfg.time_ctrl.set_timeline_and_time(
*timeline,
plot_ui.pointer_coordinate().unwrap().x as i64 + time_offset,
);
ctx.rec_cfg.time_ctrl.pause();
}
});

for line in &scene.lines {
let points = line
.points
.iter()
.map(|p| [(p.0 - time_offset) as _, p.1])
.collect::<Vec<_>>();

let c = line.color;
let color = Color32::from_rgba_premultiplied(c[0], c[1], c[2], c[3]);

match line.kind {
PlotSeriesKind::Continuous => plot_ui.line(
Line::new(points)
.name(&line.label)
.color(color)
.width(line.width),
),
PlotSeriesKind::Scatter => plot_ui.points(
Points::new(points)
.name(&line.label)
.color(color)
.radius(line.width),
),
}
if timeline.typ() == TimeType::Time {
let canvas_size = ui.available_size();
plot = plot.x_grid_spacer(move |spacer| ns_grid_spacer(canvas_size, &spacer));
}

let egui::InnerResponse {
inner: time_x,
response,
} = plot.show(ui, |plot_ui| {
if plot_ui.plot_secondary_clicked() {
let timeline = ctx.rec_cfg.time_ctrl.timeline();
ctx.rec_cfg.time_ctrl.set_timeline_and_time(
*timeline,
plot_ui.pointer_coordinate().unwrap().x as i64 + time_offset,
);
ctx.rec_cfg.time_ctrl.pause();
}

for line in &scene.lines {
let points = line
.points
.iter()
.map(|p| [(p.0 - time_offset) as _, p.1])
.collect::<Vec<_>>();

let c = line.color;
let color = Color32::from_rgba_premultiplied(c[0], c[1], c[2], c[3]);

match line.kind {
PlotSeriesKind::Continuous => plot_ui.line(
Line::new(points)
.name(&line.label)
.color(color)
.width(line.width),
),
PlotSeriesKind::Scatter => plot_ui.points(
Points::new(points)
.name(&line.label)
.color(color)
.radius(line.width),
),
}
}

current_time.map(|current_time| {
let time_x = (current_time - time_offset) as f64;
plot_ui.screen_from_plot([time_x, 0.0].into()).x
})
});
current_time.map(|current_time| {
let time_x = (current_time - time_offset) as f64;
plot_ui.screen_from_plot([time_x, 0.0].into()).x
})
});

if let Some(time_x) = time_x {
// TODO(emilk): allow interacting with the timeline (may require `egui::Plot` to return the `plot_from_screen` transform)
Expand All @@ -120,3 +137,103 @@ pub(crate) fn view_time_series(

response
}

fn format_time(time_type: TimeType, time_int: i64) -> String {
if time_type == TimeType::Time {
let ns_since_epoch = time_int;
let time = re_log_types::Time::from_ns_since_epoch(ns_since_epoch);
if time.is_abolute_date() {
use chrono::TimeZone as _;
if let chrono::LocalResult::Single(datetime) = chrono::Utc.timestamp_opt(
ns_since_epoch / 1_000_000_000,
(ns_since_epoch % 1_000_000_000) as _,
) {
let is_start_of_new_day =
datetime.time() == chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap();
if is_start_of_new_day {
// Show just the date:
return datetime.format("%Y-%m-%dZ").to_string();
} else {
// Show just the time:
let is_whole_minute = ns_since_epoch % 60_000_000_000 == 0;
let is_whole_second = ns_since_epoch % 1_000_000_000 == 0;
if is_whole_minute {
return datetime.time().format("%H:%MZ").to_string();
} else if is_whole_second {
return datetime.time().format("%H:%M:%SZ").to_string();
} else {
return datetime.time().format("%H:%M:%S%.3fZ").to_string();
}
}
}
}
}

time_type.format(re_log_types::TimeInt::from(time_int))
}

fn ns_grid_spacer(
canvas_size: egui::Vec2,
input: &egui::plot::GridInput,
) -> Vec<egui::plot::GridMark> {
fn next_time_step(spacing_ns: i64) -> i64 {
if spacing_ns <= 1_000_000_000 {
spacing_ns * 10 // up to 10 second ticks
} else if spacing_ns == 10_000_000_000 {
spacing_ns * 6 // to the whole minute
} else if spacing_ns == 60_000_000_000 {
spacing_ns * 10 // to ten minutes
} else if spacing_ns == 600_000_000_000 {
spacing_ns * 6 // to an hour
} else if spacing_ns == 60 * 60 * 1_000_000_000 {
spacing_ns * 12 // to 12 h
} else if spacing_ns == 12 * 60 * 60 * 1_000_000_000 {
spacing_ns * 2 // to a day
} else {
spacing_ns.checked_mul(10).unwrap_or(spacing_ns) // multiple of ten days
}
}

let minimum_medium_line_spacing = 150.0; // ≈min size of a label
let max_medium_lines = canvas_size.x as f64 / minimum_medium_line_spacing;

let (min_ns, max_ns) = input.bounds;
let width_ns = max_ns - min_ns;

let mut small_spacing_ns = 1;
while width_ns / (next_time_step(small_spacing_ns) as f64) > max_medium_lines {
small_spacing_ns = next_time_step(small_spacing_ns);
}
let medium_spacing_ns = next_time_step(small_spacing_ns);
let big_spacing_ns = next_time_step(medium_spacing_ns);

let mut current_ns = (min_ns.floor() as i64) / small_spacing_ns * small_spacing_ns;
let mut marks = vec![];

while current_ns <= max_ns.ceil() as i64 {
let is_big_line = current_ns % big_spacing_ns == 0;
let is_medium_line = current_ns % medium_spacing_ns == 0;

let step_size = if is_big_line {
big_spacing_ns
} else if is_medium_line {
medium_spacing_ns
} else {
small_spacing_ns
};

marks.push(egui::plot::GridMark {
value: current_ns as f64,
step_size: step_size as f64,
});

current_ns += small_spacing_ns;
}

marks
}

fn round_ns_to_start_of_day(ns: i64) -> i64 {
let ns_per_day = 24 * 60 * 60 * 1_000_000_000;
(ns + ns_per_day / 2) / ns_per_day * ns_per_day
}

1 comment on commit dfd32bd

@github-actions
Copy link

Choose a reason for hiding this comment

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

Rust Benchmark

Benchmark suite Current: dfd32bd Previous: afd74ec Ratio
datastore/insert/batch/rects/insert 566187 ns/iter (± 2483) 543855 ns/iter (± 5831) 1.04
datastore/latest_at/batch/rects/query 1802 ns/iter (± 21) 1800 ns/iter (± 7) 1.00
datastore/latest_at/missing_components/primary 356 ns/iter (± 0) 355 ns/iter (± 1) 1.00
datastore/latest_at/missing_components/secondaries 424 ns/iter (± 0) 420 ns/iter (± 2) 1.01
datastore/range/batch/rects/query 152921 ns/iter (± 276) 149177 ns/iter (± 779) 1.03
mono_points_arrow/generate_message_bundles 48262753 ns/iter (± 507605) 46337485 ns/iter (± 463167) 1.04
mono_points_arrow/generate_messages 128006155 ns/iter (± 1050498) 124442581 ns/iter (± 1114912) 1.03
mono_points_arrow/encode_log_msg 156994418 ns/iter (± 1193945) 150822654 ns/iter (± 974862) 1.04
mono_points_arrow/encode_total 334119999 ns/iter (± 1556054) 324804552 ns/iter (± 2445228) 1.03
mono_points_arrow/decode_log_msg 183055635 ns/iter (± 859424) 176947517 ns/iter (± 838515) 1.03
mono_points_arrow/decode_message_bundles 65237025 ns/iter (± 869071) 65099027 ns/iter (± 741626) 1.00
mono_points_arrow/decode_total 248680765 ns/iter (± 1595067) 237573783 ns/iter (± 2239220) 1.05
batch_points_arrow/generate_message_bundles 336904 ns/iter (± 563) 329864 ns/iter (± 2332) 1.02
batch_points_arrow/generate_messages 6454 ns/iter (± 25) 6292 ns/iter (± 29) 1.03
batch_points_arrow/encode_log_msg 358478 ns/iter (± 1753) 374339 ns/iter (± 775) 0.96
batch_points_arrow/encode_total 720168 ns/iter (± 2692) 729442 ns/iter (± 2011) 0.99
batch_points_arrow/decode_log_msg 349780 ns/iter (± 1245) 345266 ns/iter (± 989) 1.01
batch_points_arrow/decode_message_bundles 2103 ns/iter (± 8) 2058 ns/iter (± 12) 1.02
batch_points_arrow/decode_total 357686 ns/iter (± 1447) 351915 ns/iter (± 568) 1.02
arrow_mono_points/insert 6128341390 ns/iter (± 10735882) 6001053877 ns/iter (± 10507801) 1.02
arrow_mono_points/query 1750905 ns/iter (± 19124) 1723117 ns/iter (± 5184) 1.02
arrow_batch_points/insert 2747808 ns/iter (± 20329) 2624028 ns/iter (± 7661) 1.05
arrow_batch_points/query 16853 ns/iter (± 56) 16896 ns/iter (± 16) 1.00
tuid/Tuid::random 34 ns/iter (± 0) 34 ns/iter (± 0) 1

This comment was automatically generated by workflow using github-action-benchmark.

Please sign in to comment.