segment highlight, all-efforts table, live segment selection preview

This commit is contained in:
Vincent van der Wal
2026-07-22 17:15:25 +02:00
parent 2c82445291
commit e59340d33d
8 changed files with 128 additions and 6 deletions
+2 -1
View File
@@ -34,7 +34,8 @@ const TYPE_SLOT: Record<string, number> = {
'backcountry ski': 6,
'nordic ski': 6,
snowboard: 6,
snowshoe: 6
snowshoe: 6,
segment: 7
};
function hash(s: string): number {
+46 -4
View File
@@ -41,6 +41,7 @@
highlightId = null,
focus = null,
hoverPoint = null,
selection = null,
height = '100%',
onpeakclick,
onviewport
@@ -50,6 +51,7 @@
highlightId?: number | null;
focus?: [[number, number], [number, number]] | null;
hoverPoint?: { lat: number; lon: number } | null;
selection?: [number, number][] | null;
height?: string;
onpeakclick?: (id: number) => void;
onviewport?: (view: Viewport) => void;
@@ -103,7 +105,8 @@
date: track.date ?? '',
distance_m: track.distance_m ?? 0,
color: dark ? color.dark : color.light,
dim: highlightId !== null && track.id !== highlightId
dim: highlightId !== null && track.id !== highlightId,
highlight: highlightId !== null && track.id === highlightId
},
geometry: {
type: 'LineString' as const,
@@ -188,6 +191,31 @@
hoverGeojson()
);
});
function selectionGeojson(): FeatureCollection {
return {
type: 'FeatureCollection',
features:
selection && selection.length > 1
? [
{
type: 'Feature',
properties: {},
geometry: {
type: 'LineString',
coordinates: selection.map(([lat, lon]) => [lon, lat])
}
}
]
: []
};
}
$effect(() => {
void selection;
if (!loaded) return;
(map?.getSource('selection') as import('maplibre-gl').GeoJSONSource | undefined)?.setData(
selectionGeojson()
);
});
// animate the camera when a page requests a new focus; remember the
// unfocused camera so returning to the overview restores it instead of
@@ -258,8 +286,8 @@
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: {
'line-color': dark ? '#1a1a19' : '#ffffff',
'line-width': 5,
'line-opacity': ['case', ['get', 'dim'], 0.08, 0.6]
'line-width': ['case', ['get', 'highlight'], 8, 5],
'line-opacity': ['case', ['get', 'dim'], 0.08, ['get', 'highlight'], 0.9, 0.6]
}
});
map.addLayer({
@@ -269,7 +297,7 @@
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: {
'line-color': ['get', 'color'],
'line-width': 2.5,
'line-width': ['case', ['get', 'highlight'], 4.5, 2.5],
'line-opacity': ['case', ['get', 'dim'], 0.15, 1]
}
});
@@ -310,6 +338,20 @@
}
});
// live preview of a segment being created (accent, on top of tracks)
map.addSource('selection', { type: 'geojson', data: selectionGeojson() });
map.addLayer({
id: 'selection-line',
type: 'line',
source: 'selection',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: {
'line-color': dark ? '#e66767' : '#e34948',
'line-width': 5,
'line-opacity': 0.95
}
});
// chart-hover position marker, matching the chart's crosshair dot
map.addSource('hover-point', { type: 'geojson', data: hoverGeojson() });
map.addLayer({
+8
View File
@@ -11,6 +11,7 @@ let highlightId = $state<number | null>(null);
let focus = $state<Bounds | null>(null);
let detailTrack = $state<MapTrack | null>(null);
let hoverPoint = $state<{ lat: number; lon: number } | null>(null);
let selection = $state<[number, number][] | null>(null);
let carrier = $state<HTMLElement | null>(null);
let active = $state(false);
let wanted = $state(false);
@@ -37,6 +38,12 @@ export const mapState = {
get hoverPoint() {
return hoverPoint;
},
get selection() {
return selection;
},
setSelection(latlngs: [number, number][] | null) {
selection = latlngs;
},
get carrier() {
return carrier;
},
@@ -78,6 +85,7 @@ export const mapState = {
focus = null;
detailTrack = null;
hoverPoint = null;
selection = null;
peakClickHandler = null;
viewportHandler = null;
},
+31
View File
@@ -495,6 +495,37 @@ export function deleteSegment(id: number, userId: number): boolean {
return result.changes > 0;
}
/** Every effort on a segment, fastest first (untimed last, by date). */
export function segmentAllEfforts(segmentId: number): {
id: number;
user_id: number;
username: string;
activity_id: number;
activity_name: string;
elapsed_s: number | null;
date: string | null;
}[] {
return db
.prepare(
`SELECT e.id, e.user_id, u.username, e.activity_id, a.name activity_name,
e.elapsed_s, e.date
FROM segment_efforts e
JOIN users u ON u.id = e.user_id
JOIN activities a ON a.id = e.activity_id
WHERE e.segment_id = ?
ORDER BY e.elapsed_s IS NULL, e.elapsed_s ASC, e.date DESC`
)
.all(segmentId) as {
id: number;
user_id: number;
username: string;
activity_id: number;
activity_name: string;
elapsed_s: number | null;
date: string | null;
}[];
}
export function segmentEffortsForActivity(activityId: number): { segment_id: number; name: string; elapsed_s: number | null }[] {
return db
.prepare(
+1
View File
@@ -168,6 +168,7 @@
highlightId={mapState.highlightId}
focus={mapState.focus}
hoverPoint={mapState.hoverPoint}
selection={mapState.selection}
onpeakclick={(id) => mapState.handlePeakClick(id)}
onviewport={(view) => mapState.handleViewport(view)}
/>
+17
View File
@@ -32,6 +32,23 @@
segEnd = Math.round(a.distance_m);
});
// live preview of the selected slice on the map
$effect(() => {
if (!segmenting) {
mapState.setSelection(null);
return;
}
const from = Math.min(segStart, segEnd);
const to = Math.max(segStart, segEnd);
const slice: [number, number][] = [];
for (let i = 0; i < data.trackD.length; i++) {
if (data.trackD[i] >= from && data.trackD[i] <= to)
slice.push(data.latlngs[i]);
}
mapState.setSelection(slice);
return () => mapState.setSelection(null);
});
// chart hover -> marker on the map at the matching track position
function pointAt(d: number): [number, number] {
const trackD = data.trackD;
+2 -1
View File
@@ -1,5 +1,5 @@
import { error, redirect } from '@sveltejs/kit';
import { deleteSegment, getSegment, segmentLeaderboard } from '$lib/server/db';
import { deleteSegment, getSegment, segmentAllEfforts, segmentLeaderboard } from '$lib/server/db';
import type { Actions, PageServerLoad } from './$types';
import type { TrackPt } from '$lib/server/segments';
@@ -12,6 +12,7 @@ export const load: PageServerLoad = ({ params, locals }) => {
segment: { ...segment, points: undefined },
latlngs: points.map((p) => [p.lat, p.lon] as [number, number]),
leaderboard: segmentLeaderboard(segment.id),
efforts: segmentAllEfforts(segment.id),
myUserId: locals.user?.id ?? null,
canDelete:
!!locals.user && (segment.creator_id === locals.user.id || segment.creator_id === null)
+21
View File
@@ -83,6 +83,27 @@
</div>
{/if}
{#if data.efforts.length > 0}
<h2>All efforts ({data.efforts.length})</h2>
<div class="card table-wrap">
<table>
<thead>
<tr><th>Athlete</th><th>Time</th><th>Date</th><th>Activity</th></tr>
</thead>
<tbody>
{#each data.efforts as effort (effort.id)}
<tr class:me={effort.user_id === data.myUserId}>
<td><a href="/users/{effort.username}">{effort.username}</a></td>
<td class="time">{effort.elapsed_s !== null ? fmtElapsed(effort.elapsed_s) : ''}</td>
<td>{effort.date ? fmtDate(effort.date) : ''}</td>
<td><a href="/activities/{effort.activity_id}">{effort.activity_name}</a></td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<style>
.back {
display: inline-block;