track info pane, per-type colors, yearly stats

This commit is contained in:
Vincent van der Wal
2026-07-22 12:21:45 +02:00
parent ce073f3fee
commit 5937cedcc5
8 changed files with 296 additions and 18 deletions
+24
View File
@@ -20,6 +20,14 @@
--logo-wash-2: #96d6c6;
--logo-text: #56555a;
--map-icon-filter: none;
--cat-0: #2a78d6;
--cat-1: #eb6834;
--cat-2: #1baf7a;
--cat-3: #eda100;
--cat-4: #e87ba4;
--cat-5: #008300;
--cat-6: #4a3aa7;
--cat-7: #e34948;
}
/* dark tokens apply when the OS prefers dark (unless the user forced light),
@@ -46,6 +54,14 @@
--logo-wash-2: #1e6355;
--logo-text: #eef2f0;
--map-icon-filter: invert(1) brightness(1.1);
--cat-0: #3987e5;
--cat-1: #d95926;
--cat-2: #199e70;
--cat-3: #c98500;
--cat-4: #d55181;
--cat-5: #008300;
--cat-6: #9085e9;
--cat-7: #e66767;
}
}
:root[data-theme='dark'] {
@@ -69,6 +85,14 @@
--logo-wash-2: #1e6355;
--logo-text: #eef2f0;
--map-icon-filter: invert(1) brightness(1.1);
--cat-0: #3987e5;
--cat-1: #d95926;
--cat-2: #199e70;
--cat-3: #c98500;
--cat-4: #d55181;
--cat-5: #008300;
--cat-6: #9085e9;
--cat-7: #e66767;
}
* {
+53
View File
@@ -0,0 +1,53 @@
// Categorical palette slots (validated for light and dark surfaces).
// Activity types map to fixed slots so a type keeps its color everywhere.
export const CAT_SLOTS = [
{ light: '#2a78d6', dark: '#3987e5' }, // blue
{ light: '#eb6834', dark: '#d95926' }, // orange
{ light: '#1baf7a', dark: '#199e70' }, // aqua
{ light: '#eda100', dark: '#c98500' }, // yellow
{ light: '#e87ba4', dark: '#d55181' }, // magenta
{ light: '#008300', dark: '#008300' }, // green
{ light: '#4a3aa7', dark: '#9085e9' }, // violet
{ light: '#e34948', dark: '#e66767' } // red
];
const TYPE_SLOT: Record<string, number> = {
hiking: 0,
hike: 0,
walk: 0,
walking: 0,
run: 1,
running: 1,
'trail run': 1,
ride: 2,
cycling: 2,
bike: 2,
biking: 2,
'virtual ride': 2,
'mountain bike': 2,
climb: 3,
climbing: 3,
'via ferrata': 3,
alpinism: 3,
ski: 6,
'backcountry ski': 6,
'nordic ski': 6,
snowboard: 6,
snowshoe: 6
};
function hash(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
export function typeSlot(type: string): number {
const t = type.toLowerCase().trim();
return TYPE_SLOT[t] ?? hash(t) % CAT_SLOTS.length;
}
export function typeColor(type: string): { light: string; dark: string } {
return CAT_SLOTS[typeSlot(type)];
}
+5 -4
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { fmtDate, fmtDistance, fmtDuration, fmtElevation } from '$lib/format';
import { typeSlot } from '$lib/activity-colors';
let {
activity
@@ -21,7 +22,7 @@
<a class="item" href="/activities/{activity.id}">
<div class="head">
<span class="name">{activity.name}</span>
<span class="type">{activity.type}</span>
<span class="type" style="--tc: var(--cat-{typeSlot(activity.type)})">{activity.type}</span>
</div>
<div class="meta">
{#if activity.username}
@@ -61,9 +62,9 @@
}
.type {
font-size: 0.75rem;
font-weight: 500;
color: var(--accent-strong);
background: var(--accent-wash);
font-weight: 600;
color: var(--tc, var(--accent-strong));
background: color-mix(in srgb, var(--tc, var(--accent)) 13%, transparent);
padding: 0.1rem 0.5rem;
border-radius: 999px;
text-transform: capitalize;
+114 -9
View File
@@ -2,11 +2,17 @@
import { onMount } from 'svelte';
import 'maplibre-gl/dist/maplibre-gl.css';
import { theme } from '$lib/theme.svelte';
import { typeColor } from '$lib/activity-colors';
import { fmtDate, fmtDistance } from '$lib/format';
import type { FeatureCollection, Point } from 'geojson';
export interface MapTrack {
id?: number;
name?: string;
username?: string;
type?: string;
date?: string | null;
distance_m?: number;
latlngs: [number, number][];
}
export interface MapPeak {
@@ -115,14 +121,25 @@
type: 'geojson',
data: {
type: 'FeatureCollection',
features: tracks.map((track) => ({
type: 'Feature',
properties: { name: track.name ?? '' },
geometry: {
type: 'LineString',
coordinates: track.latlngs.map(([lat, lon]) => [lon, lat])
}
}))
features: tracks.map((track) => {
const color = typeColor(track.type ?? 'outdoor');
return {
type: 'Feature' as const,
properties: {
id: track.id ?? null,
name: track.name ?? '',
username: track.username ?? '',
type: track.type ?? '',
date: track.date ?? '',
distance_m: track.distance_m ?? 0,
color: dark ? color.dark : color.light
},
geometry: {
type: 'LineString' as const,
coordinates: track.latlngs.map(([lat, lon]) => [lon, lat])
}
};
})
}
});
map.addLayer({
@@ -137,7 +154,15 @@
type: 'line',
source: 'tracks',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': dark ? '#3987e5' : '#2a78d6', 'line-width': 2.5 }
paint: { 'line-color': ['get', 'color'], 'line-width': 2.5 }
});
// invisible wide line so thin routes are easy to click
map.addLayer({
id: 'tracks-hit',
type: 'line',
source: 'tracks',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': '#000', 'line-width': 16, 'line-opacity': 0.001 }
});
// peaks as native layers - scales to thousands of points
@@ -231,6 +256,53 @@
popup.remove();
});
// track interaction: click a route for an info pane
const trackPane = new maplibre.Popup({
closeButton: true,
closeOnClick: true,
offset: 10,
maxWidth: '280px',
className: 'track-pane'
});
map.on('click', 'tracks-hit', (e) => {
if (!map) return;
// a click on a peak wins over the route underneath
if (map.queryRenderedFeatures(e.point, { layers: ['peaks-circles'] }).length > 0) return;
const feature = e.features?.[0];
if (!feature) return;
const p = feature.properties;
const pane = document.createElement('div');
const title = document.createElement('strong');
title.textContent = p.name || 'Activity';
pane.appendChild(title);
const meta = document.createElement('div');
meta.className = 'tp-meta';
meta.textContent = [
p.username || null,
p.date ? fmtDate(p.date) : null,
p.distance_m ? fmtDistance(p.distance_m) : null,
p.type || null
]
.filter(Boolean)
.join(' · ');
pane.appendChild(meta);
if (p.id) {
const link = document.createElement('a');
link.href = `/activities/${p.id}`;
link.textContent = 'Open activity →';
link.className = 'tp-link';
pane.appendChild(link);
}
trackPane.setLngLat(e.lngLat).setDOMContent(pane).addTo(map);
});
map.on('mouseenter', 'tracks-hit', () => {
if (map) map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', 'tracks-hit', () => {
if (map) map.getCanvas().style.cursor = '';
});
if (onviewport) {
const report = () => {
if (!map) return;
@@ -313,4 +385,37 @@
border-top-color: var(--surface-1);
border-bottom-color: var(--surface-1);
}
:global(.maplibregl-popup.track-pane .maplibregl-popup-content) {
background: var(--surface-1);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 0.6rem;
padding: 0.7rem 0.9rem;
font-size: 0.88rem;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.15);
}
:global(.maplibregl-popup.track-pane .maplibregl-popup-tip) {
border-top-color: var(--surface-1);
border-bottom-color: var(--surface-1);
}
:global(.maplibregl-popup.track-pane .maplibregl-popup-close-button) {
color: var(--text-muted);
font-size: 1.1rem;
padding: 0 0.35rem;
}
:global(.track-pane .tp-meta) {
color: var(--text-secondary);
font-size: 0.8rem;
margin: 0.15rem 0 0.4rem;
text-transform: capitalize;
}
:global(.track-pane .tp-link) {
color: var(--accent-strong);
font-weight: 600;
text-decoration: none;
font-size: 0.85rem;
}
:global(.track-pane .tp-link:hover) {
text-decoration: underline;
}
</style>
+19 -3
View File
@@ -5,17 +5,33 @@ export const load: PageServerLoad = ({ locals }) => {
// every user's tracks, thinned for the overview map
const rows = db
.prepare(
`SELECT a.id, a.name, a.points, u.username
`SELECT a.id, a.name, a.type, a.date, a.distance_m, a.points, u.username
FROM activities a JOIN users u ON u.id = a.user_id`
)
.all() as { id: number; name: string; points: string; username: string }[];
.all() as {
id: number;
name: string;
type: string;
date: string | null;
distance_m: number;
points: string;
username: string;
}[];
const tracks = rows.map((row) => {
const points = JSON.parse(row.points) as { lat: number; lon: number }[];
const step = Math.max(1, Math.floor(points.length / 300));
const latlngs = points
.filter((_, i) => i % step === 0 || i === points.length - 1)
.map((p) => [p.lat, p.lon] as [number, number]);
return { id: row.id, name: `${row.name} · ${row.username}`, latlngs };
return {
id: row.id,
name: row.name,
username: row.username,
type: row.type,
date: row.date,
distance_m: row.distance_m,
latlngs
};
});
const user = locals.user;
+21 -1
View File
@@ -1,10 +1,30 @@
import { listActivities } from '$lib/server/db';
import { db, listActivities } from '$lib/server/db';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ locals }) => {
const activities = listActivities(locals.user!.id);
const years = db
.prepare(
`SELECT substr(coalesce(date, substr(created_at, 1, 10)), 1, 4) year,
count(*) count,
sum(distance_m) distance_m,
sum(elev_gain_m) elev_gain_m,
sum(coalesce(moving_s, duration_s, 0)) moving_s
FROM activities WHERE user_id = ?
GROUP BY year ORDER BY year DESC`
)
.all(locals.user!.id) as {
year: string;
count: number;
distance_m: number;
elev_gain_m: number;
moving_s: number;
}[];
return {
activities,
years,
totals: {
count: activities.length,
distance_m: activities.reduce((sum, a) => sum + a.distance_m, 0),
+55
View File
@@ -21,6 +21,34 @@
<StatTile label="Total ascent" value={fmtElevation(data.totals.elev_gain_m)} />
<StatTile label="Moving time" value={fmtDuration(data.totals.moving_s)} />
</div>
{#if data.years.length > 1}
<h2>Per year</h2>
<div class="card year-table-wrap">
<table class="year-table">
<thead>
<tr>
<th>Year</th>
<th>Activities</th>
<th>Distance</th>
<th>Ascent</th>
<th>Moving time</th>
</tr>
</thead>
<tbody>
{#each data.years as y (y.year)}
<tr>
<td>{y.year}</td>
<td>{y.count}</td>
<td>{fmtDistance(y.distance_m)}</td>
<td>{fmtElevation(y.elev_gain_m)}</td>
<td>{fmtDuration(y.moving_s)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
{#if data.activities.length === 0}
@@ -43,6 +71,33 @@
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.year-table-wrap {
margin-bottom: 1.25rem;
overflow-x: auto;
}
.year-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
.year-table th {
text-align: left;
font-weight: 500;
font-size: 0.78rem;
color: var(--text-muted);
padding: 0.6rem 1rem 0.35rem;
border-bottom: 1px solid var(--border);
}
.year-table td {
padding: 0.5rem 1rem;
font-variant-numeric: tabular-nums;
}
.year-table tbody tr:not(:last-child) td {
border-bottom: 1px solid var(--border);
}
.year-table td:first-child {
font-weight: 600;
}
.list :global(.item:not(:last-child)) {
border-bottom: 1px solid var(--border);
}
+5 -1
View File
@@ -62,7 +62,11 @@
{/if}
<h2>Map</h2>
<Map tracks={[{ id: a.id, name: a.name, latlngs: data.latlngs }]} peaks={data.bagged} height="440px" />
<Map
tracks={[{ name: a.name, type: a.type, latlngs: data.latlngs }]}
peaks={data.bagged}
height="440px"
/>
{#if data.profile.length > 1}
<h2>Elevation profile</h2>