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