use egui::ahash::{HashMap, HashSet};
use egui_plot::{Legend, Line, Plot, PlotPoint, Points};
use re_chunk_store::TimeType;
use re_format::next_grid_tick_magnitude_ns;
use re_log_types::{EntityPath, TimeInt, TimeZone};
use re_space_view::controls::{
ASPECT_SCROLL_MODIFIER, HORIZONTAL_SCROLL_MODIFIER, MOVE_TIME_CURSOR_BUTTON,
SELECTION_RECT_ZOOM_BUTTON, ZOOM_SCROLL_MODIFIER,
};
use re_space_view::{controls, view_property_ui};
use re_types::blueprint::archetypes::{PlotLegend, ScalarAxis};
use re_types::blueprint::components::{Corner2D, LockRangeDuringZoom, Visible};
use re_types::components::AggregationPolicy;
use re_types::{components::Range1D, datatypes::TimeRange, SpaceViewClassIdentifier, View};
use re_ui::{list_item, ModifiersMarkdown, MouseButtonMarkdown, UiExt as _};
use re_viewer_context::{
ApplicableEntities, IdentifiedViewSystem, IndicatedEntities, PerVisualizer, QueryRange,
RecommendedSpaceView, SmallVisualizerSet, SpaceViewClass, SpaceViewClassRegistryError,
SpaceViewId, SpaceViewSpawnHeuristics, SpaceViewState, SpaceViewStateExt as _,
SpaceViewSystemExecutionError, SystemExecutionOutput, TypedComponentFallbackProvider,
ViewQuery, ViewSystemIdentifier, ViewerContext, VisualizableEntities,
};
use re_viewport_blueprint::ViewProperty;
use crate::line_visualizer_system::SeriesLineSystem;
use crate::point_visualizer_system::SeriesPointSystem;
use crate::PlotSeriesKind;
#[derive(Clone)]
pub struct TimeSeriesSpaceViewState {
is_dragging_time_cursor: bool,
was_dragging_time_cursor: bool,
saved_auto_bounds: egui::Vec2b,
scalar_range: Range1D,
pub(crate) time_offset: i64,
pub(crate) default_names_for_entities: HashMap<EntityPath, String>,
}
impl Default for TimeSeriesSpaceViewState {
fn default() -> Self {
Self {
is_dragging_time_cursor: false,
was_dragging_time_cursor: false,
saved_auto_bounds: Default::default(),
scalar_range: [0.0, 0.0].into(),
time_offset: 0,
default_names_for_entities: Default::default(),
}
}
}
impl SpaceViewState for TimeSeriesSpaceViewState {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
}
#[derive(Default)]
pub struct TimeSeriesSpaceView;
type ViewType = re_types::blueprint::views::TimeSeriesView;
impl SpaceViewClass for TimeSeriesSpaceView {
fn identifier() -> SpaceViewClassIdentifier {
ViewType::identifier()
}
fn display_name(&self) -> &'static str {
"Time series"
}
fn icon(&self) -> &'static re_ui::Icon {
&re_ui::icons::SPACE_VIEW_TIMESERIES
}
fn help_markdown(&self, egui_ctx: &egui::Context) -> String {
format!(
"# Time series view
Display time series data in a plot.
## Navigation controls
- Pan by dragging, or scroll (+{horizontal_scroll_modifier} for horizontal).
- Zoom with pinch gesture or scroll + {zoom_scroll_modifier}.
- Scroll + {aspect_scroll_modifier} to zoom only the temporal axis while holding the y-range fixed.
- Drag with the {selection_rect_zoom_button} to zoom in/out using a selection.
- Click the {move_time_cursor_button} to move the time cursor.
- Double-click to reset the view.",
horizontal_scroll_modifier = ModifiersMarkdown(HORIZONTAL_SCROLL_MODIFIER, egui_ctx),
zoom_scroll_modifier = ModifiersMarkdown(ZOOM_SCROLL_MODIFIER, egui_ctx),
aspect_scroll_modifier = ModifiersMarkdown(ASPECT_SCROLL_MODIFIER, egui_ctx),
selection_rect_zoom_button = MouseButtonMarkdown(SELECTION_RECT_ZOOM_BUTTON),
move_time_cursor_button = MouseButtonMarkdown(MOVE_TIME_CURSOR_BUTTON),
)
}
fn on_register(
&self,
system_registry: &mut re_viewer_context::SpaceViewSystemRegistrator<'_>,
) -> Result<(), SpaceViewClassRegistryError> {
system_registry.register_visualizer::<SeriesLineSystem>()?;
system_registry.register_visualizer::<SeriesPointSystem>()?;
Ok(())
}
fn new_state(&self) -> Box<dyn SpaceViewState> {
Box::<TimeSeriesSpaceViewState>::default()
}
fn preferred_tile_aspect_ratio(&self, _state: &dyn SpaceViewState) -> Option<f32> {
None
}
fn layout_priority(&self) -> re_viewer_context::SpaceViewClassLayoutPriority {
re_viewer_context::SpaceViewClassLayoutPriority::Low
}
fn default_query_range(&self, _view_state: &dyn SpaceViewState) -> QueryRange {
QueryRange::TimeRange(TimeRange::EVERYTHING)
}
fn selection_ui(
&self,
ctx: &ViewerContext<'_>,
ui: &mut egui::Ui,
state: &mut dyn SpaceViewState,
_space_origin: &EntityPath,
space_view_id: SpaceViewId,
) -> Result<(), SpaceViewSystemExecutionError> {
let state = state.downcast_mut::<TimeSeriesSpaceViewState>()?;
list_item::list_item_scope(ui, "time_series_selection_ui", |ui| {
view_property_ui::<PlotLegend>(ctx, ui, space_view_id, self, state);
view_property_ui::<ScalarAxis>(ctx, ui, space_view_id, self, state);
});
Ok(())
}
fn spawn_heuristics(&self, ctx: &ViewerContext<'_>) -> SpaceViewSpawnHeuristics {
re_tracing::profile_function!();
let mut indicated_entities = IndicatedEntities::default();
for indicated in [
SeriesLineSystem::identifier(),
SeriesPointSystem::identifier(),
]
.iter()
.filter_map(|&system_id| ctx.indicated_entities_per_visualizer.get(&system_id))
{
indicated_entities.0.extend(indicated.0.iter().cloned());
}
if let Some(applicable) = ctx
.applicable_entities_per_visualizer
.get(&SeriesLineSystem::identifier())
{
indicated_entities.0.extend(applicable.iter().cloned());
}
if indicated_entities.0.is_empty() {
return SpaceViewSpawnHeuristics::default();
}
let subtree_of_root_entity = &ctx.recording().tree().children;
if indicated_entities.contains(&EntityPath::root())
|| subtree_of_root_entity
.iter()
.any(|(_, subtree)| indicated_entities.contains(&subtree.path))
{
return SpaceViewSpawnHeuristics::root();
}
let mut child_of_root_entities = HashSet::default();
for entity in indicated_entities.iter() {
if let Some(child_of_root) = entity.iter().next() {
child_of_root_entities.insert(child_of_root);
}
}
SpaceViewSpawnHeuristics::new(child_of_root_entities.into_iter().map(|path_part| {
let entity = EntityPath::new(vec![path_part.clone()]);
RecommendedSpaceView::new_subtree(entity)
}))
}
fn choose_default_visualizers(
&self,
entity_path: &EntityPath,
_applicable_entities_per_visualizer: &PerVisualizer<ApplicableEntities>,
visualizable_entities_per_visualizer: &PerVisualizer<VisualizableEntities>,
indicated_entities_per_visualizer: &PerVisualizer<IndicatedEntities>,
) -> SmallVisualizerSet {
let available_visualizers: HashSet<&ViewSystemIdentifier> =
visualizable_entities_per_visualizer
.iter()
.filter_map(|(visualizer, ents)| {
if ents.contains(entity_path) {
Some(visualizer)
} else {
None
}
})
.collect();
let mut visualizers: SmallVisualizerSet = available_visualizers
.iter()
.filter_map(|visualizer| {
if indicated_entities_per_visualizer
.get(*visualizer)
.map_or(false, |matching_list| matching_list.contains(entity_path))
{
Some(**visualizer)
} else {
None
}
})
.collect();
if visualizers.is_empty() && available_visualizers.contains(&SeriesLineSystem::identifier())
{
visualizers.insert(0, SeriesLineSystem::identifier());
}
visualizers
}
fn ui(
&self,
ctx: &ViewerContext<'_>,
ui: &mut egui::Ui,
state: &mut dyn SpaceViewState,
query: &ViewQuery<'_>,
system_output: SystemExecutionOutput,
) -> Result<(), SpaceViewSystemExecutionError> {
re_tracing::profile_function!();
let state = state.downcast_mut::<TimeSeriesSpaceViewState>()?;
let blueprint_db = ctx.blueprint_db();
let view_id = query.space_view_id;
let plot_legend =
ViewProperty::from_archetype::<PlotLegend>(blueprint_db, ctx.blueprint_query, view_id);
let legend_visible = plot_legend.component_or_fallback::<Visible>(ctx, self, state)?;
let legend_corner = plot_legend.component_or_fallback::<Corner2D>(ctx, self, state)?;
let scalar_axis =
ViewProperty::from_archetype::<ScalarAxis>(blueprint_db, ctx.blueprint_query, view_id);
let y_range = scalar_axis.component_or_fallback::<Range1D>(ctx, self, state)?;
let y_range = make_range_sane(y_range);
let y_zoom_lock =
scalar_axis.component_or_fallback::<LockRangeDuringZoom>(ctx, self, state)?;
let y_zoom_lock = y_zoom_lock.0 .0;
let (current_time, time_type, timeline) = {
let time_ctrl = ctx.rec_cfg.time_ctrl.read();
let current_time = time_ctrl.time_i64();
let time_type = time_ctrl.time_type();
let timeline = *time_ctrl.timeline();
(current_time, time_type, timeline)
};
let timeline_name = timeline.name().to_string();
let line_series = system_output.view_systems.get::<SeriesLineSystem>()?;
let point_series = system_output.view_systems.get::<SeriesPointSystem>()?;
let all_plot_series: Vec<_> = std::iter::empty()
.chain(line_series.all_series.iter())
.chain(point_series.all_series.iter())
.collect();
let min_time = all_plot_series
.iter()
.map(|line| line.min_time)
.min()
.unwrap_or(0);
let aggregation_factor = all_plot_series
.first()
.map_or(1.0, |line| line.aggregation_factor);
let aggregator = all_plot_series
.first()
.map(|line| line.aggregator)
.unwrap_or_default();
let time_offset = if timeline.typ() == TimeType::Time {
round_ns_to_start_of_day(min_time)
} else {
min_time
};
state.time_offset = time_offset;
let plot_id_src = ("plot", &timeline_name);
let lock_y_during_zoom =
y_zoom_lock || ui.input(|i| i.modifiers.contains(controls::ASPECT_SCROLL_MODIFIER));
if lock_y_during_zoom {
ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0);
}
let time_zone_for_timestamps = ctx.app_options.time_zone;
let mut plot = Plot::new(plot_id_src)
.id(crate::plot_id(query.space_view_id))
.auto_bounds([true, false].into()) .allow_zoom([true, !lock_y_during_zoom])
.x_axis_formatter(move |time, _| {
format_time(
time_type,
(time.value as i64).saturating_add(time_offset),
time_zone_for_timestamps,
)
})
.y_axis_formatter(move |mark, _| format_y_axis(mark))
.label_formatter(move |name, value| {
let name = if name.is_empty() { "y" } else { name };
let label = time_type.format(
TimeInt::new_temporal((value.x as i64).saturating_add(time_offset)),
time_zone_for_timestamps,
);
let y_value = re_format::format_f64(value.y);
if aggregator == AggregationPolicy::Off || aggregation_factor <= 1.0 {
format!("{timeline_name}: {label}\n{name}: {y_value}")
} else {
format!(
"{timeline_name}: {label}\n{name}: {y_value}\n\
{aggregator} aggregation over approx. {aggregation_factor:.1} time points",
)
}
});
if *legend_visible.0 {
plot = plot.legend(Legend::default().position(legend_corner.into()));
}
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 mut plot_item_id_to_entity_path = HashMap::default();
let mut is_resetting = false;
let egui_plot::PlotResponse {
inner: _,
response,
transform,
hovered_plot_item,
} = plot.show(ui, |plot_ui| {
if plot_ui.response().secondary_clicked() {
let mut time_ctrl_write = ctx.rec_cfg.time_ctrl.write();
let timeline = *time_ctrl_write.timeline();
time_ctrl_write.set_timeline_and_time(
timeline,
plot_ui.pointer_coordinate().unwrap().x as i64 + time_offset,
);
time_ctrl_write.pause();
}
is_resetting = plot_ui.response().double_clicked();
let current_bounds = plot_ui.plot_bounds();
plot_ui.set_plot_bounds(egui_plot::PlotBounds::from_min_max(
[current_bounds.min()[0], y_range.start()],
[current_bounds.max()[0], y_range.end()],
));
let current_auto = plot_ui.auto_bounds();
plot_ui.set_auto_bounds(
[
current_auto[0] || is_resetting,
is_resetting && !y_zoom_lock,
]
.into(),
);
*state.scalar_range.start_mut() = f64::INFINITY;
*state.scalar_range.end_mut() = f64::NEG_INFINITY;
state.default_names_for_entities = EntityPath::short_names_with_disambiguation(
all_plot_series
.iter()
.map(|series| series.entity_path.clone()),
);
for series in all_plot_series {
let points = series
.points
.iter()
.map(|p| {
if p.1 < state.scalar_range.start() {
*state.scalar_range.start_mut() = p.1;
}
if p.1 > state.scalar_range.end() {
*state.scalar_range.end_mut() = p.1;
}
[(p.0 - time_offset) as _, p.1]
})
.collect::<Vec<_>>();
let color = series.color;
let id = egui::Id::new(series.entity_path.hash());
plot_item_id_to_entity_path.insert(id, series.entity_path.clone());
match series.kind {
PlotSeriesKind::Continuous => plot_ui.line(
Line::new(points)
.name(&series.label)
.color(color)
.width(2.0 * series.radius_ui)
.id(id),
),
PlotSeriesKind::Scatter(scatter_attrs) => plot_ui.points(
Points::new(points)
.name(&series.label)
.color(color)
.radius(series.radius_ui)
.shape(scatter_attrs.marker.into())
.id(id),
),
PlotSeriesKind::Clear => {}
}
}
if state.is_dragging_time_cursor {
if !state.was_dragging_time_cursor {
state.saved_auto_bounds = plot_ui.auto_bounds();
}
plot_ui.set_plot_bounds(plot_ui.plot_bounds());
} else if state.was_dragging_time_cursor {
plot_ui.set_auto_bounds(state.saved_auto_bounds);
}
state.was_dragging_time_cursor = state.is_dragging_time_cursor;
});
let new_y_range = Range1D::new(transform.bounds().min()[1], transform.bounds().max()[1]);
if is_resetting {
scalar_axis.reset_blueprint_component::<Range1D>(ctx);
} else if new_y_range != y_range {
scalar_axis.save_blueprint_component(ctx, &new_y_range);
}
let time_x = current_time
.map(|current_time| (current_time.saturating_sub(time_offset)) as f64)
.filter(|&x| {
transform.bounds().min()[0] <= x && x <= transform.bounds().max()[0]
})
.map(|x| transform.position_from_point(&PlotPoint::new(x, 0.0)).x);
if !is_resetting {
if let Some(hovered) = hovered_plot_item
.and_then(|hovered_plot_item| plot_item_id_to_entity_path.get(&hovered_plot_item))
.map(|entity_path| {
re_viewer_context::Item::DataResult(
query.space_view_id,
entity_path.clone().into(),
)
})
.or_else(|| {
if response.hovered() {
Some(re_viewer_context::Item::SpaceView(query.space_view_id))
} else {
None
}
})
{
ctx.select_hovered_on_click(&response, hovered);
}
}
if let Some(mut time_x) = time_x {
let interact_radius = ui.style().interaction.resize_grab_radius_side;
let line_rect = egui::Rect::from_x_y_ranges(time_x..=time_x, response.rect.y_range())
.expand(interact_radius);
let time_drag_id = ui.id().with("time_drag");
let response = ui
.interact(line_rect, time_drag_id, egui::Sense::drag())
.on_hover_and_drag_cursor(egui::CursorIcon::ResizeHorizontal);
state.is_dragging_time_cursor = false;
if response.dragged() {
if let Some(pointer_pos) = ui.input(|i| i.pointer.hover_pos()) {
let new_offset_time = transform.value_from_position(pointer_pos).x;
let new_time = time_offset + new_offset_time.round() as i64;
time_x = pointer_pos.x;
let mut time_ctrl = ctx.rec_cfg.time_ctrl.write();
time_ctrl.set_time(new_time);
time_ctrl.pause();
state.is_dragging_time_cursor = true;
}
}
ui.paint_time_cursor(ui.painter(), &response, time_x, response.rect.y_range());
}
Ok(())
}
}
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);
time.format_time_compact(time_zone_for_timestamps)
} else {
time_type.format(TimeInt::new_temporal(time_int), time_zone_for_timestamps)
}
}
fn format_y_axis(mark: egui_plot::GridMark) -> String {
let num_decimals = -mark.step_size.log10().round() as usize;
re_format::FloatFormatOptions::DEFAULT_f64
.with_decimals(num_decimals)
.format(mark.value)
}
fn ns_grid_spacer(
canvas_size: egui::Vec2,
input: &egui_plot::GridInput,
) -> Vec<egui_plot::GridMark> {
let minimum_medium_line_spacing = 150.0; 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_grid_tick_magnitude_ns(small_spacing_ns) as f64) > max_medium_lines {
let next_ns = next_grid_tick_magnitude_ns(small_spacing_ns);
if small_spacing_ns < next_ns {
small_spacing_ns = next_ns;
} else {
break; }
}
let medium_spacing_ns = next_grid_tick_magnitude_ns(small_spacing_ns);
let big_spacing_ns = next_grid_tick_magnitude_ns(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,
});
if let Some(new_ns) = current_ns.checked_add(small_spacing_ns) {
current_ns = new_ns;
} else {
break;
};
}
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
}
impl TypedComponentFallbackProvider<Corner2D> for TimeSeriesSpaceView {
fn fallback_for(&self, _ctx: &re_viewer_context::QueryContext<'_>) -> Corner2D {
Corner2D::RightBottom
}
}
impl TypedComponentFallbackProvider<Range1D> for TimeSeriesSpaceView {
fn fallback_for(&self, ctx: &re_viewer_context::QueryContext<'_>) -> Range1D {
ctx.view_state
.as_any()
.downcast_ref::<TimeSeriesSpaceViewState>()
.map(|s| make_range_sane(s.scalar_range))
.unwrap_or_default()
}
}
fn make_range_sane(y_range: Range1D) -> Range1D {
let (mut start, mut end) = (y_range.start(), y_range.end());
if !start.is_finite() {
start = -1.0;
}
if !end.is_finite() {
end = 1.0;
}
if end < start {
(start, end) = (end, start);
}
if end <= start {
let center = (start + end) / 2.0;
Range1D::new(center - 1.0, center + 1.0)
} else {
Range1D::new(start, end)
}
}
re_viewer_context::impl_component_fallback_provider!(TimeSeriesSpaceView => [Corner2D, Range1D]);