heatmap_parse/
lib.rs

1use wasm_bindgen::prelude::*;
2use gpx::read;
3use std::io::Cursor;
4use serde::Serialize;
5use std::collections::HashMap;
6
7// Define the main data structures
8#[derive(Serialize)]
9pub struct HeatmapTrack {
10    coordinates: Vec<[f64; 2]>,
11    frequency: u32,
12}
13
14#[derive(Serialize)]
15pub struct HeatmapResult {
16    tracks: Vec<HeatmapTrack>,
17    max_frequency: u32,
18}
19
20// Add a console log function for debugging
21#[wasm_bindgen]
22extern "C" {
23    #[wasm_bindgen(js_namespace = console)]
24    fn log(s: &str);
25}
26
27// Function to decode Google polyline format
28pub fn decode_polyline(encoded: &str) -> Vec<[f64; 2]> {
29    let mut coords = Vec::new();
30    let mut lat = 0i32;
31    let mut lng = 0i32;
32    let mut index = 0;
33    let bytes = encoded.as_bytes();
34    
35    while index < bytes.len() {
36        // Decode latitude
37        let mut shift = 0;
38        let mut result = 0i32;
39        loop {
40            if index >= bytes.len() {
41                break;
42            }
43            let b = bytes[index] as i32 - 63;
44            index += 1;
45            result |= (b & 0x1f) << shift;
46            shift += 5;
47            if b < 0x20 {
48                break;
49            }
50        }
51        let dlat = if (result & 1) != 0 { !(result >> 1) } else { result >> 1 };
52        lat += dlat;
53        
54        // Decode longitude
55        shift = 0;
56        result = 0;
57        loop {
58            if index >= bytes.len() {
59                break;
60            }
61            let b = bytes[index] as i32 - 63;
62            index += 1;
63            result |= (b & 0x1f) << shift;
64            shift += 5;
65            if b < 0x20 {
66                break;
67            }
68        }
69        let dlng = if (result & 1) != 0 { !(result >> 1) } else { result >> 1 };
70        lng += dlng;
71        
72        // Convert to lat/lng and add to coordinates
73        let lat_f64 = lat as f64 * 1e-5;
74        let lng_f64 = lng as f64 * 1e-5;
75        
76        if is_valid_coordinate(lat_f64, lng_f64) {
77            coords.push([lat_f64, lng_f64]);
78        }
79    }
80    
81    coords
82}
83
84// Wasm-bindgen export for polyline decoding
85#[wasm_bindgen]
86pub fn decode_polyline_string(encoded: &str) -> JsValue {
87    let coords = decode_polyline(encoded);
88    serde_wasm_bindgen::to_value(&coords).unwrap()
89}
90
91// Process polyline strings - handles both encoded polylines and JSON coordinate arrays
92fn process_polyline(polyline_str: &str) -> Vec<[f64; 2]> {
93    // First try to parse as JSON (RideWithGPS format)
94    if let Ok(json_coords) = serde_json::from_str::<Vec<[f64; 2]>>(polyline_str) {
95        // It's a JSON array of coordinates
96        return if !json_coords.is_empty() {
97            filter_unrealistic_jumps(&json_coords)
98        } else {
99            Vec::new()
100        };
101    }
102    
103    // If JSON parsing fails, treat as encoded polyline (Strava format)
104    let coords = decode_polyline(polyline_str);
105    if !coords.is_empty() {
106        filter_unrealistic_jumps(&coords)
107    } else {
108        Vec::new()
109    }
110}
111
112// Add a function to process polylines from strings
113#[wasm_bindgen]
114pub fn process_polylines(polylines: js_sys::Array) -> JsValue {
115    let mut all_tracks: Vec<Vec<[f64; 2]>> = Vec::new();
116
117    // Process each polyline string
118    for i in 0..polylines.length() {
119        if let Some(polyline_str) = polylines.get(i).as_string() {
120            let coords = process_polyline(&polyline_str);
121            if coords.len() > 1 {
122                let simplified = simplify_track(&coords, 0.00005);
123                if simplified.len() > 1 {
124                    all_tracks.push(simplified);
125                }
126            }
127        }
128    }
129
130    // Apply the same processing logic as GPX files
131    let result = create_heatmap_from_tracks(all_tracks);
132    
133    serde_wasm_bindgen::to_value(&result).unwrap_or(JsValue::NULL)
134}
135
136// Helper function to create heatmap from coordinate arrays
137fn create_heatmap_from_tracks(all_tracks: Vec<Vec<[f64; 2]>>) -> HeatmapResult {
138    // Create a segment usage map to count overlapping segments
139    let mut segment_usage: HashMap<String, u32> = HashMap::new();
140    
141    // Break each track into segments and count usage
142    for track in &all_tracks {
143        for window in track.windows(2) {
144            if let [start, end] = window {
145                let segment_key = create_segment_key(*start, *end);
146                *segment_usage.entry(segment_key).or_insert(0) += 1;
147            }
148        }
149    }
150    
151    // Calculate frequency for each track based on its segments
152    let mut heatmap_tracks = Vec::new();
153    
154    for track in all_tracks {
155        if track.len() < 2 {
156            continue;
157        }
158        
159        // Calculate track frequency as the average frequency of its segments
160        let mut total_usage = 0;
161        let mut segment_count = 0;
162        
163        for window in track.windows(2) {
164            if let [start, end] = window {
165                let segment_key = create_segment_key(*start, *end);
166                if let Some(&usage) = segment_usage.get(&segment_key) {
167                    total_usage += usage;
168                    segment_count += 1;
169                }
170            }
171        }
172        
173        // Use average usage, with minimum of 1
174        let track_frequency = if segment_count > 0 {
175            (total_usage as f64 / segment_count as f64).round() as u32
176        } else {
177            1
178        };
179        
180        heatmap_tracks.push(HeatmapTrack {
181            coordinates: track,
182            frequency: track_frequency,
183        });
184    }
185    
186    // Find the maximum frequency for normalization
187    let max_frequency = heatmap_tracks.iter()
188        .map(|track| track.frequency)
189        .max()
190        .unwrap_or(1);
191    
192    HeatmapResult {
193        tracks: heatmap_tracks,
194        max_frequency,
195    }
196}
197
198fn round(value: f64) -> f64 {
199    (value * 100000.0).round() / 100000.0
200}
201
202#[wasm_bindgen]
203pub fn process_gpx_files(files: js_sys::Array) -> JsValue {
204    let mut all_tracks: Vec<Vec<[f64; 2]>> = Vec::new();
205    
206    // Parse all GPX and FIT files and extract tracks
207    for file_bytes in files.iter() {
208        let array = js_sys::Uint8Array::new(&file_bytes);
209        let bytes = array.to_vec();
210
211        // Try to parse as GPX first
212        if let Ok(gpx) = read(Cursor::new(&bytes)) {
213            for track in gpx.tracks {
214                for segment in track.segments {
215                    let mut track_coords = Vec::new();
216                    
217                    for point in segment.points {
218                        let lat = round(point.point().y());
219                        let lon = round(point.point().x());
220                        
221                        // Validate coordinates to prevent globe-spanning lines
222                        if is_valid_coordinate(lat, lon) {
223                            track_coords.push([lat, lon]);
224                        }
225                    }
226                    
227                    if track_coords.len() > 1 {
228                        // Filter out tracks with unrealistic jumps
229                        let filtered_coords = filter_unrealistic_jumps(&track_coords);
230                        
231                        if filtered_coords.len() > 1 {
232                            // Less aggressive simplification to preserve track shape
233                            let simplified = simplify_track(&filtered_coords, 0.00005);
234                            if simplified.len() > 1 {
235                                all_tracks.push(simplified);
236                            }
237                        }
238                    }
239                }
240            }
241        }
242        // Try to parse as FIT file if GPX parsing fails
243        else if is_fit_file(&bytes) {
244            // Custom FIT file parser for extracting GPS coordinates
245            let mut fit_parser = FitParser::new(bytes);
246            let fit_coordinates = fit_parser.parse_gps_coordinates();
247            
248            // Apply the same validation and filtering as GPX
249            if fit_coordinates.len() > 1 {
250                let filtered_coords = filter_unrealistic_jumps(&fit_coordinates);
251                
252                if filtered_coords.len() > 1 {
253                    let simplified = simplify_track(&filtered_coords, 0.00005);
254                    if simplified.len() > 1 {
255                        all_tracks.push(simplified);
256                    }
257                }
258            }
259        }
260        // Skip files that aren't GPX or FIT
261        else {
262            continue;
263        }
264    }
265    
266    // Create a segment usage map to count overlapping segments
267    let mut segment_usage: HashMap<String, u32> = HashMap::new();
268    
269    // Break each track into segments and count usage
270    for track in &all_tracks {
271        for window in track.windows(2) {
272            if let [start, end] = window {
273                let segment_key = create_segment_key(*start, *end);
274                *segment_usage.entry(segment_key).or_insert(0) += 1;
275            }
276        }
277    }
278    
279    // Calculate frequency for each track based on its segments
280    let mut heatmap_tracks = Vec::new();
281    let mut max_frequency = 0;
282    
283    for track in all_tracks {
284        if track.len() < 2 {
285            continue;
286        }
287        
288        // Calculate track frequency as the average frequency of its segments
289        let mut total_usage = 0;
290        let mut segment_count = 0;
291        
292        for window in track.windows(2) {
293            if let [start, end] = window {
294                let segment_key = create_segment_key(*start, *end);
295                if let Some(&usage) = segment_usage.get(&segment_key) {
296                    total_usage += usage;
297                    segment_count += 1;
298                }
299            }
300        }
301        
302        // Use average usage, with minimum of 1
303        let track_frequency = if segment_count > 0 {
304            (total_usage as f64 / segment_count as f64).round() as u32
305        } else {
306            1
307        };
308        
309        max_frequency = max_frequency.max(track_frequency);
310        
311        heatmap_tracks.push(HeatmapTrack {
312            coordinates: track,
313            frequency: track_frequency,
314        });
315    }
316    
317    let result = HeatmapResult {
318        tracks: heatmap_tracks,
319        max_frequency,
320    };
321    
322    serde_wasm_bindgen::to_value(&result).unwrap()
323}
324
325fn create_segment_key(start: [f64; 2], end: [f64; 2]) -> String {
326    // Use a larger tolerance for less aggressive matching
327    let tolerance = 0.001; // About 100 meters
328    let snap_start = snap_to_grid(start, tolerance);
329    let snap_end = snap_to_grid(end, tolerance);
330    
331    // Normalize direction (smaller coordinate first)
332    let (p1, p2) = if (snap_start[0], snap_start[1]) < (snap_end[0], snap_end[1]) {
333        (snap_start, snap_end)
334    } else {
335        (snap_end, snap_start)
336    };
337    
338    format!("{:.4},{:.4}-{:.4},{:.4}", p1[0], p1[1], p2[0], p2[1])
339}
340
341fn snap_to_grid(point: [f64; 2], tolerance: f64) -> [f64; 2] {
342    [
343        (point[0] / tolerance).round() * tolerance,
344        (point[1] / tolerance).round() * tolerance,
345    ]
346}
347
348fn simplify_track(points: &[[f64; 2]], tolerance: f64) -> Vec<[f64; 2]> {
349    if points.len() <= 2 {
350        return points.to_vec();
351    }
352    
353    let mut result = vec![points[0]];
354    let mut last_added = 0;
355    
356    for i in 1..points.len() {
357        let distance = distance(points[last_added], points[i]);
358        
359        // Add point if it's far enough from the last added point
360        // or if it's the last point in the track
361        if distance > tolerance || i == points.len() - 1 {
362            result.push(points[i]);
363            last_added = i;
364        }
365    }
366    
367    result
368}
369
370fn distance(p1: [f64; 2], p2: [f64; 2]) -> f64 {
371    let dx = p1[0] - p2[0];
372    let dy = p1[1] - p2[1];
373    (dx * dx + dy * dy).sqrt()
374}
375
376fn is_valid_coordinate(lat: f64, lon: f64) -> bool {
377    // Check for valid latitude and longitude ranges
378    if lat < -90.0 || lat > 90.0 || lon < -180.0 || lon > 180.0 {
379        return false;
380    }
381    
382    // Check for obviously invalid coordinates (0, 0) and other common invalid values
383    if (lat == 0.0 && lon == 0.0) || lat.is_nan() || lon.is_nan() || lat.is_infinite() || lon.is_infinite() {
384        return false;
385    }
386    
387    true
388}
389
390fn filter_unrealistic_jumps(coords: &[[f64; 2]]) -> Vec<[f64; 2]> {
391    if coords.len() <= 1 {
392        return coords.to_vec();
393    }
394    
395    let mut filtered = vec![coords[0]];
396    let max_jump_km = 100.0; // Back to 100km for stricter filtering
397    let mut consecutive_bad_points = 0;
398    const MAX_CONSECUTIVE_BAD: usize = 10; // Allow up to 10 consecutive bad points
399    
400    for i in 1..coords.len() {
401        let prev = filtered.last().unwrap();
402        let curr = coords[i];
403        
404        // Calculate approximate distance in kilometers using Haversine formula
405        let distance_km = haversine_distance(prev[0], prev[1], curr[0], curr[1]);
406        
407        // Only add point if it's within reasonable distance from previous point
408        if distance_km <= max_jump_km {
409            filtered.push(curr);
410            consecutive_bad_points = 0; // Reset bad point counter
411        } else {
412            consecutive_bad_points += 1;
413            
414            // If we've seen too many consecutive bad points, try to find good data ahead
415            if consecutive_bad_points <= MAX_CONSECUTIVE_BAD {
416                // Look ahead up to 20 points to see if we can find a reasonable continuation
417                let mut found_good_continuation = false;
418                for j in (i + 1)..(i + 21).min(coords.len()) {
419                    let future_point = coords[j];
420                    let future_distance = haversine_distance(prev[0], prev[1], future_point[0], future_point[1]);
421                    
422                    // If we find a reasonable point ahead, it suggests this is just a GPS glitch
423                    if future_distance <= max_jump_km * 1.5 { // Allow 1.5x distance for bridging
424                        found_good_continuation = true;
425                        break;
426                    }
427                }
428                
429                // If no good continuation found, we might be at the end of good data
430                if !found_good_continuation {
431                    // Try to find any remaining good segments by continuing to filter the rest
432                    for k in (i + 1)..coords.len() {
433                        let remaining_point = coords[k];
434                        let remaining_distance = haversine_distance(prev[0], prev[1], remaining_point[0], remaining_point[1]);
435                        
436                        // If we find a reasonable point, start a new segment from there
437                        if remaining_distance <= max_jump_km {
438                            filtered.push(remaining_point);
439                            // Continue filtering from this new point
440                            for m in (k + 1)..coords.len() {
441                                let next_prev = filtered.last().unwrap();
442                                let next_curr = coords[m];
443                                let next_distance = haversine_distance(next_prev[0], next_prev[1], next_curr[0], next_curr[1]);
444                                
445                                if next_distance <= max_jump_km {
446                                    filtered.push(next_curr);
447                                }
448                                // Skip points that are too far, but don't break - keep looking
449                            }
450                            break; // We've processed the rest of the array
451                        }
452                    }
453                    break; // Exit the main loop as we've processed everything
454                }
455            } else {
456                // Too many consecutive bad points - stop processing to avoid bad data
457                break;
458            }
459            // If there is a good continuation, just skip this point and continue
460        }
461    }
462    
463    filtered
464}
465
466fn haversine_distance(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
467    let r = 6371.0; // Earth's radius in kilometers
468    let d_lat = (lat2 - lat1).to_radians();
469    let d_lon = (lon2 - lon1).to_radians();
470    let lat1_rad = lat1.to_radians();
471    let lat2_rad = lat2.to_radians();
472    
473    let a = (d_lat / 2.0).sin().powi(2) + lat1_rad.cos() * lat2_rad.cos() * (d_lon / 2.0).sin().powi(2);
474    let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
475    
476    r * c
477}
478
479// Custom FIT file parser for extracting GPS coordinates
480// FIT file format reference: https://developer.garmin.com/fit/protocol/
481
482struct FitParser {
483    data: Vec<u8>,
484    pos: usize,
485    message_definitions: HashMap<u8, MessageDefinition>,
486}
487
488#[derive(Clone)]
489struct MessageDefinition {
490    global_message_number: u16,
491    fields: Vec<FieldDefinition>,
492}
493
494#[derive(Clone)]
495struct FieldDefinition {
496    field_def_num: u8,
497    size: u8,
498    _base_type: u8,
499}
500
501impl FitParser {
502    fn new(data: Vec<u8>) -> Self {
503        Self { 
504            data, 
505            pos: 0,
506            message_definitions: HashMap::new(),
507        }
508    }
509
510    fn read_u8(&mut self) -> Option<u8> {
511        if self.pos < self.data.len() {
512            let val = self.data[self.pos];
513            self.pos += 1;
514            Some(val)
515        } else {
516            None
517        }
518    }
519
520    fn read_u16_le(&mut self) -> Option<u16> {
521        if self.pos + 1 < self.data.len() {
522            let val = u16::from_le_bytes([self.data[self.pos], self.data[self.pos + 1]]);
523            self.pos += 2;
524            Some(val)
525        } else {
526            None
527        }
528    }
529
530    fn read_u32_le(&mut self) -> Option<u32> {
531        if self.pos + 3 < self.data.len() {
532            let val = u32::from_le_bytes([
533                self.data[self.pos],
534                self.data[self.pos + 1],
535                self.data[self.pos + 2],
536                self.data[self.pos + 3],
537            ]);
538            self.pos += 4;
539            Some(val)
540        } else {
541            None
542        }
543    }
544
545    fn read_i32_le(&mut self) -> Option<i32> {
546        if self.pos + 3 < self.data.len() {
547            let val = i32::from_le_bytes([
548                self.data[self.pos],
549                self.data[self.pos + 1],
550                self.data[self.pos + 2],
551                self.data[self.pos + 3],
552            ]);
553            self.pos += 4;
554            Some(val)
555        } else {
556            None
557        }
558    }
559
560    fn skip(&mut self, bytes: usize) {
561        self.pos = (self.pos + bytes).min(self.data.len());
562    }
563
564    fn parse_gps_coordinates(&mut self) -> Vec<[f64; 2]> {
565        let mut coordinates = Vec::new();
566
567        // Check FIT file header
568        if self.data.len() < 14 {
569            return coordinates;
570        }
571
572        // FIT file header (14 bytes)
573        let header_size = self.read_u8().unwrap_or(0);
574        if header_size < 12 {
575            return coordinates;
576        }
577
578        let _protocol_version = self.read_u8().unwrap_or(0);
579        let _profile_version = self.read_u16_le().unwrap_or(0);
580        let data_size = self.read_u32_le().unwrap_or(0);
581        
582        // Check for ".FIT" signature
583        let signature = [
584            self.read_u8().unwrap_or(0),
585            self.read_u8().unwrap_or(0),
586            self.read_u8().unwrap_or(0),
587            self.read_u8().unwrap_or(0),
588        ];
589        if signature != [b'.', b'F', b'I', b'T'] {
590            return coordinates;
591        }
592
593        // Skip header CRC if present
594        if header_size == 14 {
595            self.skip(2);
596        }
597
598        // Calculate data end position, but also consider that some FIT files 
599        // might have the data_size field incorrect, so we'll try to parse until
600        // we reach the actual end of the file (minus CRC bytes)
601        let header_data_end = (self.pos + data_size as usize).min(self.data.len());
602        let file_data_end = self.data.len().saturating_sub(2); // Leave 2 bytes for CRC at end
603        let data_end = header_data_end.max(file_data_end); // Use the larger of the two
604        
605        let mut consecutive_errors = 0;
606        const MAX_CONSECUTIVE_ERRORS: usize = 100; // Allow more errors before giving up
607        let mut processed_bytes = 0;
608        let mut last_progress_pos = self.pos;
609
610        // Parse data records - continue until we reach the end or hit too many errors
611        while self.pos < data_end && self.pos < self.data.len() && self.pos + 1 < self.data.len() {
612            let start_pos = self.pos;
613            
614            // Every 10,000 bytes, check if we're making progress
615            if self.pos - last_progress_pos > 10000 {
616                processed_bytes += self.pos - last_progress_pos;
617                last_progress_pos = self.pos;
618                
619                // If we've processed a lot of data and found some coordinates, we're probably doing well
620                if coordinates.len() > 100 && processed_bytes > 50000 {
621                    consecutive_errors = 0; // Reset error count as we're clearly making progress
622                }
623            }
624            
625            // Ensure we have at least 1 byte to read
626            if self.pos >= self.data.len() {
627                break;
628            }
629            
630            let record_header = match self.read_u8() {
631                Some(header) => header,
632                None => break, // End of data
633            };
634
635            let is_definition = (record_header & 0x40) != 0;
636            let local_message_type = record_header & 0x0F;
637
638            let parse_success = if is_definition {
639                // Parse definition message
640                match self.parse_definition_message() {
641                    Some(definition) => {
642                        self.message_definitions.insert(local_message_type, definition);
643                        true
644                    }
645                    None => {
646                        // Definition parsing failed, skip ahead a bit
647                        false
648                    }
649                }
650            } else {
651                // Parse data message
652                if let Some(definition) = self.message_definitions.get(&local_message_type).cloned() {
653                    // Verify we have enough bytes for this message
654                    let total_size: usize = definition.fields.iter().map(|f| f.size as usize).sum();
655                    if self.pos + total_size > self.data.len() {
656                        // Not enough bytes left, try to parse what we can or skip this message
657                        if total_size < 1000 { // Only try if it's a reasonable size
658                            self.skip(self.data.len() - self.pos); // Skip to end
659                        }
660                        break;
661                    }
662                    
663                    // Look for GPS data in multiple message types
664                    match definition.global_message_number {
665                        20 => {
666                            // Record message (primary GPS data)
667                            if let Some(coord) = self.parse_record_message(&definition) {
668                                if is_valid_coordinate(coord[0], coord[1]) {
669                                    coordinates.push(coord);
670                                }
671                            }
672                            true
673                        }
674                        19 => {
675                            // Lap message (might contain GPS data)
676                            if let Some(coord) = self.parse_flexible_gps_message(&definition) {
677                                if is_valid_coordinate(coord[0], coord[1]) {
678                                    coordinates.push(coord);
679                                }
680                            }
681                            true
682                        }
683                        18 => {
684                            // Session message (might contain GPS data)
685                            if let Some(coord) = self.parse_flexible_gps_message(&definition) {
686                                if is_valid_coordinate(coord[0], coord[1]) {
687                                    coordinates.push(coord);
688                                }
689                            }
690                            true
691                        }
692                        _ => {
693                            // Skip other message types but don't count as error
694                            let total_size: usize = definition.fields.iter().map(|f| f.size as usize).sum();
695                            if total_size < 1000 && self.pos + total_size <= self.data.len() {
696                                self.skip(total_size);
697                            } else {
698                                // Skip to end if message is too large or would overflow
699                                self.skip(self.data.len() - self.pos);
700                                break;
701                            }
702                            true
703                        }
704                    }
705                } else {
706                    // Unknown message type - this might be an error, but try to continue
707                    false
708                }
709            };
710
711            if parse_success {
712                consecutive_errors = 0; // Reset error counter on success
713            } else {
714                consecutive_errors += 1;
715                
716                // If we can't parse this message, try to advance by a small amount and continue
717                if self.pos == start_pos {
718                    // We didn't advance at all, force advancement to prevent infinite loop
719                    self.skip(1);
720                }
721                
722                // Only give up if we hit way too many consecutive errors AND we haven't found much data
723                if consecutive_errors >= MAX_CONSECUTIVE_ERRORS {
724                    // If we have a decent amount of coordinates, maybe this is just the end of useful data
725                    if coordinates.len() < 100 {
726                        break; // Give up if we don't have much data
727                    } else {
728                        // We have good data, try to continue a bit more
729                        consecutive_errors = MAX_CONSECUTIVE_ERRORS / 2; // Reset to half
730                    }
731                }
732            }
733        }
734
735        coordinates
736    }
737
738    fn parse_definition_message(&mut self) -> Option<MessageDefinition> {
739        let _start_pos = self.pos;
740        
741        // Check we have enough bytes for the basic structure
742        if self.pos + 5 > self.data.len() {
743            return None;
744        }
745        
746        self.skip(1); // reserved byte
747        self.skip(1); // architecture
748        let global_message_number = self.read_u16_le()?;
749        let num_fields = self.read_u8()?;
750
751        // Sanity check on number of fields
752        if num_fields > 100 {
753            // This seems unreasonable, likely a parsing error
754            return None;
755        }
756
757        // Check we have enough bytes for all field definitions
758        if self.pos + (num_fields as usize * 3) > self.data.len() {
759            return None;
760        }
761
762        let mut fields = Vec::new();
763        for _ in 0..num_fields {
764            // Check bounds before each field
765            if self.pos + 3 > self.data.len() {
766                // Not enough bytes for this field definition
767                return None;
768            }
769            
770            let field_def_num = self.read_u8()?;
771            let size = self.read_u8()?;
772            let base_type = self.read_u8()?;
773            
774            // Sanity check on field size
775            if size > 100 {
776                // Field size seems unreasonable, likely a parsing error
777                return None;
778            }
779            
780            fields.push(FieldDefinition {
781                field_def_num,
782                size,
783                _base_type: base_type,
784            });
785        }
786
787        Some(MessageDefinition {
788            global_message_number,
789            fields,
790        })
791    }
792
793    fn parse_record_message(&mut self, definition: &MessageDefinition) -> Option<[f64; 2]> {
794        let mut lat: Option<f64> = None;
795        let mut lon: Option<f64> = None;
796
797        for field in &definition.fields {
798            // More defensive bounds checking
799            if field.size == 0 || self.pos >= self.data.len() || self.pos + field.size as usize > self.data.len() {
800                // Skip this field if we can't read it safely
801                let safe_skip = (self.data.len() - self.pos).min(field.size as usize);
802                self.skip(safe_skip);
803                continue;
804            }
805            
806            match field.field_def_num {
807                0 => {
808                    // Latitude field
809                    if field.size == 4 {
810                        if let Some(lat_raw) = self.read_i32_le() {
811                            if lat_raw != 0x7FFFFFFF && lat_raw != 0 {
812                                let lat_degrees = lat_raw as f64 * (180.0 / 2147483648.0);
813                                if lat_degrees.abs() <= 90.0 {
814                                    lat = Some(lat_degrees);
815                                }
816                            }
817                        }
818                    } else {
819                        self.skip(field.size as usize);
820                    }
821                }
822                1 => {
823                    // Longitude field
824                    if field.size == 4 {
825                        if let Some(lon_raw) = self.read_i32_le() {
826                            if lon_raw != 0x7FFFFFFF && lon_raw != 0 {
827                                let lon_degrees = lon_raw as f64 * (180.0 / 2147483648.0);
828                                if lon_degrees.abs() <= 180.0 {
829                                    lon = Some(lon_degrees);
830                                }
831                            }
832                        }
833                    } else {
834                        self.skip(field.size as usize);
835                    }
836                }
837                _ => {
838                    // Skip other fields
839                    self.skip(field.size as usize);
840                }
841            }
842        }
843
844        if let (Some(lat_val), Some(lon_val)) = (lat, lon) {
845            Some([round(lat_val), round(lon_val)])
846        } else {
847            None
848        }
849    }
850
851    fn parse_flexible_gps_message(&mut self, definition: &MessageDefinition) -> Option<[f64; 2]> {
852        let mut lat: Option<f64> = None;
853        let mut lon: Option<f64> = None;
854        let mut potential_coords = Vec::new();
855
856        // Collect all potential coordinate values
857        for field in &definition.fields {
858            // More defensive bounds checking
859            if field.size == 0 || self.pos >= self.data.len() || self.pos + field.size as usize > self.data.len() {
860                // Skip this field if we can't read it safely
861                let safe_skip = (self.data.len() - self.pos).min(field.size as usize);
862                self.skip(safe_skip);
863                continue;
864            }
865            
866            if field.size == 4 {
867                if let Some(value) = self.read_i32_le() {
868                    if value != 0x7FFFFFFF && value != 0 {
869                        let degrees = value as f64 * (180.0 / 2147483648.0);
870                        // Only consider reasonable coordinate values
871                        if degrees.abs() <= 180.0 {
872                            potential_coords.push(degrees);
873                        }
874                    }
875                }
876            } else {
877                self.skip(field.size as usize);
878            }
879        }
880
881        // Try to identify lat/lon from potential coordinates
882        for coord in &potential_coords {
883            if coord.abs() <= 90.0 && lat.is_none() {
884                lat = Some(*coord);
885            } else if coord.abs() <= 180.0 && lon.is_none() && Some(*coord) != lat {
886                lon = Some(*coord);
887            }
888        }
889
890        if let (Some(lat_val), Some(lon_val)) = (lat, lon) {
891            Some([round(lat_val), round(lon_val)])
892        } else {
893            None
894        }
895    }
896}
897
898fn is_fit_file(data: &[u8]) -> bool {
899    if data.len() < 12 {
900        return false;
901    }
902    
903    // Check for FIT signature at bytes 8-11
904    data[8] == b'.' && data[9] == b'F' && data[10] == b'I' && data[11] == b'T'
905}