Added Heatmaps

This commit is contained in:
Tim Zöller 2026-01-01 23:48:05 +01:00
parent c8b37f4720
commit f391028061
22 changed files with 1696 additions and 9 deletions

View file

@ -2,7 +2,187 @@
This guide explains how to test the instance-to-instance federation functionality by running two FitPub instances locally. This guide explains how to test the instance-to-instance federation functionality by running two FitPub instances locally.
## Prerequisites ## Docker Compose Setup (Recommended)
The easiest way to test federation is using Docker Compose, which automatically sets up two complete FitPub instances with separate databases and proper networking.
### Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Docker Network (fitpub-federation) │
│ │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ Instance 1 │ │ Instance 2 │ │
│ │ (instance1.local) │◄─────►│ (instance2.local) │ │
│ │ Port: 8080 │ │ Port: 8081 │ │
│ └──────────────────────┘ └──────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ PostgreSQL 1 │ │ PostgreSQL 2 │ │
│ │ (postgres1) │ │ (postgres2) │ │
│ │ Port: 5432 │ │ Port: 5433 │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
▲ ▲
│ │
localhost:8080 localhost:8081
```
### Quick Start
1. **Start both instances**:
```bash
docker-compose -f docker-compose.federation-test.yml up -d
```
2. **Check status**:
```bash
docker-compose -f docker-compose.federation-test.yml ps
```
3. **Access the instances**:
- Instance 1: http://localhost:8080
- Instance 2: http://localhost:8081
4. **Follow the [Test Scenarios](#test-scenarios) below** to verify federation functionality
5. **View logs** (in separate terminals):
```bash
# Instance 1 logs
docker-compose -f docker-compose.federation-test.yml logs -f fitpub1
# Instance 2 logs
docker-compose -f docker-compose.federation-test.yml logs -f fitpub2
```
6. **Stop and clean up**:
```bash
# Stop containers
docker-compose -f docker-compose.federation-test.yml down
# Stop and remove volumes (complete cleanup)
docker-compose -f docker-compose.federation-test.yml down -v
```
### Service Overview
The Docker Compose setup includes:
- **postgres1**: PostgreSQL 16 with PostGIS 3.4 for Instance 1
- Database: `fitpub1`
- Port: 5432 (internal), 5434 (on host)
- **postgres2**: PostgreSQL 16 with PostGIS 3.4 for Instance 2
- Database: `fitpub2`
- Port: 5432 (internal), 5433 (on host)
- **fitpub1**: FitPub Instance 1
- Domain: `instance1.local:8080`
- Port: 8080
- Network alias: `instance1.local`
- **fitpub2**: FitPub Instance 2
- Domain: `instance2.local:8081`
- Port: 8081
- Network alias: `instance2.local`
### Docker-Specific Commands
**Access database directly**:
```bash
# Instance 1 database
docker exec -it fitpub-postgres1 psql -U fitpub -d fitpub1
# Instance 2 database
docker exec -it fitpub-postgres2 psql -U fitpub -d fitpub2
```
**Inspect network**:
```bash
docker network inspect fitpub-federation
```
**View container details**:
```bash
docker inspect fitpub-instance1
docker inspect fitpub-instance2
```
**Restart a single service**:
```bash
docker-compose -f docker-compose.federation-test.yml restart fitpub1
docker-compose -f docker-compose.federation-test.yml restart fitpub2
```
**Rebuild images** (after code changes):
```bash
docker-compose -f docker-compose.federation-test.yml build
docker-compose -f docker-compose.federation-test.yml up -d
```
### Docker Troubleshooting
**Container won't start**:
```bash
# Check logs for errors
docker-compose -f docker-compose.federation-test.yml logs fitpub1
docker-compose -f docker-compose.federation-test.yml logs fitpub2
# Check health status
docker ps -a | grep fitpub
```
**Database connection issues**:
```bash
# Verify database is healthy
docker-compose -f docker-compose.federation-test.yml ps postgres1
docker-compose -f docker-compose.federation-test.yml ps postgres2
# Check database logs
docker-compose -f docker-compose.federation-test.yml logs postgres1
```
**Network connectivity issues**:
```bash
# Test DNS resolution from inside container
docker exec -it fitpub-instance1 ping instance2.local
docker exec -it fitpub-instance2 ping instance1.local
# Test HTTP connectivity
docker exec -it fitpub-instance1 curl http://instance2.local:8081/.well-known/webfinger
```
**Port already in use**:
```bash
# Find process using port 8080
lsof -ti:8080 | xargs kill -9
# Or use different external ports in docker-compose.yml
```
**Volume permission issues**:
```bash
# Remove all volumes and start fresh
docker-compose -f docker-compose.federation-test.yml down -v
docker-compose -f docker-compose.federation-test.yml up -d
```
**Platform warning on Apple Silicon (M1/M2/M3 Macs)**:
```
The requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64/v8)
```
This is expected and safe to ignore. Docker will use emulation (Rosetta 2) to run the amd64 images. Performance may be slightly slower than native ARM images, but fully functional for testing.
---
## Manual Setup (Alternative)
If you prefer to run the instances directly without Docker, follow these instructions:
### Prerequisites
- Java 17+ - Java 17+
- Maven 3.8+ - Maven 3.8+

View file

@ -0,0 +1,193 @@
# Docker Compose for Federation Testing
# Starts two FitPub instances with separate databases for testing federation between instances
#
# Usage:
# Start: docker-compose -f docker-compose.federation-test.yml up -d
# Logs: docker-compose -f docker-compose.federation-test.yml logs -f
# Stop: docker-compose -f docker-compose.federation-test.yml down
# Cleanup: docker-compose -f docker-compose.federation-test.yml down -v
#
# Access:
# Instance 1: http://localhost:8080
# Instance 2: http://localhost:8081
services:
# PostgreSQL Database for Instance 1
postgres1:
image: postgis/postgis:16-3.4
container_name: fitpub-postgres1
platform: linux/amd64 # Explicit platform for consistency
environment:
POSTGRES_DB: fitpub1
POSTGRES_USER: fitpub
POSTGRES_PASSWORD: fitpub_test_password
ports:
- "5434:5432" # External port 5434 to avoid conflict with existing postgres
volumes:
- postgres1_data:/var/lib/postgresql/data
networks:
- fitpub-federation
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fitpub -d fitpub1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
# PostgreSQL Database for Instance 2
postgres2:
image: postgis/postgis:16-3.4
container_name: fitpub-postgres2
platform: linux/amd64 # Explicit platform for consistency
environment:
POSTGRES_DB: fitpub2
POSTGRES_USER: fitpub
POSTGRES_PASSWORD: fitpub_test_password
ports:
- "5433:5432" # External port 5433 for debugging
volumes:
- postgres2_data:/var/lib/postgresql/data
networks:
- fitpub-federation
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fitpub -d fitpub2"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
# FitPub Instance 1
fitpub1:
build: .
container_name: fitpub-instance1
depends_on:
postgres1:
condition: service_healthy
ports:
- "8080:8080"
environment:
# Spring Profile
SPRING_PROFILES_ACTIVE: dev
# Federation Configuration
FITPUB_DOMAIN: instance1.local:8080
FITPUB_BASE_URL: http://instance1.local:8080
# Database Configuration
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres1:5432/fitpub1
SPRING_DATASOURCE_USERNAME: fitpub
SPRING_DATASOURCE_PASSWORD: fitpub_test_password
# Security
JWT_SECRET: test_jwt_secret_minimum_32_characters_required_instance1
# Feature Flags
REGISTRATION_ENABLED: "true"
ACTIVITYPUB_ENABLED: "true"
WEATHER_ENABLED: "false"
OSM_TILES_ENABLED: "true"
# Federation Testing (ONLY for local testing - DO NOT use in production!)
FITPUB_ALLOW_PRIVATE_IPS: "true"
FITPUB_FEDERATION_PROTOCOL: "http"
# File Upload
FILE_UPLOAD_MAX_SIZE: 50MB
FILE_UPLOAD_DIR: /app/uploads
# Logging
LOGGING_LEVEL_ORG_OPERATON_FITPUB: DEBUG
volumes:
- instance1_uploads:/app/uploads
- instance1_logs:/var/log/fitpub
networks:
fitpub-federation:
aliases:
- instance1.local
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
restart: unless-stopped
# FitPub Instance 2
fitpub2:
build: .
container_name: fitpub-instance2
depends_on:
postgres2:
condition: service_healthy
ports:
- "8081:8080" # External port 8081, internal 8080
environment:
# Spring Profile
SPRING_PROFILES_ACTIVE: dev
# Federation Configuration
FITPUB_DOMAIN: instance2.local:8081
FITPUB_BASE_URL: http://instance2.local:8081
# Database Configuration
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres2:5432/fitpub2
SPRING_DATASOURCE_USERNAME: fitpub
SPRING_DATASOURCE_PASSWORD: fitpub_test_password
# Security
JWT_SECRET: test_jwt_secret_minimum_32_characters_required_instance2
# Feature Flags
REGISTRATION_ENABLED: "true"
ACTIVITYPUB_ENABLED: "true"
WEATHER_ENABLED: "false"
OSM_TILES_ENABLED: "true"
# Federation Testing (ONLY for local testing - DO NOT use in production!)
FITPUB_ALLOW_PRIVATE_IPS: "true"
FITPUB_FEDERATION_PROTOCOL: "http"
# File Upload
FILE_UPLOAD_MAX_SIZE: 50MB
FILE_UPLOAD_DIR: /app/uploads
# Logging
LOGGING_LEVEL_ORG_OPERATON_FITPUB: DEBUG
volumes:
- instance2_uploads:/app/uploads
- instance2_logs:/var/log/fitpub
networks:
fitpub-federation:
aliases:
- instance2.local
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
restart: unless-stopped
# Custom network for federation testing
networks:
fitpub-federation:
driver: bridge
driver_opts:
com.docker.network.bridge.name: fitpub-fed
# Persistent volumes
volumes:
postgres1_data:
name: fitpub_postgres1_data
postgres2_data:
name: fitpub_postgres2_data
instance1_uploads:
name: fitpub_instance1_uploads
instance1_logs:
name: fitpub_instance1_logs
instance2_uploads:
name: fitpub_instance2_uploads
instance2_logs:
name: fitpub_instance2_logs

View file

@ -10,6 +10,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
/** /**
@ -19,6 +20,7 @@ import org.springframework.web.client.RestTemplate;
*/ */
@SpringBootApplication @SpringBootApplication
@EnableAsync @EnableAsync
@EnableScheduling
@Slf4j @Slf4j
public class FitPubApplication { public class FitPubApplication {

View file

@ -61,6 +61,7 @@ public class SecurityConfig {
.requestMatchers("/discover").permitAll() // User discovery page .requestMatchers("/discover").permitAll() // User discovery page
.requestMatchers("/notifications").permitAll() // Auth checked client-side .requestMatchers("/notifications").permitAll() // Auth checked client-side
.requestMatchers("/analytics", "/analytics/**").permitAll() // Auth checked client-side .requestMatchers("/analytics", "/analytics/**").permitAll() // Auth checked client-side
.requestMatchers("/heatmap").permitAll() // Auth checked client-side
// Public endpoints - ActivityPub federation // Public endpoints - ActivityPub federation
.requestMatchers("/.well-known/**").permitAll() .requestMatchers("/.well-known/**").permitAll()
@ -105,6 +106,10 @@ public class SecurityConfig {
// Protected endpoints - Analytics API // Protected endpoints - Analytics API
.requestMatchers("/api/analytics/**").authenticated() .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) // Protected endpoints - Activities API (upload, edit, delete)
.requestMatchers(HttpMethod.POST, "/api/activities/upload").authenticated() .requestMatchers(HttpMethod.POST, "/api/activities/upload").authenticated()
.requestMatchers(HttpMethod.PUT, "/api/activities/*").authenticated() .requestMatchers(HttpMethod.PUT, "/api/activities/*").authenticated()

View file

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

View file

@ -13,4 +13,9 @@ public class HomeController {
public String home() { public String home() {
return "redirect:/timeline"; return "redirect:/timeline";
} }
@GetMapping("/heatmap")
public String heatmap() {
return "heatmap";
}
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -50,6 +50,7 @@ public class FitFileService {
private final TrainingLoadService trainingLoadService; private final TrainingLoadService trainingLoadService;
private final ActivitySummaryService activitySummaryService; private final ActivitySummaryService activitySummaryService;
private final WeatherService weatherService; private final WeatherService weatherService;
private final HeatmapGridService heatmapGridService;
/** /**
* Processes an uploaded FIT file and creates an activity. * Processes an uploaded FIT file and creates an activity.
@ -113,6 +114,9 @@ public class FitFileService {
personalRecordService.checkAndUpdatePersonalRecords(savedActivity); personalRecordService.checkAndUpdatePersonalRecords(savedActivity);
achievementService.checkAndAwardAchievements(savedActivity); achievementService.checkAndAwardAchievements(savedActivity);
// Update heatmap grid
heatmapGridService.updateHeatmapForActivity(savedActivity);
// Update training load and summaries (async) // Update training load and summaries (async)
trainingLoadService.updateTrainingLoad(savedActivity); trainingLoadService.updateTrainingLoad(savedActivity);
activitySummaryService.updateSummariesForActivity(savedActivity); activitySummaryService.updateSummariesForActivity(savedActivity);
@ -215,6 +219,9 @@ public class FitFileService {
trainingLoadService.updateTrainingLoad(savedActivity); trainingLoadService.updateTrainingLoad(savedActivity);
activitySummaryService.updateSummariesForActivity(savedActivity); activitySummaryService.updateSummariesForActivity(savedActivity);
// Update heatmap grid
heatmapGridService.updateHeatmapForActivity(savedActivity);
return savedActivity; return savedActivity;
} }

View file

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

View file

@ -31,6 +31,12 @@ public class WebFingerClient {
@Value("${fitpub.domain}") @Value("${fitpub.domain}")
private String localDomain; 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 int TIMEOUT_SECONDS = 5;
private static final String WEBFINGER_PATH = "/.well-known/webfinger"; private static final String WEBFINGER_PATH = "/.well-known/webfinger";
private static final String ACTIVITYPUB_CONTENT_TYPE = "application/activity+json"; 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) // Validate domain format (basic check - allow domains and IP addresses)
// Domain: must have at least one dot and end with 2+ letters // Domain: must have at least one dot and end with 2+ letters, optional port
// IP: must be 4 numbers separated by dots // IP: must be 4 numbers separated by dots, optional port
boolean isValidDomain = domain.matches("^[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); 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}$"); boolean isValidIP = domain.matches("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:[0-9]+)?$");
if (!isValidDomain && !isValidIP) { 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); return new ParsedHandle(username, domain);
@ -124,7 +130,7 @@ public class WebFingerClient {
private Map<String, Object> fetchWebFingerResource(String domain, String username) throws IOException { private Map<String, Object> fetchWebFingerResource(String domain, String username) throws IOException {
// Construct WebFinger URL // Construct WebFinger URL
String resource = "acct:" + username + "@" + domain; 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); 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."); 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 { try {
InetAddress address = InetAddress.getByName(domain); InetAddress address = InetAddress.getByName(domain);

View file

@ -55,6 +55,10 @@ fitpub:
enabled: true enabled: true
max-federation-retries: 3 max-federation-retries: 3
request-timeout-seconds: 30 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 settings
security: security:

View file

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

View file

@ -217,6 +217,7 @@ const FitPubAuth = {
const myActivitiesLink = document.getElementById('myActivitiesLink'); const myActivitiesLink = document.getElementById('myActivitiesLink');
const uploadLink = document.getElementById('uploadLink'); const uploadLink = document.getElementById('uploadLink');
const analyticsLink = document.getElementById('analyticsLink'); const analyticsLink = document.getElementById('analyticsLink');
const heatmapLink = document.getElementById('heatmapLink');
const notificationsBell = document.getElementById('notificationsBell'); const notificationsBell = document.getElementById('notificationsBell');
if (this.isAuthenticated()) { if (this.isAuthenticated()) {
@ -241,6 +242,10 @@ const FitPubAuth = {
analyticsLink.style.display = ''; analyticsLink.style.display = '';
analyticsLink.parentElement.style.display = ''; analyticsLink.parentElement.style.display = '';
} }
if (heatmapLink) {
heatmapLink.style.display = '';
heatmapLink.parentElement.style.display = '';
}
// Show notifications bell // Show notifications bell
if (notificationsBell) { if (notificationsBell) {
@ -280,6 +285,10 @@ const FitPubAuth = {
analyticsLink.style.display = 'none'; analyticsLink.style.display = 'none';
analyticsLink.parentElement.style.display = 'none'; analyticsLink.parentElement.style.display = 'none';
} }
if (heatmapLink) {
heatmapLink.style.display = 'none';
heatmapLink.parentElement.style.display = 'none';
}
} }
}, },

View 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: '&copy; <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);
}

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

View file

@ -75,6 +75,11 @@
<i class="bi bi-graph-up"></i> Analytics <i class="bi bi-graph-up"></i> Analytics
</a> </a>
</li> </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> </ul>
<!-- Right side navigation --> <!-- Right side navigation -->
@ -218,6 +223,9 @@
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<!-- Leaflet.heat Plugin -->
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
<!-- Chart.js --> <!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>

View file

@ -38,8 +38,8 @@
<input type="text" <input type="text"
class="form-control" class="form-control"
id="remoteUserHandle" id="remoteUserHandle"
placeholder="username@domain.com" placeholder="username@domain.com or username@instance.local:8080"
pattern="[a-zA-Z0-9_]+@[a-zA-Z0-9.-]+" pattern="[a-zA-Z0-9_-]+@[a-zA-Z0-9.-]+(:[0-9]+)?"
autocomplete="off" autocomplete="off"
required> required>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">

View file

@ -70,6 +70,9 @@ class FitFileServiceTest {
@Mock @Mock
private WeatherService weatherService; private WeatherService weatherService;
@Mock
private HeatmapGridService heatmapGridService;
@Spy @Spy
private ObjectMapper objectMapper; private ObjectMapper objectMapper;

View file

@ -0,0 +1,254 @@
package org.operaton.fitpub.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
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.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
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 java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* Unit tests for HeatmapGridService.
*/
@ExtendWith(MockitoExtension.class)
class HeatmapGridServiceTest {
@Mock
private UserHeatmapGridRepository heatmapGridRepository;
@Mock
private ActivityRepository activityRepository;
private HeatmapGridService heatmapGridService;
private ObjectMapper objectMapper;
private GeometryFactory geometryFactory;
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper();
heatmapGridService = new HeatmapGridService(
heatmapGridRepository,
activityRepository,
objectMapper
);
geometryFactory = new GeometryFactory(new PrecisionModel(), 4326);
}
@Test
void testUpdateHeatmapForActivity_WithValidTrackPoints() throws Exception {
// Create test activity with track points JSON
UUID userId = UUID.randomUUID();
Activity activity = Activity.builder()
.id(UUID.randomUUID())
.userId(userId)
.activityType(Activity.ActivityType.RUN)
.title("Test Run")
.visibility(Activity.Visibility.PUBLIC)
.build();
// Create track points JSON (3 points in a small area)
List<Map<String, Object>> trackPoints = new ArrayList<>();
trackPoints.add(createTrackPoint(52.520008, 13.404954)); // Berlin
trackPoints.add(createTrackPoint(52.520108, 13.405054)); // ~15m away
trackPoints.add(createTrackPoint(52.520208, 13.405154)); // ~30m away
String trackPointsJson = objectMapper.writeValueAsString(trackPoints);
activity.setTrackPointsJson(trackPointsJson);
// Mock repository behavior
when(heatmapGridRepository.findByUserIdAndGridCell(any(UUID.class), any(Point.class)))
.thenReturn(Optional.empty());
when(heatmapGridRepository.save(any(UserHeatmapGrid.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// Execute
heatmapGridService.updateHeatmapForActivity(activity);
// Verify that grid cells were saved
ArgumentCaptor<UserHeatmapGrid> gridCaptor = ArgumentCaptor.forClass(UserHeatmapGrid.class);
verify(heatmapGridRepository, atLeastOnce()).save(gridCaptor.capture());
List<UserHeatmapGrid> savedGrids = gridCaptor.getAllValues();
assertFalse(savedGrids.isEmpty(), "Should save at least one grid cell");
// Verify grid cell properties
UserHeatmapGrid firstGrid = savedGrids.get(0);
assertEquals(userId, firstGrid.getUserId());
assertNotNull(firstGrid.getGridCell());
assertTrue(firstGrid.getPointCount() > 0);
}
@Test
void testUpdateHeatmapForActivity_WithEmptyTrackPoints() {
// Create activity with empty track points
Activity activity = Activity.builder()
.id(UUID.randomUUID())
.userId(UUID.randomUUID())
.activityType(Activity.ActivityType.RUN)
.title("Test Run")
.trackPointsJson("[]")
.build();
// Execute
heatmapGridService.updateHeatmapForActivity(activity);
// Verify no grid cells were saved
verify(heatmapGridRepository, never()).save(any(UserHeatmapGrid.class));
}
@Test
void testUpdateHeatmapForActivity_WithNullTrackPoints() {
// Create activity with null track points
Activity activity = Activity.builder()
.id(UUID.randomUUID())
.userId(UUID.randomUUID())
.activityType(Activity.ActivityType.RUN)
.title("Test Run")
.trackPointsJson(null)
.build();
// Execute
heatmapGridService.updateHeatmapForActivity(activity);
// Verify no grid cells were saved
verify(heatmapGridRepository, never()).save(any(UserHeatmapGrid.class));
}
@Test
void testRecalculateUserHeatmap() throws Exception {
// Create test user and activities
UUID userId = UUID.randomUUID();
User user = User.builder()
.id(userId)
.username("testuser")
.build();
// Create activities with track points
List<Activity> activities = new ArrayList<>();
for (int i = 0; i < 3; i++) {
Activity activity = Activity.builder()
.id(UUID.randomUUID())
.userId(userId)
.activityType(Activity.ActivityType.RUN)
.title("Test Run " + i)
.build();
List<Map<String, Object>> trackPoints = new ArrayList<>();
trackPoints.add(createTrackPoint(52.520008 + i * 0.01, 13.404954 + i * 0.01));
activity.setTrackPointsJson(objectMapper.writeValueAsString(trackPoints));
activities.add(activity);
}
// Mock repository behavior
when(activityRepository.findByUserIdOrderByStartedAtDesc(userId))
.thenReturn(activities);
when(heatmapGridRepository.saveAll(anyList()))
.thenAnswer(invocation -> invocation.getArgument(0));
// Execute
heatmapGridService.recalculateUserHeatmap(user);
// Verify
verify(heatmapGridRepository).deleteByUserId(userId);
verify(activityRepository).findByUserIdOrderByStartedAtDesc(userId);
verify(heatmapGridRepository, atLeastOnce()).saveAll(anyList());
}
@Test
void testGetUserHeatmapData_WithBoundingBox() {
UUID userId = UUID.randomUUID();
List<UserHeatmapGrid> expectedGrids = new ArrayList<>();
// Mock repository
when(heatmapGridRepository.findByUserIdWithinBoundingBox(
userId, 13.0, 52.0, 14.0, 53.0))
.thenReturn(expectedGrids);
// Execute
List<UserHeatmapGrid> result = heatmapGridService.getUserHeatmapData(
userId, 13.0, 52.0, 14.0, 53.0);
// Verify
assertEquals(expectedGrids, result);
verify(heatmapGridRepository).findByUserIdWithinBoundingBox(
userId, 13.0, 52.0, 14.0, 53.0);
}
@Test
void testGetUserHeatmapData_WithoutBoundingBox() {
UUID userId = UUID.randomUUID();
List<UserHeatmapGrid> expectedGrids = new ArrayList<>();
// Mock repository
when(heatmapGridRepository.findByUserId(userId))
.thenReturn(expectedGrids);
// Execute
List<UserHeatmapGrid> result = heatmapGridService.getUserHeatmapData(
userId, null, null, null, null);
// Verify
assertEquals(expectedGrids, result);
verify(heatmapGridRepository).findByUserId(userId);
}
@Test
void testGetMaxPointCount() {
UUID userId = UUID.randomUUID();
Integer expectedMax = 150;
// Mock repository
when(heatmapGridRepository.findMaxPointCountByUserId(userId))
.thenReturn(expectedMax);
// Execute
Integer result = heatmapGridService.getMaxPointCount(userId);
// Verify
assertEquals(expectedMax, result);
verify(heatmapGridRepository).findMaxPointCountByUserId(userId);
}
@Test
void testGetMaxPointCount_ReturnsOneWhenNull() {
UUID userId = UUID.randomUUID();
// Mock repository to return null
when(heatmapGridRepository.findMaxPointCountByUserId(userId))
.thenReturn(null);
// Execute
Integer result = heatmapGridService.getMaxPointCount(userId);
// Verify it returns 1 to avoid division by zero
assertEquals(1, result);
}
/**
* Helper method to create a track point map.
*/
private Map<String, Object> createTrackPoint(double latitude, double longitude) {
Map<String, Object> point = new HashMap<>();
point.put("latitude", latitude);
point.put("longitude", longitude);
point.put("elevation", 100.0);
point.put("timestamp", "2025-01-01T12:00:00");
return point;
}
}