5167f2a988
bake_tracks now writes tracks_YYYY.json shards + tracks_index.json manifest
instead of a single monolithic tracks.json. API /api/me/tracks returns the
manifest; /api/me/tracks/{year} serves individual shards. Explore.svelte
fetches the two most recent years eagerly then streams the rest in the
background so the map renders immediately with recent data.
143 lines
4.5 KiB
Python
143 lines
4.5 KiB
Python
"""Pre-bake per-handle GPS tracks for the Explore page.
|
|
|
|
Reads all activity GeoJSON files for a handle, applies RDP simplification,
|
|
and writes per-year tracks_YYYY.json shards plus a tracks_index.json manifest
|
|
for progressive client-side loading.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from bincio.extract.simplify import _rdp_mask
|
|
|
|
_VERSION = 2
|
|
_RDP_EPSILON = 0.0001 # ~10 m on the ground
|
|
|
|
|
|
_SPORT_MAP: dict[str, str] = {
|
|
"cycling": "cycling", "road_cycling": "cycling", "gravel_cycling": "cycling",
|
|
"mountain_biking": "cycling", "e_biking": "cycling", "indoor_cycling": "cycling",
|
|
"biking": "cycling", "bike": "cycling", "ride": "cycling",
|
|
"running": "running", "trail_running": "running", "treadmill_running": "running",
|
|
"jogging": "running",
|
|
"hiking": "hiking", "walking": "hiking", "trekking": "hiking",
|
|
"mountaineering": "hiking",
|
|
"skiing": "skiing", "cross_country_skiing": "skiing", "alpine_skiing": "skiing",
|
|
"snowboarding": "skiing",
|
|
}
|
|
|
|
|
|
def _sport_to_type(sport: str | None) -> str:
|
|
if not sport:
|
|
return "other"
|
|
return _SPORT_MAP.get(sport.lower(), "other")
|
|
|
|
|
|
def bake_tracks(handle: str, data_dir: Path) -> int:
|
|
"""Build tracks.json for handle. Returns number of tracks included."""
|
|
acts_dir = data_dir / handle / "activities"
|
|
if not acts_dir.exists():
|
|
return 0
|
|
|
|
tracks = []
|
|
for gj_path in sorted(acts_dir.glob("*.geojson")):
|
|
act_id = gj_path.stem
|
|
|
|
meta: dict = {}
|
|
meta_path = acts_dir / f"{act_id}.json"
|
|
if meta_path.exists():
|
|
try:
|
|
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
pass
|
|
else:
|
|
# bare-timestamp geojson with no metadata — superseded by a slug version
|
|
if list(acts_dir.glob(f"{act_id}-*.geojson")):
|
|
continue
|
|
|
|
if meta.get("virtual") or meta.get("sub_sport") == "indoor":
|
|
continue
|
|
|
|
try:
|
|
gj = json.loads(gj_path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
continue
|
|
|
|
raw_coords = gj.get("geometry", {}).get("coordinates") or []
|
|
if len(raw_coords) < 2:
|
|
continue
|
|
|
|
lng_lat = [[float(c[0]), float(c[1])] for c in raw_coords if len(c) >= 2]
|
|
if len(lng_lat) < 2:
|
|
continue
|
|
|
|
mask = _rdp_mask(lng_lat, epsilon=_RDP_EPSILON)
|
|
simplified = [pt for pt, keep in zip(lng_lat, mask) if keep]
|
|
if len(simplified) < 2:
|
|
continue
|
|
|
|
tracks.append({
|
|
"id": act_id,
|
|
"date": (meta.get("started_at") or "")[:10],
|
|
"type": _sport_to_type(meta.get("sport")),
|
|
"name": meta.get("title") or act_id,
|
|
"dist": int(meta.get("distance_m") or 0),
|
|
"coords": simplified,
|
|
})
|
|
|
|
tracks.sort(key=lambda t: t["date"], reverse=True)
|
|
|
|
user_dir = data_dir / handle
|
|
now = int(time.time())
|
|
|
|
# Group into per-year buckets
|
|
by_year: dict[str, list] = {}
|
|
for t in tracks:
|
|
year = t["date"][:4] or "0000"
|
|
by_year.setdefault(year, []).append(t)
|
|
|
|
# Remove stale year shards that no longer have data
|
|
for old in user_dir.glob("tracks_*.json"):
|
|
stem = old.stem # e.g. "tracks_2024" or "tracks_index"
|
|
if stem == "tracks_index":
|
|
continue
|
|
year_part = stem[len("tracks_"):]
|
|
if year_part not in by_year:
|
|
old.unlink(missing_ok=True)
|
|
|
|
# Write per-year shards
|
|
for year, year_tracks in by_year.items():
|
|
shard_path = user_dir / f"tracks_{year}.json"
|
|
shard_path.write_text(
|
|
json.dumps({
|
|
"v": _VERSION,
|
|
"handle": handle,
|
|
"year": year,
|
|
"generated_at": now,
|
|
"tracks": year_tracks,
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# Write manifest
|
|
years_sorted = sorted(by_year.keys(), reverse=True)
|
|
index_path = user_dir / "tracks_index.json"
|
|
index_path.write_text(
|
|
json.dumps({
|
|
"v": _VERSION,
|
|
"handle": handle,
|
|
"generated_at": now,
|
|
"total": len(tracks),
|
|
"years": years_sorted,
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# Remove legacy monolithic file if present
|
|
legacy = user_dir / "tracks.json"
|
|
legacy.unlink(missing_ok=True)
|
|
|
|
return len(tracks)
|