From f32dcc6b23f06fb0192355cdb4abc615bafc6cb7 Mon Sep 17 00:00:00 2001 From: Daniel Graf Date: Wed, 17 Dec 2025 14:57:29 +0100 Subject: [PATCH] 535 map display over multiple years (#558) --- README.md | 1 + docker-compose-dev.yml | 37 +++ docker-compose.yml | 37 +++ .../reitti/config/RabbitMQConfig.java | 15 -- .../ReittiIntegrationController.java | 11 + .../reitti/controller/TimelineController.java | 14 +- .../UserSettingsControllerAdvice.java | 3 +- .../api/ProcessedVisitApiController.java | 164 +++++++++++++ .../controller/api/TileProxyController.java | 61 +++++ .../settings/UserSettingsController.java | 3 +- .../reitti/dto/ConfigurationForm.java | 10 +- .../reitti/dto/ProcessedVisitResponse.java | 79 +++++++ .../reitti/dto/UserTimelineData.java | 3 +- .../service/DefaultImportProcessor.java | 11 +- .../reitti/service/QueueStatsService.java | 38 ++- .../service/TilesCustomizationProvider.java | 26 +-- .../integration/ReittiIntegrationService.java | 53 ++++- .../UnifiedLocationProcessingService.java | 4 +- src/main/resources/application-dev.properties | 2 + .../resources/application-docker.properties | 1 + src/main/resources/application.properties | 1 + .../static/js/canvas-visit-renderer.js | 145 ++++++++++++ .../static/js/raw-location-loader.js | 13 +- .../templates/fragments/timeline.html | 1 + src/main/resources/templates/index.html | 220 ++++++++++-------- .../dedicatedcode/reitti/TestingService.java | 1 - .../TilesCustomizationProviderTest.java | 41 +++- 27 files changed, 825 insertions(+), 170 deletions(-) create mode 100644 src/main/java/com/dedicatedcode/reitti/controller/api/ProcessedVisitApiController.java create mode 100644 src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java create mode 100644 src/main/java/com/dedicatedcode/reitti/dto/ProcessedVisitResponse.java create mode 100644 src/main/resources/static/js/canvas-visit-renderer.js diff --git a/README.md b/README.md index 7b4303f1..0aafd651 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,7 @@ The included `docker-compose.yml` provides a complete setup with: | `PHOTON_BASE_URL` | Base URL for Photon geocoding service | | | | `PROCESSING_WAIT_TIME` | How many seconds to wait after the last data input before starting to process all unprocessed data. (⚠️ This needs to be lower than your integrated app reports data in Reitti) | 15 | 15 | | `DANGEROUS_LIFE` | Enables data management features that can reset/delete all database data (⚠️ USE WITH CAUTION) | false | true | +| `TILES_CACHE` | The url of the tile caching proxy (Set to empty value to disable the cache) | http://tile-cache | | | `CUSTOM_TILES_SERVICE` | Custom tile service URL template | | https://tiles.example.com/{z}/{x}/{y}.png | | `CUSTOM_TILES_ATTRIBUTION` | Custom attribution text for the tile service | | | | `PROCESSING_BATCH_SIZE` | How many geo points should we handle at once. For low-memory environment it could be needed to set this to 100. | 1000 | 100 | diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 9d94425c..7bf4738c 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -66,6 +66,43 @@ services: - "8083:8083" volumes: - ot-recorder-data:/store + + tile-cache: + image: nginx:alpine + environment: + NGINX_CACHE_SIZE: 1g + ports: + - "8084:80" + command: | + sh -c " + mkdir -p /var/cache/nginx/tiles + cat > /etc/nginx/nginx.conf << 'EOF' + events { + worker_connections 1024; + } + http { + proxy_cache_path /var/cache/nginx/tiles levels=1:2 keys_zone=tiles:10m max_size=1g inactive=30d use_temp_path=off; + + server { + listen 80; + location / { + proxy_pass https://tile.openstreetmap.org/; + proxy_set_header Host tile.openstreetmap.org; + proxy_set_header User-Agent "Reitti/1.0"; + proxy_cache tiles; + proxy_cache_valid 200 30d; + proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; + } + } + } + EOF + nginx -g 'daemon off;'" + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/0/0/0.png"] + interval: 30s + timeout: 10s + retries: 3 volumes: postgis-data: redis-data: diff --git a/docker-compose.yml b/docker-compose.yml index 8e6395d7..c494bc76 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,8 @@ services: restart: true redis: condition: service_healthy + tile-cache: + condition: service_healthy volumes: - reitti-data:/data/ environment: @@ -60,6 +62,41 @@ services: - REGION=de #set your main country code here to save space or drop this line to fetch the whole index. volumes: - photon-data:/photon/data + + tile-cache: + image: nginx:alpine + environment: + NGINX_CACHE_SIZE: 1g + command: | + sh -c " + mkdir -p /var/cache/nginx/tiles + cat > /etc/nginx/nginx.conf << 'EOF' + events { + worker_connections 1024; + } + http { + proxy_cache_path /var/cache/nginx/tiles levels=1:2 keys_zone=tiles:10m max_size=1g inactive=30d use_temp_path=off; + + server { + listen 80; + location / { + proxy_pass https://tile.openstreetmap.org/; + proxy_set_header Host tile.openstreetmap.org; + proxy_set_header User-Agent "Reitti/1.0"; + proxy_cache tiles; + proxy_cache_valid 200 30d; + proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; + } + } + } + EOF + nginx -g 'daemon off;'" + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/0/0/0.png"] + interval: 30s + timeout: 10s + retries: 3 volumes: postgis-data: rabbitmq-data: diff --git a/src/main/java/com/dedicatedcode/reitti/config/RabbitMQConfig.java b/src/main/java/com/dedicatedcode/reitti/config/RabbitMQConfig.java index d799dffc..99a4ab2e 100644 --- a/src/main/java/com/dedicatedcode/reitti/config/RabbitMQConfig.java +++ b/src/main/java/com/dedicatedcode/reitti/config/RabbitMQConfig.java @@ -12,8 +12,6 @@ import org.springframework.context.annotation.Configuration; public class RabbitMQConfig { public static final String EXCHANGE_NAME = "reitti-exchange"; - public static final String LOCATION_DATA_QUEUE = "reitti.location.data.v2"; - public static final String LOCATION_DATA_ROUTING_KEY = "reitti.location.data.v2"; public static final String SIGNIFICANT_PLACE_QUEUE = "reitti.place.created.v2"; public static final String SIGNIFICANT_PLACE_ROUTING_KEY = "reitti.place.created.v2"; public static final String RECALCULATE_TRIP_QUEUE = "reitti.trip.recalculate.v2"; @@ -39,14 +37,6 @@ public class RabbitMQConfig { return new TopicExchange(DLX_NAME); } - @Bean - public Queue locationDataQueue() { - return QueueBuilder.durable(LOCATION_DATA_QUEUE) - .withArgument("x-dead-letter-exchange", DLX_NAME) - .withArgument("x-dead-letter-routing-key", DLQ_NAME) - .build(); - } - @Bean public Queue recaluclateTripQueue() { return QueueBuilder.durable(RECALCULATE_TRIP_QUEUE) @@ -79,11 +69,6 @@ public class RabbitMQConfig { .build(); } - @Bean - public Binding locationDataBinding(Queue locationDataQueue, TopicExchange exchange) { - return BindingBuilder.bind(locationDataQueue).to(exchange).with(LOCATION_DATA_ROUTING_KEY); - } - @Bean public Binding significantPlaceBinding(Queue significantPlaceQueue, TopicExchange exchange) { return BindingBuilder.bind(significantPlaceQueue).to(exchange).with(SIGNIFICANT_PLACE_ROUTING_KEY); diff --git a/src/main/java/com/dedicatedcode/reitti/controller/ReittiIntegrationController.java b/src/main/java/com/dedicatedcode/reitti/controller/ReittiIntegrationController.java index 683219bf..4c233aa6 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/ReittiIntegrationController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/ReittiIntegrationController.java @@ -58,4 +58,15 @@ public class ReittiIntegrationController { @RequestParam(required = false) Integer zoom) { return ResponseEntity.ok(Map.of("points", reittiIntegrationService.getRawLocationData(user, integrationId, startDate, endDate, zoom, timezone))); } + + @GetMapping(value = "/visits/{integrationId}", produces = MediaType.APPLICATION_JSON_VALUE) + @ResponseBody + public ResponseEntity getVisits(@AuthenticationPrincipal User user, + @PathVariable Long integrationId, + @RequestParam String startDate, + @RequestParam String endDate, + @RequestParam(required = false, defaultValue = "UTC") String timezone, + @RequestParam(required = false) Integer zoom) { + return ResponseEntity.ok(reittiIntegrationService.getVisits(user, integrationId, startDate, endDate, zoom, timezone)); + } } diff --git a/src/main/java/com/dedicatedcode/reitti/controller/TimelineController.java b/src/main/java/com/dedicatedcode/reitti/controller/TimelineController.java index 7bb52b5a..1f94512c 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/TimelineController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/TimelineController.java @@ -204,9 +204,10 @@ public class TimelineController { List currentUserEntries = this.timelineService.buildTimelineEntries(user, userTimezone, selectedDate, startOfDay, endOfDay); String currentUserRawLocationPointsUrl = String.format("/api/v1/raw-location-points/%d?date=%s&timezone=%s", user.getId(), date, timezone); + String currentUserProcessedVisitsUrl = String.format("/api/v1/visits/%d?date=%s&timezone=%s", user.getId(), date, timezone); String currentUserAvatarUrl = this.avatarService.getInfo(user.getId()).map(avatarInfo -> String.format("/avatars/%d?ts=%s", user.getId(), avatarInfo.updatedAt())).orElse(String.format("/avatars/%d", user.getId())); String currentUserInitials = this.avatarService.generateInitials(user.getDisplayName()); - allUsersData.add(new UserTimelineData(user.getId() + "", user.getDisplayName(), currentUserInitials, currentUserAvatarUrl, null, currentUserEntries, currentUserRawLocationPointsUrl)); + allUsersData.add(new UserTimelineData(user.getId() + "", user.getDisplayName(), currentUserInitials, currentUserAvatarUrl, null, currentUserEntries, currentUserRawLocationPointsUrl, currentUserProcessedVisitsUrl)); if (authorities.contains("ROLE_USER") || authorities.contains("ROLE_ADMIN")) { allUsersData.addAll(this.reittiIntegrationService.getTimelineData(user, selectedDate, userTimezone)); @@ -264,9 +265,10 @@ public class TimelineController { } String currentUserRawLocationPointsUrl = String.format("/api/v1/raw-location-points/%d?startDate=%s&endDate=%s&timezone=%s", user.getId(), selectedStartDate, selectedEndDate, timezone); + String currentUserProcessedVisitsUrl = String.format("/api/v1/visits/%d?startDate=%s&endDate=%s&timezone=%s", user.getId(), selectedStartDate, selectedEndDate, timezone); String currentUserAvatarUrl = this.avatarService.getInfo(user.getId()).map(avatarInfo -> String.format("/avatars/%d?ts=%s", user.getId(), avatarInfo.updatedAt())).orElse(String.format("/avatars/%d", user.getId())); String currentUserInitials = this.avatarService.generateInitials(user.getDisplayName()); - allUsersData.add(new UserTimelineData(user.getId() + "", user.getDisplayName(), currentUserInitials, currentUserAvatarUrl, userSettings.getColor(), currentUserEntries, currentUserRawLocationPointsUrl)); + allUsersData.add(new UserTimelineData(user.getId() + "", user.getDisplayName(), currentUserInitials, currentUserAvatarUrl, userSettings.getColor(), currentUserEntries, currentUserRawLocationPointsUrl, currentUserProcessedVisitsUrl)); if (authorities.contains("ROLE_USER") || authorities.contains("ROLE_ADMIN")) { allUsersData.addAll(this.reittiIntegrationService.getTimelineDataRange(user, selectedStartDate, selectedEndDate, userTimezone)); @@ -292,6 +294,7 @@ public class TimelineController { return sharedWithUserOpt.map(sharedWithUser -> { List userTimelineEntries = this.timelineService.buildTimelineEntries(sharedWithUser, userTimezone, selectedDate, startOfDay, endOfDay); String currentUserRawLocationPointsUrl = String.format("/api/v1/raw-location-points/%d?date=%s&timezone=%s", sharedWithUser.getId(), selectedDate, userTimezone.getId()); + String currentUserProcessedVisitsUrl = String.format("/api/v1/visits/%d?date=%s&timezone=%s", sharedWithUser.getId(), selectedDate, userTimezone.getId()); String currentUserAvatarUrl = this.avatarService.getInfo(sharedWithUser.getId()).map(avatarInfo -> String.format("/avatars/%d?ts=%s", sharedWithUser.getId(), avatarInfo.updatedAt())).orElse(String.format("/avatars/%d", sharedWithUser.getId())); String currentUserInitials = this.avatarService.generateInitials(sharedWithUser.getDisplayName()); @@ -301,7 +304,8 @@ public class TimelineController { currentUserAvatarUrl, u.getColor(), userTimelineEntries, - currentUserRawLocationPointsUrl); + currentUserRawLocationPointsUrl, + currentUserProcessedVisitsUrl); }); }) .filter(Optional::isPresent) @@ -320,6 +324,7 @@ public class TimelineController { List userTimelineEntries = this.timelineService.buildTimelineEntries(sharedWithUser, userTimezone, startDate, startOfRange, endOfRange); String currentUserRawLocationPointsUrl = String.format("/api/v1/raw-location-points/%d?startDate=%s&endDate=%s&timezone=%s", sharedWithUser.getId(), startDate, endDate, userTimezone.getId()); + String currentUserProcessedVisitsUrl = String.format("/api/v1/visits/%d?startDate=%s&endDate=%s&timezone=%s", sharedWithUser.getId(), startDate, endDate, userTimezone.getId()); String currentUserAvatarUrl = this.avatarService.getInfo(sharedWithUser.getId()).map(avatarInfo -> String.format("/avatars/%d?ts=%s", sharedWithUser.getId(), avatarInfo.updatedAt())).orElse(String.format("/avatars/%d", sharedWithUser.getId())); String currentUserInitials = this.avatarService.generateInitials(sharedWithUser.getDisplayName()); @@ -329,7 +334,8 @@ public class TimelineController { currentUserAvatarUrl, u.getColor(), userTimelineEntries, - currentUserRawLocationPointsUrl); + currentUserRawLocationPointsUrl, + currentUserProcessedVisitsUrl); }); }) .filter(Optional::isPresent) diff --git a/src/main/java/com/dedicatedcode/reitti/controller/UserSettingsControllerAdvice.java b/src/main/java/com/dedicatedcode/reitti/controller/UserSettingsControllerAdvice.java index c65f9209..e0247ba5 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/UserSettingsControllerAdvice.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/UserSettingsControllerAdvice.java @@ -33,7 +33,8 @@ public class UserSettingsControllerAdvice { public UserSettingsControllerAdvice(UserJdbcService userJdbcService, UserSettingsJdbcService userSettingsJdbcService, - TilesCustomizationProvider tilesCustomizationProvider, RawLocationPointJdbcService rawLocationPointJdbcService) { + TilesCustomizationProvider tilesCustomizationProvider, + RawLocationPointJdbcService rawLocationPointJdbcService) { this.userJdbcService = userJdbcService; this.userSettingsJdbcService = userSettingsJdbcService; this.tilesCustomizationProvider = tilesCustomizationProvider; diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/ProcessedVisitApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/ProcessedVisitApiController.java new file mode 100644 index 00000000..79788cfb --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/ProcessedVisitApiController.java @@ -0,0 +1,164 @@ +package com.dedicatedcode.reitti.controller.api; + +import com.dedicatedcode.reitti.dto.ProcessedVisitResponse; +import com.dedicatedcode.reitti.model.geo.ProcessedVisit; +import com.dedicatedcode.reitti.model.geo.SignificantPlace; +import com.dedicatedcode.reitti.model.security.MagicLinkAccessLevel; +import com.dedicatedcode.reitti.model.security.TokenUser; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.ProcessedVisitJdbcService; +import com.dedicatedcode.reitti.repository.UserJdbcService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeParseException; +import java.util.*; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/v1") +public class ProcessedVisitApiController { + + private static final Logger logger = LoggerFactory.getLogger(ProcessedVisitApiController.class); + + private final ProcessedVisitJdbcService processedVisitJdbcService; + private final UserJdbcService userJdbcService; + + @Autowired + public ProcessedVisitApiController(ProcessedVisitJdbcService processedVisitJdbcService, + UserJdbcService userJdbcService) { + this.processedVisitJdbcService = processedVisitJdbcService; + this.userJdbcService = userJdbcService; + } + + @GetMapping("/visits") + public ResponseEntity getProcessedVisitsForCurrentUser(@AuthenticationPrincipal User user, + @RequestParam(required = false) String date, + @RequestParam(required = false) String startDate, + @RequestParam(required = false) String endDate, + @RequestParam(required = false, defaultValue = "UTC") String timezone) { + return this.getProcessedVisits(user, user.getId(), date, startDate, endDate, timezone); + } + + @GetMapping("/visits/{userId}") + public ResponseEntity getProcessedVisits(@AuthenticationPrincipal User user, + @PathVariable Long userId, + @RequestParam(required = false) String date, + @RequestParam(required = false) String startDate, + @RequestParam(required = false) String endDate, + @RequestParam(required = false, defaultValue = "UTC") String timezone) { + try { + ZoneId userTimezone = ZoneId.of(timezone); + Instant startOfRange = null; + Instant endOfRange = null; + + // Support both single date and date range + if (startDate != null && endDate != null) { + // First try to parse them as date time + try { + LocalDateTime startTimestamp = LocalDateTime.parse(startDate); + LocalDateTime endTimestamp = LocalDateTime.parse(endDate); + startOfRange = startTimestamp.atZone(userTimezone).toInstant(); + endOfRange = endTimestamp.atZone(userTimezone).toInstant(); + } catch (DateTimeParseException ignored) { + } + + if (startOfRange == null && endOfRange == null) { + LocalDate selectedStartDate = LocalDate.parse(startDate); + LocalDate selectedEndDate = LocalDate.parse(endDate); + startOfRange = selectedStartDate.atStartOfDay(userTimezone).toInstant(); + endOfRange = selectedEndDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1); + } + } else if (date != null) { + // Single date mode (backward compatibility) + LocalDate selectedDate = LocalDate.parse(date); + startOfRange = selectedDate.atStartOfDay(userTimezone).toInstant(); + endOfRange = selectedDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1); + } else { + return ResponseEntity.badRequest().body(Map.of( + "error", "Either 'date' or both 'startDate' and 'endDate' must be provided" + )); + } + + // Check access permissions + boolean hasAccess = true; + if (user instanceof TokenUser) { + if (!Objects.equals(user.getId(), userId)) { + throw new IllegalAccessException("User not allowed to fetch processed visits for other users"); + } + + hasAccess = user.getAuthorities().stream().anyMatch(a -> + a.equals(MagicLinkAccessLevel.FULL_ACCESS.asAuthority()) || + a.equals(MagicLinkAccessLevel.ONLY_LIVE.asAuthority()) || + a.equals(MagicLinkAccessLevel.ONLY_LIVE_WITH_PHOTOS.asAuthority())); + } + + if (!hasAccess) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(Map.of("error", "Insufficient permissions to access processed visits")); + } + + // Get the user from the repository by userId + User userToFetchDataFrom = userJdbcService.findById(userId) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Fetch processed visits in the time range + List visits = processedVisitJdbcService.findByUserAndTimeOverlap( + userToFetchDataFrom, startOfRange, endOfRange); + + // Group visits by place and create response + Map> visitsByPlace = visits.stream() + .collect(Collectors.groupingBy(ProcessedVisit::getPlace)); + + List placeSummaries = visitsByPlace.entrySet().stream() + .map(entry -> { + SignificantPlace place = entry.getKey(); + List placeVisits = entry.getValue(); + + List visitDetails = placeVisits.stream() + .map(ProcessedVisitResponse.VisitDetail::new) + .collect(Collectors.toList()); + + long totalDuration = placeVisits.stream() + .mapToLong(ProcessedVisit::getDurationSeconds) + .sum(); + + return new ProcessedVisitResponse.PlaceVisitSummary( + place, visitDetails, totalDuration, placeVisits.size()); + }) + .sorted((a, b) -> Long.compare(b.getTotalDurationSeconds(), a.getTotalDurationSeconds())) + .collect(Collectors.toList()); + + return ResponseEntity.ok(new ProcessedVisitResponse(placeSummaries)); + + } catch (DateTimeParseException e) { + return ResponseEntity.badRequest().body(Map.of( + "error", "Invalid date format. Expected format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS" + )); + } catch (Exception e) { + logger.error("Error fetching processed visits", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Error fetching processed visits: " + e.getMessage())); + } + } + + private boolean isPlaceInBox(SignificantPlace place, Double minLat, Double maxLat, Double minLng, Double maxLng) { + if (place == null || place.getLatitudeCentroid() == null || place.getLongitudeCentroid() == null) { + return false; + } + + return place.getLatitudeCentroid() >= minLat && + place.getLatitudeCentroid() <= maxLat && + place.getLongitudeCentroid() >= minLng && + place.getLongitudeCentroid() <= maxLng; + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java new file mode 100644 index 00000000..6b6b8a6b --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java @@ -0,0 +1,61 @@ +package com.dedicatedcode.reitti.controller.api; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestTemplate; + +import java.util.concurrent.TimeUnit; + +@RestController +@RequestMapping("/api/v1/tiles") +@ConditionalOnProperty(name = "reitti.ui.tiles.cache.url") +public class TileProxyController { + private static final Logger log = LoggerFactory.getLogger(TileProxyController.class); + + private final RestTemplate restTemplate; + private final String tileCacheUrl; + + public TileProxyController(@Value("${reitti.ui.tiles.cache.url:http://tile-cache}") String tileCacheUrl) { + this.tileCacheUrl = tileCacheUrl; + this.restTemplate = new RestTemplate(); + } + + @GetMapping("/{z}/{x}/{y}.png") + public ResponseEntity getTile( + @PathVariable int z, + @PathVariable int x, + @PathVariable int y) { + + String tileUrl = String.format("%s/%d/%d/%d.png", tileCacheUrl, z, x, y); + + try { + log.trace("Fetching tile: {}/{}/{}", z, x, y); + + ResponseEntity response = restTemplate.getForEntity(tileUrl, byte[].class); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.IMAGE_PNG); + headers.setCacheControl(CacheControl.maxAge(30, TimeUnit.DAYS).cachePublic()); + + headers.add("Access-Control-Allow-Origin", "*"); + + return ResponseEntity.ok() + .headers(headers) + .body(response.getBody()); + + } catch (Exception e) { + log.warn("Failed to fetch tile {}/{}/{}: {}", z, x, y, e.getMessage()); + return ResponseEntity.notFound().build(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/UserSettingsController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/UserSettingsController.java index ac7cfdfe..2bc7d6aa 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/settings/UserSettingsController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/UserSettingsController.java @@ -63,7 +63,7 @@ public class UserSettingsController { // CSS file constraints private static final long MAX_CSS_SIZE = 1024 * 1024; // 1MB private static final String ALLOWED_CSS_CONTENT_TYPE = "text/css"; - private Map defaultColors; + private final Map defaultColors = new HashMap<>(); public UserSettingsController(UserJdbcService userJdbcService, UserService userService, UserSettingsJdbcService userSettingsJdbcService, @@ -84,7 +84,6 @@ public class UserSettingsController { this.localLoginDisabled = localLoginDisabled; this.oidcEnabled = oidcEnabled; this.dataManagementEnabled = dataManagementEnabled; - this.defaultColors = new HashMap<>(); this.defaultColors.put("#f1ba63","Default Gold"); this.defaultColors.put("#4a90e2","Ocean Blue"); this.defaultColors.put("#7ed321","Fresh Green"); diff --git a/src/main/java/com/dedicatedcode/reitti/dto/ConfigurationForm.java b/src/main/java/com/dedicatedcode/reitti/dto/ConfigurationForm.java index e124a966..8f87b126 100644 --- a/src/main/java/com/dedicatedcode/reitti/dto/ConfigurationForm.java +++ b/src/main/java/com/dedicatedcode/reitti/dto/ConfigurationForm.java @@ -120,11 +120,11 @@ public class ConfigurationForm { private static DetectionParameter.VisitMerging mapSensitivityToVisitMerging(int level) { return switch (level) { - case 1 -> new DetectionParameter.VisitMerging(48, 600, 400); // Low sensitivity - case 2 -> new DetectionParameter.VisitMerging(48, 450, 300); - case 3 -> new DetectionParameter.VisitMerging(48, 300, 200); // Medium (baseline) - case 4 -> new DetectionParameter.VisitMerging(48, 225, 150); - case 5 -> new DetectionParameter.VisitMerging(48, 150, 100); // High sensitivity + case 1 -> new DetectionParameter.VisitMerging(48, 600, 250); // Low sensitivity + case 2 -> new DetectionParameter.VisitMerging(48, 450, 200); + case 3 -> new DetectionParameter.VisitMerging(48, 300, 150); // Medium (baseline) + case 4 -> new DetectionParameter.VisitMerging(48, 225, 100); + case 5 -> new DetectionParameter.VisitMerging(48, 150, 50); // High sensitivity default -> throw new IllegalArgumentException("Unhandled level [" + level + "] detected!"); }; } diff --git a/src/main/java/com/dedicatedcode/reitti/dto/ProcessedVisitResponse.java b/src/main/java/com/dedicatedcode/reitti/dto/ProcessedVisitResponse.java new file mode 100644 index 00000000..3f57f2ab --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/dto/ProcessedVisitResponse.java @@ -0,0 +1,79 @@ +package com.dedicatedcode.reitti.dto; + +import com.dedicatedcode.reitti.model.geo.ProcessedVisit; +import com.dedicatedcode.reitti.model.geo.SignificantPlace; + +import java.util.List; + +public class ProcessedVisitResponse { + + public static class PlaceVisitSummary { + private final SignificantPlace place; + private final List visits; + private final long totalDurationSeconds; + private final int visitCount; + + public PlaceVisitSummary(SignificantPlace place, List visits, long totalDurationSeconds, int visitCount) { + this.place = place; + this.visits = visits; + this.totalDurationSeconds = totalDurationSeconds; + this.visitCount = visitCount; + } + + public SignificantPlace getPlace() { + return place; + } + + public List getVisits() { + return visits; + } + + public long getTotalDurationSeconds() { + return totalDurationSeconds; + } + + public int getVisitCount() { + return visitCount; + } + } + + public static class VisitDetail { + private final Long id; + private final String startTime; + private final String endTime; + private final long durationSeconds; + + public VisitDetail(ProcessedVisit visit) { + this.id = visit.getId(); + this.startTime = visit.getStartTime().toString(); + this.endTime = visit.getEndTime().toString(); + this.durationSeconds = visit.getDurationSeconds(); + } + + public Long getId() { + return id; + } + + public String getStartTime() { + return startTime; + } + + public String getEndTime() { + return endTime; + } + + public long getDurationSeconds() { + return durationSeconds; + } + } + + private final List places; + + public ProcessedVisitResponse(List places) { + this.places = places; + } + + public List getPlaces() { + return places; + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/dto/UserTimelineData.java b/src/main/java/com/dedicatedcode/reitti/dto/UserTimelineData.java index a2994ada..e541b0d4 100644 --- a/src/main/java/com/dedicatedcode/reitti/dto/UserTimelineData.java +++ b/src/main/java/com/dedicatedcode/reitti/dto/UserTimelineData.java @@ -9,6 +9,7 @@ public record UserTimelineData( String userAvatarUrl, String baseColor, List entries, - String rawLocationPointsUrl + String rawLocationPointsUrl, + String processedVisitsUrl ) { } diff --git a/src/main/java/com/dedicatedcode/reitti/service/DefaultImportProcessor.java b/src/main/java/com/dedicatedcode/reitti/service/DefaultImportProcessor.java index fbc071b6..2afc2bdb 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/DefaultImportProcessor.java +++ b/src/main/java/com/dedicatedcode/reitti/service/DefaultImportProcessor.java @@ -27,7 +27,7 @@ public class DefaultImportProcessor implements ImportProcessor { private final ProcessingPipelineTrigger processingPipelineTrigger; private final ScheduledExecutorService scheduler; private final ConcurrentHashMap> pendingTriggers; - private final ExecutorService importExecutors = Executors.newSingleThreadExecutor(); + private final ThreadPoolExecutor importExecutors = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>()); public DefaultImportProcessor( LocationDataIngestPipeline locationDataIngestPipeline, @@ -84,12 +84,17 @@ public class DefaultImportProcessor implements ImportProcessor { @PreDestroy public void shutdown() { + importExecutors.shutdown(); scheduler.shutdown(); try { + if (!importExecutors.awaitTermination(5, TimeUnit.SECONDS)) { + importExecutors.shutdownNow(); + } if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { scheduler.shutdownNow(); } } catch (InterruptedException e) { + importExecutors.shutdownNow(); scheduler.shutdownNow(); Thread.currentThread().interrupt(); } @@ -98,4 +103,8 @@ public class DefaultImportProcessor implements ImportProcessor { public int getBatchSize() { return batchSize; } + + public int getPendingTaskCount() { + return importExecutors.getQueue().size(); + } } diff --git a/src/main/java/com/dedicatedcode/reitti/service/QueueStatsService.java b/src/main/java/com/dedicatedcode/reitti/service/QueueStatsService.java index 9d200c87..21cd4d56 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/QueueStatsService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/QueueStatsService.java @@ -3,7 +3,6 @@ package com.dedicatedcode.reitti.service; import com.dedicatedcode.reitti.config.RabbitMQConfig; import com.dedicatedcode.reitti.service.processing.ProcessingPipelineTrigger; import org.springframework.amqp.rabbit.core.RabbitAdmin; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.MessageSource; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.stereotype.Service; @@ -19,14 +18,15 @@ import java.util.concurrent.ConcurrentHashMap; public class QueueStatsService { public static final String STAY_DETECTION_QUEUE = "reitti.visit.detection.v2"; + public static final String LOCATION_DATA_QUEUE = "reitti.location.data.v2"; private final RabbitAdmin rabbitAdmin; private final MessageSource messageSource; private final ProcessingPipelineTrigger processingPipelineTrigger; + private final DefaultImportProcessor defaultImportProcessor; private static final int LOOKBACK_HOURS = 24; private static final long DEFAULT_PROCESSING_TIME = 2000; private final List QUEUES = List.of( - RabbitMQConfig.LOCATION_DATA_QUEUE, RabbitMQConfig.SIGNIFICANT_PLACE_QUEUE, RabbitMQConfig.USER_EVENT_QUEUE ); @@ -35,23 +35,29 @@ public class QueueStatsService { private final Map previousMessageCounts = new ConcurrentHashMap<>(); - @Autowired - public QueueStatsService(RabbitAdmin rabbitAdmin, MessageSource messageSource, ProcessingPipelineTrigger processingPipelineTrigger) { + public QueueStatsService(RabbitAdmin rabbitAdmin, + MessageSource messageSource, + ProcessingPipelineTrigger processingPipelineTrigger, + DefaultImportProcessor defaultImportProcessor) { this.rabbitAdmin = rabbitAdmin; this.messageSource = messageSource; this.processingPipelineTrigger = processingPipelineTrigger; + this.defaultImportProcessor = defaultImportProcessor; QUEUES.forEach(queue -> { processingHistory.put(queue, new ArrayList<>()); previousMessageCounts.put(queue, 0); }); processingHistory.put(STAY_DETECTION_QUEUE, new ArrayList<>()); previousMessageCounts.put(STAY_DETECTION_QUEUE, 0); + processingHistory.put(LOCATION_DATA_QUEUE, new ArrayList<>()); + previousMessageCounts.put(LOCATION_DATA_QUEUE, 0); } public List getQueueStats() { List list = QUEUES.stream().map(this::getQueueStats).toList(); List result = new ArrayList<>(list); + result.add(0, getQueueStats(LOCATION_DATA_QUEUE)); result.add(1, getQueueStats(STAY_DETECTION_QUEUE)); return result; } @@ -60,7 +66,10 @@ public class QueueStatsService { int currentMessageCount; if (name.equals(STAY_DETECTION_QUEUE)) { currentMessageCount = this.processingPipelineTrigger.getPendingCount(); - udpatingStayDetectionQueue(currentMessageCount); + updatingStayDetectionQueue(currentMessageCount); + }else if (name.equals(LOCATION_DATA_QUEUE)) { + currentMessageCount = this.defaultImportProcessor.getPendingTaskCount(); + updatingLocationDataQueue(currentMessageCount); } else { currentMessageCount = getMessageCount(name); updateProcessingHistoryFromRabbitMQ(name, currentMessageCount); @@ -89,7 +98,7 @@ public class QueueStatsService { previousMessageCounts.put(queueName, currentMessageCount); } - private void udpatingStayDetectionQueue(int currentMessageCount) { + private void updatingStayDetectionQueue(int currentMessageCount) { Integer previousCount = previousMessageCounts.get(STAY_DETECTION_QUEUE); if (previousCount != null && currentMessageCount < previousCount) { @@ -103,6 +112,20 @@ public class QueueStatsService { previousMessageCounts.put(STAY_DETECTION_QUEUE, currentMessageCount); } + private void updatingLocationDataQueue(int currentMessageCount) { + Integer previousCount = previousMessageCounts.get(LOCATION_DATA_QUEUE); + + if (previousCount != null && currentMessageCount < previousCount) { + long processingTimePerMessage = estimateProcessingTimePerMessage(LOCATION_DATA_QUEUE); + List history = processingHistory.get(LOCATION_DATA_QUEUE); + LocalDateTime now = LocalDateTime.now(); + history.add(new ProcessingRecord(now, this.defaultImportProcessor.getPendingTaskCount(), processingTimePerMessage)); + cleanupOldRecords(history, now); + } + + previousMessageCounts.put(LOCATION_DATA_QUEUE, currentMessageCount); + } + private long estimateProcessingTimePerMessage(String queueName) { List history = processingHistory.get(queueName); @@ -199,14 +222,13 @@ public class QueueStatsService { private String getMessageKeyForQueue(String queueName, String suffix) { return switch (queueName) { - case RabbitMQConfig.LOCATION_DATA_QUEUE -> "queue.location.data." + suffix; case RabbitMQConfig.SIGNIFICANT_PLACE_QUEUE -> "queue.significant.place." + suffix; case RabbitMQConfig.USER_EVENT_QUEUE -> "queue.user.event." + suffix; case STAY_DETECTION_QUEUE -> "queue.stay.detection." + suffix; + case LOCATION_DATA_QUEUE -> "queue.location.data." + suffix; default -> "queue.unknown." + suffix; }; } private record ProcessingRecord(LocalDateTime timestamp, long numberOfMessages, long processingTimeMs) { } - } diff --git a/src/main/java/com/dedicatedcode/reitti/service/TilesCustomizationProvider.java b/src/main/java/com/dedicatedcode/reitti/service/TilesCustomizationProvider.java index 8c44b0ba..0e65e196 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/TilesCustomizationProvider.java +++ b/src/main/java/com/dedicatedcode/reitti/service/TilesCustomizationProvider.java @@ -7,27 +7,27 @@ import org.springframework.util.StringUtils; @Service public class TilesCustomizationProvider { - private final String defaultService; - private final String defaultAttribution; - - private final String customService; - private final String customAttribution; + private final UserSettingsDTO.TilesCustomizationDTO tilesConfiguration; public TilesCustomizationProvider( + @Value("${reitti.ui.tile.cache.url:null}") String cacheUrl, @Value("${reitti.ui.tiles.default.service}") String defaultService, @Value("${reitti.ui.tiles.default.attribution}") String defaultAttribution, @Value("${reitti.ui.tiles.custom.service:}") String customService, @Value("${reitti.ui.tiles.custom.attribution:}") String customAttribution) { - this.defaultService = defaultService; - this.defaultAttribution = defaultAttribution; - this.customService = customService; - this.customAttribution = customAttribution; + String serviceUrl; + if (StringUtils.hasText(cacheUrl)) { + serviceUrl = "/api/v1/tiles/{z}/{x}/{y}.png"; + } else if (StringUtils.hasText(customService)) { + serviceUrl = customService; + } else { + serviceUrl = defaultService; + } + String attribution = StringUtils.hasText(customAttribution) ? customAttribution : defaultAttribution; + this.tilesConfiguration = new UserSettingsDTO.TilesCustomizationDTO(serviceUrl, attribution); } public UserSettingsDTO.TilesCustomizationDTO getTilesConfiguration() { - return new UserSettingsDTO.TilesCustomizationDTO( - StringUtils.hasText(customService) ? customService : defaultService, - StringUtils.hasText(customAttribution) ? customAttribution : defaultAttribution - ); + return this.tilesConfiguration; } } diff --git a/src/main/java/com/dedicatedcode/reitti/service/integration/ReittiIntegrationService.java b/src/main/java/com/dedicatedcode/reitti/service/integration/ReittiIntegrationService.java index bb2a74fd..a884cd99 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/integration/ReittiIntegrationService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/integration/ReittiIntegrationService.java @@ -66,7 +66,8 @@ public class ReittiIntegrationService { "/reitti-integration/avatar/" + integration.getId(), integration.getColor(), timelineEntries, - String.format("/reitti-integration/raw-location-points/%d?date=%s&timezone=%s", integration.getId(), selectedDate, userTimezone)); + String.format("/reitti-integration/raw-location-points/%d?date=%s&timezone=%s", integration.getId(), selectedDate, userTimezone), + String.format("/reitti-integration/visits/%d?date=%s&timezone=%s", integration.getId(), selectedDate, userTimezone)); } catch (RequestFailedException e) { log.error("couldn't fetch user info for [{}]", integration, e); update(integration.withStatus(ReittiIntegration.Status.FAILED).withLastUsed(LocalDateTime.now()).withEnabled(false)); @@ -95,7 +96,8 @@ public class ReittiIntegrationService { "/reitti-integration/avatar/" + integration.getId(), integration.getColor(), timelineEntries, - String.format("/reitti-integration/raw-location-points/%d?startDate=%s&endDate=%s&timezone=%s", integration.getId(), startDate, endDate, userTimezone)); + String.format("/reitti-integration/raw-location-points/%d?startDate=%s&endDate=%s&timezone=%s", integration.getId(), startDate, endDate, userTimezone), + String.format("/reitti-integration/visits/%d?startDate=%s&endDate=%s&timezone=%s", integration.getId(), startDate, endDate, userTimezone)); } catch (RequestFailedException e) { log.error("couldn't fetch user info for [{}]", integration, e); update(integration.withStatus(ReittiIntegration.Status.FAILED).withLastUsed(LocalDateTime.now()).withEnabled(false)); @@ -142,6 +144,52 @@ public class ReittiIntegrationService { } } + public ProcessedVisitResponse getVisits(User user, Long integrationId, String startDate, String endDate, Integer zoom, String timezone) { + return this.jdbcService + .findByIdAndUser(integrationId,user) + .stream().filter(integration -> integration.isEnabled() && VALID_INTEGRATION_STATUS.contains(integration.getStatus())) + .map(integration -> { + + log.debug("Fetching visit data for [{}]", integration); + try { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-API-TOKEN", integration.getToken()); + HttpEntity entity = new HttpEntity<>(headers); + + String remoteUrl = integration.getUrl().endsWith("/") ? + integration.getUrl() + "api/v1/visits?startDate={startDate}&endDate={endDate}&timezone={timezone}&zoom={zoom}" : + integration.getUrl() + "/api/v1/visits?startDate={startDate}&endDate={endDate}&timezone={timezone}&zoom={zoom}"; + ResponseEntity remoteResponse = restTemplate.exchange( + remoteUrl, + HttpMethod.GET, + entity, + Map.class, + startDate, + endDate, + timezone, + zoom + ); + + if (remoteResponse.getStatusCode().is2xxSuccessful() && remoteResponse.getBody() != null && remoteResponse.getBody().containsKey("place")) { + update(integration.withStatus(ReittiIntegration.Status.ACTIVE).withLastUsed(LocalDateTime.now())); + return (ProcessedVisitResponse) remoteResponse.getBody(); + } else if (remoteResponse.getStatusCode().is4xxClientError()) { + throw new RequestFailedException(remoteUrl, remoteResponse.getStatusCode(), remoteResponse.getBody()); + } else { + throw new RequestTemporaryFailedException(remoteUrl, remoteResponse.getStatusCode(), remoteResponse.getBody()); + } + } catch (RequestFailedException e) { + log.error("couldn't fetch user info for [{}]", integration, e); + update(integration.withStatus(ReittiIntegration.Status.FAILED).withLastUsed(LocalDateTime.now()).withEnabled(false)); + } catch (RequestTemporaryFailedException e) { + log.warn("couldn't temporarily fetch user info for [{}]", integration, e); + update(integration.withStatus(ReittiIntegration.Status.RECOVERABLE).withLastUsed(LocalDateTime.now())); + } + return null; + }) + .filter(Objects::nonNull) + .findFirst().orElse(null); + } public List getRawLocationData(User user, Long integrationId, String startDate, String endDate, Integer zoom, String timezone) { return this.jdbcService .findByIdAndUser(integrationId,user) @@ -425,4 +473,5 @@ public class ReittiIntegrationService { public Optional getUserIdForSubscription(String subscriptionId) { return Optional.ofNullable(this.userForSubscriptions.get(subscriptionId)); } + } diff --git a/src/main/java/com/dedicatedcode/reitti/service/processing/UnifiedLocationProcessingService.java b/src/main/java/com/dedicatedcode/reitti/service/processing/UnifiedLocationProcessingService.java index 1580d8f2..4a4ff8ec 100644 --- a/src/main/java/com/dedicatedcode/reitti/service/processing/UnifiedLocationProcessingService.java +++ b/src/main/java/com/dedicatedcode/reitti/service/processing/UnifiedLocationProcessingService.java @@ -786,9 +786,9 @@ public class UnifiedLocationProcessingService { Point point = geometryFactory.createPoint(new Coordinate(longitude, latitude)); // Find places within the merge distance if (previewId == null) { - return significantPlaceJdbcService.findNearbyPlaces(user.getId(), point, GeoUtils.metersToDegreesAtPosition(mergeConfiguration.getMinDistanceBetweenVisits(), latitude)); + return significantPlaceJdbcService.findNearbyPlaces(user.getId(), point, GeoUtils.metersToDegreesAtPosition((double) mergeConfiguration.getMinDistanceBetweenVisits() / 2, latitude)); } else { - return previewSignificantPlaceJdbcService.findNearbyPlaces(user.getId(), point, GeoUtils.metersToDegreesAtPosition(mergeConfiguration.getMinDistanceBetweenVisits(), latitude), previewId); + return previewSignificantPlaceJdbcService.findNearbyPlaces(user.getId(), point, GeoUtils.metersToDegreesAtPosition((double) mergeConfiguration.getMinDistanceBetweenVisits() /2, latitude), previewId); } } diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index 16fcde27..30111daa 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -14,6 +14,8 @@ spring.thymeleaf.cache=false reitti.server.advertise-uri=http://localhost:8080 +reitti.ui.tiles.cache.url=http://localhost:8084 + reitti.security.local-login.disable=false reitti.storage.path=data/ diff --git a/src/main/resources/application-docker.properties b/src/main/resources/application-docker.properties index b6e965c7..562ed5c5 100644 --- a/src/main/resources/application-docker.properties +++ b/src/main/resources/application-docker.properties @@ -41,6 +41,7 @@ reitti.process-data.schedule=${REITTI_PROCESS_DATA_CRON:0 */10 * * * *} reitti.ui.tiles.custom.service=${CUSTOM_TILES_SERVICE:} reitti.ui.tiles.custom.attribution=${CUSTOM_TILES_ATTRIBUTION:} +reitti.ui.tiles.cache.url=${TILES_CACHE:http://tile-cache} reitti.import.batch-size=${PROCESSING_BATCH_SIZE:10000} reitti.events.concurrency=${PROCESSING_WORKERS_PER_QUEUE:4-16} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index aac72f3d..1c3b55f3 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -88,6 +88,7 @@ reitti.geocoding.max-errors=10 reitti.geocoding.photon.base-url= # Tiles Configuration +reitti.ui.tiles.cache.url= reitti.ui.tiles.default.service=https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png reitti.ui.tiles.default.attribution=© OpenStreetMap contributors, Tiles style by Humanitarian OpenStreetMap Team hosted by OpenStreetMap France diff --git a/src/main/resources/static/js/canvas-visit-renderer.js b/src/main/resources/static/js/canvas-visit-renderer.js new file mode 100644 index 00000000..fb703e9e --- /dev/null +++ b/src/main/resources/static/js/canvas-visit-renderer.js @@ -0,0 +1,145 @@ +/** + * Canvas-based renderer for visit markers using Leaflet's Canvas renderer + */ +class CanvasVisitRenderer { + constructor(map) { + this.map = map; + this.visits = []; + this.visitMarkers = []; + this.canvasRenderer = null; + + this.init(); + } + + init() { + // Create a Canvas renderer instance for high-performance rendering + this.canvasRenderer = L.canvas({ + padding: 0.1, + tolerance: 5 // Extend click tolerance for better interaction + }); + + // Add the canvas renderer to the map + this.map.addLayer(this.canvasRenderer); + } + + setVisits(visits) { + this.clearVisits(); + this.visits = visits; + this.createVisitMarkers(); + } + + addVisit(visit) { + this.visits.push(visit); + this.createVisitMarker(visit); + } + + clearVisits() { + // Remove all existing visit markers + this.visitMarkers.forEach(marker => { + this.map.removeLayer(marker); + }); + this.visitMarkers = []; + this.visits = []; + } + + createVisitMarkers() { + this.visits.forEach(visit => { + this.createVisitMarker(visit); + }); + } + + createVisitMarker(visit) { + // Calculate radius using logarithmic scale + const durationHours = visit.totalDurationMs / (1000 * 60 * 60); + const baseRadius = 15; + const maxRadius = 50; + const minRadius = 15; + + const logScale = Math.log(1 + durationHours) / Math.log(1 + 24); + const radius = Math.min(maxRadius, Math.max(minRadius, baseRadius + (logScale * (maxRadius - baseRadius)))); + + // Create outer circle (visit area) + const outerCircle = L.circle([visit.lat, visit.lng], { + radius: radius * 5, // Convert to meters (approximate) + fillColor: this.lightenHexColor(visit.color, 20), + fillOpacity: 0.1, + color: visit.color, + weight: 1, + renderer: this.canvasRenderer, + interactive: true + }); + + // Create inner marker + const innerMarker = L.circleMarker([visit.lat, visit.lng], { + radius: 5, + fillOpacity: 1, + fillColor: this.lightenHexColor(visit.color, 80), + color: '#000', + weight: 1, + renderer: this.canvasRenderer, + interactive: true + }); + + // Create tooltip content + const totalDurationText = this.humanizeDuration(visit.totalDurationMs); + const visitCount = visit.visits.length; + const visitText = visitCount === 1 ? 'visit' : 'visits'; + + let tooltip = L.tooltip({ + content: `
${visit.place.name}
+
+ ${visitCount} ${visitText} - Total: ${totalDurationText} +
`, + className: 'visit-popup', + permanent: false + }); + innerMarker.bindTooltip(tooltip); + outerCircle.bindTooltip(tooltip); + + this.map.addLayer(outerCircle); + this.map.addLayer(innerMarker); + + // Store references for cleanup + this.visitMarkers.push(outerCircle, innerMarker); + } + + lightenHexColor(hex, percent) { + // Remove # if present + hex = hex.replace('#', ''); + // Parse RGB values + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + + // Lighten each component + const newR = Math.min(255, Math.floor(r + (255 - r) * (percent / 100))); + const newG = Math.min(255, Math.floor(g + (255 - g) * (percent / 100))); + const newB = Math.min(255, Math.floor(b + (255 - b) * (percent / 100))); + + // Convert back to hex + return '#' + + newR.toString(16).padStart(2, '0') + + newG.toString(16).padStart(2, '0') + + newB.toString(16).padStart(2, '0'); + } + + humanizeDuration(ms) { + const hours = Math.floor(ms / (1000 * 60 * 60)); + const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60)); + + if (hours > 0) { + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; + } else { + return `${minutes}m`; + } + } + + destroy() { + this.clearVisits(); + + if (this.canvasRenderer) { + this.map.removeLayer(this.canvasRenderer); + this.canvasRenderer = null; + } + } +} diff --git a/src/main/resources/static/js/raw-location-loader.js b/src/main/resources/static/js/raw-location-loader.js index 11c76fbc..a776c7e9 100644 --- a/src/main/resources/static/js/raw-location-loader.js +++ b/src/main/resources/static/js/raw-location-loader.js @@ -16,6 +16,13 @@ class RawLocationLoader { this.selectedEndTime = null; // Configuration for map bounds fitting this.fitToBoundsConfig = fitToBoundsConfig || {}; + + // Create canvas renderer for raw location paths with lower pane + this.canvasRenderer = L.canvas({ + pane: 'tilePane', // Use tilePane for lower z-index + padding: 0.1 + }); + // Listen for map events this.setupMapEventListeners(); } @@ -230,7 +237,8 @@ class RawLocationLoader { opacity: 0.9, lineJoin: 'round', lineCap: 'round', - steps: 2 + steps: 2, + renderer: this.canvasRenderer }); const rawPointsCoords = segment.points.map(point => [point.latitude, point.longitude]); bounds.extend(rawPointsCoords) @@ -377,7 +385,8 @@ class RawLocationLoader { opacity: 1, lineJoin: 'round', lineCap: 'round', - steps: 2 + steps: 2, + renderer: this.canvasRenderer }); const coords = filteredPoints.map(point => [point.latitude, point.longitude]); diff --git a/src/main/resources/templates/fragments/timeline.html b/src/main/resources/templates/fragments/timeline.html index ae70ad5e..fe8456e8 100644 --- a/src/main/resources/templates/fragments/timeline.html +++ b/src/main/resources/templates/fragments/timeline.html @@ -31,6 +31,7 @@ th:data-user-id="${userData?.userId}" th:data-base-color="${userData?.baseColor}" th:data-raw-location-points-url="${userData?.rawLocationPointsUrl}" + th:data-processed-visits-url="${userData?.processedVisitsUrl}" th:data-user-avatar-url="${userData?.userAvatarUrl}" th:data-avatar-fallback="${userData?.avatarFallback}" th:data-display-name="${userData?.displayName}"> diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index c2a6c5c7..7d2e853b 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -15,11 +15,19 @@ + + @@ -142,8 +150,9 @@ // Initialize the map const map = L.map('map', {zoomControl: false, attributionControl: false}).setView([window.userSettings.homeLatitude, window.userSettings.homeLongitude], 12); - // Initialize raw location loader + // Initialize raw location loader and canvas visit renderer let rawLocationLoader; + let canvasVisitRenderer; /** * Retrieves the selected date based on the URL parameters or application settings. @@ -338,6 +347,14 @@ // Initialize raw location loader with user configurations initializeRawLocationLoader(); + + // Initialize canvas visit renderer with higher z-index to render above raw location data + canvasVisitRenderer = new CanvasVisitRenderer(map); + + // Ensure visit renderer is above raw location data by setting higher z-index + if (canvasVisitRenderer.canvasRenderer && canvasVisitRenderer.canvasRenderer.getPane()) { + canvasVisitRenderer.canvasRenderer.getPane().style.zIndex = 450; + } // Listen for map move/zoom events to update photo markers map.on('moveend zoomend', () => { @@ -395,7 +412,7 @@ window.timelineScrollIndicator.init(); } }); - // Function to update map markers from timeline entries + // Function to update map markers from processed visits API function updateMapFromTimeline() { const bounds = L.latLngBounds(); @@ -406,113 +423,112 @@ } }); - let hasValidCoords = false; + const allVisits = []; + + // Get current map bounds for API request + const mapBounds = map.getBounds(); - // Group places by coordinates to avoid duplicate markers + // Get user configurations for colors + const userConfigs = new Map(); + const timelineUserSections = document.querySelectorAll('.user-timeline-section'); + + timelineUserSections.forEach(container => { + const userId = container.dataset.userId; + const color = container.dataset.baseColor; + userConfigs.set(userId, { color: color }); + }); - let timelineUserSections = document.querySelectorAll('.user-timeline-section '); - - for (const timelineUserSection of timelineUserSections) { - const color = timelineUserSection?.dataset.baseColor; - const timelineEntries = timelineUserSection.querySelectorAll('.timeline-entry[data-lat][data-lng]'); - - const placeGroups = new Map(); - - timelineEntries.forEach(entryElement => { - const lat = parseFloat(entryElement.dataset.lat); - const lng = parseFloat(entryElement.dataset.lng); - - if (!isNaN(lat) && !isNaN(lng) && entryElement.classList.contains('visit')) { - const coordKey = `${lat.toFixed(6)},${lng.toFixed(6)}`; - - if (!placeGroups.has(coordKey)) { - // Extract place name from the timeline entry - const placeNameElement = entryElement.querySelector('.place-name'); - const placeName = placeNameElement ? placeNameElement.textContent : 'Unknown Place'; - - placeGroups.set(coordKey, { - lat: lat, - lng: lng, - totalDurationMs: 0, - visits: [], - place: { name: placeName, address: '' } - }); - } - - const group = placeGroups.get(coordKey); - // Calculate duration from time range text - const durationElement = entryElement.querySelector('.entry-duration'); - if (durationElement) { - const durationText = durationElement.textContent; - const durationMs = parseDurationText(durationText); - group.totalDurationMs += durationMs; - } - group.visits.push({ id: entryElement.dataset.id }); - - bounds.extend([lat, lng]); - hasValidCoords = true; - } - - - // Draw markers for grouped places - placeGroups.forEach((group) => { - const { lat, lng, totalDurationMs, visits, place } = group; - - // Calculate radius using logarithmic scale - const durationHours = totalDurationMs / (1000 * 60 * 60); - const baseRadius = 15; - const maxRadius = 100; - const minRadius = 15; - - const logScale = Math.log(1 + durationHours) / Math.log(1 + 24); - const radius = Math.min(maxRadius, Math.max(minRadius, baseRadius + (logScale * (maxRadius - baseRadius)))); - - // Create marker - L.circleMarker([lat, lng], { - radius: 5, - fillColor: lightenHexColor(color, 20), - color: '#fff', - weight: 1, - opacity: 1, - fillOpacity: 0.8 - }).addTo(map); - - // Create circle with calculated radius - const circle = L.circle([lat, lng], { - color: color, - fillColor: lightenHexColor(color, 20), - fillOpacity: 0.1, - radius: radius - }).addTo(map); - - const totalDurationText = humanizeDuration(totalDurationMs, {units: ["h", "m"], round: true}); - const visitCount = visits.length; - const visitText = visitCount === 1 ? 'visit' : 'visits'; - - let tooltip = L.tooltip([lat, lng], { - content: `
${place.name}
-
- ${visitCount} ${visitText} - Total: ${totalDurationText} -
`, - className: 'visit-popup', - permanent: false - }); - - bounds.extend([lat, lng]) - - circle.bindTooltip(tooltip); - }); + // Fetch processed visits for each user + const fetchPromises = []; + timelineUserSections.forEach(container => { + const userId = container.dataset.userId; + const baseUrl = container.dataset.processedVisitsUrl; + + if (!baseUrl) { + console.warn(`No processed visits URL found for user ${userId}`); + return; + } + + const urlParams = new URLSearchParams({ + minLat: mapBounds.getSouth(), + maxLat: mapBounds.getNorth(), + minLng: mapBounds.getWest(), + maxLng: mapBounds.getEast() }); - } + const fullUrl = baseUrl.includes('?') ? `${baseUrl}&${urlParams}` : `${baseUrl}?${urlParams}`; + + fetchPromises.push( + fetch(fullUrl) + .then(response => response.json()) + .then(data => ({ userId, data, color: userConfigs.get(userId)?.color })) + .catch(error => { + console.error(`Error fetching visits for user ${userId}:`, error); + return { userId, data: { places: [] }, color: userConfigs.get(userId)?.color }; + }) + ); + }); + + Promise.all(fetchPromises).then(results => { + results.forEach(({ userId, data, color }) => { + if (data.places) { + data.places.forEach(placeSummary => { + const place = placeSummary.place; + if (place.latitudeCentroid && place.longitudeCentroid) { + const visitData = { + lat: place.latitudeCentroid, + lng: place.longitudeCentroid, + totalDurationMs: placeSummary.totalDurationSeconds * 1000, + visits: placeSummary.visits.map(v => ({ id: v.id })), + place: { + name: place.name || 'Unknown Place', + address: place.address || '' + }, + color: color + }; + + allVisits.push(visitData); + bounds.extend([place.latitudeCentroid, place.longitudeCentroid]); + } + }); + } + }); + + // Update canvas renderer with all visits + if (canvasVisitRenderer) { + canvasVisitRenderer.setVisits(allVisits); + } + }).catch(error => { + console.error('Error fetching processed visits:', error); + // Fallback to empty visits if API fails + if (canvasVisitRenderer) { + canvasVisitRenderer.setVisits([]); + } + }); + return bounds; } - function parseDurationText(durationText) { - // Extract numbers followed by 'h' or 'm' - const hours = (durationText.match(/(\d+)h/) || [0, 0])[1]; - const minutes = (durationText.match(/(\d+)m/) || [0, 0])[1]; - return (parseInt(hours) * 60 + parseInt(minutes)) * 60 * 1000; // Convert to milliseconds + + function lightenHexColor(hex, percent) { + // Remove # if present + hex = hex.replace('#', ''); + + // Parse RGB values + const r = parseInt(hex.substr(0, 2), 16); + const g = parseInt(hex.substr(2, 2), 16); + const b = parseInt(hex.substr(4, 2), 16); + + // Lighten each component + const newR = Math.min(255, Math.floor(r + (255 - r) * (percent / 100))); + const newG = Math.min(255, Math.floor(g + (255 - g) * (percent / 100))); + const newB = Math.min(255, Math.floor(b + (255 - b) * (percent / 100))); + + // Convert back to hex + return '#' + + newR.toString(16).padStart(2, '0') + + newG.toString(16).padStart(2, '0') + + newB.toString(16).padStart(2, '0'); } // Handle clicks on timeline entries diff --git a/src/test/java/com/dedicatedcode/reitti/TestingService.java b/src/test/java/com/dedicatedcode/reitti/TestingService.java index f048b762..b2883f8b 100644 --- a/src/test/java/com/dedicatedcode/reitti/TestingService.java +++ b/src/test/java/com/dedicatedcode/reitti/TestingService.java @@ -30,7 +30,6 @@ import java.util.concurrent.atomic.AtomicLong; public class TestingService { private static final List QUEUES_TO_CHECK = List.of( - RabbitMQConfig.LOCATION_DATA_QUEUE, RabbitMQConfig.SIGNIFICANT_PLACE_QUEUE ); diff --git a/src/test/java/com/dedicatedcode/reitti/service/TilesCustomizationProviderTest.java b/src/test/java/com/dedicatedcode/reitti/service/TilesCustomizationProviderTest.java index 80f6df2c..6ad0aa94 100644 --- a/src/test/java/com/dedicatedcode/reitti/service/TilesCustomizationProviderTest.java +++ b/src/test/java/com/dedicatedcode/reitti/service/TilesCustomizationProviderTest.java @@ -2,8 +2,6 @@ package com.dedicatedcode.reitti.service; import com.dedicatedcode.reitti.dto.UserSettingsDTO; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.junit.jupiter.MockitoExtension; import static org.assertj.core.api.Assertions.assertThat; @@ -18,7 +16,7 @@ class TilesCustomizationProviderTest { String customAttribution = "Custom Attribution"; TilesCustomizationProvider provider = new TilesCustomizationProvider( - defaultService, defaultAttribution, customService, customAttribution + null, defaultService, defaultAttribution, customService, customAttribution ); // When @@ -36,9 +34,9 @@ class TilesCustomizationProviderTest { String defaultAttribution = "Default Attribution"; String customService = ""; String customAttribution = "Custom Attribution"; - + TilesCustomizationProvider provider = new TilesCustomizationProvider( - defaultService, defaultAttribution, customService, customAttribution + null, defaultService, defaultAttribution, customService, customAttribution ); // When @@ -58,7 +56,7 @@ class TilesCustomizationProviderTest { String customAttribution = ""; TilesCustomizationProvider provider = new TilesCustomizationProvider( - defaultService, defaultAttribution, customService, customAttribution + null, defaultService, defaultAttribution, customService, customAttribution ); // When @@ -78,7 +76,7 @@ class TilesCustomizationProviderTest { String customAttribution = null; TilesCustomizationProvider provider = new TilesCustomizationProvider( - defaultService, defaultAttribution, customService, customAttribution + null, defaultService, defaultAttribution, customService, customAttribution ); // When @@ -98,7 +96,7 @@ class TilesCustomizationProviderTest { String customAttribution = "\t\n"; TilesCustomizationProvider provider = new TilesCustomizationProvider( - defaultService, defaultAttribution, customService, customAttribution + null, defaultService, defaultAttribution, customService, customAttribution ); // When @@ -118,7 +116,7 @@ class TilesCustomizationProviderTest { String customAttribution = ""; TilesCustomizationProvider provider = new TilesCustomizationProvider( - defaultService, defaultAttribution, customService, customAttribution + null, defaultService, defaultAttribution, customService, customAttribution ); // When @@ -138,7 +136,7 @@ class TilesCustomizationProviderTest { String customAttribution = ""; TilesCustomizationProvider provider = new TilesCustomizationProvider( - defaultService, defaultAttribution, customService, customAttribution + null, defaultService, defaultAttribution, customService, customAttribution ); // When @@ -158,7 +156,7 @@ class TilesCustomizationProviderTest { String customAttribution = "Custom Attribution"; TilesCustomizationProvider provider = new TilesCustomizationProvider( - defaultService, defaultAttribution, customService, customAttribution + null, defaultService, defaultAttribution, customService, customAttribution ); // When @@ -168,4 +166,25 @@ class TilesCustomizationProviderTest { assertThat(result.service()).isEqualTo(defaultService); assertThat(result.attribution()).isEqualTo(customAttribution); } + + @Test + void shouldReturnCacheUrlIfSet() { + // Given + String cacheUrl = "http://tiles.cache/hot/"; + String defaultService = "https://default.tiles.com/{z}/{x}/{y}.png"; + String defaultAttribution = "Default Attribution"; + String customService = ""; + String customAttribution = "Custom Attribution"; + + TilesCustomizationProvider provider = new TilesCustomizationProvider( + cacheUrl, defaultService, defaultAttribution, customService, customAttribution + ); + + // When + UserSettingsDTO.TilesCustomizationDTO result = provider.getTilesConfiguration(); + + // Then + assertThat(result.service()).isEqualTo("/api/v1/tiles/{z}/{x}/{y}.png"); + assertThat(result.attribution()).isEqualTo(customAttribution); + } }