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,
|
'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 {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user