mirror of
https://github.com/dedicatedcode/reitti.git
synced 2026-01-09 01:17:57 -05:00
535 map display over multiple years (#558)
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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!");
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ public record UserTimelineData(
|
||||
String userAvatarUrl,
|
||||
String baseColor,
|
||||
List<TimelineEntry> entries,
|
||||
String rawLocationPointsUrl
|
||||
String rawLocationPointsUrl,
|
||||
String processedVisitsUrl
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) { }
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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=© <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>
|
||||
|
||||
|
||||
145
src/main/resources/static/js/canvas-visit-renderer.js
Normal file
145
src/main/resources/static/js/canvas-visit-renderer.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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}">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user