Peak Detection
This commit is contained in:
parent
6e70e1495e
commit
a1416b232b
20 changed files with 653 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -48,3 +48,4 @@ uploads/
|
|||
logs/
|
||||
/gadm_410.gpkg
|
||||
/.postgresdata/
|
||||
/peaks_worldwide.geojson
|
||||
|
|
|
|||
134
scripts/import_peaks.py
Normal file
134
scripts/import_peaks.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Import peaks from GeoJSON into PostgreSQL/PostGIS.
|
||||
|
||||
Usage:
|
||||
python3 import_peaks.py <geojson_file> [--db-url postgresql://user:pass@host:port/dbname]
|
||||
|
||||
The script parses the GeoJSON, extracts elevation/wikipedia/wikidata from the
|
||||
OSM hstore-style other_tags field, and bulk-inserts into the peaks table using
|
||||
COPY for performance (~1M rows).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
try:
|
||||
import psycopg2
|
||||
except ImportError:
|
||||
print("psycopg2 is required: pip install psycopg2-binary", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def parse_other_tags(other_tags: str) -> dict:
|
||||
"""Parse OSM hstore-style other_tags string into a dict."""
|
||||
if not other_tags:
|
||||
return {}
|
||||
result = {}
|
||||
# Pattern: "key"=>"value"
|
||||
for match in re.finditer(r'"([^"]+)"=>"([^"]*)"', other_tags):
|
||||
result[match.group(1)] = match.group(2)
|
||||
return result
|
||||
|
||||
|
||||
def wikipedia_to_url(wikipedia: str) -> str:
|
||||
"""Convert 'en:Article Name' to full Wikipedia URL."""
|
||||
if not wikipedia:
|
||||
return None
|
||||
parts = wikipedia.split(":", 1)
|
||||
if len(parts) == 2:
|
||||
lang, title = parts
|
||||
return f"https://{lang}.wikipedia.org/wiki/{title.replace(' ', '_')}"
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Import peaks GeoJSON into PostgreSQL")
|
||||
parser.add_argument("geojson_file", help="Path to the GeoJSON file")
|
||||
parser.add_argument("--db-url", default="postgresql://test:test@localhost:5432/testdb",
|
||||
help="PostgreSQL connection URL")
|
||||
parser.add_argument("--batch-size", type=int, default=10000,
|
||||
help="Batch size for COPY operations")
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"Loading GeoJSON from {args.geojson_file}...")
|
||||
with open(args.geojson_file, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
features = data["features"]
|
||||
print(f"Loaded {len(features)} features")
|
||||
|
||||
conn = psycopg2.connect(args.db_url)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Clear existing data
|
||||
cur.execute("TRUNCATE TABLE activity_peaks CASCADE")
|
||||
cur.execute("TRUNCATE TABLE peaks CASCADE")
|
||||
conn.commit()
|
||||
|
||||
inserted = 0
|
||||
skipped = 0
|
||||
batch = []
|
||||
|
||||
for feat in features:
|
||||
props = feat["properties"]
|
||||
geom = feat["geometry"]
|
||||
coords = geom["coordinates"] # [lon, lat]
|
||||
|
||||
name = props.get("name")
|
||||
if not name:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
osm_id = int(props["osm_id"])
|
||||
other = parse_other_tags(props.get("other_tags", ""))
|
||||
|
||||
wikipedia = wikipedia_to_url(other.get("wikipedia"))
|
||||
wikidata = other.get("wikidata")
|
||||
|
||||
lon, lat = coords[0], coords[1]
|
||||
|
||||
batch.append((osm_id, name, wikipedia, wikidata, lon, lat))
|
||||
|
||||
if len(batch) >= args.batch_size:
|
||||
_copy_batch(cur, batch)
|
||||
inserted += len(batch)
|
||||
batch = []
|
||||
print(f" Inserted {inserted}...", end="\r")
|
||||
|
||||
if batch:
|
||||
_copy_batch(cur, batch)
|
||||
inserted += len(batch)
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
print(f"\nDone. Inserted {inserted} peaks, skipped {skipped} (no name).")
|
||||
|
||||
|
||||
def _copy_batch(cur, batch):
|
||||
"""Use COPY for fast bulk insert."""
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf, delimiter="\t")
|
||||
for osm_id, name, wikipedia, wikidata, lon, lat in batch:
|
||||
writer.writerow([
|
||||
osm_id,
|
||||
name,
|
||||
wikipedia if wikipedia else r"\N",
|
||||
wikidata if wikidata else r"\N",
|
||||
f"SRID=4326;POINT({lon} {lat})",
|
||||
])
|
||||
buf.seek(0)
|
||||
cur.copy_expert(
|
||||
"COPY peaks (osm_id, name, wikipedia, wikidata, geom) FROM STDIN WITH (FORMAT csv, DELIMITER E'\\t', NULL '\\N')",
|
||||
buf,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -150,6 +150,7 @@ public class SecurityConfig {
|
|||
.requestMatchers(HttpMethod.GET, "/api/users/*/followers").permitAll() // User followers list
|
||||
.requestMatchers(HttpMethod.GET, "/api/users/*/following").permitAll() // User following list
|
||||
.requestMatchers(HttpMethod.GET, "/api/users/*/follow-status").permitAll() // Follow status check
|
||||
.requestMatchers(HttpMethod.GET, "/api/users/*/peaks").permitAll() // User's visited peaks
|
||||
.requestMatchers(HttpMethod.POST, "/api/users/*/follow").authenticated() // Follow user
|
||||
.requestMatchers(HttpMethod.DELETE, "/api/users/*/follow").authenticated() // Unfollow user
|
||||
|
||||
|
|
|
|||
|
|
@ -55,10 +55,20 @@ public class ActivityController {
|
|||
private final WeatherService weatherService;
|
||||
private final PrivacyZoneService privacyZoneService;
|
||||
private final TrackPrivacyFilter trackPrivacyFilter;
|
||||
private final net.javahippie.fitpub.repository.ActivityPeakRepository activityPeakRepository;
|
||||
|
||||
@Value("${fitpub.base-url}")
|
||||
private String baseUrl;
|
||||
|
||||
private void populatePeaks(net.javahippie.fitpub.model.dto.ActivityDTO dto, UUID activityId) {
|
||||
var activityPeaks = activityPeakRepository.findByActivityId(activityId);
|
||||
if (!activityPeaks.isEmpty()) {
|
||||
dto.setPeaks(activityPeaks.stream()
|
||||
.map(ap -> net.javahippie.fitpub.model.dto.PeakDTO.fromEntity(ap.getPeak()))
|
||||
.toList());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to get user ID from authenticated UserDetails.
|
||||
*
|
||||
|
|
@ -176,6 +186,7 @@ public class ActivityController {
|
|||
if (activity.getVisibility() == Activity.Visibility.PUBLIC) {
|
||||
// Public activities are always accessible, but apply privacy filtering
|
||||
ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(activity, requestingUserId, privacyZones, trackPrivacyFilter);
|
||||
populatePeaks(dto, id);
|
||||
log.debug("Activity {} - DTO privacy zones: {}", id,
|
||||
dto.getPrivacyZones() != null ? dto.getPrivacyZones().size() : 0);
|
||||
return ResponseEntity.ok(dto);
|
||||
|
|
@ -196,6 +207,7 @@ public class ActivityController {
|
|||
|
||||
// Apply privacy filtering (owner sees full track, others see filtered)
|
||||
ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(checkedActivity, requestingUserId, privacyZones, trackPrivacyFilter);
|
||||
populatePeaks(dto, id);
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
|
||||
|
|
@ -490,6 +502,15 @@ public class ActivityController {
|
|||
try {
|
||||
java.io.File imageFile = activityImageService.getActivityImageFile(id);
|
||||
|
||||
// Regenerate if missing (e.g. after container restart or temp dir cleanup)
|
||||
if (!imageFile.exists()) {
|
||||
Activity activity = fitFileService.getActivityById(id);
|
||||
if (activity == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
activityImageService.generateActivityImage(activity);
|
||||
}
|
||||
|
||||
if (!imageFile.exists()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import java.util.*;
|
|||
public class DebugController {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final net.javahippie.fitpub.service.PeakDetectionService peakDetectionService;
|
||||
|
||||
@GetMapping("/validate-keys")
|
||||
public Map<String, Object> validateKeys() {
|
||||
|
|
@ -107,4 +108,13 @@ public class DebugController {
|
|||
signature.update(data);
|
||||
return signature.verify(signatureBytes);
|
||||
}
|
||||
|
||||
@org.springframework.web.bind.annotation.PostMapping("/backfill-peaks")
|
||||
public org.springframework.http.ResponseEntity<Map<String, String>> backfillPeaks() {
|
||||
if (peakDetectionService.isBackfillRunning()) {
|
||||
return org.springframework.http.ResponseEntity.ok(Map.of("status", "already running"));
|
||||
}
|
||||
peakDetectionService.backfillAllActivities();
|
||||
return org.springframework.http.ResponseEntity.ok(Map.of("status", "started"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ public class UserController {
|
|||
private final WebFingerClient webFingerClient;
|
||||
private final FederationService federationService;
|
||||
private final UserService userService;
|
||||
private final net.javahippie.fitpub.repository.ActivityPeakRepository activityPeakRepository;
|
||||
|
||||
@Value("${fitpub.base-url}")
|
||||
private String baseUrl;
|
||||
|
|
@ -616,4 +617,31 @@ public class UserController {
|
|||
|
||||
return ResponseEntity.ok(Map.of("isFollowing", isFollowing));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get peaks visited by a user with visit counts and latest activity.
|
||||
*/
|
||||
@GetMapping("/{username}/peaks")
|
||||
public ResponseEntity<java.util.List<Map<String, Object>>> getUserPeaks(
|
||||
@PathVariable String username
|
||||
) {
|
||||
User user = userRepository.findByUsername(username).orElse(null);
|
||||
if (user == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
var projections = activityPeakRepository.findPeaksVisitedByUser(user.getId());
|
||||
var result = projections.stream()
|
||||
.map(p -> {
|
||||
Map<String, Object> map = new java.util.LinkedHashMap<>();
|
||||
map.put("name", p.getPeakName());
|
||||
map.put("wikipedia", p.getWikipedia());
|
||||
map.put("visitCount", p.getVisitCount());
|
||||
map.put("latestActivityId", p.getLatestActivityId());
|
||||
return map;
|
||||
})
|
||||
.toList();
|
||||
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,9 @@ public class ActivityDTO {
|
|||
private Long commentsCount;
|
||||
private Boolean likedByCurrentUser; // True if current user has liked this activity
|
||||
|
||||
// Peaks visited on this activity
|
||||
private List<PeakDTO> peaks;
|
||||
|
||||
// Privacy zones (only for activity owner, to show what's hidden for others)
|
||||
private List<PrivacyZonePreview> privacyZones;
|
||||
|
||||
|
|
|
|||
30
src/main/java/net/javahippie/fitpub/model/dto/PeakDTO.java
Normal file
30
src/main/java/net/javahippie/fitpub/model/dto/PeakDTO.java
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package net.javahippie.fitpub.model.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import net.javahippie.fitpub.model.entity.Peak;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PeakDTO {
|
||||
|
||||
private Integer id;
|
||||
private String name;
|
||||
private String wikipedia;
|
||||
private Double latitude;
|
||||
private Double longitude;
|
||||
|
||||
public static PeakDTO fromEntity(Peak peak) {
|
||||
return PeakDTO.builder()
|
||||
.id(peak.getId())
|
||||
.name(peak.getName())
|
||||
.wikipedia(peak.getWikipedia())
|
||||
.latitude(peak.getGeom() != null ? peak.getGeom().getY() : null)
|
||||
.longitude(peak.getGeom() != null ? peak.getGeom().getX() : null)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package net.javahippie.fitpub.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "activity_peaks", uniqueConstraints = {
|
||||
@UniqueConstraint(columnNames = {"activity_id", "peak_id"})
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ActivityPeak {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Integer id;
|
||||
|
||||
@Column(name = "activity_id", nullable = false)
|
||||
private UUID activityId;
|
||||
|
||||
@ManyToOne(fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "peak_id", nullable = false)
|
||||
private Peak peak;
|
||||
}
|
||||
34
src/main/java/net/javahippie/fitpub/model/entity/Peak.java
Normal file
34
src/main/java/net/javahippie/fitpub/model/entity/Peak.java
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package net.javahippie.fitpub.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.locationtech.jts.geom.Point;
|
||||
|
||||
@Entity
|
||||
@Table(name = "peaks")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Peak {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Integer id;
|
||||
|
||||
@Column(name = "osm_id", nullable = false, unique = true)
|
||||
private Long osmId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(length = 500)
|
||||
private String wikipedia;
|
||||
|
||||
@Column(length = 50)
|
||||
private String wikidata;
|
||||
|
||||
@Column(columnDefinition = "geometry(Point, 4326)", nullable = false)
|
||||
private Point geom;
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package net.javahippie.fitpub.repository;
|
||||
|
||||
import net.javahippie.fitpub.model.entity.ActivityPeak;
|
||||
import net.javahippie.fitpub.model.entity.Peak;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface ActivityPeakRepository extends JpaRepository<ActivityPeak, Integer> {
|
||||
|
||||
List<ActivityPeak> findByActivityId(UUID activityId);
|
||||
|
||||
boolean existsByActivityIdAndPeakId(UUID activityId, Integer peakId);
|
||||
|
||||
/**
|
||||
* Find all unique peaks visited by a user with visit count and latest activity,
|
||||
* ordered by name.
|
||||
*/
|
||||
@Query(value = """
|
||||
SELECT p.id AS peakId, p.name AS peakName, p.wikipedia AS wikipedia,
|
||||
COUNT(ap.id) AS visitCount,
|
||||
CAST(MAX(a.started_at) AS TIMESTAMP) AS latestVisit,
|
||||
CAST((ARRAY_AGG(a.id ORDER BY a.started_at DESC))[1] AS UUID) AS latestActivityId
|
||||
FROM activity_peaks ap
|
||||
JOIN peaks p ON p.id = ap.peak_id
|
||||
JOIN activities a ON a.id = ap.activity_id
|
||||
WHERE a.user_id = :userId
|
||||
GROUP BY p.id, p.name, p.wikipedia
|
||||
ORDER BY p.name
|
||||
""",
|
||||
nativeQuery = true)
|
||||
List<PeakVisitProjection> findPeaksVisitedByUser(UUID userId);
|
||||
|
||||
interface PeakVisitProjection {
|
||||
Integer getPeakId();
|
||||
String getPeakName();
|
||||
String getWikipedia();
|
||||
Long getVisitCount();
|
||||
UUID getLatestActivityId();
|
||||
}
|
||||
|
||||
void deleteByActivityId(UUID activityId);
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package net.javahippie.fitpub.repository;
|
||||
|
||||
import net.javahippie.fitpub.model.entity.Peak;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface PeakRepository extends JpaRepository<Peak, Integer> {
|
||||
|
||||
/**
|
||||
* Find all peaks within a given distance (meters) of an activity's simplified track.
|
||||
* Uses ST_DWithin on geography type for accurate meter-based distance.
|
||||
*/
|
||||
@Query(value = """
|
||||
SELECT p.* FROM peaks p
|
||||
JOIN activities a ON a.id = :activityId
|
||||
WHERE a.simplified_track IS NOT NULL
|
||||
AND ST_DWithin(CAST(p.geom AS geography), CAST(a.simplified_track AS geography), :distanceMeters)
|
||||
ORDER BY p.name
|
||||
""",
|
||||
nativeQuery = true)
|
||||
List<Peak> findPeaksNearActivity(UUID activityId, double distanceMeters);
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ public class ActivityPostProcessingService {
|
|||
private final PersonalRecordService personalRecordService;
|
||||
private final WeatherService weatherService;
|
||||
private final HeatmapGridService heatmapGridService;
|
||||
private final PeakDetectionService peakDetectionService;
|
||||
private final FederationService federationService;
|
||||
private final ActivityImageService activityImageService;
|
||||
private final ActivityRepository activityRepository;
|
||||
|
|
@ -57,6 +58,7 @@ public class ActivityPostProcessingService {
|
|||
updatePersonalRecordsAsync(activityId);
|
||||
updateHeatmapAsync(activityId);
|
||||
fetchWeatherAsync(activityId);
|
||||
detectPeaksAsync(activityId);
|
||||
|
||||
log.info("Completed async post-processing for activity {}", activityId);
|
||||
}
|
||||
|
|
@ -130,6 +132,28 @@ public class ActivityPostProcessingService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect peaks along the activity's route.
|
||||
*
|
||||
* @param activityId the activity ID to process
|
||||
*/
|
||||
void detectPeaksAsync(UUID activityId) {
|
||||
try {
|
||||
log.debug("Async: Detecting peaks for activity {}", activityId);
|
||||
|
||||
Activity activity = activityRepository.findById(activityId)
|
||||
.orElseThrow(() -> new EntityNotFoundException("Activity not found: " + activityId));
|
||||
|
||||
peakDetectionService.detectPeaksForActivity(activity);
|
||||
|
||||
log.info("Async: Peak detection completed for activity {}", activityId);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Async: Failed to detect peaks for activity {}: {}",
|
||||
activityId, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish activity to the Fediverse (ActivityPub federation).
|
||||
* Generates activity image and sends Create activity to all follower inboxes.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
package net.javahippie.fitpub.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.javahippie.fitpub.model.entity.Activity;
|
||||
import net.javahippie.fitpub.model.entity.ActivityPeak;
|
||||
import net.javahippie.fitpub.model.entity.Peak;
|
||||
import net.javahippie.fitpub.repository.ActivityPeakRepository;
|
||||
import net.javahippie.fitpub.repository.ActivityRepository;
|
||||
import net.javahippie.fitpub.repository.PeakRepository;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class PeakDetectionService {
|
||||
|
||||
private static final double PEAK_PROXIMITY_METERS = 100.0;
|
||||
|
||||
private final PeakRepository peakRepository;
|
||||
private final ActivityPeakRepository activityPeakRepository;
|
||||
private final ActivityRepository activityRepository;
|
||||
|
||||
private final AtomicBoolean backfillRunning = new AtomicBoolean(false);
|
||||
|
||||
/**
|
||||
* Detect peaks along an activity's route and save the associations.
|
||||
*
|
||||
* @param activity the activity to detect peaks for
|
||||
* @return the list of peaks found near the route
|
||||
*/
|
||||
@Transactional
|
||||
public List<Peak> detectPeaksForActivity(Activity activity) {
|
||||
if (Boolean.TRUE.equals(activity.getIndoor())) {
|
||||
log.debug("Activity {} is indoor/virtual, skipping peak detection", activity.getId());
|
||||
return List.of();
|
||||
}
|
||||
|
||||
if (activity.getSimplifiedTrack() == null) {
|
||||
log.debug("Activity {} has no GPS track, skipping peak detection", activity.getId());
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<Peak> nearbyPeaks = peakRepository.findPeaksNearActivity(
|
||||
activity.getId(), PEAK_PROXIMITY_METERS
|
||||
);
|
||||
|
||||
if (nearbyPeaks.isEmpty()) {
|
||||
log.debug("No peaks found near activity {}", activity.getId());
|
||||
return List.of();
|
||||
}
|
||||
|
||||
for (Peak peak : nearbyPeaks) {
|
||||
if (!activityPeakRepository.existsByActivityIdAndPeakId(activity.getId(), peak.getId())) {
|
||||
ActivityPeak activityPeak = ActivityPeak.builder()
|
||||
.activityId(activity.getId())
|
||||
.peak(peak)
|
||||
.build();
|
||||
activityPeakRepository.save(activityPeak);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Detected {} peaks for activity {}", nearbyPeaks.size(), activity.getId());
|
||||
return nearbyPeaks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run peak detection retroactively on all activities.
|
||||
* Safe to call multiple times — skips activities that already have peaks detected.
|
||||
*
|
||||
* @return true if backfill was started, false if already running
|
||||
*/
|
||||
@Async("taskExecutor")
|
||||
public void backfillAllActivities() {
|
||||
if (!backfillRunning.compareAndSet(false, true)) {
|
||||
log.warn("Peak backfill already running, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
log.info("Starting retroactive peak detection for all activities");
|
||||
|
||||
int page = 0;
|
||||
int pageSize = 100;
|
||||
int totalProcessed = 0;
|
||||
int totalPeaksFound = 0;
|
||||
|
||||
Page<Activity> activityPage;
|
||||
do {
|
||||
activityPage = activityRepository.findAll(PageRequest.of(page, pageSize));
|
||||
for (Activity activity : activityPage.getContent()) {
|
||||
try {
|
||||
List<Peak> peaks = detectPeaksForActivity(activity);
|
||||
totalPeaksFound += peaks.size();
|
||||
totalProcessed++;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed peak detection for activity {}: {}", activity.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
page++;
|
||||
log.info("Peak backfill progress: processed {} / {} activities, found {} peaks so far",
|
||||
totalProcessed, activityPage.getTotalElements(), totalPeaksFound);
|
||||
} while (activityPage.hasNext());
|
||||
|
||||
log.info("Peak backfill complete: processed {} activities, found {} total peak associations",
|
||||
totalProcessed, totalPeaksFound);
|
||||
} finally {
|
||||
backfillRunning.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isBackfillRunning() {
|
||||
return backfillRunning.get();
|
||||
}
|
||||
}
|
||||
11
src/main/resources/db/migration/V27__create_peaks_table.sql
Normal file
11
src/main/resources/db/migration/V27__create_peaks_table.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
CREATE TABLE peaks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
osm_id BIGINT NOT NULL UNIQUE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
wikipedia VARCHAR(500),
|
||||
wikidata VARCHAR(50),
|
||||
geom geometry(Point, 4326) NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_peaks_geom ON peaks USING GIST (geom);
|
||||
CREATE INDEX idx_peaks_name ON peaks (name);
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
CREATE TABLE activity_peaks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
activity_id UUID NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
peak_id INTEGER NOT NULL REFERENCES peaks(id) ON DELETE CASCADE,
|
||||
UNIQUE (activity_id, peak_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_activity_peaks_activity ON activity_peaks (activity_id);
|
||||
CREATE INDEX idx_activity_peaks_peak ON activity_peaks (peak_id);
|
||||
|
|
@ -159,6 +159,23 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Peaks Card -->
|
||||
<div class="row mb-4" id="peaksSection" style="display: none;">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-triangle"></i> Peaks
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<ul class="list-group list-group-flush" id="peaksList">
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Weather Card -->
|
||||
<div class="row mb-4" id="weatherSection" style="display: none;">
|
||||
<div class="col-12">
|
||||
|
|
@ -626,6 +643,18 @@
|
|||
loadWeatherData(activity.id);
|
||||
}
|
||||
|
||||
// Render peaks
|
||||
if (activity.peaks && activity.peaks.length > 0) {
|
||||
const peaksList = document.getElementById('peaksList');
|
||||
peaksList.innerHTML = activity.peaks.map(peak => {
|
||||
const content = peak.wikipedia
|
||||
? `<a href="${peak.wikipedia}" target="_blank" rel="noopener">${peak.name} <i class="bi bi-box-arrow-up-right small"></i></a>`
|
||||
: peak.name;
|
||||
return `<li class="list-group-item">${content}</li>`;
|
||||
}).join('');
|
||||
document.getElementById('peaksSection').style.display = 'block';
|
||||
}
|
||||
|
||||
// Render elevation chart if data exists
|
||||
if (activity.trackPoints && activity.trackPoints.length > 0) {
|
||||
// Store track points globally for map marker updates
|
||||
|
|
|
|||
|
|
@ -92,6 +92,20 @@
|
|||
</div>
|
||||
|
||||
<!-- Public Activities -->
|
||||
<!-- Visited Peaks -->
|
||||
<div class="card mb-4" id="peaksSection" style="display: none;">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-triangle"></i> Visited Peaks
|
||||
<span class="badge bg-secondary ms-2" id="peaksCount">0</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<ul class="list-group list-group-flush" id="peaksList">
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
|
|
@ -149,6 +163,7 @@
|
|||
.then(user => {
|
||||
renderProfile(user);
|
||||
loadPublicActivities(user.id);
|
||||
loadPeaks();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading profile:', error);
|
||||
|
|
@ -298,6 +313,35 @@
|
|||
|
||||
let currentPage = 0;
|
||||
|
||||
async function loadPeaks() {
|
||||
try {
|
||||
const response = await fetch(`/api/users/${targetUsername}/peaks`);
|
||||
if (response.ok) {
|
||||
const peaks = await response.json();
|
||||
if (peaks.length > 0) {
|
||||
document.getElementById('peaksCount').textContent = peaks.length;
|
||||
const list = document.getElementById('peaksList');
|
||||
list.innerHTML = peaks.map(peak => {
|
||||
const nameHtml = peak.wikipedia
|
||||
? `<a href="${peak.wikipedia}" target="_blank" rel="noopener">${peak.name} <i class="bi bi-box-arrow-up-right small"></i></a>`
|
||||
: peak.name;
|
||||
const visits = peak.visitCount > 1 ? `${peak.visitCount} visits` : '1 visit';
|
||||
const activityLink = peak.latestActivityId
|
||||
? ` · <a href="/activities/${peak.latestActivityId}">latest activity</a>`
|
||||
: '';
|
||||
return `<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>${nameHtml}</span>
|
||||
<span class="text-muted small">${visits}${activityLink}</span>
|
||||
</li>`;
|
||||
}).join('');
|
||||
document.getElementById('peaksSection').style.display = 'block';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading peaks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPublicActivities(userId) {
|
||||
try {
|
||||
const response = await fetch(`/api/activities/user/${targetUsername}?page=${currentPage}&size=10`);
|
||||
|
|
|
|||
|
|
@ -89,6 +89,20 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visited Peaks -->
|
||||
<div class="card mb-4" id="peaksSection" style="display: none;">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-triangle"></i> Visited Peaks
|
||||
<span class="badge bg-secondary ms-2" id="peaksCount">0</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<ul class="list-group list-group-flush" id="peaksList">
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activities -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
|
|
@ -150,6 +164,7 @@
|
|||
const user = await response.json();
|
||||
renderProfile(user);
|
||||
loadRecentActivities();
|
||||
loadPeaks(user.username);
|
||||
} else {
|
||||
throw new Error('Failed to load profile');
|
||||
}
|
||||
|
|
@ -205,6 +220,35 @@
|
|||
// Stats (activities count will be loaded separately)
|
||||
}
|
||||
|
||||
async function loadPeaks(username) {
|
||||
try {
|
||||
const response = await fetch(`/api/users/${username}/peaks`);
|
||||
if (response.ok) {
|
||||
const peaks = await response.json();
|
||||
if (peaks.length > 0) {
|
||||
document.getElementById('peaksCount').textContent = peaks.length;
|
||||
const list = document.getElementById('peaksList');
|
||||
list.innerHTML = peaks.map(peak => {
|
||||
const nameHtml = peak.wikipedia
|
||||
? `<a href="${peak.wikipedia}" target="_blank" rel="noopener">${peak.name} <i class="bi bi-box-arrow-up-right small"></i></a>`
|
||||
: peak.name;
|
||||
const visits = peak.visitCount > 1 ? `${peak.visitCount} visits` : '1 visit';
|
||||
const activityLink = peak.latestActivityId
|
||||
? ` · <a href="/activities/${peak.latestActivityId}">latest activity</a>`
|
||||
: '';
|
||||
return `<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>${nameHtml}</span>
|
||||
<span class="text-muted small">${visits}${activityLink}</span>
|
||||
</li>`;
|
||||
}).join('');
|
||||
document.getElementById('peaksSection').style.display = 'block';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading peaks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecentActivities() {
|
||||
try {
|
||||
const response = await FitPubAuth.authenticatedFetch('/api/activities?page=0&size=5');
|
||||
|
|
|
|||
|
|
@ -379,6 +379,7 @@ class FitFileServiceTest {
|
|||
"New Title",
|
||||
"New Description",
|
||||
Activity.Visibility.PUBLIC,
|
||||
null,
|
||||
false
|
||||
);
|
||||
|
||||
|
|
@ -423,6 +424,7 @@ class FitFileServiceTest {
|
|||
"New Title",
|
||||
"New Description",
|
||||
Activity.Visibility.PUBLIC,
|
||||
null,
|
||||
false
|
||||
)
|
||||
);
|
||||
|
|
@ -453,6 +455,7 @@ class FitFileServiceTest {
|
|||
"Hacked Title",
|
||||
"Hacked Description",
|
||||
Activity.Visibility.PUBLIC,
|
||||
null,
|
||||
false
|
||||
)
|
||||
);
|
||||
|
|
@ -496,6 +499,7 @@ class FitFileServiceTest {
|
|||
"Updated Title",
|
||||
"Updated Description",
|
||||
Activity.Visibility.PUBLIC,
|
||||
null,
|
||||
false
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue