track info pane, per-type colors, yearly stats
This commit is contained in:
+24
@@ -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;
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@@ -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">
|
||||
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;
|
||||
|
||||
@@ -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 ?? '' },
|
||||
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',
|
||||
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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user