diff --git a/egui_plot/src/axis.rs b/egui_plot/src/axis.rs index 61c52756..bcaf0ce8 100644 --- a/egui_plot/src/axis.rs +++ b/egui_plot/src/axis.rs @@ -151,9 +151,18 @@ impl<'a> AxisHints<'a> { fn default_formatter(mark: GridMark, _range: &RangeInclusive) -> String { // Example: If the step to the next tick is `0.01`, we should use 2 decimals of precision: - let num_decimals = -mark.step_size.log10().round() as usize; - - emath::format_with_decimals_in_range(mark.value, num_decimals..=num_decimals) + let mut num_decimals = (-mark.step_size.log10().round()) as isize; + #[allow(clippy::manual_clamp)] + if num_decimals < 0 { + num_decimals = 0; + } + if num_decimals > 10 { + num_decimals = 10; + } + emath::format_with_decimals_in_range( + mark.value, + num_decimals as usize..=num_decimals as usize, + ) } /// Specify axis label. @@ -306,22 +315,20 @@ impl<'a> AxisWidget<'a> { // Add tick labels: if axis == Axis::X { if let Some(bx) = transform.segment_xaxis() { - let text_color = ui.visuals().text_color(); - - let step_hint = estimate_step_hint_data_units(transform); - - let raw_ticks = compute_segmented_x_ticks(transform, bx, step_hint); + const DESIRED_PX: f32 = 80.0; + let raw_ticks = compute_segmented_x_ticks_per_segment(transform, bx, DESIRED_PX); const CLUSTER_PX_THRESHOLD: f32 = 6.0; let clusters = cluster_overlapping_ticks(raw_ticks, CLUSTER_PX_THRESHOLD); + let text_color = ui.visuals().text_color(); let mut last_drawn_center_x: Option = None; let mut thickness: f32 = 0.0; for cluster in clusters { if !cluster.has_edge { - if let Some(prev_cx) = last_drawn_center_x { - if (cluster.screen_x - prev_cx).abs() < self.hints.label_spacing.min { + if let Some(prev) = last_drawn_center_x { + if (cluster.screen_x - prev).abs() < self.hints.label_spacing.min { continue; } } @@ -346,7 +353,7 @@ impl<'a> AxisWidget<'a> { for (tick, side) in to_draw { let gm = GridMark { value: tick.world_x, - step_size: step_hint, + step_size: tick.step_size, }; let txt = (self.hints.formatter)(gm, &self.range); if txt.is_empty() { @@ -368,7 +375,6 @@ impl<'a> AxisWidget<'a> { }; let label_pos = Pos2::new(label_pos_x, y); - if label_pos.x + galley_size.x < self.rect.min.x { continue; } @@ -377,7 +383,6 @@ impl<'a> AxisWidget<'a> { } painter.add(TextShape::new(label_pos, galley, text_color)); - thickness = thickness.max(galley_size.y); } @@ -466,58 +471,15 @@ impl<'a> AxisWidget<'a> { thickness } } -fn estimate_step_hint_data_units(transform: &PlotTransform) -> f64 { - let desired_px_spacing: f32 = 80.0; - let units_per_px = transform.dvalue_dpos()[0] as f32; - (units_per_px.abs() * desired_px_spacing) as f64 -} #[derive(Clone, Copy, Debug)] struct ScreenTick { world_x: f64, screen_x: f32, + step_size: f64, is_segment_edge: bool, } -fn compute_segmented_x_ticks( - tf: &PlotTransform, - bx: &crate::SegmentedAxis, - step_hint: f64, -) -> Vec { - let per_seg_ticks = bx.segment_ticks(step_hint); - - let mut out = Vec::new(); - - for (seg_idx, ticks_for_seg) in per_seg_ticks.iter().enumerate() { - let seg = &bx.segments[seg_idx]; - - for &world_x in ticks_for_seg { - if !world_x.is_finite() { - continue; - } - - let screen_x = tf.position_from_point_x(world_x); - - if !screen_x.is_finite() { - continue; - } - - out.push(ScreenTick { - world_x, - screen_x, - is_segment_edge: (world_x == seg.start) || (world_x == seg.end), - }); - } - } - - out.sort_by(|a, b| { - a.screen_x - .partial_cmp(&b.screen_x) - .unwrap_or(std::cmp::Ordering::Equal) - }); - - out -} #[derive(Clone)] struct TickCluster { pub screen_x: f32, @@ -567,3 +529,64 @@ enum TickSide { Right, Center, } +fn compute_segmented_x_ticks_per_segment( + tf: &PlotTransform, + bx: &crate::SegmentedAxis, + desired_px: f32, +) -> Vec { + let mut out = Vec::new(); + + for seg in &bx.segments { + let sx0 = tf.position_from_point_x(seg.start); + let sx1 = tf.position_from_point_x(seg.end); + let seg_px = (sx1 - sx0).abs().max(1.0); + let seg_len = seg.end - seg.start; + + let n_steps = (seg_px / desired_px).ceil().max(1.0) as i32; + + if n_steps <= 1 { + let step_size = seg_len.abs().max(1e-6); + out.push(ScreenTick { + world_x: seg.start, + screen_x: sx0, + step_size, + is_segment_edge: true, + }); + out.push(ScreenTick { + world_x: seg.end, + screen_x: sx1, + step_size, + is_segment_edge: true, + }); + continue; + } + + let step = (seg_len / n_steps as f64).abs().max(1e-6); + + for i in 0..=n_steps { + let world_x = seg.start + (step * i as f64).copysign(seg_len); + let screen_x = tf.position_from_point_x(world_x); + if !screen_x.is_finite() { + continue; + } + + let is_edge = (world_x - seg.start).abs() < f64::EPSILON + || (world_x - seg.end).abs() < f64::EPSILON; + + out.push(ScreenTick { + world_x, + screen_x, + step_size: step, + is_segment_edge: is_edge, + }); + } + } + + out.sort_by(|a, b| { + a.screen_x + .partial_cmp(&b.screen_x) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + out +} diff --git a/egui_plot/src/items/mod.rs b/egui_plot/src/items/mod.rs index b5c4503a..a3c3ca4a 100644 --- a/egui_plot/src/items/mod.rs +++ b/egui_plot/src/items/mod.rs @@ -1068,11 +1068,73 @@ impl PlotItem for Line<'_> { let draw_stroke = final_stroke.width > 0.0 && final_stroke.color != egui::epaint::ColorMode::Solid(Color32::TRANSPARENT); + if !draw_stroke { + return; + } + + + let dx_per_px = transform.dvalue_dpos()[0].abs() as f32; + + let dx_per_px = if dx_per_px > 0.0 { dx_per_px } else { 1.0 }; + + + let max_jump_px = bx.gap_px; + + let mut scratch: Vec = Vec::new(); + let mut current: Vec = Vec::new(); + + + let mut last_screen: Option = None; + let mut last_x: Option = None; + + for idx in i0..=i1 { + // screen pos + let p = get_pos(idx); + + + let x_val = match src { + Src::Col { xs, .. } => xs[idx], + Src::Legacy { pts } => pts[idx].x, + Src::Empty => unreachable!(), + }; + + + let mut break_here = false; + if let (Some(prev_p), Some(prev_x)) = (last_screen, last_x) { + + let screen_jump = (p.x - prev_p.x).abs(); + + + let data_gap = (x_val - prev_x).abs() as f32; + let natural_px_gap = data_gap / dx_per_px; + + if screen_jump > max_jump_px * 0.9 || natural_px_gap > max_jump_px * 0.9 { + break_here = true; + } + } + + if break_here { + if current.len() > 1 { + style.style_line_iter( + current.iter().copied(), + final_stroke.clone(), + base.highlight, + shapes, + &mut scratch, + ); + } + current.clear(); + } + + current.push(p); + last_screen = Some(p); + last_x = Some(x_val); + } - if draw_stroke { - let mut scratch: Vec = Vec::new(); + + if current.len() > 1 { style.style_line_iter( - (i0..=i1).map(&get_pos), + current.into_iter(), final_stroke.clone(), base.highlight, shapes, diff --git a/egui_plot/src/lib.rs b/egui_plot/src/lib.rs index 93e8718f..2988e74d 100644 --- a/egui_plot/src/lib.rs +++ b/egui_plot/src/lib.rs @@ -951,9 +951,10 @@ impl<'a> Plot<'a> { last_click_pos_for_zoom: None, x_axis_thickness: Default::default(), y_axis_thickness: Default::default(), + segmented_x_offset: 0.0, }); - let last_plot_transform = mem.transform; + let last_plot_transform = mem.transform.clone(); // Call the plot build function. let mut plot_ui = PlotUi { ctx: ui.ctx().clone(), @@ -1112,10 +1113,11 @@ impl<'a> Plot<'a> { } // Build transform - mem.transform = PlotTransform::new(plot_rect, bounds, center_axis); - mem.transform.set_segment_xaxis(segmented_x_axis); + let mut new_tf = PlotTransform::new(plot_rect, bounds, center_axis); + new_tf.set_segment_xaxis(segmented_x_axis); + mem.set_transform(new_tf); // Aspect if let Some(data_aspect) = data_aspect { if let Some((_, linked_axes)) = &linked_axes { @@ -1167,6 +1169,9 @@ impl<'a> Plot<'a> { mem.transform .translate_bounds((delta.x as f64, delta.y as f64)); + if mem.transform.segment_xaxis().is_some() { + mem.segmented_x_offset = mem.transform.segment_x_offset(); + } mem.auto_bounds = mem.auto_bounds.and(!allow_drag); last_user_cause = Some(BoundsChangeCause::Pan); @@ -1497,6 +1502,12 @@ impl<'a> Plot<'a> { } let transform = mem.transform.clone(); + + // if segmented, remember the offset we ended up with this frame + if mem.transform.segment_xaxis().is_some() { + mem.segmented_x_offset = mem.transform.segment_x_offset(); + } + mem.store(ui.ctx(), plot_id); response = if show_x || show_y { diff --git a/egui_plot/src/memory.rs b/egui_plot/src/memory.rs index d292690d..c7a47f70 100644 --- a/egui_plot/src/memory.rs +++ b/egui_plot/src/memory.rs @@ -32,6 +32,8 @@ pub struct PlotMemory { /// in order to fit the labels, if necessary. pub(crate) x_axis_thickness: BTreeMap, pub(crate) y_axis_thickness: BTreeMap, + + pub(crate) segmented_x_offset: f64, } impl PlotMemory { @@ -39,12 +41,20 @@ impl PlotMemory { pub fn transform(&self) -> &PlotTransform { &self.transform } - #[inline] - pub fn set_transform(&mut self, t: PlotTransform) { + pub fn set_transform(&mut self, mut t: PlotTransform) { + if t.segment_xaxis().is_some() { + t.set_segment_x_offset(self.segmented_x_offset); + } self.transform = t; } + #[inline] + pub fn update_segmented_x_offset_from_transform(&mut self) { + if self.transform.segment_xaxis().is_some() { + self.segmented_x_offset = self.transform.segment_x_offset(); + } + } /// Plot-space bounds. #[inline] pub fn bounds(&self) -> &PlotBounds { diff --git a/egui_plot/src/transform.rs b/egui_plot/src/transform.rs index 3599a169..ac6ce3a4 100644 --- a/egui_plot/src/transform.rs +++ b/egui_plot/src/transform.rs @@ -280,6 +280,8 @@ pub struct PlotTransform { segmented_xaxis: Option, pixels_per_x: f32, + + segment_x_offset: f64, } impl PlotTransform { @@ -341,6 +343,7 @@ impl PlotTransform { let pixels_per_x = frame.width() / (new_bounds.width() as f32).max(f32::EPSILON); Self { + segment_x_offset: 0.0, frame, bounds: new_bounds, centered: center_axis, @@ -351,12 +354,7 @@ impl PlotTransform { /// Enable segmented-x layout on this transform. Call this after constructing with `new()`. pub fn with_segmented_xaxis(mut self, segmented: SegmentedAxis) -> Self { - if let Some(first) = segmented.segments.first() { - let seg_len = first.len().max(f64::EPSILON) as f32; - - self.pixels_per_x = self.frame.width() / seg_len; - } - self.segmented_xaxis = Some(segmented); + self.set_segment_xaxis(Some(segmented)); self } @@ -382,15 +380,37 @@ impl PlotTransform { } pub fn translate_bounds(&mut self, mut delta_pos: (f64, f64)) { + let [dx_per_px, dy_per_px] = self.dvalue_dpos(); + + // x if self.centered.x { - delta_pos.0 = 0.; + delta_pos.0 = 0.0; + } else if self.segmented_xaxis.is_some() { + let dx_world = delta_pos.0 * dx_per_px; + + if dx_world.is_finite() && dx_world != 0.0 { + self.segment_x_offset += dx_world; + self.bounds.translate_x(dx_world); + } + delta_pos.0 = 0.0; + } else { + let dx_world = delta_pos.0 * dx_per_px; + if dx_world.is_finite() && dx_world != 0.0 { + self.bounds.translate_x(dx_world); + } + delta_pos.0 = 0.0; } + + // y if self.centered.y { - delta_pos.1 = 0.; + delta_pos.1 = 0.0; + } else { + let dy_world = delta_pos.1 * dy_per_px; + if dy_world.is_finite() && dy_world != 0.0 { + self.bounds.translate_y(dy_world); + } + delta_pos.1 = 0.0; } - delta_pos.0 *= self.dvalue_dpos()[0]; - delta_pos.1 *= self.dvalue_dpos()[1]; - self.bounds.translate((delta_pos.0, delta_pos.1)); } /// Zoom by a relative factor with the given screen position as center. @@ -398,12 +418,18 @@ impl PlotTransform { let center = self.value_from_position(center); let mut new_bounds = self.bounds; - new_bounds.zoom(zoom_factor, center); + + if self.segmented_xaxis.is_some() { + // In segmented-x mode, only zoom Y. + new_bounds.min[1] = center.y + (new_bounds.min[1] - center.y) / (zoom_factor.y as f64); + new_bounds.max[1] = center.y + (new_bounds.max[1] - center.y) / (zoom_factor.y as f64); + } else { + new_bounds.zoom(zoom_factor, center); + } if new_bounds.is_valid() { self.bounds = new_bounds; - // keep pixels_per_x in sync ONLY if we are in normal mode if self.segmented_xaxis.is_none() { self.pixels_per_x = self.frame.width() / (self.bounds.width() as f32).max(f32::EPSILON); @@ -518,12 +544,14 @@ impl PlotTransform { /// /// This never contracts, so we don't miss out on any data. pub(crate) fn set_aspect_by_expanding(&mut self, aspect: f64) { - let current_aspect = self.aspect(); + if self.segmented_xaxis.is_some() { + // X is controlled by segments; don't touch it here. + return; + } + let current_aspect = self.aspect(); let epsilon = 1e-5; if (current_aspect - aspect).abs() < epsilon { - // Don't make any changes when the aspect is already almost correct. - return; } @@ -535,19 +563,17 @@ impl PlotTransform { .expand_y((current_aspect / aspect - 1.0) * self.bounds.height() * 0.5); } - if self.segmented_xaxis.is_none() { - self.pixels_per_x = self.frame.width() / (self.bounds.width() as f32).max(f32::EPSILON); - } + self.pixels_per_x = self.frame.width() / (self.bounds.width() as f32).max(f32::EPSILON); } - /// Sets the aspect ratio by changing either the X or Y axis (callers choice). pub(crate) fn set_aspect_by_changing_axis(&mut self, aspect: f64, axis: Axis) { - let current_aspect = self.aspect(); + if self.segmented_xaxis.is_some() { + return; + } + let current_aspect = self.aspect(); let epsilon = 1e-5; if (current_aspect - aspect).abs() < epsilon { - // Don't make any changes when the aspect is already almost correct. - return; } @@ -562,15 +588,14 @@ impl PlotTransform { } } - if self.segmented_xaxis.is_none() { - self.pixels_per_x = self.frame.width() / (self.bounds.width() as f32).max(f32::EPSILON); - } + self.pixels_per_x = self.frame.width() / (self.bounds.width() as f32).max(f32::EPSILON); } /// Map data.x -> screen.x under a segment/stitched x-axis definition. fn position_from_point_x_segment(&self, x: f64, bx: &SegmentedAxis) -> f32 { - let mut cursor_px = self.frame.left(); + let x = x - self.segment_x_offset; + let mut cursor_px = self.frame.left(); for (i, seg) in bx.segments.iter().enumerate() { let seg_len = seg.len(); let seg_px = (seg_len as f32) * self.pixels_per_x; @@ -585,14 +610,13 @@ impl PlotTransform { } cursor_px += seg_px; - if i + 1 < bx.segments.len() { cursor_px += bx.gap_px; } } - cursor_px } + #[inline] pub fn segment_xaxis(&self) -> Option<&SegmentedAxis> { self.segmented_xaxis.as_ref() @@ -628,7 +652,8 @@ impl PlotTransform { } else { 0.0 }; - return seg.start + (t as f64) * seg.len(); + // add back the pan offset in data space + return seg.start + (t as f64) * seg.len() + self.segment_x_offset; } cursor_px += seg_px; @@ -638,7 +663,7 @@ impl PlotTransform { let gap_end_px = cursor_px + bx.gap_px; if sx >= gap_start_px && sx <= gap_end_px { - return seg.end; + return seg.end + self.segment_x_offset; } cursor_px += bx.gap_px; @@ -646,7 +671,7 @@ impl PlotTransform { } if let Some(last) = bx.segments.last() { - last.end + last.end + self.segment_x_offset } else { remap( sx as f64, @@ -657,32 +682,35 @@ impl PlotTransform { } pub fn set_segment_xaxis(&mut self, segment: Option) { + let prev_offset = self.segment_x_offset; self.segmented_xaxis = segment; if let Some(bx) = &self.segmented_xaxis { - let n = bx.segments.len(); - - let total_len: f64 = bx - .segments - .iter() - .map(|seg| seg.len().max(f64::EPSILON)) // avoid 0 - .sum(); + // keep previous pan + self.segment_x_offset = prev_offset; - let total_gap_px: f32 = if n >= 2 { - bx.gap_px * ((n as u32).saturating_sub(1)) as f32 + let seg_count = bx.segments.len(); + let total_len: f64 = bx.segments.iter().map(|s| s.len().max(f64::EPSILON)).sum(); + let total_gap_px: f32 = if seg_count >= 2 { + bx.gap_px * ((seg_count as u32).saturating_sub(1)) as f32 } else { 0.0 }; - - let usable_px = (self.frame.width()) - total_gap_px; - - let usable_px = usable_px.max(1.0); - - let scale = usable_px / (total_len as f32).max(f32::EPSILON); - - self.pixels_per_x = scale; + let usable_px = (self.frame.width() - total_gap_px).max(1.0); + self.pixels_per_x = usable_px / (total_len as f32).max(f32::EPSILON); } else { + self.segment_x_offset = 0.0; self.pixels_per_x = self.frame.width() / (self.bounds.width() as f32).max(f32::EPSILON); } } + + #[inline] + pub fn segment_x_offset(&self) -> f64 { + self.segment_x_offset + } + + #[inline] + pub fn set_segment_x_offset(&mut self, offset: f64) { + self.segment_x_offset = offset; + } }