track info pane, per-type colors, yearly stats
This commit is contained in:
+24
@@ -20,6 +20,14 @@
|
|||||||
--logo-wash-2: #96d6c6;
|
--logo-wash-2: #96d6c6;
|
||||||
--logo-text: #56555a;
|
--logo-text: #56555a;
|
||||||
--map-icon-filter: none;
|
--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),
|
/* dark tokens apply when the OS prefers dark (unless the user forced light),
|
||||||
@@ -46,6 +54,14 @@
|
|||||||
--logo-wash-2: #1e6355;
|
--logo-wash-2: #1e6355;
|
||||||
--logo-text: #eef2f0;
|
--logo-text: #eef2f0;
|
||||||
--map-icon-filter: invert(1) brightness(1.1);
|
--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'] {
|
:root[data-theme='dark'] {
|
||||||
@@ -69,6 +85,14 @@
|
|||||||
--logo-wash-2: #1e6355;
|
--logo-wash-2: #1e6355;
|
||||||
--logo-text: #eef2f0;
|
--logo-text: #eef2f0;
|
||||||
--map-icon-filter: invert(1) brightness(1.1);
|
--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;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|||||||
@@ -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)];
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fmtDate, fmtDistance, fmtDuration, fmtElevation } from '$lib/format';
|
import { fmtDate, fmtDistance, fmtDuration, fmtElevation } from '$lib/format';
|
||||||
|
import { typeSlot } from '$lib/activity-colors';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
activity
|
activity
|
||||||
@@ -21,7 +22,7 @@
|
|||||||
<a class="item" href="/activities/{activity.id}">
|
<a class="item" href="/activities/{activity.id}">
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<span class="name">{activity.name}</span>
|
<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>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
{#if activity.username}
|
{#if activity.username}
|
||||||
@@ -61,9 +62,9 @@
|
|||||||
}
|
}
|
||||||
.type {
|
.type {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: var(--accent-strong);
|
color: var(--tc, var(--accent-strong));
|
||||||
background: var(--accent-wash);
|
background: color-mix(in srgb, var(--tc, var(--accent)) 13%, transparent);
|
||||||
padding: 0.1rem 0.5rem;
|
padding: 0.1rem 0.5rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
|
|||||||
@@ -2,11 +2,17 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
import { theme } from '$lib/theme.svelte';
|
import { theme } from '$lib/theme.svelte';
|
||||||
|
import { typeColor } from '$lib/activity-colors';
|
||||||
|
import { fmtDate, fmtDistance } from '$lib/format';
|
||||||
import type { FeatureCollection, Point } from 'geojson';
|
import type { FeatureCollection, Point } from 'geojson';
|
||||||
|
|
||||||
export interface MapTrack {
|
export interface MapTrack {
|
||||||
id?: number;
|
id?: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
username?: string;
|
||||||
|
type?: string;
|
||||||
|
date?: string | null;
|
||||||
|
distance_m?: number;
|
||||||
latlngs: [number, number][];
|
latlngs: [number, number][];
|
||||||
}
|
}
|
||||||
export interface MapPeak {
|
export interface MapPeak {
|
||||||
@@ -115,14 +121,25 @@
|
|||||||
type: 'geojson',
|
type: 'geojson',
|
||||||
data: {
|
data: {
|
||||||
type: 'FeatureCollection',
|
type: 'FeatureCollection',
|
||||||
features: tracks.map((track) => ({
|
features: tracks.map((track) => {
|
||||||
type: 'Feature',
|
const color = typeColor(track.type ?? 'outdoor');
|
||||||
properties: { name: track.name ?? '' },
|
return {
|
||||||
geometry: {
|
type: 'Feature' as const,
|
||||||
type: 'LineString',
|
properties: {
|
||||||
coordinates: track.latlngs.map(([lat, lon]) => [lon, lat])
|
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({
|
map.addLayer({
|
||||||
@@ -137,7 +154,15 @@
|
|||||||
type: 'line',
|
type: 'line',
|
||||||
source: 'tracks',
|
source: 'tracks',
|
||||||
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
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
|
// peaks as native layers - scales to thousands of points
|
||||||
@@ -231,6 +256,53 @@
|
|||||||
popup.remove();
|
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) {
|
if (onviewport) {
|
||||||
const report = () => {
|
const report = () => {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
@@ -313,4 +385,37 @@
|
|||||||
border-top-color: var(--surface-1);
|
border-top-color: var(--surface-1);
|
||||||
border-bottom-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>
|
</style>
|
||||||
|
|||||||
@@ -5,17 +5,33 @@ export const load: PageServerLoad = ({ locals }) => {
|
|||||||
// every user's tracks, thinned for the overview map
|
// every user's tracks, thinned for the overview map
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare(
|
.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`
|
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 tracks = rows.map((row) => {
|
||||||
const points = JSON.parse(row.points) as { lat: number; lon: number }[];
|
const points = JSON.parse(row.points) as { lat: number; lon: number }[];
|
||||||
const step = Math.max(1, Math.floor(points.length / 300));
|
const step = Math.max(1, Math.floor(points.length / 300));
|
||||||
const latlngs = points
|
const latlngs = points
|
||||||
.filter((_, i) => i % step === 0 || i === points.length - 1)
|
.filter((_, i) => i % step === 0 || i === points.length - 1)
|
||||||
.map((p) => [p.lat, p.lon] as [number, number]);
|
.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;
|
const user = locals.user;
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
import { listActivities } from '$lib/server/db';
|
import { db, listActivities } from '$lib/server/db';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = ({ locals }) => {
|
export const load: PageServerLoad = ({ locals }) => {
|
||||||
const activities = listActivities(locals.user!.id);
|
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 {
|
return {
|
||||||
activities,
|
activities,
|
||||||
|
years,
|
||||||
totals: {
|
totals: {
|
||||||
count: activities.length,
|
count: activities.length,
|
||||||
distance_m: activities.reduce((sum, a) => sum + a.distance_m, 0),
|
distance_m: activities.reduce((sum, a) => sum + a.distance_m, 0),
|
||||||
|
|||||||
@@ -21,6 +21,34 @@
|
|||||||
<StatTile label="Total ascent" value={fmtElevation(data.totals.elev_gain_m)} />
|
<StatTile label="Total ascent" value={fmtElevation(data.totals.elev_gain_m)} />
|
||||||
<StatTile label="Moving time" value={fmtDuration(data.totals.moving_s)} />
|
<StatTile label="Moving time" value={fmtDuration(data.totals.moving_s)} />
|
||||||
</div>
|
</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}
|
||||||
|
|
||||||
{#if data.activities.length === 0}
|
{#if data.activities.length === 0}
|
||||||
@@ -43,6 +71,33 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin-bottom: 1.25rem;
|
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)) {
|
.list :global(.item:not(:last-child)) {
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<h2>Map</h2>
|
<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}
|
{#if data.profile.length > 1}
|
||||||
<h2>Elevation profile</h2>
|
<h2>Elevation profile</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user