editable activity name and type, year filter, chart-map hover sync
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let { profile }: { profile: { d: number; ele: number }[] } = $props();
|
let {
|
||||||
|
profile,
|
||||||
|
onhover,
|
||||||
|
onselect
|
||||||
|
}: {
|
||||||
|
profile: { d: number; ele: number }[];
|
||||||
|
onhover?: (d: number | null) => void;
|
||||||
|
onselect?: (d: number) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let width = $state(720);
|
let width = $state(720);
|
||||||
const height = 220;
|
const height = 220;
|
||||||
@@ -56,6 +64,7 @@
|
|||||||
else hi = mid;
|
else hi = mid;
|
||||||
}
|
}
|
||||||
hover = target - profile[lo].d < profile[hi].d - target ? profile[lo] : profile[hi];
|
hover = target - profile[lo].d < profile[hi].d - target ? profile[lo] : profile[hi];
|
||||||
|
onhover?.(hover.d);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tooltipLeft = $derived(hover ? Math.min(Math.max(x(hover.d), 70), width - 70) : 0);
|
const tooltipLeft = $derived(hover ? Math.min(Math.max(x(hover.d), 70), width - 70) : 0);
|
||||||
@@ -95,7 +104,11 @@
|
|||||||
height={height - pad.top - pad.bottom}
|
height={height - pad.top - pad.bottom}
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
onpointermove={onmove}
|
onpointermove={onmove}
|
||||||
onpointerleave={() => (hover = null)}
|
onclick={() => hover && onselect?.(hover.d)}
|
||||||
|
onpointerleave={() => {
|
||||||
|
hover = null;
|
||||||
|
onhover?.(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{#if hover}
|
{#if hover}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
peaks = [],
|
peaks = [],
|
||||||
highlightId = null,
|
highlightId = null,
|
||||||
focus = null,
|
focus = null,
|
||||||
|
hoverPoint = null,
|
||||||
height = '100%',
|
height = '100%',
|
||||||
onpeakclick,
|
onpeakclick,
|
||||||
onviewport
|
onviewport
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
peaks?: MapPeak[];
|
peaks?: MapPeak[];
|
||||||
highlightId?: number | null;
|
highlightId?: number | null;
|
||||||
focus?: [[number, number], [number, number]] | null;
|
focus?: [[number, number], [number, number]] | null;
|
||||||
|
hoverPoint?: { lat: number; lon: number } | null;
|
||||||
height?: string;
|
height?: string;
|
||||||
onpeakclick?: (id: number) => void;
|
onpeakclick?: (id: number) => void;
|
||||||
onviewport?: (view: Viewport) => void;
|
onviewport?: (view: Viewport) => void;
|
||||||
@@ -162,18 +164,54 @@
|
|||||||
tracksGeojson(currentDark ?? false)
|
tracksGeojson(currentDark ?? false)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
function hoverGeojson(): FeatureCollection {
|
||||||
// animate the camera when a page requests a new focus
|
// the marker wears the highlighted activity's type color
|
||||||
let lastFocusKey: string | undefined;
|
const track = highlightId !== null ? tracks.find((t) => t.id === highlightId) : undefined;
|
||||||
|
const color = typeColor(track?.type ?? 'outdoor');
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: hoverPoint
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: 'Feature',
|
||||||
|
properties: { color: currentDark ? color.dark : color.light },
|
||||||
|
geometry: { type: 'Point', coordinates: [hoverPoint.lon, hoverPoint.lat] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
};
|
||||||
|
}
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const key = JSON.stringify(focus);
|
void hoverPoint;
|
||||||
if (!loaded) return;
|
if (!loaded) return;
|
||||||
|
(map?.getSource('hover-point') as import('maplibre-gl').GeoJSONSource | undefined)?.setData(
|
||||||
|
hoverGeojson()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// animate the camera when a page requests a new focus; remember the
|
||||||
|
// unfocused camera so returning to the overview restores it instead of
|
||||||
|
// zooming all the way back out
|
||||||
|
let lastFocusKey: string | undefined;
|
||||||
|
let savedCamera: { center: import('maplibre-gl').LngLat; zoom: number } | null = null;
|
||||||
|
$effect(() => {
|
||||||
|
const focused = focus !== null;
|
||||||
|
const key = JSON.stringify(focus);
|
||||||
|
if (!loaded || !map) return;
|
||||||
if (lastFocusKey === undefined) {
|
if (lastFocusKey === undefined) {
|
||||||
lastFocusKey = key;
|
lastFocusKey = key;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key !== lastFocusKey) {
|
if (key === lastFocusKey) return;
|
||||||
lastFocusKey = key;
|
const wasFocused = lastFocusKey !== 'null';
|
||||||
|
lastFocusKey = key;
|
||||||
|
if (focused) {
|
||||||
|
if (!wasFocused) savedCamera = { center: map.getCenter(), zoom: map.getZoom() };
|
||||||
|
applyCamera(true);
|
||||||
|
} else if (savedCamera) {
|
||||||
|
map.flyTo({ center: savedCamera.center, zoom: savedCamera.zoom, duration: 1100 });
|
||||||
|
savedCamera = null;
|
||||||
|
} else {
|
||||||
applyCamera(true);
|
applyCamera(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -272,6 +310,20 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// chart-hover position marker, matching the chart's crosshair dot
|
||||||
|
map.addSource('hover-point', { type: 'geojson', data: hoverGeojson() });
|
||||||
|
map.addLayer({
|
||||||
|
id: 'hover-point',
|
||||||
|
type: 'circle',
|
||||||
|
source: 'hover-point',
|
||||||
|
paint: {
|
||||||
|
'circle-radius': 7,
|
||||||
|
'circle-color': ['get', 'color'],
|
||||||
|
'circle-stroke-width': 2.5,
|
||||||
|
'circle-stroke-color': dark ? '#1a1a19' : '#ffffff'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// restore 3D terrain across style swaps
|
// restore 3D terrain across style swaps
|
||||||
if (pendingTerrain) {
|
if (pendingTerrain) {
|
||||||
map.setTerrain(pendingTerrain as import('maplibre-gl').TerrainSpecification);
|
map.setTerrain(pendingTerrain as import('maplibre-gl').TerrainSpecification);
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
let { timed }: { timed: { d: number; t: number }[] } = $props();
|
let {
|
||||||
|
timed,
|
||||||
|
onhover,
|
||||||
|
onselect
|
||||||
|
}: {
|
||||||
|
timed: { d: number; t: number }[];
|
||||||
|
onhover?: (d: number | null) => void;
|
||||||
|
onselect?: (d: number) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
const WINDOWS = [
|
const WINDOWS = [
|
||||||
{ label: '10 s', w: 10 },
|
{ label: '10 s', w: 10 },
|
||||||
@@ -88,6 +96,7 @@
|
|||||||
else hi = mid;
|
else hi = mid;
|
||||||
}
|
}
|
||||||
hover = target - series[lo].d < series[hi].d - target ? series[lo] : series[hi];
|
hover = target - series[lo].d < series[hi].d - target ? series[lo] : series[hi];
|
||||||
|
onhover?.(hover.d);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tooltipLeft = $derived(hover ? Math.min(Math.max(x(hover.d), 70), width - 70) : 0);
|
const tooltipLeft = $derived(hover ? Math.min(Math.max(x(hover.d), 70), width - 70) : 0);
|
||||||
@@ -134,7 +143,11 @@
|
|||||||
height={height - pad.top - pad.bottom}
|
height={height - pad.top - pad.bottom}
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
onpointermove={onmove}
|
onpointermove={onmove}
|
||||||
onpointerleave={() => (hover = null)}
|
onclick={() => hover && onselect?.(hover.d)}
|
||||||
|
onpointerleave={() => {
|
||||||
|
hover = null;
|
||||||
|
onhover?.(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{#if hover}
|
{#if hover}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ let peaks = $state<MapPeak[]>([]);
|
|||||||
let highlightId = $state<number | null>(null);
|
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 carrier = $state<HTMLElement | null>(null);
|
let carrier = $state<HTMLElement | null>(null);
|
||||||
let active = $state(false);
|
let active = $state(false);
|
||||||
|
|
||||||
@@ -32,6 +33,9 @@ export const mapState = {
|
|||||||
get detailTrack() {
|
get detailTrack() {
|
||||||
return detailTrack;
|
return detailTrack;
|
||||||
},
|
},
|
||||||
|
get hoverPoint() {
|
||||||
|
return hoverPoint;
|
||||||
|
},
|
||||||
get carrier() {
|
get carrier() {
|
||||||
return carrier;
|
return carrier;
|
||||||
},
|
},
|
||||||
@@ -51,6 +55,9 @@ export const mapState = {
|
|||||||
setDetailTrack(track: MapTrack | null) {
|
setDetailTrack(track: MapTrack | null) {
|
||||||
detailTrack = track;
|
detailTrack = track;
|
||||||
},
|
},
|
||||||
|
setHoverPoint(point: { lat: number; lon: number } | null) {
|
||||||
|
hoverPoint = point;
|
||||||
|
},
|
||||||
setHandlers(handlers: {
|
setHandlers(handlers: {
|
||||||
onpeakclick?: (id: number) => void;
|
onpeakclick?: (id: number) => void;
|
||||||
onviewport?: (view: Viewport) => void;
|
onviewport?: (view: Viewport) => void;
|
||||||
@@ -63,6 +70,7 @@ export const mapState = {
|
|||||||
highlightId = null;
|
highlightId = null;
|
||||||
focus = null;
|
focus = null;
|
||||||
detailTrack = null;
|
detailTrack = null;
|
||||||
|
hoverPoint = null;
|
||||||
peakClickHandler = null;
|
peakClickHandler = null;
|
||||||
viewportHandler = null;
|
viewportHandler = null;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -152,6 +152,7 @@
|
|||||||
peaks={mapState.peaks}
|
peaks={mapState.peaks}
|
||||||
highlightId={mapState.highlightId}
|
highlightId={mapState.highlightId}
|
||||||
focus={mapState.focus}
|
focus={mapState.focus}
|
||||||
|
hoverPoint={mapState.hoverPoint}
|
||||||
onpeakclick={(id) => mapState.handlePeakClick(id)}
|
onpeakclick={(id) => mapState.handlePeakClick(id)}
|
||||||
onviewport={(view) => mapState.handleViewport(view)}
|
onviewport={(view) => mapState.handleViewport(view)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,6 +4,29 @@
|
|||||||
import { fmtDistance, fmtDuration, fmtElevation } from '$lib/format';
|
import { fmtDistance, fmtDuration, fmtElevation } from '$lib/format';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
|
let selectedYear = $state<string | null>(null);
|
||||||
|
|
||||||
|
function activityYear(a: { date: string | null; created_at: string }): string {
|
||||||
|
return (a.date ?? a.created_at).slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = $derived(
|
||||||
|
selectedYear === null
|
||||||
|
? data.activities
|
||||||
|
: data.activities.filter((a) => activityYear(a) === selectedYear)
|
||||||
|
);
|
||||||
|
const shown = $derived({
|
||||||
|
count: filtered.length,
|
||||||
|
distance_m: filtered.reduce((sum, a) => sum + a.distance_m, 0),
|
||||||
|
elev_gain_m: filtered.reduce((sum, a) => sum + a.elev_gain_m, 0),
|
||||||
|
moving_s: filtered.reduce((sum, a) => sum + (a.moving_s ?? a.duration_s ?? 0), 0)
|
||||||
|
});
|
||||||
|
const suffix = $derived(selectedYear === null ? '' : ` (${selectedYear})`);
|
||||||
|
|
||||||
|
function pickYear(year: string) {
|
||||||
|
selectedYear = selectedYear === year ? null : year;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -12,14 +35,17 @@
|
|||||||
|
|
||||||
<h1>Your activities</h1>
|
<h1>Your activities</h1>
|
||||||
<p class="page-sub">
|
<p class="page-sub">
|
||||||
{data.activities.length} recorded {data.activities.length === 1 ? 'activity' : 'activities'}.
|
{shown.count} recorded {shown.count === 1 ? 'activity' : 'activities'}{suffix}.
|
||||||
|
{#if selectedYear}
|
||||||
|
<button class="clear-year" onclick={() => (selectedYear = null)}>Show all years</button>
|
||||||
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if data.activities.length > 0}
|
{#if data.activities.length > 0}
|
||||||
<div class="kpis">
|
<div class="kpis">
|
||||||
<StatTile label="Total distance" value={fmtDistance(data.totals.distance_m)} />
|
<StatTile label="Distance{suffix}" value={fmtDistance(shown.distance_m)} />
|
||||||
<StatTile label="Total ascent" value={fmtElevation(data.totals.elev_gain_m)} />
|
<StatTile label="Ascent{suffix}" value={fmtElevation(shown.elev_gain_m)} />
|
||||||
<StatTile label="Moving time" value={fmtDuration(data.totals.moving_s)} />
|
<StatTile label="Moving time{suffix}" value={fmtDuration(shown.moving_s)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if data.years.length > 1}
|
{#if data.years.length > 1}
|
||||||
@@ -37,7 +63,12 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each data.years as y (y.year)}
|
{#each data.years as y (y.year)}
|
||||||
<tr>
|
<tr
|
||||||
|
class="year-row"
|
||||||
|
class:on={selectedYear === y.year}
|
||||||
|
onclick={() => pickYear(y.year)}
|
||||||
|
title={selectedYear === y.year ? 'Show all years' : `Show only ${y.year}`}
|
||||||
|
>
|
||||||
<td>{y.year}</td>
|
<td>{y.year}</td>
|
||||||
<td>{y.count}</td>
|
<td>{y.count}</td>
|
||||||
<td>{fmtDistance(y.distance_m)}</td>
|
<td>{fmtDistance(y.distance_m)}</td>
|
||||||
@@ -51,14 +82,18 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if data.activities.length === 0}
|
{#if filtered.length === 0 && data.activities.length > 0}
|
||||||
|
<div class="card empty">
|
||||||
|
<p>No activities in {selectedYear}.</p>
|
||||||
|
</div>
|
||||||
|
{:else if data.activities.length === 0}
|
||||||
<div class="card empty">
|
<div class="card empty">
|
||||||
<p>No activities yet.</p>
|
<p>No activities yet.</p>
|
||||||
<a class="btn" href="/upload">Upload a GPX file</a>
|
<a class="btn" href="/upload">Upload a GPX file</a>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="card list">
|
<div class="card list">
|
||||||
{#each data.activities as activity (activity.id)}
|
{#each filtered as activity (activity.id)}
|
||||||
<ActivityItem {activity} />
|
<ActivityItem {activity} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -98,6 +133,29 @@
|
|||||||
.year-table td:first-child {
|
.year-table td:first-child {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
.year-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.year-row:hover td {
|
||||||
|
background: var(--wash);
|
||||||
|
}
|
||||||
|
.year-row.on td {
|
||||||
|
background: var(--accent-wash);
|
||||||
|
}
|
||||||
|
.year-row.on td:first-child {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
.clear-year {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
.list :global(.item:not(:last-child)) {
|
.list :global(.item:not(:last-child)) {
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { error, redirect } from '@sveltejs/kit';
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
import { deleteActivity, getActivity, reachedPeaks } from '$lib/server/db';
|
import { db, deleteActivity, getActivity, reachedPeaks } from '$lib/server/db';
|
||||||
import { haversine } from '$lib/server/gpx';
|
import { haversine } from '$lib/server/gpx';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
@@ -16,14 +16,17 @@ export const load: PageServerLoad = ({ params, locals }) => {
|
|||||||
|
|
||||||
const latlngs = points.map((p) => [p.lat, p.lon] as [number, number]);
|
const latlngs = points.map((p) => [p.lat, p.lon] as [number, number]);
|
||||||
|
|
||||||
// cumulative-distance profiles for the elevation and speed charts
|
// cumulative-distance profiles for the elevation and speed charts,
|
||||||
|
// plus per-point distance so chart hover can find the map position
|
||||||
const profile: { d: number; ele: number }[] = [];
|
const profile: { d: number; ele: number }[] = [];
|
||||||
const timed: { d: number; t: number }[] = [];
|
const timed: { d: number; t: number }[] = [];
|
||||||
|
const trackD: number[] = [];
|
||||||
let dist = 0;
|
let dist = 0;
|
||||||
for (let i = 0; i < points.length; i++) {
|
for (let i = 0; i < points.length; i++) {
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
dist += haversine(points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon);
|
dist += haversine(points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon);
|
||||||
}
|
}
|
||||||
|
trackD.push(dist);
|
||||||
if (points[i].ele !== null) profile.push({ d: dist, ele: points[i].ele! });
|
if (points[i].ele !== null) profile.push({ d: dist, ele: points[i].ele! });
|
||||||
if (points[i].t !== null) timed.push({ d: dist, t: points[i].t! });
|
if (points[i].t !== null) timed.push({ d: dist, t: points[i].t! });
|
||||||
}
|
}
|
||||||
@@ -34,6 +37,7 @@ export const load: PageServerLoad = ({ params, locals }) => {
|
|||||||
activity: { ...activity, points: undefined },
|
activity: { ...activity, points: undefined },
|
||||||
canDelete: locals.user?.id === activity.user_id,
|
canDelete: locals.user?.id === activity.user_id,
|
||||||
latlngs,
|
latlngs,
|
||||||
|
trackD,
|
||||||
profile,
|
profile,
|
||||||
timed: timed.length > 2 ? timed : [],
|
timed: timed.length > 2 ? timed : [],
|
||||||
bagged: bagged.map((p) => ({
|
bagged: bagged.map((p) => ({
|
||||||
@@ -52,5 +56,20 @@ export const actions: Actions = {
|
|||||||
if (!locals.user) error(401, 'Not signed in');
|
if (!locals.user) error(401, 'Not signed in');
|
||||||
deleteActivity(Number(params.id), locals.user.id);
|
deleteActivity(Number(params.id), locals.user.id);
|
||||||
redirect(303, '/activities');
|
redirect(303, '/activities');
|
||||||
|
},
|
||||||
|
update: async ({ params, locals, request }) => {
|
||||||
|
if (!locals.user) error(401, 'Not signed in');
|
||||||
|
const form = await request.formData();
|
||||||
|
const name = String(form.get('name') ?? '').trim().slice(0, 120);
|
||||||
|
const type = String(form.get('type') ?? '').trim().toLowerCase().slice(0, 40);
|
||||||
|
if (!name) return fail(400, { error: 'Name cannot be empty.' });
|
||||||
|
if (!type) return fail(400, { error: 'Type cannot be empty.' });
|
||||||
|
db.prepare('UPDATE activities SET name = ?, type = ? WHERE id = ? AND user_id = ?').run(
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
Number(params.id),
|
||||||
|
locals.user.id
|
||||||
|
);
|
||||||
|
return { updated: true };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,13 +7,44 @@
|
|||||||
import { mapState } from '$lib/map-state.svelte';
|
import { mapState } from '$lib/map-state.svelte';
|
||||||
import { fmtDate, fmtDistance, fmtDuration, fmtElevation, fmtSpeed } from '$lib/format';
|
import { fmtDate, fmtDistance, fmtDuration, fmtElevation, fmtSpeed } from '$lib/format';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data, form } = $props();
|
||||||
const a = $derived(data.activity);
|
const a = $derived(data.activity);
|
||||||
|
|
||||||
const avgSpeed = $derived(
|
const avgSpeed = $derived(
|
||||||
a.moving_s && a.moving_s > 0 ? fmtSpeed(a.distance_m / a.moving_s) : null
|
a.moving_s && a.moving_s > 0 ? fmtSpeed(a.distance_m / a.moving_s) : null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let editing = $state(false);
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
// chart hover -> marker on the map at the matching track position
|
||||||
|
function pointAt(d: number): [number, number] {
|
||||||
|
const trackD = data.trackD;
|
||||||
|
let lo = 0;
|
||||||
|
let hi = trackD.length - 1;
|
||||||
|
while (hi - lo > 1) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (trackD[mid] < d) lo = mid;
|
||||||
|
else hi = mid;
|
||||||
|
}
|
||||||
|
return data.latlngs[d - trackD[lo] < trackD[hi] - d ? lo : hi];
|
||||||
|
}
|
||||||
|
|
||||||
|
function chartHover(d: number | null) {
|
||||||
|
if (d === null) {
|
||||||
|
mapState.setHoverPoint(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [lat, lon] = pointAt(d);
|
||||||
|
mapState.setHoverPoint({ lat, lon });
|
||||||
|
}
|
||||||
|
|
||||||
|
// chart click -> fly the map to that spot
|
||||||
|
function chartSelect(d: number) {
|
||||||
|
const [lat, lon] = pointAt(d);
|
||||||
|
mapState.flyTo(lat, lon, 14.5);
|
||||||
|
}
|
||||||
|
|
||||||
// the persistent map keeps the overview visible, dims the rest,
|
// the persistent map keeps the overview visible, dims the rest,
|
||||||
// and animates toward this activity
|
// and animates toward this activity
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -40,14 +71,56 @@
|
|||||||
<a class="back" href="/">← Overview</a>
|
<a class="back" href="/">← Overview</a>
|
||||||
|
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<div>
|
{#if editing}
|
||||||
<h1>{a.name}</h1>
|
<form
|
||||||
<p class="page-sub">
|
class="edit-form"
|
||||||
<a class="who" href="/users/{a.username}">{a.username}</a> · {fmtDate(a.date)} ·
|
method="POST"
|
||||||
<span class="type">{a.type}</span>
|
action="?/update"
|
||||||
</p>
|
use:enhance={() => {
|
||||||
</div>
|
saving = true;
|
||||||
{#if data.canDelete}
|
return async ({ update, result }) => {
|
||||||
|
saving = false;
|
||||||
|
if (result.type === 'success') editing = false;
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input class="edit-name" name="name" required maxlength="120" value={a.name} aria-label="Activity name" />
|
||||||
|
<input
|
||||||
|
class="edit-type"
|
||||||
|
name="type"
|
||||||
|
required
|
||||||
|
maxlength="40"
|
||||||
|
value={a.type}
|
||||||
|
list="activity-types"
|
||||||
|
aria-label="Activity type"
|
||||||
|
/>
|
||||||
|
<datalist id="activity-types">
|
||||||
|
{#each ['hike', 'run', 'ride', 'ski', 'climbing', 'walk', 'snowshoe'] as t (t)}
|
||||||
|
<option value={t}></option>
|
||||||
|
{/each}
|
||||||
|
</datalist>
|
||||||
|
<button class="btn" type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
|
||||||
|
<button class="btn ghost" type="button" onclick={() => (editing = false)}>Cancel</button>
|
||||||
|
{#if form?.error}<span class="edit-error">{form.error}</span>{/if}
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<div>
|
||||||
|
<h1>
|
||||||
|
{a.name}
|
||||||
|
{#if data.canDelete}
|
||||||
|
<button class="edit-btn" title="Edit name and type" aria-label="Edit name and type" onclick={() => (editing = true)}>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</h1>
|
||||||
|
<p class="page-sub">
|
||||||
|
<a class="who" href="/users/{a.username}">{a.username}</a> · {fmtDate(a.date)} ·
|
||||||
|
<span class="type">{a.type}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if data.canDelete && !editing}
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/delete"
|
action="?/delete"
|
||||||
@@ -88,7 +161,7 @@
|
|||||||
{#if data.profile.length > 1}
|
{#if data.profile.length > 1}
|
||||||
<h2>Elevation profile</h2>
|
<h2>Elevation profile</h2>
|
||||||
<div class="card chart">
|
<div class="card chart">
|
||||||
<ElevationChart profile={data.profile} />
|
<ElevationChart profile={data.profile} onhover={chartHover} onselect={chartSelect} />
|
||||||
<div class="chart-meta">
|
<div class="chart-meta">
|
||||||
<span>Low {fmtElevation(a.elev_min_m)}</span>
|
<span>Low {fmtElevation(a.elev_min_m)}</span>
|
||||||
<span>High {fmtElevation(a.elev_max_m)}</span>
|
<span>High {fmtElevation(a.elev_max_m)}</span>
|
||||||
@@ -99,7 +172,7 @@
|
|||||||
{#if data.timed.length > 2}
|
{#if data.timed.length > 2}
|
||||||
<h2>Speed</h2>
|
<h2>Speed</h2>
|
||||||
<div class="card chart">
|
<div class="card chart">
|
||||||
<SpeedChart timed={data.timed} />
|
<SpeedChart timed={data.timed} onhover={chartHover} onselect={chartSelect} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -125,6 +198,52 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
.edit-btn {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.edit-btn:hover {
|
||||||
|
background: var(--wash);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.edit-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.edit-form input {
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--surface-1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.edit-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.edit-type {
|
||||||
|
width: 120px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.edit-form input:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.edit-error {
|
||||||
|
color: var(--critical);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
.type {
|
.type {
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user