535 map display over multiple years (#558)

This commit is contained in:
Daniel Graf
2025-12-17 14:57:29 +01:00
committed by GitHub
parent b05955500f
commit f32dcc6b23
27 changed files with 825 additions and 170 deletions

View File

@@ -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 |

View File

@@ -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:

View File

@@ -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:

View File

@@ -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);

View File

@@ -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));
}
}

View File

@@ -204,9 +204,10 @@ public class TimelineController {
List<TimelineEntry> 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<TimelineEntry> 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<TimelineEntry> 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)

View File

@@ -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;

View File

@@ -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<ProcessedVisit> visits = processedVisitJdbcService.findByUserAndTimeOverlap(
userToFetchDataFrom, startOfRange, endOfRange);
// Group visits by place and create response
Map<SignificantPlace, List<ProcessedVisit>> visitsByPlace = visits.stream()
.collect(Collectors.groupingBy(ProcessedVisit::getPlace));
List<ProcessedVisitResponse.PlaceVisitSummary> placeSummaries = visitsByPlace.entrySet().stream()
.map(entry -> {
SignificantPlace place = entry.getKey();
List<ProcessedVisit> placeVisits = entry.getValue();
List<ProcessedVisitResponse.VisitDetail> 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;
}
}

View File

@@ -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<byte[]> 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<byte[]> 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();
}
}
}

View File

@@ -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<String, String> defaultColors;
private final Map<String, String> 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");

View File

@@ -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!");
};
}

View File

@@ -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<VisitDetail> visits;
private final long totalDurationSeconds;
private final int visitCount;
public PlaceVisitSummary(SignificantPlace place, List<VisitDetail> visits, long totalDurationSeconds, int visitCount) {
this.place = place;
this.visits = visits;
this.totalDurationSeconds = totalDurationSeconds;
this.visitCount = visitCount;
}
public SignificantPlace getPlace() {
return place;
}
public List<VisitDetail> 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<PlaceVisitSummary> places;
public ProcessedVisitResponse(List<PlaceVisitSummary> places) {
this.places = places;
}
public List<PlaceVisitSummary> getPlaces() {
return places;
}
}

View File

@@ -9,6 +9,7 @@ public record UserTimelineData(
String userAvatarUrl,
String baseColor,
List<TimelineEntry> entries,
String rawLocationPointsUrl
String rawLocationPointsUrl,
String processedVisitsUrl
) {
}

View File

@@ -27,7 +27,7 @@ public class DefaultImportProcessor implements ImportProcessor {
private final ProcessingPipelineTrigger processingPipelineTrigger;
private final ScheduledExecutorService scheduler;
private final ConcurrentHashMap<String, ScheduledFuture<?>> 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();
}
}

View File

@@ -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<String> 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<String, Integer> 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<QueueStats> getQueueStats() {
List<QueueStats> list = QUEUES.stream().map(this::getQueueStats).toList();
List<QueueStats> 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<ProcessingRecord> 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<ProcessingRecord> 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) { }
}

View File

@@ -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;
}
}

View File

@@ -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<String> 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<Map> 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<LocationPoint> 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<Long> getUserIdForSubscription(String subscriptionId) {
return Optional.ofNullable(this.userForSubscriptions.get(subscriptionId));
}
}

View File

@@ -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);
}
}

View File

@@ -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/

View File

@@ -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}

View File

@@ -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=&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, Tiles style by <a href="https://www.hotosm.org/" target="_blank">Humanitarian OpenStreetMap Team</a> hosted by <a href="https://openstreetmap.fr/" target="_blank">OpenStreetMap France</a>

View File

@@ -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: `<div class="visit-title">${visit.place.name}</div>
<div class="visit-description">
${visitCount} ${visitText} - Total: ${totalDurationText}
</div>`,
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;
}
}
}

View File

@@ -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]);

View File

@@ -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}">

View File

@@ -15,11 +15,19 @@
<link rel="stylesheet" href="/css/inline-edit.css">
<link rel="stylesheet" href="/css/avatar-marker.css">
<link th:if="${userSettings.customCssUrl}" rel="stylesheet" th:href="${userSettings.customCssUrl}">
<style>
.canvas-visit-tooltip {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.2);
}
</style>
<script src="/js/HumanizeDuration.js"></script>
<script src="/js/date-picker-combined.js"></script>
<script src="/js/timeline-scroll-indicator.js"></script>
<script src="/js/photo-client.js"></script>
<script src="/js/raw-location-loader.js"></script>
<script src="/js/canvas-visit-renderer.js"></script>
<script src="/js/htmx.min.js"></script>
<script src="/js/leaflet.js"></script>
<script src="/js/TileLayer.Grayscale.js"></script>
@@ -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: `<div class="visit-title">${place.name}</div>
<div class="visit-description">
${visitCount} ${visitText} - Total: ${totalDurationText}
</div>`,
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

View File

@@ -30,7 +30,6 @@ import java.util.concurrent.atomic.AtomicLong;
public class TestingService {
private static final List<String> QUEUES_TO_CHECK = List.of(
RabbitMQConfig.LOCATION_DATA_QUEUE,
RabbitMQConfig.SIGNIFICANT_PLACE_QUEUE
);

View File

@@ -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);
}
}