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, 'backcountry ski': 6,
'nordic ski': 6, 'nordic ski': 6,
snowboard: 6, snowboard: 6,
snowshoe: 6 snowshoe: 6,
segment: 7
}; };
function hash(s: string): number { function hash(s: string): number {
+46 -4
View File
@@ -41,6 +41,7 @@
highlightId = null, highlightId = null,
focus = null, focus = null,
hoverPoint = null, hoverPoint = null,
selection = null,
height = '100%', height = '100%',
onpeakclick, onpeakclick,
onviewport onviewport
@@ -50,6 +51,7 @@
highlightId?: number | null; highlightId?: number | null;
focus?: [[number, number], [number, number]] | null; focus?: [[number, number], [number, number]] | null;
hoverPoint?: { lat: number; lon: number } | null; hoverPoint?: { lat: number; lon: number } | null;
selection?: [number, number][] | null;
height?: string; height?: string;
onpeakclick?: (id: number) => void; onpeakclick?: (id: number) => void;
onviewport?: (view: Viewport) => void; onviewport?: (view: Viewport) => void;
@@ -103,7 +105,8 @@
date: track.date ?? '', date: track.date ?? '',
distance_m: track.distance_m ?? 0, distance_m: track.distance_m ?? 0,
color: dark ? color.dark : color.light, 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: { geometry: {
type: 'LineString' as const, type: 'LineString' as const,
@@ -188,6 +191,31 @@
hoverGeojson() 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 // animate the camera when a page requests a new focus; remember the
// unfocused camera so returning to the overview restores it instead of // unfocused camera so returning to the overview restores it instead of
@@ -258,8 +286,8 @@
layout: { 'line-join': 'round', 'line-cap': 'round' }, layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { paint: {
'line-color': dark ? '#1a1a19' : '#ffffff', 'line-color': dark ? '#1a1a19' : '#ffffff',
'line-width': 5, 'line-width': ['case', ['get', 'highlight'], 8, 5],
'line-opacity': ['case', ['get', 'dim'], 0.08, 0.6] 'line-opacity': ['case', ['get', 'dim'], 0.08, ['get', 'highlight'], 0.9, 0.6]
} }
}); });
map.addLayer({ map.addLayer({
@@ -269,7 +297,7 @@
layout: { 'line-join': 'round', 'line-cap': 'round' }, layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { paint: {
'line-color': ['get', 'color'], '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] '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 // chart-hover position marker, matching the chart's crosshair dot
map.addSource('hover-point', { type: 'geojson', data: hoverGeojson() }); map.addSource('hover-point', { type: 'geojson', data: hoverGeojson() });
map.addLayer({ map.addLayer({
+8
View File
@@ -11,6 +11,7 @@ let highlightId = $state<number | null>(null);
let focus = $state<Bounds | null>(null); let focus = $state<Bounds | null>(null);
let detailTrack = $state<MapTrack | null>(null); let detailTrack = $state<MapTrack | null>(null);
let hoverPoint = $state<{ lat: number; lon: number } | 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 carrier = $state<HTMLElement | null>(null);
let active = $state(false); let active = $state(false);
let wanted = $state(false); let wanted = $state(false);
@@ -37,6 +38,12 @@ export const mapState = {
get hoverPoint() { get hoverPoint() {
return hoverPoint; return hoverPoint;
}, },
get selection() {
return selection;
},
setSelection(latlngs: [number, number][] | null) {
selection = latlngs;
},
get carrier() { get carrier() {
return carrier; return carrier;
}, },
@@ -78,6 +85,7 @@ export const mapState = {
focus = null; focus = null;
detailTrack = null; detailTrack = null;
hoverPoint = null; hoverPoint = null;
selection = null;
peakClickHandler = null; peakClickHandler = null;
viewportHandler = null; viewportHandler = null;
}, },
+31
View File
@@ -495,6 +495,37 @@ export function deleteSegment(id: number, userId: number): boolean {
return result.changes > 0; 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 }[] { export function segmentEffortsForActivity(activityId: number): { segment_id: number; name: string; elapsed_s: number | null }[] {
return db return db
.prepare( .prepare(
+1
View File
@@ -168,6 +168,7 @@
highlightId={mapState.highlightId} highlightId={mapState.highlightId}
focus={mapState.focus} focus={mapState.focus}
hoverPoint={mapState.hoverPoint} hoverPoint={mapState.hoverPoint}
selection={mapState.selection}
onpeakclick={(id) => mapState.handlePeakClick(id)} onpeakclick={(id) => mapState.handlePeakClick(id)}
onviewport={(view) => mapState.handleViewport(view)} onviewport={(view) => mapState.handleViewport(view)}
/> />
+17
View File
@@ -32,6 +32,23 @@
segEnd = Math.round(a.distance_m); 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 // chart hover -> marker on the map at the matching track position
function pointAt(d: number): [number, number] { function pointAt(d: number): [number, number] {
const trackD = data.trackD; const trackD = data.trackD;
+2 -1
View File
@@ -1,5 +1,5 @@
import { error, redirect } from '@sveltejs/kit'; 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 { Actions, PageServerLoad } from './$types';
import type { TrackPt } from '$lib/server/segments'; import type { TrackPt } from '$lib/server/segments';
@@ -12,6 +12,7 @@ export const load: PageServerLoad = ({ params, locals }) => {
segment: { ...segment, points: undefined }, segment: { ...segment, points: undefined },
latlngs: points.map((p) => [p.lat, p.lon] as [number, number]), latlngs: points.map((p) => [p.lat, p.lon] as [number, number]),
leaderboard: segmentLeaderboard(segment.id), leaderboard: segmentLeaderboard(segment.id),
efforts: segmentAllEfforts(segment.id),
myUserId: locals.user?.id ?? null, myUserId: locals.user?.id ?? null,
canDelete: canDelete:
!!locals.user && (segment.creator_id === locals.user.id || segment.creator_id === null) !!locals.user && (segment.creator_id === locals.user.id || segment.creator_id === null)
+21
View File
@@ -83,6 +83,27 @@
</div> </div>
{/if} {/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> <style>
.back { .back {
display: inline-block; display: inline-block;