mirror of
https://github.com/dedicatedcode/reitti.git
synced 2026-01-09 17:37:57 -05:00
152 feature request multi date selection (#325)
This commit is contained in:
@@ -62,6 +62,13 @@ public class TimelineController {
|
||||
return getTimelineContent(date, timezone, principal, model, null);
|
||||
}
|
||||
|
||||
@GetMapping("/content/range")
|
||||
public String getTimelineContentRange(@RequestParam String startDate,
|
||||
@RequestParam String endDate,
|
||||
@RequestParam(required = false, defaultValue = "UTC") String timezone,
|
||||
Authentication principal, Model model) {
|
||||
return getTimelineContentRange(startDate, endDate, timezone, principal, model, null);
|
||||
}
|
||||
|
||||
@GetMapping("/places/edit-form/{id}")
|
||||
public String getPlaceEditForm(@PathVariable Long id,
|
||||
@@ -166,6 +173,62 @@ public class TimelineController {
|
||||
return "fragments/timeline :: timeline-content";
|
||||
}
|
||||
|
||||
private String getTimelineContentRange(String startDate,
|
||||
String endDate,
|
||||
String timezone,
|
||||
Authentication principal, Model model,
|
||||
Long selectedPlaceId) {
|
||||
|
||||
List<String> authorities = principal.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList();
|
||||
|
||||
LocalDate selectedStartDate = LocalDate.parse(startDate);
|
||||
LocalDate selectedEndDate = LocalDate.parse(endDate);
|
||||
ZoneId userTimezone = ZoneId.of(timezone);
|
||||
LocalDate now = LocalDate.now(userTimezone);
|
||||
|
||||
// Check if any date in the range is not today
|
||||
if (!selectedStartDate.isEqual(now) || !selectedEndDate.isEqual(now)) {
|
||||
if (!authorities.contains("ROLE_USER") && !authorities.contains("ROLE_ADMIN") && !authorities.contains("ROLE_MAGIC_LINK_FULL_ACCESS")) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
// Find the user by username
|
||||
User user = userJdbcService.findByUsername(principal.getName())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found"));
|
||||
|
||||
// Convert LocalDate to start and end Instant for the date range in user's timezone
|
||||
Instant startOfRange = selectedStartDate.atStartOfDay(userTimezone).toInstant();
|
||||
Instant endOfRange = selectedEndDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1);
|
||||
|
||||
// Build timeline data for current user and connected users
|
||||
List<UserTimelineData> allUsersData = new ArrayList<>();
|
||||
|
||||
// Add current user data first - for range, we'll use the start date for the timeline service
|
||||
List<TimelineEntry> currentUserEntries = this.timelineService.buildTimelineEntries(user, userTimezone, selectedStartDate, startOfRange, endOfRange);
|
||||
|
||||
String currentUserRawLocationPointsUrl = String.format("/api/v1/raw-location-points/%d?startDate=%s&endDate=%s&timezone=%s", user.getId(), startDate, endDate, 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.getUsername(), currentUserInitials, currentUserAvatarUrl, null, currentUserEntries, currentUserRawLocationPointsUrl));
|
||||
|
||||
if (authorities.contains("ROLE_USER") || authorities.contains("ROLE_ADMIN")) {
|
||||
allUsersData.addAll(this.reittiIntegrationService.getTimelineDataRange(user, selectedStartDate, selectedEndDate, userTimezone));
|
||||
allUsersData.addAll(handleSharedUserDataRange(user, selectedStartDate, selectedEndDate, userTimezone));
|
||||
}
|
||||
|
||||
TimelineData timelineData = new TimelineData(allUsersData.stream().filter(Objects::nonNull).toList());
|
||||
|
||||
model.addAttribute("timelineData", timelineData);
|
||||
model.addAttribute("selectedPlaceId", selectedPlaceId);
|
||||
model.addAttribute("startDate", selectedStartDate);
|
||||
model.addAttribute("endDate", selectedEndDate);
|
||||
model.addAttribute("timezone", timezone);
|
||||
model.addAttribute("isRange", true);
|
||||
model.addAttribute("timeDisplayMode", userSettingsJdbcService.getOrCreateDefaultSettings(user.getId()).getTimeDisplayMode());
|
||||
return "fragments/timeline :: timeline-content";
|
||||
}
|
||||
|
||||
private List<UserTimelineData> handleSharedUserData(User user, LocalDate selectedDate, ZoneId userTimezone, Instant startOfDay, Instant endOfDay) {
|
||||
return this.userSharingJdbcService.findBySharedWithUser(user.getId()).stream()
|
||||
.map(u -> {
|
||||
@@ -190,4 +253,32 @@ public class TimelineController {
|
||||
.sorted(Comparator.comparing(UserTimelineData::displayName))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<UserTimelineData> handleSharedUserDataRange(User user, LocalDate startDate, LocalDate endDate, ZoneId userTimezone) {
|
||||
return this.userSharingJdbcService.findBySharedWithUser(user.getId()).stream()
|
||||
.map(u -> {
|
||||
Optional<User> sharedWithUserOpt = this.userJdbcService.findById(u.getSharingUserId());
|
||||
return sharedWithUserOpt.map(sharedWithUser -> {
|
||||
Instant startOfRange = startDate.atStartOfDay(userTimezone).toInstant();
|
||||
Instant endOfRange = endDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1);
|
||||
|
||||
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 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());
|
||||
|
||||
return new UserTimelineData(sharedWithUser.getId() + "",
|
||||
sharedWithUser.getDisplayName(),
|
||||
currentUserInitials,
|
||||
currentUserAvatarUrl,
|
||||
u.getColor(),
|
||||
userTimelineEntries,
|
||||
currentUserRawLocationPointsUrl);
|
||||
});
|
||||
})
|
||||
.filter(Optional::isPresent)
|
||||
.map(Optional::get)
|
||||
.sorted(Comparator.comparing(UserTimelineData::displayName))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.dedicatedcode.reitti.model.security.ApiToken;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService;
|
||||
import com.dedicatedcode.reitti.repository.UserJdbcService;
|
||||
import com.dedicatedcode.reitti.service.LocationPointsSimplificationService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -18,10 +19,7 @@ import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
@@ -30,41 +28,67 @@ public class LocationDataApiController {
|
||||
private static final Logger logger = LoggerFactory.getLogger(LocationDataApiController.class);
|
||||
|
||||
private final RawLocationPointJdbcService rawLocationPointJdbcService;
|
||||
private final LocationPointsSimplificationService simplificationService;
|
||||
private final UserJdbcService userJdbcService;
|
||||
|
||||
@Autowired
|
||||
public LocationDataApiController(RawLocationPointJdbcService rawLocationPointJdbcService,
|
||||
UserJdbcService userJdbcService) {
|
||||
LocationPointsSimplificationService simplificationService,
|
||||
UserJdbcService userJdbcService) {
|
||||
this.rawLocationPointJdbcService = rawLocationPointJdbcService;
|
||||
this.simplificationService = simplificationService;
|
||||
this.userJdbcService = userJdbcService;
|
||||
}
|
||||
|
||||
@GetMapping("/raw-location-points")
|
||||
public ResponseEntity<?> getRawLocationPointsForCurrentUser(@AuthenticationPrincipal User user,
|
||||
@RequestParam("date") String dateStr,
|
||||
@RequestParam(required = false, defaultValue = "UTC") String timezone) {
|
||||
return this.getRawLocationPoints(user.getId(), dateStr, timezone);
|
||||
@RequestParam(required = false) String date,
|
||||
@RequestParam(required = false) String startDate,
|
||||
@RequestParam(required = false) String endDate,
|
||||
@RequestParam(required = false, defaultValue = "UTC") String timezone,
|
||||
@RequestParam(required = false) Integer zoom) {
|
||||
return this.getRawLocationPoints(user.getId(), date, startDate, endDate, timezone, zoom);
|
||||
}
|
||||
|
||||
@GetMapping("/raw-location-points/{userId}")
|
||||
public ResponseEntity<?> getRawLocationPoints(@PathVariable Long userId,
|
||||
@RequestParam("date") String dateStr,
|
||||
@RequestParam(required = false, defaultValue = "UTC") String timezone) {
|
||||
@RequestParam(required = false) String date,
|
||||
@RequestParam(required = false) String startDate,
|
||||
@RequestParam(required = false) String endDate,
|
||||
@RequestParam(required = false, defaultValue = "UTC") String timezone,
|
||||
@RequestParam(required = false) Integer zoom) {
|
||||
try {
|
||||
LocalDate date = LocalDate.parse(dateStr);
|
||||
ZoneId userTimezone = ZoneId.of(timezone);
|
||||
Instant startOfRange;
|
||||
Instant endOfRange;
|
||||
|
||||
// Convert LocalDate to start and end Instant for the selected date in user's timezone
|
||||
Instant startOfDay = date.atStartOfDay(userTimezone).toInstant();
|
||||
Instant endOfDay = date.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1);
|
||||
// Support both single date and date range
|
||||
if (startDate != null && endDate != null) {
|
||||
// Date range mode
|
||||
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"
|
||||
));
|
||||
}
|
||||
|
||||
// Get the user from the repository by userId
|
||||
User user = userJdbcService.findById(userId)
|
||||
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||
|
||||
// Get raw location points for the user and date range
|
||||
List<LocationDataRequest.LocationPoint> points = rawLocationPointJdbcService.findByUserAndTimestampBetweenOrderByTimestampAsc(user, startOfDay, endOfDay).stream()
|
||||
.filter(point -> !point.getTimestamp().isBefore(startOfDay) && point.getTimestamp().isBefore(endOfDay))
|
||||
List<LocationDataRequest.LocationPoint> points = rawLocationPointJdbcService.findByUserAndTimestampBetweenOrderByTimestampAsc(user, startOfRange, endOfRange).stream()
|
||||
.filter(point -> !point.getTimestamp().isBefore(startOfRange) && point.getTimestamp().isBefore(endOfRange))
|
||||
.sorted(Comparator.comparing(RawLocationPoint::getTimestamp))
|
||||
.map(point -> {
|
||||
LocationDataRequest.LocationPoint p = new LocationDataRequest.LocationPoint();
|
||||
@@ -76,7 +100,9 @@ public class LocationDataApiController {
|
||||
})
|
||||
.toList();
|
||||
|
||||
return ResponseEntity.ok(Map.of("points", points));
|
||||
List<LocationDataRequest.LocationPoint> simplifiedPoints = simplificationService.simplifyPoints(points, zoom);
|
||||
|
||||
return ResponseEntity.ok(Map.of("points", simplifiedPoints));
|
||||
|
||||
} catch (DateTimeParseException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
|
||||
@@ -25,14 +25,15 @@ public class PhotoApiController {
|
||||
this.immichIntegrationService = immichIntegrationService;
|
||||
this.restTemplate = restTemplate;
|
||||
}
|
||||
|
||||
@GetMapping("/day/{date}")
|
||||
public ResponseEntity<List<PhotoResponse>> getPhotosForDay(
|
||||
@PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
|
||||
|
||||
@GetMapping("/range")
|
||||
public ResponseEntity<List<PhotoResponse>> getPhotosForRange(
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@RequestParam(required = false, defaultValue = "UTC") String timezone,
|
||||
@AuthenticationPrincipal User user) {
|
||||
|
||||
List<PhotoResponse> photos = immichIntegrationService.searchPhotosForDay(user, date, timezone);
|
||||
List<PhotoResponse> photos = immichIntegrationService.searchPhotosForRange(user, startDate, endDate, timezone);
|
||||
return ResponseEntity.ok(photos);
|
||||
}
|
||||
|
||||
|
||||
@@ -61,16 +61,34 @@ public class ReittiIntegrationApiController {
|
||||
|
||||
@GetMapping("/timeline")
|
||||
public List<TimelineEntry> getTimeline(@AuthenticationPrincipal User user,
|
||||
@RequestParam String date,
|
||||
@RequestParam(required = false) String date,
|
||||
@RequestParam(required = false) String startDate,
|
||||
@RequestParam(required = false) String endDate,
|
||||
@RequestParam(required = false, defaultValue = "UTC") String timezone) {
|
||||
|
||||
LocalDate selectedDate = LocalDate.parse(date);
|
||||
ZoneId userTimezone = ZoneId.of(timezone);
|
||||
|
||||
Instant startOfDay = selectedDate.atStartOfDay(userTimezone).toInstant();
|
||||
Instant endOfDay = selectedDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1);
|
||||
// Support both single date and date range
|
||||
if (startDate != null && endDate != null) {
|
||||
// Date range mode
|
||||
LocalDate selectedStartDate = LocalDate.parse(startDate);
|
||||
LocalDate selectedEndDate = LocalDate.parse(endDate);
|
||||
|
||||
return this.timelineService.buildTimelineEntries(user, userTimezone, selectedDate, startOfDay, endOfDay);
|
||||
Instant startOfRange = selectedStartDate.atStartOfDay(userTimezone).toInstant();
|
||||
Instant endOfRange = selectedEndDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1);
|
||||
|
||||
return this.timelineService.buildTimelineEntries(user, userTimezone, selectedStartDate, startOfRange, endOfRange);
|
||||
} else if (date != null) {
|
||||
// Single date mode (backward compatibility)
|
||||
LocalDate selectedDate = LocalDate.parse(date);
|
||||
|
||||
Instant startOfDay = selectedDate.atStartOfDay(userTimezone).toInstant();
|
||||
Instant endOfDay = selectedDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1);
|
||||
|
||||
return this.timelineService.buildTimelineEntries(user, userTimezone, selectedDate, startOfDay, endOfDay);
|
||||
} else {
|
||||
throw new IllegalArgumentException("Either 'date' or both 'startDate' and 'endDate' must be provided");
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/subscribe")
|
||||
|
||||
@@ -15,6 +15,7 @@ import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -178,9 +179,9 @@ public class RawLocationPointJdbcService {
|
||||
return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM raw_location_points WHERE user_id = ?", Long.class, user.getId());
|
||||
}
|
||||
|
||||
public void bulkInsert(User user, List<LocationDataRequest.LocationPoint> points) {
|
||||
public int bulkInsert(User user, List<LocationDataRequest.LocationPoint> points) {
|
||||
if (points.isEmpty()) {
|
||||
return;
|
||||
return -1;
|
||||
}
|
||||
|
||||
String sql = "INSERT INTO raw_location_points (user_id, timestamp, accuracy_meters, geom, processed) " +
|
||||
@@ -197,7 +198,8 @@ public class RawLocationPointJdbcService {
|
||||
geometryFactory.createPoint(new Coordinate(point.getLongitude(), point.getLatitude())).toString()
|
||||
});
|
||||
}
|
||||
jdbcTemplate.batchUpdate(sql, batchArgs);
|
||||
int[] ints = jdbcTemplate.batchUpdate(sql, batchArgs);
|
||||
return Arrays.stream(ints).sum();
|
||||
}
|
||||
|
||||
public void bulkUpdateProcessedStatus(List<RawLocationPoint> points) {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package com.dedicatedcode.reitti.service;
|
||||
|
||||
import com.dedicatedcode.reitti.dto.LocationDataRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
public class LocationPointsSimplificationService {
|
||||
private static final Logger logger = org.slf4j.LoggerFactory.getLogger(LocationPointsSimplificationService.class);
|
||||
|
||||
/**
|
||||
* Simplify a list of location points using the Visvalingam-Whyatt algorithm
|
||||
*
|
||||
* @param points The original list of points
|
||||
* @param zoom The current map zoom level (null means no simplification)
|
||||
* @return Simplified list of points
|
||||
*/
|
||||
public List<LocationDataRequest.LocationPoint> simplifyPoints(List<LocationDataRequest.LocationPoint> points, Integer zoom) {
|
||||
// If zoom is not provided or points are too few, return original
|
||||
if (zoom == null || points.size() <= 2) {
|
||||
return points;
|
||||
}
|
||||
|
||||
// Calculate target point count based on zoom level
|
||||
// Higher zoom = more detail = more points
|
||||
// Zoom levels typically range from 1-20
|
||||
int targetPointCount = calculateTargetPointCount(points.size(), zoom);
|
||||
|
||||
|
||||
if (targetPointCount >= points.size()) {
|
||||
return points;
|
||||
}
|
||||
|
||||
logger.debug("Simplifying {} points to {} points for zoom level {}", points.size(), targetPointCount, zoom);
|
||||
|
||||
return visvalingamWhyatt(points, targetPointCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate target point count based on zoom level
|
||||
*/
|
||||
private int calculateTargetPointCount(int originalCount, int zoom) {
|
||||
// Ensure we keep at least 10 points and at most the original count
|
||||
// Zoom 1-5: very simplified (10-20% of points)
|
||||
// Zoom 6-10: moderately simplified (20-40% of points)
|
||||
// Zoom 11-15: lightly simplified (40-70% of points)
|
||||
// Zoom 16+: minimal simplification (70-100% of points)
|
||||
|
||||
double retentionRatio;
|
||||
if (zoom <= 13) {
|
||||
retentionRatio = 0.10 + (zoom - 1) * 0.025; // 10% to 20%
|
||||
} else if (zoom <= 15) {
|
||||
retentionRatio = 0.20 + (zoom - 6) * 0.04; // 20% to 40%
|
||||
} else if (zoom <= 18) {
|
||||
retentionRatio = 0.40 + (zoom - 11) * 0.06; // 40% to 70%
|
||||
} else {
|
||||
retentionRatio = 0.70 + Math.min(zoom - 16, 4) * 0.075; // 70% to 100%
|
||||
}
|
||||
|
||||
|
||||
int targetCount = (int) Math.ceil(originalCount * retentionRatio);
|
||||
targetCount = Math.min(3000, targetCount);
|
||||
return Math.max(10, Math.min(targetCount, originalCount));
|
||||
}
|
||||
|
||||
/**
|
||||
* Visvalingam-Whyatt algorithm implementation for polyline simplification
|
||||
*/
|
||||
private List<LocationDataRequest.LocationPoint> visvalingamWhyatt(List<LocationDataRequest.LocationPoint> points, int targetCount) {
|
||||
if (points.size() <= targetCount) {
|
||||
return points;
|
||||
}
|
||||
|
||||
// Create a list of triangles with their effective areas
|
||||
List<Triangle> triangles = new ArrayList<>();
|
||||
|
||||
// Initialize triangles for all interior points
|
||||
for (int i = 1; i < points.size() - 1; i++) {
|
||||
Triangle triangle = new Triangle(i - 1, i, i + 1, points);
|
||||
triangles.add(triangle);
|
||||
}
|
||||
|
||||
// Use a priority queue to efficiently find the triangle with minimum area
|
||||
PriorityQueue<Triangle> heap = new PriorityQueue<>(Comparator.comparingDouble(t -> t.area));
|
||||
heap.addAll(triangles);
|
||||
|
||||
// Track which points to keep
|
||||
Set<Integer> removedIndices = new HashSet<>();
|
||||
|
||||
// Remove points until we reach the target count
|
||||
int pointsToRemove = points.size() - targetCount;
|
||||
|
||||
while (pointsToRemove > 0 && !heap.isEmpty()) {
|
||||
Triangle minTriangle = heap.poll();
|
||||
|
||||
// Skip if this triangle's center point was already removed
|
||||
if (removedIndices.contains(minTriangle.centerIndex)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark the center point for removal
|
||||
removedIndices.add(minTriangle.centerIndex);
|
||||
pointsToRemove--;
|
||||
|
||||
// Update neighboring triangles
|
||||
updateNeighboringTriangles(heap, minTriangle, removedIndices, points);
|
||||
}
|
||||
|
||||
// Build the result list with remaining points
|
||||
List<LocationDataRequest.LocationPoint> result = new ArrayList<>();
|
||||
for (int i = 0; i < points.size(); i++) {
|
||||
if (!removedIndices.contains(i)) {
|
||||
result.add(points.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update neighboring triangles after removing a point
|
||||
*/
|
||||
private void updateNeighboringTriangles(PriorityQueue<Triangle> heap, Triangle removed,
|
||||
Set<Integer> removedIndices, List<LocationDataRequest.LocationPoint> points) {
|
||||
// Find the previous and next non-removed points
|
||||
int prevIndex = removed.leftIndex;
|
||||
while (prevIndex > 0 && removedIndices.contains(prevIndex)) {
|
||||
prevIndex--;
|
||||
}
|
||||
|
||||
int nextIndex = removed.rightIndex;
|
||||
while (nextIndex < points.size() - 1 && removedIndices.contains(nextIndex)) {
|
||||
nextIndex++;
|
||||
}
|
||||
|
||||
// Create new triangles if possible
|
||||
if (prevIndex > 0 && !removedIndices.contains(prevIndex - 1)) {
|
||||
Triangle newTriangle = new Triangle(prevIndex - 1, prevIndex, nextIndex, points);
|
||||
heap.add(newTriangle);
|
||||
}
|
||||
|
||||
if (nextIndex < points.size() - 1 && !removedIndices.contains(nextIndex + 1)) {
|
||||
Triangle newTriangle = new Triangle(prevIndex, nextIndex, nextIndex + 1, points);
|
||||
heap.add(newTriangle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class to represent a triangle formed by three consecutive points
|
||||
*/
|
||||
private static class Triangle {
|
||||
final int leftIndex;
|
||||
final int centerIndex;
|
||||
final int rightIndex;
|
||||
final double area;
|
||||
|
||||
Triangle(int leftIndex, int centerIndex, int rightIndex, List<LocationDataRequest.LocationPoint> points) {
|
||||
this.leftIndex = leftIndex;
|
||||
this.centerIndex = centerIndex;
|
||||
this.rightIndex = rightIndex;
|
||||
this.area = calculateTriangleArea(
|
||||
points.get(leftIndex),
|
||||
points.get(centerIndex),
|
||||
points.get(rightIndex)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the area of a triangle formed by three points using the cross product
|
||||
*/
|
||||
private static double calculateTriangleArea(LocationDataRequest.LocationPoint p1,
|
||||
LocationDataRequest.LocationPoint p2,
|
||||
LocationDataRequest.LocationPoint p3) {
|
||||
// Using the cross product formula for triangle area
|
||||
// Area = 0.5 * |x1(y2 - y3) + x2(y3 - y1) + x3(y1 - y2)|
|
||||
double area = Math.abs(
|
||||
p1.getLongitude() * (p2.getLatitude() - p3.getLatitude()) +
|
||||
p2.getLongitude() * (p3.getLatitude() - p1.getLatitude()) +
|
||||
p3.getLongitude() * (p1.getLatitude() - p2.getLatitude())
|
||||
) / 2.0;
|
||||
|
||||
return area;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -97,7 +97,7 @@ public class ImmichIntegrationService {
|
||||
}
|
||||
}
|
||||
|
||||
public List<PhotoResponse> searchPhotosForDay(User user, LocalDate date, String timezone) {
|
||||
public List<PhotoResponse> searchPhotosForRange(User user, LocalDate start, LocalDate end, String timezone) {
|
||||
Optional<ImmichIntegration> integrationOpt = getIntegrationForUser(user);
|
||||
|
||||
if (integrationOpt.isEmpty() || !integrationOpt.get().isEnabled()) {
|
||||
@@ -113,8 +113,8 @@ public class ImmichIntegrationService {
|
||||
|
||||
ZoneId userTimezone = ZoneId.of(timezone);
|
||||
// Convert LocalDate to start and end Instant for the selected date in user's timezone
|
||||
Instant startOfDay = date.atStartOfDay(userTimezone).toInstant();
|
||||
Instant endOfDay = date.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1);
|
||||
Instant startOfDay = start.atStartOfDay(userTimezone).toInstant();
|
||||
Instant endOfDay = end.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1);
|
||||
|
||||
ImmichSearchRequest searchRequest = new ImmichSearchRequest(DateTimeFormatter.ISO_INSTANT.format(startOfDay), DateTimeFormatter.ISO_INSTANT.format(endOfDay));
|
||||
|
||||
|
||||
@@ -78,6 +78,35 @@ public class ReittiIntegrationService {
|
||||
}).toList();
|
||||
}
|
||||
|
||||
public List<UserTimelineData> getTimelineDataRange(User user, LocalDate startDate, LocalDate endDate, ZoneId userTimezone) {
|
||||
return this.jdbcService
|
||||
.findAllByUser(user)
|
||||
.stream().filter(integration -> integration.isEnabled() && VALID_INTEGRATION_STATUS.contains(integration.getStatus()))
|
||||
.map(integration -> {
|
||||
|
||||
log.debug("Fetching user timeline data range for [{}] from {} to {}", integration, startDate, endDate);
|
||||
try {
|
||||
RemoteUser remoteUser = handleRemoteUser(integration);
|
||||
List<TimelineEntry> timelineEntries = loadTimeLineEntriesRange(integration, startDate, endDate, userTimezone);
|
||||
integration = update(integration.withStatus(ReittiIntegration.Status.ACTIVE).withLastUsed(LocalDateTime.now()));
|
||||
return new UserTimelineData("remote:" + integration.getId(),
|
||||
remoteUser.getDisplayName(),
|
||||
this.avatarService.generateInitials(remoteUser.getDisplayName()),
|
||||
"/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));
|
||||
} 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;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
public ReittiRemoteInfo getInfo(ReittiIntegration integration) throws RequestFailedException, RequestTemporaryFailedException {
|
||||
return getInfo(integration.getUrl(), integration.getToken());
|
||||
}
|
||||
@@ -159,6 +188,53 @@ public class ReittiIntegrationService {
|
||||
|
||||
}
|
||||
|
||||
public List<LocationDataRequest.LocationPoint> getRawLocationDataRange(User user, Long integrationId, String startDateStr, String endDateStr, String timezone) {
|
||||
return this.jdbcService
|
||||
.findByIdAndUser(integrationId,user)
|
||||
.stream().filter(integration -> integration.isEnabled() && VALID_INTEGRATION_STATUS.contains(integration.getStatus()))
|
||||
.map(integration -> {
|
||||
|
||||
log.debug("Fetching raw location data range for [{}] from {} to {}", integration, startDateStr, endDateStr);
|
||||
try {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("X-API-TOKEN", integration.getToken());
|
||||
HttpEntity<String> entity = new HttpEntity<>(headers);
|
||||
|
||||
String rawLocationDataUrl = integration.getUrl().endsWith("/") ?
|
||||
integration.getUrl() + "api/v1/raw-location-points?startDate={startDate}&endDate={endDate}&timezone={timezone}" :
|
||||
integration.getUrl() + "/api/v1/raw-location-points?startDate={startDate}&endDate={endDate}&timezone={timezone}";
|
||||
ResponseEntity<Map> remoteResponse = restTemplate.exchange(
|
||||
rawLocationDataUrl,
|
||||
HttpMethod.GET,
|
||||
entity,
|
||||
Map.class,
|
||||
startDateStr,
|
||||
endDateStr,
|
||||
timezone
|
||||
);
|
||||
|
||||
if (remoteResponse.getStatusCode().is2xxSuccessful() && remoteResponse.getBody() != null && remoteResponse.getBody().containsKey("points")) {
|
||||
update(integration.withStatus(ReittiIntegration.Status.ACTIVE).withLastUsed(LocalDateTime.now()));
|
||||
return (List<LocationDataRequest.LocationPoint>) remoteResponse.getBody().get("points");
|
||||
} else if (remoteResponse.getStatusCode().is4xxClientError()) {
|
||||
throw new RequestFailedException(rawLocationDataUrl, remoteResponse.getStatusCode(), remoteResponse.getBody());
|
||||
} else {
|
||||
throw new RequestTemporaryFailedException(rawLocationDataUrl, 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(Collections.emptyList());
|
||||
|
||||
}
|
||||
|
||||
private ReittiIntegration update(ReittiIntegration integration) {
|
||||
try {
|
||||
return this.jdbcService.update(integration).orElseThrow();
|
||||
@@ -198,6 +274,37 @@ public class ReittiIntegrationService {
|
||||
}
|
||||
}
|
||||
|
||||
private List<TimelineEntry> loadTimeLineEntriesRange(ReittiIntegration integration, LocalDate startDate, LocalDate endDate, ZoneId userTimezone) throws RequestFailedException, RequestTemporaryFailedException {
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("X-API-TOKEN", integration.getToken());
|
||||
HttpEntity<String> entity = new HttpEntity<>(headers);
|
||||
|
||||
String timelineUrl = integration.getUrl().endsWith("/") ?
|
||||
integration.getUrl() + "api/v1/reitti-integration/timeline?startDate={startDate}&endDate={endDate}&timezone={timezone}" :
|
||||
integration.getUrl() + "/api/v1/reitti-integration/timeline?startDate={startDate}&endDate={endDate}&timezone={timezone}";
|
||||
|
||||
|
||||
ParameterizedTypeReference<List<TimelineEntry>> typeRef = new ParameterizedTypeReference<>() {};
|
||||
ResponseEntity<List<TimelineEntry>> remoteResponse = restTemplate.exchange(
|
||||
timelineUrl,
|
||||
HttpMethod.GET,
|
||||
entity,
|
||||
typeRef,
|
||||
startDate,
|
||||
endDate,
|
||||
userTimezone.getId()
|
||||
);
|
||||
|
||||
if (remoteResponse.getStatusCode().is2xxSuccessful()) {
|
||||
return remoteResponse.getBody();
|
||||
} else if (remoteResponse.getStatusCode().is4xxClientError()) {
|
||||
throw new RequestFailedException(timelineUrl, remoteResponse.getStatusCode(), remoteResponse.getBody());
|
||||
} else {
|
||||
throw new RequestTemporaryFailedException(timelineUrl, remoteResponse.getStatusCode(), remoteResponse.getBody());
|
||||
}
|
||||
}
|
||||
|
||||
private RemoteUser handleRemoteUser(ReittiIntegration integration) throws RequestFailedException, RequestTemporaryFailedException {
|
||||
ReittiRemoteInfo info = getInfo(integration);
|
||||
Optional<RemoteUser> persisted = this.jdbcService.findByIntegration(integration);
|
||||
@@ -363,4 +470,4 @@ public class ReittiIntegrationService {
|
||||
public Optional<Long> getUserIdForSubscription(String subscriptionId) {
|
||||
return Optional.ofNullable(this.userForSubscriptions.get(subscriptionId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,9 +51,9 @@ public class LocationDataIngestPipeline {
|
||||
User user = userOpt.get();
|
||||
List<LocationDataRequest.LocationPoint> points = event.getPoints();
|
||||
List<LocationDataRequest.LocationPoint> filtered = this.geoPointAnomalyFilter.filterAnomalies(points);
|
||||
rawLocationPointJdbcService.bulkInsert(user, filtered);
|
||||
int updatedRows = rawLocationPointJdbcService.bulkInsert(user, filtered);
|
||||
userSettingsJdbcService.updateNewestData(user, filtered);
|
||||
userNotificationService.newRawLocationData(user, filtered);
|
||||
logger.info("Finished storing points [{}] for user [{}] in [{}]ms. Filtered out [{}] points.", filtered.size(), event.getUsername(), System.currentTimeMillis() - start, points.size() - filtered.size());
|
||||
logger.info("Finished storing points [{}] for user [{}] in [{}]ms. Filtered out [{}] points before database and [{}] after database.", filtered.size(), event.getUsername(), System.currentTimeMillis() - start, points.size() - filtered.size(), filtered.size() - updatedRows);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,19 +39,43 @@
|
||||
min-width: 80px;
|
||||
user-select: none;
|
||||
scroll-snap-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.date-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.date-item.selected {
|
||||
background-color: #4a89dc;
|
||||
background-color: #fddca1;
|
||||
color: white;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.date-item.selected::before {
|
||||
content: "\eb47";
|
||||
font-family: 'Lineicons';
|
||||
font-weight: 900;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 2rem;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.date-item.selected:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.date-item.selected:hover .day-name,
|
||||
.date-item.selected:hover .day-number,
|
||||
.date-item.selected:hover .month-year-name {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.date-item.unavailable {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
@@ -62,16 +86,134 @@
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Range mode styles */
|
||||
.date-item.range-start,
|
||||
.date-item.range-end {
|
||||
background-color: rgba(117, 117, 117, 0.67);
|
||||
color: white;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.date-item.range-start::before,
|
||||
.date-item.range-end::before {
|
||||
content: "\ec2a";
|
||||
font-family: 'Lineicons';
|
||||
font-weight: 900;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 2rem;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.date-item.range-start:hover::before,
|
||||
.date-item.range-end:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.date-item.range-start:hover .day-name,
|
||||
.date-item.range-start:hover .day-number,
|
||||
.date-item.range-end:hover .day-name,
|
||||
.date-item.range-end:hover .day-number {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.date-item.range-start::after {
|
||||
content: 'Start';
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.8rem;
|
||||
color: wheat;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.date-item.range-start:hover::after {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.date-item.range-end::after {
|
||||
content: 'End';
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.8rem;
|
||||
color: #fddca1;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.date-item.range-end:hover::after {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.date-item.in-range {
|
||||
background-color: rgba(117, 117, 117, 0.67);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.date-item.in-range:hover {
|
||||
background-color: rgba(253, 220, 161, 0.75);
|
||||
}
|
||||
|
||||
/* Range preview on hover */
|
||||
.date-item.range-preview {
|
||||
background-color: rgba(117, 117, 117, 0.4);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Clear range button */
|
||||
.clear-range-button {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background-color: rgba(220, 74, 74, 0.8);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
z-index: 52;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.clear-range-button:hover {
|
||||
background-color: rgba(220, 74, 74, 1);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.date-item .day-name {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
display: block;
|
||||
transition: opacity 0.3s ease;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.date-item .day-number {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
transition: opacity 0.3s ease;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.date-item .month-name {
|
||||
@@ -84,6 +226,9 @@
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
margin-top: 2px;
|
||||
transition: opacity 0.3s ease;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.date-nav-button {
|
||||
@@ -246,6 +391,64 @@
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.date-item.selected {
|
||||
background-color: unset;
|
||||
color: var(--color-highlight);
|
||||
transform: scale(1.05);
|
||||
box-shadow: unset;
|
||||
}
|
||||
|
||||
.month-item.selected {
|
||||
background-color: var(--color-highlight);
|
||||
color: var(--color-highlight);
|
||||
font-weight: bolder;
|
||||
background: unset;
|
||||
}
|
||||
|
||||
.year-item.selected {
|
||||
background-color: var(--color-highlight);
|
||||
color: var(--color-highlight);
|
||||
font-weight: bolder;
|
||||
background: unset;
|
||||
}
|
||||
|
||||
.today-button {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
.date-item {
|
||||
padding: unset;
|
||||
margin: unset;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.date-item.selected:hover {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.date-item .day-name,
|
||||
.date-item .day-number {
|
||||
font-size: 2rem;
|
||||
font-weight: lighter;
|
||||
}
|
||||
|
||||
.horizontal-date-picker {
|
||||
position: fixed;
|
||||
font-family: var(--serif-font);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background-color: rgba(59, 59, 59, 0.68);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 10px 0;
|
||||
z-index: 50;
|
||||
box-shadow: unset;
|
||||
}
|
||||
|
||||
.date-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.date-item {
|
||||
min-width: 60px;
|
||||
@@ -266,4 +469,21 @@
|
||||
justify-content: center;
|
||||
position: initial;
|
||||
}
|
||||
|
||||
.clear-range-button {
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.date-item.selected::before {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.date-item.range-start::before,
|
||||
.date-item.range-end::before {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -501,65 +501,6 @@ button:hover {
|
||||
background-color: #959595;
|
||||
}
|
||||
|
||||
.date-item.selected {
|
||||
background-color: unset;
|
||||
color: var(--color-highlight);
|
||||
transform: scale(1.05);
|
||||
box-shadow: unset;
|
||||
}
|
||||
|
||||
.month-item.selected {
|
||||
background-color: var(--color-highlight);
|
||||
color: var(--color-highlight);
|
||||
font-weight: bolder;
|
||||
background: unset;
|
||||
}
|
||||
|
||||
.year-item.selected {
|
||||
background-color: var(--color-highlight);
|
||||
color: var(--color-highlight);
|
||||
font-weight: bolder;
|
||||
background: unset;
|
||||
}
|
||||
|
||||
.today-button {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
.date-item {
|
||||
padding: unset;
|
||||
margin: unset;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.date-item:hover {
|
||||
background-color: unset;
|
||||
border-bottom: 1px solid white;
|
||||
}
|
||||
|
||||
.date-item.selected:hover {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.date-item .day-name,
|
||||
.date-item .day-number {
|
||||
font-size: 2rem;
|
||||
font-weight: lighter;
|
||||
}
|
||||
|
||||
.horizontal-date-picker {
|
||||
position: fixed;
|
||||
font-family: var(--serif-font);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background-color: rgba(59, 59, 59, 0.68);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 10px 0;
|
||||
z-index: 50;
|
||||
box-shadow: unset;
|
||||
}
|
||||
|
||||
.upload-options progress {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
@@ -9,6 +9,7 @@ class HorizontalDatePicker {
|
||||
daysToShow: 15,
|
||||
daysBeforeToday: 7,
|
||||
onDateSelect: null,
|
||||
onDateRangeSelect: null,
|
||||
selectedDate: new Date(),
|
||||
showNavButtons: true, // Option to show/hide navigation buttons
|
||||
minDate: null, // Minimum selectable date
|
||||
@@ -24,6 +25,14 @@ class HorizontalDatePicker {
|
||||
// Track the last valid date for reverting invalid selections
|
||||
this.lastValidDate = new Date(this.options.selectedDate);
|
||||
|
||||
// Range mode properties
|
||||
this.rangeMode = false;
|
||||
this.rangeStartDate = null;
|
||||
this.rangeEndDate = null;
|
||||
|
||||
// Track original month/year for hover restoration
|
||||
this.originalSelectedDate = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
@@ -81,6 +90,13 @@ class HorizontalDatePicker {
|
||||
this.element.appendChild(this.nextButton);
|
||||
}
|
||||
|
||||
// Create clear range button (initially hidden)
|
||||
this.clearRangeButton = document.createElement('button');
|
||||
this.clearRangeButton.className = 'clear-range-button';
|
||||
this.clearRangeButton.innerHTML = '<i class="fas fa-times"></i> Clear Range';
|
||||
this.clearRangeButton.style.display = 'none';
|
||||
this.element.appendChild(this.clearRangeButton);
|
||||
|
||||
// Append date container
|
||||
this.element.appendChild(this.dateContainer);
|
||||
|
||||
@@ -125,8 +141,24 @@ class HorizontalDatePicker {
|
||||
const dateItem = e.target.closest('.date-item');
|
||||
if (dateItem) {
|
||||
// Check if this date is already selected
|
||||
if (dateItem.classList.contains('selected')) {
|
||||
return; // Do nothing if clicking on already selected date
|
||||
if (dateItem.classList.contains('selected') && !this.rangeMode) {
|
||||
// Clicking on selected date enters range mode
|
||||
this.enterRangeMode(dateItem);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.rangeMode) {
|
||||
// Check if clicking on range start or end date to exit range mode
|
||||
const clickedDate = this.parseDate(dateItem.dataset.date);
|
||||
if ((this.rangeStartDate && this.isSameDay(clickedDate, this.rangeStartDate)) ||
|
||||
(this.rangeEndDate && this.isSameDay(clickedDate, this.rangeEndDate))) {
|
||||
this.exitRangeMode();
|
||||
return;
|
||||
}
|
||||
|
||||
// In range mode, select the end date
|
||||
this.selectRangeEnd(dateItem);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent auto-selection from interfering with manual clicks
|
||||
@@ -142,6 +174,36 @@ class HorizontalDatePicker {
|
||||
}
|
||||
});
|
||||
|
||||
// Add hover listener for range preview
|
||||
this.dateContainer.addEventListener('mouseover', (e) => {
|
||||
const dateItem = e.target.closest('.date-item');
|
||||
if (dateItem) {
|
||||
if (this.rangeMode && this.rangeStartDate) {
|
||||
this.showRangePreview(dateItem);
|
||||
}
|
||||
|
||||
// Update month row for hovered date
|
||||
this.updateMonthRowForHoveredDate(dateItem);
|
||||
}
|
||||
});
|
||||
|
||||
this.dateContainer.addEventListener('mouseout', (e) => {
|
||||
const dateItem = e.target.closest('.date-item');
|
||||
if (dateItem && this.rangeMode && this.rangeStartDate) {
|
||||
this.clearRangePreview();
|
||||
}
|
||||
});
|
||||
|
||||
// Restore original month row when mouse leaves the date container
|
||||
this.dateContainer.addEventListener('mouseleave', () => {
|
||||
this.restoreOriginalMonthRow();
|
||||
});
|
||||
|
||||
// Clear range button
|
||||
this.clearRangeButton.addEventListener('click', () => {
|
||||
this.exitRangeMode();
|
||||
});
|
||||
|
||||
// Navigation buttons (if enabled)
|
||||
if (this.options.showNavButtons) {
|
||||
this.prevButton.addEventListener('click', () => {
|
||||
@@ -227,8 +289,24 @@ class HorizontalDatePicker {
|
||||
|
||||
if (dateItem) {
|
||||
// Check if this date is already selected
|
||||
if (dateItem.classList.contains('selected')) {
|
||||
return; // Do nothing if tapping on already selected date
|
||||
if (dateItem.classList.contains('selected') && !this.rangeMode) {
|
||||
// Clicking on selected date enters range mode
|
||||
this.enterRangeMode(dateItem);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.rangeMode) {
|
||||
// Check if tapping on range start or end date to exit range mode
|
||||
const tappedDate = this.parseDate(dateItem.dataset.date);
|
||||
if ((this.rangeStartDate && this.isSameDay(tappedDate, this.rangeStartDate)) ||
|
||||
(this.rangeEndDate && this.isSameDay(tappedDate, this.rangeEndDate))) {
|
||||
this.exitRangeMode();
|
||||
return;
|
||||
}
|
||||
|
||||
// In range mode, select the end date
|
||||
this.selectRangeEnd(dateItem);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent auto-selection from interfering with manual taps
|
||||
@@ -390,8 +468,26 @@ class HorizontalDatePicker {
|
||||
dateItem.classList.add('unavailable');
|
||||
}
|
||||
|
||||
// Check if this date is selected
|
||||
if (this.isSameDay(date, this.options.selectedDate)) {
|
||||
// Check if this date is in a range
|
||||
if (this.rangeMode && this.rangeStartDate && this.rangeEndDate) {
|
||||
if (this.isDateInRange(date, this.rangeStartDate, this.rangeEndDate)) {
|
||||
dateItem.classList.add('in-range');
|
||||
}
|
||||
if (this.isSameDay(date, this.rangeStartDate)) {
|
||||
dateItem.classList.add('range-start');
|
||||
}
|
||||
if (this.isSameDay(date, this.rangeEndDate)) {
|
||||
dateItem.classList.add('range-end');
|
||||
}
|
||||
} else if (this.rangeMode && this.rangeStartDate && !this.rangeEndDate) {
|
||||
// Only start date is selected
|
||||
if (this.isSameDay(date, this.rangeStartDate)) {
|
||||
dateItem.classList.add('range-start');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this date is selected (for non-range mode)
|
||||
if (!this.rangeMode && this.isSameDay(date, this.options.selectedDate)) {
|
||||
dateItem.classList.add('selected');
|
||||
this.selectedElement = dateItem;
|
||||
}
|
||||
@@ -417,8 +513,8 @@ class HorizontalDatePicker {
|
||||
dateItem.appendChild(monthName);
|
||||
}
|
||||
|
||||
// Add month and year for selected date
|
||||
if (this.isSameDay(date, this.options.selectedDate)) {
|
||||
// Add month and year for selected date (only in non-range mode)
|
||||
if (!this.rangeMode && this.isSameDay(date, this.options.selectedDate)) {
|
||||
const monthYearName = document.createElement('span');
|
||||
monthYearName.className = 'month-year-name';
|
||||
monthYearName.textContent = `${this.getMonthName(date)} ${date.getFullYear()}`;
|
||||
@@ -525,13 +621,353 @@ class HorizontalDatePicker {
|
||||
const event = new CustomEvent('dateSelected', {
|
||||
detail: {
|
||||
date: dateToSelect,
|
||||
formattedDate: dateItem.dataset.date
|
||||
formattedDate: dateItem.dataset.date,
|
||||
isRange: false,
|
||||
rangeStart: null,
|
||||
rangeEnd: null
|
||||
}
|
||||
});
|
||||
this.element.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Enter range mode
|
||||
enterRangeMode(dateItem) {
|
||||
this.rangeMode = true;
|
||||
this.rangeStartDate = this.parseDate(dateItem.dataset.date);
|
||||
this.rangeEndDate = this.parseDate(dateItem.dataset.date);
|
||||
|
||||
// Show clear range button
|
||||
this.clearRangeButton.style.display = 'flex';
|
||||
|
||||
// Update all date items to show range mode
|
||||
this.updateDateItemsForRange();
|
||||
|
||||
// Add visual feedback
|
||||
dateItem.classList.add('range-start');
|
||||
dateItem.classList.remove('selected');
|
||||
|
||||
// Remove month-year-name from the start date
|
||||
const monthYearEl = dateItem.querySelector('.month-year-name');
|
||||
if (monthYearEl) {
|
||||
dateItem.removeChild(monthYearEl);
|
||||
}
|
||||
|
||||
console.log('Entered range mode, start date:', this.rangeStartDate);
|
||||
}
|
||||
|
||||
// Select range end date
|
||||
selectRangeEnd(dateItem) {
|
||||
const clickedDate = this.parseDate(dateItem.dataset.date);
|
||||
|
||||
// Check if date is within min/max range
|
||||
if ((this.options.minDate && clickedDate < new Date(this.options.minDate)) ||
|
||||
(this.options.maxDate && clickedDate > new Date(this.options.maxDate))) {
|
||||
this.flashInvalidSelection(dateItem);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if future dates are allowed
|
||||
if (!this.options.allowFutureDates) {
|
||||
const today = new Date();
|
||||
today.setHours(23, 59, 59, 59);
|
||||
if (clickedDate > today) {
|
||||
this.flashInvalidSelection(dateItem);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine behavior based on where the clicked date is relative to the current range
|
||||
if (clickedDate < this.rangeStartDate) {
|
||||
// Clicking before the start: move the start date
|
||||
this.rangeStartDate = clickedDate;
|
||||
// Keep the end date as is (if it exists)
|
||||
} else if (this.rangeEndDate && this.isDateInRange(clickedDate, this.rangeStartDate, this.rangeEndDate)) {
|
||||
// Clicking inside the range: move the end date
|
||||
this.rangeEndDate = clickedDate;
|
||||
} else if (this.isSameDay(clickedDate, this.rangeStartDate)) {
|
||||
// Clicking on the start date: exit range mode
|
||||
this.exitRangeMode();
|
||||
return;
|
||||
} else {
|
||||
// Clicking after the start (or after the current end): move the end date
|
||||
this.rangeEndDate = clickedDate;
|
||||
}
|
||||
|
||||
// Clear any preview
|
||||
this.clearRangePreview();
|
||||
|
||||
// Update all date items to show the complete range
|
||||
this.updateDateItemsForRange();
|
||||
|
||||
// Call the onDateRangeSelect callback with range information only if both dates are set
|
||||
if (this.rangeStartDate && this.rangeEndDate) {
|
||||
if (typeof this.options.onDateRangeSelect === 'function') {
|
||||
this.options.onDateRangeSelect(
|
||||
this.rangeStartDate,
|
||||
this.rangeEndDate,
|
||||
this.formatDate(this.rangeStartDate),
|
||||
this.formatDate(this.rangeEndDate)
|
||||
);
|
||||
}
|
||||
|
||||
// Dispatch custom event
|
||||
const event = new CustomEvent('dateSelected', {
|
||||
detail: {
|
||||
date: this.rangeStartDate,
|
||||
formattedDate: this.formatDate(this.rangeStartDate),
|
||||
isRange: true,
|
||||
rangeStart: this.rangeStartDate,
|
||||
rangeEnd: this.rangeEndDate,
|
||||
formattedRangeStart: this.formatDate(this.rangeStartDate),
|
||||
formattedRangeEnd: this.formatDate(this.rangeEndDate)
|
||||
}
|
||||
});
|
||||
this.element.dispatchEvent(event);
|
||||
}
|
||||
|
||||
console.log('Range selected:', this.rangeStartDate, 'to', this.rangeEndDate);
|
||||
}
|
||||
|
||||
// Exit range mode
|
||||
exitRangeMode() {
|
||||
this.rangeMode = false;
|
||||
const lastStartDate = this.rangeStartDate;
|
||||
this.rangeStartDate = null;
|
||||
this.rangeEndDate = null;
|
||||
|
||||
// Hide clear range button
|
||||
this.clearRangeButton.style.display = 'none';
|
||||
|
||||
// Clear any preview
|
||||
this.clearRangePreview();
|
||||
|
||||
// Update all date items to remove range styling
|
||||
this.updateDateItemsForRange();
|
||||
|
||||
// Restore the original selected date
|
||||
if (lastStartDate) {
|
||||
this.options.selectedDate = lastStartDate;
|
||||
const dateItems = this.dateContainer.querySelectorAll('.date-item');
|
||||
const formattedDate = this.formatDate(lastStartDate);
|
||||
|
||||
for (const item of dateItems) {
|
||||
if (item.dataset.date === formattedDate) {
|
||||
this.selectDateItem(item, true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Exited range mode');
|
||||
}
|
||||
|
||||
// Show range preview on hover
|
||||
showRangePreview(dateItem) {
|
||||
const hoveredDate = this.parseDate(dateItem.dataset.date);
|
||||
|
||||
// Don't show preview if hovering over the start date or end date
|
||||
if (this.isSameDay(hoveredDate, this.rangeStartDate) ||
|
||||
(this.rangeEndDate && this.isSameDay(hoveredDate, this.rangeEndDate))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any existing preview
|
||||
this.clearRangePreview();
|
||||
|
||||
// Determine the preview range based on where the hovered date is
|
||||
let previewStart, previewEnd;
|
||||
|
||||
if (hoveredDate < this.rangeStartDate) {
|
||||
// Hovering before start: preview shows new start to current end (or current start if no end)
|
||||
previewStart = hoveredDate;
|
||||
previewEnd = this.rangeEndDate || this.rangeStartDate;
|
||||
} else if (this.rangeEndDate && hoveredDate <= this.rangeEndDate) {
|
||||
// Hovering inside the range: preview shows start to hovered date
|
||||
previewStart = this.rangeStartDate;
|
||||
previewEnd = hoveredDate;
|
||||
} else {
|
||||
// Hovering after start (or after end): preview shows start to hovered date
|
||||
previewStart = this.rangeStartDate;
|
||||
previewEnd = hoveredDate;
|
||||
}
|
||||
|
||||
// Apply preview styling to dates in the range
|
||||
const dateItems = this.dateContainer.querySelectorAll('.date-item');
|
||||
dateItems.forEach(item => {
|
||||
const date = this.parseDate(item.dataset.date);
|
||||
|
||||
// Don't apply preview to start/end dates
|
||||
if (this.isDateInRange(date, previewStart, previewEnd) &&
|
||||
!this.isSameDay(date, this.rangeStartDate) &&
|
||||
!(this.rangeEndDate && this.isSameDay(date, this.rangeEndDate))) {
|
||||
item.classList.add('range-preview');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clear range preview
|
||||
clearRangePreview() {
|
||||
const dateItems = this.dateContainer.querySelectorAll('.date-item');
|
||||
dateItems.forEach(item => {
|
||||
item.classList.remove('range-preview');
|
||||
});
|
||||
}
|
||||
|
||||
// Update all date items to reflect range mode
|
||||
updateDateItemsForRange() {
|
||||
const dateItems = this.dateContainer.querySelectorAll('.date-item');
|
||||
|
||||
dateItems.forEach(item => {
|
||||
const date = this.parseDate(item.dataset.date);
|
||||
|
||||
// Remove all range-related classes
|
||||
item.classList.remove('in-range', 'range-start', 'range-end', 'selected', 'range-preview');
|
||||
|
||||
// Remove month-year-name if it exists
|
||||
const monthYearEl = item.querySelector('.month-year-name');
|
||||
if (monthYearEl) {
|
||||
item.removeChild(monthYearEl);
|
||||
}
|
||||
|
||||
// Restore month-name for first day of month
|
||||
if (date.getDate() === 1 && !item.querySelector('.month-name')) {
|
||||
const monthName = document.createElement('span');
|
||||
monthName.className = 'month-name';
|
||||
monthName.textContent = this.getMonthName(date);
|
||||
item.appendChild(monthName);
|
||||
}
|
||||
|
||||
if (this.rangeMode) {
|
||||
if (this.rangeStartDate && this.isSameDay(date, this.rangeStartDate)) {
|
||||
item.classList.add('range-start');
|
||||
}
|
||||
|
||||
if (this.rangeEndDate) {
|
||||
if (this.isSameDay(date, this.rangeEndDate)) {
|
||||
item.classList.add('range-end');
|
||||
}
|
||||
|
||||
if (this.isDateInRange(date, this.rangeStartDate, this.rangeEndDate)) {
|
||||
item.classList.add('in-range');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Restore selected state for non-range mode
|
||||
if (this.isSameDay(date, this.options.selectedDate)) {
|
||||
item.classList.add('selected');
|
||||
this.selectedElement = item;
|
||||
|
||||
// Remove month-name to avoid duplication
|
||||
const monthNameEl = item.querySelector('.month-name');
|
||||
if (monthNameEl) {
|
||||
item.removeChild(monthNameEl);
|
||||
}
|
||||
|
||||
// Add month-year-name
|
||||
if (!item.querySelector('.month-year-name')) {
|
||||
const monthYearName = document.createElement('span');
|
||||
monthYearName.className = 'month-year-name';
|
||||
monthYearName.textContent = `${this.getMonthName(date)} ${date.getFullYear()}`;
|
||||
item.appendChild(monthYearName);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update month row for hovered date
|
||||
updateMonthRowForHoveredDate(dateItem) {
|
||||
if (!this.options.showMonthRow) return;
|
||||
|
||||
const hoveredDate = this.parseDate(dateItem.dataset.date);
|
||||
const hoveredYear = hoveredDate.getFullYear();
|
||||
const hoveredMonth = hoveredDate.getMonth();
|
||||
|
||||
// Store the original selected date if not already stored
|
||||
if (!this.originalSelectedDate) {
|
||||
this.originalSelectedDate = new Date(this.options.selectedDate);
|
||||
}
|
||||
|
||||
// Check if the hovered date is in a different month or year
|
||||
const selectedYear = this.options.selectedDate.getFullYear();
|
||||
const selectedMonth = this.options.selectedDate.getMonth();
|
||||
|
||||
// Update year items
|
||||
const yearItems = this.monthRowContainer.querySelectorAll('.year-item');
|
||||
yearItems.forEach(item => {
|
||||
const itemYear = parseInt(item.dataset.year);
|
||||
item.classList.remove('selected');
|
||||
|
||||
if (itemYear === hoveredYear) {
|
||||
item.classList.add('selected');
|
||||
}
|
||||
});
|
||||
|
||||
// Update month items
|
||||
const monthItems = this.monthRowContainer.querySelectorAll('.month-item');
|
||||
monthItems.forEach(item => {
|
||||
const itemYear = parseInt(item.dataset.year);
|
||||
const itemMonth = parseInt(item.dataset.month);
|
||||
item.classList.remove('selected');
|
||||
|
||||
if (itemYear === hoveredYear && itemMonth === hoveredMonth) {
|
||||
item.classList.add('selected');
|
||||
|
||||
// Scroll to the hovered month
|
||||
item.scrollIntoView({behavior: 'smooth', block: 'nearest', inline: 'center'});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Restore original month row
|
||||
restoreOriginalMonthRow() {
|
||||
if (!this.options.showMonthRow || !this.originalSelectedDate) return;
|
||||
|
||||
const originalYear = this.originalSelectedDate.getFullYear();
|
||||
const originalMonth = this.originalSelectedDate.getMonth();
|
||||
|
||||
// Restore year items
|
||||
const yearItems = this.monthRowContainer.querySelectorAll('.year-item');
|
||||
yearItems.forEach(item => {
|
||||
const itemYear = parseInt(item.dataset.year);
|
||||
item.classList.remove('selected');
|
||||
|
||||
if (itemYear === originalYear) {
|
||||
item.classList.add('selected');
|
||||
}
|
||||
});
|
||||
|
||||
// Restore month items
|
||||
const monthItems = this.monthRowContainer.querySelectorAll('.month-item');
|
||||
monthItems.forEach(item => {
|
||||
const itemYear = parseInt(item.dataset.year);
|
||||
const itemMonth = parseInt(item.dataset.month);
|
||||
item.classList.remove('selected');
|
||||
|
||||
if (itemYear === originalYear && itemMonth === originalMonth) {
|
||||
item.classList.add('selected');
|
||||
|
||||
// Scroll back to the original month
|
||||
item.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
||||
}
|
||||
});
|
||||
|
||||
// Clear the stored original date
|
||||
this.originalSelectedDate = null;
|
||||
}
|
||||
|
||||
// Check if a date is in a range
|
||||
isDateInRange(date, startDate, endDate) {
|
||||
if (!startDate || !endDate) return false;
|
||||
|
||||
const dateTime = date.getTime();
|
||||
const startTime = startDate.getTime();
|
||||
const endTime = endDate.getTime();
|
||||
|
||||
return dateTime >= startTime && dateTime <= endTime;
|
||||
}
|
||||
|
||||
navigateDates(offset) {
|
||||
const firstDateElement = this.dateContainer.firstElementChild;
|
||||
const firstDate = this.parseDate(firstDateElement.dataset.date);
|
||||
@@ -969,7 +1405,10 @@ class HorizontalDatePicker {
|
||||
const event = new CustomEvent('dateSelected', {
|
||||
detail: {
|
||||
date: exactSelectedDate,
|
||||
formattedDate: formattedDate
|
||||
formattedDate: formattedDate,
|
||||
isRange: false,
|
||||
rangeStart: null,
|
||||
rangeEnd: null
|
||||
}
|
||||
});
|
||||
this.element.dispatchEvent(event);
|
||||
@@ -1069,7 +1508,10 @@ class HorizontalDatePicker {
|
||||
const event = new CustomEvent('dateSelected', {
|
||||
detail: {
|
||||
date: exactSelectedDate,
|
||||
formattedDate: formattedDate
|
||||
formattedDate: formattedDate,
|
||||
isRange: false,
|
||||
rangeStart: null,
|
||||
rangeEnd: null
|
||||
}
|
||||
});
|
||||
this.element.dispatchEvent(event);
|
||||
@@ -1193,7 +1635,10 @@ class HorizontalDatePicker {
|
||||
const event = new CustomEvent('dateSelected', {
|
||||
detail: {
|
||||
date: newDate,
|
||||
formattedDate: formattedDate
|
||||
formattedDate: formattedDate,
|
||||
isRange: false,
|
||||
rangeStart: null,
|
||||
rangeEnd: null
|
||||
}
|
||||
});
|
||||
this.element.dispatchEvent(event);
|
||||
@@ -1226,7 +1671,10 @@ class HorizontalDatePicker {
|
||||
const event = new CustomEvent('dateSelected', {
|
||||
detail: {
|
||||
date: today,
|
||||
formattedDate: formattedDate
|
||||
formattedDate: formattedDate,
|
||||
isRange: false,
|
||||
rangeStart: null,
|
||||
rangeEnd: null
|
||||
}
|
||||
});
|
||||
this.element.dispatchEvent(event);
|
||||
|
||||
@@ -2,20 +2,13 @@ class PhotoClient {
|
||||
constructor(map) {
|
||||
this.map = map;
|
||||
this.photoMarkers = [];
|
||||
this.currentDate = null;
|
||||
this.photos = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update photos for the selected date
|
||||
* @param {string} date - Date in YYYY-MM-DD format
|
||||
* @param timezone
|
||||
*/
|
||||
async updatePhotosForDate(date, timezone) {
|
||||
this.currentDate = date;
|
||||
|
||||
async updatePhotosForRange(start, end, timezone) {
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/photos/day/${date}?timezone=${timezone}`);
|
||||
const response = await fetch(`/api/v1/photos/range?timezone=${timezone}&startDate=${start}&endDate=${end}`);
|
||||
if (!response.ok) {
|
||||
console.warn('Could not fetch photos for date:', date);
|
||||
this.photos = [];
|
||||
@@ -30,14 +23,6 @@ class PhotoClient {
|
||||
this.updatePhotoMarkers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all photos (when date is deselected)
|
||||
*/
|
||||
clearPhotos() {
|
||||
this.currentDate = null;
|
||||
this.photos = [];
|
||||
this.clearPhotoMarkers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update photo markers based on current map bounds
|
||||
@@ -442,11 +427,8 @@ class PhotoClient {
|
||||
this.photoMarkers = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle map move/zoom events to update visible photos
|
||||
*/
|
||||
onMapMoveEnd() {
|
||||
if (this.currentDate && this.photos.length > 0) {
|
||||
if (this.photos.length > 0) {
|
||||
this.updatePhotoMarkers();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,9 +56,9 @@
|
||||
</form>
|
||||
</div>
|
||||
<div class="timeline-container"
|
||||
hx-get="/timeline/content"
|
||||
hx-get="/timeline/content/range"
|
||||
hx-trigger="load, dateChanged from:body"
|
||||
hx-vals='js:{"date": getSelectedDate(), "timezone": getUserTimezone()}'
|
||||
hx-vals="js:{startDate: getTimelineParams().startDate, endDate: getTimelineParams().endDate, timezone: getTimelineParams().timezone}"
|
||||
hx-indicator="#loading-indicator">
|
||||
<div id="loading-indicator" class="timeline-entry" th:text="#{timeline.loading}">Loading...</div>
|
||||
</div>
|
||||
@@ -114,6 +114,12 @@
|
||||
let firstEventTime = null;
|
||||
let reconnectTimeoutId = null;
|
||||
|
||||
// Store current date range selection
|
||||
let currentDateRange = null;
|
||||
|
||||
// Store current zoom level
|
||||
let currentZoomLevel = null;
|
||||
|
||||
// Initialize the map
|
||||
const map = L.map('map', {zoomControl: false, attributionControl: false}).setView([window.userSettings.homeLatitude, window.userSettings.homeLongitude], 12);
|
||||
|
||||
@@ -136,6 +142,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getTimelineParams() {
|
||||
const timezone = getUserTimezone();
|
||||
if (currentDateRange && currentDateRange.startDate && currentDateRange.endDate) {
|
||||
// Range mode - use actual range
|
||||
return {
|
||||
startDate: currentDateRange.startDate,
|
||||
endDate: currentDateRange.endDate,
|
||||
timezone: timezone
|
||||
};
|
||||
} else {
|
||||
// Single date mode - use same date for both start and end
|
||||
const selectedDate = getSelectedDate();
|
||||
return {
|
||||
startDate: selectedDate,
|
||||
endDate: selectedDate,
|
||||
timezone: timezone
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function selectUser(userHeader) {
|
||||
// Remove active class from all user headers
|
||||
document.querySelectorAll('.user-header').forEach(header => {
|
||||
@@ -214,10 +240,87 @@
|
||||
map.on('moveend zoomend', () => {
|
||||
photoClient.onMapMoveEnd();
|
||||
});
|
||||
|
||||
// Listen for zoom end events to reload raw location points
|
||||
map.on('zoomend', () => {
|
||||
const newZoomLevel = Math.round(map.getZoom());
|
||||
|
||||
// Only reload if zoom level actually changed
|
||||
if (currentZoomLevel !== null && currentZoomLevel !== newZoomLevel) {
|
||||
console.log('Zoom level changed from', currentZoomLevel, 'to', newZoomLevel, '- reloading raw location points');
|
||||
currentZoomLevel = newZoomLevel;
|
||||
reloadRawLocationPoints();
|
||||
} else if (currentZoomLevel === null) {
|
||||
currentZoomLevel = newZoomLevel;
|
||||
}
|
||||
});
|
||||
|
||||
function loadTimelineData(date) {
|
||||
// Load photos for the selected date
|
||||
photoClient.updatePhotosForDate(date, getUserTimezone());
|
||||
function reloadRawLocationPoints() {
|
||||
|
||||
// Reload raw location points with new zoom level
|
||||
const timelineContainer = document.querySelectorAll('.user-timeline-section');
|
||||
let bounds = L.latLngBounds();
|
||||
const fetchPromises = [];
|
||||
|
||||
for (let i = 0; i < timelineContainer.length; i++) {
|
||||
const element = timelineContainer[i];
|
||||
const rawLocationPointsUrl = element?.dataset.rawLocationPointsUrl;
|
||||
const color = element?.dataset.baseColor;
|
||||
if (rawLocationPointsUrl) {
|
||||
// Get current zoom level
|
||||
const currentZoom = Math.round(map.getZoom());
|
||||
|
||||
// Append zoom parameter to URL
|
||||
const urlWithZoom = rawLocationPointsUrl + (rawLocationPointsUrl.includes('?') ? '&' : '?') + 'zoom=' + currentZoom;
|
||||
|
||||
// Create fetch promise for raw location points with index to maintain order
|
||||
const fetchPromise = fetch(urlWithZoom).then(response => {
|
||||
if (!response.ok) {
|
||||
console.warn('Could not fetch raw location points');
|
||||
return { points: [], index: i, color: color };
|
||||
}
|
||||
return response.json();
|
||||
}).then(rawPointsData => {
|
||||
return { ...rawPointsData, index: i, color: color };
|
||||
}).catch(error => {
|
||||
console.warn('Error fetching raw location points:', error);
|
||||
return { points: [], index: i, color: color };
|
||||
});
|
||||
|
||||
fetchPromises.push(fetchPromise);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all fetch operations to complete, then update map in correct order
|
||||
Promise.all(fetchPromises).then(results => {
|
||||
for (const path of rawPointPaths) {
|
||||
path.remove();
|
||||
}
|
||||
rawPointPaths.length = 0;
|
||||
|
||||
results.sort((a, b) => a.index - b.index);
|
||||
|
||||
// Process results in order
|
||||
results.forEach(result => {
|
||||
const fetchBounds = updateMapWithRawPoints(result, result.color);
|
||||
if (fetchBounds.isValid()) {
|
||||
bounds.extend(fetchBounds);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function loadTimelineData(startDate, endDate) {
|
||||
// Load photos for the selected date or date range
|
||||
if (startDate && endDate && startDate !== endDate) {
|
||||
// Range mode
|
||||
photoClient.updatePhotosForRange(startDate, endDate, getUserTimezone());
|
||||
} else {
|
||||
// Single date mode
|
||||
const date = startDate || getSelectedDate();
|
||||
photoClient.updatePhotosForRange(date, date, getUserTimezone());
|
||||
}
|
||||
|
||||
// Remove pulsating markers when loading new data
|
||||
removePulsatingMarkers();
|
||||
// Get raw location points URL from timeline container
|
||||
@@ -234,8 +337,14 @@
|
||||
const rawLocationPointsUrl = element?.dataset.rawLocationPointsUrl;
|
||||
const color = element?.dataset.baseColor;
|
||||
if (rawLocationPointsUrl) {
|
||||
// Get current zoom level
|
||||
const currentZoom = Math.round(map.getZoom());
|
||||
|
||||
// Append zoom parameter to URL
|
||||
const urlWithZoom = rawLocationPointsUrl + (rawLocationPointsUrl.includes('?') ? '&' : '?') + 'zoom=' + currentZoom;
|
||||
|
||||
// Create fetch promise for raw location points with index to maintain order
|
||||
const fetchPromise = fetch(rawLocationPointsUrl).then(response => {
|
||||
const fetchPromise = fetch(urlWithZoom).then(response => {
|
||||
if (!response.ok) {
|
||||
console.warn('Could not fetch raw location points');
|
||||
return { points: [], index: i, color: color };
|
||||
@@ -245,7 +354,7 @@
|
||||
return { ...rawPointsData, index: i, color: color };
|
||||
}).catch(error => {
|
||||
console.warn('Error fetching raw location points:', error);
|
||||
return { points: [], index: i, color: color }; // Return empty data with index on error
|
||||
return { points: [], index: i, color: color };
|
||||
});
|
||||
|
||||
fetchPromises.push(fetchPromise);
|
||||
@@ -329,7 +438,8 @@
|
||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||
if (event.detail.target.classList.contains('timeline-container')) {
|
||||
// Timeline content has been updated, update map markers
|
||||
loadTimelineData(getSelectedDate())
|
||||
const params = getTimelineParams();
|
||||
loadTimelineData(params.startDate, params.endDate);
|
||||
updateMapFromTimeline();
|
||||
// Initialize scroll indicator after timeline is updated
|
||||
if (window.timelineScrollIndicator) {
|
||||
@@ -514,7 +624,7 @@
|
||||
|
||||
// Load initial timeline data via HTMX (will be triggered by the hx-trigger="load")
|
||||
// Also load photos and raw points for the initial date
|
||||
loadTimelineData(formattedDate);
|
||||
loadTimelineData(formattedDate, formattedDate);
|
||||
|
||||
// Parse the initial date properly to ensure correct date picker initialization
|
||||
let dateToUse = new Date();
|
||||
@@ -529,22 +639,39 @@
|
||||
window.horizontalDatePicker = new HorizontalDatePicker({
|
||||
container: document.getElementById('horizontal-date-picker-container'),
|
||||
selectedDate: dateToUse,
|
||||
showNavButtons: false, // Show navigation buttons
|
||||
daysToShow: 21, // Show more days
|
||||
showMonthRow: true, // Enable month selection row
|
||||
showYearRow: true, // Enable year selection row
|
||||
yearsToShow: 5, // Show 5 years in the year row
|
||||
allowFutureDates: false, // Disable selection of future dates
|
||||
showTodayButton: true, // Show the Today button
|
||||
// No min/max date for infinite scrolling
|
||||
showNavButtons: false,
|
||||
daysToShow: 21,
|
||||
showMonthRow: true,
|
||||
showYearRow: true,
|
||||
yearsToShow: 5,
|
||||
allowFutureDates: false,
|
||||
showTodayButton: true,
|
||||
onDateSelect: (date, formattedDate, isManualSelected) => {
|
||||
// Update URL
|
||||
updateUrlWithDate(formattedDate);
|
||||
// Clear any existing date range
|
||||
currentDateRange = null;
|
||||
// Trigger HTMX reload of timeline
|
||||
document.body.dispatchEvent(new CustomEvent('dateChanged'));
|
||||
if (isManualSelected) {
|
||||
disableAutoUpdate();
|
||||
}
|
||||
},
|
||||
onDateRangeSelect: (startDate, endDate, formattedStartDate, formattedEndDate) => {
|
||||
// Store the date range
|
||||
currentDateRange = {
|
||||
startDate: formattedStartDate,
|
||||
endDate: formattedEndDate
|
||||
};
|
||||
// Update URL to show range
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('startDate', formattedStartDate);
|
||||
url.searchParams.set('endDate', formattedEndDate);
|
||||
url.searchParams.delete('date');
|
||||
window.history.pushState({}, '', url);
|
||||
// Trigger HTMX reload of timeline
|
||||
document.body.dispatchEvent(new CustomEvent('dateChanged'));
|
||||
disableAutoUpdate();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user