diff --git a/CLAUDE.md b/CLAUDE.md index ccf9dbf4..a4658b37 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,9 @@ src/ ├── units.rs # Unit preference types and conversions ├── normalize.rs # Field name normalization system ├── computed.rs # Computed channels data types and library +├── colormap.rs # Viridis/Turbo colormap LUTs for track coloring +├── laps.rs # GPS coordinate-format normalization, track sanitization, lap detection +├── tiles.rs # Map tile providers, bounded worker pool, disk/texture caches ├── expression/ │ ├── mod.rs # Formula parsing, channel refs, time shifts, evaluation │ └── engine.rs # Built-in expression compiler/evaluator (replaced meval) @@ -117,6 +120,10 @@ src/ ├── settings_panel.rs # Consolidated settings (display, units, normalization, updates) ├── tool_properties_panel.rs # Dynamic panel showing controls for the active tool (channels / histogram / scatter) ├── analysis_panel.rs # Window for running analysis algorithms (src/analysis) on the active log + ├── data_panel.rs # Right-side data panel hosting DataWidget panes (rail, header, hide/restore) + ├── widgets/ + │ ├── mod.rs # DataWidget trait + static widget registry + │ └── track_map.rs # GPS Track Map widget (polyline, laps, tile backgrounds) ├── sidebar.rs # Legacy files panel, superseded by files_panel (kept, not wired into render path) ├── channels.rs # Selected-channel cards; legacy channel-picker superseded by tool_properties_panel ├── chart.rs # Chart rendering, legends, LTTB algorithm @@ -377,7 +384,7 @@ because it also leads with a `Time` column, and only matches a first column of e ### Settings Persistence Contract -`UserSettings` (`src/settings.rs`) is a `Serialize`/`Deserialize` struct persisted as JSON at `{app_data_dir}/UltraLog/settings.json` (or `.../ultralog/settings.json` on Linux). It currently covers: `language`, `scroll_to_zoom`, `show_grid`, `grid_opacity`, `unit_preferences`, `font_scale`, `color_blind_mode`, `field_normalization`, `cursor_tracking`, `auto_check_updates`, and `custom_normalizations`. +`UserSettings` (`src/settings.rs`) is a `Serialize`/`Deserialize` struct persisted as JSON at `{app_data_dir}/UltraLog/settings.json` (or `.../ultralog/settings.json` on Linux). It currently covers: `language`, `scroll_to_zoom`, `show_grid`, `grid_opacity`, `unit_preferences`, `font_scale`, `color_blind_mode`, `field_normalization`, `cursor_tracking`, `auto_check_updates`, `custom_normalizations`, and the Track Map preferences (`tile_provider`, `tile_cache_max_mb`, `tiles_enabled`, `tile_opacity`, `tile_grayscale`, `tile_privacy_notice_seen`, `hidden_widgets`). - **Load** - `UltraLogApp::new` calls `UserSettings::load()` and copies each field into the corresponding live `UltraLogApp` field (e.g. `app.color_blind_mode = user_settings.color_blind_mode`). - **Save** - `UltraLogApp` implements `eframe::App::save`, which eframe calls on its auto-save interval (~30s) and again at shutdown. `save()` rebuilds a `UserSettings` from the current live fields, and only writes to disk if it differs from the last-loaded/saved value. @@ -393,11 +400,18 @@ Several `UltraLogApp` fields cache expensive per-file, per-channel computations **Load-bearing invariant:** these caches are keyed by index (file index / channel index), not by identity. Any code path that mutates or reindexes channel data — removing a file, or removing/editing a computed channel (which shifts later computed channels' indices down) — **must clear the relevant caches**, or stale entries will silently render the wrong data against a different channel's index. See `remove_computed_channel` and the file-removal path in `src/app.rs` for the current call sites (`self.downsample_cache.clear()`, `self.minmax_cache.clear()`, `self.scatter_histogram_cache.clear()`). +### Map Tile Fetching Contract (src/tiles.rs) + +The Track Map widget can draw map tile backgrounds. Tiles are **opt-in** (off by default) and only two hardcoded HTTPS providers exist: Esri World Imagery and OpenStreetMap. Requests carry integer tile coordinates plus the UltraLog User-Agent, nothing else. The first time a user enables tiles, a one-time toast (`tile_privacy_notice_seen`) notes that the track's approximate location is shared with the provider. + +**Load-bearing invariant:** network fetches are gated by per-provider permits (`FetchPermits` + `TileProvider::max_concurrent_fetches`). The OSM tile usage policy allows at most **2 simultaneous download connections** (), so `OpenStreetMap::max_concurrent_fetches` returns 2. Do not raise it and do not bypass the permit gate — exceeding the policy risks a per-IP block that would hit every UltraLog user at once. + ## Key Features - **Multi-ECU Support** - Haltech, ECUMaster, RomRaider, Speeduino, rusEFI, AiM, Link, Emerald, MegaSquirt, MHD Tuning, Motorsport Electronics, Woolich Racing Tuned, BlueDriver, DynamicEFI, and Locomotive log formats - **Computed Channels** - Create virtual channels from mathematical formulas with time-shifting (e.g., `RPM[-1]`, `Boost@-0.5s`) - **Analysis Algorithms** - AFR/Lambda drift and zone detection, derived metrics (VE, injector duty cycle), signal filters, and descriptive statistics (`src/analysis/`) +- **GPS Track Map** - Right-side data panel with a track map: lap detection, channel-colored polyline (Viridis/Turbo with editable range), hover-scrub/click-seek cursor sync, and opt-in Esri/OSM tile backgrounds (`src/ui/widgets/track_map.rs`, `src/tiles.rs`, `src/laps.rs`). GPS coordinate encodings are auto-detected and normalized to decimal degrees (`GpsCoordSpec` in `src/laps.rs`): NMEA `DDMM.mmmm`, milli/micro/1e-7-scaled integer degrees, and 0-360 longitude. Detection is conservative - values already in valid degree ranges are never transformed, and radians are deliberately not detected (ambiguous with genuine near-equator degree tracks). - **Claude Desktop / MCP Integration** - Embedded MCP server (`src/mcp/`) lets Claude control the running app over `http://localhost:52385/mcp` — select channels, add computed channels, query log data - **Unit Preferences** - Users can select display units for temperature, pressure, speed, distance, fuel economy, volume, flow rate, and acceleration - **Field Normalization** - Maps ECU-specific channel names to standardized names for cross-ECU comparison @@ -474,7 +488,7 @@ Example log files are in `exampleLogs/` organized by ECU type: - `exampleLogs/link/` - Link ECU LLG files - `exampleLogs/woolich/` - Woolich Racing Tuned CSV exports - `exampleLogs/emerald/` - Emerald K6/M3D `.lg1`/`.lg2` files -- `exampleLogs/megasquirt/` - MegaSquirt TunerStudio CSV exports +- `exampleLogs/megasquirt/` - MegaSquirt TunerStudio CSV exports, plus a `_gps.mlg` fixture with synthetic GPS channels (generated by `examples/inject_fake_gps_mlg.rs`; the coordinates are a fake closed loop, not a real location) - `exampleLogs/mhd/` - MHD Tuning CSV exports (VIN redacted) - `exampleLogs/motorsportElectronics/` - Motorsport Electronics ME Tuner CSV exports - `exampleLogs/bluedriver/` - BlueDriver OBD-II CSV exports diff --git a/Cargo.lock b/Cargo.lock index ab05866d..f5555244 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2032,6 +2032,8 @@ dependencies = [ "num-traits", "png", "tiff", + "zune-core", + "zune-jpeg", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1b663d10..7493a423 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,8 +63,8 @@ zip = "8.6" # ZIP extraction for Windows updates flate2 = "1.1" # Gzip decompression for Linux updates tar = "0.4" # Tar archive extraction for Linux updates -# Image loading (for app icon and PNG export) -image = { version = "0.25", default-features = false, features = ["png"] } +# Image loading (for app icon, PNG export, and map tiles – Esri serves JPEG) +image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } # PDF generation for chart export printpdf = "0.12" diff --git a/docs/FORMAT_SPECIFICATIONS.md b/docs/FORMAT_SPECIFICATIONS.md index 8490acf0..cffd69b2 100644 --- a/docs/FORMAT_SPECIFICATIONS.md +++ b/docs/FORMAT_SPECIFICATIONS.md @@ -231,9 +231,13 @@ The MLG format is used by Speeduino, rusEFI, and MegaSquirt ecosystems. It's a c | 5 | S32 | 4 | | 6 | S64 | 8 | | 7 | F32 | 4 | -| 10 | U08 Bitfield | 1 | -| 11 | U16 Bitfield | 2 | -| 12 | U32 Bitfield | 4 | +| `0x10` (or legacy `10`) | U08 Bitfield | 1 | +| `0x11` (or legacy `11`) | U16 Bitfield | 2 | +| `0x12` (or legacy `12`) | U32 Bitfield | 4 | + +The MLG specification writes the bitfield type IDs in hexadecimal (`0x10`..`0x12` = 16..18), +but some earlier tooling emitted the decimal interpretation (10..12). UltraLog's parser +accepts both, so logs from either generation of tools remain readable. ### Data Records diff --git a/exampleLogs/megasquirt/2026-04-12_12.49.36_gps.mlg b/exampleLogs/megasquirt/2026-04-12_12.49.36_gps.mlg new file mode 100644 index 00000000..ec339f19 Binary files /dev/null and b/exampleLogs/megasquirt/2026-04-12_12.49.36_gps.mlg differ diff --git a/examples/check_gps_in_log.rs b/examples/check_gps_in_log.rs new file mode 100644 index 00000000..9e9cc5ff --- /dev/null +++ b/examples/check_gps_in_log.rs @@ -0,0 +1,43 @@ +//! Diagnostic: parse an MLG file and replicate `detect_gps_channels` end-to-end. + +use std::env; +use std::fs; + +use ultralog::adapters::registry; +use ultralog::parsers::Speeduino; + +fn main() { + let path = env::args() + .nth(1) + .unwrap_or_else(|| "exampleLogs/megasquirt/2026-04-12_12.49.36_gps.mlg".to_string()); + let data = fs::read(&path).expect("read file"); + let log = Speeduino::parse_binary(&data).expect("parse"); + println!("path: {}", path); + println!("channels: {}", log.channels.len()); + + let mut lat_idx: Option = None; + let mut lon_idx: Option = None; + let mut gps_hits = Vec::new(); + for (i, ch) in log.channels.iter().enumerate() { + let name = ch.name(); + if name.to_lowercase().contains("gps") + || name.to_lowercase().contains("lat") + || name.to_lowercase().contains("lon") + { + let canon = registry::get_channel_metadata(&name) + .map(|m| m.canonical_id) + .unwrap_or_else(|| "".to_string()); + gps_hits.push((i, name.clone(), canon.clone())); + match canon.as_str() { + "gps_latitude" => lat_idx = Some(i), + "gps_longitude" => lon_idx = Some(i), + _ => {} + } + } + } + println!("GPS-ish channels found: {}", gps_hits.len()); + for (i, name, canon) in &gps_hits { + println!(" [{}] name={:?} canonical_id={}", i, name, canon); + } + println!("detect_gps_channels result: {:?}", (lat_idx, lon_idx)); +} diff --git a/examples/check_gps_lookup.rs b/examples/check_gps_lookup.rs new file mode 100644 index 00000000..d977e66a --- /dev/null +++ b/examples/check_gps_lookup.rs @@ -0,0 +1,28 @@ +//! Quick diagnostic: do "GPS Latitude" / "GPS Longitude" resolve to canonical +//! GPS IDs in the OECUA registry the way the Track Map widget expects? + +use ultralog::adapters::registry; + +fn main() { + let names = [ + "GPS Latitude", + "GPS Longitude", + "gps latitude", + "GPS Lat", + "Latitude", + "Lon", + ]; + println!("adapters loaded: {}", registry::get_adapters().len()); + for name in names { + match registry::get_channel_metadata(name) { + Some(meta) => println!( + " {:<14} -> canonical_id={:<14} display={:<14} vendor={}", + format!("\"{}\"", name), + meta.canonical_id, + meta.display_name, + meta.vendor + ), + None => println!(" \"{}\" -> NOT FOUND", name), + } + } +} diff --git a/examples/inject_fake_gps_mlg.rs b/examples/inject_fake_gps_mlg.rs new file mode 100644 index 00000000..031f543e --- /dev/null +++ b/examples/inject_fake_gps_mlg.rs @@ -0,0 +1,422 @@ +//! Injects fake GPS coordinates into a MegaLogViewer (.mlg) file. +//! +//! Reads an existing MLG (Speeduino/rusEFI/MegaSquirt) log, appends two new +//! S32 fields (`GPS Latitude`, `GPS Longitude`, scale 1e-7 deg) to the field +//! definitions, and synthesizes coordinates for every data record so the +//! resulting log traces a closed-loop track. The names match `source_names` +//! already declared in the AiM OECUA adapter spec, so UltraLog's Track Map +//! widget detects them via the global canonical-id lookup without any +//! parser-side change. +//! +//! Usage: +//! cargo run --example inject_fake_gps_mlg -- [output.mlg] + +use std::env; +use std::fs; +use std::process::ExitCode; + +const FIELD_LEN_V2: usize = 89; +const FIELD_LEN_V1: usize = 55; +const HEADER_LEN_V2: usize = 24; +const HEADER_LEN_V1: usize = 22; + +const FT_S32: u8 = 5; + +// Closed-loop trace approximating the Kartodrom "Zmeinka" kart circuit +// (Vladivostok). An outer egg-shaped loop with an inner switchback so the +// shape reads as a real track on the OSM tile, not a perfect oval. +const CENTER_LAT: f64 = 43.081965; +const CENTER_LON: f64 = 131.905281; +const LAP_PERIOD_S: f64 = 35.0; +const M_PER_DEG_LAT: f64 = 111_320.0; + +/// Lap waypoints in `(east_m, north_m)` relative to `CENTER_*`, walked in +/// order, looping back to the first point. Roughly traced by eye from the +/// satellite outline; each leg is linearly interpolated so a constant +/// `LAP_PERIOD_S` lap time yields constant ground speed regardless of leg +/// length. +const WAYPOINTS: &[(f64, f64)] = &[ + (-110.0, -55.0), // 0 start/finish (near Kartodrom "Zmeinka" marker) + (-95.0, -25.0), // 1 turn 1 entry, climbing east-north + (-75.0, 5.0), // 2 + (-50.0, 35.0), // 3 + (-15.0, 60.0), // 4 + (30.0, 75.0), // 5 top of outer loop + (85.0, 80.0), // 6 + (140.0, 70.0), // 7 + (180.0, 50.0), // 8 hairpin top-right + (195.0, 20.0), // 9 + (180.0, -5.0), // 10 + (140.0, -15.0), // 11 back inside, entering switchback + (95.0, -5.0), // 12 + (60.0, 20.0), // 13 inner loop top + (25.0, 35.0), // 14 + (-5.0, 25.0), // 15 + (5.0, 0.0), // 16 inner crossover dip + (40.0, -15.0), // 17 + (85.0, -30.0), // 18 + (130.0, -50.0), // 19 sweep along bottom + (115.0, -75.0), // 20 + (60.0, -85.0), // 21 + (0.0, -85.0), // 22 bottom straight + (-60.0, -80.0), // 23 + (-100.0, -70.0), // 24 return to start/finish +]; + +fn main() -> ExitCode { + let args: Vec = env::args().collect(); + if args.len() < 2 { + eprintln!( + "usage: {} [output.mlg]", + args.first() + .map(String::as_str) + .unwrap_or("inject_fake_gps_mlg") + ); + return ExitCode::from(2); + } + let in_path = &args[1]; + let out_path = args.get(2).cloned().unwrap_or_else(|| { + if let Some(stem) = in_path.strip_suffix(".mlg") { + format!("{}_gps.mlg", stem) + } else { + format!("{}.gps.mlg", in_path) + } + }); + + let data = match fs::read(in_path) { + Ok(d) => d, + Err(e) => { + eprintln!("failed to read {}: {}", in_path, e); + return ExitCode::FAILURE; + } + }; + + if data.len() < HEADER_LEN_V1 || &data[0..5] != b"MLVLG" { + eprintln!("not an MLVLG file: {}", in_path); + return ExitCode::FAILURE; + } + + let format_version = i16::from_be_bytes([data[6], data[7]]); + let is_v2 = format_version == 2; + let header_len = if is_v2 { HEADER_LEN_V2 } else { HEADER_LEN_V1 }; + let field_len = if is_v2 { FIELD_LEN_V2 } else { FIELD_LEN_V1 }; + if data.len() < header_len { + eprintln!("MLG header is truncated"); + return ExitCode::FAILURE; + } + + // Header offsets (matches the layout the speeduino parser walks). + let info_off = 6 + 2 + 4; + let data_begin_off = info_off + if is_v2 { 4 } else { 2 }; + let record_len_off = data_begin_off + 4; + let num_fields_off = record_len_off + 2; + + let info_data_start = if is_v2 { + u32::from_be_bytes([ + data[info_off], + data[info_off + 1], + data[info_off + 2], + data[info_off + 3], + ]) as usize + } else { + u16::from_be_bytes([data[info_off], data[info_off + 1]]) as usize + }; + let data_begin_index = u32::from_be_bytes([ + data[data_begin_off], + data[data_begin_off + 1], + data[data_begin_off + 2], + data[data_begin_off + 3], + ]) as usize; + let record_length = u16::from_be_bytes([data[record_len_off], data[record_len_off + 1]]); + let num_fields = u16::from_be_bytes([data[num_fields_off], data[num_fields_off + 1]]); + + let fields_end = header_len + (num_fields as usize) * field_len; + if fields_end > data.len() + || data_begin_index > data.len() + || data_begin_index < fields_end + || (info_data_start != 0 + && (info_data_start < fields_end || info_data_start > data_begin_index)) + { + eprintln!("header offsets out of bounds"); + return ExitCode::FAILURE; + } + + let Some(new_record_length) = (record_length as usize).checked_add(8) else { + eprintln!("record length overflow"); + return ExitCode::FAILURE; + }; + let Some(new_num_fields) = num_fields.checked_add(2) else { + eprintln!("field count overflow"); + return ExitCode::FAILURE; + }; + let inserted_header_bytes = 2 * field_len; + let Some(new_data_begin) = data_begin_index.checked_add(inserted_header_bytes) else { + eprintln!("data_begin overflow"); + return ExitCode::FAILURE; + }; + let new_info_data_start = if info_data_start == 0 { + 0 + } else { + let Some(value) = info_data_start.checked_add(inserted_header_bytes) else { + eprintln!("info_data_start overflow"); + return ExitCode::FAILURE; + }; + value + }; + if new_record_length > u16::MAX as usize + || new_data_begin > u32::MAX as usize + || new_info_data_start + > if is_v2 { + u32::MAX as usize + } else { + u16::MAX as usize + } + { + eprintln!("updated MLG header value exceeds its field width"); + return ExitCode::FAILURE; + } + + // ---- Build new file in memory ---- + let mut out = Vec::with_capacity(data.len() + 2 * field_len + 8 * 4096); + + // Header: copy verbatim, then patch the four changed fields below. + out.extend_from_slice(&data[..header_len]); + if is_v2 { + out[info_off..info_off + 4].copy_from_slice(&(new_info_data_start as u32).to_be_bytes()); + } else { + out[info_off..info_off + 2].copy_from_slice(&(new_info_data_start as u16).to_be_bytes()); + } + out[data_begin_off..data_begin_off + 4].copy_from_slice(&(new_data_begin as u32).to_be_bytes()); + out[record_len_off..record_len_off + 2] + .copy_from_slice(&(new_record_length as u16).to_be_bytes()); + out[num_fields_off..num_fields_off + 2].copy_from_slice(&new_num_fields.to_be_bytes()); + + // Existing fields, followed by two new GPS field descriptors. + out.extend_from_slice(&data[header_len..fields_end]); + out.extend_from_slice(&build_field_descriptor( + is_v2, + "GPS Latitude", + "deg", + 1e-7, + "GPS", + )); + out.extend_from_slice(&build_field_descriptor( + is_v2, + "GPS Longitude", + "deg", + 1e-7, + "GPS", + )); + + // Info section verbatim (between fields_end and data_begin_index). + out.extend_from_slice(&data[fields_end..data_begin_index]); + + // ---- Walk data section, rewriting each record ---- + let mut off = data_begin_index; + let mut record_index: usize = 0; + let mut prev_raw_ts: u16 = 0; + let mut wrap_count: u64 = 0; + let mut data_record_count: usize = 0; + let mut marker_count: usize = 0; + + while off + 4 <= data.len() { + let block_type = data[off]; + let counter = data[off + 1]; + let raw_ts = u16::from_be_bytes([data[off + 2], data[off + 3]]); + + match block_type { + 0 => { + let payload_end = off + 4 + record_length as usize; + if payload_end + 1 > data.len() { + eprintln!( + "truncated data record at offset {} (need {}, have {})", + off, + record_length as usize + 5, + data.len() - off + ); + return ExitCode::FAILURE; + } + + if raw_ts < prev_raw_ts && (prev_raw_ts - raw_ts) > 30_000 { + wrap_count += 1; + } + prev_raw_ts = raw_ts; + let timestamp_s = (raw_ts as f64 + wrap_count as f64 * 65_536.0) * 1e-5; + + let (lat_raw, lon_raw) = synth_lat_lon(timestamp_s, record_index); + + // Rewrite block: same 4-byte block header, original field + // data, then 8 bytes of GPS, then a CRC over the field data + // (the MLG record CRC is a wrapping sum of the data bytes). + out.push(block_type); + out.push(counter); + out.extend_from_slice(&raw_ts.to_be_bytes()); + let original_payload = &data[off + 4..payload_end]; + out.extend_from_slice(original_payload); + out.extend_from_slice(&lat_raw.to_be_bytes()); + out.extend_from_slice(&lon_raw.to_be_bytes()); + + let mut crc: u8 = 0; + for b in original_payload { + crc = crc.wrapping_add(*b); + } + for b in lat_raw.to_be_bytes() { + crc = crc.wrapping_add(b); + } + for b in lon_raw.to_be_bytes() { + crc = crc.wrapping_add(b); + } + out.push(crc); + + off = payload_end + 1; // include CRC + record_index += 1; + data_record_count += 1; + } + 1 => { + let marker_end = off + 4 + 50; + if marker_end > data.len() { + eprintln!("truncated marker block at offset {}", off); + return ExitCode::FAILURE; + } + out.extend_from_slice(&data[off..marker_end]); + off = marker_end; + marker_count += 1; + } + _ => { + eprintln!( + "unknown block type {} at offset {} - stopping at first unknown block", + block_type, off + ); + return ExitCode::FAILURE; + } + } + } + + if off != data.len() { + eprintln!( + "truncated block header at offset {} (have {} trailing bytes)", + off, + data.len() - off + ); + return ExitCode::FAILURE; + } + + if let Err(e) = fs::write(&out_path, &out) { + eprintln!("failed to write {}: {}", out_path, e); + return ExitCode::FAILURE; + } + + println!("wrote {} bytes to {}", out.len(), out_path); + println!( + " format v{} fields {} -> {} record_length {} -> {}", + format_version, num_fields, new_num_fields, record_length, new_record_length + ); + println!( + " info_data_start {} -> {} data_begin {} -> {}", + info_data_start, new_info_data_start, data_begin_index, new_data_begin + ); + println!( + " data records: {} markers: {}", + data_record_count, marker_count + ); + println!( + " trace: Zmeinka polyline ({} waypoints) centered at ({:.4}, {:.4}), {}s/lap", + WAYPOINTS.len(), + CENTER_LAT, + CENTER_LON, + LAP_PERIOD_S + ); + + ExitCode::SUCCESS +} + +fn build_field_descriptor( + is_v2: bool, + name: &str, + units: &str, + scale: f32, + category: &str, +) -> Vec { + let len = if is_v2 { FIELD_LEN_V2 } else { FIELD_LEN_V1 }; + let mut buf = vec![0u8; len]; + buf[0] = FT_S32; + write_padded(&mut buf[1..1 + 34], name); + write_padded(&mut buf[35..35 + 10], units); + buf[45] = 0; // display_style = MLG_FLOAT + buf[46..50].copy_from_slice(&scale.to_be_bytes()); + buf[50..54].copy_from_slice(&0f32.to_be_bytes()); // transform + buf[54] = 7; // digits + if is_v2 { + write_padded(&mut buf[55..55 + 34], category); + } + buf +} + +fn write_padded(dst: &mut [u8], s: &str) { + let bytes = s.as_bytes(); + let n = bytes.len().min(dst.len()); + dst[..n].copy_from_slice(&bytes[..n]); +} + +/// Synthesize a (lat, lon) pair as MLG raw S32 values (scale 1e-7 deg). +/// +/// Walks the [`WAYPOINTS`] polyline at constant ground speed parameterised +/// by cumulative distance, completing one full lap every `LAP_PERIOD_S`. +/// A small per-record sine jitter adds GPS-like noise so the trace doesn't +/// look perfectly synthetic. +fn synth_lat_lon(t_s: f64, record_index: usize) -> (i32, i32) { + let (east_m, north_m) = sample_polyline(t_s); + let jitter = (record_index as f64).sin() * 0.5; // ±0.5 m + + let dlat_m = north_m + jitter; + let dlon_m = east_m + jitter; + + let dlat = dlat_m / M_PER_DEG_LAT; + let dlon = dlon_m / (M_PER_DEG_LAT * (CENTER_LAT.to_radians()).cos()); + let lat = CENTER_LAT + dlat; + let lon = CENTER_LON + dlon; + + let lat_raw = (lat / 1e-7).round() as i32; + let lon_raw = (lon / 1e-7).round() as i32; + (lat_raw, lon_raw) +} + +/// Sample the closed `WAYPOINTS` polyline at time `t_s`, returning local +/// `(east_m, north_m)` offsets from the track centre. Each segment is +/// linearly interpolated against its share of the perimeter so the +/// resulting motion is constant-speed. +fn sample_polyline(t_s: f64) -> (f64, f64) { + let n = WAYPOINTS.len(); + debug_assert!(n >= 2); + + // Per-segment lengths (closing the loop with the last -> first leg). + let mut seg_len = [0f64; 64]; + let mut perimeter = 0.0; + for i in 0..n { + let (ax, ay) = WAYPOINTS[i]; + let (bx, by) = WAYPOINTS[(i + 1) % n]; + let dx = bx - ax; + let dy = by - ay; + let l = (dx * dx + dy * dy).sqrt(); + seg_len[i] = l; + perimeter += l; + } + + let lap_progress = (t_s / LAP_PERIOD_S).rem_euclid(1.0); + let mut target = lap_progress * perimeter; + + for i in 0..n { + if target <= seg_len[i] { + let (ax, ay) = WAYPOINTS[i]; + let (bx, by) = WAYPOINTS[(i + 1) % n]; + let f = if seg_len[i] > 0.0 { + target / seg_len[i] + } else { + 0.0 + }; + return (ax + (bx - ax) * f, ay + (by - ay) * f); + } + target -= seg_len[i]; + } + WAYPOINTS[0] +} diff --git a/i18n/ar.yaml b/i18n/ar.yaml index 3abf9379..e11d043b 100644 --- a/i18n/ar.yaml +++ b/i18n/ar.yaml @@ -360,3 +360,41 @@ activity: channels_tooltip: "القنوات" tools_tooltip: "الأدوات" settings_tooltip: "الإعدادات" + +# Data panel (src/ui/data_panel.rs) +data_panel: + title: "لوحة البيانات" + expand: "عرض لوحة البيانات" + hide: "إخفاء لوحة البيانات" + no_widgets: "لا توجد عناصر واجهة لعرضها لهذا الملف" + options: "خيارات لوحة البيانات" + add_widget: "إضافة عنصر واجهة" + no_data_suffix: "لا توجد بيانات" + no_widgets_to_add: "جميع العناصر مرئية" + section: + widgets: "العناصر" + pane: "اللوحة" + +# Track Map widget (src/ui/widgets/track_map.rs) +track_map: + title: "خريطة المسار" + no_gps: "لا توجد بيانات GPS في هذا الملف" + color_by: "تلوين حسب" + solid: "صلب" + colormap: "خريطة الألوان" + colormap.viridis: "Viridis" + colormap.turbo: "Turbo" + range: "النطاق" + range_auto: "تلقائي" + lap: "دورة" + lap_all: "جميع الدورات" + reset_view: "إعادة تعيين العرض" + legend.solid_hint: "حدد قناة أعلاه لتلوين المسار." + tiles.show: "خلفية الأقمار الصناعية" + tiles.provider: "مزود البلاط" + tiles.opacity: "الشفافية" + tiles.grayscale: "رمادي" + tiles.attribution_prefix: "بيانات الخريطة: " + tiles.privacy_notice: "يتم تنزيل مربعات الخريطة من %{provider}. تتم مشاركة الموقع التقريبي لمسارك مع هذا المزود." + tooltip.time: "الوقت" + tooltip.position: "الموضع" diff --git a/i18n/bn.yaml b/i18n/bn.yaml index 356a5d50..de515f1e 100644 --- a/i18n/bn.yaml +++ b/i18n/bn.yaml @@ -360,3 +360,41 @@ activity: channels_tooltip: "চ্যানেলসমূহ" tools_tooltip: "টুলস" settings_tooltip: "সেটিংস" + +# Data panel (src/ui/data_panel.rs) +data_panel: + title: "ডেটা প্যানেল" + expand: "ডেটা প্যানেল দেখান" + hide: "ডেটা প্যানেল লুকান" + no_widgets: "এই ফাইলের জন্য প্রদর্শনের জন্য কোনো উইজেট নেই" + options: "ডেটা প্যানেল বিকল্পগুলি" + add_widget: "উইজেট যোগ করুন" + no_data_suffix: "কোনো ডেটা নেই" + no_widgets_to_add: "সমস্ত উইজেট দৃশ্যমান" + section: + widgets: "উইজেটগুলি" + pane: "প্যানেল" + +# Track Map widget (src/ui/widgets/track_map.rs) +track_map: + title: "ট্র্যাক ম্যাপ" + no_gps: "এই ফাইলে কোনো GPS ডেটা নেই" + color_by: "রঙিন করুন" + solid: "সোলিড" + colormap: "রঙ মানচিত্র" + colormap.viridis: "Viridis" + colormap.turbo: "Turbo" + range: "পরিসর" + range_auto: "স্বয়ংক্রিয়" + lap: "ল্যাপ" + lap_all: "সমস্ত ল্যাপ" + reset_view: "দৃশ্য রিসেট করুন" + legend.solid_hint: "ট্র্যাককে রঙিন করতে উপরে একটি চ্যানেল নির্বাচন করুন।" + tiles.show: "স্যাটেলাইট পটভূমি" + tiles.provider: "টাইল প্রদানকারী" + tiles.opacity: "অস্বচ্ছতা" + tiles.grayscale: "গ্রেস্কেল" + tiles.attribution_prefix: "মানচিত্র ডেটা: " + tiles.privacy_notice: "মানচিত্রের টাইলগুলি %{provider} থেকে ডাউনলোড করা হয়। আপনার ট্র্যাকের আনুমানিক অবস্থান এই প্রদানকারীর সাথে শেয়ার করা হয়।" + tooltip.time: "সময়" + tooltip.position: "অবস্থান" diff --git a/i18n/de.yaml b/i18n/de.yaml index 78ce786e..2f244596 100644 --- a/i18n/de.yaml +++ b/i18n/de.yaml @@ -360,3 +360,41 @@ activity: channels_tooltip: "Kanäle" tools_tooltip: "Werkzeuge" settings_tooltip: "Einstellungen" + +# Data panel (src/ui/data_panel.rs) +data_panel: + title: "Datenpanel" + expand: "Datenpanel anzeigen" + hide: "Datenpanel ausblenden" + no_widgets: "Keine Widgets für diese Datei verfügbar" + options: "Datenpanel-Einstellungen" + add_widget: "Widget hinzufügen" + no_data_suffix: "keine Daten" + no_widgets_to_add: "Alle Widgets sind sichtbar" + section: + widgets: "Widgets" + pane: "Panel" + +# Track Map widget (src/ui/widgets/track_map.rs) +track_map: + title: "Streckenkarte" + no_gps: "Keine GPS-Daten in dieser Datei" + color_by: "Farbe nach" + solid: "Durchgehend" + colormap: "Farbschema" + colormap.viridis: "Viridis" + colormap.turbo: "Turbo" + range: "Bereich" + range_auto: "Auto" + lap: "Runde" + lap_all: "Alle Runden" + reset_view: "Ansicht zurücksetzen" + legend.solid_hint: "Wählen Sie einen Kanal oben, um die Strecke zu färben." + tiles.show: "Satellitenhintergrund" + tiles.provider: "Kachelanbieter" + tiles.opacity: "Deckkraft" + tiles.grayscale: "Graustufen" + tiles.attribution_prefix: "Kartendaten: " + tiles.privacy_notice: "Kartenkacheln werden von %{provider} heruntergeladen. Der ungefähre Standort Ihrer Strecke wird an diesen Anbieter übermittelt." + tooltip.time: "Zeit" + tooltip.position: "Position" diff --git a/i18n/en.yaml b/i18n/en.yaml index 723744a0..e3bd087a 100644 --- a/i18n/en.yaml +++ b/i18n/en.yaml @@ -360,3 +360,41 @@ activity: channels_tooltip: "Channels" tools_tooltip: "Tools" settings_tooltip: "Settings" + +# Data panel (src/ui/data_panel.rs) +data_panel: + title: "Data Panel" + expand: "Show data panel" + hide: "Hide data panel" + no_widgets: "No widgets to display for this file" + options: "Data panel options" + add_widget: "Add widget" + no_data_suffix: "no data" + no_widgets_to_add: "All widgets are visible" + section: + widgets: "Widgets" + pane: "Pane" + +# Track Map widget (src/ui/widgets/track_map.rs) +track_map: + title: "Track Map" + no_gps: "No GPS data in this file" + color_by: "Color by" + solid: "Solid" + colormap: "Colormap" + colormap.viridis: "Viridis" + colormap.turbo: "Turbo" + range: "Range" + range_auto: "Auto" + lap: "Lap" + lap_all: "All laps" + reset_view: "Reset view" + legend.solid_hint: "Pick a channel above to color the track." + tiles.show: "Satellite background" + tiles.provider: "Tile provider" + tiles.opacity: "Opacity" + tiles.grayscale: "Grayscale" + tiles.attribution_prefix: "Map data: " + tiles.privacy_notice: "Map tiles are downloaded from %{provider}. Your track's approximate location is shared with this provider." + tooltip.time: "Time" + tooltip.position: "Position" diff --git a/i18n/es.yaml b/i18n/es.yaml index f1ab5dba..80c3cf02 100644 --- a/i18n/es.yaml +++ b/i18n/es.yaml @@ -360,3 +360,41 @@ activity: channels_tooltip: "Canales" tools_tooltip: "Herramientas" settings_tooltip: "Configuracion" + +# Data panel (src/ui/data_panel.rs) +data_panel: + title: "Panel de datos" + expand: "Mostrar panel de datos" + hide: "Ocultar panel de datos" + no_widgets: "No hay widgets disponibles para este archivo" + options: "Opciones del panel de datos" + add_widget: "Añadir widget" + no_data_suffix: "sin datos" + no_widgets_to_add: "Todos los widgets son visibles" + section: + widgets: "Widgets" + pane: "Panel" + +# Track Map widget (src/ui/widgets/track_map.rs) +track_map: + title: "Mapa de circuito" + no_gps: "Sin datos GPS en este archivo" + color_by: "Colorear por" + solid: "Sólido" + colormap: "Mapa de colores" + colormap.viridis: "Viridis" + colormap.turbo: "Turbo" + range: "Rango" + range_auto: "Auto" + lap: "Vuelta" + lap_all: "Todas las vueltas" + reset_view: "Restablecer vista" + legend.solid_hint: "Selecciona un canal arriba para colorear el circuito." + tiles.show: "Fondo satelital" + tiles.provider: "Proveedor de mosaicos" + tiles.opacity: "Opacidad" + tiles.grayscale: "Escala de grises" + tiles.attribution_prefix: "Datos del mapa: " + tiles.privacy_notice: "Los mosaicos del mapa se descargan de %{provider}. La ubicación aproximada de tu trazado se comparte con este proveedor." + tooltip.time: "Tiempo" + tooltip.position: "Posición" diff --git a/i18n/fr.yaml b/i18n/fr.yaml index 7ab6290b..5c7df8f1 100644 --- a/i18n/fr.yaml +++ b/i18n/fr.yaml @@ -360,3 +360,41 @@ activity: channels_tooltip: "Canaux" tools_tooltip: "Outils" settings_tooltip: "Parametres" + +# Data panel (src/ui/data_panel.rs) +data_panel: + title: "Panneau de données" + expand: "Afficher le panneau de données" + hide: "Masquer le panneau de données" + no_widgets: "Aucun widget disponible pour ce fichier" + options: "Options du panneau de données" + add_widget: "Ajouter un widget" + no_data_suffix: "aucune donnée" + no_widgets_to_add: "Tous les widgets sont visibles" + section: + widgets: "Widgets" + pane: "Panneau" + +# Track Map widget (src/ui/widgets/track_map.rs) +track_map: + title: "Carte du circuit" + no_gps: "Aucune donnée GPS dans ce fichier" + color_by: "Colorer par" + solid: "Uniforme" + colormap: "Palette de couleurs" + colormap.viridis: "Viridis" + colormap.turbo: "Turbo" + range: "Plage" + range_auto: "Auto" + lap: "Tour" + lap_all: "Tous les tours" + reset_view: "Réinitialiser la vue" + legend.solid_hint: "Sélectionnez un canal ci-dessus pour colorer la piste." + tiles.show: "Fond satellite" + tiles.provider: "Fournisseur de tuiles" + tiles.opacity: "Opacité" + tiles.grayscale: "Niveaux de gris" + tiles.attribution_prefix: "Données cartographiques: " + tiles.privacy_notice: "Les tuiles de carte sont téléchargées depuis %{provider}. La position approximative de votre tracé est partagée avec ce fournisseur." + tooltip.time: "Temps" + tooltip.position: "Position" diff --git a/i18n/hi.yaml b/i18n/hi.yaml index e40a9c4a..95224fef 100644 --- a/i18n/hi.yaml +++ b/i18n/hi.yaml @@ -360,3 +360,41 @@ activity: channels_tooltip: "चैनल" tools_tooltip: "टूल्स" settings_tooltip: "सेटिंग्स" + +# Data panel (src/ui/data_panel.rs) +data_panel: + title: "डेटा पैनल" + expand: "डेटा पैनल दिखाएं" + hide: "डेटा पैनल छुपाएं" + no_widgets: "इस फ़ाइल के लिए कोई विजेट प्रदर्शित करने के लिए नहीं है" + options: "डेटा पैनल विकल्प" + add_widget: "विजेट जोड़ें" + no_data_suffix: "कोई डेटा नहीं" + no_widgets_to_add: "सभी विजेट दिखाई दे रहे हैं" + section: + widgets: "विजेट" + pane: "पैनल" + +# Track Map widget (src/ui/widgets/track_map.rs) +track_map: + title: "ट्रैक मानचित्र" + no_gps: "इस फ़ाइल में कोई GPS डेटा नहीं है" + color_by: "रंग चुनें" + solid: "ठोस" + colormap: "रंग मानचित्र" + colormap.viridis: "Viridis" + colormap.turbo: "Turbo" + range: "रेंज" + range_auto: "स्वतः" + lap: "लैप" + lap_all: "सभी लैप" + reset_view: "दृश्य रीसेट करें" + legend.solid_hint: "ट्रैक को रंगने के लिए ऊपर एक चैनल चुनें।" + tiles.show: "उपग्रह पृष्ठभूमि" + tiles.provider: "टाइल प्रदाता" + tiles.opacity: "अपारदर्शिता" + tiles.grayscale: "ग्रेस्केल" + tiles.attribution_prefix: "नक्शा डेटा: " + tiles.privacy_notice: "मानचित्र टाइलें %{provider} से डाउनलोड की जाती हैं। आपके ट्रैक का अनुमानित स्थान इस प्रदाता के साथ साझा किया जाता है।" + tooltip.time: "समय" + tooltip.position: "स्थिति" diff --git a/i18n/id.yaml b/i18n/id.yaml index a4a4f38d..d92e5f7b 100644 --- a/i18n/id.yaml +++ b/i18n/id.yaml @@ -360,3 +360,41 @@ activity: channels_tooltip: "Kanal" tools_tooltip: "Alat" settings_tooltip: "Pengaturan" + +# Data panel (src/ui/data_panel.rs) +data_panel: + title: "Panel Data" + expand: "Tampilkan panel data" + hide: "Sembunyikan panel data" + no_widgets: "Tidak ada widget untuk ditampilkan untuk file ini" + options: "Opsi panel data" + add_widget: "Tambah widget" + no_data_suffix: "tidak ada data" + no_widgets_to_add: "Semua widget sudah terlihat" + section: + widgets: "Widget" + pane: "Panel" + +# Track Map widget (src/ui/widgets/track_map.rs) +track_map: + title: "Peta Lintasan" + no_gps: "Tidak ada data GPS di file ini" + color_by: "Warna berdasarkan" + solid: "Padat" + colormap: "Peta warna" + colormap.viridis: "Viridis" + colormap.turbo: "Turbo" + range: "Rentang" + range_auto: "Otomatis" + lap: "Lap" + lap_all: "Semua lap" + reset_view: "Atur ulang tampilan" + legend.solid_hint: "Pilih saluran di atas untuk mewarnai lintasan." + tiles.show: "Latar belakang satelit" + tiles.provider: "Penyedia ubin" + tiles.opacity: "Opasitas" + tiles.grayscale: "Skala abu-abu" + tiles.attribution_prefix: "Data peta: " + tiles.privacy_notice: "Ubin peta diunduh dari %{provider}. Perkiraan lokasi lintasan Anda dibagikan dengan penyedia ini." + tooltip.time: "Waktu" + tooltip.position: "Posisi" diff --git a/i18n/it.yaml b/i18n/it.yaml index 63a0100f..97ee1fd3 100644 --- a/i18n/it.yaml +++ b/i18n/it.yaml @@ -360,3 +360,41 @@ activity: channels_tooltip: "Canali" tools_tooltip: "Strumenti" settings_tooltip: "Impostazioni" + +# Data panel (src/ui/data_panel.rs) +data_panel: + title: "Pannello dati" + expand: "Mostra pannello dati" + hide: "Nascondi pannello dati" + no_widgets: "Nessun widget disponibile per questo file" + options: "Opzioni pannello dati" + add_widget: "Aggiungi widget" + no_data_suffix: "nessun dato" + no_widgets_to_add: "Tutti i widget sono visibili" + section: + widgets: "Widget" + pane: "Pannello" + +# Track Map widget (src/ui/widgets/track_map.rs) +track_map: + title: "Mappa del circuito" + no_gps: "Nessun dato GPS in questo file" + color_by: "Colora per" + solid: "Solid" + colormap: "Mappa colori" + colormap.viridis: "Viridis" + colormap.turbo: "Turbo" + range: "Intervallo" + range_auto: "Auto" + lap: "Giro" + lap_all: "Tutti i giri" + reset_view: "Ripristina vista" + legend.solid_hint: "Seleziona un canale sopra per colorare il circuito." + tiles.show: "Sfondo satellite" + tiles.provider: "Fornitore di tile" + tiles.opacity: "Opacità" + tiles.grayscale: "Scala di grigi" + tiles.attribution_prefix: "Dati mappa: " + tiles.privacy_notice: "Le tessere della mappa vengono scaricate da %{provider}. La posizione approssimativa del tuo tracciato viene condivisa con questo fornitore." + tooltip.time: "Tempo" + tooltip.position: "Posizione" diff --git a/i18n/ja.yaml b/i18n/ja.yaml index f2fe0d38..28e3e82a 100644 --- a/i18n/ja.yaml +++ b/i18n/ja.yaml @@ -360,3 +360,41 @@ activity: channels_tooltip: "チャンネル" tools_tooltip: "ツール" settings_tooltip: "設定" + +# Data panel (src/ui/data_panel.rs) +data_panel: + title: "データパネル" + expand: "データパネルを表示" + hide: "データパネルを非表示" + no_widgets: "このファイルに表示するウィジェットがありません" + options: "データパネルのオプション" + add_widget: "ウィジェットを追加" + no_data_suffix: "データなし" + no_widgets_to_add: "すべてのウィジェットが表示されています" + section: + widgets: "ウィジェット" + pane: "パネル" + +# Track Map widget (src/ui/widgets/track_map.rs) +track_map: + title: "トラックマップ" + no_gps: "このファイルにGPSデータがありません" + color_by: "色分け方法" + solid: "ベタ塗り" + colormap: "カラーマップ" + colormap.viridis: "Viridis" + colormap.turbo: "Turbo" + range: "範囲" + range_auto: "自動" + lap: "ラップ" + lap_all: "すべてのラップ" + reset_view: "表示をリセット" + legend.solid_hint: "上のチャンネルを選択してトラックに色を付けます。" + tiles.show: "衛星背景" + tiles.provider: "タイルプロバイダー" + tiles.opacity: "不透明度" + tiles.grayscale: "グレースケール" + tiles.attribution_prefix: "マップデータ: " + tiles.privacy_notice: "地図タイルは%{provider}からダウンロードされます。トラックのおおよその位置情報がこのプロバイダーに送信されます。" + tooltip.time: "時間" + tooltip.position: "位置" diff --git a/i18n/pt-BR.yaml b/i18n/pt-BR.yaml index a5797d3b..c72cb905 100644 --- a/i18n/pt-BR.yaml +++ b/i18n/pt-BR.yaml @@ -360,3 +360,41 @@ activity: channels_tooltip: "Canais" tools_tooltip: "Ferramentas" settings_tooltip: "Configurações" + +# Data panel (src/ui/data_panel.rs) +data_panel: + title: "Painel de dados" + expand: "Mostrar painel de dados" + hide: "Ocultar painel de dados" + no_widgets: "Nenhum widget disponível para este arquivo" + options: "Opções do painel de dados" + add_widget: "Adicionar widget" + no_data_suffix: "sem dados" + no_widgets_to_add: "Todos os widgets estão visíveis" + section: + widgets: "Widgets" + pane: "Painel" + +# Track Map widget (src/ui/widgets/track_map.rs) +track_map: + title: "Mapa de pista" + no_gps: "Nenhum dado GPS neste arquivo" + color_by: "Colorir por" + solid: "Sólido" + colormap: "Mapa de cores" + colormap.viridis: "Viridis" + colormap.turbo: "Turbo" + range: "Intervalo" + range_auto: "Automático" + lap: "Volta" + lap_all: "Todas as voltas" + reset_view: "Redefinir visualização" + legend.solid_hint: "Selecione um canal acima para colorir a pista." + tiles.show: "Fundo de satélite" + tiles.provider: "Provedor de blocos" + tiles.opacity: "Opacidade" + tiles.grayscale: "Escala de cinza" + tiles.attribution_prefix: "Dados do mapa: " + tiles.privacy_notice: "Os blocos do mapa são baixados de %{provider}. A localização aproximada do seu traçado é compartilhada com esse provedor." + tooltip.time: "Tempo" + tooltip.position: "Posição" diff --git a/i18n/pt-PT.yaml b/i18n/pt-PT.yaml index e70ee427..59ecb0ab 100644 --- a/i18n/pt-PT.yaml +++ b/i18n/pt-PT.yaml @@ -360,3 +360,41 @@ activity: channels_tooltip: "Canais" tools_tooltip: "Ferramentas" settings_tooltip: "Definições" + +# Data panel (src/ui/data_panel.rs) +data_panel: + title: "Painel de dados" + expand: "Mostrar painel de dados" + hide: "Ocultar painel de dados" + no_widgets: "Nenhum widget disponível para este ficheiro" + options: "Opções do painel de dados" + add_widget: "Adicionar widget" + no_data_suffix: "sem dados" + no_widgets_to_add: "Todos os widgets estão visíveis" + section: + widgets: "Widgets" + pane: "Painel" + +# Track Map widget (src/ui/widgets/track_map.rs) +track_map: + title: "Mapa de pista" + no_gps: "Nenhum dado GPS neste ficheiro" + color_by: "Colorir por" + solid: "Sólido" + colormap: "Mapa de cores" + colormap.viridis: "Viridis" + colormap.turbo: "Turbo" + range: "Intervalo" + range_auto: "Automático" + lap: "Volta" + lap_all: "Todas as voltas" + reset_view: "Repor visualização" + legend.solid_hint: "Seleccione um canal acima para colorir a pista." + tiles.show: "Fundo de satélite" + tiles.provider: "Fornecedor de blocos" + tiles.opacity: "Opacidade" + tiles.grayscale: "Escala de cinza" + tiles.attribution_prefix: "Dados do mapa: " + tiles.privacy_notice: "Os mosaicos do mapa são descarregados de %{provider}. A localização aproximada do seu traçado é partilhada com este fornecedor." + tooltip.time: "Tempo" + tooltip.position: "Posição" diff --git a/i18n/ru.yaml b/i18n/ru.yaml index 4c1c92f0..a23d814a 100644 --- a/i18n/ru.yaml +++ b/i18n/ru.yaml @@ -360,3 +360,41 @@ activity: channels_tooltip: "Каналы" tools_tooltip: "Инструменты" settings_tooltip: "Настройки" + +# Data panel (src/ui/data_panel.rs) +data_panel: + title: "Панель данных" + expand: "Показать панель данных" + hide: "Скрыть панель данных" + no_widgets: "Для этого файла нет виджетов" + options: "Настройки панели" + add_widget: "Добавить виджет" + no_data_suffix: "нет данных" + no_widgets_to_add: "Все виджеты уже на панели" + section: + widgets: "Виджеты" + pane: "Панель" + +# Track Map widget (src/ui/widgets/track_map.rs) +track_map: + title: "Карта трека" + no_gps: "В файле нет GPS-данных" + color_by: "Цвет по" + solid: "Сплошной" + colormap: "Палитра" + colormap.viridis: "Viridis" + colormap.turbo: "Turbo" + range: "Диапазон" + range_auto: "Авто" + lap: "Круг" + lap_all: "Все круги" + reset_view: "Сбросить вид" + legend.solid_hint: "Выберите канал выше, чтобы раскрасить трек." + tiles.show: "Спутниковая подложка" + tiles.provider: "Поставщик тайлов" + tiles.opacity: "Прозрачность" + tiles.grayscale: "Чёрно-белые" + tiles.attribution_prefix: "Источник карты: " + tiles.privacy_notice: "Тайлы карты загружаются с %{provider}. Приблизительное местоположение вашего трека передаётся этому провайдеру." + tooltip.time: "Время" + tooltip.position: "Позиция" diff --git a/i18n/ur.yaml b/i18n/ur.yaml index bb8dbbb2..3aa60417 100644 --- a/i18n/ur.yaml +++ b/i18n/ur.yaml @@ -361,3 +361,41 @@ activity: channels_tooltip: "چینلز" tools_tooltip: "ٹولز" settings_tooltip: "ترتیبات" + +# Data panel (src/ui/data_panel.rs) +data_panel: + title: "ڈیٹا پینل" + expand: "ڈیٹا پینل دکھائیں" + hide: "ڈیٹا پینل چھپائیں" + no_widgets: "اس فائل کے لیے کوئی ونڈجیٹ نہیں ہے" + options: "ڈیٹا پینل کی ترتیبات" + add_widget: "ونڈجیٹ شامل کریں" + no_data_suffix: "کوئی ڈیٹا نہیں" + no_widgets_to_add: "تمام ونڈجیٹ نظر آ رہے ہیں" + section: + widgets: "ونڈجیٹس" + pane: "پینل" + +# Track Map widget (src/ui/widgets/track_map.rs) +track_map: + title: "ٹریک نقشہ" + no_gps: "اس فائل میں کوئی GPS ڈیٹا نہیں ہے" + color_by: "رنگ کریں" + solid: "ٹھوس" + colormap: "رنگ نقشہ" + colormap.viridis: "Viridis" + colormap.turbo: "Turbo" + range: "رینج" + range_auto: "خودکار" + lap: "چکر" + lap_all: "تمام چکر" + reset_view: "نظارہ ری سیٹ کریں" + legend.solid_hint: "ٹریک کو رنگ دینے کے لیے اوپر ایک چینل منتخب کریں۔" + tiles.show: "سیٹلائٹ پس منظر" + tiles.provider: "ٹائل فراہم کنندہ" + tiles.opacity: "شفافیت" + tiles.grayscale: "سرمائی" + tiles.attribution_prefix: "نقشہ ڈیٹا: " + tiles.privacy_notice: "نقشے کی ٹائلیں %{provider} سے ڈاؤن لوڈ کی جاتی ہیں۔ آپ کے ٹریک کا تخمینی مقام اس فراہم کنندہ کے ساتھ شیئر کیا جاتا ہے۔" + tooltip.time: "وقت" + tooltip.position: "مقام" diff --git a/i18n/zh-CN.yaml b/i18n/zh-CN.yaml index 45508d07..3d8486f7 100644 --- a/i18n/zh-CN.yaml +++ b/i18n/zh-CN.yaml @@ -360,3 +360,41 @@ activity: channels_tooltip: "通道" tools_tooltip: "工具" settings_tooltip: "设置" + +# Data panel (src/ui/data_panel.rs) +data_panel: + title: "数据面板" + expand: "显示数据面板" + hide: "隐藏数据面板" + no_widgets: "此文件没有要显示的小部件" + options: "数据面板选项" + add_widget: "添加小部件" + no_data_suffix: "无数据" + no_widgets_to_add: "所有小部件均已显示" + section: + widgets: "小部件" + pane: "面板" + +# Track Map widget (src/ui/widgets/track_map.rs) +track_map: + title: "赛道地图" + no_gps: "此文件中没有GPS数据" + color_by: "按以下方式着色" + solid: "实心" + colormap: "颜色映射" + colormap.viridis: "Viridis" + colormap.turbo: "Turbo" + range: "范围" + range_auto: "自动" + lap: "圈" + lap_all: "所有圈数" + reset_view: "重置视图" + legend.solid_hint: "选择上面的通道来给赛道着色。" + tiles.show: "卫星背景" + tiles.provider: "瓦片提供商" + tiles.opacity: "不透明度" + tiles.grayscale: "灰度" + tiles.attribution_prefix: "地图数据: " + tiles.privacy_notice: "地图图块从 %{provider} 下载。您赛道的大致位置将共享给该提供商。" + tooltip.time: "时间" + tooltip.position: "位置" diff --git a/scripts/build-release.sh b/scripts/build-release.sh index b57cc360..ada421f3 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -354,21 +354,17 @@ install_local() { APP_SRC="$BUILD_DIR/$APP_NAME.app" APP_DEST="/Applications/$APP_NAME.app" - # Check if app bundle exists, if not build it - if [ ! -d "$APP_SRC" ]; then - echo "App bundle not found. Building first..." - - mkdir -p "$OUTPUT_DIR" - - if [ "$APP_ARCH" = "arm64" ]; then - echo "Building aarch64-apple-darwin (Apple Silicon)..." - cargo build --release --target aarch64-apple-darwin - create_app_bundle "arm64" "$PROJECT_DIR/target/aarch64-apple-darwin/release/ultralog" - else - echo "Building x86_64-apple-darwin (Intel)..." - cargo build --release --target x86_64-apple-darwin - create_app_bundle "intel" "$PROJECT_DIR/target/x86_64-apple-darwin/release/ultralog" - fi + # Always rebuild so --install cannot reuse a stale app bundle. + mkdir -p "$OUTPUT_DIR" + + if [ "$APP_ARCH" = "arm64" ]; then + echo "Building aarch64-apple-darwin (Apple Silicon)..." + cargo build --release --target aarch64-apple-darwin + create_app_bundle "arm64" "$PROJECT_DIR/target/aarch64-apple-darwin/release/ultralog" + else + echo "Building x86_64-apple-darwin (Intel)..." + cargo build --release --target x86_64-apple-darwin + create_app_bundle "intel" "$PROJECT_DIR/target/x86_64-apple-darwin/release/ultralog" fi # Remove old installation diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs index 2ae0db4a..f2bbbec9 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -31,7 +31,7 @@ pub use registry::{ get_adapter_by_id, get_adapters, get_adapters_by_vendor, get_all_categories, get_channel_metadata, get_channels_by_category, get_protocol_by_id, get_protocols, get_spec_normalizations, get_spec_source, has_spec_normalization, normalize_from_spec, - refresh_specs_from_api, specs_refreshed, + refresh_specs_from_api, spec_generation, specs_refreshed, }; pub use types::{ AdapterSpec, ByteOrder, ChannelCategory, ChannelSpec, DataType, EnumSpec, FileFormatSpec, diff --git a/src/adapters/registry.rs b/src/adapters/registry.rs index f8dc2fdb..97e87399 100644 --- a/src/adapters/registry.rs +++ b/src/adapters/registry.rs @@ -7,7 +7,7 @@ //! - Support background refresh of specs from the API use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{LazyLock, RwLock}; use super::api; @@ -144,6 +144,10 @@ fn load_protocols_with_fallback() -> Vec { /// Tracks whether specs have been refreshed from API static SPECS_REFRESHED: AtomicBool = AtomicBool::new(false); +/// Monotonic version of the adapter-derived lookup maps. Consumers can use +/// this to invalidate caches when a background refresh replaces the specs. +static SPEC_GENERATION: AtomicU64 = AtomicU64::new(0); + /// Dynamically updatable adapter specifications /// Initial load uses cache/embedded, background refresh updates from API static ADAPTER_SPECS: LazyLock>> = @@ -413,6 +417,7 @@ pub fn refresh_specs_from_api() -> RefreshResult { *meta_lock = build_metadata_map(&specs); } } + SPEC_GENERATION.fetch_add(1, Ordering::SeqCst); Some(count) } Err(e) => { @@ -481,6 +486,11 @@ pub fn specs_refreshed() -> bool { SPECS_REFRESHED.load(Ordering::SeqCst) } +/// Return the version of the adapter-derived lookup maps. +pub fn spec_generation() -> u64 { + SPEC_GENERATION.load(Ordering::SeqCst) +} + /// Get the current spec source (for display purposes) pub fn get_spec_source() -> &'static str { if SPECS_REFRESHED.load(Ordering::SeqCst) { diff --git a/src/app.rs b/src/app.rs index 34c6cdb5..09e7de98 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,7 +7,7 @@ use eframe::egui; use encoding_rs::{UTF_16BE, UTF_16LE}; use memmap2::Mmap; use rust_i18n::t; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs::{self, File}; use std::path::PathBuf; use std::sync::mpsc::{Receiver, Sender, channel}; @@ -29,7 +29,7 @@ use crate::state::{ ActivePanel, ActiveTool, CHART_COLORS, COLORBLIND_COLORS, CacheKey, DownsampleCache, FontScale, LoadResult, LoadedFile, LoadingState, MAX_CHANNELS, MAX_CHANNELS_PER_PLOT, MAX_TOTAL_CHANNELS, MIN_PLOT_HEIGHT, PlotArea, ScatterHistogramCache, ScatterPlotConfig, ScatterPlotState, - SelectedChannel, Tab, ToastType, + SelectedChannel, Tab, TileProviderId, ToastType, }; use crate::units::UnitPreferences; use crate::updater::{DownloadResult, UpdateCheckResult, UpdateState}; @@ -176,6 +176,24 @@ pub struct UltraLogApp { pub(crate) show_analysis_panel: bool, /// Selected category in analysis panel (None = show all) pub(crate) analysis_selected_category: Option, + // === Track Map / Data Panel Preferences === + // Live copies of persisted preferences, synced back into UserSettings + // by eframe::App::save (see the Settings Persistence Contract in + // CLAUDE.md - every field here appears in three places). + /// Default tile provider for the Track Map widget + pub(crate) tile_provider: TileProviderId, + /// Soft cap for the on-disk tile cache, in MB + pub(crate) tile_cache_max_mb: u32, + /// Whether the satellite/map tile background was last enabled + pub(crate) tiles_enabled: bool, + /// Tile overlay opacity (0.1..=1.0) + pub(crate) tile_opacity: f32, + /// Whether tiles render in grayscale + pub(crate) tile_grayscale: bool, + /// Whether the one-time tile privacy notice has been shown + pub(crate) tile_privacy_notice_seen: bool, + /// Widget IDs the user has hidden from the data panel + pub(crate) hidden_widgets: HashSet, // === Internationalization === /// User settings (persisted to disk) pub(crate) user_settings: UserSettings, @@ -249,6 +267,13 @@ impl Default for UltraLogApp { analysis_results: HashMap::new(), show_analysis_panel: false, analysis_selected_category: None, + tile_provider: TileProviderId::default(), + tile_cache_max_mb: 256, + tiles_enabled: false, + tile_opacity: 1.0, + tile_grayscale: false, + tile_privacy_notice_seen: false, + hidden_widgets: HashSet::new(), user_settings: UserSettings::default(), language: Language::default(), spec_refresh_started: false, @@ -313,6 +338,13 @@ impl UltraLogApp { cursor_tracking: user_settings.cursor_tracking, auto_check_updates: user_settings.auto_check_updates, custom_normalizations: user_settings.custom_normalizations.clone(), + tile_provider: user_settings.tile_provider, + tile_cache_max_mb: user_settings.tile_cache_max_mb, + tiles_enabled: user_settings.tiles_enabled, + tile_opacity: user_settings.tile_opacity, + tile_grayscale: user_settings.tile_grayscale, + tile_privacy_notice_seen: user_settings.tile_privacy_notice_seen, + hidden_widgets: user_settings.hidden_widgets.clone(), ..Self::default() }; @@ -1286,6 +1318,9 @@ impl UltraLogApp { for idx in indices_to_remove { if idx < tab.selected_channels.len() { + tab.data_panel_state + .track_map + .remove_color_channel_slot(idx); tab.selected_channels.remove(idx); } } @@ -1431,6 +1466,10 @@ impl UltraLogApp { } } + tab.data_panel_state + .track_map + .remove_color_channel_slot(channel_idx); + // Remove from selected_channels tab.selected_channels.remove(channel_idx); } @@ -2153,6 +2192,13 @@ impl eframe::App for UltraLogApp { cursor_tracking: self.cursor_tracking, auto_check_updates: self.auto_check_updates, custom_normalizations: self.custom_normalizations.clone(), + tile_provider: self.tile_provider, + tile_cache_max_mb: self.tile_cache_max_mb, + tiles_enabled: self.tiles_enabled, + tile_opacity: self.tile_opacity, + tile_grayscale: self.tile_grayscale, + tile_privacy_notice_seen: self.tile_privacy_notice_seen, + hidden_widgets: self.hidden_widgets.clone(), }; if settings != self.user_settings { self.user_settings = settings; @@ -2193,6 +2239,11 @@ impl eframe::App for UltraLogApp { // Handle IPC commands from MCP server self.process_ipc_commands(ctx); + // Tile workers can finish while the map widget is not being rendered. + // Drain their responses here and invalidate obsolete requests whenever + // the active view no longer displays the tile layer. + crate::ui::widgets::track_map::maintain_tile_source(ctx, self); + // Request repaint while loading or updating (for spinner animation) if matches!(self.loading_state, LoadingState::Loading(_)) || matches!( @@ -2306,6 +2357,43 @@ impl eframe::App for UltraLogApp { ui.add_space(10.0); ui.separator(); + // Right-side data panel (track map + future widgets). + // - When the user has it expanded -> render the full + // panel carved out before the chart. + // - When collapsed but there's content available -> + // render a thin rail with an expand affordance so + // the panel is discoverable. The rail itself is + // the only entry point; the previous "Show data + // panel" checkbox was removed. + if self.data_panel_should_show() { + let avail_w = ui.available_width(); + let tab_idx = self + .active_tab + .expect("a visible data panel requires an active tab"); + let tab_id = self.tabs[tab_idx].id; + let frac = self.tabs[tab_idx] + .data_panel_state + .split_fraction + .clamp(0.2, 0.7); + let max_panel_w = (avail_w * 0.7).max(260.0); + let panel_w = (avail_w * frac).clamp(260.0, max_panel_w); + let panel = + egui::Panel::right(egui::Id::new(("ultralog_data_panel", tab_id))) + .resizable(true) + .default_size(panel_w) + .min_size(260.0) + .max_size(max_panel_w) + .show(ui, |ui| { + self.render_data_panel(ui); + }); + if avail_w > 0.0 { + self.tabs[tab_idx].data_panel_state.split_fraction = + (panel.response.rect.width() / avail_w).clamp(0.2, 0.7); + } + } else if self.data_panel_has_content() { + self.render_data_panel_rail(ui); + } + // Chart takes remaining space self.render_chart(ui); } diff --git a/src/colormap.rs b/src/colormap.rs new file mode 100644 index 00000000..bd3168d6 --- /dev/null +++ b/src/colormap.rs @@ -0,0 +1,199 @@ +//! Perceptually uniform colormaps for data visualization. +//! +//! Two LUTs are bundled: +//! - **Viridis** - matplotlib default; Public Domain (CC0). Source: +//! +//! - **Turbo** - Google improved rainbow; Apache-2.0. Source: +//! +//! +//! Both LUTs are 256 stops of sRGB triples. `sample` does linear interpolation +//! between adjacent stops so output is smooth at any t in [0, 1]. + +use eframe::egui::Color32; + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize, +)] +pub enum Colormap { + #[default] + Viridis, + Turbo, +} + +impl Colormap { + pub fn label(self) -> &'static str { + match self { + Self::Viridis => "Viridis", + Self::Turbo => "Turbo", + } + } + + fn lut(self) -> &'static [[u8; 3]; 256] { + match self { + Self::Viridis => &VIRIDIS, + Self::Turbo => &TURBO, + } + } +} + +/// Sample the colormap at `t` (clamped to `[0.0, 1.0]`). Non-finite `t` +/// (NaN, ±inf) collapse to 0 so callers can pass raw normalized values +/// without pre-checking. +pub fn sample(map: Colormap, t: f32) -> Color32 { + let t = if t.is_finite() { + t.clamp(0.0, 1.0) + } else { + 0.0 + }; + let lut = map.lut(); + let scaled = t * 255.0; + let i0 = scaled.floor() as usize; + let i1 = (i0 + 1).min(255); + let f = scaled - i0 as f32; + + let a = lut[i0]; + let b = lut[i1]; + let lerp = |x: u8, y: u8| -> u8 { (x as f32 + (y as f32 - x as f32) * f).round() as u8 }; + Color32::from_rgb(lerp(a[0], b[0]), lerp(a[1], b[1]), lerp(a[2], b[2])) +} + +// --- Viridis LUT (256 stops) --- +// Source: matplotlib (CC0). Computed from the canonical _viridis_data. +#[rustfmt::skip] +static VIRIDIS: [[u8; 3]; 256] = [ + [68, 1, 84], [68, 2, 86], [69, 4, 87], [69, 5, 89], [70, 7, 90], [70, 8, 92], [70, 10, 93], [70, 11, 94], + [71, 13, 96], [71, 14, 97], [71, 16, 99], [71, 17, 100], [71, 19, 101], [72, 20, 103], [72, 22, 104], [72, 23, 105], + [72, 24, 106], [72, 26, 108], [72, 27, 109], [72, 28, 110], [72, 29, 111], [72, 31, 112], [72, 32, 113], [72, 33, 115], + [72, 35, 116], [72, 36, 117], [72, 37, 118], [72, 38, 119], [72, 40, 120], [72, 41, 121], [71, 42, 122], [71, 44, 122], + [71, 45, 123], [71, 46, 124], [71, 47, 125], [70, 48, 126], [70, 50, 126], [70, 51, 127], [70, 52, 128], [69, 53, 129], + [69, 55, 129], [69, 56, 130], [68, 57, 131], [68, 58, 131], [68, 59, 132], [67, 61, 132], [67, 62, 133], [66, 63, 133], + [66, 64, 134], [66, 65, 134], [65, 66, 135], [65, 68, 135], [64, 69, 136], [64, 70, 136], [63, 71, 136], [63, 72, 137], + [62, 73, 137], [62, 74, 137], [62, 76, 138], [61, 77, 138], [61, 78, 138], [60, 79, 138], [60, 80, 139], [59, 81, 139], + [59, 82, 139], [58, 83, 139], [58, 84, 140], [57, 85, 140], [57, 86, 140], [56, 88, 140], [56, 89, 140], [55, 90, 140], + [55, 91, 141], [54, 92, 141], [54, 93, 141], [53, 94, 141], [53, 95, 141], [52, 96, 141], [52, 97, 141], [51, 98, 141], + [51, 99, 141], [50, 100, 142], [50, 101, 142], [49, 102, 142], [49, 103, 142], [49, 104, 142], [48, 105, 142], [48, 106, 142], + [47, 107, 142], [47, 108, 142], [46, 109, 142], [46, 110, 142], [46, 111, 142], [45, 112, 142], [45, 113, 142], [44, 113, 142], + [44, 114, 142], [44, 115, 142], [43, 116, 142], [43, 117, 142], [42, 118, 142], [42, 119, 142], [42, 120, 142], [41, 121, 142], + [41, 122, 142], [41, 123, 142], [40, 124, 142], [40, 125, 142], [39, 126, 142], [39, 127, 142], [39, 128, 142], [38, 129, 142], + [38, 130, 142], [38, 130, 142], [37, 131, 142], [37, 132, 142], [37, 133, 142], [36, 134, 142], [36, 135, 142], [35, 136, 142], + [35, 137, 142], [35, 138, 141], [34, 139, 141], [34, 140, 141], [34, 141, 141], [33, 142, 141], [33, 143, 141], [33, 144, 141], + [33, 145, 140], [32, 146, 140], [32, 146, 140], [32, 147, 140], [31, 148, 140], [31, 149, 139], [31, 150, 139], [31, 151, 139], + [31, 152, 139], [31, 153, 138], [31, 154, 138], [30, 155, 138], [30, 156, 137], [30, 157, 137], [31, 158, 137], [31, 159, 136], + [31, 160, 136], [31, 161, 136], [31, 161, 135], [31, 162, 135], [32, 163, 134], [32, 164, 134], [33, 165, 133], [33, 166, 133], + [34, 167, 133], [34, 168, 132], [35, 169, 131], [36, 170, 131], [37, 171, 130], [37, 172, 130], [38, 173, 129], [39, 173, 129], + [40, 174, 128], [41, 175, 127], [42, 176, 127], [44, 177, 126], [45, 178, 125], [46, 179, 124], [47, 180, 124], [49, 181, 123], + [50, 182, 122], [52, 182, 121], [53, 183, 121], [55, 184, 120], [56, 185, 119], [58, 186, 118], [59, 187, 117], [61, 188, 116], + [63, 188, 115], [64, 189, 114], [66, 190, 113], [68, 191, 112], [70, 192, 111], [72, 193, 110], [74, 193, 109], [76, 194, 108], + [78, 195, 107], [80, 196, 106], [82, 197, 105], [84, 197, 104], [86, 198, 103], [88, 199, 101], [90, 200, 100], [92, 200, 99], + [94, 201, 98], [96, 202, 96], [99, 203, 95], [101, 203, 94], [103, 204, 92], [105, 205, 91], [108, 205, 90], [110, 206, 88], + [112, 207, 87], [115, 208, 86], [117, 208, 84], [119, 209, 83], [122, 209, 81], [124, 210, 80], [127, 211, 78], [129, 211, 77], + [132, 212, 75], [134, 213, 73], [137, 213, 72], [139, 214, 70], [142, 214, 69], [144, 215, 67], [147, 215, 65], [149, 216, 64], + [152, 216, 62], [155, 217, 60], [157, 217, 59], [160, 218, 57], [162, 218, 55], [165, 219, 54], [168, 219, 52], [170, 220, 50], + [173, 220, 48], [176, 221, 47], [178, 221, 45], [181, 222, 43], [184, 222, 41], [186, 222, 40], [189, 223, 38], [192, 223, 37], + [194, 223, 35], [197, 224, 33], [200, 224, 32], [202, 225, 31], [205, 225, 29], [208, 225, 28], [210, 226, 27], [213, 226, 26], + [216, 226, 25], [218, 227, 25], [221, 227, 24], [223, 227, 24], [226, 228, 24], [229, 228, 25], [231, 228, 25], [234, 229, 26], + [236, 229, 27], [239, 229, 28], [241, 229, 29], [244, 230, 30], [246, 230, 32], [248, 230, 33], [251, 231, 35], [253, 231, 37], +]; + +// --- Turbo LUT (256 stops) --- +// Source: Google Turbo (Apache-2.0). Adapted from Mikhail Korobov's published +// 256-entry table. +#[rustfmt::skip] +static TURBO: [[u8; 3]; 256] = [ + [48, 18, 59], [50, 21, 67], [51, 24, 74], [52, 27, 81], [53, 30, 88], [54, 33, 95], [55, 36, 102], [56, 39, 109], + [57, 42, 115], [58, 45, 121], [59, 47, 128], [60, 50, 134], [61, 53, 139], [62, 56, 145], [63, 59, 151], [63, 62, 156], + [64, 64, 162], [65, 67, 167], [65, 70, 172], [66, 73, 177], [66, 75, 181], [67, 78, 186], [68, 81, 191], [68, 84, 195], + [68, 86, 199], [69, 89, 203], [69, 92, 207], [69, 94, 211], [70, 97, 214], [70, 100, 218], [70, 102, 221], [70, 105, 224], + [70, 107, 227], [71, 110, 230], [71, 113, 233], [71, 115, 235], [71, 118, 238], [71, 120, 240], [71, 123, 242], [70, 125, 244], + [70, 128, 246], [70, 130, 248], [70, 133, 250], [70, 135, 251], [69, 138, 252], [69, 140, 253], [68, 143, 254], [67, 145, 254], + [66, 148, 255], [65, 150, 255], [64, 153, 255], [62, 155, 254], [61, 158, 254], [59, 160, 253], [58, 163, 252], [56, 165, 251], + [55, 168, 250], [53, 171, 248], [51, 173, 247], [49, 175, 245], [47, 178, 244], [46, 180, 242], [44, 183, 240], [42, 185, 238], + [40, 188, 235], [39, 190, 233], [37, 192, 231], [35, 195, 228], [34, 197, 226], [32, 199, 223], [31, 201, 221], [30, 203, 218], + [28, 205, 216], [27, 208, 213], [26, 210, 210], [26, 212, 208], [25, 213, 205], [24, 215, 202], [24, 217, 200], [24, 219, 197], + [24, 221, 194], [24, 222, 192], [24, 224, 189], [25, 226, 187], [25, 227, 185], [26, 228, 182], [28, 230, 180], [29, 231, 178], + [31, 233, 175], [32, 234, 172], [34, 235, 170], [37, 236, 167], [39, 238, 164], [42, 239, 161], [44, 240, 158], [47, 241, 155], + [50, 242, 152], [53, 243, 148], [56, 244, 145], [60, 245, 142], [63, 246, 138], [67, 247, 135], [70, 248, 132], [74, 248, 128], + [78, 249, 125], [82, 250, 122], [85, 250, 118], [89, 251, 115], [93, 252, 111], [97, 252, 108], [101, 253, 105], [105, 253, 102], + [109, 254, 98], [113, 254, 95], [117, 254, 92], [121, 254, 89], [125, 255, 86], [128, 255, 83], [132, 255, 81], [136, 255, 78], + [139, 255, 75], [143, 255, 73], [146, 255, 71], [150, 254, 68], [153, 254, 66], [156, 254, 64], [159, 253, 63], [161, 253, 61], + [164, 252, 60], [167, 252, 58], [169, 251, 57], [172, 251, 56], [175, 250, 55], [177, 249, 54], [180, 248, 54], [183, 247, 53], + [185, 246, 53], [188, 245, 52], [190, 244, 52], [193, 243, 52], [195, 241, 52], [198, 240, 52], [200, 239, 52], [203, 237, 52], + [205, 236, 52], [208, 234, 52], [210, 233, 53], [212, 231, 53], [215, 229, 53], [217, 228, 54], [219, 226, 54], [221, 224, 55], + [223, 223, 55], [225, 221, 55], [227, 219, 56], [229, 217, 56], [231, 215, 57], [233, 213, 57], [235, 211, 57], [236, 209, 58], + [238, 207, 58], [239, 205, 58], [241, 203, 58], [242, 201, 58], [244, 199, 58], [245, 197, 58], [246, 195, 58], [247, 193, 58], + [248, 190, 57], [249, 188, 57], [250, 186, 57], [251, 184, 56], [251, 182, 55], [252, 179, 54], [252, 177, 54], [253, 174, 53], + [253, 172, 52], [254, 169, 51], [254, 167, 50], [254, 164, 49], [254, 161, 48], [254, 158, 47], [254, 155, 45], [254, 153, 44], + [254, 150, 43], [254, 147, 42], [254, 144, 41], [253, 141, 39], [253, 138, 38], [252, 135, 37], [252, 132, 35], [251, 129, 34], + [251, 126, 33], [250, 123, 31], [249, 120, 30], [249, 117, 29], [248, 114, 28], [247, 111, 26], [246, 108, 25], [245, 105, 24], + [244, 102, 23], [243, 99, 21], [242, 96, 20], [241, 93, 19], [240, 91, 18], [239, 88, 17], [237, 85, 16], [236, 83, 15], + [235, 80, 14], [234, 78, 13], [232, 75, 12], [231, 73, 12], [229, 71, 11], [228, 69, 10], [226, 67, 10], [225, 65, 9], + [223, 63, 8], [221, 61, 8], [220, 59, 7], [218, 57, 7], [216, 55, 6], [214, 53, 6], [212, 51, 5], [210, 49, 5], + [208, 47, 5], [206, 45, 4], [204, 43, 4], [202, 42, 4], [200, 40, 3], [197, 38, 3], [195, 37, 3], [193, 35, 2], + [190, 33, 2], [188, 32, 2], [185, 30, 2], [183, 29, 2], [180, 27, 1], [178, 26, 1], [175, 24, 1], [172, 23, 1], + [169, 22, 1], [167, 20, 1], [164, 19, 1], [161, 18, 1], [158, 16, 1], [155, 15, 1], [152, 14, 1], [149, 13, 1], + [146, 11, 1], [142, 10, 1], [139, 9, 2], [136, 8, 2], [133, 7, 2], [129, 6, 2], [126, 5, 2], [122, 4, 3], +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sample_clamps_out_of_range() { + let lo = sample(Colormap::Viridis, -1.0); + let zero = sample(Colormap::Viridis, 0.0); + assert_eq!(lo, zero); + + let hi = sample(Colormap::Viridis, 2.0); + let one = sample(Colormap::Viridis, 1.0); + assert_eq!(hi, one); + } + + #[test] + fn sample_handles_non_finite() { + let nan = sample(Colormap::Turbo, f32::NAN); + let zero = sample(Colormap::Turbo, 0.0); + assert_eq!(nan, zero); + } + + #[test] + fn sample_endpoints_match_lut() { + let v0 = sample(Colormap::Viridis, 0.0); + assert_eq!(v0, Color32::from_rgb(68, 1, 84)); + let v1 = sample(Colormap::Viridis, 1.0); + assert_eq!(v1, Color32::from_rgb(253, 231, 37)); + + let t0 = sample(Colormap::Turbo, 0.0); + assert_eq!(t0, Color32::from_rgb(48, 18, 59)); + let t1 = sample(Colormap::Turbo, 1.0); + assert_eq!(t1, Color32::from_rgb(122, 4, 3)); + } + + #[test] + fn canonical_luts_match_source_hashes() { + assert_eq!(lut_hash(&VIRIDIS), 0x1b8e_5490_518c_939b); + assert_eq!(lut_hash(&TURBO), 0xcf9a_52c7_e29b_2626); + } + + #[test] + fn sample_interpolates_between_stops() { + // At t = 0.5/255 we should land halfway between stops 0 and 1. + let v = sample(Colormap::Viridis, 0.5 / 255.0); + let a = VIRIDIS[0]; + let b = VIRIDIS[1]; + let expected = Color32::from_rgb( + ((a[0] as f32 + b[0] as f32) * 0.5).round() as u8, + ((a[1] as f32 + b[1] as f32) * 0.5).round() as u8, + ((a[2] as f32 + b[2] as f32) * 0.5).round() as u8, + ); + assert_eq!(v, expected); + } + + fn lut_hash(lut: &[[u8; 3]; 256]) -> u64 { + lut.iter() + .flatten() + .fold(0xcbf2_9ce4_8422_2325, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3) + }) + } +} diff --git a/src/ipc/handler.rs b/src/ipc/handler.rs index 515f2535..8b8c5d9a 100644 --- a/src/ipc/handler.rs +++ b/src/ipc/handler.rs @@ -380,6 +380,10 @@ impl UltraLogApp { fn handle_deselect_all_channels(&mut self) -> IpcResponse { if let Some(tab_idx) = self.active_tab { + self.tabs[tab_idx] + .data_panel_state + .track_map + .clear_color_channel(); self.tabs[tab_idx].selected_channels.clear(); } IpcResponse::ok() diff --git a/src/laps.rs b/src/laps.rs new file mode 100644 index 00000000..dc2f79fa --- /dev/null +++ b/src/laps.rs @@ -0,0 +1,680 @@ +//! Lap detection from a GPS track. +//! +//! Strategy: a single proximity gate around the first finite GPS point. We +//! detect a lap completion when the car re-enters the gate (after having +//! left it for at least `min_lap_seconds`). Distances are computed in local +//! meters using an equirectangular projection scaled by `cos(mid_latitude)`, +//! which is more than accurate enough for sub-100 m gate radii. +//! +//! This isn't a full Motec-style start/finish line algorithm - it's the +//! pragmatic v1 that works without any user-placed reference. Sectors and +//! crossing-line detection are explicit non-goals here. +//! +//! This module also owns the shared GPS track helpers: coordinate-format +//! detection/normalization ([`GpsCoordSpec`]) and fix sanitization +//! ([`sanitize_gps_track`]). Everything downstream of GPS channel data +//! (projection, lap detection, tooltips) works in decimal degrees; +//! `GpsCoordSpec` is the single place that knows how to get there from a +//! parser's raw channel values. + +use std::f64::consts::PI; + +/// Tunable parameters for lap detection. +#[derive(Debug, Clone, Copy)] +pub struct GateParams { + /// Inner radius (m): once the car is inside this distance from the gate + /// origin, it counts as "armed" and the next exit/re-entry can close a lap. + pub inner_radius_m: f64, + /// Outer radius (m): the car must travel at least this far from the gate + /// origin before re-entry counts. Acts as hysteresis. + pub outer_radius_m: f64, + /// Minimum lap duration (s). Anything shorter is treated as gate dwell + /// (e.g. car stopped near the start). + pub min_lap_seconds: f64, +} + +impl Default for GateParams { + fn default() -> Self { + Self { + inner_radius_m: 10.0, + outer_radius_m: 30.0, + min_lap_seconds: 20.0, + } + } +} + +/// One detected lap. +#[derive(Debug, Clone, PartialEq)] +pub struct LapInfo { + pub index: usize, + pub start_record: usize, + pub end_record: usize, + pub duration_s: f64, +} + +const EARTH_RADIUS_M: f64 = 6_378_137.0; +const MAX_GPS_SPEED_MPS: f64 = 300.0; +const GPS_JUMP_ALLOWANCE_M: f64 = 500.0; +const MAX_SPEED_CHECK_SECONDS: f64 = 5.0; + +/// Format a duration in seconds as `m:ss.fff` (no leading zero on minutes). +pub fn fmt_mmssms(seconds: f64) -> String { + if !seconds.is_finite() || seconds < 0.0 { + return "–".to_string(); + } + let total_ms = (seconds * 1000.0).round() as u64; + let minutes = total_ms / 60_000; + let secs = (total_ms % 60_000) / 1000; + let millis = total_ms % 1000; + format!("{minutes}:{secs:02}.{millis:03}") +} + +/// Detect laps from latitude/longitude/time triples. +/// +/// `lats`, `lons`, and `times` must be the same length. Non-finite samples +/// are skipped (they don't break the state machine - they just don't +/// advance it). +pub fn detect_laps(lats: &[f64], lons: &[f64], times: &[f64], params: GateParams) -> Vec { + let n = lats.len().min(lons.len()).min(times.len()); + if n < 2 { + return Vec::new(); + } + + // Find the first valid point. This becomes the gate origin. + let mut gate_idx = None; + for i in 0..n { + if is_valid_gps_point(lats[i], lons[i]) && times[i].is_finite() { + gate_idx = Some(i); + break; + } + } + let gate_idx = match gate_idx { + Some(i) => i, + None => return Vec::new(), + }; + + let gate_lat = lats[gate_idx]; + let gate_lon = lons[gate_idx]; + let cos_mid = (gate_lat * PI / 180.0).cos(); + let m_per_deg_lat = EARTH_RADIUS_M * PI / 180.0; + let m_per_deg_lon = m_per_deg_lat * cos_mid; + + let dist_m = |lat: f64, lon: f64| -> f64 { + let dy = (lat - gate_lat) * m_per_deg_lat; + let dx = longitude_delta_degrees(gate_lon, lon) * m_per_deg_lon; + (dx * dx + dy * dy).sqrt() + }; + + let mut laps = Vec::new(); + let mut lap_start = gate_idx; + let mut lap_start_time = times[gate_idx]; + // State: have we left the outer radius since `lap_start`? + let mut went_outside = false; + let mut lap_index = 0usize; + + for i in (gate_idx + 1)..n { + let lat = lats[i]; + let lon = lons[i]; + let t = times[i]; + if !is_valid_gps_point(lat, lon) || !t.is_finite() { + continue; + } + + let d = dist_m(lat, lon); + + if !went_outside { + if d > params.outer_radius_m { + went_outside = true; + } + continue; + } + + // Already left the gate area; watch for re-entry. + if d <= params.inner_radius_m { + let duration = t - lap_start_time; + if duration >= params.min_lap_seconds { + laps.push(LapInfo { + index: lap_index, + start_record: lap_start, + end_record: i, + duration_s: duration, + }); + lap_index += 1; + lap_start = i; + lap_start_time = t; + went_outside = false; + } else { + // Treat an early crossing as a new start candidate. Requiring + // another exit prevents a vehicle that stops inside the gate + // from eventually being counted as a completed lap. + lap_start = i; + lap_start_time = t; + went_outside = false; + } + } + } + + laps +} + +/// Return whether a latitude/longitude pair can represent a GPS fix. +pub fn is_valid_gps_point(latitude: f64, longitude: f64) -> bool { + latitude.is_finite() + && longitude.is_finite() + && (-90.0..=90.0).contains(&latitude) + && (-180.0..=180.0).contains(&longitude) + && !(latitude.abs() <= 1e-9 && longitude.abs() <= 1e-9) +} + +/// Replace invalid fixes and isolated implausible jumps with NaN sentinels. +/// A new cluster of consecutive plausible samples starts a new track segment. +pub fn sanitize_gps_track(lats: &mut [f64], lons: &mut [f64], times: &[f64]) { + let n = lats.len().min(lons.len()).min(times.len()); + let candidates: Vec<(usize, f64, f64, f64)> = (0..n) + .filter_map(|index| { + let latitude = lats[index]; + let longitude = lons[index]; + is_valid_gps_point(latitude, longitude).then_some(( + index, + latitude, + longitude, + times[index], + )) + }) + .collect(); + + for index in 0..lats.len().min(lons.len()) { + lats[index] = f64::NAN; + lons[index] = f64::NAN; + } + if candidates.is_empty() { + return; + } + + let anchor_position = candidates + .windows(2) + .position(|pair| plausible_gps_step(pair[0], pair[1])) + .unwrap_or(0); + let mut last_accepted = None; + + for position in anchor_position..candidates.len() { + let candidate = candidates[position]; + let accepted = last_accepted + .map(|previous| plausible_gps_step(previous, candidate)) + .unwrap_or(true); + if accepted { + lats[candidate.0] = candidate.1; + lons[candidate.0] = candidate.2; + last_accepted = Some(candidate); + continue; + } + + if candidates + .get(position + 1) + .is_some_and(|next| plausible_gps_step(candidate, *next)) + { + last_accepted = None; + } + } +} + +/// Return the signed shortest angular distance from one longitude to another. +pub fn longitude_delta_degrees(from: f64, to: f64) -> f64 { + let delta = (to - from).rem_euclid(360.0); + if delta > 180.0 { delta - 360.0 } else { delta } +} + +/// How a log's GPS channels encode coordinate values. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum GpsCoordFormat { + /// Plain decimal degrees - the no-op default. + DecimalDegrees, + /// NMEA-style degrees-decimal-minutes packed as `DDMM.mmmm` + /// (e.g. `4807.038` = 48° 07.038' = 48.1173°). Sign carries the + /// hemisphere, matching loggers that fold N/S/E/W into the value. + DegreesDecimalMinutes, + /// Degrees premultiplied into a fixed-point integer; multiply by the + /// carried factor to recover degrees (1e-3 = millidegrees, 1e-6 = + /// microdegrees, 1e-7 = the common GPS int32 encoding). + ScaledDegrees(f64), +} + +/// Detected coordinate encoding for one file's lat/lon channel pair. +/// +/// Detection is deliberately conservative: anything already inside the +/// valid decimal-degree range is left untouched, so a wrong guess can +/// never corrupt a log that was correct to begin with. Radians are +/// intentionally NOT detected - a radian track is numerically +/// indistinguishable from a genuine near-equator degree track, and +/// converting would corrupt real logs recorded near (0°, 0°). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct GpsCoordSpec { + pub format: GpsCoordFormat, + /// Longitude uses the 0..360 convention; values > 180 wrap negative. + pub lon_0_360: bool, +} + +impl Default for GpsCoordSpec { + fn default() -> Self { + Self { + format: GpsCoordFormat::DecimalDegrees, + lon_0_360: false, + } + } +} + +/// Upper bounds of the NMEA `DDMM.mmmm` packing (90° / 180° with a +/// minutes field that is always < 60). +const DDM_MAX_LAT: f64 = 9060.0; +const DDM_MAX_LON: f64 = 18060.0; +/// Fixed-point factors to try, largest first, so e.g. a microdegree log +/// is never mistaken for a 1e-7 log (which would shrink it 10x). +const SCALED_DEGREE_FACTORS: [f64; 3] = [1e-3, 1e-6, 1e-7]; + +impl GpsCoordSpec { + /// Inspect a lat/lon channel pair and infer how it encodes degrees. + /// + /// Tiers, first match wins: + /// 1. Values inside ±90 / ±180 -> decimal degrees, untouched. (A DDM + /// track that never leaves 0°..1° is ambiguous with this and stays + /// untransformed - the safe default.) + /// 2. Latitude valid but longitude in 180..=360 -> 0..360 longitude. + /// 3. Both axes inside the `DDMM.mmmm` envelope with a valid minutes + /// field (< 60) on ≥95% of finite samples -> NMEA DDM. + /// 4. A fixed-point factor (1e-3, 1e-6, 1e-7) that brings both axes + /// into range -> scaled degrees. + /// 5. Nothing fits (e.g. Garmin semicircles, garbage) -> identity; + /// `sanitize_gps_track` will then drop the out-of-range fixes + /// exactly as it does today. + pub fn detect(lats: &[f64], lons: &[f64]) -> Self { + let max_abs = |values: &[f64]| { + values + .iter() + .copied() + .filter(|value| value.is_finite()) + .fold(0.0_f64, |acc, value| acc.max(value.abs())) + }; + let max_lat = max_abs(lats); + let max_lon = max_abs(lons); + + if max_lat <= 90.0 && max_lon <= 180.0 { + return Self::default(); + } + if max_lat <= 90.0 && max_lon <= 360.0 { + return Self { + format: GpsCoordFormat::DecimalDegrees, + lon_0_360: true, + }; + } + if max_lat < DDM_MAX_LAT + && max_lon < DDM_MAX_LON + && minutes_field_is_valid(lats) + && minutes_field_is_valid(lons) + { + return Self { + format: GpsCoordFormat::DegreesDecimalMinutes, + lon_0_360: false, + }; + } + for factor in SCALED_DEGREE_FACTORS { + if max_lat * factor <= 90.0 && max_lon * factor <= 180.0 { + return Self { + format: GpsCoordFormat::ScaledDegrees(factor), + lon_0_360: false, + }; + } + } + Self::default() + } + + /// True when normalization would be a no-op. + pub fn is_identity(&self) -> bool { + self.format == GpsCoordFormat::DecimalDegrees && !self.lon_0_360 + } + + /// Convert one raw latitude sample to decimal degrees. + pub fn lat_to_degrees(&self, value: f64) -> f64 { + match self.format { + GpsCoordFormat::DecimalDegrees => value, + GpsCoordFormat::DegreesDecimalMinutes => ddm_to_degrees(value), + GpsCoordFormat::ScaledDegrees(factor) => value * factor, + } + } + + /// Convert one raw longitude sample to decimal degrees. + pub fn lon_to_degrees(&self, value: f64) -> f64 { + let degrees = self.lat_to_degrees(value); + if self.lon_0_360 && degrees > 180.0 { + degrees - 360.0 + } else { + degrees + } + } + + /// Normalize both channels to decimal degrees in place. Non-finite + /// samples pass through unchanged. + pub fn normalize_in_place(&self, lats: &mut [f64], lons: &mut [f64]) { + if self.is_identity() { + return; + } + for value in lats.iter_mut() { + if value.is_finite() { + *value = self.lat_to_degrees(*value); + } + } + for value in lons.iter_mut() { + if value.is_finite() { + *value = self.lon_to_degrees(*value); + } + } + } +} + +/// `DDMM.mmmm` -> decimal degrees, preserving sign. +fn ddm_to_degrees(value: f64) -> f64 { + if !value.is_finite() { + return value; + } + let sign = if value < 0.0 { -1.0 } else { 1.0 }; + let packed = value.abs(); + let degrees = (packed / 100.0).trunc(); + let minutes = packed - degrees * 100.0; + sign * (degrees + minutes / 60.0) +} + +/// True when ≥95% of finite samples have a valid DDM minutes field +/// (the two digits left of the decimal point read as minutes < 60). +fn minutes_field_is_valid(values: &[f64]) -> bool { + let mut finite = 0_usize; + let mut valid = 0_usize; + for value in values.iter().copied().filter(|value| value.is_finite()) { + finite += 1; + if value.abs() % 100.0 < 60.0 { + valid += 1; + } + } + finite > 0 && (valid as f64) / (finite as f64) >= 0.95 +} + +fn plausible_gps_step(previous: (usize, f64, f64, f64), current: (usize, f64, f64, f64)) -> bool { + let delta_seconds = if previous.3.is_finite() && current.3.is_finite() { + (current.3 - previous.3).clamp(0.0, MAX_SPEED_CHECK_SECONDS) + } else { + 0.0 + }; + let maximum_distance = GPS_JUMP_ALLOWANCE_M + MAX_GPS_SPEED_MPS * delta_seconds; + gps_distance_m(previous.1, previous.2, current.1, current.2) <= maximum_distance +} + +fn gps_distance_m(lat_a: f64, lon_a: f64, lat_b: f64, lon_b: f64) -> f64 { + let mid_latitude = ((lat_a + lat_b) * 0.5).to_radians(); + let dy = (lat_b - lat_a).to_radians() * EARTH_RADIUS_M; + let dx = + longitude_delta_degrees(lon_a, lon_b).to_radians() * EARTH_RADIUS_M * mid_latitude.cos(); + dx.hypot(dy) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn circle_track( + center_lat: f64, + center_lon: f64, + radius_m: f64, + laps: usize, + samples_per_lap: usize, + lap_seconds: f64, + ) -> (Vec, Vec, Vec) { + let mut lats = Vec::new(); + let mut lons = Vec::new(); + let mut times = Vec::new(); + + let m_per_deg_lat = EARTH_RADIUS_M * PI / 180.0; + let m_per_deg_lon = m_per_deg_lat * (center_lat * PI / 180.0).cos(); + + let total = laps * samples_per_lap; + for i in 0..=total { + let angle = 2.0 * PI * (i as f64 / samples_per_lap as f64); + let dx = radius_m * angle.cos(); + let dy = radius_m * angle.sin(); + let lat = center_lat + dy / m_per_deg_lat; + let lon = center_lon + dx / m_per_deg_lon; + let t = lap_seconds * (i as f64 / samples_per_lap as f64); + lats.push(lat); + lons.push(lon); + times.push(t); + } + (lats, lons, times) + } + + #[test] + fn detects_three_full_laps_on_circle() { + let (lats, lons, times) = circle_track(45.0, 9.0, 200.0, 3, 200, 90.0); + let laps = detect_laps(&lats, &lons, ×, GateParams::default()); + assert_eq!( + laps.len(), + 3, + "expected 3 laps, got {}: {:?}", + laps.len(), + laps + ); + for lap in &laps { + assert!( + (lap.duration_s - 90.0).abs() < 1.0, + "lap duration {} should be ~90s", + lap.duration_s + ); + } + } + + #[test] + fn ignores_partial_trailing_lap() { + let (mut lats, mut lons, mut times) = circle_track(45.0, 9.0, 200.0, 2, 200, 90.0); + // Append a partial 3rd lap (only 1/4 of the way around). + let extra = 50; + let m_per_deg_lat = EARTH_RADIUS_M * PI / 180.0; + let m_per_deg_lon = m_per_deg_lat * (45.0_f64 * PI / 180.0).cos(); + let last_t = *times.last().unwrap(); + for i in 1..=extra { + let angle = 2.0 * PI * (i as f64 / 200.0); + let dx = 200.0 * angle.cos(); + let dy = 200.0 * angle.sin(); + lats.push(45.0 + dy / m_per_deg_lat); + lons.push(9.0 + dx / m_per_deg_lon); + times.push(last_t + 90.0 * (i as f64 / 200.0)); + } + let laps = detect_laps(&lats, &lons, ×, GateParams::default()); + assert_eq!(laps.len(), 2); + } + + #[test] + fn empty_input_yields_empty_laps() { + let laps = detect_laps(&[], &[], &[], GateParams::default()); + assert!(laps.is_empty()); + } + + #[test] + fn all_non_finite_yields_empty() { + let laps = detect_laps( + &[f64::NAN, f64::NAN], + &[f64::NAN, f64::NAN], + &[0.0, 1.0], + GateParams::default(), + ); + assert!(laps.is_empty()); + } + + #[test] + fn dwelling_at_start_does_not_close_lap() { + // 30 samples within 5 m of the start (car stopped), no laps should + // close even though we're "near the gate". + let lats = vec![45.0_f64; 30]; + let lons = vec![9.0_f64; 30]; + let times: Vec = (0..30).map(|i| i as f64 * 0.1).collect(); + let laps = detect_laps(&lats, &lons, ×, GateParams::default()); + assert!(laps.is_empty()); + } + + #[test] + fn early_reentry_then_dwell_does_not_close_lap() { + let latitude = 45.0_f64; + let longitude = 9.0_f64; + let meters_per_degree_lon = EARTH_RADIUS_M * PI / 180.0 * latitude.to_radians().cos(); + let outside_longitude = longitude + 40.0 / meters_per_degree_lon; + + let mut lats = vec![latitude, latitude]; + let mut lons = vec![longitude, outside_longitude]; + let mut times = vec![0.0, 1.0]; + for second in 2..=30 { + lats.push(latitude); + lons.push(longitude); + times.push(second as f64); + } + + let laps = detect_laps(&lats, &lons, ×, GateParams::default()); + assert!(laps.is_empty()); + } + + #[test] + fn fmt_mmssms_basic() { + assert_eq!(fmt_mmssms(0.0), "0:00.000"); + assert_eq!(fmt_mmssms(1.234), "0:01.234"); + assert_eq!(fmt_mmssms(65.5), "1:05.500"); + assert_eq!(fmt_mmssms(125.0), "2:05.000"); + assert_eq!(fmt_mmssms(f64::NAN), "–"); + assert_eq!(fmt_mmssms(-1.0), "–"); + } + + #[test] + fn gps_validation_rejects_sentinels_and_out_of_range_values() { + assert!(!is_valid_gps_point(0.0, 0.0)); + assert!(!is_valid_gps_point(91.0, 10.0)); + assert!(!is_valid_gps_point(45.0, 181.0)); + assert!(is_valid_gps_point(45.0, 9.0)); + } + + #[test] + fn sanitization_removes_isolated_jump_and_recovers_track() { + let mut lats = vec![10.0, 45.0, 45.0001, 20.0, 45.0002, 45.0003]; + let mut lons = vec![10.0, 9.0, 9.0001, 20.0, 9.0002, 9.0003]; + let times = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0]; + + sanitize_gps_track(&mut lats, &mut lons, ×); + + assert!(lats[0].is_nan()); + assert!(lats[3].is_nan()); + assert_eq!(lats[1], 45.0); + assert_eq!(lats[5], 45.0003); + } + + #[test] + fn sanitization_accepts_antimeridian_crossing() { + let mut lats = vec![0.1, 0.1, 0.1]; + let mut lons = vec![179.999, -179.999, -179.998]; + let times = vec![0.0, 1.0, 2.0]; + + sanitize_gps_track(&mut lats, &mut lons, ×); + + assert!(lats.iter().all(|value| value.is_finite())); + assert!((longitude_delta_degrees(179.999, -179.999) - 0.002).abs() < 1e-9); + } + + #[test] + fn lap_gate_distance_uses_shortest_longitude_delta() { + let lats = vec![0.1, 0.1, 0.1]; + let lons = vec![179.9999, -179.9999, 179.9999]; + let times = vec![0.0, 20.0, 40.0]; + + let laps = detect_laps(&lats, &lons, ×, GateParams::default()); + + assert!(laps.is_empty()); + } + + #[test] + fn coord_detection_leaves_decimal_degrees_untouched() { + let spec = GpsCoordSpec::detect(&[45.0, 45.001], &[9.0, 9.001]); + assert!(spec.is_identity()); + + let spec = GpsCoordSpec::detect(&[-89.9], &[-179.9]); + assert!(spec.is_identity()); + + // All-NaN or empty input defaults to identity. + assert!(GpsCoordSpec::detect(&[f64::NAN], &[f64::NAN]).is_identity()); + assert!(GpsCoordSpec::detect(&[], &[]).is_identity()); + } + + #[test] + fn coord_detection_wraps_0_360_longitude() { + let spec = GpsCoordSpec::detect(&[34.05, 34.06], &[241.75, 241.76]); + assert_eq!(spec.format, GpsCoordFormat::DecimalDegrees); + assert!(spec.lon_0_360); + // 241.75°E in 0..360 is 118.25°W. + assert!((spec.lon_to_degrees(241.75) - (-118.25)).abs() < 1e-9); + // Values already <= 180 pass through. + assert!((spec.lon_to_degrees(120.0) - 120.0).abs() < 1e-9); + assert!((spec.lat_to_degrees(34.05) - 34.05).abs() < 1e-9); + } + + #[test] + fn coord_detection_handles_nmea_degrees_decimal_minutes() { + // 48° 07.038' N, 11° 31.324' E (the canonical NMEA GGA example). + let lats = vec![4807.038, 4807.040, 4807.035]; + let lons = vec![1131.324, 1131.326, 1131.320]; + let spec = GpsCoordSpec::detect(&lats, &lons); + assert_eq!(spec.format, GpsCoordFormat::DegreesDecimalMinutes); + assert!((spec.lat_to_degrees(4807.038) - 48.1173).abs() < 1e-4); + assert!((spec.lon_to_degrees(1131.324) - 11.522_066).abs() < 1e-4); + // Southern/western hemispheres carry the sign through. + assert!((spec.lat_to_degrees(-4807.038) - (-48.1173)).abs() < 1e-4); + } + + #[test] + fn coord_detection_rejects_ddm_with_invalid_minutes_field() { + // Values in the DDM envelope but with "minutes" >= 60 - not DDM. + // They fit millidegrees instead (8.999°, 17.998°). + let spec = GpsCoordSpec::detect(&[8999.0, 8998.0], &[17998.0, 17997.0]); + assert_eq!(spec.format, GpsCoordFormat::ScaledDegrees(1e-3)); + } + + #[test] + fn coord_detection_handles_scaled_integer_degrees() { + // Millidegrees. + let spec = GpsCoordSpec::detect(&[45_679.0], &[123_456.0]); + assert_eq!(spec.format, GpsCoordFormat::ScaledDegrees(1e-3)); + assert!((spec.lat_to_degrees(45_679.0) - 45.679).abs() < 1e-9); + + // Microdegrees. + let spec = GpsCoordSpec::detect(&[45_679_123.0], &[123_456_789.0]); + assert_eq!(spec.format, GpsCoordFormat::ScaledDegrees(1e-6)); + assert!((spec.lat_to_degrees(45_679_123.0) - 45.679_123).abs() < 1e-9); + + // 1e-7 int32 encoding (what inject_fake_gps_mlg writes pre-scale). + let spec = GpsCoordSpec::detect(&[456_791_230.0], &[1_234_567_890.0]); + assert_eq!(spec.format, GpsCoordFormat::ScaledDegrees(1e-7)); + assert!((spec.lon_to_degrees(1_234_567_890.0) - 123.456_789).abs() < 1e-9); + } + + #[test] + fn coord_detection_gives_up_on_unrecognized_encodings() { + // Beyond every known envelope (e.g. Garmin semicircles at the + // longitude extreme) - identity, so sanitize drops the fixes. + let spec = GpsCoordSpec::detect(&[1.0e12], &[2.1e12]); + assert!(spec.is_identity()); + } + + #[test] + fn coord_normalization_converts_in_place_and_skips_non_finite() { + let spec = GpsCoordSpec { + format: GpsCoordFormat::ScaledDegrees(1e-6), + lon_0_360: false, + }; + let mut lats = vec![45_000_000.0, f64::NAN]; + let mut lons = vec![9_000_000.0, f64::INFINITY]; + spec.normalize_in_place(&mut lats, &mut lons); + assert!((lats[0] - 45.0).abs() < 1e-9); + assert!((lons[0] - 9.0).abs() < 1e-9); + assert!(lats[1].is_nan()); + assert!(lons[1].is_infinite()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 10b0ec98..016bd2da 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,15 +36,18 @@ pub mod adapters; pub mod analysis; pub mod analytics; pub mod app; +pub mod colormap; pub mod computed; pub mod expression; pub mod i18n; pub mod ipc; +pub mod laps; pub mod mcp; pub mod normalize; pub mod parsers; pub mod settings; pub mod state; +pub mod tiles; pub mod ui; pub mod units; pub mod updater; diff --git a/src/parsers/speeduino.rs b/src/parsers/speeduino.rs index 724a2d6e..c07e7724 100644 --- a/src/parsers/speeduino.rs +++ b/src/parsers/speeduino.rs @@ -40,9 +40,12 @@ impl FieldType { 5 => Some(Self::S32), 6 => Some(Self::S64), 7 => Some(Self::F32), - 10 => Some(Self::U08Bitfield), - 11 => Some(Self::U16Bitfield), - 12 => Some(Self::U32Bitfield), + // The MLG specification writes bitfield type IDs in hexadecimal + // (0x10..=0x12). Accept the old decimal interpretation as well so + // logs produced by earlier tools remain readable. + 10 | 0x10 => Some(Self::U08Bitfield), + 11 | 0x11 => Some(Self::U16Bitfield), + 12 | 0x12 => Some(Self::U32Bitfield), _ => None, } } @@ -600,10 +603,23 @@ mod tests { FieldType::from_u8(12), Some(FieldType::U32Bitfield) )); + assert!(matches!( + FieldType::from_u8(0x10), + Some(FieldType::U08Bitfield) + )); + assert!(matches!( + FieldType::from_u8(0x11), + Some(FieldType::U16Bitfield) + )); + assert!(matches!( + FieldType::from_u8(0x12), + Some(FieldType::U32Bitfield) + )); // Invalid types assert!(FieldType::from_u8(8).is_none()); assert!(FieldType::from_u8(9).is_none()); assert!(FieldType::from_u8(13).is_none()); + assert!(FieldType::from_u8(19).is_none()); assert!(FieldType::from_u8(255).is_none()); } diff --git a/src/settings.rs b/src/settings.rs index c4847c9c..a8b794c4 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -3,11 +3,11 @@ //! This module handles loading and saving user preferences across sessions. use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use crate::i18n::Language; -use crate::state::FontScale; +use crate::state::{FontScale, TileProviderId}; use crate::units::UnitPreferences; /// User settings that persist across sessions @@ -50,6 +50,35 @@ pub struct UserSettings { /// User-defined channel-name normalization mappings (source -> display) #[serde(default)] pub custom_normalizations: HashMap, + /// Default tile provider for the Track Map widget. Persisted so users + /// don't have to re-pick after every restart. + #[serde(default)] + pub tile_provider: TileProviderId, + /// Soft cap for the on-disk tile cache, in MB. + #[serde(default = "default_tile_cache_max_mb")] + pub tile_cache_max_mb: u32, + /// Default for `TrackMapState::tiles_enabled`: remember whether the + /// satellite background was last shown so the next session starts the + /// same way. + #[serde(default)] + pub tiles_enabled: bool, + /// Default for `TrackMapState::tile_opacity` (0.1..=1.0). + #[serde(default = "default_tile_opacity")] + pub tile_opacity: f32, + /// Default for `TrackMapState::tile_grayscale`. + #[serde(default)] + pub tile_grayscale: bool, + /// Set of widget IDs the user has explicitly hidden from the data + /// panel. The user choice wins over `is_available()`: a hidden + /// widget stays hidden even after a file with matching data is + /// opened. The user re-adds it via the panel header's "+" button. + #[serde(default)] + pub hidden_widgets: HashSet, + /// Whether the one-time "map tiles are downloaded from a third-party + /// provider" notice has been shown. Set the first time the user + /// enables the tile background. + #[serde(default)] + pub tile_privacy_notice_seen: bool, } fn default_version() -> u32 { @@ -68,6 +97,14 @@ fn default_true() -> bool { true } +fn default_tile_cache_max_mb() -> u32 { + 256 +} + +fn default_tile_opacity() -> f32 { + 1.0 +} + impl Default for UserSettings { fn default() -> Self { Self { @@ -83,6 +120,13 @@ impl Default for UserSettings { cursor_tracking: default_true(), auto_check_updates: default_true(), custom_normalizations: HashMap::new(), + tile_provider: TileProviderId::default(), + tile_cache_max_mb: default_tile_cache_max_mb(), + tiles_enabled: false, + tile_opacity: default_tile_opacity(), + tile_grayscale: false, + hidden_widgets: HashSet::new(), + tile_privacy_notice_seen: false, } } } diff --git a/src/state.rs b/src/state.rs index 0c90daab..ce435e67 100644 --- a/src/state.rs +++ b/src/state.rs @@ -5,7 +5,10 @@ use std::path::PathBuf; use std::sync::OnceLock; +use std::sync::atomic::{AtomicU64, Ordering}; +use crate::colormap::Colormap; +use crate::laps::{GpsCoordSpec, LapInfo}; use crate::parsers::{Channel, EcuType, Log}; // ============================================================================ @@ -624,6 +627,193 @@ impl PlotArea { } } +// ============================================================================ +// Data Panel + Track Map Types +// ============================================================================ + +/// Identifier for a satellite/map tile provider. +/// +/// New variants slot in here without touching the trait wiring; see +/// `crate::tiles` for the runtime provider trait. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize, +)] +pub enum TileProviderId { + #[default] + EsriWorldImagery, + OpenStreetMap, +} + +/// Pan + zoom state for the track map. Independent of the chosen projection. +#[derive(Debug, Clone, Copy)] +pub struct MapView { + /// User pan offset (screen pixels) on top of the auto-fit transform. + pub pan_px: eframe::egui::Vec2, + /// Multiplicative zoom relative to the auto-fit baseline. 1.0 = fit. + pub zoom: f32, +} + +impl Default for MapView { + fn default() -> Self { + Self { + pan_px: eframe::egui::Vec2::ZERO, + zoom: 1.0, + } + } +} + +/// Cached projection of a single GPS track. Rebuilt when the source file +/// changes or the lat/lon channel binding changes; per-segment color cache +/// is invalidated on `(color_channel, color_min, color_max, colormap)` change. +#[derive(Debug, Clone)] +pub struct TrackCache { + pub file_index: usize, + pub lat_idx: usize, + pub lon_idx: usize, + /// How the source channels encode coordinates. The cached geometry is + /// already normalized to decimal degrees; this is kept so single raw + /// channel reads (e.g. the hover tooltip) can be converted the same way. + pub coord_spec: GpsCoordSpec, + /// Local meters projection (equirectangular) used in the no-tiles path. + pub points_m: std::sync::Arc<[eframe::egui::Vec2]>, + /// Web Mercator offsets at zoom 0 relative to the track bbox center. + /// Keeping local offsets avoids precision loss at high tile zoom levels. + pub mercator_offsets_z0: std::sync::Arc<[eframe::egui::Vec2]>, + /// Record indices used for rendering. Large logs are reduced to a bounded + /// number of points. + pub render_indices: std::sync::Arc<[usize]>, + /// Contiguous valid-track segment for each entry in `render_indices`. + /// Different IDs prevent decimation from joining points across GPS gaps. + pub render_segments: std::sync::Arc<[Option]>, + pub bbox_min: eframe::egui::Vec2, + pub bbox_max: eframe::egui::Vec2, + /// (west, east, south, north) in continuous degrees. West/east may fall + /// outside the conventional longitude range for antimeridian tracks. + pub lonlat_bbox: (f64, f64, f64, f64), + /// Per-segment color, lazily populated on first paint after coloring + /// inputs change. + pub color_cache: Option>, + /// Snapshot of the color inputs for the cached colors. When this no + /// longer matches the active selection, the cache is invalidated. + pub color_signature: Option, + /// Effective min/max values used by `color_cache` and its legend. + pub color_range: Option<(f64, f64)>, +} + +/// Identity tuple for a `color_cache` so we can detect staleness without +/// recomputing the colors themselves. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ColorSignature { + pub channel: Option<(usize, usize)>, + pub data_address: usize, + pub data_length: usize, + pub colormap: Colormap, + pub requested_min: Option, + pub requested_max: Option, +} + +/// Per-tab state for the Track Map widget. +#[derive(Debug, Clone)] +pub struct TrackMapState { + pub enabled: bool, + /// Index into `Tab.selected_channels` used to color the polyline. + /// `None` means a single solid color. + pub color_channel: Option, + pub colormap: Colormap, + pub color_min: Option, + pub color_max: Option, + pub view: MapView, + /// Cached GPS channel lookup for this tab. Interior mutability keeps + /// availability checks cheap even though the widget registry uses `&self`. + pub gps_channels: std::cell::Cell>, + pub cache: Option, + pub laps: Vec, + /// `None` means show all laps. + pub selected_lap: Option, + pub tiles_enabled: bool, + pub tile_provider: TileProviderId, + /// Tile alpha (0.0..=1.0). 1.0 = fully opaque; lower values let the + /// background colour bleed through so the coloured polyline pops. + pub tile_opacity: f32, + /// When true, decoded tiles are converted to greyscale before upload - + /// useful when the tile colours fight the data overlay (e.g. green map + /// vs viridis polyline). + pub tile_grayscale: bool, +} + +impl Default for TrackMapState { + fn default() -> Self { + Self { + enabled: true, + color_channel: None, + colormap: Colormap::default(), + color_min: None, + color_max: None, + view: MapView::default(), + gps_channels: std::cell::Cell::new(None), + cache: None, + laps: Vec::new(), + selected_lap: None, + tiles_enabled: false, + tile_provider: TileProviderId::default(), + tile_opacity: 1.0, + tile_grayscale: false, + } + } +} + +/// Identity of a cached GPS channel lookup. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GpsChannelCache { + pub file_index: usize, + pub spec_generation: u64, + pub channels: Option<(usize, usize)>, +} + +impl TrackMapState { + pub(crate) fn remove_color_channel_slot(&mut self, removed_index: usize) { + match self.color_channel { + Some(index) if index == removed_index => self.clear_color_channel(), + Some(index) if index > removed_index => { + self.color_channel = Some(index - 1); + } + _ => {} + } + } + + pub(crate) fn clear_color_channel(&mut self) { + self.color_channel = None; + self.color_min = None; + self.color_max = None; + if let Some(cache) = self.cache.as_mut() { + cache.color_cache = None; + cache.color_signature = None; + cache.color_range = None; + } + } +} + +/// Per-tab state for the right-side data panel that hosts widgets. +#[derive(Debug, Clone)] +pub struct DataPanelState { + /// Master visibility toggle for the panel. + pub visible: bool, + /// Width of the panel as a fraction of the available central area. + pub split_fraction: f32, + pub track_map: TrackMapState, + // future widgets go here: g_sensor, gauges, camera, ... +} + +impl Default for DataPanelState { + fn default() -> Self { + Self { + visible: true, + split_fraction: 0.4, + track_map: TrackMapState::default(), + } + } +} + // ============================================================================ // Tab Types // ============================================================================ @@ -631,6 +821,8 @@ impl PlotArea { /// A tab representing a single log file's view state #[derive(Clone)] pub struct Tab { + /// Stable identity used for persistent UI state. + pub id: u64, /// Index of the file this tab displays pub file_index: usize, /// Display name for the tab (usually filename) @@ -651,6 +843,8 @@ pub struct Tab { pub scatter_plot_state: ScatterPlotState, /// Histogram state for this tab pub histogram_state: HistogramState, + /// Right-side data panel (track map + future widgets) state + pub data_panel_state: DataPanelState, /// Request to jump the view to a specific time (used for min/max jump buttons) pub jump_to_time: Option, /// Plot areas for stacked mode (ordered top to bottom) @@ -673,6 +867,7 @@ impl Tab { let default_plot = PlotArea::new(0, "Plot 1".to_string()); Self { + id: NEXT_TAB_ID.fetch_add(1, Ordering::Relaxed), file_index, name, selected_channels: Vec::new(), @@ -683,6 +878,7 @@ impl Tab { time_range: None, scatter_plot_state, histogram_state: HistogramState::default(), + data_panel_state: DataPanelState::default(), jump_to_time: None, plot_areas: vec![default_plot], stacked_mode: false, @@ -690,3 +886,38 @@ impl Tab { } } } + +static NEXT_TAB_ID: AtomicU64 = AtomicU64::new(1); + +#[cfg(test)] +mod tests { + use super::{Tab, TrackMapState}; + + #[test] + fn track_map_color_slot_follows_channel_removals() { + let mut state = TrackMapState { + color_channel: Some(3), + color_min: Some(1.0), + color_max: Some(2.0), + ..TrackMapState::default() + }; + + state.remove_color_channel_slot(1); + assert_eq!(state.color_channel, Some(2)); + assert_eq!(state.color_min, Some(1.0)); + assert_eq!(state.color_max, Some(2.0)); + + state.remove_color_channel_slot(2); + assert_eq!(state.color_channel, None); + assert_eq!(state.color_min, None); + assert_eq!(state.color_max, None); + } + + #[test] + fn tabs_receive_distinct_persistent_ids() { + let first = Tab::new(0, "first".to_string()); + let second = Tab::new(0, "second".to_string()); + + assert_ne!(first.id, second.id); + } +} diff --git a/src/tiles.rs b/src/tiles.rs new file mode 100644 index 00000000..9d200395 --- /dev/null +++ b/src/tiles.rs @@ -0,0 +1,1067 @@ +//! Map tile providers + in-memory and on-disk caching. +//! +//! Two providers ship: Esri World Imagery (satellite) and OpenStreetMap +//! (raster street map). Tiles are fetched by a bounded background worker +//! pool; the UI thread requests `(provider, z, x, y)` and workers return PNG +//! bytes. Decoded PNGs are cached in memory as +//! `egui::TextureHandle` so subsequent frames don't re-decode. +//! +//! Disk cache lives at `{data_dir}/UltraLog/tiles/{provider}/{z}/{x}/{y}.png`, +//! mirroring the OECUA spec cache layout. +//! +//! Privacy / bandwidth note: the widget exposes a per-tab toggle and tiles +//! are off by default. Users opt in explicitly. Both providers' attribution +//! requirements are surfaced in the Track Map UI when the corresponding +//! provider is active. + +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender, SyncSender, TrySendError}; +use std::sync::{Arc, Mutex, RwLock}; +use std::thread; +use std::time::{Duration, Instant, SystemTime}; + +use eframe::egui; + +use crate::state::TileProviderId; + +const USER_AGENT: &str = concat!( + "UltraLog/", + env!("CARGO_PKG_VERSION"), + " (https://github.com/ClassicMiniDIY/UltraLog)" +); +const TILES_CACHE_DIR: &str = "tiles"; +const DEFAULT_TILE_CACHE_MAX_MB: u32 = 256; +const MAX_TILE_BYTES: u64 = 4 * 1024 * 1024; +const MAX_TILE_DIMENSION: u32 = 4096; +const MAX_TILE_PIXELS: u64 = 1024 * 1024; +const TILE_REQUEST_TIMEOUT: Duration = Duration::from_secs(15); +const TILE_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const TILE_RETRY_DELAY: Duration = Duration::from_secs(10); +const TILE_QUEUE_RETRY_DELAY: Duration = Duration::from_millis(16); +const TILE_REQUEST_QUEUE_CAPACITY: usize = 64; +const TILE_WORKER_POLL_INTERVAL: Duration = Duration::from_millis(100); + +/// Source of map tiles. New providers add a `TileProviderId` variant in +/// `state.rs` and a match arm in `provider_for`. +pub trait TileProvider: Send + Sync { + fn id(&self) -> TileProviderId; + fn url(&self, z: u8, x: u32, y: u32) -> String; + fn attribution(&self) -> &'static str; + fn max_zoom(&self) -> u8; + fn cache_subdir(&self) -> &'static str; + /// Maximum simultaneous network fetches allowed against this provider. + /// Disk-cache reads are not counted. Defaults to the full worker pool. + fn max_concurrent_fetches(&self) -> usize { + WORKER_COUNT + } +} + +pub struct EsriWorldImagery; +impl TileProvider for EsriWorldImagery { + fn id(&self) -> TileProviderId { + TileProviderId::EsriWorldImagery + } + fn url(&self, z: u8, x: u32, y: u32) -> String { + format!( + "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" + ) + } + fn attribution(&self) -> &'static str { + "Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" + } + fn max_zoom(&self) -> u8 { + 19 + } + fn cache_subdir(&self) -> &'static str { + "esri_world_imagery" + } +} + +pub struct OpenStreetMap; +impl TileProvider for OpenStreetMap { + fn id(&self) -> TileProviderId { + TileProviderId::OpenStreetMap + } + fn url(&self, z: u8, x: u32, y: u32) -> String { + format!("https://tile.openstreetmap.org/{z}/{x}/{y}.png") + } + fn attribution(&self) -> &'static str { + "© OpenStreetMap contributors" + } + fn max_zoom(&self) -> u8 { + 19 + } + fn cache_subdir(&self) -> &'static str { + "openstreetmap" + } + /// The OSM tile usage policy caps applications at 2 simultaneous + /// download connections (). + /// Exceeding it risks a per-IP block that would hit every UltraLog user. + fn max_concurrent_fetches(&self) -> usize { + 2 + } +} + +/// Resolve a [`TileProviderId`] to the corresponding static provider. +pub fn provider_for(id: TileProviderId) -> &'static dyn TileProvider { + match id { + TileProviderId::EsriWorldImagery => &EsriWorldImagery, + TileProviderId::OpenStreetMap => &OpenStreetMap, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct TileKey { + pub provider: TileProviderId, + pub z: u8, + pub x: u32, + pub y: u32, +} + +#[derive(Debug)] +struct FetchedTile { + key: TileKey, + /// `Some` on success, `None` if the fetch failed - the UI thread still + /// needs to clear the in-flight marker so the request can be retried + /// later (e.g. after the user pans away and back). + bytes: Option>, + /// A cancelled request was no longer visible when a worker dequeued it. + /// It must not enter the retry backoff used for network failures. + cancelled: bool, +} + +#[derive(Debug)] +enum WorkerMsg { + Fetch(TileKey), +} + +/// Number of parallel HTTP workers. Tile servers have per-IP concurrency +/// limits; 4 is a polite default that still hides per-tile latency on +/// high-zoom panning. Providers with stricter published limits are gated +/// further by [`TileProvider::max_concurrent_fetches`] via [`FetchPermits`]. +const WORKER_COUNT: usize = 4; + +/// How long a worker sleeps between attempts to acquire a per-provider +/// network permit. Visibility and shutdown are re-checked on every attempt. +const TILE_PERMIT_POLL_INTERVAL: Duration = Duration::from_millis(25); + +/// Counts in-flight network fetches per provider so the worker pool can +/// honor per-provider connection limits (OSM allows at most 2). +#[derive(Debug, Default)] +struct FetchPermits { + in_flight: Mutex>, +} + +impl FetchPermits { + /// Try to reserve a fetch slot for `provider`. Returns `true` on + /// success; the caller must balance it with [`Self::release`]. + fn try_acquire(&self, provider: TileProviderId) -> bool { + let limit = provider_for(provider).max_concurrent_fetches().max(1); + let mut in_flight = self.in_flight.lock().expect("fetch permits poisoned"); + let count = in_flight.entry(provider).or_insert(0); + if *count < limit { + *count += 1; + true + } else { + false + } + } + + fn release(&self, provider: TileProviderId) { + let mut in_flight = self.in_flight.lock().expect("fetch permits poisoned"); + if let Some(count) = in_flight.get_mut(&provider) { + *count = count.saturating_sub(1); + } + } +} + +/// Lazy singleton tile source. Owns the worker thread pool, the in-flight +/// set, the raw-bytes cache, and the on-GPU texture cache. UI thread uses +/// [`Self::request`] each frame for the tiles it wants to draw. +/// +/// Decoding is split from fetching so the same tile can be re-decoded into +/// a different visual mode (e.g. greyscale) without re-fetching from disk +/// or network. Texture cache is keyed on `(TileKey, grayscale)`. +pub struct TileSource { + tx: SyncSender, + rx: Mutex>, + in_flight: Mutex>, + visible_tiles: Arc>>, + shutdown: Arc, + failed_until: Mutex>, + disk_cache: Arc, + /// Raw PNG/JPEG bytes per tile, populated by the worker pool. + /// Decoding into a `TextureHandle` happens lazily in [`Self::request`] so the + /// same bytes can serve both colour and greyscale variants. + bytes_cache: Mutex>>>, + textures: Mutex>, + /// Soft cap on resident textures; oldest entries get evicted en masse + /// when exceeded. Simpler than tracking strict LRU for the v1 budget. + capacity: usize, + /// Soft cap on cached PNG/JPEG bytes - typically 10-30 KB each, so 512 + /// is well under 20 MB and lets several zooms stay warm. + bytes_capacity: usize, +} + +/// Key for the GPU texture cache. Includes the visual mode so a tile drawn +/// once in colour and once in greyscale produces two distinct textures. +type TextureKey = (TileKey, bool); + +#[derive(Debug)] +struct DiskTileCache { + root: Option, + max_bytes: u64, + state: Mutex, +} + +#[derive(Debug, Default)] +struct DiskCacheState { + initialized: bool, + total_bytes: u64, + next_access: u64, + entries: HashMap, +} + +#[derive(Debug)] +struct DiskCacheEntry { + size: u64, + last_used: u64, +} + +impl DiskTileCache { + fn new(max_mb: u32) -> Self { + Self::with_root( + tiles_cache_root(), + u64::from(max_mb).saturating_mul(1024 * 1024), + ) + } + + fn with_root(root: Option, max_bytes: u64) -> Self { + Self { + root, + max_bytes, + state: Mutex::new(DiskCacheState::default()), + } + } + + fn read(&self, key: TileKey) -> Option> { + let path = self.path(key)?; + let mut state = self.state.lock().expect("disk cache poisoned"); + self.ensure_index(&mut state); + + match fs::read(&path) { + Ok(bytes) => { + let size = bytes.len() as u64; + let previous_size = state.entries.get(&path).map_or(0, |entry| entry.size); + state.total_bytes = state + .total_bytes + .saturating_sub(previous_size) + .saturating_add(size); + let last_used = next_access(&mut state); + state + .entries + .insert(path, DiskCacheEntry { size, last_used }); + self.evict_to_fit(&mut state, 0); + Some(bytes) + } + Err(_) => { + remove_index_entry(&mut state, &path); + None + } + } + } + + fn write(&self, key: TileKey, bytes: &[u8]) { + let size = bytes.len() as u64; + if self.max_bytes == 0 || size > self.max_bytes { + return; + } + let Some(path) = self.path(key) else { + return; + }; + + let mut state = self.state.lock().expect("disk cache poisoned"); + self.ensure_index(&mut state); + remove_index_entry(&mut state, &path); + self.evict_to_fit(&mut state, size); + + let Some(parent) = path.parent() else { + return; + }; + if fs::create_dir_all(parent).is_err() { + return; + } + + let temporary = path.with_extension(format!("tmp-{}", std::process::id())); + if fs::write(&temporary, bytes).is_err() { + return; + } + if path.exists() { + let _ = fs::remove_file(&path); + } + if fs::rename(&temporary, &path).is_err() { + let _ = fs::remove_file(temporary); + return; + } + + let last_used = next_access(&mut state); + state.total_bytes = state.total_bytes.saturating_add(size); + state + .entries + .insert(path, DiskCacheEntry { size, last_used }); + } + + fn remove(&self, key: TileKey) { + let Some(path) = self.path(key) else { + return; + }; + let mut state = self.state.lock().expect("disk cache poisoned"); + self.ensure_index(&mut state); + let _ = fs::remove_file(&path); + remove_index_entry(&mut state, &path); + } + + fn path(&self, key: TileKey) -> Option { + self.root.as_ref().map(|root| tile_disk_path_in(root, key)) + } + + fn ensure_index(&self, state: &mut DiskCacheState) { + if state.initialized { + return; + } + state.initialized = true; + let Some(root) = self.root.as_ref() else { + return; + }; + + let mut files = Vec::new(); + collect_cache_files(root, &mut files); + files.sort_by_key(|(_, _, modified)| *modified); + for (path, size, _) in files { + let last_used = next_access(state); + state.total_bytes = state.total_bytes.saturating_add(size); + state + .entries + .insert(path, DiskCacheEntry { size, last_used }); + } + self.evict_to_fit(state, 0); + } + + fn evict_to_fit(&self, state: &mut DiskCacheState, incoming: u64) { + while state.total_bytes.saturating_add(incoming) > self.max_bytes { + let Some(oldest) = state + .entries + .iter() + .min_by_key(|(_, entry)| entry.last_used) + .map(|(path, _)| path.clone()) + else { + break; + }; + + match fs::remove_file(&oldest) { + Ok(()) => remove_index_entry(state, &oldest), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + remove_index_entry(state, &oldest); + } + Err(error) => { + tracing::warn!( + path = %oldest.display(), + error = %error, + "failed to evict map tile from disk cache" + ); + break; + } + } + } + } +} + +fn next_access(state: &mut DiskCacheState) -> u64 { + state.next_access = state.next_access.wrapping_add(1); + state.next_access +} + +fn remove_index_entry(state: &mut DiskCacheState, path: &PathBuf) { + if let Some(entry) = state.entries.remove(path) { + state.total_bytes = state.total_bytes.saturating_sub(entry.size); + } +} + +fn collect_cache_files(directory: &std::path::Path, files: &mut Vec<(PathBuf, u64, SystemTime)>) { + let Ok(entries) = fs::read_dir(directory) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(metadata) = entry.metadata() else { + continue; + }; + if metadata.is_dir() { + collect_cache_files(&path, files); + } else if metadata.is_file() { + files.push(( + path, + metadata.len(), + metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH), + )); + } + } +} + +impl TileSource { + pub fn new(repaint_ctx: egui::Context, disk_cache_max_mb: u32) -> Self { + let (req_tx, req_rx) = mpsc::sync_channel::(TILE_REQUEST_QUEUE_CAPACITY); + let (resp_tx, resp_rx) = mpsc::channel::(); + let disk_cache = Arc::new(DiskTileCache::new(disk_cache_max_mb)); + let http_agent = build_http_agent(); + let visible_tiles = Arc::new(RwLock::new(HashSet::new())); + let shutdown = Arc::new(AtomicBool::new(false)); + + // Wrap the request receiver in Arc so multiple workers can + // share it (mpsc::Receiver is !Sync; Mutex makes it act like an MPMC + // for our coarse Fetch granularity). + let req_rx = Arc::new(Mutex::new(req_rx)); + let fetch_permits = Arc::new(FetchPermits::default()); + for i in 0..WORKER_COUNT { + let rx_clone = Arc::clone(&req_rx); + let tx_clone = resp_tx.clone(); + let cache_clone = Arc::clone(&disk_cache); + let agent_clone = http_agent.clone(); + let ctx_clone = repaint_ctx.clone(); + let visible_clone = Arc::clone(&visible_tiles); + let shutdown_clone = Arc::clone(&shutdown); + let permits_clone = Arc::clone(&fetch_permits); + thread::Builder::new() + .name(format!("ultralog-tile-worker-{i}")) + .spawn(move || { + worker_loop( + rx_clone, + tx_clone, + cache_clone, + agent_clone, + ctx_clone, + visible_clone, + shutdown_clone, + permits_clone, + ) + }) + .ok(); + } + + Self { + tx: req_tx, + rx: Mutex::new(resp_rx), + in_flight: Mutex::new(HashSet::new()), + visible_tiles, + shutdown, + failed_until: Mutex::new(HashMap::new()), + disk_cache, + bytes_cache: Mutex::new(HashMap::new()), + textures: Mutex::new(HashMap::new()), + capacity: 256, + bytes_capacity: 512, + } + } + + /// Drain any tiles fetched since the last call into the bytes cache. + /// Texture upload happens lazily in [`Self::request`] so callers can render + /// the same tile in different visual modes (e.g. greyscale). + pub fn poll(&self, ctx: &egui::Context) { + let rx = self.rx.lock().expect("rx poisoned"); + let mut got_any = false; + while let Ok(tile) = rx.try_recv() { + let mut in_flight = self.in_flight.lock().expect("in_flight poisoned"); + in_flight.remove(&tile.key); + drop(in_flight); + + if tile.cancelled { + continue; + } + let Some(bytes) = tile.bytes else { + self.failed_until + .lock() + .expect("failed_until poisoned") + .insert(tile.key, Instant::now() + TILE_RETRY_DELAY); + continue; + }; + self.failed_until + .lock() + .expect("failed_until poisoned") + .remove(&tile.key); + let mut bc = self.bytes_cache.lock().expect("bytes_cache poisoned"); + if bc.len() >= self.bytes_capacity { + let drop_n = self.bytes_capacity / 2; + let to_drop: Vec = bc.keys().copied().take(drop_n).collect(); + for k in to_drop { + bc.remove(&k); + } + } + bc.insert(tile.key, Arc::new(bytes)); + got_any = true; + } + if got_any { + ctx.request_repaint(); + } + } + + /// Replace the current visible tile set. Workers consult this set before + /// starting disk or network work, so queued requests from an older view + /// are discarded without delaying the current map position. + pub fn set_visible_tiles(&self, keys: &[TileKey]) { + let mut visible = self + .visible_tiles + .write() + .unwrap_or_else(|error| error.into_inner()); + visible.clear(); + visible.extend(keys.iter().copied()); + } + + /// Mark every queued request as obsolete. + pub fn cancel_pending(&self) { + self.set_visible_tiles(&[]); + } + + /// Returns the texture for `(key, grayscale)` if it's already uploaded + /// or the raw bytes are cached locally; otherwise returns `None` and + /// kicks off a background fetch. Caller draws a placeholder for misses. + pub fn request( + &self, + ctx: &egui::Context, + key: TileKey, + grayscale: bool, + ) -> Option { + if !tile_is_visible(&self.visible_tiles, key) { + return None; + } + let tex_key = (key, grayscale); + { + let textures = self.textures.lock().expect("textures poisoned"); + if let Some(t) = textures.get(&tex_key) { + return Some(t.clone()); + } + } + + let bytes_opt = { + let bc = self.bytes_cache.lock().expect("bytes_cache poisoned"); + bc.get(&key).cloned() + }; + if let Some(bytes) = bytes_opt { + if let Some(image) = decode_image(&bytes, grayscale) { + let name = format!( + "tile_{:?}_{}_{}_{}_{}", + key.provider, + key.z, + key.x, + key.y, + if grayscale { "g" } else { "c" } + ); + let handle = ctx.load_texture(name, image, egui::TextureOptions::LINEAR); + let mut textures = self.textures.lock().expect("textures poisoned"); + if textures.len() >= self.capacity { + let drop_n = self.capacity / 2; + let to_drop: Vec = textures.keys().copied().take(drop_n).collect(); + for k in to_drop { + textures.remove(&k); + } + } + textures.insert(tex_key, handle.clone()); + return Some(handle); + } + + self.bytes_cache + .lock() + .expect("bytes_cache poisoned") + .remove(&key); + self.disk_cache.remove(key); + } + + let retry_after = self + .failed_until + .lock() + .expect("failed_until poisoned") + .get(&key) + .copied(); + if let Some(until) = retry_after { + let now = Instant::now(); + if now < until { + ctx.request_repaint_after(until - now); + return None; + } + self.failed_until + .lock() + .expect("failed_until poisoned") + .remove(&key); + } + + let mut in_flight = self.in_flight.lock().expect("in_flight poisoned"); + if !in_flight.contains(&key) { + in_flight.insert(key); + match self.tx.try_send(WorkerMsg::Fetch(key)) { + Ok(()) => {} + Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => { + in_flight.remove(&key); + ctx.request_repaint_after(TILE_QUEUE_RETRY_DELAY); + } + } + } + None + } + + /// Stop the worker thread. Currently unused (workers live for the app + /// lifetime) but exposed for tests / future settings UI. + pub fn shutdown(&self) { + self.cancel_pending(); + self.shutdown.store(true, Ordering::Release); + } +} + +impl Default for TileSource { + fn default() -> Self { + Self::new(egui::Context::default(), DEFAULT_TILE_CACHE_MAX_MB) + } +} + +#[allow(clippy::too_many_arguments)] +fn worker_loop( + rx: Arc>>, + resp_tx: Sender, + disk_cache: Arc, + http_agent: ureq::Agent, + repaint_ctx: egui::Context, + visible_tiles: Arc>>, + shutdown: Arc, + fetch_permits: Arc, +) { + loop { + if shutdown.load(Ordering::Acquire) { + break; + } + let msg = { + // Brief lock to pop the next message; release before doing the + // network/disk work so siblings can pull in parallel. + // + // Holding the mutex across `recv_timeout` does serialize the + // *waiting* (one worker blocks in recv, siblings block on the + // mutex), but not anything that matters: the producer side + // (`SyncSender::try_send` on the UI thread) never touches this + // mutex, the waiter wakes immediately when a message arrives, + // and the lock is released before the fetch begins - so the + // per-dequeue handoff is microseconds against the hundreds of + // milliseconds each fetch takes. A true MPMC channel would buy + // nothing here at the cost of a new dependency. + let guard = rx.lock().expect("rx poisoned"); + guard.recv_timeout(TILE_WORKER_POLL_INTERVAL) + }; + let msg = match msg { + Ok(msg) => msg, + Err(RecvTimeoutError::Timeout) => continue, + Err(RecvTimeoutError::Disconnected) => break, + }; + match msg { + WorkerMsg::Fetch(key) => { + if !tile_is_visible(&visible_tiles, key) { + if resp_tx + .send(FetchedTile { + key, + bytes: None, + cancelled: true, + }) + .is_err() + { + break; + } + repaint_ctx.request_repaint(); + continue; + } + let outcome = load_or_fetch( + key, + &disk_cache, + &http_agent, + &fetch_permits, + &visible_tiles, + &shutdown, + ); + // Always send back a FetchedTile (Some/None) so the UI can + // clear in_flight even on failure - otherwise a dropped tile + // would never be retried until restart. + let tile = match outcome { + FetchOutcome::Shutdown => break, + FetchOutcome::Bytes(bytes) => FetchedTile { + key, + bytes: Some(bytes), + cancelled: false, + }, + FetchOutcome::Failed => FetchedTile { + key, + bytes: None, + cancelled: false, + }, + FetchOutcome::Cancelled => FetchedTile { + key, + bytes: None, + cancelled: true, + }, + }; + if resp_tx.send(tile).is_err() { + break; + } + repaint_ctx.request_repaint(); + } + } + } +} + +fn tile_is_visible(visible_tiles: &RwLock>, key: TileKey) -> bool { + visible_tiles + .read() + .unwrap_or_else(|error| error.into_inner()) + .contains(&key) +} + +/// Result of one tile load attempt, distinguishing polite bail-outs from +/// real failures so only the latter enter the retry backoff. +enum FetchOutcome { + Bytes(Vec), + Failed, + Cancelled, + Shutdown, +} + +fn load_or_fetch( + key: TileKey, + disk_cache: &DiskTileCache, + http_agent: &ureq::Agent, + fetch_permits: &FetchPermits, + visible_tiles: &RwLock>, + shutdown: &AtomicBool, +) -> FetchOutcome { + if let Some(bytes) = disk_cache.read(key) { + if valid_tile_bytes(&bytes) { + return FetchOutcome::Bytes(bytes); + } + disk_cache.remove(key); + } + + // Wait for a per-provider network permit. Visibility and shutdown are + // re-checked while waiting so a queued tile that scrolled out of view + // never consumes a connection slot. + while !fetch_permits.try_acquire(key.provider) { + if shutdown.load(Ordering::Acquire) { + return FetchOutcome::Shutdown; + } + if !tile_is_visible(visible_tiles, key) { + return FetchOutcome::Cancelled; + } + thread::sleep(TILE_PERMIT_POLL_INTERVAL); + } + + let provider = provider_for(key.provider); + let url = provider.url(key.z, key.x, key.y); + let bytes = http_get(http_agent, &url); + fetch_permits.release(key.provider); + + let Some(bytes) = bytes else { + return FetchOutcome::Failed; + }; + if !valid_tile_bytes(&bytes) { + return FetchOutcome::Failed; + } + disk_cache.write(key, &bytes); + FetchOutcome::Bytes(bytes) +} + +fn build_http_agent() -> ureq::Agent { + ureq::Agent::config_builder() + .timeout_global(Some(TILE_REQUEST_TIMEOUT)) + .timeout_connect(Some(TILE_CONNECT_TIMEOUT)) + .timeout_recv_body(Some(TILE_REQUEST_TIMEOUT)) + .build() + .into() +} + +fn http_get(agent: &ureq::Agent, url: &str) -> Option> { + let mut response = agent + .get(url) + .header("User-Agent", USER_AGENT) + .call() + .ok()?; + response + .body_mut() + .with_config() + .limit(MAX_TILE_BYTES) + .read_to_vec() + .ok() +} + +fn valid_tile_bytes(bytes: &[u8]) -> bool { + let reader = image::ImageReader::new(std::io::Cursor::new(bytes)); + let Ok(reader) = reader.with_guessed_format() else { + return false; + }; + let dimensions_are_valid = reader + .into_dimensions() + .is_ok_and(|(width, height)| valid_tile_dimensions(width, height)); + dimensions_are_valid && image::load_from_memory(bytes).is_ok() +} + +fn valid_tile_dimensions(width: u32, height: u32) -> bool { + width > 0 + && height > 0 + && width <= MAX_TILE_DIMENSION + && height <= MAX_TILE_DIMENSION + && u64::from(width) * u64::from(height) <= MAX_TILE_PIXELS +} + +/// Decode tile bytes (PNG or JPEG, sniffed by `image`) into an +/// `egui::ColorImage`. When `grayscale` is set, RGB is collapsed to luma +/// using Rec. 601 weights so the texture goes monochrome at upload time. +fn decode_image(bytes: &[u8], grayscale: bool) -> Option { + let mut img = image::load_from_memory(bytes).ok()?.to_rgba8(); + if grayscale { + for px in img.pixels_mut() { + let y = (0.299 * px[0] as f32 + 0.587 * px[1] as f32 + 0.114 * px[2] as f32) + .round() + .clamp(0.0, 255.0) as u8; + px[0] = y; + px[1] = y; + px[2] = y; + } + } + let (w, h) = img.dimensions(); + Some(egui::ColorImage::from_rgba_unmultiplied( + [w as usize, h as usize], + img.as_raw(), + )) +} + +/// Tile cache root: `{data_dir}/UltraLog/tiles/`. +pub fn tiles_cache_root() -> Option { + dirs::data_dir().map(|base| base.join("UltraLog").join(TILES_CACHE_DIR)) +} + +fn tile_disk_path_in(root: &std::path::Path, key: TileKey) -> PathBuf { + let provider = provider_for(key.provider); + root.join(provider.cache_subdir()) + .join(key.z.to_string()) + .join(key.x.to_string()) + .join(format!("{}.png", key.y)) +} + +// ============================================================================ +// Web Mercator math +// ============================================================================ + +/// Convert a lat/lon (degrees) to fractional tile coordinates at zoom `z`. +pub fn lonlat_to_tile_xy(lon_deg: f64, lat_deg: f64, z: u8) -> (f64, f64) { + let n = 2f64.powi(z as i32); + let x = (lon_deg + 180.0) / 360.0 * n; + let lat_rad = lat_deg.clamp(-85.051_128_78, 85.051_128_78).to_radians(); + let y = (1.0 - (lat_rad.tan() + 1.0 / lat_rad.cos()).ln() / std::f64::consts::PI) / 2.0 * n; + (x, y) +} + +/// Convert lat/lon (degrees) to absolute Mercator pixel coordinates at zoom +/// `z`. One tile = 256 px. +pub fn lonlat_to_pixel(lon_deg: f64, lat_deg: f64, z: u8) -> (f64, f64) { + let (tx, ty) = lonlat_to_tile_xy(lon_deg, lat_deg, z); + (tx * 256.0, ty * 256.0) +} + +/// Pick the largest zoom level at which a lon/lat bbox of `(west, east, +/// south, north)` fits within `screen_size` pixels (with a 10% margin). +pub fn fit_zoom( + west: f64, + east: f64, + south: f64, + north: f64, + screen_size: egui::Vec2, + max_zoom: u8, +) -> u8 { + let target_w = (screen_size.x as f64 * 0.9).max(64.0); + let target_h = (screen_size.y as f64 * 0.9).max(64.0); + for z in (1..=max_zoom).rev() { + let (x0, y0) = lonlat_to_pixel(west, north, z); + let (x1, y1) = lonlat_to_pixel(east, south, z); + let w = (x1 - x0).abs(); + let h = (y1 - y0).abs(); + if w <= target_w && h <= target_h { + return z; + } + } + 1 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lonlat_zero_is_origin_at_zoom_0() { + let (x, y) = lonlat_to_tile_xy(0.0, 0.0, 0); + assert!((x - 0.5).abs() < 1e-9); + assert!((y - 0.5).abs() < 1e-9); + } + + #[test] + fn lonlat_round_trip_at_zoom_10() { + let (px, _py) = lonlat_to_pixel(9.0, 45.0, 10); + // At zoom 10 the world is 256 * 2^10 = 262144 pixels wide. lon 9° + // sits at (9 + 180)/360 * 262144 ~= 137625.6. + let expected = ((9.0 + 180.0) / 360.0) * 256.0 * (1u32 << 10) as f64; + assert!((px - expected).abs() < 1e-3); + } + + #[test] + fn mercator_projection_clamps_polar_latitudes() { + let (_, north) = lonlat_to_tile_xy(0.0, 90.0, 10); + let (_, south) = lonlat_to_tile_xy(0.0, -90.0, 10); + assert!(north.is_finite()); + assert!(south.is_finite()); + assert!(north >= -1e-7); + assert!(south <= 1024.0 + 1e-7); + } + + #[test] + fn fit_zoom_picks_higher_for_smaller_bbox() { + let small = fit_zoom(9.000, 9.001, 44.999, 45.000, egui::vec2(400.0, 400.0), 19); + let big = fit_zoom(0.0, 90.0, 0.0, 60.0, egui::vec2(400.0, 400.0), 19); + assert!( + small > big, + "small bbox should fit at higher zoom: {small} vs {big}" + ); + } + + #[test] + fn provider_for_returns_distinct_subdirs() { + let a = provider_for(TileProviderId::EsriWorldImagery).cache_subdir(); + let b = provider_for(TileProviderId::OpenStreetMap).cache_subdir(); + assert_ne!(a, b); + } + + #[test] + fn tile_validation_rejects_non_images() { + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(1, 1) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + assert!(valid_tile_bytes(png.get_ref())); + assert!(!valid_tile_bytes(b"not an image")); + } + + #[test] + fn tile_validation_limits_decoded_pixel_count() { + assert!(valid_tile_dimensions(256, 256)); + assert!(valid_tile_dimensions(1024, 1024)); + assert!(!valid_tile_dimensions(4096, 4096)); + assert!(!valid_tile_dimensions(0, 256)); + } + + #[test] + fn bounded_request_queue_rejects_excess_work() { + let (tx, _rx) = mpsc::sync_channel(TILE_REQUEST_QUEUE_CAPACITY); + let key = TileKey { + provider: TileProviderId::OpenStreetMap, + z: 1, + x: 0, + y: 0, + }; + for _ in 0..TILE_REQUEST_QUEUE_CAPACITY { + tx.try_send(WorkerMsg::Fetch(key)).unwrap(); + } + + assert!(matches!( + tx.try_send(WorkerMsg::Fetch(key)), + Err(TrySendError::Full(_)) + )); + } + + #[test] + fn fetch_permits_enforce_the_osm_connection_limit() { + let permits = FetchPermits::default(); + + assert!(permits.try_acquire(TileProviderId::OpenStreetMap)); + assert!(permits.try_acquire(TileProviderId::OpenStreetMap)); + assert!( + !permits.try_acquire(TileProviderId::OpenStreetMap), + "OSM allows at most 2 simultaneous fetches" + ); + + // A saturated OSM pool must not block other providers. + assert!(permits.try_acquire(TileProviderId::EsriWorldImagery)); + + permits.release(TileProviderId::OpenStreetMap); + assert!(permits.try_acquire(TileProviderId::OpenStreetMap)); + } + + #[test] + fn worker_discards_tile_that_left_the_visible_view() { + let (req_tx, req_rx) = mpsc::sync_channel(1); + let (resp_tx, resp_rx) = mpsc::channel(); + let visible_tiles = Arc::new(RwLock::new(HashSet::new())); + let shutdown = Arc::new(AtomicBool::new(false)); + let key = TileKey { + provider: TileProviderId::OpenStreetMap, + z: 1, + x: 0, + y: 0, + }; + req_tx.try_send(WorkerMsg::Fetch(key)).unwrap(); + + let worker_visible = Arc::clone(&visible_tiles); + let worker_shutdown = Arc::clone(&shutdown); + let worker = thread::spawn(move || { + worker_loop( + Arc::new(Mutex::new(req_rx)), + resp_tx, + Arc::new(DiskTileCache::with_root(None, 0)), + build_http_agent(), + egui::Context::default(), + worker_visible, + worker_shutdown, + Arc::new(FetchPermits::default()), + ); + }); + + let response = resp_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert_eq!(response.key, key); + assert!(response.cancelled); + assert!(response.bytes.is_none()); + + shutdown.store(true, Ordering::Release); + worker.join().unwrap(); + } + + #[test] + fn disk_cache_evicts_oldest_file_to_respect_limit() { + let unique = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "ultralog-tile-cache-test-{}-{unique}", + std::process::id() + )); + let cache = DiskTileCache::with_root(Some(root.clone()), 6); + let first = TileKey { + provider: TileProviderId::OpenStreetMap, + z: 1, + x: 0, + y: 0, + }; + let second = TileKey { x: 1, ..first }; + + cache.write(first, &[1, 2, 3, 4]); + cache.write(second, &[5, 6, 7, 8]); + + let state = cache.state.lock().unwrap(); + assert!(state.total_bytes <= 6); + assert_eq!(state.entries.len(), 1); + assert!(state.entries.contains_key(&cache.path(second).unwrap())); + drop(state); + + let _ = fs::remove_dir_all(root); + } +} diff --git a/src/ui/chart.rs b/src/ui/chart.rs index 4e264e03..387d4c05 100644 --- a/src/ui/chart.rs +++ b/src/ui/chart.rs @@ -42,13 +42,15 @@ impl UltraLogApp { if !self.cursor_tracking { return; } - // Don't react to scroll/pinch happening over other UI (e.g. the - // Settings panel) — `ui.input` is global, so without this guard a - // wheel event over the side panel would still resize the cursor - // tracking window. `min_rect()` is empty before any chart content - // is drawn, so we use `max_rect()` (the full available area of the - // central panel) instead. - if !ui.rect_contains_pointer(ui.max_rect()) { + // Don't react to scroll/pinch happening over other UI (Settings + // panel, right-side data panel with the Track Map). `ui.input` is + // global, so without this guard a wheel event over the data panel + // would still resize the cursor-tracking window even though the + // pointer is nowhere near the chart. `max_rect()` was used here + // historically but it spans the entire central area *including* + // any side panels carved out before us; `available_rect_before_wrap` + // is exactly the chart's slot at this point in the layout pass. + if !ui.rect_contains_pointer(ui.available_rect_before_wrap()) { return; } let Some((min_t, max_t)) = self.get_time_range() else { diff --git a/src/ui/data_panel.rs b/src/ui/data_panel.rs new file mode 100644 index 00000000..12d957ce --- /dev/null +++ b/src/ui/data_panel.rs @@ -0,0 +1,547 @@ +//! Right-side data panel that hosts data widgets (track map and friends). +//! +//! The panel is a thin host: it iterates the static widget registry from +//! [`crate::ui::widgets`], skips widgets that report no data for the active +//! tab, and renders the rest stacked vertically with collapsible headers. +//! +//! Panel visibility uses two conditions: +//! 1. `widget.is_available(app)` reports content for the active tab. +//! 2. `Tab.data_panel_state.visible` records whether the panel is expanded. +//! +//! `UserSettings::hidden_widgets` filters widget bodies, not the panel +//! container. An expanded empty panel remains reachable so a hidden widget +//! can be restored from the header. +//! +//! The rail uses only automatic availability. It remains visible as long as +//! something could be shown, including when every available widget is hidden. +//! +//! Visual design notes: +//! - The header is a single 30 px row: title on the left, action buttons +//! on the right (`+` only when there are hidden widgets to add, then +//! cog, then collapse). Matches the `DataPane` design from the bundled +//! HTML prototype. +//! - All icon buttons are drawn with `Painter` primitives (no glyphs, no +//! `Button` widgets) so they never shift footprint on hover. + +use eframe::egui; + +use crate::app::UltraLogApp; +use crate::ui::widgets::{DataWidget, registered}; + +/// Pixel width of the collapsed-rail column. +const RAIL_WIDTH: f32 = 32.0; +/// Pixel size of every icon button (rail expand, header collapse, header +/// options menu, header add). +const ICON_BTN: f32 = 24.0; +/// Header row height - matches the design's `--hdr-h: 30px`. +const HDR_HEIGHT: f32 = 30.0; +/// Width of the cog/plus popup. Wide enough to fit the longest widget +/// title without truncation in any locale we ship. +const POPUP_WIDTH: f32 = 240.0; + +impl UltraLogApp { + /// True when the panel is expanded and at least one registered widget + /// has data for the active tab. Hidden widgets do not suppress the + /// container because the header is required to restore them. + pub fn data_panel_should_show(&self) -> bool { + let Some(idx) = self.active_tab else { + return false; + }; + self.tabs[idx].data_panel_state.visible && self.data_panel_has_content() + } + + /// True when the panel *could* show something - i.e. any widget is + /// available for the active tab. Used to decide whether to draw the + /// collapsed rail. Deliberately ignores `hidden_widgets` so the user + /// can re-add a hidden widget via the "+" button after expanding the + /// panel. + pub fn data_panel_has_content(&self) -> bool { + if self.active_tab.is_none() { + return false; + } + registered().iter().any(|w| w.is_available(self)) + } + + /// Render a small expand button anchored at the top of a thin column + /// on the right edge. + pub fn render_data_panel_rail(&mut self, ui: &mut egui::Ui) { + let Some(ti) = self.active_tab else { + return; + }; + + egui::Panel::right("ultralog_data_panel_rail") + .exact_size(RAIL_WIDTH) + .resizable(false) + .show_separator_line(false) + .show(ui, |ui| { + ui.add_space(4.0); + ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| { + let label = rust_i18n::t!("data_panel.expand"); + let resp = icon_button(ui, ICON_BTN, &label, draw_panel_show_icon); + if resp.clicked() { + self.tabs[ti].data_panel_state.visible = true; + } + let _ = resp.on_hover_text(label); + }); + }); + } + + /// Render the panel body. Caller is responsible for placing this inside + /// an `egui::SidePanel` (or any other container). + pub fn render_data_panel(&mut self, ui: &mut egui::Ui) { + let Some(ti) = self.active_tab else { return }; + + // Snapshot widget metadata so the popup closures can read titles + // without needing `&mut self` re-borrows. + let widget_meta: Vec<(&'static dyn DataWidget, String, bool, bool)> = registered() + .iter() + .map(|w| { + let title = w.title(self); + let available = w.is_available(self); + let hidden = self.hidden_widgets.contains(w.id()); + (*w, title, available, hidden) + }) + .collect(); + let has_addable = widget_meta.iter().any(|(_, _, av, hi)| *av && *hi); + + // Header row. + let header_actions = self.render_header_row(ui, &widget_meta, has_addable); + let panel_collapsed = header_actions.collapse_panel; + if panel_collapsed { + self.tabs[ti].data_panel_state.visible = false; + for widget in registered() { + widget.cancel_background_work(); + } + } + // `hidden_widgets` is a live preference; eframe::App::save syncs it + // into UserSettings on the auto-save/shutdown cycle. + for id in header_actions.toggle_hidden { + if self.hidden_widgets.contains(id.as_str()) { + self.hidden_widgets.remove(id.as_str()); + } else { + self.hidden_widgets.insert(id.clone()); + if let Some(widget) = registered().iter().find(|widget| widget.id() == id) { + widget.cancel_background_work(); + } + } + } + for id in header_actions.add { + self.hidden_widgets.remove(id.as_str()); + } + if panel_collapsed { + return; + } + + ui.separator(); + + // Visible widget panes. + let visible_widgets: Vec<&'static dyn DataWidget> = widget_meta + .iter() + .filter(|(widget, _, available, _)| { + *available && !self.hidden_widgets.contains(widget.id()) + }) + .map(|(w, _, _, _)| *w) + .collect(); + + if visible_widgets.is_empty() { + ui.centered_and_justified(|ui| { + ui.weak(rust_i18n::t!("data_panel.no_widgets")); + }); + return; + } + + egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + let last = visible_widgets.len().saturating_sub(1); + for (i, widget) in visible_widgets.iter().enumerate() { + render_widget_pane(ui, self, *widget); + if i != last { + ui.add_space(4.0); + } + } + }); + } + + /// Render the header row (title + action buttons + popups) and return + /// the deferred actions the caller should apply with `&mut self`. The + /// popup closures cannot mutate `self` directly because they'd need + /// to re-borrow through the `Ui` they were given. + fn render_header_row( + &mut self, + ui: &mut egui::Ui, + widget_meta: &[(&'static dyn DataWidget, String, bool, bool)], + has_addable: bool, + ) -> HeaderActions { + let mut actions = HeaderActions::default(); + let row_size = egui::vec2(ui.available_width(), HDR_HEIGHT); + ui.allocate_ui_with_layout( + row_size, + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + ui.add_space(8.0); + ui.label(egui::RichText::new(rust_i18n::t!("data_panel.title")).strong()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(4.0); + + // Collapse panel. + let hide_label = rust_i18n::t!("data_panel.hide"); + let collapse = icon_button(ui, ICON_BTN, &hide_label, draw_panel_hide_icon); + if collapse.clicked() { + actions.collapse_panel = true; + } + let _ = collapse.on_hover_text(hide_label); + + // Cog (settings) - always visible. + let cog = icon_button( + ui, + ICON_BTN, + &rust_i18n::t!("data_panel.options"), + draw_cog_icon, + ); + let cog_id = ui.make_persistent_id("data_panel_cog_popup"); + let _ = cog + .clone() + .on_hover_text(rust_i18n::t!("data_panel.options")); + egui::Popup::menu(&cog) + .id(cog_id) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show(|ui| { + ui.set_min_width(POPUP_WIDTH); + ui.label( + egui::RichText::new(rust_i18n::t!("data_panel.section.widgets")) + .small() + .weak(), + ); + for (w, title, available, hidden) in widget_meta { + let mut visible = !*hidden; + let label = if *available { + title.clone() + } else { + format!( + "{} ({})", + title, + rust_i18n::t!("data_panel.no_data_suffix") + ) + }; + if ui.checkbox(&mut visible, label).changed() { + actions.toggle_hidden.push(w.id().to_string()); + } + } + ui.add_space(4.0); + ui.separator(); + ui.label( + egui::RichText::new(rust_i18n::t!("data_panel.section.pane")) + .small() + .weak(), + ); + if ui.button(rust_i18n::t!("data_panel.hide")).clicked() { + actions.collapse_panel = true; + } + }); + + // Plus (add widget) - only when there's something + // to add, matching the design's hybrid mode. + if has_addable { + let plus = icon_button( + ui, + ICON_BTN, + &rust_i18n::t!("data_panel.add_widget"), + draw_plus_icon, + ); + let plus_id = ui.make_persistent_id("data_panel_plus_popup"); + let _ = plus + .clone() + .on_hover_text(rust_i18n::t!("data_panel.add_widget")); + egui::Popup::menu(&plus) + .id(plus_id) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show(|ui| { + ui.set_min_width(POPUP_WIDTH); + let mut any = false; + for (w, title, available, hidden) in widget_meta { + if !*available || !*hidden { + continue; + } + any = true; + if ui.button(title).clicked() { + actions.add.push(w.id().to_string()); + } + } + if !any { + ui.weak(rust_i18n::t!("data_panel.no_widgets_to_add")); + } + }); + } + }); + }, + ); + actions + } +} + +/// Deferred header actions, applied after the popup closure ends so we +/// can mutate `&mut self` without overlapping borrows. +#[derive(Default)] +struct HeaderActions { + collapse_panel: bool, + /// Widget IDs whose hidden flag should be toggled (cog checklist). + toggle_hidden: Vec, + /// Widget IDs to remove from `hidden_widgets` (plus popup). + add: Vec, +} + +/// Allocate a square click target, paint a hover background (no frame +/// at rest, so toggling never shifts siblings), and invoke `draw_icon` +/// to paint a 14x14 icon centered inside. +/// +/// `label` is registered as the button's accessible name (AccessKit / +/// screen readers). `Sense::click()` makes the target keyboard-focusable, +/// so Enter/Space activate it like a regular button. +fn icon_button( + ui: &mut egui::Ui, + size: f32, + label: &str, + draw_icon: fn(&egui::Painter, egui::Rect, egui::Color32), +) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::click()); + resp.widget_info(|| { + egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label) + }); + let visuals = ui.visuals(); + let painter = ui.painter(); + if resp.hovered() { + painter.rect_filled(rect, 4.0, visuals.widgets.hovered.bg_fill); + } + let fg = if resp.hovered() { + visuals.strong_text_color() + } else { + visuals.weak_text_color() + }; + let icon_rect = egui::Rect::from_center_size(rect.center(), egui::vec2(14.0, 14.0)); + draw_icon(painter, icon_rect, fg); + resp +} + +/// Outlined rectangle with a vertical divider near the right edge and a +/// chevron pointing right inside the right strip - "collapse panel to +/// the right". +fn draw_panel_hide_icon(painter: &egui::Painter, rect: egui::Rect, color: egui::Color32) { + let body = egui::Rect::from_center_size(rect.center(), egui::vec2(14.0, 11.0)); + let stroke = egui::Stroke::new(1.2, color); + painter.rect_stroke(body, 1.5, stroke, egui::StrokeKind::Inside); + let div_x = body.left() + body.width() * 0.7; + painter.line_segment( + [ + egui::pos2(div_x, body.top()), + egui::pos2(div_x, body.bottom()), + ], + stroke, + ); + let cy = body.center().y; + let cx = (div_x + body.right()) * 0.5; + painter.line_segment( + [egui::pos2(cx - 1.0, cy - 2.0), egui::pos2(cx + 1.0, cy)], + stroke, + ); + painter.line_segment( + [egui::pos2(cx + 1.0, cy), egui::pos2(cx - 1.0, cy + 2.0)], + stroke, + ); +} + +/// Mirror of [`draw_panel_hide_icon`] for the rail: divider on the left, +/// chevron pointing left. +fn draw_panel_show_icon(painter: &egui::Painter, rect: egui::Rect, color: egui::Color32) { + let body = egui::Rect::from_center_size(rect.center(), egui::vec2(14.0, 11.0)); + let stroke = egui::Stroke::new(1.2, color); + painter.rect_stroke(body, 1.5, stroke, egui::StrokeKind::Inside); + let div_x = body.left() + body.width() * 0.3; + painter.line_segment( + [ + egui::pos2(div_x, body.top()), + egui::pos2(div_x, body.bottom()), + ], + stroke, + ); + let cy = body.center().y; + let cx = (body.left() + div_x) * 0.5; + painter.line_segment( + [egui::pos2(cx + 1.0, cy - 2.0), egui::pos2(cx - 1.0, cy)], + stroke, + ); + painter.line_segment( + [egui::pos2(cx - 1.0, cy), egui::pos2(cx + 1.0, cy + 2.0)], + stroke, + ); +} + +/// Six-spoke gear icon: outer ring, inner hole, six radial spokes. Read +/// as "settings" without depending on glyph availability. +fn draw_cog_icon(painter: &egui::Painter, rect: egui::Rect, color: egui::Color32) { + let center = rect.center(); + let stroke = egui::Stroke::new(1.2, color); + let r_outer = 4.5; + let r_inner = 1.6; + let r_tooth = 6.0; + painter.circle_stroke(center, r_outer, stroke); + painter.circle_stroke(center, r_inner, stroke); + for i in 0..6 { + let a = std::f32::consts::PI / 3.0 * i as f32; + let (s, c) = a.sin_cos(); + let p1 = egui::pos2(center.x + c * r_outer, center.y + s * r_outer); + let p2 = egui::pos2(center.x + c * r_tooth, center.y + s * r_tooth); + painter.line_segment([p1, p2], stroke); + } +} + +/// Plus sign - two perpendicular line segments. +fn draw_plus_icon(painter: &egui::Painter, rect: egui::Rect, color: egui::Color32) { + let center = rect.center(); + let half = 4.0; + let stroke = egui::Stroke::new(1.4, color); + painter.line_segment( + [ + egui::pos2(center.x - half, center.y), + egui::pos2(center.x + half, center.y), + ], + stroke, + ); + painter.line_segment( + [ + egui::pos2(center.x, center.y - half), + egui::pos2(center.x, center.y + half), + ], + stroke, + ); +} + +fn render_widget_pane(ui: &mut egui::Ui, app: &mut UltraLogApp, widget: &'static dyn DataWidget) { + let title = widget.title(app); + let tab_id = app.active_tab.map(|index| app.tabs[index].id); + let id = widget_pane_id(ui, tab_id, widget.id()); + let mut enabled = widget.is_enabled(app); + + let header = egui::CollapsingHeader::new(title) + .id_salt(id) + .default_open(enabled); + let response = header.show(ui, |ui| { + widget.render(ui, app); + }); + let now_open = response.openness > 0.5; + if now_open != enabled { + enabled = now_open; + widget.set_enabled(app, enabled); + } +} + +fn widget_pane_id(ui: &egui::Ui, tab_id: Option, widget_id: &str) -> egui::Id { + ui.make_persistent_id(("data_panel_widget", tab_id, widget_id)) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::parsers::aim::AimChannel; + use crate::parsers::types::Meta; + use crate::parsers::{Channel, EcuType, Log, Value}; + use crate::state::{LoadedFile, Tab}; + + fn app_with_gps_data() -> UltraLogApp { + let log = Log { + meta: Meta::Empty, + channels: vec![ + Channel::Aim(AimChannel { + name: "GPS Latitude".to_string(), + unit: "deg".to_string(), + }), + Channel::Aim(AimChannel { + name: "GPS Longitude".to_string(), + unit: "deg".to_string(), + }), + ], + times: vec![0.0], + data: vec![vec![Value::Float(43.1), Value::Float(131.9)]], + }; + + let mut app = UltraLogApp::default(); + app.files.push(LoadedFile::new( + PathBuf::from("gps.xrk"), + "gps.xrk".to_string(), + EcuType::Aim, + log, + )); + app.tabs.push(Tab::new(0, "gps.xrk".to_string())); + app.active_tab = Some(0); + app + } + + #[test] + fn hidden_available_widget_does_not_block_panel_expansion() { + let mut app = app_with_gps_data(); + app.hidden_widgets.insert("track_map".to_string()); + + assert!(app.data_panel_has_content()); + assert!(app.data_panel_should_show()); + + app.tabs[0].data_panel_state.visible = false; + assert!(!app.data_panel_should_show()); + } + + #[test] + fn widget_pane_ids_are_scoped_to_tabs() { + egui::__run_test_ui(|ui| { + let first = widget_pane_id(ui, Some(1), "track_map"); + let second = widget_pane_id(ui, Some(2), "track_map"); + assert_ne!(first, second); + }); + } + + #[test] + fn gps_channel_lookup_is_cached_and_tracks_the_file() { + let mut app = app_with_gps_data(); + assert!( + app.tabs[0] + .data_panel_state + .track_map + .gps_channels + .get() + .is_none() + ); + + assert!(app.data_panel_has_content()); + let cached = app.tabs[0] + .data_panel_state + .track_map + .gps_channels + .get() + .unwrap(); + assert_eq!(cached.file_index, 0); + assert_eq!(cached.channels, Some((0, 1))); + + app.files.push(LoadedFile::new( + PathBuf::from("without-gps.xrk"), + "without-gps.xrk".to_string(), + EcuType::Aim, + Log { + meta: Meta::Empty, + channels: Vec::new(), + times: Vec::new(), + data: Vec::new(), + }, + )); + app.tabs[0].file_index = 1; + + assert!(!app.data_panel_has_content()); + let cached = app.tabs[0] + .data_panel_state + .track_map + .gps_channels + .get() + .unwrap(); + assert_eq!(cached.file_index, 1); + assert_eq!(cached.channels, None); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 87f49f7a..2b8b9e7b 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -31,11 +31,13 @@ // New activity bar architecture pub mod activity_bar; +pub mod data_panel; pub mod files_panel; pub mod settings_panel; pub mod side_panel; pub mod tool_properties_panel; pub mod tools_panel; +pub mod widgets; // Core UI components pub mod analysis_panel; diff --git a/src/ui/widgets/mod.rs b/src/ui/widgets/mod.rs new file mode 100644 index 00000000..eb0ec758 --- /dev/null +++ b/src/ui/widgets/mod.rs @@ -0,0 +1,55 @@ +//! Data-panel widget registry. +//! +//! A widget is a small unit-struct that implements [`DataWidget`]. Widget +//! instances are static singletons; per-tab state lives in +//! [`crate::state::DataPanelState`] (or its nested per-widget structs) so +//! widgets themselves carry no runtime state. +//! +//! Adding a new widget is a one-file change: +//! 1. Create `src/ui/widgets/.rs` with a unit-struct and +//! `impl DataWidget`. +//! 2. Register it in [`registered`] below. +//! 3. Add per-tab state to `DataPanelState` if needed. + +use eframe::egui; + +use crate::app::UltraLogApp; + +pub mod track_map; + +/// Contract every right-side data-panel widget implements. +/// +/// `&self` (rather than `&mut self`) on every method is intentional: it +/// lets the host iterate over `&'static` widget singletons and pass +/// `&mut UltraLogApp` to each `render` without borrow conflicts. All +/// mutable state lives on the app. +pub trait DataWidget { + /// Stable identifier used for state lookup, telemetry, and i18n keys. + fn id(&self) -> &'static str; + + /// Localized header title for the widget pane. + fn title(&self, app: &UltraLogApp) -> String; + + /// Whether this widget has data to show for the active tab. The host + /// hides the widget header (and the panel itself, if no widget is + /// available) when this returns `false`. + fn is_available(&self, app: &UltraLogApp) -> bool; + + /// Whether the user has enabled this widget on the active tab. When + /// `false`, the widget pane shows only its header (collapsed). + fn is_enabled(&self, app: &UltraLogApp) -> bool; + + /// Toggle the per-tab enabled flag. + fn set_enabled(&self, app: &mut UltraLogApp, enabled: bool); + + /// Cancel background work when the widget or its host panel is hidden. + fn cancel_background_work(&self) {} + + /// Render the widget body. The host has already drawn the header. + fn render(&self, ui: &mut egui::Ui, app: &mut UltraLogApp); +} + +/// Static registry of all data-panel widgets, in display order. +pub fn registered() -> &'static [&'static dyn DataWidget] { + &[&track_map::TrackMapWidget] +} diff --git a/src/ui/widgets/track_map.rs b/src/ui/widgets/track_map.rs new file mode 100644 index 00000000..4f91b891 --- /dev/null +++ b/src/ui/widgets/track_map.rs @@ -0,0 +1,1454 @@ +//! Track Map widget - GPS lat/lon visualization on a 2D map. +//! +//! Owns: +//! - GPS channel detection (lat/lon resolution via OECUA canonical IDs) +//! - Track polyline rendering (no tiles in v1; tile rendering slots in +//! later via the `tiles` module) +//! - Cursor sync: hovering the track scrubs the chart cursor/timeline +//! while playback is stopped; clicking seeks (and stops playback) +//! - Lap dropdown wired to [`crate::laps`] + +use std::f64::consts::PI; +use std::sync::OnceLock; + +use eframe::egui; +use rust_i18n::t; + +use crate::app::UltraLogApp; +use crate::colormap::{Colormap, sample as colormap_sample}; +use crate::laps::{ + GateParams, GpsCoordSpec, detect_laps, longitude_delta_degrees, sanitize_gps_track, +}; +use crate::state::{ActiveTool, ColorSignature, GpsChannelCache, MapView, TrackCache}; + +use super::DataWidget; + +const EARTH_RADIUS_M: f64 = 6_378_137.0; +const TRACK_STROKE_WIDTH: f32 = 2.0; +const FADED_STROKE_WIDTH: f32 = 1.0; +const TRACK_BG: egui::Color32 = egui::Color32::from_rgb(20, 22, 28); +const MAX_RENDER_POINTS: usize = 20_000; +type ColorCacheStatus = (Option<(usize, usize)>, Colormap, Option<(f64, f64)>); +static TILE_SOURCE: OnceLock = OnceLock::new(); + +pub struct TrackMapWidget; + +impl DataWidget for TrackMapWidget { + fn id(&self) -> &'static str { + "track_map" + } + + fn title(&self, _app: &UltraLogApp) -> String { + t!("track_map.title").to_string() + } + + fn is_available(&self, app: &UltraLogApp) -> bool { + // Pure query - background-work cancellation for unavailable states + // is handled centrally by `maintain_tile_source` each frame. + detect_gps_channels(app).is_some() + } + + fn is_enabled(&self, app: &UltraLogApp) -> bool { + app.active_tab + .map(|i| app.tabs[i].data_panel_state.track_map.enabled) + .unwrap_or(false) + } + + fn set_enabled(&self, app: &mut UltraLogApp, enabled: bool) { + if let Some(i) = app.active_tab { + app.tabs[i].data_panel_state.track_map.enabled = enabled; + } + if !enabled { + cancel_tile_requests(); + } + } + + fn cancel_background_work(&self) { + cancel_tile_requests(); + } + + fn render(&self, ui: &mut egui::Ui, app: &mut UltraLogApp) { + let Some((lat_idx, lon_idx)) = detect_gps_channels(app) else { + ui.weak(t!("track_map.no_gps")); + return; + }; + let Some(tab_idx) = app.active_tab else { + return; + }; + let file_idx = app.tabs[tab_idx].file_index; + + ensure_cache(app, tab_idx, file_idx, lat_idx, lon_idx); + + if app.tabs[tab_idx].data_panel_state.track_map.cache.is_none() { + ui.weak(t!("track_map.no_gps")); + return; + } + + render_toolbar(ui, app, tab_idx); + ui.add_space(2.0); + render_canvas(ui, app, tab_idx, file_idx); + } +} + +/// Returns `(lat_channel_index, lon_channel_index)` from the active tab's +/// log if both GPS channels are present. Resolution prefers the OECUA +/// metadata map so any parser whose channel names map to canonical +/// `gps_latitude` / `gps_longitude` IDs is picked up automatically; falls +/// back to a small set of well-known column names so detection still works +/// when the API-refreshed adapter spec drops or renames the GPS entries. +pub fn detect_gps_channels(app: &UltraLogApp) -> Option<(usize, usize)> { + let tab_idx = app.active_tab?; + let file_idx = app.tabs[tab_idx].file_index; + let file = app.files.get(file_idx)?; + let spec_generation = crate::adapters::spec_generation(); + let cache = &app.tabs[tab_idx].data_panel_state.track_map.gps_channels; + if let Some(cached) = cache.get() + && cached.file_index == file_idx + && cached.spec_generation == spec_generation + { + return cached.channels; + } + + let channels = find_gps_channels(&file.log.channels); + cache.set(Some(GpsChannelCache { + file_index: file_idx, + spec_generation, + channels, + })); + channels +} + +fn find_gps_channels(channels: &[crate::parsers::Channel]) -> Option<(usize, usize)> { + let mut lat_idx = None; + let mut lon_idx = None; + for (i, ch) in channels.iter().enumerate() { + let name = ch.name(); + let canonical = + crate::adapters::registry::get_channel_metadata(&name).map(|m| m.canonical_id); + // Exact alias matching stays an independent fallback: a refreshed + // adapter spec that maps a known alias like "Latitude" to some + // other (or renamed) canonical ID must not break GPS detection. + let is_lat = + canonical.as_deref() == Some("gps_latitude") || matches_gps_name(&name, GpsAxis::Lat); + let is_lon = + canonical.as_deref() == Some("gps_longitude") || matches_gps_name(&name, GpsAxis::Lon); + if is_lat && lat_idx.is_none() { + lat_idx = Some(i); + } + if is_lon && lon_idx.is_none() { + lon_idx = Some(i); + } + if lat_idx.is_some() && lon_idx.is_some() { + break; + } + } + Some((lat_idx?, lon_idx?)) +} + +#[derive(Clone, Copy)] +enum GpsAxis { + Lat, + Lon, +} + +/// Heuristic match for a GPS column when the OECUA registry doesn't +/// resolve it. Errs on the side of common motorsport / OBD / GPS-logger +/// column names rather than every conceivable string containing "lat". +fn matches_gps_name(name: &str, axis: GpsAxis) -> bool { + let lc = name.trim().to_lowercase(); + let candidates: &[&str] = match axis { + GpsAxis::Lat => &[ + "gps latitude", + "gps_latitude", + "gps lat", + "gps_lat", + "latitude", + "lat", + ], + GpsAxis::Lon => &[ + "gps longitude", + "gps_longitude", + "gps long", + "gps_long", + "gps lon", + "gps_lon", + "longitude", + "long", + "lon", + ], + }; + candidates.iter().any(|c| lc == *c) +} + +fn ensure_cache( + app: &mut UltraLogApp, + tab_idx: usize, + file_idx: usize, + lat_idx: usize, + lon_idx: usize, +) { + let needs_rebuild = match &app.tabs[tab_idx].data_panel_state.track_map.cache { + Some(c) => c.file_index != file_idx || c.lat_idx != lat_idx || c.lon_idx != lon_idx, + None => true, + }; + if !needs_rebuild { + return; + } + + let mut lat_data = app.get_channel_data(file_idx, lat_idx); + let mut lon_data = app.get_channel_data(file_idx, lon_idx); + let n = lat_data.len().min(lon_data.len()); + if n == 0 { + app.tabs[tab_idx].data_panel_state.track_map.cache = None; + app.tabs[tab_idx].data_panel_state.track_map.laps = Vec::new(); + return; + } + + let times = app.files[file_idx].log.get_times_as_f64(); + // Detect how this log encodes coordinates (NMEA DDM, scaled-integer + // degrees, 0..360 longitude) and normalize to decimal degrees before + // anything downstream touches the data. Identity for valid degrees. + let coord_spec = GpsCoordSpec::detect(&lat_data, &lon_data); + coord_spec.normalize_in_place(&mut lat_data, &mut lon_data); + sanitize_gps_track(&mut lat_data, &mut lon_data, times); + + // Pick a mid-latitude that is finite. Falls back to the first finite + // sample. + let mid_lat_deg = lat_data + .iter() + .copied() + .find(|v| v.is_finite()) + .unwrap_or(0.0); + let mid_lat_rad = mid_lat_deg * PI / 180.0; + let m_per_deg_lat = EARTH_RADIUS_M * PI / 180.0; + let m_per_deg_lon = m_per_deg_lat * mid_lat_rad.cos(); + + // Anchor the projection at the first finite sample so coordinates stay + // small (better f32 precision after the f64 -> f32 cast). + let anchor = lat_data + .iter() + .zip(lon_data.iter()) + .find(|(la, lo)| la.is_finite() && lo.is_finite()) + .map(|(la, lo)| (*la, *lo)) + .unwrap_or((0.0, 0.0)); + + let mut points_m = Vec::with_capacity(n); + let mut bbox_min = egui::vec2(f32::INFINITY, f32::INFINITY); + let mut bbox_max = egui::vec2(f32::NEG_INFINITY, f32::NEG_INFINITY); + let (mut west, mut east) = (f64::INFINITY, f64::NEG_INFINITY); + let (mut south, mut north) = (f64::INFINITY, f64::NEG_INFINITY); + for i in 0..n { + let la = lat_data[i]; + let lo = lon_data[i]; + if la.is_finite() && lo.is_finite() { + let continuous_lon = anchor.1 + longitude_delta_degrees(anchor.1, lo); + let x = ((continuous_lon - anchor.1) * m_per_deg_lon) as f32; + // Y axis points up in our local frame; screen flips it later. + let y = ((la - anchor.0) * m_per_deg_lat) as f32; + let p = egui::vec2(x, y); + points_m.push(p); + bbox_min.x = bbox_min.x.min(p.x); + bbox_min.y = bbox_min.y.min(p.y); + bbox_max.x = bbox_max.x.max(p.x); + bbox_max.y = bbox_max.y.max(p.y); + west = west.min(continuous_lon); + east = east.max(continuous_lon); + south = south.min(la); + north = north.max(la); + } else { + // Push NaN sentinels so the polyline can break at gaps. + points_m.push(egui::vec2(f32::NAN, f32::NAN)); + } + } + + if !bbox_min.x.is_finite() { + // No finite samples - degenerate cache. + app.tabs[tab_idx].data_panel_state.track_map.cache = None; + app.tabs[tab_idx].data_panel_state.track_map.laps = Vec::new(); + return; + } + + let (west_px, north_py) = crate::tiles::lonlat_to_pixel(west, north, 0); + let (east_px, south_py) = crate::tiles::lonlat_to_pixel(east, south, 0); + let mercator_center = ((west_px + east_px) * 0.5, (north_py + south_py) * 0.5); + let mercator_offsets_z0: Vec = lat_data + .iter() + .zip(lon_data.iter()) + .map(|(lat, lon)| { + if lat.is_finite() && lon.is_finite() { + let continuous_lon = anchor.1 + longitude_delta_degrees(anchor.1, *lon); + let (x, y) = crate::tiles::lonlat_to_pixel(continuous_lon, *lat, 0); + egui::vec2( + (x - mercator_center.0) as f32, + (y - mercator_center.1) as f32, + ) + } else { + egui::vec2(f32::NAN, f32::NAN) + } + }) + .collect(); + let (render_indices, render_segments) = build_render_samples(&points_m); + + let cache = TrackCache { + file_index: file_idx, + lat_idx, + lon_idx, + coord_spec, + points_m: points_m.into(), + mercator_offsets_z0: mercator_offsets_z0.into(), + render_indices: render_indices.into(), + render_segments: render_segments.into(), + bbox_min, + bbox_max, + lonlat_bbox: (west, east, south, north), + color_cache: None, + color_signature: None, + color_range: None, + }; + + let laps = detect_laps(&lat_data, &lon_data, times, GateParams::default()); + + // Map look settings persist globally - seed the per-tab state from the + // live app preferences so re-opening a log behaves consistently. + let default_provider = app.tile_provider; + let default_tiles_enabled = app.tiles_enabled; + let default_tile_opacity = app.tile_opacity; + let default_tile_grayscale = app.tile_grayscale; + let st = &mut app.tabs[tab_idx].data_panel_state.track_map; + st.cache = Some(cache); + st.laps = laps; + st.view = Default::default(); + st.color_channel = None; + st.color_min = None; + st.color_max = None; + st.selected_lap = None; + st.tile_provider = default_provider; + st.tiles_enabled = default_tiles_enabled; + st.tile_opacity = default_tile_opacity; + st.tile_grayscale = default_tile_grayscale; +} + +fn build_render_samples(points: &[egui::Vec2]) -> (Vec, Vec>) { + if points.is_empty() { + return (Vec::new(), Vec::new()); + } + + let sample_count = points.len().min(MAX_RENDER_POINTS); + let indices: Vec = if points.len() <= MAX_RENDER_POINTS { + (0..points.len()).collect() + } else { + let last_record = points.len() - 1; + let last_sample = sample_count - 1; + (0..sample_count) + .map(|sample| ((sample as u128 * last_record as u128) / last_sample as u128) as usize) + .collect() + }; + + let mut segments = Vec::with_capacity(indices.len()); + let mut sample_position = 0; + let mut segment_id = 0u32; + let mut in_segment = false; + for (record, point) in points.iter().enumerate() { + let valid = point.x.is_finite() && point.y.is_finite(); + if valid && !in_segment { + segment_id = segment_id.saturating_add(1); + } + in_segment = valid; + + if indices.get(sample_position).copied() == Some(record) { + segments.push(valid.then_some(segment_id)); + sample_position += 1; + if sample_position == indices.len() { + break; + } + } + } + + debug_assert_eq!(indices.len(), segments.len()); + (indices, segments) +} + +fn render_toolbar(ui: &mut egui::Ui, app: &mut UltraLogApp, tab_idx: usize) { + ui.horizontal_wrapped(|ui| { + // Color by selector + let selected_channels = app.tabs[tab_idx].selected_channels.clone(); + let color_label = match app.tabs[tab_idx].data_panel_state.track_map.color_channel { + Some(i) => selected_channels + .get(i) + .map(|c| c.channel.name()) + .unwrap_or_else(|| "–".to_string()), + None => t!("track_map.solid").to_string(), + }; + ui.label(t!("track_map.color_by")); + egui::ComboBox::from_id_salt("track_map_color_by") + .selected_text(color_label) + .show_ui(ui, |ui| { + let st = &mut app.tabs[tab_idx].data_panel_state.track_map; + if ui + .selectable_label(st.color_channel.is_none(), t!("track_map.solid")) + .clicked() + { + st.color_channel = None; + } + for (i, sc) in selected_channels.iter().enumerate() { + if ui + .selectable_label(st.color_channel == Some(i), sc.channel.name()) + .clicked() + { + st.color_channel = Some(i); + st.color_min = None; + st.color_max = None; + } + } + }); + + ui.separator(); + ui.label(t!("track_map.colormap")); + let st = &mut app.tabs[tab_idx].data_panel_state.track_map; + let cmap_label = match st.colormap { + Colormap::Viridis => t!("track_map.colormap.viridis"), + Colormap::Turbo => t!("track_map.colormap.turbo"), + }; + egui::ComboBox::from_id_salt("track_map_colormap") + .selected_text(cmap_label) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut st.colormap, + Colormap::Viridis, + t!("track_map.colormap.viridis"), + ); + ui.selectable_value( + &mut st.colormap, + Colormap::Turbo, + t!("track_map.colormap.turbo"), + ); + }); + + // Color range: editable min/max with a return-to-automatic reset. + // `None` means automatic; a committed drag pins the value. Edits + // are ordered (min <= max) so the color cache never sees an + // inverted range. + if st.color_channel.is_some() { + ui.separator(); + ui.label(t!("track_map.range")); + let (auto_min, auto_max) = st + .cache + .as_ref() + .and_then(|cache| cache.color_range) + .unwrap_or((0.0, 1.0)); + let speed = ((auto_max - auto_min).abs() / 200.0).max(0.01); + let mut min_value = st.color_min.unwrap_or(auto_min); + let mut max_value = st.color_max.unwrap_or(auto_max); + if ui + .add(egui::DragValue::new(&mut min_value).speed(speed)) + .changed() + { + st.color_min = Some(min_value.min(max_value)); + } + if ui + .add(egui::DragValue::new(&mut max_value).speed(speed)) + .changed() + { + st.color_max = Some(max_value.max(min_value)); + } + if (st.color_min.is_some() || st.color_max.is_some()) + && ui.button(t!("track_map.range_auto")).clicked() + { + st.color_min = None; + st.color_max = None; + } + } + + ui.separator(); + let lap_count = st.laps.len(); + if lap_count > 0 { + ui.label(t!("track_map.lap")); + let lap_label = match st.selected_lap { + Some(i) => st + .laps + .get(i) + .map(localized_lap_label) + .unwrap_or_else(|| "–".to_string()), + None => t!("track_map.lap_all").to_string(), + }; + egui::ComboBox::from_id_salt("track_map_lap") + .selected_text(lap_label) + .show_ui(ui, |ui| { + if ui + .selectable_label(st.selected_lap.is_none(), t!("track_map.lap_all")) + .clicked() + { + st.selected_lap = None; + } + for (i, lap) in st.laps.iter().enumerate() { + if ui + .selectable_label(st.selected_lap == Some(i), localized_lap_label(lap)) + .clicked() + { + st.selected_lap = Some(i); + } + } + }); + ui.separator(); + } + + if ui.button(t!("track_map.reset_view")).clicked() { + st.view = Default::default(); + } + + ui.separator(); + // Snapshot the pre-edit toggle so the first-ever enable can show + // the one-time privacy notice below. + let prev_tiles_enabled = st.tiles_enabled; + ui.checkbox(&mut st.tiles_enabled, t!("track_map.tiles.show")); + if st.tiles_enabled { + let provider_label = match st.tile_provider { + crate::state::TileProviderId::EsriWorldImagery => "Esri World Imagery", + crate::state::TileProviderId::OpenStreetMap => "OpenStreetMap", + }; + egui::ComboBox::from_id_salt("track_map_tile_provider") + .selected_text(provider_label) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut st.tile_provider, + crate::state::TileProviderId::EsriWorldImagery, + "Esri World Imagery", + ); + ui.selectable_value( + &mut st.tile_provider, + crate::state::TileProviderId::OpenStreetMap, + "OpenStreetMap", + ); + }); + + ui.separator(); + ui.label(t!("track_map.tiles.opacity")); + ui.add( + egui::Slider::new(&mut st.tile_opacity, 0.1..=1.0) + .show_value(false) + .clamping(egui::SliderClamping::Always), + ); + ui.checkbox(&mut st.tile_grayscale, t!("track_map.tiles.grayscale")); + } + + let new_tiles_enabled = st.tiles_enabled; + let new_tile_provider = st.tile_provider; + let new_tile_grayscale = st.tile_grayscale; + let new_tile_opacity = st.tile_opacity; + + // Mirror the per-tab look settings into the live app preferences. + // Persistence happens on eframe's auto-save/shutdown cycle + // (eframe::App::save), same as every other setting - never a disk + // write on the UI thread here. + app.tiles_enabled = new_tiles_enabled; + app.tile_provider = new_tile_provider; + app.tile_grayscale = new_tile_grayscale; + app.tile_opacity = new_tile_opacity; + + if new_tiles_enabled && !prev_tiles_enabled && !app.tile_privacy_notice_seen { + app.tile_privacy_notice_seen = true; + let provider_label = match new_tile_provider { + crate::state::TileProviderId::EsriWorldImagery => "Esri World Imagery", + crate::state::TileProviderId::OpenStreetMap => "OpenStreetMap", + }; + let notice = + t!("track_map.tiles.privacy_notice", provider = provider_label).to_string(); + app.show_toast(¬ice); + } + if !new_tiles_enabled { + cancel_tile_requests(); + } + }); +} + +fn localized_lap_label(lap: &crate::laps::LapInfo) -> String { + format!( + "{} {} ({})", + t!("track_map.lap"), + lap.index + 1, + crate::laps::fmt_mmssms(lap.duration_s) + ) +} + +fn zoom_around_pointer(view: &mut MapView, pointer_offset: egui::Vec2, requested_factor: f32) { + let old_zoom = view.zoom.clamp(0.1, 50.0); + let new_zoom = (old_zoom * requested_factor).clamp(0.1, 50.0); + let effective_factor = new_zoom / old_zoom; + view.pan_px = pointer_offset - (pointer_offset - view.pan_px) * effective_factor; + view.zoom = new_zoom; +} + +fn layout_tile_attribution( + ui: &egui::Ui, + provider_id: crate::state::TileProviderId, + font_id: egui::FontId, + color: egui::Color32, + max_width: f32, +) -> std::sync::Arc { + let provider = crate::tiles::provider_for(provider_id); + ui.painter().layout( + format!( + "{}{}", + t!("track_map.tiles.attribution_prefix"), + provider.attribution() + ), + font_id, + color, + max_width.max(1.0), + ) +} + +fn render_canvas(ui: &mut egui::Ui, app: &mut UltraLogApp, tab_idx: usize, file_idx: usize) { + let (color_channel, colormap, color_range) = ensure_color_cache(app, tab_idx); + let cache = match app.tabs[tab_idx].data_panel_state.track_map.cache.as_ref() { + Some(c) => c.clone(), + None => return, + }; + + // Claim the entire remaining vertical space as one rect. Footer + // (legend / hint / attribution) is painted into a reserved band + // *inside* this rect via the same `Painter`, so egui never sees any + // extra widgets below the canvas - that means no auto `item_spacing` + // can leak past the container and trigger the parent ScrollArea. + let avail = ui.available_size(); + let tiles_enabled = app.tabs[tab_idx].data_panel_state.track_map.tiles_enabled; + let tile_provider_id = app.tabs[tab_idx].data_panel_state.track_map.tile_provider; + let line_h = ui.text_style_height(&egui::TextStyle::Body); + let weak_color = ui.style().visuals.weak_text_color(); + let body_font = egui::TextStyle::Body.resolve(ui.style()); + let attribution_galley = tiles_enabled.then(|| { + layout_tile_attribution(ui, tile_provider_id, body_font.clone(), weak_color, avail.x) + }); + // Footer band: 4 px gap + legend/hint row + an optional wrapped + // attribution block. Its measured height keeps every line inside the + // reserved area at narrow panel widths. + let attribution_band_h = attribution_galley + .as_ref() + .map_or(0.0, |galley| 4.0 + galley.size().y); + let footer_band_h = 4.0 + line_h + attribution_band_h; + + let canvas_size = egui::vec2(avail.x, avail.y.max(160.0).max(footer_band_h + 80.0)); + let (rect, _) = ui.allocate_exact_size(canvas_size, egui::Sense::hover()); + let painter = ui.painter_at(rect); + painter.rect_filled(rect, 4.0, TRACK_BG); + + let map_rect = egui::Rect::from_min_max( + rect.min, + egui::pos2(rect.right(), rect.bottom() - footer_band_h), + ); + let footer_rect = + egui::Rect::from_min_max(egui::pos2(rect.left(), map_rect.bottom()), rect.max); + let map_painter = painter.with_clip_rect(map_rect); + let response = ui.interact( + map_rect, + ui.make_persistent_id(("track_map_canvas", app.tabs[tab_idx].id)), + egui::Sense::click_and_drag(), + ); + + // Apply pan and zoom before projecting the bounded render set. + if response.dragged() { + let delta = ui.input(|input| input.pointer.delta()); + app.tabs[tab_idx].data_panel_state.track_map.view.pan_px += delta; + } + + let scroll = ui.input(|input| input.smooth_scroll_delta.y); + if response.hovered() + && scroll.abs() > 0.0 + && let Some(pointer) = response.hover_pos() + { + let factor = (1.0_f32 + scroll * 0.0015).clamp(0.5, 2.0); + let st = &mut app.tabs[tab_idx].data_panel_state.track_map; + let before = pointer - map_rect.center(); + zoom_around_pointer(&mut st.view, before, factor); + ui.input_mut(|input| input.smooth_scroll_delta.y = 0.0); + } + + let inner = map_rect.shrink(8.0); + let canvas_center = map_rect.center(); + let view_zoom = app.tabs[tab_idx] + .data_panel_state + .track_map + .view + .zoom + .max(0.05); + let pan = app.tabs[tab_idx].data_panel_state.track_map.view.pan_px; + + let projection = if tiles_enabled { + let provider = crate::tiles::provider_for(tile_provider_id); + let (west, east, south, north) = cache.lonlat_bbox; + // Pick a base zoom from the static track bbox, then promote/demote + // by `view_zoom` so a tile on screen stays close to its native 256px + // even as the user zooms in or out (otherwise tiles either bloat + // and blur, or trip the n_tiles safety cap and blank out). + let base_z = + crate::tiles::fit_zoom(west, east, south, north, inner.size(), provider.max_zoom()); + let zoom_steps = view_zoom.log2().round() as i32; + let z = (base_z as i32 + zoom_steps).clamp(0, provider.max_zoom() as i32) as u8; + // Effective scale at chosen z relative to view_zoom: 1 mercator px at + // base_z = 2^(z - base_z) px at z, so the on-screen scale we apply + // to mercator pixels is view_zoom / 2^(z - base_z). + let z_scale = (z as i32 - base_z as i32) as f32; + let render_scale = view_zoom / 2f32.powf(z_scale); + let (west_px, north_py) = crate::tiles::lonlat_to_pixel(west, north, z); + let (east_px, south_py) = crate::tiles::lonlat_to_pixel(east, south, z); + let center_world = ((west_px + east_px) * 0.5, (north_py + south_py) * 0.5); + + // Draw tiles before polyline so they sit underneath. + let tile_opacity = app.tabs[tab_idx] + .data_panel_state + .track_map + .tile_opacity + .clamp(0.0, 1.0); + let tile_grayscale = app.tabs[tab_idx].data_panel_state.track_map.tile_grayscale; + draw_tiles( + ui, + &map_painter, + map_rect, + tile_provider_id, + z, + center_world, + render_scale, + canvas_center, + pan, + tile_opacity, + tile_grayscale, + app.tile_cache_max_mb, + ); + + TrackProjection::Mercator { + scale: 2f32.powi(base_z as i32) * view_zoom, + } + } else { + let bbox_size = cache.bbox_max - cache.bbox_min; + let bbox_w = bbox_size.x.max(1.0); + let bbox_h = bbox_size.y.max(1.0); + let fit_scale = (inner.width() / bbox_w) + .min(inner.height() / bbox_h) + .max(1e-6); + let scale = fit_scale * view_zoom; + let bbox_center = (cache.bbox_min + cache.bbox_max) * 0.5; + TrackProjection::Local { scale, bbox_center } + }; + + let project_record = |record| projection.project(&cache, record, canvas_center, pan); + let points_screen: Vec = cache + .render_indices + .iter() + .zip(cache.render_segments.iter()) + .map(|(record, segment_id)| ScreenPoint { + record: *record, + position: project_record(*record), + segment_id: *segment_id, + }) + .collect(); + + let (lap_start, lap_end) = match app.tabs[tab_idx].data_panel_state.track_map.selected_lap { + Some(i) => app.tabs[tab_idx] + .data_panel_state + .track_map + .laps + .get(i) + .map(|l| (l.start_record, l.end_record)) + .unwrap_or((0, cache.points_m.len().saturating_sub(1))), + None => (0, cache.points_m.len().saturating_sub(1)), + }; + + let colors_ref = cache.color_cache.as_deref(); + for pair in points_screen.windows(2) { + if pair[0].segment_id.is_none() || pair[0].segment_id != pair[1].segment_id { + continue; + } + let (Some(a), Some(b)) = (pair[0].position, pair[1].position) else { + continue; + }; + let record = pair[1].record; + let in_window = record > lap_start && record <= lap_end; + let color = match colors_ref { + Some(colors) => colors + .get(record.saturating_sub(1)) + .copied() + .unwrap_or(egui::Color32::LIGHT_GRAY), + None => egui::Color32::from_rgb(120, 200, 255), + }; + let (color, width) = if in_window { + (color, TRACK_STROKE_WIDTH) + } else { + (color.gamma_multiply(0.25), FADED_STROKE_WIDTH) + }; + map_painter.line_segment([a, b], egui::Stroke::new(width, color)); + } + + if let Some(record) = app.tabs[tab_idx].cursor_record + && let Some(position) = project_record(record) + { + map_painter.circle_stroke(position, 6.0, egui::Stroke::new(2.0, egui::Color32::WHITE)); + map_painter.circle_filled(position, 4.0, egui::Color32::from_rgb(255, 200, 0)); + } + + if response.clicked() + && let Some(pointer) = response.interact_pointer_pos() + && let Some(record) = nearest_record(&points_screen, pointer, &project_record) + { + let times = app.files[file_idx].log.get_times_as_f64(); + if let Some(time) = times.get(record).copied() { + app.is_playing = false; + app.last_frame_time = None; + app.tabs[tab_idx].cursor_time = Some(time); + app.tabs[tab_idx].cursor_record = Some(record); + ui.ctx().request_repaint(); + } + } + + if let Some(pointer) = response.hover_pos() + && let Some(record) = nearest_record(&points_screen, pointer, &project_record) + { + let times = app.files[file_idx].log.get_times_as_f64(); + if let Some(time) = times.get(record).copied() { + // Hover scrubs the chart cursor/timeline while playback is + // stopped; during playback hover only shows the tooltip so it + // cannot fight the advancing cursor. Click-to-seek (above) + // remains the way to jump while playing. + if !app.is_playing { + app.tabs[tab_idx].cursor_time = Some(time); + app.tabs[tab_idx].cursor_record = Some(record); + } + // Raw channel reads bypass the normalized cache, so convert + // through the detected coordinate encoding for display. + let latitude = app + .get_value_at_record(file_idx, cache.lat_idx, record) + .map(|value| cache.coord_spec.lat_to_degrees(value)) + .unwrap_or(f64::NAN); + let longitude = app + .get_value_at_record(file_idx, cache.lon_idx, record) + .map(|value| cache.coord_spec.lon_to_degrees(value)) + .unwrap_or(f64::NAN); + response.clone().on_hover_text(format!( + "{}: {}\n{}: {:.5}, {:.5}", + t!("track_map.tooltip.time"), + crate::laps::fmt_mmssms(time), + t!("track_map.tooltip.position"), + latitude, + longitude, + )); + } + } + + // --- Legend + tile attribution (painter-based, fits in footer_rect) --- + let row1_y = footer_rect.top() + 4.0 + line_h * 0.5; + let row1_left = footer_rect.left(); + + if color_channel.is_some() { + // Painter-rendered legend: min text + gradient bar + max text on one row. + let bar_w = 180.0; + let bar_h = 12.0; + let (minimum, maximum) = color_range.unwrap_or((0.0, 1.0)); + let min_text = format!("{minimum:.2}"); + let max_text = format!("{maximum:.2}"); + // Measure left label so the bar starts after it. + let min_galley = painter.layout_no_wrap(min_text.clone(), body_font.clone(), weak_color); + painter.text( + egui::pos2(row1_left, row1_y), + egui::Align2::LEFT_CENTER, + &min_text, + body_font.clone(), + weak_color, + ); + let bar_left = row1_left + min_galley.size().x + 6.0; + let bar_top = row1_y - bar_h * 0.5; + let bar_rect = + egui::Rect::from_min_size(egui::pos2(bar_left, bar_top), egui::vec2(bar_w, bar_h)); + let steps = 64; + for i in 0..steps { + let t0 = i as f32 / steps as f32; + let t1 = (i + 1) as f32 / steps as f32; + let x0 = bar_rect.left() + t0 * bar_rect.width(); + let x1 = bar_rect.left() + t1 * bar_rect.width(); + let color = colormap_sample(colormap, (t0 + t1) * 0.5); + painter.rect_filled( + egui::Rect::from_min_max( + egui::pos2(x0, bar_rect.top()), + egui::pos2(x1, bar_rect.bottom()), + ), + 0.0, + color, + ); + } + painter.text( + egui::pos2(bar_rect.right() + 6.0, row1_y), + egui::Align2::LEFT_CENTER, + &max_text, + body_font.clone(), + weak_color, + ); + } else { + painter.text( + egui::pos2(row1_left, row1_y), + egui::Align2::LEFT_CENTER, + t!("track_map.legend.solid_hint"), + body_font.clone(), + weak_color, + ); + } + + if let Some(attribution_galley) = attribution_galley { + let row2_top = footer_rect.top() + 4.0 + line_h + 4.0; + painter.galley( + egui::pos2(footer_rect.left(), row2_top), + attribution_galley, + weak_color, + ); + } +} + +fn ensure_color_cache(app: &mut UltraLogApp, tab_idx: usize) -> ColorCacheStatus { + let state = &app.tabs[tab_idx].data_panel_state.track_map; + let colormap = state.colormap; + let requested_min = state.color_min; + let requested_max = state.color_max; + let channel = state.color_channel.and_then(|slot| { + app.tabs[tab_idx] + .selected_channels + .get(slot) + .map(|selected| (selected.file_index, selected.channel_index)) + }); + let (data_address, data_length) = channel.map_or((0, 0), |(file_index, channel_index)| { + let values = app.get_channel_data_ref(file_index, channel_index); + (values.as_ptr() as usize, values.len()) + }); + let signature = ColorSignature { + channel, + data_address, + data_length, + colormap, + requested_min, + requested_max, + }; + + let needs_rebuild = app.tabs[tab_idx] + .data_panel_state + .track_map + .cache + .as_ref() + .is_some_and(|cache| cache.color_signature != Some(signature)); + + if needs_rebuild { + let record_count = app.tabs[tab_idx] + .data_panel_state + .track_map + .cache + .as_ref() + .map_or(0, |cache| cache.points_m.len()); + let rebuilt = channel.and_then(|(file_index, channel_index)| { + let values = app.get_channel_data_ref(file_index, channel_index); + let (mut automatic_min, mut automatic_max) = (f64::INFINITY, f64::NEG_INFINITY); + for value in values + .iter() + .take(record_count) + .filter(|value| value.is_finite()) + { + automatic_min = automatic_min.min(*value); + automatic_max = automatic_max.max(*value); + } + if !automatic_min.is_finite() || !automatic_max.is_finite() { + return None; + } + + let minimum = requested_min.unwrap_or(automatic_min); + let maximum = requested_max.unwrap_or(automatic_max); + let (minimum, maximum) = if maximum > minimum { + (minimum, maximum) + } else { + (minimum - 0.5, maximum + 0.5) + }; + let colors = build_color_cache(record_count, colormap, minimum, maximum, values); + let colors: std::sync::Arc<[egui::Color32]> = colors.into(); + Some((colors, (minimum, maximum))) + }); + + if let Some(cache) = app.tabs[tab_idx].data_panel_state.track_map.cache.as_mut() { + cache.color_cache = rebuilt.as_ref().map(|(colors, _)| colors.clone()); + cache.color_range = rebuilt.map(|(_, range)| range); + cache.color_signature = Some(signature); + } + } + + let color_range = app.tabs[tab_idx] + .data_panel_state + .track_map + .cache + .as_ref() + .and_then(|cache| cache.color_range); + (channel, colormap, color_range) +} + +fn build_color_cache( + record_count: usize, + colormap: Colormap, + minimum: f64, + maximum: f64, + values: &[f64], +) -> Vec { + let span = (maximum - minimum).max(f64::EPSILON); + (0..record_count.saturating_sub(1)) + .map(|index| { + let value = values.get(index + 1).copied().unwrap_or(f64::NAN); + if value.is_finite() { + let position = ((value - minimum) / span).clamp(0.0, 1.0) as f32; + colormap_sample(colormap, position) + } else { + egui::Color32::DARK_GRAY + } + }) + .collect() +} + +#[derive(Clone, Copy)] +enum TrackProjection { + Local { scale: f32, bbox_center: egui::Vec2 }, + Mercator { scale: f32 }, +} + +impl TrackProjection { + fn project( + self, + cache: &TrackCache, + record: usize, + canvas_center: egui::Pos2, + pan: egui::Vec2, + ) -> Option { + let offset = match self { + Self::Local { scale, bbox_center } => { + let point = *cache.points_m.get(record)?; + if !point.x.is_finite() { + return None; + } + egui::vec2( + (point.x - bbox_center.x) * scale, + -(point.y - bbox_center.y) * scale, + ) + } + Self::Mercator { scale } => { + let point = *cache.mercator_offsets_z0.get(record)?; + if !point.x.is_finite() { + return None; + } + point * scale + } + }; + Some(canvas_center + pan + offset) + } +} + +#[derive(Clone, Copy)] +struct ScreenPoint { + record: usize, + position: Option, + segment_id: Option, +} + +fn nearest_record( + points_screen: &[ScreenPoint], + pointer: egui::Pos2, + project_record: &impl Fn(usize) -> Option, +) -> Option { + let mut best_sample = None; + let mut best_d2 = f32::INFINITY; + for (sample_index, point) in points_screen.iter().enumerate() { + if let Some(position) = point.position { + let dx = position.x - pointer.x; + let dy = position.y - pointer.y; + let d2 = dx * dx + dy * dy; + if d2 < best_d2 { + best_d2 = d2; + best_sample = Some(sample_index); + } + } + } + + let sample_index = best_sample?; + let lower = sample_index + .checked_sub(1) + .and_then(|index| points_screen.get(index)) + .map_or(points_screen[sample_index].record, |point| point.record); + let upper = points_screen + .get(sample_index + 1) + .map_or(points_screen[sample_index].record, |point| point.record); + let mut best_record = Some(points_screen[sample_index].record); + for record in lower..=upper { + if let Some(position) = project_record(record) { + let dx = position.x - pointer.x; + let dy = position.y - pointer.y; + let d2 = dx * dx + dy * dy; + if d2 < best_d2 { + best_d2 = d2; + best_record = Some(record); + } + } + } + + if best_d2 <= 32.0 * 32.0 { + best_record + } else { + None + } +} + +/// Single global tile source. Lazy-initialized on first access. +fn tile_source(ctx: &egui::Context, disk_cache_max_mb: u32) -> &'static crate::tiles::TileSource { + TILE_SOURCE.get_or_init(|| crate::tiles::TileSource::new(ctx.clone(), disk_cache_max_mb)) +} + +fn cancel_tile_requests() { + if let Some(source) = TILE_SOURCE.get() { + source.cancel_pending(); + } +} + +/// Keep the shared tile source responsive even when the map widget is not +/// rendered. Workers request a repaint when a fetch completes, so polling from +/// the application loop prevents completed responses from accumulating while +/// another tool, a collapsed panel, or a hidden widget is active. +pub(crate) fn maintain_tile_source(ctx: &egui::Context, app: &UltraLogApp) { + let Some(source) = TILE_SOURCE.get() else { + return; + }; + + if !tile_rendering_is_active(app) { + source.cancel_pending(); + } + source.poll(ctx); +} + +fn tile_rendering_is_active(app: &UltraLogApp) -> bool { + let Some(tab_idx) = app.active_tab else { + return false; + }; + let Some(tab) = app.tabs.get(tab_idx) else { + return false; + }; + + app.active_tool == ActiveTool::LogViewer + && tab.data_panel_state.visible + && tab.data_panel_state.track_map.enabled + && tab.data_panel_state.track_map.tiles_enabled + && tab.data_panel_state.track_map.cache.is_some() + && !app.hidden_widgets.contains("track_map") + && detect_gps_channels(app).is_some() +} + +/// Paint Web Mercator tiles covering `rect`. The screen -> world mapping is +/// the same as the polyline projection: a Mercator pixel `p` maps to +/// `canvas_center + (p - center_world) * view_zoom + pan`. +/// +/// `opacity` is applied as an alpha tint so the polyline stays at full +/// strength on top. `grayscale` selects the desaturated texture variant +/// from the tile cache. +#[allow(clippy::too_many_arguments)] +fn draw_tiles( + ui: &egui::Ui, + painter: &egui::Painter, + rect: egui::Rect, + provider_id: crate::state::TileProviderId, + z: u8, + center_world: (f64, f64), + view_zoom: f32, + canvas_center: egui::Pos2, + pan: egui::Vec2, + opacity: f32, + grayscale: bool, + disk_cache_max_mb: u32, +) { + let src = tile_source(ui.ctx(), disk_cache_max_mb); + + let alpha = (opacity.clamp(0.0, 1.0) * 255.0).round() as u8; + let tint = egui::Color32::from_white_alpha(alpha); + + // Inverse: world_px = canvas_center + (world - center_world) * view_zoom + pan. + let view_scale = f64::from(view_zoom); + let to_world = |screen: egui::Pos2| -> (f64, f64) { + ( + center_world.0 + f64::from(screen.x - canvas_center.x - pan.x) / view_scale, + center_world.1 + f64::from(screen.y - canvas_center.y - pan.y) / view_scale, + ) + }; + + let world_tl = to_world(rect.min); + let world_br = to_world(rect.max); + + let tile_size_world = 256.0_f64; + let tile_x_min = (world_tl.0 / tile_size_world).floor() as i64; + let tile_x_max = (world_br.0 / tile_size_world).floor() as i64; + let tile_y_min = (world_tl.1 / tile_size_world).floor() as i64; + let tile_y_max = (world_br.1 / tile_size_world).floor() as i64; + + let max_tiles = 256; // safety cap so a degenerate zoom can't melt the GPU + let n_tiles = ((tile_x_max - tile_x_min + 1) * (tile_y_max - tile_y_min + 1)).max(0); + if n_tiles > max_tiles { + src.cancel_pending(); + return; + } + + let tile_center_x = (tile_x_min + tile_x_max) as f64 * 0.5; + let tile_center_y = (tile_y_min + tile_y_max) as f64 * 0.5; + let mut tiles = Vec::with_capacity(n_tiles as usize); + let world_dim = (1u64 << z as u64) as i64; + for ty in tile_y_min..=tile_y_max { + for tx in tile_x_min..=tile_x_max { + if ty < 0 || ty >= world_dim { + continue; + } + let wx = tx as f64 * tile_size_world; + let wy = ty as f64 * tile_size_world; + let s_min = egui::pos2( + canvas_center.x + ((wx - center_world.0) * view_scale) as f32 + pan.x, + canvas_center.y + ((wy - center_world.1) * view_scale) as f32 + pan.y, + ); + let s_max = egui::pos2( + s_min.x + (tile_size_world * view_scale) as f32, + s_min.y + (tile_size_world * view_scale) as f32, + ); + let dst = egui::Rect::from_min_max(s_min, s_max); + + let key = crate::tiles::TileKey { + provider: provider_id, + z, + x: tx.rem_euclid(world_dim) as u32, + y: ty as u32, + }; + let center_distance = + (tx as f64 - tile_center_x).powi(2) + (ty as f64 - tile_center_y).powi(2); + tiles.push((center_distance, key, dst)); + } + } + + tiles.sort_by(|left, right| left.0.total_cmp(&right.0)); + let visible_keys: Vec<_> = tiles.iter().map(|(_, key, _)| *key).collect(); + src.set_visible_tiles(&visible_keys); + src.poll(ui.ctx()); + + for (_, key, dst) in tiles { + if let Some(tex) = src.request(ui.ctx(), key, grayscale) { + painter.image( + tex.id(), + dst, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + tint, + ); + } else { + // Loading placeholder: subtle outline so the user knows tiles + // are arriving. + painter.rect_stroke( + dst, + 0.0, + egui::Stroke::new(1.0, egui::Color32::from_rgb(40, 50, 60)), + egui::StrokeKind::Inside, + ); + } + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::parsers::aim::AimChannel; + use crate::parsers::types::Meta; + use crate::parsers::{Channel, EcuType, Log, Value}; + use crate::state::{LoadedFile, Tab, TileProviderId}; + + fn app_with_active_tile_map() -> UltraLogApp { + let log = Log { + meta: Meta::Empty, + channels: vec![ + Channel::Aim(AimChannel { + name: "GPS Latitude".to_string(), + unit: "deg".to_string(), + }), + Channel::Aim(AimChannel { + name: "GPS Longitude".to_string(), + unit: "deg".to_string(), + }), + ], + times: vec![0.0], + data: vec![vec![Value::Float(43.1), Value::Float(131.9)]], + }; + + let mut app = UltraLogApp::default(); + app.files.push(LoadedFile::new( + PathBuf::from("gps.xrk"), + "gps.xrk".to_string(), + EcuType::Aim, + log, + )); + app.tabs.push(Tab::new(0, "gps.xrk".to_string())); + app.active_tab = Some(0); + ensure_cache(&mut app, 0, 0, 0, 1); + app.tabs[0].data_panel_state.track_map.tiles_enabled = true; + app + } + + #[test] + fn ensure_cache_normalizes_nmea_ddm_tracks_to_degrees() { + // 48° 07.038' N, 11° 31.324' E in NMEA DDMM.mmmm packing. + let log = Log { + meta: Meta::Empty, + channels: vec![ + Channel::Aim(AimChannel { + name: "GPS Latitude".to_string(), + unit: "deg".to_string(), + }), + Channel::Aim(AimChannel { + name: "GPS Longitude".to_string(), + unit: "deg".to_string(), + }), + ], + times: vec![0.0, 1.0], + data: vec![ + vec![Value::Float(4807.038), Value::Float(1131.324)], + vec![Value::Float(4807.040), Value::Float(1131.326)], + ], + }; + + let mut app = UltraLogApp::default(); + app.files.push(LoadedFile::new( + PathBuf::from("ddm.csv"), + "ddm.csv".to_string(), + EcuType::Aim, + log, + )); + app.tabs.push(Tab::new(0, "ddm.csv".to_string())); + app.active_tab = Some(0); + ensure_cache(&mut app, 0, 0, 0, 1); + + let cache = app.tabs[0] + .data_panel_state + .track_map + .cache + .as_ref() + .expect("DDM track should build a cache"); + assert_eq!( + cache.coord_spec.format, + crate::laps::GpsCoordFormat::DegreesDecimalMinutes + ); + let (west, east, south, north) = cache.lonlat_bbox; + assert!((south - 48.1173).abs() < 1e-3, "south was {south}"); + assert!((north - 48.1173).abs() < 1e-3, "north was {north}"); + assert!((west - 11.5221).abs() < 1e-3, "west was {west}"); + assert!((east - 11.5221).abs() < 1e-3, "east was {east}"); + // The raw-value converters used by the hover tooltip agree. + assert!((cache.coord_spec.lat_to_degrees(4807.038) - 48.1173).abs() < 1e-4); + assert!((cache.coord_spec.lon_to_degrees(1131.324) - 11.5221).abs() < 1e-4); + } + + #[test] + fn tile_rendering_activity_tracks_every_visibility_gate() { + let mut app = app_with_active_tile_map(); + assert!(tile_rendering_is_active(&app)); + + app.active_tool = ActiveTool::ScatterPlot; + assert!(!tile_rendering_is_active(&app)); + app.active_tool = ActiveTool::LogViewer; + + app.tabs[0].data_panel_state.visible = false; + assert!(!tile_rendering_is_active(&app)); + app.tabs[0].data_panel_state.visible = true; + + app.tabs[0].data_panel_state.track_map.enabled = false; + assert!(!tile_rendering_is_active(&app)); + app.tabs[0].data_panel_state.track_map.enabled = true; + + app.hidden_widgets.insert("track_map".to_string()); + assert!(!tile_rendering_is_active(&app)); + app.hidden_widgets.remove("track_map"); + + app.tabs[0].data_panel_state.track_map.cache = None; + assert!(!tile_rendering_is_active(&app)); + } + + #[test] + fn tile_attribution_wraps_to_narrow_footer() { + let ctx = egui::Context::default(); + let mut row_count = 0; + let mut laid_out_width = f32::INFINITY; + let output = ctx.run_ui(egui::RawInput::default(), |ui| { + let max_width = 180.0; + let galley = layout_tile_attribution( + ui, + TileProviderId::EsriWorldImagery, + egui::TextStyle::Body.resolve(ui.style()), + egui::Color32::WHITE, + max_width, + ); + row_count = galley.rows.len(); + laid_out_width = galley.size().x; + }); + output.drop_without_applying_deltas(); + + assert!(row_count > 1); + assert!(laid_out_width <= 181.0); + } + + #[test] + fn render_indices_bound_large_tracks() { + let points = vec![egui::vec2(1.0, 1.0); 100_000]; + let (indices, segments) = build_render_samples(&points); + assert!(indices.len() <= MAX_RENDER_POINTS); + assert_eq!(indices.len(), segments.len()); + assert_eq!(indices.first(), Some(&0)); + assert_eq!(indices.last(), Some(&(points.len() - 1))); + } + + #[test] + fn render_samples_preserve_gap_segments() { + let mut points = vec![egui::vec2(1.0, 1.0); 100_000]; + points[50_000] = egui::vec2(f32::NAN, f32::NAN); + let (indices, segments) = build_render_samples(&points); + let before = indices + .iter() + .enumerate() + .rfind(|(_, record)| **record < 50_000) + .and_then(|(index, _)| segments[index]); + let after = indices + .iter() + .enumerate() + .find(|(_, record)| **record > 50_000) + .and_then(|(index, _)| segments[index]); + assert!(before.is_some()); + assert!(after.is_some()); + assert_ne!(before, after); + } + + #[test] + fn render_samples_remain_bounded_with_frequent_gaps() { + let points: Vec = (0..100_000) + .map(|index| { + if index % 2 == 0 { + egui::vec2(1.0, 1.0) + } else { + egui::vec2(f32::NAN, f32::NAN) + } + }) + .collect(); + let (indices, segments) = build_render_samples(&points); + assert!(indices.len() <= MAX_RENDER_POINTS); + assert_eq!(indices.len(), segments.len()); + } + + #[test] + fn zoom_keeps_world_point_under_pointer_after_pan() { + let mut view = MapView { + pan_px: egui::vec2(40.0, -20.0), + zoom: 2.0, + }; + let pointer_offset = egui::vec2(15.0, 30.0); + let world_point = (pointer_offset - view.pan_px) / view.zoom; + + zoom_around_pointer(&mut view, pointer_offset, 1.5); + + let projected = view.pan_px + world_point * view.zoom; + assert!((projected.x - pointer_offset.x).abs() < 1e-5); + assert!((projected.y - pointer_offset.y).abs() < 1e-5); + assert_eq!(view.zoom, 3.0); + } +} diff --git a/tests/parsers/romraider_tests.rs b/tests/parsers/romraider_tests.rs index f857d889..7cb3dd81 100644 --- a/tests/parsers/romraider_tests.rs +++ b/tests/parsers/romraider_tests.rs @@ -123,12 +123,9 @@ fn test_romraider_unit_extraction() { let parser = RomRaider; let log = parser.parse(sample).expect("Should parse"); - // Check that units were extracted - for channel in &log.channels { - let unit = channel.unit(); - // Units should be inferred or extracted - assert!(!unit.is_empty() || unit == "C" || unit == "V" || true); - } + assert_eq!(log.channels.len(), 2); + assert_eq!(log.channels[0].unit(), "C"); + assert_eq!(log.channels[1].unit(), "V"); } #[test] diff --git a/tests/parsers/speeduino_tests.rs b/tests/parsers/speeduino_tests.rs index 86e79f1d..d763f668 100644 --- a/tests/parsers/speeduino_tests.rs +++ b/tests/parsers/speeduino_tests.rs @@ -414,6 +414,54 @@ fn test_speeduino_find_channel_index() { assert_eq!(not_found, None); } +#[test] +fn test_megasquirt_gps_log_supports_hex_bitfield_and_consistent_offsets() { + let file_path = "exampleLogs/megasquirt/2026-04-12_12.49.36_gps.mlg"; + if !example_file_exists(file_path) { + eprintln!("Skipping test: {} not found", file_path); + return; + } + + let data = read_example_binary(file_path); + // Validate the header before indexing so a truncated or v1 fixture + // fails with a clear assertion instead of an out-of-bounds panic. + // The v2 offsets used below: magic (6) + version (2) + timestamp (4) + // + info_data_start (4) + data_begin (4) + record_len (2) + field + // count (2) = 24-byte header, 89-byte field descriptors. + assert!( + data.len() >= 24, + "MLG header truncated: {} bytes", + data.len() + ); + assert_eq!(&data[0..5], b"MLVLG", "not an MLVLG file"); + let format_version = i16::from_be_bytes([data[6], data[7]]); + assert_eq!(format_version, 2, "fixture must be MLG format v2"); + + let info_data_start = u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize; + let field_count = u16::from_be_bytes([data[22], data[23]]) as usize; + let fields_end = 24 + field_count * 89; + assert!(data.len() >= fields_end, "field table truncated"); + assert!(info_data_start >= fields_end); + + // The fixture must actually contain a hex bitfield type ID, otherwise + // this test would pass without exercising the 0x10..=0x12 acceptance + // its name claims to cover. + let field_types: Vec = (0..field_count) + .map(|index| data[24 + index * 89]) + .collect(); + assert!( + field_types + .iter() + .any(|field_type| (0x10..=0x12).contains(field_type)), + "fixture contains no hex bitfield field types; present: {field_types:?}" + ); + + let log = Speeduino::parse_binary(&data).expect("Should parse MegaSquirt GPS MLG"); + assert!(log.find_channel_index("GPS Latitude").is_some()); + assert!(log.find_channel_index("GPS Longitude").is_some()); + assert_eq!(log.times.len(), log.data.len()); +} + // ============================================ // Value Range Tests // ============================================