Process activities without GPS better
This commit is contained in:
parent
4fe283f246
commit
4b166c0637
10 changed files with 623 additions and 50 deletions
|
|
@ -50,6 +50,7 @@ public class ActivityDTO {
|
||||||
// Map rendering data
|
// Map rendering data
|
||||||
private Map<String, Object> simplifiedTrack; // GeoJSON LineString
|
private Map<String, Object> simplifiedTrack; // GeoJSON LineString
|
||||||
private List<Map<String, Object>> trackPoints; // Full track points from JSONB
|
private List<Map<String, Object>> trackPoints; // Full track points from JSONB
|
||||||
|
private Boolean hasGpsTrack; // True if activity has GPS data (outdoor), false for indoor activities
|
||||||
|
|
||||||
// Social interaction counts (populated separately)
|
// Social interaction counts (populated separately)
|
||||||
private Long likesCount;
|
private Long likesCount;
|
||||||
|
|
@ -115,7 +116,10 @@ public class ActivityDTO {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert simplified track to GeoJSON
|
// Convert simplified track to GeoJSON
|
||||||
if (activity.getSimplifiedTrack() != null) {
|
boolean hasGps = activity.getSimplifiedTrack() != null;
|
||||||
|
builder.hasGpsTrack(hasGps);
|
||||||
|
|
||||||
|
if (hasGps) {
|
||||||
builder.simplifiedTrack(lineStringToGeoJson(activity.getSimplifiedTrack()));
|
builder.simplifiedTrack(lineStringToGeoJson(activity.getSimplifiedTrack()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,9 @@ public class TimelineActivityDTO {
|
||||||
private Long commentsCount;
|
private Long commentsCount;
|
||||||
private Boolean likedByCurrentUser;
|
private Boolean likedByCurrentUser;
|
||||||
|
|
||||||
|
// GPS track availability
|
||||||
|
private Boolean hasGpsTrack; // True if activity has GPS data
|
||||||
|
|
||||||
// Metrics summary
|
// Metrics summary
|
||||||
private ActivityMetricsSummary metrics;
|
private ActivityMetricsSummary metrics;
|
||||||
|
|
||||||
|
|
@ -77,6 +80,7 @@ public class TimelineActivityDTO {
|
||||||
.displayName(displayName)
|
.displayName(displayName)
|
||||||
.avatarUrl(avatarUrl)
|
.avatarUrl(avatarUrl)
|
||||||
.isLocal(true)
|
.isLocal(true)
|
||||||
|
.hasGpsTrack(activity.getSimplifiedTrack() != null)
|
||||||
.metrics(activity.getMetrics() != null ? ActivityMetricsSummary.fromMetrics(activity.getMetrics()) : null)
|
.metrics(activity.getMetrics() != null ? ActivityMetricsSummary.fromMetrics(activity.getMetrics()) : null)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
@ -116,6 +120,7 @@ public class TimelineActivityDTO {
|
||||||
.isLocal(false)
|
.isLocal(false)
|
||||||
.activityUri(remote.getActivityUri())
|
.activityUri(remote.getActivityUri())
|
||||||
.mapImageUrl(remote.getMapImageUrl())
|
.mapImageUrl(remote.getMapImageUrl())
|
||||||
|
.hasGpsTrack(remote.getMapImageUrl() != null) // Remote activity has GPS if it has a map image
|
||||||
.metrics(metrics)
|
.metrics(metrics)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -265,12 +265,19 @@ public class ActivityFileService {
|
||||||
String trackPointsJson = convertTrackPointsToJson(parsedData.getTrackPoints());
|
String trackPointsJson = convertTrackPointsToJson(parsedData.getTrackPoints());
|
||||||
activity.setTrackPointsJson(trackPointsJson);
|
activity.setTrackPointsJson(trackPointsJson);
|
||||||
|
|
||||||
// Create full LineString from all points
|
// Create and simplify track only if GPS data is present
|
||||||
LineString fullTrack = createLineStringFromTrackPoints(parsedData.getTrackPoints());
|
if (!parsedData.getTrackPoints().isEmpty()) {
|
||||||
|
// Create full LineString from all points
|
||||||
|
LineString fullTrack = createLineStringFromTrackPoints(parsedData.getTrackPoints());
|
||||||
|
|
||||||
// Simplify track for map rendering
|
// Simplify track for map rendering
|
||||||
LineString simplifiedTrack = trackSimplifier.simplify(fullTrack.getCoordinates());
|
LineString simplifiedTrack = trackSimplifier.simplify(fullTrack.getCoordinates());
|
||||||
activity.setSimplifiedTrack(simplifiedTrack);
|
activity.setSimplifiedTrack(simplifiedTrack);
|
||||||
|
} else {
|
||||||
|
// No GPS track for indoor activities
|
||||||
|
activity.setSimplifiedTrack(null);
|
||||||
|
log.info("Activity has no GPS track (indoor activity)");
|
||||||
|
}
|
||||||
|
|
||||||
// Create metrics
|
// Create metrics
|
||||||
if (parsedData.getMetrics() != null) {
|
if (parsedData.getMetrics() != null) {
|
||||||
|
|
@ -282,11 +289,17 @@ public class ActivityFileService {
|
||||||
// Save activity (single INSERT instead of 855!)
|
// Save activity (single INSERT instead of 855!)
|
||||||
Activity savedActivity = activityRepository.save(activity);
|
Activity savedActivity = activityRepository.save(activity);
|
||||||
|
|
||||||
log.info("Successfully created {} activity {} with {} track points (simplified to {} for map)",
|
if (savedActivity.getSimplifiedTrack() != null) {
|
||||||
parsedData.getSourceFormat(),
|
log.info("Successfully created {} activity {} with {} track points (simplified to {} for map)",
|
||||||
savedActivity.getId(),
|
parsedData.getSourceFormat(),
|
||||||
parsedData.getTrackPoints().size(),
|
savedActivity.getId(),
|
||||||
simplifiedTrack.getNumPoints());
|
parsedData.getTrackPoints().size(),
|
||||||
|
savedActivity.getSimplifiedTrack().getNumPoints());
|
||||||
|
} else {
|
||||||
|
log.info("Successfully created {} activity {} (indoor activity without GPS track)",
|
||||||
|
parsedData.getSourceFormat(),
|
||||||
|
savedActivity.getId());
|
||||||
|
}
|
||||||
|
|
||||||
// Execute side effects based on processing options
|
// Execute side effects based on processing options
|
||||||
// In batch import mode, these are skipped and executed later as a batch
|
// In batch import mode, these are skipped and executed later as a batch
|
||||||
|
|
|
||||||
|
|
@ -59,13 +59,14 @@ public class ActivityImageService {
|
||||||
|
|
||||||
// Calculate bounds once for both map tiles and track rendering
|
// Calculate bounds once for both map tiles and track rendering
|
||||||
TrackBounds trackBounds = null;
|
TrackBounds trackBounds = null;
|
||||||
|
boolean isIndoorActivity = activity.getSimplifiedTrack() == null;
|
||||||
|
|
||||||
// Render background - either OSM tiles or gradient background
|
// Render background - either OSM tiles or gradient background
|
||||||
if (activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) {
|
if (activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) {
|
||||||
trackBounds = calculateTrackBounds(activity);
|
trackBounds = calculateTrackBounds(activity);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (osmTilesEnabled && trackBounds != null) {
|
if (osmTilesEnabled && trackBounds != null && !isIndoorActivity) {
|
||||||
try {
|
try {
|
||||||
// Render OSM tiles for left 60% of image (track area)
|
// Render OSM tiles for left 60% of image (track area)
|
||||||
int trackWidth = (int) (width * 0.6);
|
int trackWidth = (int) (width * 0.6);
|
||||||
|
|
@ -96,24 +97,31 @@ public class ActivityImageService {
|
||||||
g2d.fillRect(0, 0, width, height);
|
g2d.fillRect(0, 0, width, height);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// OSM tiles disabled or no track data - use gradient background
|
// OSM tiles disabled or no track data (indoor activity) - use gradient background
|
||||||
GradientPaint gradient = new GradientPaint(
|
GradientPaint gradient = new GradientPaint(
|
||||||
0, 0, new Color(26, 0, 51),
|
0, 0, new Color(26, 0, 51),
|
||||||
width, height, new Color(45, 0, 82)
|
width, height, new Color(45, 0, 82)
|
||||||
);
|
);
|
||||||
g2d.setPaint(gradient);
|
g2d.setPaint(gradient);
|
||||||
g2d.fillRect(0, 0, width, height);
|
g2d.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// For indoor activities, draw a large emoji in the center-left area
|
||||||
|
if (isIndoorActivity) {
|
||||||
|
drawIndoorActivityEmoji(g2d, activity, width, height);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw track if available
|
// Draw track if available (not for indoor activities)
|
||||||
if (activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) {
|
if (!isIndoorActivity) {
|
||||||
drawTrack(g2d, activity, width, height);
|
if (activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) {
|
||||||
} else if (activity.getSimplifiedTrack() != null) {
|
drawTrack(g2d, activity, width, height);
|
||||||
drawSimplifiedTrack(g2d, activity, width, height);
|
} else if (activity.getSimplifiedTrack() != null) {
|
||||||
|
drawSimplifiedTrack(g2d, activity, width, height);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw metadata overlay
|
// Draw metadata overlay
|
||||||
drawMetadata(g2d, activity, width, height);
|
drawMetadata(g2d, activity, width, height, isIndoorActivity);
|
||||||
|
|
||||||
g2d.dispose();
|
g2d.dispose();
|
||||||
|
|
||||||
|
|
@ -377,7 +385,7 @@ public class ActivityImageService {
|
||||||
/**
|
/**
|
||||||
* Draw metadata overlay on the right side of the image in 80s Aerobic style.
|
* Draw metadata overlay on the right side of the image in 80s Aerobic style.
|
||||||
*/
|
*/
|
||||||
private void drawMetadata(Graphics2D g2d, Activity activity, int width, int height) {
|
private void drawMetadata(Graphics2D g2d, Activity activity, int width, int height, boolean isIndoorActivity) {
|
||||||
int metadataX = (int) (width * 0.62); // Start at 62% to leave some margin
|
int metadataX = (int) (width * 0.62); // Start at 62% to leave some margin
|
||||||
int y = 80;
|
int y = 80;
|
||||||
int lineHeight = 50;
|
int lineHeight = 50;
|
||||||
|
|
@ -387,6 +395,7 @@ public class ActivityImageService {
|
||||||
Color neonCyan = new Color(0, 255, 255);
|
Color neonCyan = new Color(0, 255, 255);
|
||||||
Color neonOrange = new Color(255, 102, 0);
|
Color neonOrange = new Color(255, 102, 0);
|
||||||
Color neonGreen = new Color(57, 255, 20);
|
Color neonGreen = new Color(57, 255, 20);
|
||||||
|
Color neonYellow = new Color(255, 255, 0);
|
||||||
|
|
||||||
// Title with neon pink
|
// Title with neon pink
|
||||||
g2d.setColor(neonPink);
|
g2d.setColor(neonPink);
|
||||||
|
|
@ -399,6 +408,14 @@ public class ActivityImageService {
|
||||||
g2d.drawString(title, metadataX, y);
|
g2d.drawString(title, metadataX, y);
|
||||||
y += lineHeight + 20;
|
y += lineHeight + 20;
|
||||||
|
|
||||||
|
// Indoor activity label (if applicable)
|
||||||
|
if (isIndoorActivity) {
|
||||||
|
g2d.setFont(new Font("Arial Black", Font.BOLD, 16));
|
||||||
|
g2d.setColor(neonYellow);
|
||||||
|
g2d.drawString("INDOOR ACTIVITY", metadataX, y);
|
||||||
|
y += 35;
|
||||||
|
}
|
||||||
|
|
||||||
// Activity type badge with border
|
// Activity type badge with border
|
||||||
g2d.setFont(new Font("Arial Black", Font.BOLD, 20));
|
g2d.setFont(new Font("Arial Black", Font.BOLD, 20));
|
||||||
String formattedType = ActivityFormatter.formatActivityType(activity.getActivityType()).toUpperCase();
|
String formattedType = ActivityFormatter.formatActivityType(activity.getActivityType()).toUpperCase();
|
||||||
|
|
@ -451,8 +468,8 @@ public class ActivityImageService {
|
||||||
y += lineHeight + 35;
|
y += lineHeight + 35;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Elevation gain with neon green value
|
// Elevation gain with neon green value (only for outdoor activities)
|
||||||
if (activity.getElevationGain() != null) {
|
if (activity.getElevationGain() != null && !isIndoorActivity) {
|
||||||
g2d.setFont(new Font("Arial Black", Font.BOLD, 40));
|
g2d.setFont(new Font("Arial Black", Font.BOLD, 40));
|
||||||
g2d.setColor(neonGreen);
|
g2d.setColor(neonGreen);
|
||||||
String elevation = String.format("%.0f", activity.getElevationGain());
|
String elevation = String.format("%.0f", activity.getElevationGain());
|
||||||
|
|
@ -469,6 +486,42 @@ public class ActivityImageService {
|
||||||
y += lineHeight + 35;
|
y += lineHeight + 35;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Heart Rate with neon orange value (for indoor activities)
|
||||||
|
if (isIndoorActivity && activity.getMetrics() != null && activity.getMetrics().getAverageHeartRate() != null) {
|
||||||
|
g2d.setFont(new Font("Arial Black", Font.BOLD, 40));
|
||||||
|
g2d.setColor(neonOrange);
|
||||||
|
String hr = String.format("%d", activity.getMetrics().getAverageHeartRate());
|
||||||
|
g2d.drawString(hr, metadataX, y);
|
||||||
|
|
||||||
|
g2d.setFont(new Font("Arial Black", Font.BOLD, 22));
|
||||||
|
g2d.setColor(Color.WHITE);
|
||||||
|
int hrWidth = g2d.getFontMetrics(new Font("Arial Black", Font.BOLD, 40)).stringWidth(hr);
|
||||||
|
g2d.drawString("BPM", metadataX + hrWidth + 10, y);
|
||||||
|
|
||||||
|
g2d.setFont(new Font("Arial Black", Font.PLAIN, 16));
|
||||||
|
g2d.setColor(new Color(180, 180, 180));
|
||||||
|
g2d.drawString("AVG HEART RATE", metadataX, y + 22);
|
||||||
|
y += lineHeight + 35;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calories with neon green value (for indoor activities)
|
||||||
|
if (isIndoorActivity && activity.getMetrics() != null && activity.getMetrics().getCalories() != null) {
|
||||||
|
g2d.setFont(new Font("Arial Black", Font.BOLD, 40));
|
||||||
|
g2d.setColor(neonGreen);
|
||||||
|
String calories = String.format("%d", activity.getMetrics().getCalories());
|
||||||
|
g2d.drawString(calories, metadataX, y);
|
||||||
|
|
||||||
|
g2d.setFont(new Font("Arial Black", Font.BOLD, 22));
|
||||||
|
g2d.setColor(Color.WHITE);
|
||||||
|
int calWidth = g2d.getFontMetrics(new Font("Arial Black", Font.BOLD, 40)).stringWidth(calories);
|
||||||
|
g2d.drawString("KCAL", metadataX + calWidth + 10, y);
|
||||||
|
|
||||||
|
g2d.setFont(new Font("Arial Black", Font.PLAIN, 16));
|
||||||
|
g2d.setColor(new Color(180, 180, 180));
|
||||||
|
g2d.drawString("CALORIES", metadataX, y + 22);
|
||||||
|
y += lineHeight + 35;
|
||||||
|
}
|
||||||
|
|
||||||
// Branding with neon pink gradient effect
|
// Branding with neon pink gradient effect
|
||||||
g2d.setFont(new Font("Arial Black", Font.BOLD, 28));
|
g2d.setFont(new Font("Arial Black", Font.BOLD, 28));
|
||||||
g2d.setColor(neonPink);
|
g2d.setColor(neonPink);
|
||||||
|
|
@ -582,6 +635,78 @@ public class ActivityImageService {
|
||||||
1.0 / Math.cos(Math.toRadians(lat))) / Math.PI) / 2.0;
|
1.0 / Math.cos(Math.toRadians(lat))) / Math.PI) / 2.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw a large emoji for indoor activities in the center-left area.
|
||||||
|
*/
|
||||||
|
private void drawIndoorActivityEmoji(Graphics2D g2d, Activity activity, int width, int height) {
|
||||||
|
// Map activity types to emojis
|
||||||
|
String emoji;
|
||||||
|
switch (activity.getActivityType()) {
|
||||||
|
case RUN:
|
||||||
|
emoji = "🏃";
|
||||||
|
break;
|
||||||
|
case RIDE:
|
||||||
|
emoji = "🚴";
|
||||||
|
break;
|
||||||
|
case SWIM:
|
||||||
|
emoji = "🏊";
|
||||||
|
break;
|
||||||
|
case WORKOUT:
|
||||||
|
emoji = "💪";
|
||||||
|
break;
|
||||||
|
case YOGA:
|
||||||
|
emoji = "🧘";
|
||||||
|
break;
|
||||||
|
case ROWING:
|
||||||
|
emoji = "🚣";
|
||||||
|
break;
|
||||||
|
case WALK:
|
||||||
|
emoji = "🚶";
|
||||||
|
break;
|
||||||
|
case HIKE:
|
||||||
|
emoji = "🥾";
|
||||||
|
break;
|
||||||
|
case ALPINE_SKI:
|
||||||
|
case NORDIC_SKI:
|
||||||
|
case BACKCOUNTRY_SKI:
|
||||||
|
emoji = "⛷️";
|
||||||
|
break;
|
||||||
|
case SNOWBOARD:
|
||||||
|
emoji = "🏂";
|
||||||
|
break;
|
||||||
|
case KAYAKING:
|
||||||
|
case CANOEING:
|
||||||
|
emoji = "🛶";
|
||||||
|
break;
|
||||||
|
case ROCK_CLIMBING:
|
||||||
|
case MOUNTAINEERING:
|
||||||
|
emoji = "🧗";
|
||||||
|
break;
|
||||||
|
case INLINE_SKATING:
|
||||||
|
emoji = "🛼";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
emoji = "🏋️";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw emoji in the center-left area (where the map would be)
|
||||||
|
int emojiX = (int) (width * 0.3) - 100; // Center of left 60%
|
||||||
|
int emojiY = height / 2;
|
||||||
|
|
||||||
|
// Use a very large font for the emoji
|
||||||
|
g2d.setFont(new Font("Segoe UI Emoji", Font.PLAIN, 200));
|
||||||
|
g2d.setColor(Color.WHITE);
|
||||||
|
|
||||||
|
// Calculate emoji width to center it properly
|
||||||
|
FontMetrics fm = g2d.getFontMetrics();
|
||||||
|
int emojiWidth = fm.stringWidth(emoji);
|
||||||
|
int emojiHeight = fm.getHeight();
|
||||||
|
|
||||||
|
// Draw emoji centered in the left area
|
||||||
|
g2d.drawString(emoji, emojiX - emojiWidth / 2, emojiY + emojiHeight / 3);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper class to store track geographic bounds.
|
* Helper class to store track geographic bounds.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,14 @@ public class TrainingLoadService {
|
||||||
double distance = distanceMeters.doubleValue();
|
double distance = distanceMeters.doubleValue();
|
||||||
double elevation = elevationMeters.doubleValue();
|
double elevation = elevationMeters.doubleValue();
|
||||||
|
|
||||||
|
// For indoor activities without distance (treadmill, indoor cycling, etc.)
|
||||||
|
// Calculate TSS based on duration alone with moderate intensity assumption
|
||||||
|
if (distance == 0.0) {
|
||||||
|
// Assume moderate intensity (0.7) for indoor activities
|
||||||
|
double tss = durationHours * 0.7 * 100.0;
|
||||||
|
return BigDecimal.valueOf(tss).setScale(2, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
// Intensity factor based on distance/time ratio and elevation
|
// Intensity factor based on distance/time ratio and elevation
|
||||||
double speed = distance / durationSeconds; // m/s
|
double speed = distance / durationSeconds; // m/s
|
||||||
double intensityFactor = Math.min(1.0, speed / 3.0); // Normalize to ~3 m/s baseline
|
double intensityFactor = Math.min(1.0, speed / 3.0); // Normalize to ~3 m/s baseline
|
||||||
|
|
|
||||||
|
|
@ -105,16 +105,19 @@ public class FitParser {
|
||||||
throw new FitFileProcessingException("Failed to decode FIT file");
|
throw new FitFileProcessingException("Failed to decode FIT file");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process GPS-related data only if track points are present
|
||||||
if (parsedData.getTrackPoints().isEmpty()) {
|
if (parsedData.getTrackPoints().isEmpty()) {
|
||||||
throw new FitFileProcessingException("No GPS track points found in FIT file");
|
log.info("No GPS track points found in FIT file - likely an indoor activity");
|
||||||
|
// Default to UTC timezone for indoor activities
|
||||||
|
parsedData.setTimezone("UTC");
|
||||||
|
} else {
|
||||||
|
// Determine timezone from first GPS coordinate
|
||||||
|
determineTimezone(parsedData);
|
||||||
|
|
||||||
|
// Apply speed smoothing and recalculate max speed
|
||||||
|
smoothSpeedData(parsedData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine timezone from first GPS coordinate
|
|
||||||
determineTimezone(parsedData);
|
|
||||||
|
|
||||||
// Apply speed smoothing and recalculate max speed
|
|
||||||
smoothSpeedData(parsedData);
|
|
||||||
|
|
||||||
log.info("Successfully parsed FIT file: {} track points, activity type: {}, timezone: {}",
|
log.info("Successfully parsed FIT file: {} track points, activity type: {}, timezone: {}",
|
||||||
parsedData.getTrackPoints().size(), parsedData.getActivityType(), parsedData.getTimezone());
|
parsedData.getTrackPoints().size(), parsedData.getActivityType(), parsedData.getTimezone());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -982,4 +982,36 @@ h1 {
|
||||||
footer.bg-light p {
|
footer.bg-light p {
|
||||||
color: var(--dark-text) !important;
|
color: var(--dark-text) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Indoor Activity Placeholder - Dark Mode */
|
||||||
|
#indoorPlaceholder .card {
|
||||||
|
background: var(--dark-surface);
|
||||||
|
border-color: var(--neon-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
#indoorPlaceholder .card-body {
|
||||||
|
background: var(--dark-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
#indoorPlaceholder .text-muted,
|
||||||
|
#indoorPlaceholder h4 {
|
||||||
|
color: var(--dark-text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#indoorPlaceholder p.text-muted {
|
||||||
|
color: var(--dark-text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Indoor activity placeholder for timeline - Dark Mode */
|
||||||
|
.indoor-activity-placeholder {
|
||||||
|
background-color: var(--dark-surface) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indoor-activity-placeholder .text-muted {
|
||||||
|
color: var(--dark-text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indoor-activity-placeholder .fw-bold {
|
||||||
|
color: var(--dark-text) !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -155,16 +155,21 @@ const FitPubTimeline = {
|
||||||
<!-- Activity Metrics -->
|
<!-- Activity Metrics -->
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
<strong>Distance:</strong> ${this.formatDistance(activity.totalDistance)} •
|
${activity.hasGpsTrack
|
||||||
<strong>Duration:</strong> ${this.formatDuration(activity.totalDurationSeconds)} •
|
? `<strong>Distance:</strong> ${this.formatDistance(activity.totalDistance)} •
|
||||||
<strong>Pace:</strong> ${this.formatPace(activity.totalDurationSeconds, activity.totalDistance)} •
|
<strong>Duration:</strong> ${this.formatDuration(activity.totalDurationSeconds)} •
|
||||||
<strong>Elevation:</strong> ${activity.elevationGain ? Math.round(activity.elevationGain) + 'm' : 'N/A'}
|
<strong>Pace:</strong> ${this.formatPace(activity.totalDurationSeconds, activity.totalDistance)} •
|
||||||
|
<strong>Elevation:</strong> ${activity.elevationGain ? Math.round(activity.elevationGain) + 'm' : 'N/A'}`
|
||||||
|
: `<strong>Duration:</strong> ${this.formatDuration(activity.totalDurationSeconds)}
|
||||||
|
${activity.metrics?.averageHeartRate ? ` • <strong>Avg HR:</strong> ${activity.metrics.averageHeartRate} bpm` : ''}
|
||||||
|
${activity.metrics?.calories ? ` • <strong>Calories:</strong> ${activity.metrics.calories} kcal` : ''}`
|
||||||
|
}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Preview Map -->
|
<!-- Preview Map or Indoor Placeholder -->
|
||||||
<div class="activity-preview-map" id="${mapId}" style="height: 300px; border-radius: 8px; margin-bottom: 1rem;">
|
<div class="activity-preview-map" id="${mapId}" style="height: 300px; border-radius: 8px; margin-bottom: 1rem;">
|
||||||
<!-- Map will be rendered here -->
|
<!-- Map or placeholder will be rendered here -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Activity Actions -->
|
<!-- Activity Actions -->
|
||||||
|
|
@ -295,6 +300,13 @@ const FitPubTimeline = {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if activity has GPS track
|
||||||
|
if (!activity.hasGpsTrack) {
|
||||||
|
// Show indoor activity placeholder
|
||||||
|
this.renderIndoorPlaceholder(mapElement, activity.activityType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle remote activities differently - show static map image
|
// Handle remote activities differently - show static map image
|
||||||
if (!activity.isLocal) {
|
if (!activity.isLocal) {
|
||||||
if (activity.mapImageUrl) {
|
if (activity.mapImageUrl) {
|
||||||
|
|
@ -517,5 +529,66 @@ const FitPubTimeline = {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render indoor activity placeholder with emoji
|
||||||
|
* @param {HTMLElement} element - Container element
|
||||||
|
* @param {string} activityType - Activity type
|
||||||
|
*/
|
||||||
|
renderIndoorPlaceholder: function(element, activityType) {
|
||||||
|
const emojiMap = {
|
||||||
|
'RUN': '🏃',
|
||||||
|
'RIDE': '🚴',
|
||||||
|
'CYCLING': '🚴',
|
||||||
|
'INDOOR_CYCLING': '🚴',
|
||||||
|
'HIKE': '🥾',
|
||||||
|
'WALK': '🚶',
|
||||||
|
'SWIM': '🏊',
|
||||||
|
'WORKOUT': '💪',
|
||||||
|
'YOGA': '🧘',
|
||||||
|
'ALPINE_SKI': '⛷️',
|
||||||
|
'NORDIC_SKI': '⛷️',
|
||||||
|
'SNOWBOARD': '🏂',
|
||||||
|
'ROWING': '🚣',
|
||||||
|
'KAYAKING': '🛶',
|
||||||
|
'CANOEING': '🛶',
|
||||||
|
'ROCK_CLIMBING': '🧗',
|
||||||
|
'MOUNTAINEERING': '⛰️',
|
||||||
|
'OTHER': '🏋️'
|
||||||
|
};
|
||||||
|
|
||||||
|
const nameMap = {
|
||||||
|
'RUN': 'Indoor Running',
|
||||||
|
'RIDE': 'Indoor Cycling',
|
||||||
|
'CYCLING': 'Indoor Cycling',
|
||||||
|
'INDOOR_CYCLING': 'Indoor Cycling',
|
||||||
|
'HIKE': 'Indoor Activity',
|
||||||
|
'WALK': 'Indoor Walking',
|
||||||
|
'SWIM': 'Indoor Swimming',
|
||||||
|
'WORKOUT': 'Workout',
|
||||||
|
'YOGA': 'Yoga',
|
||||||
|
'ALPINE_SKI': 'Skiing',
|
||||||
|
'NORDIC_SKI': 'Cross-Country Skiing',
|
||||||
|
'SNOWBOARD': 'Snowboarding',
|
||||||
|
'ROWING': 'Indoor Rowing',
|
||||||
|
'KAYAKING': 'Kayaking',
|
||||||
|
'CANOEING': 'Canoeing',
|
||||||
|
'ROCK_CLIMBING': 'Climbing',
|
||||||
|
'MOUNTAINEERING': 'Mountaineering',
|
||||||
|
'OTHER': 'Indoor Activity'
|
||||||
|
};
|
||||||
|
|
||||||
|
const emoji = emojiMap[activityType] || '🏋️';
|
||||||
|
const name = nameMap[activityType] || 'Indoor Activity';
|
||||||
|
|
||||||
|
element.innerHTML = `
|
||||||
|
<div class="d-flex flex-column align-items-center justify-content-center h-100 indoor-activity-placeholder">
|
||||||
|
<div style="font-size: 4rem;" class="mb-2">${emoji}</div>
|
||||||
|
<div class="text-muted fw-bold">${this.escapeHtml(name)}</div>
|
||||||
|
<div class="text-muted small">No GPS track</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
element.style.backgroundColor = '#f8f9fa';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -198,8 +198,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Map -->
|
<!-- Map / Indoor Activity Placeholder -->
|
||||||
<div class="row mb-4">
|
<div class="row mb-4" id="mapSection">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
|
|
@ -214,6 +214,19 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Indoor Activity Placeholder -->
|
||||||
|
<div class="row mb-4" id="indoorPlaceholder" style="display: none;">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<div id="activityTypeEmoji" style="font-size: 5rem;" class="mb-3">🏋️</div>
|
||||||
|
<h4 id="activityTypeName" class="text-muted">Indoor Activity</h4>
|
||||||
|
<p class="text-muted">No GPS track available</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Charts Row -->
|
<!-- Charts Row -->
|
||||||
<div class="row mb-4" id="chartsSection" style="display: none;">
|
<div class="row mb-4" id="chartsSection" style="display: none;">
|
||||||
<!-- Elevation Chart -->
|
<!-- Elevation Chart -->
|
||||||
|
|
@ -475,20 +488,33 @@
|
||||||
checkActivityOwnership(activity);
|
checkActivityOwnership(activity);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metrics - Basic (always shown)
|
// Check if activity has GPS track
|
||||||
document.getElementById('metricDistance').textContent = formatDistance(activity.totalDistance);
|
const hasGpsTrack = activity.hasGpsTrack === true;
|
||||||
document.getElementById('metricDuration').textContent = formatDuration(activity.totalDuration);
|
|
||||||
document.getElementById('metricElevationGain').textContent = activity.elevationGain ? Math.round(activity.elevationGain) + ' m' : 'N/A';
|
|
||||||
|
|
||||||
// Calculate pace
|
// Metrics - Conditional based on GPS availability
|
||||||
if (activity.totalDistance && activity.totalDuration) {
|
if (hasGpsTrack) {
|
||||||
const paceSeconds = activity.totalDuration / (activity.totalDistance / 1000);
|
// Show GPS-related metrics
|
||||||
document.getElementById('metricPace').textContent = formatPace(paceSeconds);
|
document.getElementById('metricDistance').textContent = formatDistance(activity.totalDistance);
|
||||||
|
document.getElementById('metricElevationGain').textContent = activity.elevationGain ? Math.round(activity.elevationGain) + ' m' : 'N/A';
|
||||||
|
|
||||||
|
// Calculate pace
|
||||||
|
if (activity.totalDistance && activity.totalDuration) {
|
||||||
|
const paceSeconds = activity.totalDuration / (activity.totalDistance / 1000);
|
||||||
|
document.getElementById('metricPace').textContent = formatPace(paceSeconds);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Hide GPS-related metrics for indoor activities
|
||||||
|
document.getElementById('metricDistance').parentElement.parentElement.parentElement.style.display = 'none';
|
||||||
|
document.getElementById('metricElevationGain').parentElement.parentElement.parentElement.style.display = 'none';
|
||||||
|
document.getElementById('metricPace').parentElement.parentElement.parentElement.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Duration is always shown
|
||||||
|
document.getElementById('metricDuration').textContent = formatDuration(activity.totalDuration);
|
||||||
|
|
||||||
// Additional Metrics (conditional)
|
// Additional Metrics (conditional)
|
||||||
// Note: averageSpeed is already in km/h from backend (converted in FitParser)
|
// Note: averageSpeed is already in km/h from backend (converted in FitParser)
|
||||||
if (activity.averageSpeed) {
|
if (activity.averageSpeed && hasGpsTrack) {
|
||||||
document.getElementById('metricAvgSpeedContainer').style.display = 'block';
|
document.getElementById('metricAvgSpeedContainer').style.display = 'block';
|
||||||
document.getElementById('metricAvgSpeed').textContent = parseFloat(activity.averageSpeed).toFixed(1) + ' km/h';
|
document.getElementById('metricAvgSpeed').textContent = parseFloat(activity.averageSpeed).toFixed(1) + ' km/h';
|
||||||
}
|
}
|
||||||
|
|
@ -501,13 +527,22 @@
|
||||||
document.getElementById('metricCalories').textContent = activity.calories + ' kcal';
|
document.getElementById('metricCalories').textContent = activity.calories + ' kcal';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render map if track data exists
|
// Render map or indoor placeholder
|
||||||
if (activity.simplifiedTrack) {
|
if (hasGpsTrack && activity.simplifiedTrack) {
|
||||||
|
document.getElementById('mapSection').style.display = 'block';
|
||||||
|
document.getElementById('indoorPlaceholder').style.display = 'none';
|
||||||
renderMap(activity.simplifiedTrack);
|
renderMap(activity.simplifiedTrack);
|
||||||
|
} else {
|
||||||
|
// Show indoor activity placeholder
|
||||||
|
document.getElementById('mapSection').style.display = 'none';
|
||||||
|
document.getElementById('indoorPlaceholder').style.display = 'block';
|
||||||
|
showIndoorPlaceholder(activity.activityType);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load weather data
|
// Load weather data (only for outdoor activities)
|
||||||
loadWeatherData(activity.id);
|
if (hasGpsTrack) {
|
||||||
|
loadWeatherData(activity.id);
|
||||||
|
}
|
||||||
|
|
||||||
// Render elevation chart if data exists
|
// Render elevation chart if data exists
|
||||||
if (activity.trackPoints && activity.trackPoints.length > 0) {
|
if (activity.trackPoints && activity.trackPoints.length > 0) {
|
||||||
|
|
@ -1378,6 +1413,59 @@
|
||||||
default: return 'question-circle';
|
default: return 'question-circle';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show indoor activity placeholder with appropriate emoji
|
||||||
|
*/
|
||||||
|
function showIndoorPlaceholder(activityType) {
|
||||||
|
const emojiMap = {
|
||||||
|
'RUN': '🏃',
|
||||||
|
'RIDE': '🚴',
|
||||||
|
'CYCLING': '🚴',
|
||||||
|
'INDOOR_CYCLING': '🚴',
|
||||||
|
'HIKE': '🥾',
|
||||||
|
'WALK': '🚶',
|
||||||
|
'SWIM': '🏊',
|
||||||
|
'WORKOUT': '💪',
|
||||||
|
'YOGA': '🧘',
|
||||||
|
'ALPINE_SKI': '⛷️',
|
||||||
|
'NORDIC_SKI': '⛷️',
|
||||||
|
'SNOWBOARD': '🏂',
|
||||||
|
'ROWING': '🚣',
|
||||||
|
'KAYAKING': '🛶',
|
||||||
|
'CANOEING': '🛶',
|
||||||
|
'ROCK_CLIMBING': '🧗',
|
||||||
|
'MOUNTAINEERING': '⛰️',
|
||||||
|
'OTHER': '🏋️'
|
||||||
|
};
|
||||||
|
|
||||||
|
const nameMap = {
|
||||||
|
'RUN': 'Indoor Running',
|
||||||
|
'RIDE': 'Indoor Cycling',
|
||||||
|
'CYCLING': 'Indoor Cycling',
|
||||||
|
'INDOOR_CYCLING': 'Indoor Cycling',
|
||||||
|
'HIKE': 'Indoor Activity',
|
||||||
|
'WALK': 'Indoor Walking',
|
||||||
|
'SWIM': 'Indoor Swimming',
|
||||||
|
'WORKOUT': 'Workout',
|
||||||
|
'YOGA': 'Yoga',
|
||||||
|
'ALPINE_SKI': 'Skiing',
|
||||||
|
'NORDIC_SKI': 'Cross-Country Skiing',
|
||||||
|
'SNOWBOARD': 'Snowboarding',
|
||||||
|
'ROWING': 'Indoor Rowing',
|
||||||
|
'KAYAKING': 'Kayaking',
|
||||||
|
'CANOEING': 'Canoeing',
|
||||||
|
'ROCK_CLIMBING': 'Climbing',
|
||||||
|
'MOUNTAINEERING': 'Mountaineering',
|
||||||
|
'OTHER': 'Indoor Activity'
|
||||||
|
};
|
||||||
|
|
||||||
|
const emoji = emojiMap[activityType] || '🏋️';
|
||||||
|
const name = nameMap[activityType] || 'Indoor Activity';
|
||||||
|
|
||||||
|
document.getElementById('activityTypeEmoji').textContent = emoji;
|
||||||
|
document.getElementById('activityTypeName').textContent = name;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
|
||||||
222
src/test/java/org/operaton/fitpub/util/FitFileAnalyzer.java
Normal file
222
src/test/java/org/operaton/fitpub/util/FitFileAnalyzer.java
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
package org.operaton.fitpub.util;
|
||||||
|
|
||||||
|
import com.garmin.fit.*;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to analyze FIT file structure and print all available fields.
|
||||||
|
* Helps debug what data is actually present in a FIT file.
|
||||||
|
*/
|
||||||
|
public class FitFileAnalyzer {
|
||||||
|
|
||||||
|
private static final double SEMICIRCLES_TO_DEGREES = 180.0 / Math.pow(2, 31);
|
||||||
|
private static final long FIT_EPOCH_OFFSET = 631065600L;
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
if (args.length == 0) {
|
||||||
|
System.out.println("Usage: FitFileAnalyzer <path-to-fit-file>");
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
String filePath = args[0];
|
||||||
|
analyzeFitFile(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void analyzeFitFile(String filePath) {
|
||||||
|
System.out.println("=== FIT File Analysis ===");
|
||||||
|
System.out.println("File: " + filePath);
|
||||||
|
System.out.println();
|
||||||
|
|
||||||
|
try (InputStream inputStream = new FileInputStream(filePath)) {
|
||||||
|
Decode decode = new Decode();
|
||||||
|
MesgBroadcaster broadcaster = new MesgBroadcaster(decode);
|
||||||
|
|
||||||
|
// Counter for record messages
|
||||||
|
final int[] recordCount = {0};
|
||||||
|
final boolean[] hasGpsData = {false};
|
||||||
|
|
||||||
|
// Listen for FileId messages
|
||||||
|
broadcaster.addListener((FileIdMesgListener) fileId -> {
|
||||||
|
System.out.println("=== FILE ID MESSAGE ===");
|
||||||
|
System.out.println("Type: " + fileId.getType());
|
||||||
|
System.out.println("Manufacturer: " + fileId.getManufacturer());
|
||||||
|
System.out.println("Product: " + fileId.getProduct());
|
||||||
|
if (fileId.getTimeCreated() != null) {
|
||||||
|
System.out.println("Time Created: " + convertDateTime(fileId.getTimeCreated()));
|
||||||
|
}
|
||||||
|
System.out.println();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for Session messages (summary)
|
||||||
|
broadcaster.addListener((SessionMesgListener) session -> {
|
||||||
|
System.out.println("=== SESSION MESSAGE ===");
|
||||||
|
System.out.println("Sport: " + session.getSport());
|
||||||
|
System.out.println("Sub Sport: " + session.getSubSport());
|
||||||
|
|
||||||
|
if (session.getStartTime() != null) {
|
||||||
|
System.out.println("Start Time: " + convertDateTime(session.getStartTime()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.getTotalElapsedTime() != null) {
|
||||||
|
System.out.println("Total Elapsed Time: " + session.getTotalElapsedTime() + " seconds");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.getTotalTimerTime() != null) {
|
||||||
|
System.out.println("Total Timer Time: " + session.getTotalTimerTime() + " seconds");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.getTotalDistance() != null) {
|
||||||
|
System.out.println("Total Distance: " + session.getTotalDistance() + " meters (" +
|
||||||
|
(session.getTotalDistance() / 1000.0) + " km)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.getTotalAscent() != null) {
|
||||||
|
System.out.println("Total Ascent: " + session.getTotalAscent() + " meters");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.getTotalDescent() != null) {
|
||||||
|
System.out.println("Total Descent: " + session.getTotalDescent() + " meters");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.getAvgSpeed() != null) {
|
||||||
|
System.out.println("Average Speed: " + session.getAvgSpeed() + " m/s (" +
|
||||||
|
(session.getAvgSpeed() * 3.6) + " km/h)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.getMaxSpeed() != null) {
|
||||||
|
System.out.println("Max Speed: " + session.getMaxSpeed() + " m/s (" +
|
||||||
|
(session.getMaxSpeed() * 3.6) + " km/h)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.getAvgHeartRate() != null) {
|
||||||
|
System.out.println("Average Heart Rate: " + session.getAvgHeartRate() + " bpm");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.getMaxHeartRate() != null) {
|
||||||
|
System.out.println("Max Heart Rate: " + session.getMaxHeartRate() + " bpm");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.getAvgCadence() != null) {
|
||||||
|
System.out.println("Average Cadence: " + session.getAvgCadence() + " rpm");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.getMaxCadence() != null) {
|
||||||
|
System.out.println("Max Cadence: " + session.getMaxCadence() + " rpm");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.getAvgPower() != null) {
|
||||||
|
System.out.println("Average Power: " + session.getAvgPower() + " watts");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.getMaxPower() != null) {
|
||||||
|
System.out.println("Max Power: " + session.getMaxPower() + " watts");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.getTotalCalories() != null) {
|
||||||
|
System.out.println("Total Calories: " + session.getTotalCalories() + " kcal");
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for Record messages (data points)
|
||||||
|
broadcaster.addListener((RecordMesgListener) record -> {
|
||||||
|
recordCount[0]++;
|
||||||
|
|
||||||
|
// Only print first 5 records to avoid spam
|
||||||
|
if (recordCount[0] <= 5) {
|
||||||
|
System.out.println("=== RECORD MESSAGE #" + recordCount[0] + " ===");
|
||||||
|
|
||||||
|
if (record.getTimestamp() != null) {
|
||||||
|
System.out.println("Timestamp: " + convertDateTime(record.getTimestamp()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Integer positionLat = record.getPositionLat();
|
||||||
|
Integer positionLong = record.getPositionLong();
|
||||||
|
|
||||||
|
if (positionLat != null && positionLong != null) {
|
||||||
|
double latitude = positionLat * SEMICIRCLES_TO_DEGREES;
|
||||||
|
double longitude = positionLong * SEMICIRCLES_TO_DEGREES;
|
||||||
|
System.out.println("GPS Position: " + latitude + ", " + longitude);
|
||||||
|
hasGpsData[0] = true;
|
||||||
|
} else {
|
||||||
|
System.out.println("GPS Position: NOT AVAILABLE");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.getDistance() != null) {
|
||||||
|
System.out.println("Distance: " + record.getDistance() + " meters");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.getSpeed() != null) {
|
||||||
|
System.out.println("Speed: " + record.getSpeed() + " m/s (" +
|
||||||
|
(record.getSpeed() * 3.6) + " km/h)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.getAltitude() != null) {
|
||||||
|
System.out.println("Altitude: " + record.getAltitude() + " meters");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.getHeartRate() != null) {
|
||||||
|
System.out.println("Heart Rate: " + record.getHeartRate() + " bpm");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.getCadence() != null) {
|
||||||
|
System.out.println("Cadence: " + record.getCadence() + " rpm");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.getPower() != null) {
|
||||||
|
System.out.println("Power: " + record.getPower() + " watts");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.getTemperature() != null) {
|
||||||
|
System.out.println("Temperature: " + record.getTemperature() + " °C");
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for Lap messages
|
||||||
|
broadcaster.addListener((LapMesgListener) lap -> {
|
||||||
|
System.out.println("=== LAP MESSAGE ===");
|
||||||
|
if (lap.getTotalDistance() != null) {
|
||||||
|
System.out.println("Lap Distance: " + lap.getTotalDistance() + " meters");
|
||||||
|
}
|
||||||
|
if (lap.getTotalTimerTime() != null) {
|
||||||
|
System.out.println("Lap Time: " + lap.getTotalTimerTime() + " seconds");
|
||||||
|
}
|
||||||
|
System.out.println();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Decode the file
|
||||||
|
if (!decode.read(inputStream, broadcaster)) {
|
||||||
|
System.err.println("ERROR: Failed to decode FIT file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println("=== SUMMARY ===");
|
||||||
|
System.out.println("Total Record Messages: " + recordCount[0]);
|
||||||
|
System.out.println("GPS Data Present: " + hasGpsData[0]);
|
||||||
|
|
||||||
|
if (!hasGpsData[0] && recordCount[0] > 0) {
|
||||||
|
System.out.println();
|
||||||
|
System.out.println("NOTE: File contains record messages with distance/speed but NO GPS coordinates.");
|
||||||
|
System.out.println("This is typical for indoor activities with virtual distance (smart trainers, apps).");
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("ERROR analyzing FIT file: " + e.getMessage());
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDateTime convertDateTime(DateTime dateTime) {
|
||||||
|
long timestamp = dateTime.getTimestamp();
|
||||||
|
Instant instant = Instant.ofEpochSecond(timestamp + FIT_EPOCH_OFFSET);
|
||||||
|
return LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue