map now working

This commit is contained in:
Davide Scaini
2026-03-28 19:34:22 +01:00
parent 5d58126d2f
commit 3441079913
18 changed files with 1489 additions and 10 deletions
+7
View File
@@ -7,4 +7,11 @@ export default defineConfig({
output: "static",
// When hosting at a subdirectory (e.g. GitHub Pages project site), set:
// base: "/repo-name",
vite: {
optimizeDeps: {
include: ['maplibre-gl'],
esbuildOptions: { target: 'es2022' },
},
build: { target: 'es2022' },
},
});
+163
View File
@@ -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" />
+140
View File
@@ -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}
+182
View File
@@ -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}
+124
View File
@@ -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" />
+179
View File
@@ -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 (MonSun)
// 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}
+41
View File
@@ -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>
+82
View File
@@ -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;
}
+2
View File
@@ -24,6 +24,8 @@ export interface ActivitySummary {
privacy: Privacy;
detail_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 {
+27
View File
@@ -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>
+8
View File
@@ -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>
+8
View File
@@ -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>