Moar federation

This commit is contained in:
Tim Zöller 2025-12-03 21:48:07 +01:00
parent a0d6518cd3
commit 6d42a4dc74
3 changed files with 358 additions and 3 deletions

View file

@ -25,12 +25,17 @@ import java.util.UUID;
@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.
*
@ -52,9 +57,62 @@ public class ActivityImageService {
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
// Background
g2d.setColor(new Color(30, 30, 30)); // Dark background
g2d.fillRect(0, 0, width, height);
// Render background - either OSM tiles or dark background
if (osmTilesEnabled && activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) {
try {
// Calculate bounds from track points
List<Map<String, Object>> trackPoints = parseTrackPoints(activity.getTrackPointsJson());
if (trackPoints != null && !trackPoints.isEmpty()) {
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;
// Render OSM tiles for left 60% of image (track area)
int trackWidth = (int) (width * 0.6);
BufferedImage mapTiles = osmTileRenderer.renderMapWithTiles(
minLat, maxLat, minLon, maxLon, trackWidth, height);
g2d.drawImage(mapTiles, 0, 0, null);
// Dark background for metadata area (right 40%)
g2d.setColor(new Color(30, 30, 30));
g2d.fillRect(trackWidth, 0, width - trackWidth, height);
log.debug("Rendered OSM tiles for activity {}", activity.getId());
} else {
// Fallback to dark background
g2d.setColor(new Color(30, 30, 30));
g2d.fillRect(0, 0, width, height);
}
} catch (Exception e) {
log.warn("Failed to render OSM tiles, using dark background: {}", e.getMessage());
// Fallback to dark background
g2d.setColor(new Color(30, 30, 30));
g2d.fillRect(0, 0, width, height);
}
} else {
// OSM tiles disabled or no track data - use dark background
g2d.setColor(new Color(30, 30, 30));
g2d.fillRect(0, 0, width, height);
}
// Draw track if available
if (activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) {

View file

@ -0,0 +1,288 @@
package org.operaton.fitpub.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Semaphore;
/**
* Service for rendering OpenStreetMap tiles into activity images.
* Implements OSM tile usage policy: proper User-Agent, rate limiting, and caching.
*/
@Service
@Slf4j
public class OsmTileRenderer {
private static final String TILE_SERVER_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
private static final int TILE_SIZE = 256; // Standard OSM tile size in pixels
private static final Duration TILE_CACHE_MAX_AGE = Duration.ofDays(30);
// Rate limiting: OSM policy requires max 2 tiles/second
private static final Semaphore rateLimiter = new Semaphore(1);
private static Instant lastRequestTime = Instant.now();
@Value("${fitpub.storage.tile-cache.path:${java.io.tmpdir}/fitpub/tiles}")
private String tileCachePath;
@Value("${fitpub.base-url}")
private String baseUrl;
private final HttpClient httpClient;
public OsmTileRenderer() {
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
}
/**
* Render a map image with OSM tiles covering the specified geographic bounds.
*
* @param minLat minimum latitude
* @param maxLat maximum latitude
* @param minLon minimum longitude
* @param maxLon maximum longitude
* @param width target image width in pixels
* @param height target image height in pixels
* @return BufferedImage with rendered map tiles
*/
public BufferedImage renderMapWithTiles(double minLat, double maxLat,
double minLon, double maxLon,
int width, int height) throws IOException {
// Calculate optimal zoom level for the given bounds and image size
int zoom = calculateOptimalZoom(minLat, maxLat, minLon, maxLon, width, height);
// Calculate tile coordinates covering the bounds
TileCoordinate topLeft = getTileCoordinate(maxLat, minLon, zoom);
TileCoordinate bottomRight = getTileCoordinate(minLat, maxLon, zoom);
log.debug("Rendering map tiles: zoom={}, tiles=({},{}) to ({},{})",
zoom, topLeft.x, topLeft.y, bottomRight.x, bottomRight.y);
// Calculate the size of the tile grid
int tilesX = bottomRight.x - topLeft.x + 1;
int tilesY = bottomRight.y - topLeft.y + 1;
// Limit tile count to prevent excessive downloads
if (tilesX * tilesY > 50) {
log.warn("Too many tiles required ({} tiles), using lower zoom", tilesX * tilesY);
zoom = Math.max(1, zoom - 1);
return renderMapWithTiles(minLat, maxLat, minLon, maxLon, width, height);
}
// Create base image for all tiles
int fullWidth = tilesX * TILE_SIZE;
int fullHeight = tilesY * TILE_SIZE;
BufferedImage fullMap = new BufferedImage(fullWidth, fullHeight, BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = fullMap.createGraphics();
// Download and composite tiles
for (int x = topLeft.x; x <= bottomRight.x; x++) {
for (int y = topLeft.y; y <= bottomRight.y; y++) {
try {
BufferedImage tile = getTile(zoom, x, y);
int drawX = (x - topLeft.x) * TILE_SIZE;
int drawY = (y - topLeft.y) * TILE_SIZE;
g2d.drawImage(tile, drawX, drawY, null);
} catch (Exception e) {
log.warn("Failed to load tile {}/{}/{}: {}", zoom, x, y, e.getMessage());
// Draw a placeholder gray tile
g2d.setColor(new Color(200, 200, 200));
int drawX = (x - topLeft.x) * TILE_SIZE;
int drawY = (y - topLeft.y) * TILE_SIZE;
g2d.fillRect(drawX, drawY, TILE_SIZE, TILE_SIZE);
}
}
}
g2d.dispose();
// Calculate crop area to match the exact geographic bounds
double topLeftPixelX = longitudeToPixel(minLon, zoom);
double topLeftPixelY = latitudeToPixel(maxLat, zoom);
double bottomRightPixelX = longitudeToPixel(maxLon, zoom);
double bottomRightPixelY = latitudeToPixel(minLat, zoom);
int cropX = (int) (topLeftPixelX - topLeft.x * TILE_SIZE);
int cropY = (int) (topLeftPixelY - topLeft.y * TILE_SIZE);
int cropWidth = (int) (bottomRightPixelX - topLeftPixelX);
int cropHeight = (int) (bottomRightPixelY - topLeftPixelY);
// Crop to exact bounds
BufferedImage croppedMap = fullMap.getSubimage(
Math.max(0, cropX),
Math.max(0, cropY),
Math.min(cropWidth, fullWidth - cropX),
Math.min(cropHeight, fullHeight - cropY)
);
// Scale to target dimensions
BufferedImage scaledMap = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = scaledMap.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.drawImage(croppedMap, 0, 0, width, height, null);
g.dispose();
return scaledMap;
}
/**
* Get a single tile, either from cache or by downloading.
*/
private BufferedImage getTile(int zoom, int x, int y) throws IOException, InterruptedException {
// Check cache first
File cacheFile = getTileCacheFile(zoom, x, y);
if (cacheFile.exists() && !isCacheExpired(cacheFile)) {
try {
return ImageIO.read(cacheFile);
} catch (IOException e) {
log.warn("Failed to read cached tile, will re-download: {}", e.getMessage());
cacheFile.delete();
}
}
// Download tile with rate limiting
return downloadTile(zoom, x, y, cacheFile);
}
/**
* Download a tile from OSM tile server with proper rate limiting and User-Agent.
*/
private BufferedImage downloadTile(int zoom, int x, int y, File cacheFile)
throws IOException, InterruptedException {
// Rate limiting: max 2 requests per second (500ms between requests)
rateLimiter.acquire();
try {
Duration timeSinceLastRequest = Duration.between(lastRequestTime, Instant.now());
if (timeSinceLastRequest.toMillis() < 500) {
Thread.sleep(500 - timeSinceLastRequest.toMillis());
}
lastRequestTime = Instant.now();
String url = TILE_SERVER_URL
.replace("{z}", String.valueOf(zoom))
.replace("{x}", String.valueOf(x))
.replace("{y}", String.valueOf(y));
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("User-Agent", "FitPub/1.0 (" + baseUrl + "; contact via repository)")
.timeout(Duration.ofSeconds(10))
.GET()
.build();
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() == 200) {
byte[] imageData = response.body();
// Save to cache
cacheFile.getParentFile().mkdirs();
Files.write(cacheFile.toPath(), imageData);
// Read and return image
return ImageIO.read(new java.io.ByteArrayInputStream(imageData));
} else {
throw new IOException("Failed to download tile: HTTP " + response.statusCode());
}
} finally {
rateLimiter.release();
}
}
/**
* Get the cache file path for a tile.
*/
private File getTileCacheFile(int zoom, int x, int y) {
return new File(tileCachePath, String.format("%d/%d/%d.png", zoom, x, y));
}
/**
* Check if a cached tile has expired (older than 30 days).
*/
private boolean isCacheExpired(File cacheFile) {
try {
Instant fileTime = Files.getLastModifiedTime(cacheFile.toPath()).toInstant();
Duration age = Duration.between(fileTime, Instant.now());
return age.compareTo(TILE_CACHE_MAX_AGE) > 0;
} catch (IOException e) {
return true;
}
}
/**
* Calculate optimal zoom level for the given bounds and image size.
*/
private int calculateOptimalZoom(double minLat, double maxLat,
double minLon, double maxLon,
int width, int height) {
// Try different zoom levels and pick the one that best fits
for (int zoom = 18; zoom >= 1; zoom--) {
TileCoordinate topLeft = getTileCoordinate(maxLat, minLon, zoom);
TileCoordinate bottomRight = getTileCoordinate(minLat, maxLon, zoom);
int tilesX = bottomRight.x - topLeft.x + 1;
int tilesY = bottomRight.y - topLeft.y + 1;
int pixelWidth = tilesX * TILE_SIZE;
int pixelHeight = tilesY * TILE_SIZE;
// Use this zoom if it provides enough resolution
if (pixelWidth >= width * 0.8 && pixelHeight >= height * 0.8 && tilesX * tilesY <= 20) {
return zoom;
}
}
return 12; // Default fallback
}
/**
* Convert latitude/longitude to tile coordinates at a given zoom level.
*/
private TileCoordinate getTileCoordinate(double lat, double lon, int zoom) {
int x = (int) Math.floor((lon + 180.0) / 360.0 * (1 << zoom));
int y = (int) Math.floor((1.0 - Math.log(Math.tan(Math.toRadians(lat)) +
1.0 / Math.cos(Math.toRadians(lat))) / Math.PI) / 2.0 * (1 << zoom));
return new TileCoordinate(x, y);
}
/**
* Convert longitude to pixel X coordinate at a given zoom level.
*/
private double longitudeToPixel(double lon, int zoom) {
return (lon + 180.0) / 360.0 * (1 << zoom) * TILE_SIZE;
}
/**
* Convert latitude to pixel Y coordinate at a given zoom level.
*/
private double latitudeToPixel(double lat, int zoom) {
return (1.0 - Math.log(Math.tan(Math.toRadians(lat)) +
1.0 / Math.cos(Math.toRadians(lat))) / Math.PI) / 2.0 * (1 << zoom) * TILE_SIZE;
}
/**
* Simple record to hold tile coordinates.
*/
private record TileCoordinate(int x, int y) {}
}

View file

@ -71,6 +71,15 @@ fitpub:
fit-files:
enabled: true
retention-days: 365
images:
path: ${FITPUB_IMAGES_PATH:${java.io.tmpdir}/fitpub/images}
tile-cache:
path: ${FITPUB_TILE_CACHE_PATH:${java.io.tmpdir}/fitpub/tiles}
# Image generation settings
image:
osm-tiles:
enabled: ${OSM_TILES_ENABLED:true}
# Logging configuration
logging: