Added Heatmaps
This commit is contained in:
parent
c8b37f4720
commit
f391028061
22 changed files with 1696 additions and 9 deletions
|
|
@ -10,6 +10,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
|
|||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
|
|
@ -19,6 +20,7 @@ import org.springframework.web.client.RestTemplate;
|
|||
*/
|
||||
@SpringBootApplication
|
||||
@EnableAsync
|
||||
@EnableScheduling
|
||||
@Slf4j
|
||||
public class FitPubApplication {
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ public class SecurityConfig {
|
|||
.requestMatchers("/discover").permitAll() // User discovery page
|
||||
.requestMatchers("/notifications").permitAll() // Auth checked client-side
|
||||
.requestMatchers("/analytics", "/analytics/**").permitAll() // Auth checked client-side
|
||||
.requestMatchers("/heatmap").permitAll() // Auth checked client-side
|
||||
|
||||
// Public endpoints - ActivityPub federation
|
||||
.requestMatchers("/.well-known/**").permitAll()
|
||||
|
|
@ -105,6 +106,10 @@ public class SecurityConfig {
|
|||
// Protected endpoints - Analytics API
|
||||
.requestMatchers("/api/analytics/**").authenticated()
|
||||
|
||||
// Protected endpoints - Heatmap API
|
||||
.requestMatchers("/api/heatmap/me").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/heatmap/user/*").permitAll()
|
||||
|
||||
// Protected endpoints - Activities API (upload, edit, delete)
|
||||
.requestMatchers(HttpMethod.POST, "/api/activities/upload").authenticated()
|
||||
.requestMatchers(HttpMethod.PUT, "/api/activities/*").authenticated()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
package org.operaton.fitpub.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.operaton.fitpub.model.dto.HeatmapDataDTO;
|
||||
import org.operaton.fitpub.model.entity.User;
|
||||
import org.operaton.fitpub.model.entity.UserHeatmapGrid;
|
||||
import org.operaton.fitpub.repository.UserRepository;
|
||||
import org.operaton.fitpub.service.HeatmapGridService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* REST controller for user activity heatmap data.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/heatmap")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class HeatmapController {
|
||||
|
||||
private final HeatmapGridService heatmapGridService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
/**
|
||||
* Get heatmap data for the authenticated user.
|
||||
* Optionally filtered by bounding box (viewport).
|
||||
*
|
||||
* @param userDetails authenticated user
|
||||
* @param minLon minimum longitude (optional)
|
||||
* @param minLat minimum latitude (optional)
|
||||
* @param maxLon maximum longitude (optional)
|
||||
* @param maxLat maximum latitude (optional)
|
||||
* @return heatmap data in GeoJSON format
|
||||
*/
|
||||
@GetMapping("/me")
|
||||
public ResponseEntity<HeatmapDataDTO> getMyHeatmap(
|
||||
@AuthenticationPrincipal UserDetails userDetails,
|
||||
@RequestParam(required = false) Double minLon,
|
||||
@RequestParam(required = false) Double minLat,
|
||||
@RequestParam(required = false) Double maxLon,
|
||||
@RequestParam(required = false) Double maxLat) {
|
||||
|
||||
log.debug("User {} requesting heatmap data", userDetails.getUsername());
|
||||
|
||||
User user = userRepository.findByUsername(userDetails.getUsername())
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
|
||||
List<UserHeatmapGrid> gridCells = heatmapGridService.getUserHeatmapData(
|
||||
user.getId(), minLon, minLat, maxLon, maxLat);
|
||||
|
||||
Integer maxIntensity = heatmapGridService.getMaxPointCount(user.getId());
|
||||
|
||||
HeatmapDataDTO heatmapData = HeatmapDataDTO.fromGridCells(gridCells, maxIntensity);
|
||||
|
||||
log.debug("Returning {} grid cells for user {}", gridCells.size(), userDetails.getUsername());
|
||||
|
||||
return ResponseEntity.ok(heatmapData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get heatmap data for a specific user by username.
|
||||
* Only returns data for public activities.
|
||||
*
|
||||
* @param username the username
|
||||
* @param minLon minimum longitude (optional)
|
||||
* @param minLat minimum latitude (optional)
|
||||
* @param maxLon maximum longitude (optional)
|
||||
* @param maxLat maximum latitude (optional)
|
||||
* @return heatmap data in GeoJSON format
|
||||
*/
|
||||
@GetMapping("/user/{username}")
|
||||
public ResponseEntity<HeatmapDataDTO> getUserHeatmap(
|
||||
@PathVariable String username,
|
||||
@RequestParam(required = false) Double minLon,
|
||||
@RequestParam(required = false) Double minLat,
|
||||
@RequestParam(required = false) Double maxLon,
|
||||
@RequestParam(required = false) Double maxLat) {
|
||||
|
||||
log.debug("Requesting heatmap data for user {}", username);
|
||||
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
|
||||
|
||||
List<UserHeatmapGrid> gridCells = heatmapGridService.getUserHeatmapData(
|
||||
user.getId(), minLon, minLat, maxLon, maxLat);
|
||||
|
||||
Integer maxIntensity = heatmapGridService.getMaxPointCount(user.getId());
|
||||
|
||||
HeatmapDataDTO heatmapData = HeatmapDataDTO.fromGridCells(gridCells, maxIntensity);
|
||||
|
||||
log.debug("Returning {} grid cells for user {}", gridCells.size(), username);
|
||||
|
||||
return ResponseEntity.ok(heatmapData);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,4 +13,9 @@ public class HomeController {
|
|||
public String home() {
|
||||
return "redirect:/timeline";
|
||||
}
|
||||
|
||||
@GetMapping("/heatmap")
|
||||
public String heatmap() {
|
||||
return "heatmap";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
package org.operaton.fitpub.model.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.operaton.fitpub.model.entity.UserHeatmapGrid;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DTO for heatmap data in GeoJSON-compatible format.
|
||||
* Used by frontend to render heatmap with Leaflet.heat.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class HeatmapDataDTO {
|
||||
|
||||
@Builder.Default
|
||||
private String type = "FeatureCollection";
|
||||
private List<Feature> features;
|
||||
private Integer maxIntensity;
|
||||
|
||||
/**
|
||||
* GeoJSON Feature representing a heatmap grid cell.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Feature {
|
||||
@Builder.Default
|
||||
private String type = "Feature";
|
||||
private Geometry geometry;
|
||||
private Properties properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* GeoJSON Point geometry.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Geometry {
|
||||
@Builder.Default
|
||||
private String type = "Point";
|
||||
private double[] coordinates; // [lon, lat]
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature properties containing intensity.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Properties {
|
||||
private Integer intensity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert list of UserHeatmapGrid entities to HeatmapDataDTO.
|
||||
*
|
||||
* @param gridCells list of grid cells
|
||||
* @param maxIntensity maximum intensity for normalization
|
||||
* @return HeatmapDataDTO
|
||||
*/
|
||||
public static HeatmapDataDTO fromGridCells(List<UserHeatmapGrid> gridCells, Integer maxIntensity) {
|
||||
List<Feature> features = new ArrayList<>();
|
||||
|
||||
for (UserHeatmapGrid cell : gridCells) {
|
||||
double lon = cell.getGridCell().getX();
|
||||
double lat = cell.getGridCell().getY();
|
||||
|
||||
Feature feature = Feature.builder()
|
||||
.type("Feature")
|
||||
.geometry(Geometry.builder()
|
||||
.type("Point")
|
||||
.coordinates(new double[]{lon, lat})
|
||||
.build())
|
||||
.properties(Properties.builder()
|
||||
.intensity(cell.getPointCount())
|
||||
.build())
|
||||
.build();
|
||||
|
||||
features.add(feature);
|
||||
}
|
||||
|
||||
return HeatmapDataDTO.builder()
|
||||
.type("FeatureCollection")
|
||||
.features(features)
|
||||
.maxIntensity(maxIntensity)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
package org.operaton.fitpub.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
import org.locationtech.jts.geom.Point;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* User heatmap grid entity representing aggregated track point density.
|
||||
* Each row represents a grid cell with the count of track points that fall within it.
|
||||
* Used to efficiently render user activity heatmaps without processing all activities.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "user_heatmap_grid",
|
||||
uniqueConstraints = @UniqueConstraint(name = "unique_user_grid_cell", columnNames = {"user_id", "grid_cell"}),
|
||||
indexes = {
|
||||
@Index(name = "idx_user_heatmap_grid_user", columnList = "user_id"),
|
||||
@Index(name = "idx_user_heatmap_grid_updated", columnList = "last_updated")
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class UserHeatmapGrid {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private UUID userId;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id", insertable = false, updatable = false)
|
||||
private User user;
|
||||
|
||||
/**
|
||||
* Center point of the grid cell.
|
||||
* Grid cells are ~100m x 100m (0.001 degrees).
|
||||
*/
|
||||
@Column(name = "grid_cell", nullable = false, columnDefinition = "geometry(Point,4326)")
|
||||
private Point gridCell;
|
||||
|
||||
/**
|
||||
* Number of track points that fall within this grid cell.
|
||||
* Higher counts indicate more frequently visited areas.
|
||||
*/
|
||||
@Column(name = "point_count", nullable = false)
|
||||
@Builder.Default
|
||||
private Integer pointCount = 0;
|
||||
|
||||
@Column(name = "last_updated", nullable = false)
|
||||
@UpdateTimestamp
|
||||
private LocalDateTime lastUpdated;
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
package org.operaton.fitpub.repository;
|
||||
|
||||
import org.locationtech.jts.geom.Point;
|
||||
import org.operaton.fitpub.model.entity.UserHeatmapGrid;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Repository for UserHeatmapGrid entities.
|
||||
*/
|
||||
@Repository
|
||||
public interface UserHeatmapGridRepository extends JpaRepository<UserHeatmapGrid, Long> {
|
||||
|
||||
/**
|
||||
* Find all grid cells for a user.
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @return list of grid cells
|
||||
*/
|
||||
List<UserHeatmapGrid> findByUserId(UUID userId);
|
||||
|
||||
/**
|
||||
* Find grid cells for a user within a bounding box.
|
||||
* Uses PostGIS ST_MakeEnvelope to create a bounding box and ST_Intersects for spatial filtering.
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @param minLon minimum longitude
|
||||
* @param minLat minimum latitude
|
||||
* @param maxLon maximum longitude
|
||||
* @param maxLat maximum latitude
|
||||
* @return list of grid cells within the bounding box
|
||||
*/
|
||||
@Query(value = "SELECT * FROM user_heatmap_grid " +
|
||||
"WHERE user_id = :userId " +
|
||||
"AND ST_Intersects(grid_cell, ST_MakeEnvelope(:minLon, :minLat, :maxLon, :maxLat, 4326))",
|
||||
nativeQuery = true)
|
||||
List<UserHeatmapGrid> findByUserIdWithinBoundingBox(
|
||||
@Param("userId") UUID userId,
|
||||
@Param("minLon") double minLon,
|
||||
@Param("minLat") double minLat,
|
||||
@Param("maxLon") double maxLon,
|
||||
@Param("maxLat") double maxLat
|
||||
);
|
||||
|
||||
/**
|
||||
* Find a grid cell for a user by exact coordinates.
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @param gridCell the grid cell point
|
||||
* @return optional grid cell
|
||||
*/
|
||||
Optional<UserHeatmapGrid> findByUserIdAndGridCell(UUID userId, Point gridCell);
|
||||
|
||||
/**
|
||||
* Delete all grid cells for a user.
|
||||
* Used when recalculating the entire heatmap.
|
||||
*
|
||||
* @param userId the user ID
|
||||
*/
|
||||
@Modifying
|
||||
@Query("DELETE FROM UserHeatmapGrid g WHERE g.userId = :userId")
|
||||
void deleteByUserId(@Param("userId") UUID userId);
|
||||
|
||||
/**
|
||||
* Count grid cells for a user.
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @return count of grid cells
|
||||
*/
|
||||
long countByUserId(UUID userId);
|
||||
|
||||
/**
|
||||
* Find maximum point count for a user.
|
||||
* Used for normalizing intensity values.
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @return maximum point count
|
||||
*/
|
||||
@Query("SELECT MAX(g.pointCount) FROM UserHeatmapGrid g WHERE g.userId = :userId")
|
||||
Integer findMaxPointCountByUserId(@Param("userId") UUID userId);
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package org.operaton.fitpub.scheduler;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.operaton.fitpub.model.entity.User;
|
||||
import org.operaton.fitpub.repository.UserRepository;
|
||||
import org.operaton.fitpub.service.HeatmapGridService;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Scheduled task to recalculate user heatmaps nightly.
|
||||
* Ensures heatmap data stays in sync with activities even if incremental updates fail.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class HeatmapRecalculationScheduler {
|
||||
|
||||
private final HeatmapGridService heatmapGridService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
/**
|
||||
* Recalculate heatmaps for all users.
|
||||
* Runs daily at 3 AM server time.
|
||||
*/
|
||||
@Scheduled(cron = "0 0 3 * * *")
|
||||
public void recalculateAllUserHeatmaps() {
|
||||
log.info("Starting nightly heatmap recalculation for all users");
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
List<User> users = userRepository.findAll();
|
||||
log.info("Found {} users to process", users.size());
|
||||
|
||||
int successCount = 0;
|
||||
int errorCount = 0;
|
||||
|
||||
for (User user : users) {
|
||||
try {
|
||||
heatmapGridService.recalculateUserHeatmap(user);
|
||||
successCount++;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to recalculate heatmap for user {}", user.getUsername(), e);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
log.info("Heatmap recalculation completed in {}ms. Success: {}, Errors: {}",
|
||||
duration, successCount, errorCount);
|
||||
}
|
||||
}
|
||||
|
|
@ -50,6 +50,7 @@ public class FitFileService {
|
|||
private final TrainingLoadService trainingLoadService;
|
||||
private final ActivitySummaryService activitySummaryService;
|
||||
private final WeatherService weatherService;
|
||||
private final HeatmapGridService heatmapGridService;
|
||||
|
||||
/**
|
||||
* Processes an uploaded FIT file and creates an activity.
|
||||
|
|
@ -113,6 +114,9 @@ public class FitFileService {
|
|||
personalRecordService.checkAndUpdatePersonalRecords(savedActivity);
|
||||
achievementService.checkAndAwardAchievements(savedActivity);
|
||||
|
||||
// Update heatmap grid
|
||||
heatmapGridService.updateHeatmapForActivity(savedActivity);
|
||||
|
||||
// Update training load and summaries (async)
|
||||
trainingLoadService.updateTrainingLoad(savedActivity);
|
||||
activitySummaryService.updateSummariesForActivity(savedActivity);
|
||||
|
|
@ -215,6 +219,9 @@ public class FitFileService {
|
|||
trainingLoadService.updateTrainingLoad(savedActivity);
|
||||
activitySummaryService.updateSummariesForActivity(savedActivity);
|
||||
|
||||
// Update heatmap grid
|
||||
heatmapGridService.updateHeatmapForActivity(savedActivity);
|
||||
|
||||
return savedActivity;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,277 @@
|
|||
package org.operaton.fitpub.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.locationtech.jts.geom.Coordinate;
|
||||
import org.locationtech.jts.geom.GeometryFactory;
|
||||
import org.locationtech.jts.geom.Point;
|
||||
import org.locationtech.jts.geom.PrecisionModel;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
import org.operaton.fitpub.model.entity.User;
|
||||
import org.operaton.fitpub.model.entity.UserHeatmapGrid;
|
||||
import org.operaton.fitpub.repository.ActivityRepository;
|
||||
import org.operaton.fitpub.repository.UserHeatmapGridRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Service for managing user activity heatmap grids.
|
||||
* Aggregates GPS track points into spatial grid cells for efficient heatmap rendering.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class HeatmapGridService {
|
||||
|
||||
private final UserHeatmapGridRepository heatmapGridRepository;
|
||||
private final ActivityRepository activityRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* Grid resolution in degrees (~100m at equator).
|
||||
* 0.001 degrees = ~111 meters
|
||||
*/
|
||||
private static final double GRID_SIZE = 0.001;
|
||||
|
||||
/**
|
||||
* SRID for WGS84 coordinate system.
|
||||
*/
|
||||
private static final int SRID = 4326;
|
||||
|
||||
/**
|
||||
* Geometry factory for creating PostGIS points.
|
||||
*/
|
||||
private final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), SRID);
|
||||
|
||||
/**
|
||||
* Sampling rate for large activities.
|
||||
* Process every Nth point to avoid overwhelming the grid.
|
||||
*/
|
||||
private static final int SAMPLING_RATE = 10;
|
||||
|
||||
/**
|
||||
* Update heatmap grid for a single activity.
|
||||
* Called when a new activity is uploaded.
|
||||
*
|
||||
* @param activity the activity to process
|
||||
*/
|
||||
@Transactional
|
||||
public void updateHeatmapForActivity(Activity activity) {
|
||||
log.info("Updating heatmap grid for activity {} (user {})", activity.getId(), activity.getUserId());
|
||||
|
||||
List<Point> gridCells = extractGridCellsFromActivity(activity);
|
||||
if (gridCells.isEmpty()) {
|
||||
log.warn("No grid cells extracted from activity {}", activity.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
// Count frequency of each grid cell
|
||||
Map<String, Integer> cellCounts = new HashMap<>();
|
||||
for (Point cell : gridCells) {
|
||||
String key = cellKey(cell);
|
||||
cellCounts.put(key, cellCounts.getOrDefault(key, 0) + 1);
|
||||
}
|
||||
|
||||
// Upsert grid cells
|
||||
for (Map.Entry<String, Integer> entry : cellCounts.entrySet()) {
|
||||
Point cell = parseCell(entry.getKey());
|
||||
int count = entry.getValue();
|
||||
upsertGridCell(activity.getUserId(), cell, count);
|
||||
}
|
||||
|
||||
log.info("Updated {} unique grid cells for activity {}", cellCounts.size(), activity.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate entire heatmap for a user.
|
||||
* Called by scheduled job or when user requests full recalculation.
|
||||
*
|
||||
* @param user the user to recalculate
|
||||
*/
|
||||
@Transactional
|
||||
public void recalculateUserHeatmap(User user) {
|
||||
log.info("Recalculating heatmap for user {}", user.getUsername());
|
||||
|
||||
// Delete existing grid
|
||||
heatmapGridRepository.deleteByUserId(user.getId());
|
||||
|
||||
// Get all activities for user
|
||||
List<Activity> activities = activityRepository.findByUserIdOrderByStartedAtDesc(user.getId());
|
||||
if (activities.isEmpty()) {
|
||||
log.info("No activities found for user {}", user.getUsername());
|
||||
return;
|
||||
}
|
||||
|
||||
// Aggregate all grid cells across all activities
|
||||
Map<String, Integer> allCellCounts = new HashMap<>();
|
||||
|
||||
for (Activity activity : activities) {
|
||||
List<Point> gridCells = extractGridCellsFromActivity(activity);
|
||||
for (Point cell : gridCells) {
|
||||
String key = cellKey(cell);
|
||||
allCellCounts.put(key, allCellCounts.getOrDefault(key, 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk insert grid cells
|
||||
List<UserHeatmapGrid> gridEntities = new ArrayList<>();
|
||||
for (Map.Entry<String, Integer> entry : allCellCounts.entrySet()) {
|
||||
Point cell = parseCell(entry.getKey());
|
||||
UserHeatmapGrid grid = UserHeatmapGrid.builder()
|
||||
.userId(user.getId())
|
||||
.gridCell(cell)
|
||||
.pointCount(entry.getValue())
|
||||
.build();
|
||||
gridEntities.add(grid);
|
||||
}
|
||||
|
||||
heatmapGridRepository.saveAll(gridEntities);
|
||||
log.info("Recalculated {} grid cells for user {} from {} activities",
|
||||
gridEntities.size(), user.getUsername(), activities.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get heatmap data for a user, optionally filtered by bounding box.
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @param minLon minimum longitude (optional)
|
||||
* @param minLat minimum latitude (optional)
|
||||
* @param maxLon maximum longitude (optional)
|
||||
* @param maxLat maximum latitude (optional)
|
||||
* @return list of grid cells with intensities
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<UserHeatmapGrid> getUserHeatmapData(UUID userId, Double minLon, Double minLat, Double maxLon, Double maxLat) {
|
||||
if (minLon != null && minLat != null && maxLon != null && maxLat != null) {
|
||||
log.debug("Fetching heatmap for user {} with bounding box", userId);
|
||||
return heatmapGridRepository.findByUserIdWithinBoundingBox(userId, minLon, minLat, maxLon, maxLat);
|
||||
} else {
|
||||
log.debug("Fetching full heatmap for user {}", userId);
|
||||
return heatmapGridRepository.findByUserId(userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum point count for a user (for normalization).
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @return maximum point count
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Integer getMaxPointCount(UUID userId) {
|
||||
Integer max = heatmapGridRepository.findMaxPointCountByUserId(userId);
|
||||
return max != null ? max : 1; // Avoid division by zero
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract grid cells from an activity's track points.
|
||||
*
|
||||
* @param activity the activity
|
||||
* @return list of grid cell points
|
||||
*/
|
||||
private List<Point> extractGridCellsFromActivity(Activity activity) {
|
||||
String trackPointsJson = activity.getTrackPointsJson();
|
||||
if (trackPointsJson == null || trackPointsJson.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(trackPointsJson);
|
||||
if (!root.isArray()) {
|
||||
log.warn("Track points JSON is not an array for activity {}", activity.getId());
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<Point> gridCells = new ArrayList<>();
|
||||
int pointIndex = 0;
|
||||
|
||||
for (JsonNode pointNode : root) {
|
||||
// Sample every Nth point for large activities
|
||||
if (pointIndex % SAMPLING_RATE != 0) {
|
||||
pointIndex++;
|
||||
continue;
|
||||
}
|
||||
|
||||
JsonNode latNode = pointNode.get("latitude");
|
||||
JsonNode lonNode = pointNode.get("longitude");
|
||||
|
||||
if (latNode != null && lonNode != null) {
|
||||
double lat = latNode.asDouble();
|
||||
double lon = lonNode.asDouble();
|
||||
Point gridCell = snapToGrid(lat, lon);
|
||||
gridCells.add(gridCell);
|
||||
}
|
||||
|
||||
pointIndex++;
|
||||
}
|
||||
|
||||
return gridCells;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to parse track points for activity {}", activity.getId(), e);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a coordinate to the nearest grid cell center.
|
||||
*
|
||||
* @param lat latitude
|
||||
* @param lon longitude
|
||||
* @return grid cell point
|
||||
*/
|
||||
private Point snapToGrid(double lat, double lon) {
|
||||
double gridLat = Math.floor(lat / GRID_SIZE) * GRID_SIZE + (GRID_SIZE / 2);
|
||||
double gridLon = Math.floor(lon / GRID_SIZE) * GRID_SIZE + (GRID_SIZE / 2);
|
||||
return geometryFactory.createPoint(new Coordinate(gridLon, gridLat));
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a grid cell (insert or increment count).
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @param gridCell the grid cell point
|
||||
* @param increment the count to add
|
||||
*/
|
||||
private void upsertGridCell(UUID userId, Point gridCell, int increment) {
|
||||
Optional<UserHeatmapGrid> existing = heatmapGridRepository.findByUserIdAndGridCell(userId, gridCell);
|
||||
if (existing.isPresent()) {
|
||||
UserHeatmapGrid grid = existing.get();
|
||||
grid.setPointCount(grid.getPointCount() + increment);
|
||||
heatmapGridRepository.save(grid);
|
||||
} else {
|
||||
UserHeatmapGrid grid = UserHeatmapGrid.builder()
|
||||
.userId(userId)
|
||||
.gridCell(gridCell)
|
||||
.pointCount(increment)
|
||||
.build();
|
||||
heatmapGridRepository.save(grid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique key for a grid cell.
|
||||
*
|
||||
* @param cell the grid cell point
|
||||
* @return cell key string
|
||||
*/
|
||||
private String cellKey(Point cell) {
|
||||
return String.format("%.6f,%.6f", cell.getY(), cell.getX());
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a cell from a key string.
|
||||
*
|
||||
* @param key the cell key
|
||||
* @return grid cell point
|
||||
*/
|
||||
private Point parseCell(String key) {
|
||||
String[] parts = key.split(",");
|
||||
double lat = Double.parseDouble(parts[0]);
|
||||
double lon = Double.parseDouble(parts[1]);
|
||||
return geometryFactory.createPoint(new Coordinate(lon, lat));
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,12 @@ public class WebFingerClient {
|
|||
@Value("${fitpub.domain}")
|
||||
private String localDomain;
|
||||
|
||||
@Value("${fitpub.activitypub.allow-private-ips:false}")
|
||||
private boolean allowPrivateIps;
|
||||
|
||||
@Value("${fitpub.activitypub.federation-protocol:https}")
|
||||
private String federationProtocol;
|
||||
|
||||
private static final int TIMEOUT_SECONDS = 5;
|
||||
private static final String WEBFINGER_PATH = "/.well-known/webfinger";
|
||||
private static final String ACTIVITYPUB_CONTENT_TYPE = "application/activity+json";
|
||||
|
|
@ -101,13 +107,13 @@ public class WebFingerClient {
|
|||
}
|
||||
|
||||
// Validate domain format (basic check - allow domains and IP addresses)
|
||||
// Domain: must have at least one dot and end with 2+ letters
|
||||
// IP: must be 4 numbers separated by dots
|
||||
boolean isValidDomain = domain.matches("^[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$");
|
||||
boolean isValidIP = domain.matches("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$");
|
||||
// Domain: must have at least one dot and end with 2+ letters, optional port
|
||||
// IP: must be 4 numbers separated by dots, optional port
|
||||
boolean isValidDomain = domain.matches("^[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}(:[0-9]+)?$");
|
||||
boolean isValidIP = domain.matches("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:[0-9]+)?$");
|
||||
|
||||
if (!isValidDomain && !isValidIP) {
|
||||
throw new IllegalArgumentException("Invalid domain format");
|
||||
throw new IllegalArgumentException("Invalid domain format. Domain must be a valid hostname or IP address, optionally with port");
|
||||
}
|
||||
|
||||
return new ParsedHandle(username, domain);
|
||||
|
|
@ -124,7 +130,7 @@ public class WebFingerClient {
|
|||
private Map<String, Object> fetchWebFingerResource(String domain, String username) throws IOException {
|
||||
// Construct WebFinger URL
|
||||
String resource = "acct:" + username + "@" + domain;
|
||||
String webFingerUrl = "https://" + domain + WEBFINGER_PATH + "?resource=" + resource;
|
||||
String webFingerUrl = federationProtocol + "://" + domain + WEBFINGER_PATH + "?resource=" + resource;
|
||||
|
||||
log.debug("Fetching WebFinger resource: {}", webFingerUrl);
|
||||
|
||||
|
|
@ -173,6 +179,12 @@ public class WebFingerClient {
|
|||
throw new IllegalArgumentException("Cannot discover local users via WebFinger. Use local API instead.");
|
||||
}
|
||||
|
||||
// If private IPs are allowed (local testing mode), skip SSRF protection
|
||||
if (allowPrivateIps) {
|
||||
log.debug("Private IPs allowed - skipping SSRF validation for domain: {}", domain);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
InetAddress address = InetAddress.getByName(domain);
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ fitpub:
|
|||
enabled: true
|
||||
max-federation-retries: 3
|
||||
request-timeout-seconds: 30
|
||||
# Allow connections to private IPs (for local testing only - disable in production!)
|
||||
allow-private-ips: ${FITPUB_ALLOW_PRIVATE_IPS:false}
|
||||
# Federation protocol (http or https) - use http for local testing, https for production
|
||||
federation-protocol: ${FITPUB_FEDERATION_PROTOCOL:https}
|
||||
|
||||
# Security settings
|
||||
security:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
-- Create user_heatmap_grid table for aggregated track density
|
||||
CREATE TABLE user_heatmap_grid (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
grid_cell GEOMETRY(Point, 4326) NOT NULL,
|
||||
point_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT unique_user_grid_cell UNIQUE(user_id, grid_cell)
|
||||
);
|
||||
|
||||
-- Index for user lookups
|
||||
CREATE INDEX idx_user_heatmap_grid_user ON user_heatmap_grid(user_id);
|
||||
|
||||
-- Spatial index for grid cell lookups
|
||||
CREATE INDEX idx_user_heatmap_grid_spatial ON user_heatmap_grid USING GIST(grid_cell);
|
||||
|
||||
-- Index for time-based queries
|
||||
CREATE INDEX idx_user_heatmap_grid_updated ON user_heatmap_grid(last_updated);
|
||||
|
|
@ -217,6 +217,7 @@ const FitPubAuth = {
|
|||
const myActivitiesLink = document.getElementById('myActivitiesLink');
|
||||
const uploadLink = document.getElementById('uploadLink');
|
||||
const analyticsLink = document.getElementById('analyticsLink');
|
||||
const heatmapLink = document.getElementById('heatmapLink');
|
||||
const notificationsBell = document.getElementById('notificationsBell');
|
||||
|
||||
if (this.isAuthenticated()) {
|
||||
|
|
@ -241,6 +242,10 @@ const FitPubAuth = {
|
|||
analyticsLink.style.display = '';
|
||||
analyticsLink.parentElement.style.display = '';
|
||||
}
|
||||
if (heatmapLink) {
|
||||
heatmapLink.style.display = '';
|
||||
heatmapLink.parentElement.style.display = '';
|
||||
}
|
||||
|
||||
// Show notifications bell
|
||||
if (notificationsBell) {
|
||||
|
|
@ -280,6 +285,10 @@ const FitPubAuth = {
|
|||
analyticsLink.style.display = 'none';
|
||||
analyticsLink.parentElement.style.display = 'none';
|
||||
}
|
||||
if (heatmapLink) {
|
||||
heatmapLink.style.display = 'none';
|
||||
heatmapLink.parentElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
177
src/main/resources/static/js/heatmap.js
Normal file
177
src/main/resources/static/js/heatmap.js
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* Heatmap visualization module
|
||||
* Renders user activity heatmap using Leaflet.heat
|
||||
*/
|
||||
|
||||
let heatmapMap = null;
|
||||
let heatLayer = null;
|
||||
|
||||
/**
|
||||
* Initialize the heatmap on page load
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
// Check authentication
|
||||
if (!FitPubAuth.isAuthenticated()) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
await loadHeatmap();
|
||||
});
|
||||
|
||||
/**
|
||||
* Load and render the heatmap
|
||||
*/
|
||||
async function loadHeatmap() {
|
||||
const loadingIndicator = document.getElementById('loadingIndicator');
|
||||
const errorAlert = document.getElementById('errorAlert');
|
||||
const errorMessage = document.getElementById('errorMessage');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const heatmapContainer = document.getElementById('heatmapContainer');
|
||||
const statsCard = document.getElementById('statsCard');
|
||||
const legend = document.getElementById('legend');
|
||||
|
||||
// Show loading
|
||||
loadingIndicator.style.display = 'block';
|
||||
errorAlert.classList.add('d-none');
|
||||
emptyState.classList.add('d-none');
|
||||
heatmapContainer.style.display = 'none';
|
||||
statsCard.style.display = 'none';
|
||||
legend.style.display = 'none';
|
||||
|
||||
try {
|
||||
// Fetch heatmap data
|
||||
const response = await FitPubAuth.authenticatedFetch('/api/heatmap/me');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load heatmap data');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Hide loading
|
||||
loadingIndicator.style.display = 'none';
|
||||
|
||||
// Check if user has any data
|
||||
if (!data.features || data.features.length === 0) {
|
||||
emptyState.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show map and stats
|
||||
heatmapContainer.style.display = 'block';
|
||||
statsCard.style.display = 'block';
|
||||
legend.style.display = 'block';
|
||||
|
||||
// Update stats
|
||||
document.getElementById('cellCount').textContent = data.features.length.toLocaleString();
|
||||
document.getElementById('maxIntensity').textContent = data.maxIntensity.toLocaleString();
|
||||
|
||||
// Initialize map
|
||||
initializeMap();
|
||||
|
||||
// Render heatmap
|
||||
renderHeatmap(data);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading heatmap:', error);
|
||||
loadingIndicator.style.display = 'none';
|
||||
errorAlert.classList.remove('d-none');
|
||||
errorMessage.textContent = 'Failed to load heatmap. Please try again later.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the Leaflet map
|
||||
*/
|
||||
function initializeMap() {
|
||||
if (heatmapMap) {
|
||||
return; // Already initialized
|
||||
}
|
||||
|
||||
// Create map centered on world
|
||||
heatmapMap = L.map('heatmapContainer').setView([20, 0], 2);
|
||||
|
||||
// Add OpenStreetMap tile layer
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
maxZoom: 18
|
||||
}).addTo(heatmapMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render heatmap layer from GeoJSON data
|
||||
*/
|
||||
function renderHeatmap(data) {
|
||||
// Convert GeoJSON features to Leaflet.heat format: [lat, lon, intensity]
|
||||
const heatData = data.features.map(feature => {
|
||||
const lon = feature.geometry.coordinates[0];
|
||||
const lat = feature.geometry.coordinates[1];
|
||||
const intensity = feature.properties.intensity;
|
||||
|
||||
// Normalize intensity to 0-1 range
|
||||
const normalizedIntensity = Math.min(intensity / data.maxIntensity, 1.0);
|
||||
|
||||
return [lat, lon, normalizedIntensity];
|
||||
});
|
||||
|
||||
// Remove existing heat layer if present
|
||||
if (heatLayer) {
|
||||
heatmapMap.removeLayer(heatLayer);
|
||||
}
|
||||
|
||||
// Create heat layer
|
||||
heatLayer = L.heatLayer(heatData, {
|
||||
radius: 25,
|
||||
blur: 15,
|
||||
maxZoom: 17,
|
||||
max: 1.0,
|
||||
gradient: {
|
||||
0.0: 'blue',
|
||||
0.4: 'cyan',
|
||||
0.6: 'lime',
|
||||
0.7: 'yellow',
|
||||
0.9: 'orange',
|
||||
1.0: 'red'
|
||||
}
|
||||
}).addTo(heatmapMap);
|
||||
|
||||
// Fit map bounds to heatmap data
|
||||
fitMapToBounds(data.features);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fit map to show all heatmap data
|
||||
*/
|
||||
function fitMapToBounds(features) {
|
||||
if (features.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate bounds
|
||||
let minLat = Infinity;
|
||||
let maxLat = -Infinity;
|
||||
let minLon = Infinity;
|
||||
let maxLon = -Infinity;
|
||||
|
||||
features.forEach(feature => {
|
||||
const lon = feature.geometry.coordinates[0];
|
||||
const lat = feature.geometry.coordinates[1];
|
||||
|
||||
minLat = Math.min(minLat, lat);
|
||||
maxLat = Math.max(maxLat, lat);
|
||||
minLon = Math.min(minLon, lon);
|
||||
maxLon = Math.max(maxLon, lon);
|
||||
});
|
||||
|
||||
// Add padding
|
||||
const latPadding = (maxLat - minLat) * 0.1;
|
||||
const lonPadding = (maxLon - minLon) * 0.1;
|
||||
|
||||
const bounds = [
|
||||
[minLat - latPadding, minLon - lonPadding],
|
||||
[maxLat + latPadding, maxLon + lonPadding]
|
||||
];
|
||||
|
||||
heatmapMap.fitBounds(bounds);
|
||||
}
|
||||
132
src/main/resources/templates/heatmap.html
Normal file
132
src/main/resources/templates/heatmap.html
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout}">
|
||||
|
||||
<head>
|
||||
<title>My Heatmap - FitPub</title>
|
||||
<style>
|
||||
#heatmapContainer {
|
||||
height: 80vh;
|
||||
min-height: 500px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.heatmap-stats {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-item i {
|
||||
font-size: 1.5rem;
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div layout:fragment="content">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="mb-4">
|
||||
<h2 class="mb-3">
|
||||
<i class="bi bi-map text-primary"></i>
|
||||
My Activity Heatmap
|
||||
</h2>
|
||||
<p class="text-muted">
|
||||
Visualize all your activities on a single map. Hotter colors show areas you visit more frequently.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Card -->
|
||||
<div class="heatmap-stats mb-4" id="statsCard" style="display: none;">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3 mb-md-0">
|
||||
<div class="stat-item">
|
||||
<i class="bi bi-grid-3x3"></i>
|
||||
<div>
|
||||
<small class="text-muted d-block">Grid Cells</small>
|
||||
<strong id="cellCount">0</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3 mb-md-0">
|
||||
<div class="stat-item">
|
||||
<i class="bi bi-fire"></i>
|
||||
<div>
|
||||
<small class="text-muted d-block">Max Intensity</small>
|
||||
<strong id="maxIntensity">0</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="stat-item">
|
||||
<i class="bi bi-clock-history"></i>
|
||||
<div>
|
||||
<small class="text-muted d-block">Last Updated</small>
|
||||
<strong id="lastUpdated">Just now</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Indicator -->
|
||||
<div id="loadingIndicator" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p class="mt-2 text-muted">Loading your heatmap...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<div id="errorAlert" class="alert alert-danger d-none" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||
<span id="errorMessage"></span>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="emptyState" class="empty-state empty-state-activities d-none">
|
||||
<div class="empty-state-icon">
|
||||
<i class="bi bi-map"></i>
|
||||
</div>
|
||||
<h3>No Activities Yet</h3>
|
||||
<p>Upload your first activity to see your heatmap!</p>
|
||||
<a th:href="@{/upload}" class="btn btn-primary">
|
||||
<i class="bi bi-upload"></i> Upload Activity
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Map Container -->
|
||||
<div id="heatmapContainer" style="display: none;"></div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="mt-3 text-center text-muted" id="legend" style="display: none;">
|
||||
<small>
|
||||
<span style="color: blue;">●</span> Low Activity
|
||||
<span class="ms-2" style="color: cyan;">●</span> Moderate
|
||||
<span class="ms-2" style="color: yellow;">●</span> High
|
||||
<span class="ms-2" style="color: red;">●</span> Very High
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<th:block layout:fragment="scripts">
|
||||
<script th:src="@{/js/heatmap.js}"></script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -75,6 +75,11 @@
|
|||
<i class="bi bi-graph-up"></i> Analytics
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" th:href="@{/heatmap}" id="heatmapLink" style="display: none;">
|
||||
<i class="bi bi-map"></i> Heatmap
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Right side navigation -->
|
||||
|
|
@ -218,6 +223,9 @@
|
|||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
<!-- Leaflet.heat Plugin -->
|
||||
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
|
||||
|
||||
<!-- Chart.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@
|
|||
<input type="text"
|
||||
class="form-control"
|
||||
id="remoteUserHandle"
|
||||
placeholder="username@domain.com"
|
||||
pattern="[a-zA-Z0-9_]+@[a-zA-Z0-9.-]+"
|
||||
placeholder="username@domain.com or username@instance.local:8080"
|
||||
pattern="[a-zA-Z0-9_-]+@[a-zA-Z0-9.-]+(:[0-9]+)?"
|
||||
autocomplete="off"
|
||||
required>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue