diff --git a/src/config/settings.rs b/src/config/settings.rs index ea69c82..f044554 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -46,6 +46,12 @@ pub struct GraphSettings { pub trail_brake_threshold: f32, #[serde(default)] pub phase_plot_open: bool, + #[serde(default = "default_true")] + pub show_track_strip: bool, + #[serde(default)] + pub lap_comparison_open: bool, + pub show_tc: bool, + pub show_speed: bool, } fn default_true() -> bool { @@ -71,6 +77,10 @@ pub struct ColorScheme { pub trail_brake: String, #[serde(default = "default_abs_cornering_color")] pub abs_cornering: String, + #[serde(default = "default_tc_active_color")] + pub tc_active: String, + #[serde(default = "default_speed_color")] + pub speed: String, } fn default_clutch_color() -> String { @@ -85,6 +95,14 @@ fn default_abs_cornering_color() -> String { "#FF44AA".to_string() } +fn default_tc_active_color() -> String { + "#FFCC00".to_string() +} + +fn default_speed_color() -> String { + "#E8C800".to_string() +} + /// Pre-parsed version of [`ColorScheme`] holding `Color32` values ready for rendering. /// /// Derive once per settings change via [`ParsedColors::from_scheme`] instead of @@ -100,6 +118,8 @@ pub struct ParsedColors { pub text: egui::Color32, pub trail_brake: egui::Color32, pub abs_cornering: egui::Color32, + pub tc_active: egui::Color32, + pub speed: egui::Color32, } impl ParsedColors { @@ -114,6 +134,8 @@ impl ParsedColors { text: AppSettings::parse_color(&scheme.text), trail_brake: AppSettings::parse_color(&scheme.trail_brake), abs_cornering: AppSettings::parse_color(&scheme.abs_cornering), + tc_active: AppSettings::parse_color(&scheme.tc_active), + speed: AppSettings::parse_color(&scheme.speed), } } } @@ -150,6 +172,10 @@ impl Default for AppSettings { show_abs_cornering: true, trail_brake_threshold: 5.0, phase_plot_open: false, + show_track_strip: true, + lap_comparison_open: false, + show_tc: true, + show_speed: true, }, colors: ColorScheme { throttle: "#00FF00".to_string(), @@ -161,6 +187,8 @@ impl Default for AppSettings { text: "#FFFFFF".to_string(), trail_brake: "#00BBFF".to_string(), abs_cornering: "#FF44AA".to_string(), + tc_active: "#FFCC00".to_string(), + speed: "#E8C800".to_string(), }, overlay: OverlaySettings { width: 600.0, @@ -267,6 +295,19 @@ mod tests { show_grid = true show_legend = true line_width = 2.0 + speed_mph = false + show_throttle = true + show_brake = true + show_abs = true + show_clutch = false + show_trail_brake = true + show_abs_cornering = true + trail_brake_threshold = 5.0 + phase_plot_open = true + show_track_strip = false + lap_comparison_open = false + show_tc = true + show_speed = false [colors] throttle = "#00FF00" @@ -275,6 +316,10 @@ mod tests { background = "#1A1A1A" grid = "#333333" text = "#FFFFFF" + trail_brake = "#00BBFF" + abs_cornering = "#FF44AA" + tc_active = "#FFCC00" + speed = "#E8C800" [overlay] width = 600.0 diff --git a/src/core/lap_store.rs b/src/core/lap_store.rs new file mode 100644 index 0000000..f904789 --- /dev/null +++ b/src/core/lap_store.rs @@ -0,0 +1,117 @@ +//! Lap boundary detection and per-lap telemetry storage. + +use crate::core::TelemetryPoint; +use std::time::Instant; + +/// A single telemetry sample stored within a lap, keyed by track position. +#[derive(Clone, Debug)] +#[allow(dead_code)] +pub struct LapPoint { + /// Track position: 0.0 = start/finish line, 1.0 = just before it. + pub track_position: f32, + pub throttle: f32, + pub brake: f32, + pub speed: f32, + pub abs_active: bool, + /// Milliseconds elapsed since the first point of this lap. + pub elapsed_ms: f32, +} + +/// Detects lap crossings and maintains a reference lap for comparison. +#[derive(Clone, Default)] +pub struct LapStore { + /// The most recently completed full lap, sorted by `track_position`. + pub reference_lap: Option>, + current_lap: Vec, + lap_start_at: Option, + last_track_pos: Option, + /// Deduplication: skip if this point was already processed. + last_captured_at: Option, +} + +impl LapStore { + pub fn new() -> Self { + Self::default() + } + + /// Push the latest telemetry point. + /// + /// When `track_position` crosses the start/finish line (wraps from near + /// 1.0 back to near 0.0), the completed lap is saved as the reference. + pub fn push(&mut self, pt: &TelemetryPoint) { + // `buffer.latest()` returns the same point every frame until a new one + // arrives, so we skip duplicates by comparing captured_at instants. + if self.last_captured_at == Some(pt.captured_at) { + return; + } + self.last_captured_at = Some(pt.captured_at); + + let pos = pt.telemetry.track_position; + + let crossed = self + .last_track_pos + .map(|last| last > 0.85 && pos < 0.15) + .unwrap_or(false); + + if crossed && self.current_lap.len() > 20 { + // Completed a valid lap — promote to reference. + let mut completed = std::mem::take(&mut self.current_lap); + // Sort by track_position so the comparison panel can interpolate. + completed.sort_by(|a, b| { + a.track_position + .partial_cmp(&b.track_position) + .unwrap_or(std::cmp::Ordering::Equal) + }); + self.reference_lap = Some(completed); + self.lap_start_at = None; + } + + // Begin timing the new lap on its first point. + if self.current_lap.is_empty() { + self.lap_start_at = Some(pt.captured_at); + } + + if let Some(start) = self.lap_start_at { + self.current_lap.push(LapPoint { + track_position: pos, + throttle: pt.telemetry.throttle, + brake: pt.telemetry.brake, + speed: pt.telemetry.speed, + abs_active: pt.abs_active, + elapsed_ms: pt.captured_at.duration_since(start).as_secs_f32() * 1000.0, + }); + } + + self.last_track_pos = Some(pos); + } + + /// The telemetry points for the lap currently in progress, in push order. + pub fn current_lap(&self) -> &[LapPoint] { + &self.current_lap + } + + /// Promote the current partial lap to the reference immediately. + pub fn set_current_as_reference(&mut self) { + if !self.current_lap.is_empty() { + let mut snap = self.current_lap.clone(); + snap.sort_by(|a, b| { + a.track_position + .partial_cmp(&b.track_position) + .unwrap_or(std::cmp::Ordering::Equal) + }); + self.reference_lap = Some(snap); + } + } + + pub fn clear_reference(&mut self) { + self.reference_lap = None; + } + + /// Reset all accumulated data (call after a plugin change). + pub fn clear(&mut self) { + self.current_lap.clear(); + self.lap_start_at = None; + self.last_track_pos = None; + self.last_captured_at = None; + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index 26b91b9..ada1ac2 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -2,8 +2,10 @@ pub mod buffer; pub mod collector; +pub mod lap_store; pub mod model; pub use buffer::TelemetryBuffer; pub use collector::DataCollector; +pub use lap_store::LapStore; pub use model::{TelemetryData, TelemetryPoint, VehicleTelemetry}; diff --git a/src/renderer/app.rs b/src/renderer/app.rs index 814bf1f..4304fa4 100644 --- a/src/renderer/app.rs +++ b/src/renderer/app.rs @@ -15,6 +15,9 @@ const POLL_RATE_HZ: u64 = 60; /// Minimum overlay dimensions — keeps the layout from collapsing. const MIN_WIDTH: f32 = 300.0; const MIN_HEIGHT: f32 = 130.0; +/// Track position strip height and gap below the main content. +const STRIP_H: f32 = 10.0; +const STRIP_GAP: f32 = 3.0; // ── Background poller ───────────────────────────────────────────────────────── @@ -56,6 +59,8 @@ pub struct SimTraceApp { save_toast: Option, /// Colors pre-parsed from `settings.colors`; re-derived when config changes them. parsed_colors: ParsedColors, + /// Lap boundary detection and per-lap telemetry for comparison. + lap_store: crate::core::LapStore, } impl SimTraceApp { @@ -87,6 +92,7 @@ impl SimTraceApp { active_plugin, save_toast: None, parsed_colors, + lap_store: crate::core::LapStore::new(), } } @@ -135,6 +141,7 @@ impl SimTraceApp { .map(|p| p.get_config().max_steering_angle) .unwrap_or(450.0); self.active_plugin = plugin; + self.lap_store.clear(); } } @@ -184,6 +191,7 @@ impl eframe::App for SimTraceApp { if self.running { if let Some(pt) = self.buffer.latest() { self.current_steering = pt.telemetry.steering_angle; + self.lap_store.push(&pt); } } // Clone the Arc so the closure below can take &mut self freely. @@ -192,6 +200,8 @@ impl eframe::App for SimTraceApp { } else { None }; + // Clone the reference lap so the closure can read it without borrowing self. + let reference_lap = self.lap_store.reference_lap.clone(); ctx.send_viewport_cmd(egui::ViewportCommand::MinInnerSize(egui::vec2( MIN_WIDTH, MIN_HEIGHT, @@ -439,6 +449,7 @@ impl eframe::App for SimTraceApp { self.max_steering_angle, a, cap_r, + reference_lap.as_deref(), ); } else { let font_size = (content_rect.height() * 0.28).clamp(14.0, 42.0); @@ -513,6 +524,7 @@ impl eframe::App for SimTraceApp { &mut self.running, &mut self.save_toast, buffer.as_ref(), + &mut self.lap_store, ); }); // Re-derive parsed colors in case the color pickers changed them. @@ -582,6 +594,67 @@ impl eframe::App for SimTraceApp { let _ = self.settings.save_to_config_path(); } } + + // ── Lap comparison viewport ─────────────────────────────────────────── + if self.settings.graph.lap_comparison_open { + let reference = self.lap_store.reference_lap.clone(); + let current = self.lap_store.current_lap().to_vec(); + let current_track_pos = self + .lap_store + .current_lap() + .last() + .map(|p| p.track_position) + .unwrap_or(0.0); + let colors = self.parsed_colors.clone(); + let opacity = self.settings.overlay.opacity; + + let close_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let close_arc = close_flag.clone(); + + ctx.show_viewport_immediate( + egui::ViewportId::from_hash_of("lap_comparison"), + egui::ViewportBuilder::default() + .with_title("Lap Comparison") + .with_inner_size([380.0, 260.0]) + .with_transparent(true) + .with_decorations(false) + .with_window_level(egui::WindowLevel::AlwaysOnTop), + |ctx, _class| { + egui::CentralPanel::default() + .frame(egui::Frame::NONE) + .show(ctx, |ui| { + let size = ui.available_size(); + let closed = crate::renderer::LapComparison::new( + reference.as_ref(), + ¤t, + current_track_pos, + &colors, + opacity, + ) + .show(ui, size); + if closed { + close_arc.store(true, std::sync::atomic::Ordering::Relaxed); + } + }); + let vp = ctx.viewport_rect(); + let close_center = egui::Pos2::new(vp.max.x - 14.0, vp.min.y + 12.0); + if ctx.input(|i| { + let pressed = i.pointer.button_pressed(egui::PointerButton::Primary); + let on_close = i.pointer.interact_pos().is_some_and(|p| { + (p.x - close_center.x).hypot(p.y - close_center.y) < 12.0 + }); + pressed && !on_close + }) { + ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + }, + ); + + if close_flag.load(std::sync::atomic::Ordering::Relaxed) { + self.settings.graph.lap_comparison_open = false; + let _ = self.settings.save_to_config_path(); + } + } } } @@ -597,6 +670,7 @@ fn draw_telemetry( max_steering_angle: f32, a: u8, cap_r: f32, + reference_lap: Option<&[crate::core::lap_store::LapPoint]>, ) { let opacity = settings.overlay.opacity; let available = ui.available_rect_before_wrap(); @@ -606,21 +680,33 @@ fn draw_telemetry( let brake = latest.as_ref().map(|p| p.telemetry.brake).unwrap_or(0.0); let clutch = latest.as_ref().map(|p| p.telemetry.clutch).unwrap_or(0.0); let abs_on = latest.as_ref().map(|p| p.abs_active).unwrap_or(false); + let tc_on = latest + .as_ref() + .map(|p| p.telemetry.tc_active) + .unwrap_or(false); let gear = latest.as_ref().map(|p| p.telemetry.gear).unwrap_or(0); let speed_ms = latest.as_ref().map(|p| p.telemetry.speed).unwrap_or(0.0); let bar_gap = 4.0_f32; let gap = 8.0_f32; + // Reserve space for track strip at bottom when enabled. + let strip_total = if settings.graph.show_track_strip { + STRIP_H + STRIP_GAP + } else { + 0.0 + }; + let content_h = available.height() - strip_total; + // Wheel column: height-derived but capped so it never crowds the graph let wheel_col_w = ((cap_r - 2.0) * 2.0).min(available.width() * 0.30); // Bar width scales with height so bars stay proportional when the widget is short - let bar_w = (available.height() * 0.28).clamp(12.0, 22.0); + let bar_w = (content_h * 0.28).clamp(12.0, 22.0); let bars_col_w = bar_w * 3.0 + bar_gap * 2.0; let graph_w = (available.width() - bars_col_w - wheel_col_w - gap * 2.0).max(40.0); - let graph_h = available.height(); + let graph_h = content_h; // No data arriving? Show overlay on graph area. let is_waiting = latest @@ -635,13 +721,11 @@ fn draw_telemetry( .show(ui, egui::vec2(graph_w, graph_h)); // Gap between graph and bars - ui.allocate_exact_size(egui::vec2(gap, available.height()), egui::Sense::hover()); + ui.allocate_exact_size(egui::vec2(gap, content_h), egui::Sense::hover()); // ── Pedal bars ─────────────────────────────────────────────────────── - let (bars_rect, _) = ui.allocate_exact_size( - egui::vec2(bars_col_w, available.height()), - egui::Sense::hover(), - ); + let (bars_rect, _) = + ui.allocate_exact_size(egui::vec2(bars_col_w, content_h), egui::Sense::hover()); let p = ui.painter(); let is_braking = brake > 0.01; @@ -653,10 +737,16 @@ fn draw_telemetry( _ => colors.brake, }; + let throttle_color = if tc_on && settings.graph.show_tc { + colors.tc_active + } else { + colors.throttle + }; + let specs: &[(f32, egui::Color32)] = &[ (clutch, colors.clutch), (brake, brake_color), - (throttle, colors.throttle), + (throttle, throttle_color), ]; let label_h = 16.0_f32; @@ -722,13 +812,11 @@ fn draw_telemetry( } // Gap between bars and steering wheel - ui.allocate_exact_size(egui::vec2(gap, available.height()), egui::Sense::hover()); + ui.allocate_exact_size(egui::vec2(gap, content_h), egui::Sense::hover()); // ── Steering wheel ────────────────────────────────────────────────── - let (wheel_rect, _) = ui.allocate_exact_size( - egui::vec2(wheel_col_w, available.height()), - egui::Sense::hover(), - ); + let (wheel_rect, _) = + ui.allocate_exact_size(egui::vec2(wheel_col_w, content_h), egui::Sense::hover()); // Center the wheel in the cap — vertically centred, horizontally at cap centre let center = wheel_rect.center(); // Fit inside the cap with margin for stroke (thickness ≈ radius * 0.28) @@ -796,6 +884,28 @@ fn draw_telemetry( ); }); + // ── Track position strip ───────────────────────────────────────────────── + if settings.graph.show_track_strip { + let current_track_pos = latest + .as_ref() + .map(|p| p.telemetry.track_position) + .unwrap_or(0.0); + let strip_rect = egui::Rect::from_min_size( + egui::pos2(available.min.x, available.max.y - STRIP_H), + egui::vec2(available.width(), STRIP_H), + ); + draw_track_strip( + ui.painter(), + strip_rect, + buffer, + current_track_pos, + &settings.graph, + colors, + a, + reference_lap, + ); + } + if is_waiting { ui.painter().text( graph_rect.center(), @@ -807,6 +917,151 @@ fn draw_telemetry( } } +// ── Track position strip ────────────────────────────────────────────────────── + +#[allow(clippy::too_many_arguments)] +fn draw_track_strip( + painter: &egui::Painter, + rect: egui::Rect, + buffer: Option<&Arc>, + current_pos: f32, + settings: &crate::config::GraphSettings, + colors: &ParsedColors, + a: u8, + reference_lap: Option<&[crate::core::lap_store::LapPoint]>, +) { + // Background + painter.rect_filled( + rect, + 2.0, + egui::Color32::from_rgba_unmultiplied(8, 8, 8, (a as f32 * 0.9) as u8), + ); + painter.rect_stroke( + rect, + 2.0, + egui::Stroke::new(0.5, with_alpha(BORDER, a)), + egui::StrokeKind::Middle, + ); + + // ── Reference lap ghost ───────────────────────────────────────────────── + // Bin reference points by pixel column and take the max brake/throttle + // per column, which avoids aliasing when many points map to the same pixel. + if let Some(ref_lap) = reference_lap { + let w = rect.width().max(1.0) as usize; + let mut brake_bins = vec![0.0_f32; w]; + let mut throttle_bins = vec![0.0_f32; w]; + + for pt in ref_lap { + let bin = ((pt.track_position.clamp(0.0, 1.0) * w as f32) as usize).min(w - 1); + brake_bins[bin] = brake_bins[bin].max(pt.brake); + throttle_bins[bin] = throttle_bins[bin].max(pt.throttle); + } + + let inner_h = rect.height() - 2.0; + let ghost_opacity = a as f32 * 0.38; // dim — reference sits behind live data + + let [br, bg, bb, _] = colors.brake.to_array(); + let [tr, tg, tb, _] = colors.throttle.to_array(); + + for col in 0..w { + let x = rect.min.x + col as f32 + 0.5; + + // Brake: from bottom up + if brake_bins[col] > 0.02 { + let h = (brake_bins[col] * inner_h).max(1.0); + let alpha = (ghost_opacity * brake_bins[col]).min(255.0) as u8; + painter.line_segment( + [ + egui::pos2(x, rect.max.y - 1.0), + egui::pos2(x, rect.max.y - 1.0 - h), + ], + egui::Stroke::new( + 1.0, + egui::Color32::from_rgba_unmultiplied(br, bg, bb, alpha), + ), + ); + } + + // Throttle: from top down + if throttle_bins[col] > 0.02 { + let h = (throttle_bins[col] * inner_h).max(1.0); + let alpha = (ghost_opacity * throttle_bins[col]).min(255.0) as u8; + painter.line_segment( + [ + egui::pos2(x, rect.min.y + 1.0), + egui::pos2(x, rect.min.y + 1.0 + h), + ], + egui::Stroke::new( + 1.0, + egui::Color32::from_rgba_unmultiplied(tr, tg, tb, alpha), + ), + ); + } + } + } + + // Live brake and throttle blips from recent history. + if let Some(buf) = buffer { + let points = buf.get_points(); + let now = std::time::Instant::now(); + let window_secs = settings.window_seconds as f32; + let window_dur = std::time::Duration::from_secs_f64(settings.window_seconds); + let inner_h = rect.height() - 2.0; + + for pt in points + .iter() + .filter(|p| now.duration_since(p.captured_at) <= window_dur) + { + let age = now.duration_since(pt.captured_at).as_secs_f32(); + let freshness = (1.0 - age / window_secs).clamp(0.0, 1.0); + let x = rect.min.x + pt.telemetry.track_position * rect.width(); + + // Brake: from bottom up + let brake_intensity = pt.telemetry.brake * freshness; + if brake_intensity > 0.02 { + let color = if pt.abs_active && settings.show_abs { + colors.abs_active + } else { + colors.brake + }; + let [r, g, b, ca] = color.to_array(); + let alpha = ((ca as f32) * (a as f32 / 255.0) * brake_intensity).min(255.0) as u8; + let h = (pt.telemetry.brake * inner_h).max(1.0); + painter.line_segment( + [ + egui::pos2(x, rect.max.y - 1.0), + egui::pos2(x, rect.max.y - 1.0 - h), + ], + egui::Stroke::new(1.5, egui::Color32::from_rgba_unmultiplied(r, g, b, alpha)), + ); + } + + // Throttle: from top down + let throttle_intensity = pt.telemetry.throttle * freshness; + if throttle_intensity > 0.02 { + let [r, g, b, ca] = colors.throttle.to_array(); + let alpha = + ((ca as f32) * (a as f32 / 255.0) * throttle_intensity).min(255.0) as u8; + let h = (pt.telemetry.throttle * inner_h).max(1.0); + painter.line_segment( + [ + egui::pos2(x, rect.min.y + 1.0), + egui::pos2(x, rect.min.y + 1.0 + h), + ], + egui::Stroke::new(1.5, egui::Color32::from_rgba_unmultiplied(r, g, b, alpha)), + ); + } + } + } + + // Current position cursor — bright white vertical line. + let cx = rect.min.x + current_pos.clamp(0.0, 1.0) * rect.width(); + painter.line_segment( + [egui::pos2(cx, rect.min.y), egui::pos2(cx, rect.max.y)], + egui::Stroke::new(2.0, with_alpha(egui::Color32::WHITE, a)), + ); +} + // ── Config panel ───────────────────────────────────────────────────────────── fn draw_config( @@ -815,6 +1070,7 @@ fn draw_config( running: &mut bool, save_toast: &mut Option, buffer: Option<&Arc>, + lap_store: &mut crate::core::LapStore, ) { // Ensure all widgets (sliders, dropdowns, colour pickers) use dark styling // regardless of the OS theme reported by the platform layer. @@ -894,6 +1150,7 @@ fn draw_config( // ── Display ────────────────────────────────────────────────────────────── section_header(ui, "DISPLAY"); ui.checkbox(&mut settings.graph.show_legend, "Show legend"); + ui.checkbox(&mut settings.graph.show_track_strip, "Show track strip"); ui.horizontal(|ui| { ui.label( egui::RichText::new("Speed unit") @@ -951,6 +1208,14 @@ fn draw_config( &mut settings.graph.show_throttle, &mut settings.colors.throttle, ); + ui.indent("tc_indent", |ui| { + trace_section( + ui, + "TC", + &mut settings.graph.show_tc, + &mut settings.colors.tc_active, + ); + }); trace_section( ui, "BRAKE", @@ -971,6 +1236,12 @@ fn draw_config( &mut settings.graph.show_clutch, &mut settings.colors.clutch, ); + trace_section( + ui, + "SPEED", + &mut settings.graph.show_speed, + &mut settings.colors.speed, + ); // ── Trail braking ───────────────────────────────────────────────────────── section_header(ui, "TRAIL BRAKING"); @@ -1006,6 +1277,31 @@ fn draw_config( settings.graph.phase_plot_open = !settings.graph.phase_plot_open; } + // ── Lap comparison ──────────────────────────────────────────────────────── + section_header(ui, "LAP COMPARISON"); + let cmp_label = if settings.graph.lap_comparison_open { + "Hide comparison" + } else { + "Show comparison" + }; + if ui.add(styled_button(cmp_label)).clicked() { + settings.graph.lap_comparison_open = !settings.graph.lap_comparison_open; + } + ui.add_space(4.0); + let ref_status = match &lap_store.reference_lap { + Some(pts) => format!("Ref: {} pts", pts.len()), + None => "No reference lap".to_string(), + }; + ui.label(egui::RichText::new(ref_status).size(10.0).color(LABEL_DIM)); + ui.horizontal(|ui| { + if ui.add(styled_button("Set Ref")).clicked() { + lap_store.set_current_as_reference(); + } + if ui.add(styled_button("Clear")).clicked() { + lap_store.clear_reference(); + } + }); + // ── Logs ───────────────────────────────────────────────────────────────── section_header(ui, "LOGS"); if ui.add(styled_button("Open log folder")).clicked() { diff --git a/src/renderer/lap_comparison.rs b/src/renderer/lap_comparison.rs new file mode 100644 index 0000000..8cb893a --- /dev/null +++ b/src/renderer/lap_comparison.rs @@ -0,0 +1,400 @@ +//! Lap comparison panel — overlays the current lap against a reference lap, +//! with a delta strip showing cumulative time gained/lost. + +use crate::config::ParsedColors; +use crate::core::lap_store::LapPoint; +use egui::{Color32, Painter, Pos2, Rect, Stroke, Vec2}; + +pub struct LapComparison<'a> { + /// The saved reference lap, sorted by track_position. + reference: Option<&'a Vec>, + /// The current lap in progress, in push order. + current: &'a [LapPoint], + /// Driver's current track position (0–1), used for the cursor. + current_track_pos: f32, + colors: &'a ParsedColors, + opacity: f32, +} + +impl<'a> LapComparison<'a> { + pub fn new( + reference: Option<&'a Vec>, + current: &'a [LapPoint], + current_track_pos: f32, + colors: &'a ParsedColors, + opacity: f32, + ) -> Self { + Self { + reference, + current, + current_track_pos, + colors, + opacity, + } + } + + /// Returns `true` if the close button was clicked. + pub fn show(&self, ui: &mut egui::Ui, size: Vec2) -> bool { + let (rect, _) = ui.allocate_exact_size(size, egui::Sense::empty()); + let painter = ui.painter().with_clip_rect(rect); + + // Background + border + painter.rect_filled(rect, 6.0, self.apply_opacity(self.colors.background)); + painter.rect_stroke( + rect, + 6.0, + Stroke::new(1.0, Color32::from_rgba_unmultiplied(60, 60, 60, 180)), + egui::StrokeKind::Inside, + ); + + // Title + painter.text( + Pos2::new(rect.min.x + 10.0, rect.min.y + 12.0), + egui::Align2::LEFT_CENTER, + "LAP COMPARISON", + egui::FontId::proportional(10.0), + Color32::from_rgba_unmultiplied(130, 130, 130, (180.0 * self.opacity) as u8), + ); + + // Layout: main plot on top, delta strip below. + let pad_top = 24.0; + let pad_side = 10.0; + let pad_bottom = 6.0; + let delta_h = 46.0; + let delta_gap = 4.0; + + let main_rect = Rect::from_min_max( + Pos2::new(rect.min.x + pad_side, rect.min.y + pad_top), + Pos2::new( + rect.max.x - pad_side, + rect.max.y - pad_bottom - delta_h - delta_gap, + ), + ); + let delta_rect = Rect::from_min_max( + Pos2::new(rect.min.x + pad_side, rect.max.y - pad_bottom - delta_h), + Pos2::new(rect.max.x - pad_side, rect.max.y - pad_bottom), + ); + + self.draw_main(&painter, main_rect); + self.draw_delta(&painter, delta_rect); + + // Current-position cursor through both plots. + let cursor_color = + Color32::from_rgba_unmultiplied(255, 255, 255, (110.0 * self.opacity) as u8); + let cursor = Stroke::new(1.0, cursor_color); + let cx = main_rect.min.x + self.current_track_pos.clamp(0.0, 1.0) * main_rect.width(); + painter.line_segment( + [ + Pos2::new(cx, main_rect.min.y), + Pos2::new(cx, main_rect.max.y), + ], + cursor, + ); + let dcx = delta_rect.min.x + self.current_track_pos.clamp(0.0, 1.0) * delta_rect.width(); + painter.line_segment( + [ + Pos2::new(dcx, delta_rect.min.y), + Pos2::new(dcx, delta_rect.max.y), + ], + cursor, + ); + + // Close button — same style as PhasePlot. + let close_center = Pos2::new(rect.max.x - 14.0, rect.min.y + 12.0); + let close_rect = Rect::from_center_size(close_center, Vec2::splat(20.0)); + let close_resp = ui.interact(close_rect, ui.id().with("close"), egui::Sense::click()); + let cross_alpha = if close_resp.hovered() { 230u8 } else { 100u8 }; + let cross = Stroke::new( + 1.5, + Color32::from_rgba_unmultiplied(210, 210, 210, cross_alpha), + ); + let arm = 4.5_f32; + let (cx, cy) = (close_center.x, close_center.y); + painter.line_segment( + [Pos2::new(cx - arm, cy - arm), Pos2::new(cx + arm, cy + arm)], + cross, + ); + painter.line_segment( + [Pos2::new(cx + arm, cy - arm), Pos2::new(cx - arm, cy + arm)], + cross, + ); + + close_resp.clicked() + } + + // ── Main plot ───────────────────────────────────────────────────────────── + + fn draw_main(&self, painter: &Painter, rect: Rect) { + // Grid + let grid = Stroke::new( + 0.5, + Color32::from_rgba_unmultiplied(50, 50, 50, (200.0 * self.opacity) as u8), + ); + for i in 1..4 { + let x = rect.min.x + rect.width() * i as f32 / 4.0; + painter.line_segment([Pos2::new(x, rect.min.y), Pos2::new(x, rect.max.y)], grid); + } + for i in 1..4 { + let y = rect.min.y + rect.height() * i as f32 / 4.0; + painter.line_segment([Pos2::new(rect.min.x, y), Pos2::new(rect.max.x, y)], grid); + } + + // Axes + let axis = Stroke::new(1.0, self.apply_opacity(self.colors.grid)); + painter.line_segment( + [ + Pos2::new(rect.min.x, rect.max.y), + Pos2::new(rect.max.x, rect.max.y), + ], + axis, + ); + painter.line_segment( + [ + Pos2::new(rect.min.x, rect.min.y), + Pos2::new(rect.min.x, rect.max.y), + ], + axis, + ); + + // X-axis tick labels + let dim = Color32::from_rgba_unmultiplied(75, 75, 75, (220.0 * self.opacity) as u8); + let font = egui::FontId::proportional(8.0); + for (frac, label) in [(0.0_f32, "S/F"), (0.25, "25%"), (0.5, "50%"), (0.75, "75%")] { + painter.text( + Pos2::new(rect.min.x + frac * rect.width(), rect.max.y + 3.0), + egui::Align2::CENTER_TOP, + label, + font.clone(), + dim, + ); + } + + // Y-axis labels + for (frac, label) in [(0.0_f32, "100%"), (0.5, "50%"), (1.0, "0")] { + painter.text( + Pos2::new(rect.min.x - 3.0, rect.min.y + frac * rect.height()), + egui::Align2::RIGHT_CENTER, + label, + font.clone(), + dim, + ); + } + + if self.reference.is_none() && self.current.is_empty() { + self.draw_no_data( + painter, + rect, + "Complete a lap to enable comparison\nor press Set Reference", + ); + return; + } + + // Reference traces (dimmed to ~28% — visible but clearly behind). + if let Some(ref_lap) = self.reference { + self.draw_trace(painter, rect, ref_lap, |p| p.brake, self.colors.brake, 0.28); + self.draw_trace( + painter, + rect, + ref_lap, + |p| p.throttle, + self.colors.throttle, + 0.28, + ); + } + + // Current lap traces (full brightness, drawn on top). + self.draw_trace( + painter, + rect, + self.current, + |p| p.brake, + self.colors.brake, + 1.0, + ); + self.draw_trace( + painter, + rect, + self.current, + |p| p.throttle, + self.colors.throttle, + 1.0, + ); + } + + fn draw_trace( + &self, + painter: &Painter, + rect: Rect, + lap: &[LapPoint], + value_fn: impl Fn(&LapPoint) -> f32, + color: Color32, + dim: f32, + ) { + if lap.len() < 2 { + return; + } + let [r, g, b, a] = color.to_array(); + let alpha = ((a as f32) * self.opacity * dim) as u8; + let stroke = Stroke::new(1.5, Color32::from_rgba_unmultiplied(r, g, b, alpha)); + let pts: Vec = lap + .iter() + .map(|p| { + let x = rect.min.x + p.track_position.clamp(0.0, 1.0) * rect.width(); + let y = rect.max.y - value_fn(p).clamp(0.0, 1.0) * rect.height(); + Pos2::new(x, y) + }) + .collect(); + painter.add(egui::Shape::line(pts, stroke)); + } + + // ── Delta strip ─────────────────────────────────────────────────────────── + + fn draw_delta(&self, painter: &Painter, rect: Rect) { + painter.rect_filled( + rect, + 2.0, + Color32::from_rgba_unmultiplied(8, 8, 8, (220.0 * self.opacity) as u8), + ); + painter.rect_stroke( + rect, + 2.0, + Stroke::new( + 0.5, + Color32::from_rgba_unmultiplied(50, 50, 50, (200.0 * self.opacity) as u8), + ), + egui::StrokeKind::Middle, + ); + + let dim = Color32::from_rgba_unmultiplied(70, 70, 70, (200.0 * self.opacity) as u8); + let font = egui::FontId::proportional(8.0); + + painter.text( + Pos2::new(rect.min.x + 3.0, rect.min.y + 4.0), + egui::Align2::LEFT_TOP, + "Δ", + font.clone(), + dim, + ); + + let (Some(reference), false) = (self.reference, self.current.is_empty()) else { + // No reference or no current data — show hint. + let hint = if self.reference.is_none() { + "Set a reference lap to see delta" + } else { + "" + }; + if !hint.is_empty() { + painter.text(rect.center(), egui::Align2::CENTER_CENTER, hint, font, dim); + } + return; + }; + + // Compute (track_pos, delta_seconds) for each current-lap point. + let deltas: Vec<(f32, f32)> = self + .current + .iter() + .filter_map(|pt| { + let ref_t = interp_elapsed(reference, pt.track_position)?; + Some((pt.track_position, (pt.elapsed_ms - ref_t) / 1000.0)) + }) + .collect(); + + if deltas.len() < 2 { + return; + } + + // Auto-scale, capped at ±10 s so one massive outlier doesn't flatten everything. + let max_abs = deltas + .iter() + .map(|(_, d)| d.abs()) + .fold(0.5_f32, f32::max) + .min(10.0); + + let y_mid = rect.center().y; + + // Zero line + painter.line_segment( + [Pos2::new(rect.min.x, y_mid), Pos2::new(rect.max.x, y_mid)], + Stroke::new( + 0.5, + Color32::from_rgba_unmultiplied(80, 80, 80, (200.0 * self.opacity) as u8), + ), + ); + + // Scale labels (top = slower, bottom = faster relative to ref) + painter.text( + Pos2::new(rect.max.x - 2.0, rect.min.y + 2.0), + egui::Align2::RIGHT_TOP, + format!("+{:.1}s", max_abs), + font.clone(), + dim, + ); + painter.text( + Pos2::new(rect.max.x - 2.0, rect.max.y - 2.0), + egui::Align2::RIGHT_BOTTOM, + format!("-{:.1}s", max_abs), + font, + dim, + ); + + // Delta curve — green when ahead (d < 0), red when behind (d > 0). + for i in 0..deltas.len() - 1 { + let (p0, d0) = deltas[i]; + let (p1, d1) = deltas[i + 1]; + let x0 = rect.min.x + p0 * rect.width(); + let x1 = rect.min.x + p1 * rect.width(); + let half_h = rect.height() / 2.0; + let y0 = (y_mid - d0 / max_abs * half_h).clamp(rect.min.y, rect.max.y); + let y1 = (y_mid - d1 / max_abs * half_h).clamp(rect.min.y, rect.max.y); + let color = if d0 <= 0.0 { + Color32::from_rgba_unmultiplied(55, 200, 80, (230.0 * self.opacity) as u8) + } else { + Color32::from_rgba_unmultiplied(220, 55, 55, (230.0 * self.opacity) as u8) + }; + painter.line_segment( + [Pos2::new(x0, y0), Pos2::new(x1, y1)], + Stroke::new(1.5, color), + ); + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + fn draw_no_data(&self, painter: &Painter, rect: Rect, msg: &str) { + painter.text( + rect.center(), + egui::Align2::CENTER_CENTER, + msg, + egui::FontId::proportional(10.0), + Color32::from_rgba_unmultiplied(70, 70, 70, (200.0 * self.opacity) as u8), + ); + } + + fn apply_opacity(&self, color: Color32) -> Color32 { + let [r, g, b, a] = color.to_array(); + Color32::from_rgba_unmultiplied(r, g, b, ((a as f32) * self.opacity) as u8) + } +} + +/// Linearly interpolate `elapsed_ms` at `track_pos` within a lap that is +/// sorted by `track_position`. +fn interp_elapsed(lap: &[LapPoint], track_pos: f32) -> Option { + if lap.is_empty() { + return None; + } + let i = lap.partition_point(|p| p.track_position < track_pos); + if i == 0 { + return Some(lap[0].elapsed_ms); + } + if i >= lap.len() { + return Some(lap[lap.len() - 1].elapsed_ms); + } + let p0 = &lap[i - 1]; + let p1 = &lap[i]; + let span = p1.track_position - p0.track_position; + if span < 1e-6 { + return Some(p0.elapsed_ms); + } + let t = (track_pos - p0.track_position) / span; + Some(p0.elapsed_ms + t * (p1.elapsed_ms - p0.elapsed_ms)) +} diff --git a/src/renderer/mod.rs b/src/renderer/mod.rs index 3f58b86..128168e 100644 --- a/src/renderer/mod.rs +++ b/src/renderer/mod.rs @@ -1,11 +1,13 @@ //! egui renderer for telemetry visualization pub mod app; +pub mod lap_comparison; pub mod phase_plot; pub mod steering_wheel; pub mod trace_graph; pub use app::SimTraceApp; +pub use lap_comparison::LapComparison; pub use phase_plot::PhasePlot; pub use steering_wheel::SteeringWheel; pub use trace_graph::TraceGraph; diff --git a/src/renderer/trace_graph.rs b/src/renderer/trace_graph.rs index 9fd33d7..a7f3429 100644 --- a/src/renderer/trace_graph.rs +++ b/src/renderer/trace_graph.rs @@ -17,6 +17,12 @@ enum BrakeState { AbsCornering, } +#[derive(Clone, Copy, PartialEq)] +enum ThrottleState { + Normal, + TcActive, +} + /// Renders a scrolling trace of pedal inputs against time. pub struct TraceGraph<'a> { buffer: Option<&'a TelemetryBuffer>, @@ -60,7 +66,43 @@ impl<'a> TraceGraph<'a> { .collect(); if !points.is_empty() { - // Draw order: clutch → throttle → brake/ABS (top, always visible) + // Draw order: speed → clutch → throttle → brake/ABS (top, always visible) + if self.settings.show_speed { + let max_speed = points + .iter() + .map(|p| p.telemetry.speed) + .fold(0.0_f32, f32::max); + if max_speed > 0.5 { + self.draw_trace( + &painter, + rect, + &points, + now, + window_dur, + |p| p.telemetry.speed / max_speed, + self.colors.speed, + ); + // Scale label: top-right, shows what the top of the axis equals + let speed_val = if self.settings.speed_mph { + max_speed * 2.237 + } else { + max_speed * 3.6 + }; + let unit = if self.settings.speed_mph { + "mph" + } else { + "kph" + }; + let label_color = self.apply_opacity(self.colors.speed); + painter.text( + Pos2::new(rect.max.x - 4.0, rect.min.y + 4.0), + egui::Align2::RIGHT_TOP, + format!("{:.0} {}", speed_val, unit), + egui::FontId::proportional(9.0), + label_color, + ); + } + } if self.settings.show_clutch { self.draw_trace( &painter, @@ -73,15 +115,7 @@ impl<'a> TraceGraph<'a> { ); } if self.settings.show_throttle { - self.draw_trace( - &painter, - rect, - &points, - now, - window_dur, - |p| p.telemetry.throttle, - self.colors.throttle, - ); + self.draw_throttle_trace(&painter, rect, &points, now, window_dur); } if self.settings.show_brake { self.draw_brake_trace(&painter, rect, &points, now, window_dur); @@ -140,6 +174,68 @@ impl<'a> TraceGraph<'a> { } } + /// Draw the throttle trace, colouring segments by TC state. + fn draw_throttle_trace( + &self, + painter: &egui::Painter, + rect: Rect, + points: &[TelemetryPoint], + now: std::time::Instant, + window_dur: std::time::Duration, + ) { + if points.len() < 2 { + return; + } + + let mut segments: Vec<(Vec, ThrottleState)> = Vec::new(); + let mut current_pts: Vec = Vec::new(); + let mut current_state: Option = None; + + for point in points { + let pos = Pos2::new( + self.x_position(rect, point, now, window_dur), + self.y_position(rect, point.telemetry.throttle), + ); + + let state = if point.telemetry.tc_active && self.settings.show_tc { + ThrottleState::TcActive + } else { + ThrottleState::Normal + }; + + if Some(state) != current_state { + if !current_pts.is_empty() { + current_pts.push(pos); + segments.push(( + std::mem::take(&mut current_pts), + current_state.unwrap_or(ThrottleState::Normal), + )); + } + current_pts.push(pos); + current_state = Some(state); + } else { + current_pts.push(pos); + } + } + if !current_pts.is_empty() { + segments.push((current_pts, current_state.unwrap_or(ThrottleState::Normal))); + } + + for (seg_pts, state) in segments { + if seg_pts.len() < 2 { + continue; + } + let color = match state { + ThrottleState::Normal => self.colors.throttle, + ThrottleState::TcActive => self.colors.tc_active, + }; + painter.add(egui::Shape::line( + seg_pts, + Stroke::new(self.settings.line_width, self.apply_opacity(color)), + )); + } + } + /// Draw the brake trace, colouring segments by trail braking / ABS state. fn draw_brake_trace( &self, @@ -248,8 +344,14 @@ impl<'a> TraceGraph<'a> { .linear_multiply(0.8); let mut entries: Vec<(&str, Color32)> = Vec::new(); + if self.settings.show_speed { + entries.push(("Speed", self.colors.speed)); + } if self.settings.show_throttle { entries.push(("Throttle", self.colors.throttle)); + if self.settings.show_tc { + entries.push(("TC", self.colors.tc_active)); + } } if self.settings.show_brake { entries.push(("Brake", self.colors.brake));