Skip to content

Release v2.9.0: chart UX fixes, grid toggle, MLG timestamp fix#68

Merged
SomethingNew71 merged 8 commits intomainfrom
dev
Apr 25, 2026
Merged

Release v2.9.0: chart UX fixes, grid toggle, MLG timestamp fix#68
SomethingNew71 merged 8 commits intomainfrom
dev

Conversation

@SomethingNew71
Copy link
Copy Markdown
Collaborator

Summary

Release v2.9.0 promotes the dev branch to main. Includes the chart UX overhaul from #67, an IPC fix, a clippy/test follow-up, and the version bump.

Changes

Bug fixes

  • MLG timestamp unit corrected (660050d) — Speeduino/rusEFI/MS MLG parser was treating the u16 tick as 1 ms instead of 10 µs per the EFI Analytics MLG Binary Log Format spec. Wall-clock duration was inflated ~100×; a 30-minute log displayed as ~48 hours. Adds three regression tests against bundled sample logs.
  • Chart jitter eliminated during playback (cb4141e) — replaced index-bucketed LTTB with min/max-per-bucket downsampling anchored to absolute time, so picked points are invariant to viewport offset.
  • Zoom now works in cursor-tracking mode (5cdf8a4) — pinch / cmd+wheel were silently ignored when cursor tracking was on.
  • Chart zoom decoupled from the Window slider (d0cbc1b) — split into view_window_seconds (slider-bound, persisted) and current_view_window (transient). Stops scroll inside the Settings panel from rewriting the saved tracking window.
  • IPC listener no longer busy-polls (a84caf7) — dropped the non-blocking accept + 100ms sleep in favor of blocking accept.

New feature

  • Grid toggle and opacity control (65cb2c0) — adds Show Grid to the View menu and a Grid Opacity slider in Settings → Display. Persists via additive serde defaults so existing config files keep loading. Opacity slider only writes on drag_stopped/lost_focus to avoid thrashing settings.json.

Review follow-up

  • Clippy + test coverage (03035d4) — flattened a collapsible_if violation in the grid-opacity persistence guard (edition 2021, no let_chains) and added 4 unit tests for the new LoadedFile::get_channel_column lazy column accessor.

Test plan

  • cargo fmt --all -- --check passes
  • cargo clippy -- -D warnings passes
  • cargo test — 893 tests pass (4 new in tests/core/state_tests.rs, 3 new MLG regression tests)
  • Smoke test: load a Speeduino MLG and confirm wall-clock duration is realistic
  • Smoke test: toggle grid via View menu and adjust opacity slider
  • Smoke test: zoom with cmd+wheel during cursor-tracked playback

irudoy and others added 8 commits April 25, 2026 18:45
Pinch and cmd+wheel zoom were silently no-ops while cursor_tracking was
on (the default). The cursor-tracking branch in the chart closure forces
bounds to a window of view_window_seconds centered on the cursor every
frame, overwriting any zoom egui_plot just applied.

Translate zoom_delta (and smooth_scroll_delta.y when scroll_to_zoom is
enabled) into changes of view_window_seconds itself, so the window
shrinks/grows around the cursor as the user expects.
Two MLG-related fixes bundled together:

1. Parser: MLG raw u16 timestamps are 10 µs/tick per the EFI Analytics
   MLG Binary Log Format spec, not 1 ms/tick. The previous formula
   compounded a 10× error in both the raw remainder and the wrap count
   for a net 100× over-estimate of wall-clock time (a ~30 min log
   showed as ~48 hours). Fixed via named constants
   MLG_TICK_SECONDS = 1e-5 and MLG_WRAP_TICKS = 65536, plus a
   regression test covering rusefi/speeduino sample logs.

2. UI: chart downsampling now slices raw data to the visible viewport
   before LTTB so detail scales with zoom. Previously LTTB compressed
   the full log to MAX_CHART_POINTS regardless of zoom, leaving fine
   detail invisible. Viewport bounds are remembered per plot area
   between frames; Y normalization uses the channel's global min/max
   so heights stay stable across pans.
LTTB partitions a slice into N equal buckets *by index* and picks the
"best" point per bucket. During cursor-tracked playback the slice
itself shifts a few samples each frame, so each bucket gets a slightly
different sample set and the chosen peak flickers — visibly so when
zoomed far out, where peaks are flattened to single buckets.

Replace LTTB in compute_viewport_points with min/max-per-bucket, where
bucket boundaries are anchored to absolute time (k × bucket_size from
t = 0) instead of the viewport. As the cursor advances, samples slide
through a fixed grid and each bucket's contents are invariant to the
viewport offset, so peaks stay put. Two points per bucket (min, max)
preserve the envelope; the bucket count is halved so total output
stays under MAX_CHART_POINTS.
Add a Show Grid checkbox in the View menu and a Show Grid /
Opacity slider in Settings → Display. Both knobs persist in
UserSettings so the choice survives across sessions.

