fitpub/src/main/java/org/operaton/fitpub/service/ActivityImageService.java
2026-01-05 10:42:06 +01:00

726 lines
30 KiB
Java

package org.operaton.fitpub.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.operaton.fitpub.model.entity.Activity;
import org.operaton.fitpub.util.ActivityFormatter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.geom.Path2D;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Service for generating activity preview images for ActivityPub federation.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ActivityImageService {
private final OsmTileRenderer osmTileRenderer;
@Value("${fitpub.storage.images.path:${java.io.tmpdir}/fitpub/images}")
private String imagesPath;
@Value("${fitpub.base-url}")
private String baseUrl;
@Value("${fitpub.image.osm-tiles.enabled:true}")
private boolean osmTilesEnabled;
/**
* Generate a preview image for an activity showing the track outline and metadata.
*
* @param activity the activity to generate an image for
* @return the URL of the generated image
*/
public String generateActivityImage(Activity activity) {
try {
// Image dimensions
int width = 1200;
int height = 630; // Open Graph standard size
// Create image
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = image.createGraphics();
// Enable antialiasing
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
// Calculate bounds once for both map tiles and track rendering
TrackBounds trackBounds = null;
boolean isIndoorActivity = activity.getSimplifiedTrack() == null;
// Render background - either OSM tiles or gradient background
if (activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) {
trackBounds = calculateTrackBounds(activity);
}
if (osmTilesEnabled && trackBounds != null && !isIndoorActivity) {
try {
// Render OSM tiles for left 60% of image (track area)
int trackWidth = (int) (width * 0.6);
BufferedImage mapTiles = osmTileRenderer.renderMapWithTiles(
trackBounds.minLat, trackBounds.maxLat,
trackBounds.minLon, trackBounds.maxLon,
trackWidth, height);
g2d.drawImage(mapTiles, 0, 0, null);
// 80s Aerobic style gradient background for metadata area (right 40%)
int metadataX = trackWidth;
GradientPaint gradient = new GradientPaint(
metadataX, 0, new Color(26, 0, 51), // Dark purple
width, height, new Color(45, 0, 82) // Lighter purple
);
g2d.setPaint(gradient);
g2d.fillRect(metadataX, 0, width - metadataX, height);
log.debug("Rendered OSM tiles for activity {}", activity.getId());
} catch (Exception e) {
log.warn("Failed to render OSM tiles, using gradient background: {}", e.getMessage());
// Fallback to gradient background
GradientPaint gradient = new GradientPaint(
0, 0, new Color(26, 0, 51),
width, height, new Color(45, 0, 82)
);
g2d.setPaint(gradient);
g2d.fillRect(0, 0, width, height);
}
} else {
// OSM tiles disabled or no track data (indoor activity) - use gradient background
GradientPaint gradient = new GradientPaint(
0, 0, new Color(26, 0, 51),
width, height, new Color(45, 0, 82)
);
g2d.setPaint(gradient);
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 (not for indoor activities)
if (!isIndoorActivity) {
if (activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) {
drawTrack(g2d, activity, width, height);
} else if (activity.getSimplifiedTrack() != null) {
drawSimplifiedTrack(g2d, activity, width, height);
}
}
// Draw metadata overlay
drawMetadata(g2d, activity, width, height, isIndoorActivity);
g2d.dispose();
// Save image
File imagesDir = new File(imagesPath);
if (!imagesDir.exists()) {
imagesDir.mkdirs();
}
String filename = activity.getId() + ".png";
File imageFile = new File(imagesDir, filename);
ImageIO.write(image, "png", imageFile);
log.info("Generated activity image: {}", imageFile.getAbsolutePath());
// Return URL to the image
return baseUrl + "/api/activities/" + activity.getId() + "/image";
} catch (Exception e) {
log.error("Failed to generate activity image for {}", activity.getId(), e);
return null;
}
}
/**
* Draw the track outline from high-resolution track points with privacy protection.
* Fades in/out the first and last 100 meters, completely hides first/last 100m.
* Uses Web Mercator projection to match OSM tiles.
*/
private void drawTrack(Graphics2D g2d, Activity activity, int width, int height) {
List<Map<String, Object>> trackPoints = parseTrackPoints(activity.getTrackPointsJson());
if (trackPoints == null || trackPoints.isEmpty()) {
return;
}
// Calculate cumulative distances along the track
double[] cumulativeDistances = calculateCumulativeDistances(trackPoints);
double totalDistance = cumulativeDistances[cumulativeDistances.length - 1];
// Calculate bounds with padding (must match OSM tile rendering)
TrackBounds bounds = calculateTrackBounds(activity);
if (bounds == null) {
return;
}
// Calculate scale (use left 60% of image for track, right 40% for metadata)
int trackWidth = (int) (width * 0.6);
int trackHeight = height;
// Get letterbox transformation from OSM renderer
OsmTileRenderer.LetterboxTransform letterbox = osmTileRenderer.getLastLetterboxTransform();
if (letterbox == null) {
log.warn("No letterbox transform available, track overlay may be misaligned");
return;
}
// Convert bounds to Web Mercator normalized coordinates (0-1)
// This matches the projection used by OSM tiles
double minX = longitudeToWebMercatorX(bounds.minLon);
double maxX = longitudeToWebMercatorX(bounds.maxLon);
double minY = latitudeToWebMercatorY(bounds.maxLat); // Note: maxLat -> minY (inverted)
double maxY = latitudeToWebMercatorY(bounds.minLat); // Note: minLat -> maxY (inverted)
// The letterbox transform gives us the actual rendered area within trackWidth x trackHeight
// We need to map our mercator coordinates to fit within that rendered area
// Calculate the mercator range that corresponds to the letterboxed (cropped/scaled) map
double mercatorWidth = maxX - minX;
double mercatorHeight = maxY - minY;
// The scale factors tell us how the mercator coordinates map to the letterboxed area
double pixelsPerMercatorX = letterbox.scaledWidth / mercatorWidth;
double pixelsPerMercatorY = letterbox.scaledHeight / mercatorHeight;
// Draw track segments with privacy fade - 80s neon glow style
// First pass: draw glow effect (thicker, semi-transparent)
g2d.setStroke(new BasicStroke(8.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
final double HIDDEN_DISTANCE = 100.0; // First/last 100m completely hidden
final double FADE_DISTANCE = 200.0; // Fade zone from 100m to 200m
// Draw glow pass
for (int i = 0; i < trackPoints.size() - 1; i++) {
Map<String, Object> point1 = trackPoints.get(i);
Map<String, Object> point2 = trackPoints.get(i + 1);
Double lat1 = getDouble(point1, "latitude");
Double lon1 = getDouble(point1, "longitude");
Double lat2 = getDouble(point2, "latitude");
Double lon2 = getDouble(point2, "longitude");
if (lat1 != null && lon1 != null && lat2 != null && lon2 != null) {
// Convert lat/lon to Web Mercator coordinates (same projection as OSM tiles)
double mercatorX1 = longitudeToWebMercatorX(lon1);
double mercatorY1 = latitudeToWebMercatorY(lat1);
double mercatorX2 = longitudeToWebMercatorX(lon2);
double mercatorY2 = latitudeToWebMercatorY(lat2);
// Map Web Mercator coordinates to pixel coordinates within the letterbox
double x1 = (mercatorX1 - minX) * pixelsPerMercatorX + letterbox.offsetX;
double y1 = (mercatorY1 - minY) * pixelsPerMercatorY + letterbox.offsetY;
double x2 = (mercatorX2 - minX) * pixelsPerMercatorX + letterbox.offsetX;
double y2 = (mercatorY2 - minY) * pixelsPerMercatorY + letterbox.offsetY;
// Calculate opacity based on distance from start/end
double distanceFromStart = cumulativeDistances[i];
double distanceFromEnd = totalDistance - cumulativeDistances[i];
// Calculate fade opacity (0.0 to 1.0)
float opacity = 1.0f;
// Hide first 100m completely, fade in from 100m to 200m
if (distanceFromStart < HIDDEN_DISTANCE) {
opacity = 0.0f;
} else if (distanceFromStart < FADE_DISTANCE) {
opacity = Math.min(opacity, (float) ((distanceFromStart - HIDDEN_DISTANCE) / (FADE_DISTANCE - HIDDEN_DISTANCE)));
}
// Hide last 100m completely, fade out from 200m to 100m before end
if (distanceFromEnd < HIDDEN_DISTANCE) {
opacity = 0.0f;
} else if (distanceFromEnd < FADE_DISTANCE) {
opacity = Math.min(opacity, (float) ((distanceFromEnd - HIDDEN_DISTANCE) / (FADE_DISTANCE - HIDDEN_DISTANCE)));
}
// Skip completely transparent segments
if (opacity <= 0.0f) {
continue;
}
// Apply opacity to glow color (semi-transparent cyan)
int alpha = Math.max(0, Math.min(128, (int) (opacity * 128))); // Max 50% alpha for glow
g2d.setColor(new Color(0, 255, 255, alpha));
// Draw glow segment
g2d.drawLine((int) x1, (int) y1, (int) x2, (int) y2);
}
}
// Second pass: draw main track line (thinner, full opacity)
g2d.setStroke(new BasicStroke(4.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
for (int i = 0; i < trackPoints.size() - 1; i++) {
Map<String, Object> point1 = trackPoints.get(i);
Map<String, Object> point2 = trackPoints.get(i + 1);
Double lat1 = getDouble(point1, "latitude");
Double lon1 = getDouble(point1, "longitude");
Double lat2 = getDouble(point2, "latitude");
Double lon2 = getDouble(point2, "longitude");
if (lat1 != null && lon1 != null && lat2 != null && lon2 != null) {
// Convert lat/lon to Web Mercator coordinates (same projection as OSM tiles)
double mercatorX1 = longitudeToWebMercatorX(lon1);
double mercatorY1 = latitudeToWebMercatorY(lat1);
double mercatorX2 = longitudeToWebMercatorX(lon2);
double mercatorY2 = latitudeToWebMercatorY(lat2);
// Map Web Mercator coordinates to pixel coordinates within the letterbox
double x1 = (mercatorX1 - minX) * pixelsPerMercatorX + letterbox.offsetX;
double y1 = (mercatorY1 - minY) * pixelsPerMercatorY + letterbox.offsetY;
double x2 = (mercatorX2 - minX) * pixelsPerMercatorX + letterbox.offsetX;
double y2 = (mercatorY2 - minY) * pixelsPerMercatorY + letterbox.offsetY;
// Calculate opacity based on distance from start/end
double distanceFromStart = cumulativeDistances[i];
double distanceFromEnd = totalDistance - cumulativeDistances[i];
// Calculate fade opacity (0.0 to 1.0)
float opacity = 1.0f;
// Hide first 100m completely, fade in from 100m to 200m
if (distanceFromStart < HIDDEN_DISTANCE) {
opacity = 0.0f;
} else if (distanceFromStart < FADE_DISTANCE) {
// Fade in from 100m to 200m
opacity = Math.min(opacity, (float) ((distanceFromStart - HIDDEN_DISTANCE) / (FADE_DISTANCE - HIDDEN_DISTANCE)));
}
// Hide last 100m completely, fade out from 200m to 100m before end
if (distanceFromEnd < HIDDEN_DISTANCE) {
opacity = 0.0f;
} else if (distanceFromEnd < FADE_DISTANCE) {
// Fade out from 200m to 100m before end
opacity = Math.min(opacity, (float) ((distanceFromEnd - HIDDEN_DISTANCE) / (FADE_DISTANCE - HIDDEN_DISTANCE)));
}
// Skip completely transparent segments
if (opacity <= 0.0f) {
continue;
}
// Apply opacity to track color - neon cyan for 80s style
int alpha = Math.max(0, Math.min(255, (int) (opacity * 255)));
g2d.setColor(new Color(0, 255, 255, alpha)); // Neon cyan with alpha
// Draw segment
g2d.drawLine((int) x1, (int) y1, (int) x2, (int) y2);
}
}
}
/**
* Draw the track from simplified track (LineString).
*/
private void drawSimplifiedTrack(Graphics2D g2d, Activity activity, int width, int height) {
// Similar logic but using simplified track coordinates
// This is a fallback if high-res track points aren't available
log.debug("Using simplified track for activity {}", activity.getId());
// TODO: Implement if needed
}
/**
* Calculate cumulative distances along the track using Haversine formula.
* Returns an array where each element is the total distance from the start to that point.
*/
private double[] calculateCumulativeDistances(List<Map<String, Object>> trackPoints) {
double[] distances = new double[trackPoints.size()];
distances[0] = 0.0;
for (int i = 1; i < trackPoints.size(); i++) {
Map<String, Object> point1 = trackPoints.get(i - 1);
Map<String, Object> point2 = trackPoints.get(i);
Double lat1 = getDouble(point1, "latitude");
Double lon1 = getDouble(point1, "longitude");
Double lat2 = getDouble(point2, "latitude");
Double lon2 = getDouble(point2, "longitude");
if (lat1 != null && lon1 != null && lat2 != null && lon2 != null) {
double segmentDistance = haversineDistance(lat1, lon1, lat2, lon2);
distances[i] = distances[i - 1] + segmentDistance;
} else {
distances[i] = distances[i - 1];
}
}
return distances;
}
/**
* Calculate distance between two GPS coordinates using Haversine formula.
* Returns distance in meters.
*/
private double haversineDistance(double lat1, double lon1, double lat2, double lon2) {
final double EARTH_RADIUS = 6371000.0; // Earth radius in meters
double dLat = Math.toRadians(lat2 - lat1);
double dLon = Math.toRadians(lon2 - lon1);
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return EARTH_RADIUS * c;
}
/**
* 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, boolean isIndoorActivity) {
int metadataX = (int) (width * 0.62); // Start at 62% to leave some margin
int y = 80;
int lineHeight = 50;
// Neon colors
Color neonPink = new Color(255, 20, 147);
Color neonCyan = new Color(0, 255, 255);
Color neonOrange = new Color(255, 102, 0);
Color neonGreen = new Color(57, 255, 20);
Color neonYellow = new Color(255, 255, 0);
// Title with neon pink
g2d.setColor(neonPink);
g2d.setFont(new Font("Arial Black", Font.BOLD, 36));
String title = activity.getTitle() != null ? activity.getTitle() : "ACTIVITY";
title = title.toUpperCase();
if (title.length() > 18) {
title = title.substring(0, 18) + "...";
}
g2d.drawString(title, metadataX, y);
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
g2d.setFont(new Font("Arial Black", Font.BOLD, 20));
String formattedType = ActivityFormatter.formatActivityType(activity.getActivityType()).toUpperCase();
// Draw border box around type
int typeWidth = g2d.getFontMetrics().stringWidth(formattedType);
int boxPadding = 12;
g2d.setColor(neonCyan);
g2d.setStroke(new BasicStroke(3.0f));
g2d.drawRect(metadataX - boxPadding, y - 25, typeWidth + boxPadding * 2, 35);
g2d.drawString(formattedType, metadataX, y);
y += lineHeight + 10;
// Distance with neon orange value
if (activity.getTotalDistance() != null) {
g2d.setFont(new Font("Arial Black", Font.BOLD, 40));
g2d.setColor(neonOrange);
String distance = String.format("%.2f", activity.getTotalDistance().doubleValue() / 1000.0);
g2d.drawString(distance, metadataX, y);
g2d.setFont(new Font("Arial Black", Font.BOLD, 22));
g2d.setColor(Color.WHITE);
int distanceWidth = g2d.getFontMetrics(new Font("Arial Black", Font.BOLD, 40)).stringWidth(distance);
g2d.drawString("KM", metadataX + distanceWidth + 10, y);
g2d.setFont(new Font("Arial Black", Font.PLAIN, 16));
g2d.setColor(new Color(180, 180, 180));
g2d.drawString("DISTANCE", metadataX, y + 22);
y += lineHeight + 35;
}
// Duration with neon cyan value
if (activity.getTotalDurationSeconds() != null) {
long hours = activity.getTotalDurationSeconds() / 3600;
long minutes = (activity.getTotalDurationSeconds() % 3600) / 60;
long seconds = activity.getTotalDurationSeconds() % 60;
g2d.setFont(new Font("Arial Black", Font.BOLD, 40));
g2d.setColor(neonCyan);
String duration;
if (hours > 0) {
duration = String.format("%d:%02d:%02d", hours, minutes, seconds);
} else {
duration = String.format("%d:%02d", minutes, seconds);
}
g2d.drawString(duration, metadataX, y);
g2d.setFont(new Font("Arial Black", Font.PLAIN, 16));
g2d.setColor(new Color(180, 180, 180));
g2d.drawString("DURATION", metadataX, y + 22);
y += lineHeight + 35;
}
// Elevation gain with neon green value (only for outdoor activities)
if (activity.getElevationGain() != null && !isIndoorActivity) {
g2d.setFont(new Font("Arial Black", Font.BOLD, 40));
g2d.setColor(neonGreen);
String elevation = String.format("%.0f", activity.getElevationGain());
g2d.drawString(elevation, metadataX, y);
g2d.setFont(new Font("Arial Black", Font.BOLD, 22));
g2d.setColor(Color.WHITE);
int elevationWidth = g2d.getFontMetrics(new Font("Arial Black", Font.BOLD, 40)).stringWidth(elevation);
g2d.drawString("M", metadataX + elevationWidth + 10, y);
g2d.setFont(new Font("Arial Black", Font.PLAIN, 16));
g2d.setColor(new Color(180, 180, 180));
g2d.drawString("ELEVATION", metadataX, y + 22);
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
g2d.setFont(new Font("Arial Black", Font.BOLD, 28));
g2d.setColor(neonPink);
g2d.drawString("FITPUB", metadataX, height - 50);
// Add decorative line above branding
g2d.setColor(neonCyan);
g2d.setStroke(new BasicStroke(3.0f));
g2d.drawLine(metadataX, height - 75, metadataX + 150, height - 75);
}
/**
* Helper to safely extract Double from Map.
*/
private Double getDouble(Map<String, Object> map, String key) {
Object value = map.get(key);
if (value instanceof Number) {
return ((Number) value).doubleValue();
}
return null;
}
/**
* Get the file path for an activity image.
*/
public File getActivityImageFile(UUID activityId) {
return new File(imagesPath, activityId + ".png");
}
/**
* Parses track points from JSONB string.
*/
private List<Map<String, Object>> parseTrackPoints(String trackPointsJson) {
if (trackPointsJson == null || trackPointsJson.isEmpty()) {
return null;
}
try {
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(trackPointsJson);
if (root.isArray()) {
List<Map<String, Object>> trackPoints = new java.util.ArrayList<>();
for (com.fasterxml.jackson.databind.JsonNode node : root) {
Map<String, Object> point = new java.util.LinkedHashMap<>();
if (node.has("latitude")) point.put("latitude", node.get("latitude").asDouble());
if (node.has("longitude")) point.put("longitude", node.get("longitude").asDouble());
if (node.has("elevation")) point.put("elevation", node.get("elevation").asDouble());
trackPoints.add(point);
}
return trackPoints;
}
} catch (Exception e) {
log.error("Error parsing track points JSON: " + e.getMessage(), e);
}
return null;
}
/**
* Calculate and cache track bounds with padding for consistent rendering.
*/
private TrackBounds calculateTrackBounds(Activity activity) {
List<Map<String, Object>> trackPoints = parseTrackPoints(activity.getTrackPointsJson());
if (trackPoints == null || trackPoints.isEmpty()) {
return null;
}
double minLat = Double.MAX_VALUE, maxLat = -Double.MAX_VALUE;
double minLon = Double.MAX_VALUE, maxLon = -Double.MAX_VALUE;
for (Map<String, Object> point : trackPoints) {
Double lat = getDouble(point, "latitude");
Double lon = getDouble(point, "longitude");
if (lat != null && lon != null) {
minLat = Math.min(minLat, lat);
maxLat = Math.max(maxLat, lat);
minLon = Math.min(minLon, lon);
maxLon = Math.max(maxLon, lon);
}
}
// Add padding
double latRange = maxLat - minLat;
double lonRange = maxLon - minLon;
double padding = 0.1; // 10% padding
minLat -= latRange * padding;
maxLat += latRange * padding;
minLon -= lonRange * padding;
maxLon += lonRange * padding;
return new TrackBounds(minLat, maxLat, minLon, maxLon);
}
/**
* Convert longitude to Web Mercator X coordinate (normalized 0-1).
* This must match the projection used by OsmTileRenderer.
*/
private double longitudeToWebMercatorX(double lon) {
return (lon + 180.0) / 360.0;
}
/**
* Convert latitude to Web Mercator Y coordinate (normalized 0-1).
* This must match the projection used by OsmTileRenderer.
* Uses the same logarithmic transformation as OSM tiles.
*/
private double latitudeToWebMercatorY(double lat) {
return (1.0 - Math.log(Math.tan(Math.toRadians(lat)) +
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.
*/
private static class TrackBounds {
final double minLat;
final double maxLat;
final double minLon;
final double maxLon;
TrackBounds(double minLat, double maxLat, double minLon, double maxLon) {
this.minLat = minLat;
this.maxLat = maxLat;
this.minLon = minLon;
this.maxLon = maxLon;
}
}
}