diff --git a/src/lib/activity-colors.ts b/src/lib/activity-colors.ts index 8eea9f5..c9e28bb 100644 --- a/src/lib/activity-colors.ts +++ b/src/lib/activity-colors.ts @@ -34,7 +34,8 @@ const TYPE_SLOT: Record = { 'backcountry ski': 6, 'nordic ski': 6, snowboard: 6, - snowshoe: 6 + snowshoe: 6, + segment: 7 }; function hash(s: string): number { diff --git a/src/lib/components/Map.svelte b/src/lib/components/Map.svelte index a220247..d79ee04 100644 --- a/src/lib/components/Map.svelte +++ b/src/lib/components/Map.svelte @@ -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({ diff --git a/src/lib/map-state.svelte.ts b/src/lib/map-state.svelte.ts index 3255b52..1baf1ad 100644 --- a/src/lib/map-state.svelte.ts +++ b/src/lib/map-state.svelte.ts @@ -11,6 +11,7 @@ let highlightId = $state(null); let focus = $state(null); let detailTrack = $state(null); let hoverPoint = $state<{ lat: number; lon: number } | null>(null); +let selection = $state<[number, number][] | null>(null); let carrier = $state(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; }, diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index b2f85bd..9945ffe 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -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( diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 0caf84a..f013f74 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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)} /> diff --git a/src/routes/activities/[id]/+page.svelte b/src/routes/activities/[id]/+page.svelte index 98f838f..bfd9ea0 100644 --- a/src/routes/activities/[id]/+page.svelte +++ b/src/routes/activities/[id]/+page.svelte @@ -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; diff --git a/src/routes/segments/[id]/+page.server.ts b/src/routes/segments/[id]/+page.server.ts index 172338f..391ab96 100644 --- a/src/routes/segments/[id]/+page.server.ts +++ b/src/routes/segments/[id]/+page.server.ts @@ -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) diff --git a/src/routes/segments/[id]/+page.svelte b/src/routes/segments/[id]/+page.svelte index 90077a5..e98a492 100644 --- a/src/routes/segments/[id]/+page.svelte +++ b/src/routes/segments/[id]/+page.svelte @@ -83,6 +83,27 @@ {/if} +{#if data.efforts.length > 0} +

All efforts ({data.efforts.length})

+
+ + + + + + {#each data.efforts as effort (effort.id)} + + + + + + + {/each} + +
AthleteTimeDateActivity
{effort.username}{effort.elapsed_s !== null ? fmtElapsed(effort.elapsed_s) : '–'}{effort.date ? fmtDate(effort.date) : '–'}{effort.activity_name}
+
+{/if} +