@@ -7,8 +7,8 @@ use rust_i18n::t;
77use crate :: app:: UltraLogApp ;
88use crate :: normalize:: normalize_channel_name_with_custom;
99use crate :: state:: {
10- CacheKey , PlotArea , SelectedChannel , CHART_COLORS , COLORBLIND_COLORS , MAX_CHART_POINTS ,
11- MIN_PLOT_HEIGHT , PLOT_RESIZE_HANDLE_HEIGHT ,
10+ PlotArea , SelectedChannel , CHART_COLORS , COLORBLIND_COLORS , MAX_CHART_POINTS , MIN_PLOT_HEIGHT ,
11+ PLOT_RESIZE_HANDLE_HEIGHT ,
1212} ;
1313
1414/// Sensitivity multiplier for scroll-to-zoom (higher = faster zoom per scroll tick).
@@ -76,32 +76,16 @@ impl UltraLogApp {
7676 return ;
7777 }
7878
79- // Pre-compute and cache downsampled + normalized data for all selected channels
80- for selected in & selected_channels {
81- if selected. file_index >= self . files . len ( ) {
82- continue ;
83- }
84-
85- let cache_key = CacheKey {
86- file_index : selected. file_index ,
87- channel_index : selected. channel_index ,
88- plot_area_id : 0 , // Single-plot mode uses plot_area_id 0
89- } ;
90-
91- if !self . downsample_cache . contains_key ( & cache_key) {
92- let file = & self . files [ selected. file_index ] ;
93- let times = file. log . get_times_as_f64 ( ) ;
94- // Use app method to get channel data (handles both regular and computed channels)
95- let data = self . get_channel_data ( selected. file_index , selected. channel_index ) ;
96-
97- if times. len ( ) == data. len ( ) && !times. is_empty ( ) {
98- let downsampled = Self :: downsample_lttb ( times, & data, MAX_CHART_POINTS ) ;
99- // Normalize Y values to 0-1 range so all channels overlay
100- let normalized = Self :: normalize_points ( & downsampled) ;
101- self . downsample_cache . insert ( cache_key, normalized) ;
102- }
103- }
104- }
79+ // Compute downsampled + normalized data sliced to the current viewport.
80+ // Detail scales with zoom level: a 1% viewport gets MAX_CHART_POINTS
81+ // over that 1%, not over the whole log.
82+ let viewport = self . chart_last_x_bounds . get ( & 0 ) . copied ( ) ;
83+ let chart_points: Vec < Option < Vec < [ f64 ; 2 ] > > > = selected_channels
84+ . iter ( )
85+ . map ( |selected| {
86+ self . compute_viewport_points ( selected. file_index , selected. channel_index , viewport)
87+ } )
88+ . collect ( ) ;
10589
10690 // Pre-compute legend names with current values at cursor position
10791 let use_normalization = self . field_normalization ;
@@ -139,7 +123,7 @@ impl UltraLogApp {
139123 . collect ( ) ;
140124
141125 // Prepare data for the plot closure (can't borrow self mutably inside)
142- let cache = & self . downsample_cache ;
126+ let chart_points = & chart_points ;
143127 let files = & self . files ;
144128 // selected_channels already defined at top of function from get_selected_channels()
145129 let cursor_time = self . get_cursor_time ( ) ;
@@ -275,13 +259,7 @@ impl UltraLogApp {
275259 continue ;
276260 }
277261
278- let cache_key = CacheKey {
279- file_index : selected. file_index ,
280- channel_index : selected. channel_index ,
281- plot_area_id : 0 , // Single-plot mode uses plot_area_id 0
282- } ;
283-
284- if let Some ( points) = cache. get ( & cache_key) {
262+ if let Some ( points) = chart_points. get ( i) . and_then ( |p| p. as_ref ( ) ) {
285263 let plot_points: PlotPoints = points. iter ( ) . copied ( ) . collect ( ) ;
286264 let palette = if color_blind_mode {
287265 COLORBLIND_COLORS
@@ -314,6 +292,12 @@ impl UltraLogApp {
314292 plot_ui. pointer_coordinate ( )
315293 } ) ;
316294
295+ // Remember the X-axis bounds we just rendered so the next frame can
296+ // slice raw data to this viewport before LTTB-downsampling.
297+ let final_bounds = response. transform . bounds ( ) ;
298+ self . chart_last_x_bounds
299+ . insert ( 0 , ( final_bounds. min ( ) [ 0 ] , final_bounds. max ( ) [ 0 ] ) ) ;
300+
317301 // Detect user interaction with chart (drag, zoom, scroll)
318302 // This marks the chart as "interacted" so we stop using the initial zoomed view
319303 if response. response . dragged ( )
@@ -506,30 +490,14 @@ impl UltraLogApp {
506490 plot_area_id : usize ,
507491 height : f32 ,
508492 ) {
509- // Pre-compute and cache data for these channels
510- for selected in channels {
511- if selected. file_index >= self . files . len ( ) {
512- continue ;
513- }
514-
515- let cache_key = CacheKey {
516- file_index : selected. file_index ,
517- channel_index : selected. channel_index ,
518- plot_area_id,
519- } ;
520-
521- if !self . downsample_cache . contains_key ( & cache_key) {
522- let file = & self . files [ selected. file_index ] ;
523- let times = file. log . get_times_as_f64 ( ) ;
524- let data = self . get_channel_data ( selected. file_index , selected. channel_index ) ;
525-
526- if times. len ( ) == data. len ( ) && !times. is_empty ( ) {
527- let downsampled = Self :: downsample_lttb ( times, & data, MAX_CHART_POINTS ) ;
528- let normalized = Self :: normalize_points ( & downsampled) ;
529- self . downsample_cache . insert ( cache_key, normalized) ;
530- }
531- }
532- }
493+ // Compute viewport-aware downsampled + normalized points for this plot area.
494+ let viewport = self . chart_last_x_bounds . get ( & plot_area_id) . copied ( ) ;
495+ let chart_points: Vec < Option < Vec < [ f64 ; 2 ] > > > = channels
496+ . iter ( )
497+ . map ( |selected| {
498+ self . compute_viewport_points ( selected. file_index , selected. channel_index , viewport)
499+ } )
500+ . collect ( ) ;
533501
534502 // Build legend names with values
535503 let use_normalization = self . field_normalization ;
@@ -567,7 +535,7 @@ impl UltraLogApp {
567535 . collect ( ) ;
568536
569537 // Prepare data for plot
570- let cache = & self . downsample_cache ;
538+ let chart_points = & chart_points ;
571539 let files = & self . files ;
572540 let cursor_time = self . get_cursor_time ( ) ;
573541 let cursor_tracking = self . cursor_tracking ;
@@ -652,13 +620,7 @@ impl UltraLogApp {
652620 continue ;
653621 }
654622
655- let cache_key = CacheKey {
656- file_index : selected. file_index ,
657- channel_index : selected. channel_index ,
658- plot_area_id,
659- } ;
660-
661- if let Some ( points) = cache. get ( & cache_key) {
623+ if let Some ( points) = chart_points. get ( i) . and_then ( |p| p. as_ref ( ) ) {
662624 let plot_points: PlotPoints = points. iter ( ) . copied ( ) . collect ( ) ;
663625 let palette = if color_blind_mode {
664626 COLORBLIND_COLORS
@@ -688,6 +650,12 @@ impl UltraLogApp {
688650 plot_ui. pointer_coordinate ( )
689651 } ) ;
690652
653+ // Save the bounds we just rendered so the next frame's downsample
654+ // matches the visible viewport.
655+ let final_bounds = response. transform . bounds ( ) ;
656+ self . chart_last_x_bounds
657+ . insert ( plot_area_id, ( final_bounds. min ( ) [ 0 ] , final_bounds. max ( ) [ 0 ] ) ) ;
658+
691659 // Detect interaction
692660 if response. response . dragged ( )
693661 || response. response . drag_started ( )
@@ -872,6 +840,61 @@ impl UltraLogApp {
872840 }
873841 }
874842
843+ /// Compute the points to plot for one channel, sliced to the currently
844+ /// visible viewport before LTTB-downsampling. Y is normalized to [0, 1]
845+ /// against the channel's full-range min/max so heights stay stable when
846+ /// the user pans or zooms. `viewport` is the previous frame's X bounds;
847+ /// when `None` (e.g., first frame after load) the full data range is used.
848+ fn compute_viewport_points (
849+ & mut self ,
850+ file_index : usize ,
851+ channel_index : usize ,
852+ viewport : Option < ( f64 , f64 ) > ,
853+ ) -> Option < Vec < [ f64 ; 2 ] > > {
854+ let file = self . files . get ( file_index) ?;
855+ let times = file. log . get_times_as_f64 ( ) ;
856+ let data = self . get_channel_data ( file_index, channel_index) ;
857+ if times. is_empty ( ) || times. len ( ) != data. len ( ) {
858+ return None ;
859+ }
860+
861+ let ( lo, hi) = match viewport {
862+ Some ( ( vmin, vmax) ) if vmax > vmin => {
863+ let pad = ( vmax - vmin) * 0.1 ;
864+ let lo_t = vmin - pad;
865+ let hi_t = vmax + pad;
866+ let lo_i = times. partition_point ( |& t| t < lo_t) . saturating_sub ( 1 ) ;
867+ let hi_i = times
868+ . partition_point ( |& t| t <= hi_t)
869+ . saturating_add ( 1 )
870+ . min ( times. len ( ) ) ;
871+ ( lo_i, hi_i. max ( lo_i + 1 ) )
872+ }
873+ _ => ( 0 , times. len ( ) ) ,
874+ } ;
875+
876+ let times_slice = & times[ lo..hi] ;
877+ let data_slice = & data[ lo..hi] ;
878+ let downsampled = Self :: downsample_lttb ( times_slice, data_slice, MAX_CHART_POINTS ) ;
879+
880+ let ( min_y, max_y) = self
881+ . get_channel_min_max ( file_index, channel_index)
882+ . unwrap_or ( ( 0.0 , 1.0 ) ) ;
883+ let range = ( max_y - min_y) . abs ( ) ;
884+ // Constant channels (range ≈ 0) get parked at the middle of the
885+ // overlay strip so they remain visible instead of pinning to the
886+ // bottom edge — matches the prior `normalize_points` behavior.
887+ if range < f64:: EPSILON {
888+ return Some ( downsampled. into_iter ( ) . map ( |p| [ p[ 0 ] , 0.5 ] ) . collect ( ) ) ;
889+ }
890+ Some (
891+ downsampled
892+ . into_iter ( )
893+ . map ( |p| [ p[ 0 ] , ( p[ 1 ] - min_y) / range] )
894+ . collect ( ) ,
895+ )
896+ }
897+
875898 /// Normalize values to 0-1 range for overlay display
876899 pub fn normalize_points ( points : & [ [ f64 ; 2 ] ] ) -> Vec < [ f64 ; 2 ] > {
877900 if points. is_empty ( ) {
0 commit comments