464 lines
11 KiB
Svelte
464 lines
11 KiB
Svelte
<script lang="ts">
|
||
import { enhance } from "$app/forms";
|
||
import MapSlot from "$lib/components/MapSlot.svelte";
|
||
import ElevationChart from "$lib/components/ElevationChart.svelte";
|
||
import SpeedChart from "$lib/components/SpeedChart.svelte";
|
||
import StatTile from "$lib/components/StatTile.svelte";
|
||
import { mapState } from "$lib/map-state.svelte";
|
||
import {
|
||
fmtDate,
|
||
fmtDistance,
|
||
fmtDuration,
|
||
fmtElapsed,
|
||
fmtElevation,
|
||
fmtSpeed,
|
||
} from "$lib/format";
|
||
|
||
let { data, form } = $props();
|
||
const a = $derived(data.activity);
|
||
|
||
const avgSpeed = $derived(
|
||
a.moving_s && a.moving_s > 0 ? fmtSpeed(a.distance_m / a.moving_s) : null,
|
||
);
|
||
|
||
let editing = $state(false);
|
||
let saving = $state(false);
|
||
|
||
// create-segment panel: a distance slice of this track
|
||
let segmenting = $state(false);
|
||
let segStart = $state(0);
|
||
let segEnd = $state(0);
|
||
$effect(() => {
|
||
segEnd = Math.round(a.distance_m);
|
||
});
|
||
|
||
// 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];
|
||
}
|
||
|
||
let hoverD = $state<number | null>(null);
|
||
|
||
function chartHover(d: number | null) {
|
||
hoverD = d;
|
||
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);
|
||
}
|
||
|
||
// the persistent map keeps the overview visible, dims the rest,
|
||
// and animates toward this activity
|
||
$effect(() => {
|
||
mapState.setDetailTrack({
|
||
id: a.id,
|
||
name: a.name,
|
||
username: a.username,
|
||
type: a.type,
|
||
date: a.date,
|
||
distance_m: a.distance_m,
|
||
latlngs: data.latlngs,
|
||
});
|
||
mapState.setHighlight(a.id);
|
||
mapState.setPeaks(data.bagged);
|
||
mapState.setFocus(JSON.parse(a.bounds));
|
||
return () => mapState.reset();
|
||
});
|
||
</script>
|
||
|
||
<svelte:head>
|
||
<title>{a.name} · Streba</title>
|
||
</svelte:head>
|
||
|
||
<a class="back" href="/">← Overview</a>
|
||
|
||
<div class="head">
|
||
{#if editing}
|
||
<form
|
||
class="edit-form"
|
||
method="POST"
|
||
action="?/update"
|
||
use:enhance={() => {
|
||
saving = true;
|
||
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
|
||
method="POST"
|
||
action="?/delete"
|
||
use:enhance={({ cancel }) => {
|
||
if (
|
||
!confirm("Delete this activity? Peaks it reached stay in your list.")
|
||
)
|
||
cancel();
|
||
}}
|
||
>
|
||
<button class="btn danger" type="submit">Delete</button>
|
||
</form>
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="kpis">
|
||
<StatTile label="Distance" value={fmtDistance(a.distance_m)} />
|
||
<StatTile
|
||
label="Ascent"
|
||
value={fmtElevation(a.elev_gain_m)}
|
||
detail="↓ {fmtElevation(a.elev_loss_m)}"
|
||
/>
|
||
<StatTile
|
||
label="Moving time"
|
||
value={fmtDuration(a.moving_s ?? a.duration_s)}
|
||
detail={a.duration_s ? `total ${fmtDuration(a.duration_s)}` : ""}
|
||
/>
|
||
{#if avgSpeed}
|
||
<StatTile label="Avg moving speed" value={avgSpeed} />
|
||
{:else}
|
||
<StatTile label="Highest point" value={fmtElevation(a.elev_max_m)} />
|
||
{/if}
|
||
</div>
|
||
|
||
{#if data.bagged.length > 0}
|
||
<div class="card bagged">
|
||
⛰ Peaks reached:
|
||
{#each data.bagged as peak, i (peak.id)}{i > 0 ? ", " : " "}<strong
|
||
>{peak.name}</strong
|
||
>
|
||
({peak.elevation_m} m){/each}
|
||
</div>
|
||
{/if}
|
||
|
||
{#if data.segmentEfforts.length > 0}
|
||
<div class="card segments">
|
||
<span class="seg-title">Segments on this activity:</span>
|
||
{#each data.segmentEfforts as effort, i (effort.segment_id)}{i > 0
|
||
? " · "
|
||
: " "}<a href="/segments/{effort.segment_id}">{effort.name}</a>{#if effort.elapsed_s !== null} ({fmtElapsed(
|
||
effort.elapsed_s,
|
||
)}){/if}{/each}
|
||
</div>
|
||
{/if}
|
||
|
||
{#if data.canCreateSegment}
|
||
<div class="segment-create">
|
||
{#if segmenting}
|
||
<form class="card seg-form" method="POST" action="?/segment" use:enhance>
|
||
<div class="seg-sliders">
|
||
<label>
|
||
Start · {fmtDistance(Math.min(segStart, segEnd))}
|
||
<input
|
||
type="range"
|
||
name="start_d"
|
||
min="0"
|
||
max={Math.round(a.distance_m)}
|
||
step="10"
|
||
bind:value={segStart}
|
||
/>
|
||
</label>
|
||
<label>
|
||
End · {fmtDistance(Math.max(segStart, segEnd))}
|
||
<input
|
||
type="range"
|
||
name="end_d"
|
||
min="0"
|
||
max={Math.round(a.distance_m)}
|
||
step="10"
|
||
bind:value={segEnd}
|
||
/>
|
||
</label>
|
||
<span class="seg-len"
|
||
>Length: {fmtDistance(Math.abs(segEnd - segStart))}</span
|
||
>
|
||
</div>
|
||
<div class="seg-actions">
|
||
<input name="name" placeholder="Segment name" required maxlength="80" />
|
||
<button class="btn" type="submit">Create segment</button>
|
||
<button class="btn ghost" type="button" onclick={() => (segmenting = false)}
|
||
>Cancel</button
|
||
>
|
||
</div>
|
||
{#if form?.error}<p class="edit-error">{form.error}</p>{/if}
|
||
</form>
|
||
{:else}
|
||
<button class="btn ghost" onclick={() => (segmenting = true)}
|
||
>+ Create segment from this activity</button
|
||
>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
<MapSlot />
|
||
|
||
{#if data.profile.length > 1}
|
||
<h2>Elevation profile</h2>
|
||
<div class="card chart">
|
||
<ElevationChart
|
||
profile={data.profile}
|
||
{hoverD}
|
||
onhover={chartHover}
|
||
onselect={chartSelect}
|
||
/>
|
||
<div class="chart-meta">
|
||
<span>Low {fmtElevation(a.elev_min_m)}</span>
|
||
<span>High {fmtElevation(a.elev_max_m)}</span>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if data.timed.length > 2}
|
||
<h2>Speed</h2>
|
||
<div class="card chart">
|
||
<SpeedChart
|
||
timed={data.timed}
|
||
{hoverD}
|
||
onhover={chartHover}
|
||
onselect={chartSelect}
|
||
/>
|
||
</div>
|
||
{/if}
|
||
|
||
<style>
|
||
.back {
|
||
display: inline-block;
|
||
margin-bottom: 0.75rem;
|
||
font-size: 0.85rem;
|
||
font-weight: 500;
|
||
color: var(--text-secondary);
|
||
text-decoration: none;
|
||
padding: 0.25rem 0.6rem;
|
||
margin-left: -0.6rem;
|
||
border-radius: 0.45rem;
|
||
}
|
||
.back:hover {
|
||
background: var(--wash);
|
||
color: var(--text-primary);
|
||
}
|
||
.head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
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 {
|
||
text-transform: capitalize;
|
||
}
|
||
.segments {
|
||
margin-top: 1rem;
|
||
padding: 0.85rem 1.15rem;
|
||
border-left: 3px solid var(--accent);
|
||
color: var(--text-secondary);
|
||
}
|
||
.seg-title {
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
}
|
||
.segments a {
|
||
color: var(--accent-strong);
|
||
font-weight: 600;
|
||
text-decoration: none;
|
||
}
|
||
.segments a:hover {
|
||
text-decoration: underline;
|
||
}
|
||
.segment-create {
|
||
margin-top: 1rem;
|
||
}
|
||
.seg-form {
|
||
padding: 1.15rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.85rem;
|
||
}
|
||
.seg-sliders {
|
||
display: flex;
|
||
gap: 1.25rem;
|
||
align-items: flex-end;
|
||
flex-wrap: wrap;
|
||
}
|
||
.seg-sliders label {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.3rem;
|
||
font-size: 0.82rem;
|
||
font-weight: 500;
|
||
color: var(--text-secondary);
|
||
flex: 1;
|
||
min-width: 180px;
|
||
}
|
||
.seg-sliders input[type="range"] {
|
||
accent-color: var(--accent);
|
||
}
|
||
.seg-len {
|
||
font-size: 0.85rem;
|
||
color: var(--text-primary);
|
||
font-weight: 600;
|
||
padding-bottom: 0.2rem;
|
||
}
|
||
.seg-actions {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
.seg-actions input {
|
||
flex: 1;
|
||
min-width: 180px;
|
||
padding: 0.5rem 0.7rem;
|
||
border: 1px solid var(--border);
|
||
border-radius: 0.5rem;
|
||
background: var(--surface-1);
|
||
color: var(--text-primary);
|
||
font: inherit;
|
||
}
|
||
.who {
|
||
font-weight: 600;
|
||
color: var(--accent-strong);
|
||
text-decoration: none;
|
||
}
|
||
.who:hover {
|
||
text-decoration: underline;
|
||
}
|
||
.kpis {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||
gap: 0.75rem;
|
||
}
|
||
.bagged {
|
||
margin-top: 1rem;
|
||
padding: 0.85rem 1.15rem;
|
||
border-left: 3px solid var(--good);
|
||
color: var(--text-secondary);
|
||
}
|
||
.bagged strong {
|
||
color: var(--text-primary);
|
||
}
|
||
.chart {
|
||
padding: 1rem 1rem 0.5rem;
|
||
}
|
||
.chart-meta {
|
||
display: flex;
|
||
gap: 1.25rem;
|
||
padding: 0.5rem 0.25rem 0.6rem;
|
||
font-size: 0.8rem;
|
||
color: var(--text-muted);
|
||
}
|
||
</style>
|