Wire egui_plot's show_grid([x, y]) and grid_color() into both
Plot::new chains (single and multi-area). The opacity slider
overrides only the alpha of the base grid color, leaving the
distance-based fade and theme RGB intact.

Adds en/ru translations for menu.show_grid, settings.show_grid,
settings.show_grid_desc, settings.grid_opacity.
Pinch / cmd+wheel / scroll-to-zoom in cursor-tracking mode used to
write straight into view_window_seconds — the same field bound to
the Window slider in Settings. Scrolling the chart visibly dragged
the slider, and the zoomed value got persisted as the new default.

Split the field in two:

- `view_window_seconds` stays the user-set persistent default
  (Slider in Settings / Sidebar). The slider's write-path also
  resets the live render width.
- `current_view_window` is the live render width used by the
  chart in cursor-tracking mode. Zoom interactions update only
  this field, leaving the slider and persisted value alone.

Also gate the zoom helper on `ui.rect_contains_pointer(ui.max_rect())`
so wheel/pinch events that land over the side panel (Settings, etc.)
no longer reach the chart at all.
The IPC server set the listener to non-blocking and slept 100 ms between
WouldBlock errors. There is no shutdown signal feeding the loop, so the
non-blocking pattern only added up to ~100 ms of latency before each
accepted connection plus continuous busy-poll wakeups.

Switching to blocking accept removes the latency and the wakeups, and
restores deterministic ordering for the in-tree IPC integration tests
that previously flaked under cargo test parallelism (the listener could
be mid-sleep when a connection arrived, pushing command delivery past
the test's 2 s polling window).
Flatten the nested grid_opacity persistence guards in the settings panel
to satisfy clippy::collapsible_if (edition 2021, no let_chains). Cover
the new LoadedFile::get_channel_column lazy column-major accessor with
unit tests for hot, out-of-bounds, idempotent, and empty-log paths.
@SomethingNew71 SomethingNew71 merged commit 5534740 into main Apr 25, 2026
4 checks passed
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request updates UltraLog to version 2.9.0, adding a customizable chart grid and viewport-aware downsampling for better visual detail. It also fixes Speeduino MLG timestamp scaling and introduces lazy data transposition for performance. Feedback suggests moving viewport state to the tab level to prevent flickering, implementing cache invalidation for computed channels, and transposing data per-channel to further reduce memory overhead.

Comment thread src/app.rs
/// the visible viewport before LTTB-downsampling, so chart detail scales
/// with zoom level instead of being fixed at MAX_CHART_POINTS over the
/// full log range. Keyed by plot_area_id (0 in single-plot mode).
pub(crate) chart_last_x_bounds: HashMap<usize, (f64, f64)>,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Storing viewport bounds globally in the UltraLogApp state keyed by plot_area_id causes a 1-frame flicker when switching between tabs with different time ranges. The new tab will initially attempt to downsample its data using the previous tab's viewport bounds before the chart interaction logic can update them. Moving this state into the Tab struct would ensure each log maintains its own independent viewport memory and eliminate this artifact.

Comment thread src/app.rs
};

// Cache the result
self.minmax_cache.insert(cache_key, (min_val, max_val));
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The minmax_cache is now used for computed channels via get_channel_data_ref, but there is no mechanism to invalidate these entries when a computed channel's formula is edited or when channels are removed (which shifts indices). This can lead to incorrect vertical scaling on the chart. Consider clearing the cache for a specific file whenever its computed channel list is modified.

Comment thread src/state.rs
/// access so the chart hot path can borrow `&[f64]` for a channel instead
/// of re-collecting an owned `Vec<f64>` from the row-major store on every
/// frame.
channel_columns: OnceLock<Vec<Vec<f64>>>,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The current OnceLock<Vec<Vec<f64>>> type forces a full transposition of all log channels into column-major format as soon as any single channel is accessed. For logs with hundreds of channels, this causes a significant lag spike and doubles the memory usage for data that may never be viewed. Changing this to a Vec<OnceLock<Vec<f64>>> would allow for per-channel lazy initialization, significantly reducing the performance and memory impact.

    channel_columns: Vec<OnceLock<Vec<f64>>>,

Comment thread src/state.rs
log,
channels_with_data,
channel_columns: OnceLock::new(),
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Initialize the per-channel OnceLock vector based on the number of channels in the log.

            channel_columns: (0..log.channels.len()).map(|_| OnceLock::new()).collect(),

Comment thread src/state.rs
Comment on lines +127 to +134
pub fn get_channel_column(&self, channel_index: usize) -> Option<&[f64]> {
let cols = self.channel_columns.get_or_init(|| {
(0..self.log.channels.len())
.map(|i| self.log.get_channel_data(i))
.collect()
});
cols.get(channel_index).map(Vec::as_slice)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Update the accessor to lazily initialize only the requested channel column.

    pub fn get_channel_column(&self, channel_index: usize) -> Option<&[f64]> {
        Some(self.channel_columns
            .get(channel_index)?
            .get_or_init(|| self.log.get_channel_data(channel_index)))
    }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants