map now working
This commit is contained in:
@@ -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' },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
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 {
|
||||
|
||||
@@ -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