map now working
This commit is contained in:
@@ -0,0 +1,161 @@
|
|||||||
|
# BincioActivity — Context for Claude
|
||||||
|
|
||||||
|
## What this project is
|
||||||
|
|
||||||
|
BincioActivity is a federated, open-source, self-hosted activity stats platform
|
||||||
|
(think personal Strava). Two-stage pipeline:
|
||||||
|
|
||||||
|
1. **`bincio extract`** (Python): GPX/FIT/TCX → BAS JSON data store
|
||||||
|
2. **`bincio render`** (Astro/Node): BAS data store → static website
|
||||||
|
|
||||||
|
The BAS (BincioActivity Schema) JSON files are the federation protocol.
|
||||||
|
Anyone can publish their data as BAS JSON and others can include it.
|
||||||
|
|
||||||
|
## Key design decisions
|
||||||
|
|
||||||
|
- **No database, no server** — everything is static files
|
||||||
|
- **Python with uv** for the extract stage
|
||||||
|
- **Astro + Svelte + Tailwind + MapLibre GL + Observable Plot** for the site
|
||||||
|
- **Haversine** (not geopy) for distance calculations (10x faster)
|
||||||
|
- **Worker initializer pattern** for ProcessPoolExecutor — large shared data
|
||||||
|
(strava_lookup dict, known_hashes frozenset) is sent once per worker via
|
||||||
|
`initializer=`, not once per task
|
||||||
|
- **BAS activity IDs** always use UTC with Z suffix for URL safety
|
||||||
|
- **TCX files** from Garmin use both `http://` and `https://` namespace URIs —
|
||||||
|
parser handles both
|
||||||
|
|
||||||
|
## User's data
|
||||||
|
|
||||||
|
- Source: `~/src/cycling_data_davide/`
|
||||||
|
- `activities/` — Strava export (GPX, FIT, TCX, all with .gz variants)
|
||||||
|
- `Karoo_2026/` — recent Karoo device FIT files
|
||||||
|
- `Karoo/` — older Karoo FIT files
|
||||||
|
- `activities.csv` — Strava metadata (names, descriptions, gear)
|
||||||
|
- Extracted output: `~/bincio_data/` (or `/tmp/bincio_test/` for testing)
|
||||||
|
- ~3,200 input files → ~2,082 unique activities after dedup
|
||||||
|
- Date range: 2014–2026
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
bincio/ Python package
|
||||||
|
extract/
|
||||||
|
models.py DataPoint, ParsedActivity, LapData
|
||||||
|
parsers/ GPX, FIT, TCX parsers + factory
|
||||||
|
sport.py sport name normalisation
|
||||||
|
metrics.py haversine-based stats computation (single pass)
|
||||||
|
timeseries.py downsample to 1Hz, build BAS timeseries object
|
||||||
|
simplify.py RDP track simplification → GeoJSON
|
||||||
|
dedup.py exact (hash) + near-duplicate detection
|
||||||
|
strava_csv.py Strava activities.csv importer
|
||||||
|
writer.py BAS JSON + GeoJSON writer
|
||||||
|
config.py extract_config.yaml loader
|
||||||
|
cli.py `bincio extract` CLI
|
||||||
|
render/
|
||||||
|
cli.py `bincio render` CLI (symlinks data, runs astro build/dev)
|
||||||
|
schema/
|
||||||
|
bas-v1.schema.json JSON Schema for BAS
|
||||||
|
SCHEMA.md Human-readable BAS spec
|
||||||
|
site/ Astro project
|
||||||
|
src/
|
||||||
|
layouts/Base.astro
|
||||||
|
pages/
|
||||||
|
index.astro Activity feed (loads index.json client-side)
|
||||||
|
activity/[id].astro Single activity (SSG, loads detail JSON client-side)
|
||||||
|
stats/index.astro Heatmap + year totals
|
||||||
|
components/
|
||||||
|
ActivityFeed.svelte Card grid, sport filter, pagination
|
||||||
|
ActivityDetail.svelte Map + stats + charts wrapper
|
||||||
|
ActivityMap.svelte MapLibre GL (gradient track, linked hover dot)
|
||||||
|
ActivityCharts.svelte Observable Plot (elevation/speed/HR/cadence tabs)
|
||||||
|
StatsView.svelte Yearly heatmap + totals
|
||||||
|
lib/
|
||||||
|
types.ts BAS TypeScript types
|
||||||
|
format.ts formatDistance, formatDuration, sportIcon, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
## How to run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Extract
|
||||||
|
cd ~/src/bincio_activity
|
||||||
|
uv run bincio extract --input ~/src/cycling_data_davide/activities --output /tmp/bincio_test
|
||||||
|
|
||||||
|
# Site dev server
|
||||||
|
cd site
|
||||||
|
ln -sf /tmp/bincio_test public/data # symlink data
|
||||||
|
BINCIO_DATA_DIR=/tmp/bincio_test npm run dev
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## MapLibre GL + Vite/Astro — known gotchas
|
||||||
|
|
||||||
|
Learnt the hard way during debugging (March 2026):
|
||||||
|
|
||||||
|
- **`maplibregl.workerUrl = ...` is the v3 API and silently no-ops in v4+.**
|
||||||
|
The v5 API is `maplibregl.setWorkerUrl(url)`, but you don't need it at all in a
|
||||||
|
normal Vite environment — MapLibre handles the blob worker automatically.
|
||||||
|
|
||||||
|
- **`optimizeDeps: { exclude: ['maplibre-gl'] }` breaks tile loading.**
|
||||||
|
It prevents Vite from converting MapLibre's UMD bundle to ESM. The UMD bundle
|
||||||
|
uses AMD `define()` internally; served raw, the tile worker blob fails silently →
|
||||||
|
black map, no tiles. The correct setting is `include: ['maplibre-gl']`.
|
||||||
|
|
||||||
|
- **`build.target: 'es2022'` (and `optimizeDeps.esbuildOptions.target`) is required.**
|
||||||
|
MapLibre's dependencies use ES2022 class field syntax. If esbuild downgrades it,
|
||||||
|
helpers like `__publicField` aren't available inside the serialised worker blob
|
||||||
|
scope → tile loading fails. This is a known upstream issue (maplibre-gl-js #6680).
|
||||||
|
|
||||||
|
- **Use static imports, not dynamic `await import('maplibre-gl')`, when possible.**
|
||||||
|
With `client:only="svelte"` in Astro, SSR never runs for the component so there is
|
||||||
|
no `window is not defined` risk. Static import lets Vite pre-bundle correctly.
|
||||||
|
|
||||||
|
- **Use `client:only="svelte"` (not `client:load`) for the activity detail page.**
|
||||||
|
`client:load` does SSR + hydration; complex interactive components with MapLibre
|
||||||
|
can hit hydration mismatch issues. `client:only` mounts fresh on the client only.
|
||||||
|
|
||||||
|
- **MapLibre v5 requires explicit `center` and `zoom` in the Map constructor.**
|
||||||
|
v4 silently defaulted to `center: [0,0], zoom: 0`. v5 leaves internal projection
|
||||||
|
state undefined → `Cannot read properties of undefined (reading 'lng')` crashes
|
||||||
|
on any operation that touches coordinates (markers, resize, render). Always pass
|
||||||
|
`center` and `zoom` even if you plan to `fitBounds` later.
|
||||||
|
|
||||||
|
- **MapLibre v5 requires `setLngLat()` on markers before `.addTo(map)`.**
|
||||||
|
v4 tolerated markers without coordinates. v5 calls `Marker._update()` inside
|
||||||
|
`addTo()`, which needs valid lngLat → same `'lng'` crash. Set a dummy `[0, 0]`
|
||||||
|
if the real position arrives later (e.g. hover markers).
|
||||||
|
|
||||||
|
The working `astro.config.mjs` Vite section:
|
||||||
|
```js
|
||||||
|
vite: {
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['maplibre-gl'],
|
||||||
|
esbuildOptions: { target: 'es2022' },
|
||||||
|
},
|
||||||
|
build: { target: 'es2022' },
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known issues / next steps
|
||||||
|
|
||||||
|
- `bincio render` Python CLI is a stub — site is built via `npm run build` directly
|
||||||
|
- Activity IDs in existing test data still use `+0000` format (pre-fix); re-run extract to get `Z` format
|
||||||
|
- Some activities appear with both untitled and titled IDs (near-dedup timing race)
|
||||||
|
- Stats page heatmap month labels use absolute positioning and may misalign
|
||||||
|
- Federation (remote data sources) not yet implemented in site
|
||||||
|
- Friends pages (`/friends/{handle}/`) not yet implemented
|
||||||
|
- `bincio render` should automate: symlink data → `astro build`
|
||||||
|
- The `site/.env` file is gitignored — document the setup for new users
|
||||||
|
- Add `--workers` benchmark: on 8 cores, ~7 min for 3,200 activities first run
|
||||||
|
|
||||||
|
## What "good" looks like (not yet done)
|
||||||
|
|
||||||
|
- [ ] `bincio render` Python CLI wraps `astro build` properly
|
||||||
|
- [ ] Friends/federation pages in site
|
||||||
|
- [ ] Personal records page
|
||||||
|
- [ ] Activity search / full-text filter in feed
|
||||||
|
- [ ] Map thumbnail in activity cards (SVG path from GeoJSON)
|
||||||
|
- [ ] GitHub Actions template for auto-publish
|
||||||
|
- [ ] Karoo/Garmin Connect importers beyond Strava
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# BincioActivity
|
||||||
|
|
||||||
|
A federated, open-source, self-hosted activity stats platform.
|
||||||
|
Own your data. Share what you want. Follow friends by URL.
|
||||||
|
|
||||||
|
## What it is
|
||||||
|
|
||||||
|
BincioActivity turns a folder of GPX/FIT/TCX files into a beautiful, modern
|
||||||
|
static website — no database, no server required. It can run from the local
|
||||||
|
filesystem, GitHub Pages, or any static host.
|
||||||
|
|
||||||
|
**Federation**: anyone can "follow" a friend's data by adding a URL to their
|
||||||
|
config. Friends' activities appear in your site, attributed to them.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install
|
||||||
|
pip install bincio # or: uv add bincio
|
||||||
|
|
||||||
|
# Extract your activities
|
||||||
|
cp extract_config.example.yaml extract_config.yaml
|
||||||
|
# edit extract_config.yaml with your paths
|
||||||
|
bincio extract
|
||||||
|
|
||||||
|
# Build the site (requires Node ≥ 20)
|
||||||
|
cd site && npm install
|
||||||
|
BINCIO_DATA_DIR=~/bincio_data npm run build
|
||||||
|
# open site/dist/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Two stages
|
||||||
|
|
||||||
|
### Stage 1 — Extract (`bincio extract`)
|
||||||
|
|
||||||
|
Reads GPX, FIT, TCX files (including `.gz` compressed) and writes a
|
||||||
|
BincioActivity Schema (BAS) data store: plain JSON + GeoJSON files.
|
||||||
|
|
||||||
|
```
|
||||||
|
bincio extract # uses extract_config.yaml
|
||||||
|
bincio extract --input ~/rides --output ~/bincio_data
|
||||||
|
bincio extract --file ride.gpx # single file → stdout
|
||||||
|
bincio extract --since 2025-01-01 # incremental
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported sources:
|
||||||
|
- GPX (generic, Garmin extensions)
|
||||||
|
- FIT (Garmin, Hammerhead Karoo)
|
||||||
|
- TCX (including Garmin's https:// namespace variant)
|
||||||
|
- All of the above gzip-compressed (`.gz`)
|
||||||
|
- Strava bulk export (`activities.csv` carries titles and descriptions)
|
||||||
|
|
||||||
|
### Stage 2 — Render (`bincio render`)
|
||||||
|
|
||||||
|
Generates a static site from the BAS data store using Astro.
|
||||||
|
|
||||||
|
```
|
||||||
|
cd site
|
||||||
|
BINCIO_DATA_DIR=~/bincio_data npm run dev # development
|
||||||
|
BINCIO_DATA_DIR=~/bincio_data npm run build # production build → site/dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### extract_config.yaml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
owner:
|
||||||
|
handle: yourname
|
||||||
|
display_name: Your Name
|
||||||
|
|
||||||
|
input:
|
||||||
|
dirs:
|
||||||
|
- ~/Activities/gpx
|
||||||
|
- ~/Activities/fit
|
||||||
|
metadata_csv: ~/strava_export/activities.csv # optional Strava metadata
|
||||||
|
|
||||||
|
output:
|
||||||
|
dir: ~/bincio_data
|
||||||
|
|
||||||
|
default_privacy: public # public | blur_start | no_gps | private
|
||||||
|
|
||||||
|
track:
|
||||||
|
rdp_epsilon: 0.0001 # GPS simplification (~11m at equator)
|
||||||
|
|
||||||
|
incremental: true # skip already-processed files
|
||||||
|
```
|
||||||
|
|
||||||
|
### site/.env
|
||||||
|
|
||||||
|
```
|
||||||
|
BINCIO_DATA_DIR=/path/to/bincio_data
|
||||||
|
```
|
||||||
|
|
||||||
|
## The BincioActivity Schema (BAS)
|
||||||
|
|
||||||
|
The data store is a directory of plain JSON files:
|
||||||
|
|
||||||
|
```
|
||||||
|
bincio_data/
|
||||||
|
index.json ← activity feed + owner manifest
|
||||||
|
activities/
|
||||||
|
{id}.json ← full activity with timeseries
|
||||||
|
{id}.geojson ← simplified GPS track
|
||||||
|
```
|
||||||
|
|
||||||
|
See `SCHEMA.md` for the full specification. The schema is versioned and
|
||||||
|
published as a standalone document so anyone can write importers in any
|
||||||
|
language.
|
||||||
|
|
||||||
|
## Federation
|
||||||
|
|
||||||
|
Add a friend's `index.json` URL to your `site_config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
data_sources:
|
||||||
|
- type: local
|
||||||
|
path: ~/bincio_data
|
||||||
|
- type: remote
|
||||||
|
handle: alice
|
||||||
|
url: https://alice.github.io/bincio/index.json
|
||||||
|
```
|
||||||
|
|
||||||
|
At build time the renderer fetches their public data and renders it under
|
||||||
|
`/friends/alice/`.
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
Privacy is enforced at the data layer — activities never leave your control:
|
||||||
|
|
||||||
|
| Level | GPS track | Stats visible |
|
||||||
|
|---|---|---|
|
||||||
|
| `public` | Full track | Yes |
|
||||||
|
| `blur_start` | First/last 200 m removed | Yes |
|
||||||
|
| `no_gps` | Not published | Yes |
|
||||||
|
| `private` | Not published | Not in index |
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|---|---|
|
||||||
|
| Extract | Python 3.12, click, fitdecode, gpxpy, lxml, rdp |
|
||||||
|
| Site framework | Astro (static generation) |
|
||||||
|
| UI components | Svelte 5 |
|
||||||
|
| Styling | Tailwind CSS |
|
||||||
|
| Charts | Observable Plot |
|
||||||
|
| Maps | MapLibre GL + OpenFreeMap tiles |
|
||||||
|
| Package manager (Python) | uv |
|
||||||
|
| Package manager (Node) | npm |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Python
|
||||||
|
uv sync
|
||||||
|
uv run pytest
|
||||||
|
uv run bincio --help
|
||||||
|
|
||||||
|
# Site
|
||||||
|
cd site && npm install
|
||||||
|
BINCIO_DATA_DIR=/tmp/bincio_test npm run dev
|
||||||
|
```
|
||||||
|
|||||||
@@ -25,6 +25,33 @@ def simplify_track(
|
|||||||
return [p for (p, _, _), keep in zip(gps_pts, mask) if keep]
|
return [p for (p, _, _), keep in zip(gps_pts, mask) if keep]
|
||||||
|
|
||||||
|
|
||||||
|
def preview_coords(
|
||||||
|
points: list[DataPoint],
|
||||||
|
max_points: int = 20,
|
||||||
|
) -> list[list[float]] | None:
|
||||||
|
"""Return a small list of [lat, lon] pairs for card thumbnail rendering.
|
||||||
|
|
||||||
|
Uses a coarser RDP pass, then subsamples to at most max_points.
|
||||||
|
Returns None if there is no GPS data.
|
||||||
|
"""
|
||||||
|
gps = [(p.lat, p.lon) for p in points if p.lat is not None and p.lon is not None]
|
||||||
|
if len(gps) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Coarse RDP (larger epsilon = fewer points)
|
||||||
|
coords = [[lon, lat] for lat, lon in gps]
|
||||||
|
mask = rdp(coords, epsilon=0.001, return_mask=True)
|
||||||
|
reduced = [gps[i] for i, keep in enumerate(mask) if keep]
|
||||||
|
|
||||||
|
# Subsample if still too many
|
||||||
|
if len(reduced) > max_points:
|
||||||
|
step = len(reduced) / max_points
|
||||||
|
reduced = [reduced[int(i * step)] for i in range(max_points)]
|
||||||
|
reduced.append(gps[-1]) # always include the last point
|
||||||
|
|
||||||
|
return [[round(lat, 5), round(lon, 5)] for lat, lon in reduced]
|
||||||
|
|
||||||
|
|
||||||
def build_geojson(
|
def build_geojson(
|
||||||
points: list[DataPoint],
|
points: list[DataPoint],
|
||||||
activity_id: str,
|
activity_id: str,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from bincio.extract.metrics import ComputedMetrics
|
from bincio.extract.metrics import ComputedMetrics
|
||||||
from bincio.extract.models import LapData, ParsedActivity
|
from bincio.extract.models import LapData, ParsedActivity
|
||||||
from bincio.extract.simplify import build_geojson
|
from bincio.extract.simplify import build_geojson, preview_coords
|
||||||
from bincio.extract.timeseries import build_timeseries
|
from bincio.extract.timeseries import build_timeseries
|
||||||
|
|
||||||
|
|
||||||
@@ -119,6 +119,8 @@ def build_summary(
|
|||||||
"privacy": privacy,
|
"privacy": privacy,
|
||||||
"detail_url": f"activities/{activity_id}.json",
|
"detail_url": f"activities/{activity_id}.json",
|
||||||
"track_url": f"activities/{activity_id}.geojson" if has_gps else None,
|
"track_url": f"activities/{activity_id}.geojson" if has_gps else None,
|
||||||
|
# Small track preview for card thumbnails — no separate fetch needed
|
||||||
|
"preview_coords": preview_coords(activity.points) if has_gps else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+141
-9
@@ -1,4 +1,10 @@
|
|||||||
"""bincio render — CLI command (stub, Astro stage TBD)."""
|
"""bincio render — build or serve the Astro static site."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
@@ -6,13 +12,139 @@ from rich.console import Console
|
|||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
|
def _find_site_dir(explicit: Optional[str]) -> Path:
|
||||||
|
"""Locate the Astro project directory."""
|
||||||
|
if explicit:
|
||||||
|
p = Path(explicit).expanduser().resolve()
|
||||||
|
if not (p / "package.json").exists():
|
||||||
|
raise click.UsageError(f"No package.json found in --site-dir {p}")
|
||||||
|
return p
|
||||||
|
|
||||||
|
# Search upward from cwd: ./site, ../site (for when cwd is bincio_data/)
|
||||||
|
for candidate in [Path.cwd() / "site", Path.cwd().parent / "site"]:
|
||||||
|
if (candidate / "package.json").exists():
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
raise click.UsageError(
|
||||||
|
"Could not find the Astro site directory. "
|
||||||
|
"Run from the project root or pass --site-dir."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_data_dir(explicit: Optional[str], config_path: Optional[str]) -> Path:
|
||||||
|
"""Resolve the BAS data directory."""
|
||||||
|
if explicit:
|
||||||
|
return Path(explicit).expanduser().resolve()
|
||||||
|
|
||||||
|
if config_path and Path(config_path).exists():
|
||||||
|
import yaml
|
||||||
|
raw = yaml.safe_load(Path(config_path).read_text())
|
||||||
|
out = raw.get("output", {}).get("dir")
|
||||||
|
if out:
|
||||||
|
return Path(out).expanduser().resolve()
|
||||||
|
|
||||||
|
# Default: ./bincio_data next to cwd
|
||||||
|
default = Path.cwd() / "bincio_data"
|
||||||
|
if default.exists():
|
||||||
|
return default
|
||||||
|
|
||||||
|
raise click.UsageError(
|
||||||
|
"Could not find the BAS data directory. "
|
||||||
|
"Run `bincio extract` first, or pass --data-dir."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_npm(site: Path) -> None:
|
||||||
|
"""Run `npm install` if node_modules is missing or stale."""
|
||||||
|
if not (site / "node_modules").exists():
|
||||||
|
console.print("Running [cyan]npm install[/cyan]…")
|
||||||
|
subprocess.run(["npm", "install"], cwd=site, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _link_data(site: Path, data: Path) -> None:
|
||||||
|
"""Symlink the BAS data store into site/public/data."""
|
||||||
|
public_data = site / "public" / "data"
|
||||||
|
if public_data.is_symlink():
|
||||||
|
if public_data.resolve() == data:
|
||||||
|
return # already correct
|
||||||
|
public_data.unlink()
|
||||||
|
elif public_data.exists():
|
||||||
|
console.print(
|
||||||
|
f"[yellow]Warning:[/yellow] {public_data} exists and is not a symlink — "
|
||||||
|
"remove it manually if you want bincio to manage it."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
public_data.symlink_to(data)
|
||||||
|
console.print(f"Linked data: [cyan]{data}[/cyan] → [cyan]{public_data}[/cyan]")
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@click.option("--config", "config_path", default="site_config.yaml")
|
@click.option("--config", "config_path", default=None,
|
||||||
@click.option("--out", "out_dir", default="./site/dist")
|
help="Path to extract_config.yaml (reads output.dir from it).")
|
||||||
@click.option("--serve", is_flag=True, help="Start dev server with hot reload.")
|
@click.option("--data-dir", default=None,
|
||||||
|
help="BAS data store directory (output of bincio extract).")
|
||||||
|
@click.option("--site-dir", default=None,
|
||||||
|
help="Astro project directory (default: ./site).")
|
||||||
|
@click.option("--out", "out_dir", default=None,
|
||||||
|
help="Build output directory (default: site/dist).")
|
||||||
|
@click.option("--serve", is_flag=True,
|
||||||
|
help="Start dev server with hot reload instead of building.")
|
||||||
@click.option("--deploy", default=None, metavar="TARGET",
|
@click.option("--deploy", default=None, metavar="TARGET",
|
||||||
help="Deploy target: 'github'.")
|
help="Deploy after build. Currently supports: github.")
|
||||||
def render(config_path: str, out_dir: str, serve: bool, deploy: str | None) -> None:
|
def render(
|
||||||
"""Generate static site from BAS data store (Astro stage — coming soon)."""
|
config_path: Optional[str],
|
||||||
console.print("[yellow]bincio render is not yet implemented.[/yellow]")
|
data_dir: Optional[str],
|
||||||
console.print("The web renderer (Astro + MapLibre + Observable Plot) is next.")
|
site_dir: Optional[str],
|
||||||
|
out_dir: Optional[str],
|
||||||
|
serve: bool,
|
||||||
|
deploy: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
"""Build (or serve) the BincioActivity static site from a BAS data store."""
|
||||||
|
|
||||||
|
site = _find_site_dir(site_dir)
|
||||||
|
data = _find_data_dir(data_dir, config_path)
|
||||||
|
|
||||||
|
console.print(f"Site: [cyan]{site}[/cyan]")
|
||||||
|
console.print(f"Data: [cyan]{data}[/cyan]")
|
||||||
|
|
||||||
|
_ensure_npm(site)
|
||||||
|
_link_data(site, data)
|
||||||
|
|
||||||
|
env = {**os.environ, "BINCIO_DATA_DIR": str(data)}
|
||||||
|
|
||||||
|
if serve:
|
||||||
|
console.print("Starting [cyan]astro dev[/cyan]…")
|
||||||
|
subprocess.run(["npm", "run", "dev"], cwd=site, env=env)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build
|
||||||
|
cmd = ["npm", "run", "build"]
|
||||||
|
if out_dir:
|
||||||
|
# Pass outDir via Astro CLI flag
|
||||||
|
cmd = ["npx", "astro", "build", "--outDir", str(Path(out_dir).resolve())]
|
||||||
|
|
||||||
|
console.print("Running [cyan]astro build[/cyan]…")
|
||||||
|
result = subprocess.run(cmd, cwd=site, env=env)
|
||||||
|
if result.returncode != 0:
|
||||||
|
console.print("[red]Build failed.[/red]")
|
||||||
|
sys.exit(result.returncode)
|
||||||
|
|
||||||
|
dist = Path(out_dir).resolve() if out_dir else site / "dist"
|
||||||
|
console.print(f"\n[green]Build complete.[/green] Output: [cyan]{dist}[/cyan]")
|
||||||
|
|
||||||
|
if deploy == "github":
|
||||||
|
_deploy_github(site, dist)
|
||||||
|
|
||||||
|
|
||||||
|
def _deploy_github(site: Path, dist: Path) -> None:
|
||||||
|
"""Push dist/ to the gh-pages branch."""
|
||||||
|
console.print("Deploying to [cyan]GitHub Pages[/cyan]…")
|
||||||
|
# Requires npx gh-pages or git subtree push
|
||||||
|
result = subprocess.run(
|
||||||
|
["npx", "gh-pages", "-d", str(dist)],
|
||||||
|
cwd=site,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
console.print(
|
||||||
|
"[yellow]Tip:[/yellow] install gh-pages with `npm install -g gh-pages`"
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
owner:
|
||||||
|
handle: brutsalvadi
|
||||||
|
display_name: Bru
|
||||||
|
|
||||||
|
input:
|
||||||
|
dirs:
|
||||||
|
- ~/src/cycling_data_davide/activities
|
||||||
|
- ~/src/cycling_data_davide/Karoo_2026
|
||||||
|
- ~/src/cycling_data_davide/Karoo
|
||||||
|
# Strava bulk export metadata — provides names, descriptions, gear
|
||||||
|
metadata_csv: ~/src/cycling_data_davide/activities.csv
|
||||||
|
|
||||||
|
output:
|
||||||
|
dir: ~/bincio_data
|
||||||
|
|
||||||
|
default_privacy: public
|
||||||
|
|
||||||
|
sensors:
|
||||||
|
heart_rate: true
|
||||||
|
cadence: true
|
||||||
|
temperature: true
|
||||||
|
power: true
|
||||||
|
|
||||||
|
track:
|
||||||
|
simplify: rdp
|
||||||
|
rdp_epsilon: 0.0001 # ~11m at equator
|
||||||
|
timeseries_hz: 1 # 1 sample/second max
|
||||||
|
|
||||||
|
classifier:
|
||||||
|
enabled: false # ML activity type classifier (requires scikit-learn extra)
|
||||||
|
|
||||||
|
incremental: true # skip files whose hash hasn't changed since last run
|
||||||
@@ -7,4 +7,11 @@ export default defineConfig({
|
|||||||
output: "static",
|
output: "static",
|
||||||
// When hosting at a subdirectory (e.g. GitHub Pages project site), set:
|
// When hosting at a subdirectory (e.g. GitHub Pages project site), set:
|
||||||
// base: "/repo-name",
|
// base: "/repo-name",
|
||||||
|
vite: {
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['maplibre-gl'],
|
||||||
|
esbuildOptions: { target: 'es2022' },
|
||||||
|
},
|
||||||
|
build: { target: 'es2022' },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { Timeseries } from '../lib/types';
|
||||||
|
|
||||||
|
export let timeseries: Timeseries;
|
||||||
|
// Linked hover: emit/receive index into timeseries arrays
|
||||||
|
export let hoveredIdx: number | null = null;
|
||||||
|
|
||||||
|
type Tab = 'elevation' | 'speed' | 'hr' | 'cadence';
|
||||||
|
|
||||||
|
let activeTab: Tab = 'elevation';
|
||||||
|
let chartEl: HTMLDivElement;
|
||||||
|
let Plot: any;
|
||||||
|
let chart: SVGElement | null = null;
|
||||||
|
|
||||||
|
// Pre-build data array once
|
||||||
|
$: data = timeseries.t.map((t, i) => ({
|
||||||
|
t,
|
||||||
|
elevation: timeseries.elevation_m[i],
|
||||||
|
speed: timeseries.speed_kmh[i],
|
||||||
|
hr: timeseries.hr_bpm[i],
|
||||||
|
cadence: timeseries.cadence_rpm[i],
|
||||||
|
}));
|
||||||
|
|
||||||
|
$: hasHR = timeseries.hr_bpm.some(v => v != null);
|
||||||
|
$: hasCadence = timeseries.cadence_rpm.some(v => v != null);
|
||||||
|
$: hasElevation = timeseries.elevation_m.some(v => v != null);
|
||||||
|
$: hasSpeed = timeseries.speed_kmh.some(v => v != null);
|
||||||
|
|
||||||
|
const tabLabels: Record<Tab, string> = {
|
||||||
|
elevation: 'Elevation',
|
||||||
|
speed: 'Speed',
|
||||||
|
hr: 'Heart Rate',
|
||||||
|
cadence: 'Cadence',
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
Plot = await import('@observablehq/plot');
|
||||||
|
renderChart();
|
||||||
|
});
|
||||||
|
|
||||||
|
$: if (Plot && chartEl) {
|
||||||
|
activeTab; // reactive dependency
|
||||||
|
renderChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChart() {
|
||||||
|
if (!Plot || !chartEl) return;
|
||||||
|
chart?.remove();
|
||||||
|
|
||||||
|
const w = chartEl.clientWidth || 800;
|
||||||
|
const h = 220;
|
||||||
|
|
||||||
|
const marks: any[] = [];
|
||||||
|
let yLabel = '';
|
||||||
|
let yKey = '';
|
||||||
|
let color = '#00c8ff';
|
||||||
|
|
||||||
|
if (activeTab === 'elevation' && hasElevation) {
|
||||||
|
yKey = 'elevation'; yLabel = 'Elevation (m)'; color = '#00c8ff';
|
||||||
|
marks.push(
|
||||||
|
Plot.areaY(data, { x: 't', y: yKey, fill: color, fillOpacity: 0.15, curve: 'monotoneX' }),
|
||||||
|
Plot.lineY(data, { x: 't', y: yKey, stroke: color, strokeWidth: 1.5, curve: 'monotoneX' }),
|
||||||
|
);
|
||||||
|
} else if (activeTab === 'speed' && hasSpeed) {
|
||||||
|
yKey = 'speed'; yLabel = 'Speed (km/h)'; color = '#ff6b35';
|
||||||
|
marks.push(
|
||||||
|
Plot.areaY(data, { x: 't', y: yKey, fill: color, fillOpacity: 0.15, curve: 'monotoneX' }),
|
||||||
|
Plot.lineY(data, { x: 't', y: yKey, stroke: color, strokeWidth: 1.5, curve: 'monotoneX' }),
|
||||||
|
);
|
||||||
|
} else if (activeTab === 'hr' && hasHR) {
|
||||||
|
yKey = 'hr'; yLabel = 'Heart Rate (bpm)'; color = '#f87171';
|
||||||
|
marks.push(
|
||||||
|
Plot.areaY(data, { x: 't', y: yKey, fill: color, fillOpacity: 0.15, curve: 'monotoneX' }),
|
||||||
|
Plot.lineY(data, { x: 't', y: yKey, stroke: color, strokeWidth: 1.5, curve: 'monotoneX' }),
|
||||||
|
);
|
||||||
|
} else if (activeTab === 'cadence' && hasCadence) {
|
||||||
|
yKey = 'cadence'; yLabel = 'Cadence (rpm)'; color = '#a78bfa';
|
||||||
|
marks.push(
|
||||||
|
Plot.lineY(data, { x: 't', y: yKey, stroke: color, strokeWidth: 1.5, curve: 'monotoneX' }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!marks.length) return;
|
||||||
|
|
||||||
|
// Hover crosshair
|
||||||
|
marks.push(
|
||||||
|
Plot.ruleX(data, Plot.pointerX({
|
||||||
|
x: 't',
|
||||||
|
stroke: 'rgba(255,255,255,0.3)',
|
||||||
|
strokeWidth: 1,
|
||||||
|
strokeDasharray: '4,4',
|
||||||
|
})),
|
||||||
|
Plot.dot(data, Plot.pointerX({
|
||||||
|
x: 't', y: yKey,
|
||||||
|
r: 4, fill: color, stroke: 'white', strokeWidth: 1.5,
|
||||||
|
})),
|
||||||
|
Plot.text(data, Plot.pointerX({
|
||||||
|
x: 't', y: yKey,
|
||||||
|
text: (d: any) => d[yKey] != null ? `${Math.round(d[yKey])}` : '',
|
||||||
|
dy: -12, fill: 'white', fontSize: 11, fontWeight: '600',
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
chart = Plot.plot({
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
marginLeft: 48,
|
||||||
|
marginBottom: 32,
|
||||||
|
style: { background: 'transparent', color: '#a1a1aa', fontSize: '11px' },
|
||||||
|
x: {
|
||||||
|
label: null,
|
||||||
|
tickFormat: (t: number) => {
|
||||||
|
const h = Math.floor(t / 3600);
|
||||||
|
const m = Math.floor((t % 3600) / 60);
|
||||||
|
return h > 0 ? `${h}h${m.toString().padStart(2,'0')}` : `${m}m`;
|
||||||
|
},
|
||||||
|
grid: false,
|
||||||
|
ticks: 6,
|
||||||
|
},
|
||||||
|
y: { label: yLabel, grid: true, tickCount: 4 },
|
||||||
|
marks,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach pointer listener to emit hover index
|
||||||
|
chart.addEventListener('input', () => {
|
||||||
|
const pt = (chart as any)?.value;
|
||||||
|
if (pt) {
|
||||||
|
hoveredIdx = timeseries.t.findIndex(t => t === pt.t);
|
||||||
|
} else {
|
||||||
|
hoveredIdx = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
chartEl.appendChild(chart);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Tab bar -->
|
||||||
|
<div class="flex gap-1 mb-3">
|
||||||
|
{#each Object.entries(tabLabels) as [tab, label]}
|
||||||
|
{@const enabled =
|
||||||
|
tab === 'elevation' ? hasElevation :
|
||||||
|
tab === 'speed' ? hasSpeed :
|
||||||
|
tab === 'hr' ? hasHR :
|
||||||
|
hasCadence}
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 rounded-md text-sm transition-colors"
|
||||||
|
class:opacity-30={!enabled}
|
||||||
|
class:cursor-not-allowed={!enabled}
|
||||||
|
class:bg-zinc-800={activeTab === tab}
|
||||||
|
class:text-white={activeTab === tab}
|
||||||
|
class:text-zinc-500={activeTab !== tab}
|
||||||
|
class:hover:text-zinc-300={activeTab !== tab && enabled}
|
||||||
|
disabled={!enabled}
|
||||||
|
on:click={() => { if (enabled) activeTab = tab as Tab; }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div bind:this={chartEl} class="w-full overflow-hidden" />
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { ActivitySummary, ActivityDetail } from '../lib/types';
|
||||||
|
import { formatDistance, formatDuration, formatElevation, formatSpeed, formatDate, formatTime, sportIcon, sportLabel, sportColor } from '../lib/format';
|
||||||
|
import ActivityMap from './ActivityMap.svelte';
|
||||||
|
import ActivityCharts from './ActivityCharts.svelte';
|
||||||
|
|
||||||
|
export let activity: ActivitySummary;
|
||||||
|
export let base: string = '/';
|
||||||
|
|
||||||
|
let detail: ActivityDetail | null = null;
|
||||||
|
let error = '';
|
||||||
|
// Linked hover index shared between map and charts
|
||||||
|
let hoveredIdx: number | null = null;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!activity.detail_url) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${base}data/${activity.detail_url}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
detail = await res.json();
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$: trackUrl = activity.track_url ? `${base}data/${activity.track_url}` : null;
|
||||||
|
$: color = sportColor(activity.sport);
|
||||||
|
|
||||||
|
const stat = (label: string, value: string) => ({ label, value });
|
||||||
|
$: stats = [
|
||||||
|
stat('Distance', formatDistance(activity.distance_m)),
|
||||||
|
stat('Moving time', formatDuration(activity.moving_time_s ?? activity.duration_s)),
|
||||||
|
stat('Elevation ↑', formatElevation(activity.elevation_gain_m)),
|
||||||
|
stat('Avg speed', formatSpeed(activity.avg_speed_kmh)),
|
||||||
|
stat('Max speed', formatSpeed(activity.max_speed_kmh)),
|
||||||
|
stat('Avg HR', activity.avg_hr_bpm ? `${activity.avg_hr_bpm} bpm` : '—'),
|
||||||
|
stat('Max HR', activity.max_hr_bpm ? `${activity.max_hr_bpm} bpm` : '—'),
|
||||||
|
stat('Cadence', activity.avg_cadence_rpm ? `${activity.avg_cadence_rpm} rpm` : '—'),
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-start gap-4 mb-6">
|
||||||
|
<a href={`${base}`} class="text-zinc-500 hover:text-white transition-colors mt-1 shrink-0">
|
||||||
|
← Back
|
||||||
|
</a>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span
|
||||||
|
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||||
|
style="background:{color}22;color:{color}"
|
||||||
|
>
|
||||||
|
{sportIcon(activity.sport)} {sportLabel(activity.sport, activity.sub_sport)}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-zinc-500">
|
||||||
|
{formatDate(activity.started_at)} · {formatTime(activity.started_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold text-white">{activity.title}</h1>
|
||||||
|
{#if detail?.description}
|
||||||
|
<p class="text-zinc-400 mt-1 text-sm">{detail.description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map + Stats split -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-4 mb-4">
|
||||||
|
<!-- Map -->
|
||||||
|
<div class="h-[400px] lg:h-[420px] rounded-xl overflow-hidden bg-zinc-800">
|
||||||
|
{#if trackUrl}
|
||||||
|
<ActivityMap
|
||||||
|
{trackUrl}
|
||||||
|
timeseries={detail?.timeseries ?? null}
|
||||||
|
bbox={detail?.bbox ?? null}
|
||||||
|
accentColor={color}
|
||||||
|
bind:hoveredIdx
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="w-full h-full flex items-center justify-center text-zinc-600 text-sm">
|
||||||
|
No GPS track
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats panel -->
|
||||||
|
<div class="grid grid-cols-2 lg:grid-cols-1 gap-px bg-zinc-800 rounded-xl overflow-hidden">
|
||||||
|
{#each stats as s}
|
||||||
|
<div class="bg-zinc-900 px-4 py-3">
|
||||||
|
<p class="text-2xl font-bold text-white">{s.value}</p>
|
||||||
|
<p class="text-xs text-zinc-500">{s.label}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if detail?.gear}
|
||||||
|
<div class="bg-zinc-900 px-4 py-3 col-span-2 lg:col-span-1">
|
||||||
|
<p class="text-sm font-medium text-zinc-300">{detail.gear}</p>
|
||||||
|
<p class="text-xs text-zinc-500">Gear</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts -->
|
||||||
|
{#if error}
|
||||||
|
<p class="text-red-400 text-sm mt-4">{error}</p>
|
||||||
|
{:else if detail?.timeseries && detail.timeseries.t.length > 0}
|
||||||
|
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4">
|
||||||
|
<ActivityCharts timeseries={detail.timeseries} bind:hoveredIdx />
|
||||||
|
</div>
|
||||||
|
{:else if !detail}
|
||||||
|
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4 h-32 animate-pulse" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Laps -->
|
||||||
|
{#if detail?.laps?.length}
|
||||||
|
<div class="mt-4 bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="border-b border-zinc-800">
|
||||||
|
<tr class="text-left text-zinc-500 text-xs">
|
||||||
|
<th class="px-4 py-2">Lap</th>
|
||||||
|
<th class="px-4 py-2">Distance</th>
|
||||||
|
<th class="px-4 py-2">Time</th>
|
||||||
|
<th class="px-4 py-2">Avg speed</th>
|
||||||
|
<th class="px-4 py-2">Avg HR</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each detail.laps as lap}
|
||||||
|
<tr class="border-b border-zinc-800/50 hover:bg-zinc-800/50">
|
||||||
|
<td class="px-4 py-2 text-zinc-400">#{lap.index + 1}</td>
|
||||||
|
<td class="px-4 py-2 text-white">{formatDistance(lap.distance_m)}</td>
|
||||||
|
<td class="px-4 py-2 text-white">{formatDuration(lap.duration_s)}</td>
|
||||||
|
<td class="px-4 py-2 text-white">{formatSpeed(lap.avg_speed_kmh)}</td>
|
||||||
|
<td class="px-4 py-2 text-white">{lap.avg_hr_bpm ? `${lap.avg_hr_bpm} bpm` : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
|
||||||
|
import { formatDistance, formatDuration, formatElevation, formatDate, sportIcon, sportColor, sportLabel } from '../lib/format';
|
||||||
|
|
||||||
|
/** Render preview_coords as an SVG polyline path string. */
|
||||||
|
function trackPath(coords: [number, number][] | null, w: number, h: number): string {
|
||||||
|
if (!coords || coords.length < 2) return '';
|
||||||
|
const lats = coords.map(c => c[0]);
|
||||||
|
const lons = coords.map(c => c[1]);
|
||||||
|
const minLat = Math.min(...lats), maxLat = Math.max(...lats);
|
||||||
|
const minLon = Math.min(...lons), maxLon = Math.max(...lons);
|
||||||
|
const latR = maxLat - minLat || 0.001;
|
||||||
|
const lonR = maxLon - minLon || 0.001;
|
||||||
|
const pad = 4;
|
||||||
|
const scaleX = (w - pad * 2) / lonR;
|
||||||
|
const scaleY = (h - pad * 2) / latR;
|
||||||
|
const scale = Math.min(scaleX, scaleY);
|
||||||
|
const offX = pad + (w - pad * 2 - lonR * scale) / 2;
|
||||||
|
const offY = pad + (h - pad * 2 - latR * scale) / 2;
|
||||||
|
return coords
|
||||||
|
.map(([lat, lon], i) => {
|
||||||
|
const x = (lon - minLon) * scale + offX;
|
||||||
|
const y = h - ((lat - minLat) * scale + offY); // flip: SVG y↓, lat↑
|
||||||
|
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 60;
|
||||||
|
|
||||||
|
let all: ActivitySummary[] = [];
|
||||||
|
let sport: Sport | 'all' = 'all';
|
||||||
|
let shown = PAGE_SIZE;
|
||||||
|
let loading = true;
|
||||||
|
let error = '';
|
||||||
|
|
||||||
|
$: filtered = sport === 'all' ? all : all.filter(a => a.sport === sport);
|
||||||
|
$: visible = filtered.slice(0, shown);
|
||||||
|
$: hasMore = shown < filtered.length;
|
||||||
|
|
||||||
|
$: if (sport) shown = PAGE_SIZE; // reset pagination on filter change
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${import.meta.env.BASE_URL}data/index.json`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const index: BASIndex = await res.json();
|
||||||
|
all = index.activities.filter(a => a.privacy !== 'private');
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e.message;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sports: Array<{ value: Sport | 'all'; label: string }> = [
|
||||||
|
{ value: 'all', label: 'All' },
|
||||||
|
{ value: 'cycling', label: '🚴 Cycling' },
|
||||||
|
{ value: 'running', label: '🏃 Running' },
|
||||||
|
{ value: 'hiking', label: '🥾 Hiking' },
|
||||||
|
{ value: 'walking', label: '🚶 Walking' },
|
||||||
|
{ value: 'swimming', label: '🏊 Swimming' },
|
||||||
|
{ value: 'other', label: '⚡ Other' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Filter bar -->
|
||||||
|
<div class="flex gap-2 mb-6 flex-wrap">
|
||||||
|
{#each sports as s}
|
||||||
|
<button
|
||||||
|
class="px-3 py-1 rounded-full text-sm font-medium border transition-colors"
|
||||||
|
class:border-zinc-700={sport !== s.value}
|
||||||
|
class:text-zinc-400={sport !== s.value}
|
||||||
|
class:border-[--accent]={sport === s.value}
|
||||||
|
class:text-white={sport === s.value}
|
||||||
|
style={sport === s.value ? 'background:var(--accent-dim)' : ''}
|
||||||
|
on:click={() => sport = s.value}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if all.length > 0}
|
||||||
|
<span class="ml-auto text-sm text-zinc-500 self-center">
|
||||||
|
{filtered.length} {filtered.length === 1 ? 'activity' : 'activities'}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{#each Array(12) as _}
|
||||||
|
<div class="h-36 rounded-xl bg-zinc-800 animate-pulse"></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<p class="text-red-400 text-center py-12">Could not load activities: {error}</p>
|
||||||
|
{:else if filtered.length === 0}
|
||||||
|
<p class="text-zinc-500 text-center py-12">No activities found.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{#each visible as a (a.id)}
|
||||||
|
<a
|
||||||
|
href={`${import.meta.env.BASE_URL}activity/${a.id}/`}
|
||||||
|
class="block rounded-xl bg-zinc-900 border border-zinc-800 p-4 hover:border-zinc-600 hover:bg-zinc-800/80 transition-all group"
|
||||||
|
>
|
||||||
|
<!-- header -->
|
||||||
|
<div class="flex items-start justify-between gap-2 mb-3">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-xs text-zinc-500 mb-0.5">{formatDate(a.started_at)}</p>
|
||||||
|
<h3 class="font-semibold text-white truncate group-hover:text-[--accent] transition-colors">
|
||||||
|
{a.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0"
|
||||||
|
style="background:{sportColor(a.sport)}22; color:{sportColor(a.sport)}"
|
||||||
|
>
|
||||||
|
{sportIcon(a.sport)} {sportLabel(a.sport, a.sub_sport)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- track thumbnail -->
|
||||||
|
{#if a.preview_coords}
|
||||||
|
<svg viewBox="0 0 120 70" class="w-full mt-2 mb-3 rounded overflow-hidden bg-zinc-800/60" style="height:70px">
|
||||||
|
<path
|
||||||
|
d={trackPath(a.preview_coords, 120, 70)}
|
||||||
|
fill="none"
|
||||||
|
stroke={sportColor(a.sport)}
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
opacity="0.9"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- stats row -->
|
||||||
|
<div class="grid grid-cols-3 gap-2 text-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-lg font-bold text-white">{formatDistance(a.distance_m)}</p>
|
||||||
|
<p class="text-xs text-zinc-500">Distance</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-lg font-bold text-white">{formatDuration(a.moving_time_s ?? a.duration_s)}</p>
|
||||||
|
<p class="text-xs text-zinc-500">Moving time</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-lg font-bold text-white">{formatElevation(a.elevation_gain_m)}</p>
|
||||||
|
<p class="text-xs text-zinc-500">Elevation</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- secondary stats -->
|
||||||
|
{#if a.avg_speed_kmh || a.avg_hr_bpm}
|
||||||
|
<div class="flex gap-4 mt-3 pt-3 border-t border-zinc-800 text-xs text-zinc-400">
|
||||||
|
{#if a.avg_speed_kmh}
|
||||||
|
<span>⚡ {a.avg_speed_kmh.toFixed(1)} km/h</span>
|
||||||
|
{/if}
|
||||||
|
{#if a.avg_hr_bpm}
|
||||||
|
<span>♥ {a.avg_hr_bpm} bpm</span>
|
||||||
|
{/if}
|
||||||
|
{#if a.avg_cadence_rpm}
|
||||||
|
<span>↻ {a.avg_cadence_rpm} rpm</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if hasMore}
|
||||||
|
<div class="text-center mt-8">
|
||||||
|
<button
|
||||||
|
class="px-6 py-2 rounded-full border border-zinc-700 text-zinc-300 hover:border-zinc-500 hover:text-white transition-colors text-sm"
|
||||||
|
on:click={() => shown += PAGE_SIZE}
|
||||||
|
>
|
||||||
|
Load more ({filtered.length - shown} remaining)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import maplibregl from 'maplibre-gl';
|
||||||
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
|
import type { Timeseries } from '../lib/types';
|
||||||
|
|
||||||
|
export let trackUrl: string;
|
||||||
|
export let timeseries: Timeseries | null = null;
|
||||||
|
export let bbox: [number, number, number, number] | null = null;
|
||||||
|
export let accentColor: string = '#00c8ff';
|
||||||
|
export let hoveredIdx: number | null = null;
|
||||||
|
|
||||||
|
let mapEl: HTMLDivElement;
|
||||||
|
let map: any;
|
||||||
|
const MarkerClass = maplibregl.Marker;
|
||||||
|
let hoverMarker: any;
|
||||||
|
let markersAdded = false;
|
||||||
|
|
||||||
|
const TILE_STYLE = 'https://tiles.openfreemap.org/styles/positron';
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
map = new maplibregl.Map({
|
||||||
|
container: mapEl,
|
||||||
|
style: TILE_STYLE,
|
||||||
|
center: [0, 0],
|
||||||
|
zoom: 1,
|
||||||
|
attributionControl: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right');
|
||||||
|
|
||||||
|
// Hover dot marker — must set lngLat before addTo in MapLibre v5
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.style.cssText = `
|
||||||
|
width:12px;height:12px;border-radius:50%;
|
||||||
|
background:white;border:2px solid ${accentColor};
|
||||||
|
box-shadow:0 0 6px ${accentColor};display:none;pointer-events:none;
|
||||||
|
`;
|
||||||
|
hoverMarker = new maplibregl.Marker({ element: el, anchor: 'center' })
|
||||||
|
.setLngLat([0, 0])
|
||||||
|
.addTo(map);
|
||||||
|
|
||||||
|
map.on('load', () => {
|
||||||
|
map.addSource('track', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: trackUrl,
|
||||||
|
lineMetrics: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: 'track-shadow',
|
||||||
|
type: 'line',
|
||||||
|
source: 'track',
|
||||||
|
paint: { 'line-color': 'rgba(0,0,0,0.3)', 'line-width': 5, 'line-blur': 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: 'track-line',
|
||||||
|
type: 'line',
|
||||||
|
source: 'track',
|
||||||
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||||
|
paint: {
|
||||||
|
'line-width': 3,
|
||||||
|
'line-gradient': [
|
||||||
|
'interpolate', ['linear'], ['line-progress'],
|
||||||
|
0, accentColor,
|
||||||
|
0.5, '#ff6b35',
|
||||||
|
1, accentColor,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fit to bbox when detail JSON loads (bbox is null at map init)
|
||||||
|
$: if (map && bbox) {
|
||||||
|
const fit = () => map.fitBounds(
|
||||||
|
[[bbox![0], bbox![1]], [bbox![2], bbox![3]]],
|
||||||
|
{ padding: 40, animate: true },
|
||||||
|
);
|
||||||
|
map.loaded() ? fit() : map.once('load', fit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add start/end markers when timeseries arrives
|
||||||
|
$: if (map && MarkerClass && timeseries && !markersAdded) {
|
||||||
|
markersAdded = true;
|
||||||
|
const add = () => {
|
||||||
|
const lats = (timeseries!.lat ?? []).filter(v => v != null) as number[];
|
||||||
|
const lons = (timeseries!.lon ?? []).filter(v => v != null) as number[];
|
||||||
|
if (!lats.length) return;
|
||||||
|
new MarkerClass({ element: makeDot('#4ade80'), anchor: 'center' })
|
||||||
|
.setLngLat([lons[0], lats[0]]).addTo(map);
|
||||||
|
new MarkerClass({ element: makeDot('#f87171'), anchor: 'center' })
|
||||||
|
.setLngLat([lons[lons.length - 1], lats[lats.length - 1]]).addTo(map);
|
||||||
|
};
|
||||||
|
map.loaded() ? add() : map.once('load', add);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hover dot linked to chart crosshair
|
||||||
|
$: if (hoverMarker && timeseries && hoveredIdx != null) {
|
||||||
|
const lat = timeseries.lat?.[hoveredIdx];
|
||||||
|
const lon = timeseries.lon?.[hoveredIdx];
|
||||||
|
if (lat != null && lon != null) {
|
||||||
|
hoverMarker.getElement().style.display = 'block';
|
||||||
|
hoverMarker.setLngLat([lon, lat]);
|
||||||
|
}
|
||||||
|
} else if (hoverMarker) {
|
||||||
|
hoverMarker.getElement().style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDot(color: string): HTMLDivElement {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.style.cssText = `
|
||||||
|
width:10px;height:10px;border-radius:50%;
|
||||||
|
background:${color};border:2px solid white;
|
||||||
|
box-shadow:0 0 4px rgba(0,0,0,0.5);
|
||||||
|
`;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => map?.remove());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={mapEl} class="w-full h-full" />
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
|
||||||
|
import { formatDistance, sportIcon } from '../lib/format';
|
||||||
|
|
||||||
|
let activities: ActivitySummary[] = [];
|
||||||
|
let loading = true;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const res = await fetch(`${import.meta.env.BASE_URL}data/index.json`);
|
||||||
|
const index: BASIndex = await res.json();
|
||||||
|
activities = index.activities.filter(a => a.privacy !== 'private' && a.distance_m);
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Heatmap ───────────────────────────────────────────────────────────────
|
||||||
|
// Build a map: dateString → total distance (m)
|
||||||
|
$: byDate = (() => {
|
||||||
|
const m = new Map<string, number>();
|
||||||
|
for (const a of activities) {
|
||||||
|
const d = a.started_at.slice(0, 10); // YYYY-MM-DD
|
||||||
|
m.set(d, (m.get(d) ?? 0) + (a.distance_m ?? 0));
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Current year and prior 3 years to show
|
||||||
|
const now = new Date();
|
||||||
|
const years = [now.getFullYear(), now.getFullYear()-1, now.getFullYear()-2, now.getFullYear()-3];
|
||||||
|
|
||||||
|
function getWeeks(year: number): string[][] {
|
||||||
|
// Returns array of weeks, each week is array of 7 date strings (Mon–Sun)
|
||||||
|
// Pad with empty strings at start/end
|
||||||
|
const jan1 = new Date(year, 0, 1);
|
||||||
|
const dec31 = new Date(year, 11, 31);
|
||||||
|
// Align to Monday
|
||||||
|
const start = new Date(jan1);
|
||||||
|
start.setDate(jan1.getDate() - ((jan1.getDay() + 6) % 7));
|
||||||
|
const end = new Date(dec31);
|
||||||
|
end.setDate(dec31.getDate() + (6 - (dec31.getDay() + 6) % 7));
|
||||||
|
const weeks: string[][] = [];
|
||||||
|
let cur = new Date(start);
|
||||||
|
while (cur <= end) {
|
||||||
|
const week: string[] = [];
|
||||||
|
for (let d = 0; d < 7; d++) {
|
||||||
|
const iso = cur.toISOString().slice(0, 10);
|
||||||
|
week.push(cur.getFullYear() === year ? iso : '');
|
||||||
|
cur.setDate(cur.getDate() + 1);
|
||||||
|
}
|
||||||
|
weeks.push(week);
|
||||||
|
}
|
||||||
|
return weeks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellColor(date: string): string {
|
||||||
|
if (!date) return 'transparent';
|
||||||
|
const km = (byDate.get(date) ?? 0) / 1000;
|
||||||
|
if (km === 0) return '#27272a'; // zinc-800
|
||||||
|
if (km < 20) return '#0e4c5a';
|
||||||
|
if (km < 50) return '#0a6e82';
|
||||||
|
if (km < 80) return '#0891b2'; // cyan-600
|
||||||
|
if (km < 120) return '#06b6d4'; // cyan-500
|
||||||
|
return '#00c8ff';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Totals ────────────────────────────────────────────────────────────────
|
||||||
|
$: totalsByYear = (() => {
|
||||||
|
const m = new Map<number, { dist: number; count: number }>();
|
||||||
|
for (const a of activities) {
|
||||||
|
const y = new Date(a.started_at).getFullYear();
|
||||||
|
const cur = m.get(y) ?? { dist: 0, count: 0 };
|
||||||
|
cur.dist += a.distance_m ?? 0;
|
||||||
|
cur.count += 1;
|
||||||
|
m.set(y, cur);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
})();
|
||||||
|
|
||||||
|
$: allYears = [...totalsByYear.keys()].sort((a, b) => b - a);
|
||||||
|
|
||||||
|
const DOW = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
|
||||||
|
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
|
|
||||||
|
function monthLabels(weeks: string[][]): Array<{month:string;col:number}> {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return weeks.flatMap((week, i) => {
|
||||||
|
const day = week.find(d => d);
|
||||||
|
if (!day) return [];
|
||||||
|
const m = MONTHS[parseInt(day.slice(5, 7)) - 1];
|
||||||
|
if (seen.has(m)) return [];
|
||||||
|
seen.add(m);
|
||||||
|
return [{ month: m, col: i }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="h-64 rounded-xl bg-zinc-800 animate-pulse mb-6" />
|
||||||
|
{:else}
|
||||||
|
|
||||||
|
<!-- Year totals -->
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
|
||||||
|
{#each allYears.slice(0, 4) as year}
|
||||||
|
{@const t = totalsByYear.get(year)}
|
||||||
|
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4">
|
||||||
|
<p class="text-xs text-zinc-500 mb-1">{year}</p>
|
||||||
|
<p class="text-2xl font-bold text-white">{formatDistance(t?.dist ?? 0)}</p>
|
||||||
|
<p class="text-sm text-zinc-400">{t?.count ?? 0} activities</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Heatmaps per year -->
|
||||||
|
{#each years as year}
|
||||||
|
{@const weeks = getWeeks(year)}
|
||||||
|
{@const labels = monthLabels(weeks)}
|
||||||
|
{@const yt = totalsByYear.get(year)}
|
||||||
|
{#if yt}
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex items-baseline gap-3 mb-2">
|
||||||
|
<h2 class="text-lg font-semibold text-white">{year}</h2>
|
||||||
|
<span class="text-sm text-zinc-400">
|
||||||
|
{formatDistance(yt.dist)} · {yt.count} activities
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<div class="inline-block">
|
||||||
|
<!-- Month labels -->
|
||||||
|
<div class="flex mb-1 ml-6">
|
||||||
|
{#each labels as { month, col }}
|
||||||
|
<span
|
||||||
|
class="text-xs text-zinc-500 absolute"
|
||||||
|
style="left: calc({col} * 13px)"
|
||||||
|
>{month}</span>
|
||||||
|
{/each}
|
||||||
|
<!-- spacer to set width -->
|
||||||
|
<div style="width:{weeks.length * 13}px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid -->
|
||||||
|
<div class="flex gap-[3px]">
|
||||||
|
<!-- Day-of-week labels -->
|
||||||
|
<div class="flex flex-col gap-[3px] mr-1">
|
||||||
|
{#each DOW as d, i}
|
||||||
|
<span class="text-[9px] text-zinc-600 h-[10px] leading-[10px] w-3 text-right">
|
||||||
|
{i % 2 === 1 ? d : ''}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<!-- Weeks -->
|
||||||
|
{#each weeks as week}
|
||||||
|
<div class="flex flex-col gap-[3px]">
|
||||||
|
{#each week as date}
|
||||||
|
<div
|
||||||
|
class="w-[10px] h-[10px] rounded-[2px]"
|
||||||
|
style="background:{cellColor(date)}"
|
||||||
|
title={date ? `${date}: ${formatDistance(byDate.get(date) ?? 0)}` : ''}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legend -->
|
||||||
|
<div class="flex items-center gap-1 mt-2">
|
||||||
|
<span class="text-xs text-zinc-500 mr-1">Less</span>
|
||||||
|
{#each ['#27272a','#0e4c5a','#0a6e82','#0891b2','#06b6d4','#00c8ff'] as c}
|
||||||
|
<div class="w-[10px] h-[10px] rounded-[2px]" style="background:{c}" />
|
||||||
|
{/each}
|
||||||
|
<span class="text-xs text-zinc-500 ml-1">More</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
const { title = 'BincioActivity', description = 'Your personal activity stats' } = Astro.props;
|
||||||
|
---
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<title>{title}</title>
|
||||||
|
<style is:global>
|
||||||
|
:root {
|
||||||
|
--accent: #00c8ff;
|
||||||
|
--accent-dim: rgba(0,200,255,0.15);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
body { margin: 0; }
|
||||||
|
/* MapLibre GL needs these */
|
||||||
|
.maplibregl-canvas { outline: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-zinc-950 text-zinc-100 font-sans antialiased min-h-screen">
|
||||||
|
<nav class="border-b border-zinc-800 sticky top-0 z-50 bg-zinc-950/90 backdrop-blur">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 h-12 flex items-center gap-6">
|
||||||
|
<a href="/" class="font-bold text-white tracking-tight hover:text-[--accent] transition-colors">
|
||||||
|
Bincio<span class="text-[--accent]">Activity</span>
|
||||||
|
</a>
|
||||||
|
<a href="/" class="text-sm text-zinc-400 hover:text-white transition-colors">Feed</a>
|
||||||
|
<a href="/stats/" class="text-sm text-zinc-400 hover:text-white transition-colors">Stats</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main class="max-w-7xl mx-auto px-4 py-6">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import type { Sport } from './types';
|
||||||
|
|
||||||
|
export function formatDistance(m: number | null, unit: 'metric' | 'imperial' = 'metric'): string {
|
||||||
|
if (m == null) return '—';
|
||||||
|
if (unit === 'imperial') {
|
||||||
|
const miles = m / 1609.344;
|
||||||
|
return miles >= 10 ? `${miles.toFixed(1)} mi` : `${miles.toFixed(2)} mi`;
|
||||||
|
}
|
||||||
|
const km = m / 1000;
|
||||||
|
return km >= 10 ? `${km.toFixed(1)} km` : `${km.toFixed(2)} km`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(s: number | null): string {
|
||||||
|
if (s == null) return '—';
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
if (h > 0) return `${h}h ${m.toString().padStart(2, '0')}m`;
|
||||||
|
return `${m}m ${sec.toString().padStart(2, '0')}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSpeed(kmh: number | null): string {
|
||||||
|
if (kmh == null) return '—';
|
||||||
|
return `${kmh.toFixed(1)} km/h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatElevation(m: number | null): string {
|
||||||
|
if (m == null) return '—';
|
||||||
|
return `${Math.round(m)} m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString('en-GB', {
|
||||||
|
day: 'numeric', month: 'short', year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleTimeString('en-GB', {
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateShort(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString('en-GB', {
|
||||||
|
day: 'numeric', month: 'short',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPORT_ICONS: Record<Sport, string> = {
|
||||||
|
cycling: '🚴',
|
||||||
|
running: '🏃',
|
||||||
|
hiking: '🥾',
|
||||||
|
walking: '🚶',
|
||||||
|
swimming: '🏊',
|
||||||
|
other: '⚡',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SPORT_COLORS: Record<Sport, string> = {
|
||||||
|
cycling: '#00c8ff',
|
||||||
|
running: '#ff6b35',
|
||||||
|
hiking: '#4ade80',
|
||||||
|
walking: '#a3e635',
|
||||||
|
swimming: '#38bdf8',
|
||||||
|
other: '#a78bfa',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sportIcon(sport: Sport): string {
|
||||||
|
return SPORT_ICONS[sport] ?? '⚡';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sportColor(sport: Sport): string {
|
||||||
|
return SPORT_COLORS[sport] ?? '#a78bfa';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sportLabel(sport: Sport, subSport?: string | null): string {
|
||||||
|
const base = sport.charAt(0).toUpperCase() + sport.slice(1);
|
||||||
|
if (subSport && subSport !== 'generic') {
|
||||||
|
return `${subSport.charAt(0).toUpperCase() + subSport.slice(1)} ${base}`;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ export interface ActivitySummary {
|
|||||||
privacy: Privacy;
|
privacy: Privacy;
|
||||||
detail_url: string | null;
|
detail_url: string | null;
|
||||||
track_url: string | null;
|
track_url: string | null;
|
||||||
|
/** ~20 [lat, lon] pairs for card thumbnail — no separate fetch needed. */
|
||||||
|
preview_coords: [number, number][] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BASIndex {
|
export interface BASIndex {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import Base from '../../layouts/Base.astro';
|
||||||
|
import ActivityDetail from '../../components/ActivityDetail.svelte';
|
||||||
|
import type { BASIndex, ActivitySummary } from '../../lib/types';
|
||||||
|
|
||||||
|
export async function getStaticPaths() {
|
||||||
|
const dataDir = process.env.BINCIO_DATA_DIR
|
||||||
|
?? resolve(process.cwd(), '..', 'bincio_data');
|
||||||
|
const raw = readFileSync(join(dataDir, 'index.json'), 'utf-8');
|
||||||
|
const index: BASIndex = JSON.parse(raw);
|
||||||
|
|
||||||
|
return index.activities
|
||||||
|
.filter(a => a.privacy !== 'private' && a.id)
|
||||||
|
.map(a => ({
|
||||||
|
params: { id: a.id },
|
||||||
|
props: { activity: a },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { activity } = Astro.props as { activity: ActivitySummary };
|
||||||
|
const base = import.meta.env.BASE_URL;
|
||||||
|
---
|
||||||
|
<Base title={`${activity.title} — BincioActivity`}>
|
||||||
|
<ActivityDetail {activity} {base} client:only="svelte" />
|
||||||
|
</Base>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
import Base from '../layouts/Base.astro';
|
||||||
|
import ActivityFeed from '../components/ActivityFeed.svelte';
|
||||||
|
---
|
||||||
|
<Base title="BincioActivity — Feed">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-6">Activities</h1>
|
||||||
|
<ActivityFeed client:load />
|
||||||
|
</Base>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
import Base from '../../layouts/Base.astro';
|
||||||
|
import StatsView from '../../components/StatsView.svelte';
|
||||||
|
---
|
||||||
|
<Base title="Stats — BincioActivity">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-6">Stats</h1>
|
||||||
|
<StatsView client:load />
|
||||||
|
</Base>
|
||||||
Reference in New Issue
Block a user