mirror of
https://github.com/dedicatedcode/reitti.git
synced 2026-01-09 09:27:58 -05:00
Smaller changes (#561)
This commit is contained in:
@@ -99,7 +99,7 @@ services:
|
||||
nginx -g 'daemon off;'"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/0/0/0.png"]
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/0/0/0.png"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -93,7 +93,7 @@ services:
|
||||
nginx -g 'daemon off;'"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/0/0/0.png"]
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/0/0/0.png"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -17,10 +17,7 @@ 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.*;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.*;
|
||||
|
||||
@@ -119,13 +116,17 @@ public class LocationDataApiController {
|
||||
User userToFetchDataFrom = userJdbcService.findById(userId)
|
||||
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||
|
||||
long start = System.nanoTime();
|
||||
List<RawLocationDataResponse.Segment> result;
|
||||
if (includeRawLocationPath) {
|
||||
List<List<LocationPoint>> segments = loadSegmentsInBoundingBoxAndTime(userToFetchDataFrom, minLat, maxLat, minLng, maxLng, startOfRange, endOfRange);
|
||||
logger.trace("Loaded segments in [{}]ms", (System.nanoTime() - start) / 1_000_000);
|
||||
start = System.nanoTime();
|
||||
result = segments.stream().map(s -> {
|
||||
List<LocationPoint> simplifiedPoints = simplificationService.simplifyPoints(s, zoom);
|
||||
return new RawLocationDataResponse.Segment(simplifiedPoints);
|
||||
return new RawLocationDataResponse.Segment(s);
|
||||
}).toList();
|
||||
logger.trace("Simplified segments in [{}]ms", (System.nanoTime() - start) / 1_000_000);
|
||||
} else {
|
||||
result = Collections.emptyList();
|
||||
}
|
||||
@@ -181,15 +182,21 @@ public class LocationDataApiController {
|
||||
|
||||
private List<List<LocationPoint>> loadSegmentsInBoundingBoxAndTime(User user, Double minLat, Double maxLat, Double minLng, Double maxLng, Instant startOfRange, Instant endOfRange) {
|
||||
List<RawLocationPoint> pointsInBoxWithNeighbors;
|
||||
if (minLat == null || maxLat == null || minLng == null || maxLng == null) {
|
||||
long start = System.nanoTime();
|
||||
if (Duration.between(startOfRange, endOfRange).toDays() > 30 && minLat == null && maxLat == null && minLng == null && maxLng == null) {
|
||||
pointsInBoxWithNeighbors = rawLocationPointJdbcService.findSimplifiedRouteForPeriod(user, startOfRange, endOfRange, 10000);
|
||||
} else if (minLat == null || maxLat == null || minLng == null || maxLng == null) {
|
||||
pointsInBoxWithNeighbors = this.rawLocationPointJdbcService.findByUserAndTimestampBetweenOrderByTimestampAsc(user, startOfRange, endOfRange);
|
||||
logger.trace("Loaded points in time range from database in [{}]ms", (System.nanoTime() - start) / 1_000_000);
|
||||
} else {
|
||||
pointsInBoxWithNeighbors = this.rawLocationPointJdbcService.findPointsInBoxWithNeighbors(user, startOfRange, endOfRange, minLat, maxLat, minLng, maxLng);
|
||||
pointsInBoxWithNeighbors = this.rawLocationPointJdbcService.findPointsInBoxWithNeighbors(user, startOfRange, endOfRange, minLat, maxLat, minLng, maxLng, 10000);
|
||||
logger.trace("Loaded points in box with neighbors from database in [{}]ms", (System.nanoTime() - start) / 1_000_000);
|
||||
}
|
||||
return extractPathSegments(pointsInBoxWithNeighbors, minLat, maxLat, minLng, maxLng);
|
||||
}
|
||||
|
||||
private List<List<LocationPoint>> extractPathSegments(List<RawLocationPoint> points, Double minLat, Double maxLat, Double minLng, Double maxLng) {
|
||||
long start = System.nanoTime();
|
||||
List<List<LocationPoint>> segments = new ArrayList<>();
|
||||
List<LocationPoint> currentPath = new ArrayList<>();
|
||||
int consecutiveOutside = 0;
|
||||
@@ -220,6 +227,7 @@ public class LocationDataApiController {
|
||||
segments.add(new ArrayList<>(currentPath));
|
||||
}
|
||||
|
||||
logger.trace("Extracted path segments in [{}]ms", (System.nanoTime() - start) / 1_000_000);
|
||||
return segments;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import com.dedicatedcode.reitti.model.geo.RawLocationPoint;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import org.locationtech.jts.geom.Coordinate;
|
||||
import org.locationtech.jts.geom.GeometryFactory;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -25,7 +27,7 @@ import java.util.stream.Collectors;
|
||||
@Service
|
||||
@Transactional
|
||||
public class RawLocationPointJdbcService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(RawLocationPointJdbcService.class);
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final RowMapper<RawLocationPoint> rawLocationPointRowMapper;
|
||||
private final PointReaderWriter pointReaderWriter;
|
||||
@@ -190,10 +192,31 @@ public class RawLocationPointJdbcService {
|
||||
double minLat,
|
||||
double maxLat,
|
||||
double minLon,
|
||||
double maxLon) {
|
||||
double maxLon,
|
||||
int maxPoints) {
|
||||
long start = System.nanoTime();
|
||||
|
||||
String sql = """
|
||||
WITH all_points AS (
|
||||
String countSql = """
|
||||
SELECT COUNT(*)
|
||||
FROM raw_location_points
|
||||
WHERE user_id = ?
|
||||
AND ST_Within(geom, ST_MakeEnvelope(?, ?, ?, ?, 4326))
|
||||
AND timestamp BETWEEN ?::timestamp AND ?::timestamp
|
||||
AND ignored = false
|
||||
""";
|
||||
|
||||
Long relevantPointCount = jdbcTemplate.queryForObject(countSql, Long.class,
|
||||
minLon, minLat, maxLon, maxLat,
|
||||
user.getId(),
|
||||
Timestamp.from(startTime),
|
||||
Timestamp.from(endTime)
|
||||
);
|
||||
|
||||
logger.trace("took [{}]ms to count relevant points", (System.nanoTime() - start) / 1_000_000);
|
||||
// If we have fewer points than the budget, return all without sampling
|
||||
if (relevantPointCount <= maxPoints) {
|
||||
String sql = """
|
||||
WITH box_filtered_points AS (
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
@@ -212,7 +235,7 @@ public class RawLocationPointJdbcService {
|
||||
OVER (ORDER BY timestamp) as next_in_box
|
||||
FROM raw_location_points
|
||||
WHERE user_id = ?
|
||||
AND timestamp BETWEEN ? AND ?
|
||||
AND timestamp BETWEEN ?::timestamp AND ?::timestamp
|
||||
AND ignored = false
|
||||
)
|
||||
SELECT
|
||||
@@ -226,29 +249,157 @@ public class RawLocationPointJdbcService {
|
||||
synthetic,
|
||||
ignored,
|
||||
version
|
||||
FROM all_points
|
||||
FROM box_filtered_points
|
||||
WHERE in_box = true
|
||||
OR prev_in_box = true
|
||||
OR next_in_box = true
|
||||
ORDER BY timestamp
|
||||
""";
|
||||
|
||||
return jdbcTemplate.query(
|
||||
sql,
|
||||
rawLocationPointRowMapper,
|
||||
// ST_MakeEnvelope params for in_box
|
||||
minLon, minLat, maxLon, maxLat,
|
||||
// ST_MakeEnvelope params for prev_in_box
|
||||
minLon, minLat, maxLon, maxLat,
|
||||
// ST_MakeEnvelope params for next_in_box
|
||||
minLon, minLat, maxLon, maxLat,
|
||||
// WHERE clause params
|
||||
user.getId(),
|
||||
Timestamp.from(startTime),
|
||||
Timestamp.from(endTime)
|
||||
return jdbcTemplate.query(sql, rawLocationPointRowMapper,
|
||||
minLon, minLat, maxLon, maxLat,
|
||||
minLon, minLat, maxLon, maxLat,
|
||||
minLon, minLat, maxLon, maxLat,
|
||||
user.getId(),
|
||||
Timestamp.from(startTime),
|
||||
Timestamp.from(endTime)
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, apply sampling
|
||||
Duration period = Duration.between(startTime, endTime);
|
||||
long intervalMinutes = Math.max(1, period.toMinutes() / maxPoints);
|
||||
|
||||
String sql = """
|
||||
WITH box_filtered_points AS (
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
timestamp,
|
||||
geom,
|
||||
accuracy_meters,
|
||||
elevation_meters,
|
||||
processed,
|
||||
ignored,
|
||||
synthetic,
|
||||
version,
|
||||
ST_Within(geom, ST_MakeEnvelope(?, ?, ?, ?, 4326)) as in_box,
|
||||
LAG(ST_Within(geom, ST_MakeEnvelope(?, ?, ?, ?, 4326)))
|
||||
OVER (ORDER BY timestamp) as prev_in_box,
|
||||
LEAD(ST_Within(geom, ST_MakeEnvelope(?, ?, ?, ?, 4326)))
|
||||
OVER (ORDER BY timestamp) as next_in_box
|
||||
FROM raw_location_points
|
||||
WHERE user_id = ?
|
||||
AND timestamp BETWEEN ?::timestamp AND ?::timestamp
|
||||
AND ignored = false
|
||||
),
|
||||
relevant_points AS (
|
||||
SELECT *
|
||||
FROM box_filtered_points
|
||||
WHERE in_box = true
|
||||
OR prev_in_box = true
|
||||
OR next_in_box = true
|
||||
),
|
||||
sampled_points AS (
|
||||
SELECT DISTINCT ON (
|
||||
date_trunc('hour', timestamp) +
|
||||
(EXTRACT(minute FROM timestamp)::int / %d) * interval '%d minutes'
|
||||
)
|
||||
id,
|
||||
user_id,
|
||||
timestamp,
|
||||
geom,
|
||||
accuracy_meters,
|
||||
elevation_meters,
|
||||
processed,
|
||||
ignored,
|
||||
synthetic,
|
||||
version
|
||||
FROM relevant_points
|
||||
ORDER BY
|
||||
date_trunc('hour', timestamp) +
|
||||
(EXTRACT(minute FROM timestamp)::int / %d) * interval '%d minutes',
|
||||
timestamp
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
timestamp,
|
||||
ST_AsText(geom) as geom,
|
||||
accuracy_meters,
|
||||
elevation_meters,
|
||||
processed,
|
||||
synthetic,
|
||||
ignored,
|
||||
version
|
||||
FROM sampled_points
|
||||
ORDER BY timestamp
|
||||
""".formatted(intervalMinutes, intervalMinutes, intervalMinutes, intervalMinutes);
|
||||
|
||||
return jdbcTemplate.query(sql, rawLocationPointRowMapper,
|
||||
minLon, minLat, maxLon, maxLat,
|
||||
minLon, minLat, maxLon, maxLat,
|
||||
minLon, minLat, maxLon, maxLat,
|
||||
user.getId(),
|
||||
Timestamp.from(startTime),
|
||||
Timestamp.from(endTime)
|
||||
);
|
||||
}
|
||||
|
||||
public List<RawLocationPoint> findSimplifiedRouteForPeriod(
|
||||
User user,
|
||||
Instant startTime,
|
||||
Instant endTime,
|
||||
int maxPoints) {
|
||||
|
||||
// Calculate sampling interval based on time range and desired point count
|
||||
Duration period = Duration.between(startTime, endTime);
|
||||
long intervalMinutes = Math.max(1, period.toMinutes() / maxPoints);
|
||||
|
||||
String sql = """
|
||||
WITH sampled_points AS (
|
||||
SELECT DISTINCT ON (
|
||||
date_trunc('hour', timestamp) +
|
||||
(EXTRACT(minute FROM timestamp)::int / %d) * interval '%d minutes'
|
||||
)
|
||||
id,
|
||||
timestamp,
|
||||
geom,
|
||||
accuracy_meters,
|
||||
elevation_meters,
|
||||
processed,
|
||||
synthetic,
|
||||
ignored,
|
||||
version
|
||||
FROM raw_location_points
|
||||
WHERE user_id = ?
|
||||
AND timestamp BETWEEN ? AND ?
|
||||
AND ignored = false
|
||||
ORDER BY
|
||||
date_trunc('hour', timestamp) +
|
||||
(EXTRACT(minute FROM timestamp)::int / %d) * interval '%d minutes',
|
||||
timestamp
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
accuracy_meters,
|
||||
elevation_meters,
|
||||
timestamp,
|
||||
ST_AsText(geom) as geom,
|
||||
processed,
|
||||
synthetic,
|
||||
ignored,
|
||||
version
|
||||
FROM sampled_points
|
||||
ORDER BY timestamp
|
||||
""".formatted(intervalMinutes, intervalMinutes, intervalMinutes, intervalMinutes);
|
||||
|
||||
return jdbcTemplate.query(sql,
|
||||
rawLocationPointRowMapper,
|
||||
user.getId(),
|
||||
Timestamp.from(startTime), Timestamp.from(endTime));
|
||||
}
|
||||
|
||||
public long countByUser(User user) {
|
||||
return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM raw_location_points WHERE user_id = ?", Long.class, user.getId());
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@ public class TilesCustomizationProvider {
|
||||
@Value("${reitti.ui.tiles.custom.service:}") String customService,
|
||||
@Value("${reitti.ui.tiles.custom.attribution:}") String customAttribution) {
|
||||
String serviceUrl;
|
||||
if (StringUtils.hasText(cacheUrl)) {
|
||||
serviceUrl = "/api/v1/tiles/{z}/{x}/{y}.png";
|
||||
} else if (StringUtils.hasText(customService)) {
|
||||
if (StringUtils.hasText(customService)) {
|
||||
serviceUrl = customService;
|
||||
} else if (StringUtils.hasText(cacheUrl)) {
|
||||
serviceUrl = "/api/v1/tiles/{z}/{x}/{y}.png";
|
||||
} else {
|
||||
serviceUrl = defaultService;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ reitti.data-management.enabled=true
|
||||
|
||||
logging.level.com.dedicatedcode.reitti=DEBUG
|
||||
logging.level.com.dedicatedcode.reitti.service.processing.UnifiedLocationProcessingService = TRACE
|
||||
logging.level.com.dedicatedcode.reitti.controller.api.LocationDataApiController = TRACE
|
||||
|
||||
|
||||
reitti.geocoding.photon.base-url=http://localhost:2322
|
||||
|
||||
@@ -1,3 +1,78 @@
|
||||
.leaflet-cluster-anim .leaflet-marker-icon, .leaflet-cluster-anim .leaflet-marker-shadow {
|
||||
-webkit-transition: -webkit-transform 0.3s ease-out, opacity 0.3s ease-in;
|
||||
-moz-transition: -moz-transform 0.3s ease-out, opacity 0.3s ease-in;
|
||||
-o-transition: -o-transform 0.3s ease-out, opacity 0.3s ease-in;
|
||||
transition: transform 0.3s ease-out, opacity 0.3s ease-in;
|
||||
}
|
||||
|
||||
.leaflet-cluster-spider-leg {
|
||||
/* stroke-dashoffset (duration and function) should match with leaflet-marker-icon transform in order to track it exactly */
|
||||
-webkit-transition: -webkit-stroke-dashoffset 0.3s ease-out, -webkit-stroke-opacity 0.3s ease-in;
|
||||
-moz-transition: -moz-stroke-dashoffset 0.3s ease-out, -moz-stroke-opacity 0.3s ease-in;
|
||||
-o-transition: -o-stroke-dashoffset 0.3s ease-out, -o-stroke-opacity 0.3s ease-in;
|
||||
transition: stroke-dashoffset 0.3s ease-out, stroke-opacity 0.3s ease-in;
|
||||
}
|
||||
.marker-cluster-small {
|
||||
background-color: rgba(181, 226, 140, 0.6);
|
||||
}
|
||||
.marker-cluster-small div {
|
||||
background-color: rgba(110, 204, 57, 0.6);
|
||||
}
|
||||
|
||||
.marker-cluster-medium {
|
||||
background-color: rgba(241, 211, 87, 0.6);
|
||||
}
|
||||
.marker-cluster-medium div {
|
||||
background-color: rgba(240, 194, 12, 0.6);
|
||||
}
|
||||
|
||||
.marker-cluster-large {
|
||||
background-color: rgba(253, 156, 115, 0.6);
|
||||
}
|
||||
.marker-cluster-large div {
|
||||
background-color: rgba(241, 128, 23, 0.6);
|
||||
}
|
||||
|
||||
/* IE 6-8 fallback colors */
|
||||
.leaflet-oldie .marker-cluster-small {
|
||||
background-color: rgb(181, 226, 140);
|
||||
}
|
||||
.leaflet-oldie .marker-cluster-small div {
|
||||
background-color: rgb(110, 204, 57);
|
||||
}
|
||||
|
||||
.leaflet-oldie .marker-cluster-medium {
|
||||
background-color: rgb(241, 211, 87);
|
||||
}
|
||||
.leaflet-oldie .marker-cluster-medium div {
|
||||
background-color: rgb(240, 194, 12);
|
||||
}
|
||||
|
||||
.leaflet-oldie .marker-cluster-large {
|
||||
background-color: rgb(253, 156, 115);
|
||||
}
|
||||
.leaflet-oldie .marker-cluster-large div {
|
||||
background-color: rgb(241, 128, 23);
|
||||
}
|
||||
|
||||
.marker-cluster {
|
||||
background-clip: padding-box;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.marker-cluster div {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-left: 5px;
|
||||
margin-top: 5px;
|
||||
|
||||
text-align: center;
|
||||
border-radius: 15px;
|
||||
font: 12px "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
}
|
||||
.marker-cluster span {
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
:root {
|
||||
--photo-marker-size: 50px;
|
||||
--photo-grid-thumbnail-size: 450px;
|
||||
@@ -257,3 +332,25 @@
|
||||
cursor: help;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
/* Photo cluster marker styles */
|
||||
.photo-cluster-marker .photo-marker-icon {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.photo-cluster-marker .photo-count-indicator {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
width: var(--photo-count-indicator-size);
|
||||
height: var(--photo-count-indicator-size);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
border: 2px solid #fff;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
class CanvasVisitRenderer {
|
||||
constructor(map) {
|
||||
this.map = map;
|
||||
this.visits = [];
|
||||
this.allVisits = [];
|
||||
this.visibleVisits = [];
|
||||
this.visitMarkers = [];
|
||||
this.canvasRenderer = null;
|
||||
|
||||
@@ -20,17 +21,22 @@ class CanvasVisitRenderer {
|
||||
|
||||
// Add the canvas renderer to the map
|
||||
this.map.addLayer(this.canvasRenderer);
|
||||
|
||||
// Listen for zoom changes to update visible visits
|
||||
this.map.on('zoomend', () => {
|
||||
this.updateVisibleVisits();
|
||||
});
|
||||
}
|
||||
|
||||
setVisits(visits) {
|
||||
this.clearVisits();
|
||||
this.visits = visits;
|
||||
this.createVisitMarkers();
|
||||
this.allVisits = visits;
|
||||
this.updateVisibleVisits();
|
||||
}
|
||||
|
||||
addVisit(visit) {
|
||||
this.visits.push(visit);
|
||||
this.createVisitMarker(visit);
|
||||
this.allVisits.push(visit);
|
||||
this.updateVisibleVisits();
|
||||
}
|
||||
|
||||
clearVisits() {
|
||||
@@ -39,11 +45,47 @@ class CanvasVisitRenderer {
|
||||
this.map.removeLayer(marker);
|
||||
});
|
||||
this.visitMarkers = [];
|
||||
this.visits = [];
|
||||
this.allVisits = [];
|
||||
this.visibleVisits = [];
|
||||
}
|
||||
|
||||
updateVisibleVisits() {
|
||||
const zoom = this.map.getZoom();
|
||||
|
||||
// Filter visits based on zoom level and duration
|
||||
let minDurationMs;
|
||||
if (zoom >= 15) {
|
||||
minDurationMs = 5 * 60 * 1000; // 5 minutes at high zoom
|
||||
} else if (zoom >= 12) {
|
||||
minDurationMs = 30 * 60 * 1000; // 30 minutes at medium zoom
|
||||
} else if (zoom >= 10) {
|
||||
minDurationMs = 2 * 60 * 60 * 1000; // 2 hours at low zoom
|
||||
} else {
|
||||
minDurationMs = 6 * 60 * 60 * 1000; // 6+ hours at very low zoom
|
||||
}
|
||||
|
||||
this.visibleVisits = this.allVisits.filter(visit =>
|
||||
visit.totalDurationMs >= minDurationMs
|
||||
);
|
||||
|
||||
this.renderVisibleVisits();
|
||||
}
|
||||
|
||||
renderVisibleVisits() {
|
||||
// Clear existing markers
|
||||
this.visitMarkers.forEach(marker => {
|
||||
this.map.removeLayer(marker);
|
||||
});
|
||||
this.visitMarkers = [];
|
||||
|
||||
// Create markers for visible visits
|
||||
this.visibleVisits.forEach(visit => {
|
||||
this.createVisitMarker(visit);
|
||||
});
|
||||
}
|
||||
|
||||
createVisitMarkers() {
|
||||
this.visits.forEach(visit => {
|
||||
this.visibleVisits.forEach(visit => {
|
||||
this.createVisitMarker(visit);
|
||||
});
|
||||
}
|
||||
|
||||
3
src/main/resources/static/js/leaflet.markercluster.js
Normal file
3
src/main/resources/static/js/leaflet.markercluster.js
Normal file
File diff suppressed because one or more lines are too long
@@ -4,6 +4,70 @@ class PhotoClient {
|
||||
this.photoMarkers = [];
|
||||
this.photos = [];
|
||||
this.enabled = enabled;
|
||||
this.markerClusterGroup = null;
|
||||
|
||||
if (this.enabled) {
|
||||
this.initializeClusterGroup();
|
||||
}
|
||||
}
|
||||
|
||||
initializeClusterGroup() {
|
||||
this.markerClusterGroup = L.markerClusterGroup({
|
||||
maxClusterRadius: (zoom) => {
|
||||
// Adjust cluster radius based on zoom level
|
||||
if (zoom >= 15) return 20; // Small clusters at high zoom
|
||||
if (zoom >= 12) return 40; // Medium clusters
|
||||
if (zoom >= 10) return 60; // Larger clusters
|
||||
return 80; // Large clusters at low zoom
|
||||
},
|
||||
iconCreateFunction: (cluster) => {
|
||||
const count = cluster.getChildCount();
|
||||
const iconSize = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--photo-marker-size').trim();
|
||||
const iconSizeNum = parseInt(iconSize) || 50;
|
||||
|
||||
// Get the first photo from the cluster to use as the representative image
|
||||
const firstMarker = cluster.getAllChildMarkers()[0];
|
||||
const firstPhoto = firstMarker.options.photoData;
|
||||
|
||||
const iconHtml = `
|
||||
<div class="photo-marker-icon" style="width: ${iconSize}; height: ${iconSize};">
|
||||
<img src="${firstPhoto.thumbnailUrl}"
|
||||
alt="Cluster of ${count} photos"
|
||||
onerror="this.style.display='none'; this.parentElement.innerHTML='📷';">
|
||||
<div class="photo-count-indicator">${count}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return L.divIcon({
|
||||
html: iconHtml,
|
||||
className: 'photo-marker photo-cluster-marker',
|
||||
iconSize: [iconSizeNum, iconSizeNum],
|
||||
iconAnchor: [iconSizeNum / 2, iconSizeNum / 2]
|
||||
});
|
||||
},
|
||||
spiderfyOnMaxZoom: false,
|
||||
showCoverageOnHover: false,
|
||||
zoomToBoundsOnClick: false
|
||||
});
|
||||
|
||||
// Add cluster click handler to show gallery
|
||||
this.markerClusterGroup.on('clusterclick', (event) => {
|
||||
const cluster = event.layer;
|
||||
const childMarkers = cluster.getAllChildMarkers();
|
||||
const photos = childMarkers.map(marker => marker.options.photoData);
|
||||
this.showPhotoGridModal(photos);
|
||||
});
|
||||
|
||||
this.map.addLayer(this.markerClusterGroup);
|
||||
|
||||
// Set a higher z-index to ensure photos appear above other layers
|
||||
if (this.markerClusterGroup.getPane) {
|
||||
const pane = this.markerClusterGroup.getPane();
|
||||
if (pane) {
|
||||
pane.style.zIndex = 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updatePhotosForRange(start, end, timezone) {
|
||||
@@ -13,13 +77,11 @@ class PhotoClient {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/photos/immich/range?timezone=${timezone}&startDate=${start}&endDate=${end}`);
|
||||
if (!response.ok) {
|
||||
console.warn('Could not fetch photos for date:', date);
|
||||
this.photos = [];
|
||||
} else {
|
||||
this.photos = await response.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error fetching photos:', error);
|
||||
this.photos = [];
|
||||
}
|
||||
|
||||
@@ -38,85 +100,38 @@ class PhotoClient {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current map bounds
|
||||
const bounds = this.map.getBounds();
|
||||
// Ensure cluster group exists and is on the map
|
||||
if (!this.markerClusterGroup) {
|
||||
this.initializeClusterGroup();
|
||||
} else if (!this.map.hasLayer(this.markerClusterGroup)) {
|
||||
this.map.addLayer(this.markerClusterGroup);
|
||||
}
|
||||
|
||||
// Filter photos that are within the current bounds and have valid coordinates
|
||||
const visiblePhotos = this.photos.filter(photo => {
|
||||
if (!photo.latitude || !photo.longitude) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const photoLatLng = L.latLng(photo.latitude, photo.longitude);
|
||||
return bounds.contains(photoLatLng);
|
||||
// Filter photos that have valid coordinates
|
||||
const validPhotos = this.photos.filter(photo => {
|
||||
return photo.latitude && photo.longitude;
|
||||
});
|
||||
|
||||
// Group photos by location (with small tolerance for GPS precision)
|
||||
const photoGroups = this.groupPhotosByLocation(visiblePhotos);
|
||||
|
||||
// Create markers for photo groups
|
||||
photoGroups.forEach(group => {
|
||||
this.createPhotoGroupMarker(group);
|
||||
// Create markers for all valid photos and add to cluster group
|
||||
validPhotos.forEach(photo => {
|
||||
this.createPhotoMarker(photo);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Group photos by location with tolerance for GPS precision
|
||||
* @param {Array} photos - Array of photo objects
|
||||
* @returns {Array} Array of photo groups
|
||||
* Create a marker for a single photo
|
||||
* @param {Object} photo - Photo object
|
||||
*/
|
||||
groupPhotosByLocation(photos) {
|
||||
const groups = [];
|
||||
const tolerance = 0.0003; // ~10 meters tolerance
|
||||
|
||||
photos.forEach(photo => {
|
||||
let foundGroup = false;
|
||||
|
||||
for (let group of groups) {
|
||||
const latDiff = Math.abs(group.latitude - photo.latitude);
|
||||
const lngDiff = Math.abs(group.longitude - photo.longitude);
|
||||
|
||||
if (latDiff < tolerance && lngDiff < tolerance) {
|
||||
group.photos.push(photo);
|
||||
foundGroup = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundGroup) {
|
||||
groups.push({
|
||||
latitude: photo.latitude,
|
||||
longitude: photo.longitude,
|
||||
photos: [photo]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a marker for a photo group
|
||||
* @param {Object} group - Photo group object with latitude, longitude, and photos array
|
||||
*/
|
||||
createPhotoGroupMarker(group) {
|
||||
createPhotoMarker(photo) {
|
||||
const iconSize = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--photo-marker-size').trim();
|
||||
const iconSizeNum = parseInt(iconSize);
|
||||
const primaryPhoto = group.photos[0];
|
||||
const photoCount = group.photos.length;
|
||||
|
||||
// Create count indicator if more than one photo
|
||||
const countIndicator = photoCount > 1 ? `
|
||||
<div class="photo-count-indicator">+${photoCount - 1}</div>
|
||||
` : '';
|
||||
const iconSizeNum = parseInt(iconSize) || 50; // fallback to 50px
|
||||
|
||||
const iconHtml = `
|
||||
<div class="photo-marker-icon" style="width: ${iconSize}; height: ${iconSize};">
|
||||
<img src="${primaryPhoto.thumbnailUrl}"
|
||||
alt="${primaryPhoto.fileName || 'Photo'}"
|
||||
<img src="${photo.thumbnailUrl}"
|
||||
alt="${photo.fileName || 'Photo'}"
|
||||
onerror="this.style.display='none'; this.parentElement.innerHTML='📷';">
|
||||
${countIndicator}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -127,19 +142,24 @@ class PhotoClient {
|
||||
iconAnchor: [iconSizeNum / 2, iconSizeNum / 2]
|
||||
});
|
||||
|
||||
const marker = L.marker([group.latitude, group.longitude], {
|
||||
icon: customIcon
|
||||
const marker = L.marker([photo.latitude, photo.longitude], {
|
||||
icon: customIcon,
|
||||
photoData: photo // Store photo data for cluster icon creation
|
||||
});
|
||||
|
||||
// Add click handler to show photo grid
|
||||
// Add click handler to show single photo
|
||||
marker.on('click', () => {
|
||||
this.showPhotoGridModal(group.photos);
|
||||
this.showPhotoModal(photo);
|
||||
});
|
||||
|
||||
marker.addTo(this.map);
|
||||
this.photoMarkers.push(marker);
|
||||
// Add to cluster group instead of directly to map
|
||||
if (this.markerClusterGroup) {
|
||||
this.markerClusterGroup.addLayer(marker);
|
||||
this.photoMarkers.push(marker);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Show photo grid modal
|
||||
* @param {Array} photos - Array of photo objects
|
||||
@@ -424,9 +444,13 @@ class PhotoClient {
|
||||
* Clear all photo markers from the map
|
||||
*/
|
||||
clearPhotoMarkers() {
|
||||
this.photoMarkers.forEach(marker => {
|
||||
this.map.removeLayer(marker);
|
||||
});
|
||||
if (this.markerClusterGroup) {
|
||||
this.markerClusterGroup.clearLayers();
|
||||
// Ensure cluster group stays on the map after clearing
|
||||
if (!this.map.hasLayer(this.markerClusterGroup)) {
|
||||
this.map.addLayer(this.markerClusterGroup);
|
||||
}
|
||||
}
|
||||
this.photoMarkers = [];
|
||||
}
|
||||
|
||||
|
||||
@@ -230,19 +230,34 @@ class RawLocationLoader {
|
||||
color: color == null ? '#f1ba63' : color
|
||||
};
|
||||
this.allSegments.push(segmentWithMetadata);
|
||||
|
||||
const rawPointsPath = L.geodesic([], {
|
||||
color: color == null ? '#f1ba63' : color,
|
||||
weight: 6,
|
||||
opacity: 0.9,
|
||||
lineJoin: 'round',
|
||||
lineCap: 'round',
|
||||
steps: 2,
|
||||
renderer: this.canvasRenderer
|
||||
});
|
||||
|
||||
|
||||
const rawPointsCoords = segment.points.map(point => [point.latitude, point.longitude]);
|
||||
|
||||
// Use polyline for segments with many points (>100), geodesic for fewer points
|
||||
let rawPointsPath;
|
||||
if (segment.points.length > 100) {
|
||||
rawPointsPath = L.polyline(rawPointsCoords, {
|
||||
color: color == null ? '#f1ba63' : color,
|
||||
weight: 6,
|
||||
opacity: 0.9,
|
||||
lineJoin: 'round',
|
||||
lineCap: 'round',
|
||||
renderer: this.canvasRenderer
|
||||
});
|
||||
} else {
|
||||
rawPointsPath = L.geodesic(rawPointsCoords, {
|
||||
color: color == null ? '#f1ba63' : color,
|
||||
weight: 6,
|
||||
opacity: 0.9,
|
||||
lineJoin: 'round',
|
||||
lineCap: 'round',
|
||||
steps: 2,
|
||||
renderer: this.canvasRenderer
|
||||
});
|
||||
}
|
||||
|
||||
bounds.extend(rawPointsCoords)
|
||||
rawPointsPath.setLatLngs(rawPointsCoords);
|
||||
rawPointsPath.addTo(this.map);
|
||||
this.rawPointPaths.push(rawPointsPath)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<script src="/js/leaflet.js"></script>
|
||||
<script src="/js/TileLayer.Grayscale.js"></script>
|
||||
<script src="/js/leaflet.geodesic.2.7.2.js"></script>
|
||||
<script src="/js/leaflet.markercluster.js"></script>
|
||||
<script src="/js/util.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -509,28 +510,6 @@
|
||||
return bounds;
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
document.querySelector('.timeline-container').addEventListener('click', function (event) {
|
||||
const entry = event.target.closest('.timeline-entry');
|
||||
|
||||
@@ -168,7 +168,7 @@ class TilesCustomizationProviderTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnCacheUrlIfSet() {
|
||||
void shouldReturnCacheUrlIfSetAndCustomServiceEmpty() {
|
||||
// Given
|
||||
String cacheUrl = "http://tiles.cache/hot/";
|
||||
String defaultService = "https://default.tiles.com/{z}/{x}/{y}.png";
|
||||
@@ -187,4 +187,25 @@ class TilesCustomizationProviderTest {
|
||||
assertThat(result.service()).isEqualTo("/api/v1/tiles/{z}/{x}/{y}.png");
|
||||
assertThat(result.attribution()).isEqualTo(customAttribution);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPreferCustomServiceOverCacheUrlIfSet() {
|
||||
// Given
|
||||
String cacheUrl = "http://tiles.cache/hot/";
|
||||
String defaultService = "https://default.tiles.com/{z}/{x}/{y}.png";
|
||||
String defaultAttribution = "Default Attribution";
|
||||
String customService = "https://custom.tiles.com/{z}/{x}/{y}.png";
|
||||
String customAttribution = "Custom Attribution";
|
||||
|
||||
TilesCustomizationProvider provider = new TilesCustomizationProvider(
|
||||
cacheUrl, defaultService, defaultAttribution, customService, customAttribution
|
||||
);
|
||||
|
||||
// When
|
||||
UserSettingsDTO.TilesCustomizationDTO result = provider.getTilesConfiguration();
|
||||
|
||||
// Then
|
||||
assertThat(result.service()).isEqualTo("https://custom.tiles.com/{z}/{x}/{y}.png");
|
||||
assertThat(result.attribution()).isEqualTo(customAttribution);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user