Moar federation
This commit is contained in:
parent
a0d6518cd3
commit
6d42a4dc74
3 changed files with 358 additions and 3 deletions
|
|
@ -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()) {
|
||||
|
|
|
|||
288
src/main/java/org/operaton/fitpub/service/OsmTileRenderer.java
Normal file
288
src/main/java/org/operaton/fitpub/service/OsmTileRenderer.java
Normal 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) {}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue