segment highlight, all-efforts table, live segment selection preview
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user