speed chart, old logo revival, softer hillshade, peaks as side feature
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
# Streba
|
# Streba
|
||||||
|
|
||||||
The GPX analyser, reborn. A self-hosted web app for analysing GPX tracks and
|
The GPX analyser, reborn. A self-hosted web app for analysing GPX tracks -
|
||||||
bagging Alpine peaks.
|
with Alpine peak collecting on the side.
|
||||||
|
|
||||||
- **Accounts** - anyone can sign up; activities and climbed peaks are per user.
|
- **Accounts** - anyone can sign up; activities and climbed peaks are per user.
|
||||||
The home page is a public community overview: every member's tracks on one
|
The home page is a public community overview: every member's tracks on one
|
||||||
worldmap plus a recent-activity feed. Uploading and peak bagging need an
|
worldmap plus a recent-activity feed. Uploading and peak tracking need an
|
||||||
account.
|
account.
|
||||||
- **Upload** GPX files (drag & drop, multiple at once) - distance, ascent,
|
- **Upload** GPX files (drag & drop, multiple at once) - distance, ascent,
|
||||||
moving time and an elevation profile are computed on the spot.
|
moving time and an elevation profile are computed on the spot.
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.ico" sizes="any" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<script>
|
<script>
|
||||||
// apply the stored theme before first paint to avoid a flash
|
// apply the stored theme before first paint to avoid a flash
|
||||||
|
|||||||
@@ -97,8 +97,8 @@
|
|||||||
type: 'hillshade',
|
type: 'hillshade',
|
||||||
source: 'hillshade-dem',
|
source: 'hillshade-dem',
|
||||||
paint: {
|
paint: {
|
||||||
'hillshade-exaggeration': 0.3,
|
'hillshade-exaggeration': dark ? 0.3 : 0.22,
|
||||||
'hillshade-shadow-color': dark ? '#000000' : '#5a5a50',
|
'hillshade-shadow-color': dark ? '#000000' : '#8b8a80',
|
||||||
'hillshade-highlight-color': dark ? '#3a3a38' : '#ffffff'
|
'hillshade-highlight-color': dark ? '#3a3a38' : '#ffffff'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
let { timed }: { timed: { d: number; t: number }[] } = $props();
|
||||||
|
|
||||||
|
const WINDOWS = [
|
||||||
|
{ label: '10 s', w: 10 },
|
||||||
|
{ label: '30 s', w: 30 },
|
||||||
|
{ label: '1 min', w: 60 },
|
||||||
|
{ label: '5 min', w: 300 }
|
||||||
|
];
|
||||||
|
const STORAGE_KEY = 'streba:speed-window';
|
||||||
|
|
||||||
|
let windowS = $state(60);
|
||||||
|
if (browser) {
|
||||||
|
const stored = parseInt(localStorage.getItem(STORAGE_KEY) ?? '', 10);
|
||||||
|
if (WINDOWS.some((o) => o.w === stored)) windowS = stored;
|
||||||
|
}
|
||||||
|
function pick(w: number) {
|
||||||
|
windowS = w;
|
||||||
|
if (browser) localStorage.setItem(STORAGE_KEY, String(w));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** centered rolling average: distance covered over the time window around each point */
|
||||||
|
const series = $derived.by(() => {
|
||||||
|
const half = windowS / 2;
|
||||||
|
const out: { d: number; v: number }[] = [];
|
||||||
|
let j1 = 0;
|
||||||
|
let j2 = 0;
|
||||||
|
for (let i = 0; i < timed.length; i++) {
|
||||||
|
while (j1 < i && timed[j1].t < timed[i].t - half) j1++;
|
||||||
|
while (j2 < timed.length - 1 && timed[j2 + 1].t <= timed[i].t + half) j2++;
|
||||||
|
const dt = timed[j2].t - timed[j1].t;
|
||||||
|
if (dt > 0) out.push({ d: timed[i].d, v: ((timed[j2].d - timed[j1].d) / dt) * 3.6 });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
let width = $state(720);
|
||||||
|
const height = 200;
|
||||||
|
const pad = { top: 12, right: 12, bottom: 24, left: 46 };
|
||||||
|
|
||||||
|
const totalDist = $derived(series.length ? series[series.length - 1].d : 1);
|
||||||
|
const vMax = $derived(Math.max(...series.map((p) => p.v), 1));
|
||||||
|
|
||||||
|
const yTicks = $derived.by(() => {
|
||||||
|
const step = [0.5, 1, 2, 5, 10, 20, 50].find((s) => vMax / s <= 5) ?? 50;
|
||||||
|
const ticks: number[] = [];
|
||||||
|
for (let v = step; v <= vMax; v += step) ticks.push(v);
|
||||||
|
return ticks;
|
||||||
|
});
|
||||||
|
|
||||||
|
const xTicks = $derived.by(() => {
|
||||||
|
const km = totalDist / 1000;
|
||||||
|
const step = [0.5, 1, 2, 5, 10, 20, 50, 100].find((s) => km / s <= 6) ?? 100;
|
||||||
|
const ticks: number[] = [];
|
||||||
|
for (let v = 0; v <= km; v += step) ticks.push(v);
|
||||||
|
return ticks;
|
||||||
|
});
|
||||||
|
|
||||||
|
function x(d: number): number {
|
||||||
|
return pad.left + (d / totalDist) * (width - pad.left - pad.right);
|
||||||
|
}
|
||||||
|
function y(v: number): number {
|
||||||
|
return pad.top + (1 - v / vMax) * (height - pad.top - pad.bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
const linePath = $derived(
|
||||||
|
series.map((p, i) => `${i === 0 ? 'M' : 'L'}${x(p.d).toFixed(1)},${y(p.v).toFixed(1)}`).join('')
|
||||||
|
);
|
||||||
|
const areaPath = $derived(
|
||||||
|
series.length
|
||||||
|
? `${linePath}L${x(totalDist).toFixed(1)},${height - pad.bottom}L${pad.left},${height - pad.bottom}Z`
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
|
let hover = $state<{ d: number; v: number } | null>(null);
|
||||||
|
|
||||||
|
function onmove(event: PointerEvent) {
|
||||||
|
const rect = (event.currentTarget as SVGRectElement).getBoundingClientRect();
|
||||||
|
const frac = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
|
||||||
|
const target = frac * totalDist;
|
||||||
|
let lo = 0;
|
||||||
|
let hi = series.length - 1;
|
||||||
|
while (hi - lo > 1) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (series[mid].d < target) lo = mid;
|
||||||
|
else hi = mid;
|
||||||
|
}
|
||||||
|
hover = target - series[lo].d < series[hi].d - target ? series[lo] : series[hi];
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltipLeft = $derived(hover ? Math.min(Math.max(x(hover.d), 70), width - 70) : 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="head">
|
||||||
|
<span class="label">Rolling average</span>
|
||||||
|
<div class="windows" role="group" aria-label="Rolling average window">
|
||||||
|
{#each WINDOWS as option (option.w)}
|
||||||
|
<button class="win" class:on={windowS === option.w} onclick={() => pick(option.w)}>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-wrap" bind:clientWidth={width}>
|
||||||
|
<svg viewBox="0 0 {width} {height}" role="img" aria-label="Speed profile">
|
||||||
|
{#each yTicks as tick (tick)}
|
||||||
|
<line x1={pad.left} x2={width - pad.right} y1={y(tick)} y2={y(tick)} class="grid" />
|
||||||
|
<text x={pad.left - 8} y={y(tick) + 3.5} class="tick" text-anchor="end">{tick}</text>
|
||||||
|
{/each}
|
||||||
|
{#each xTicks as tick (tick)}
|
||||||
|
<text x={x(tick * 1000)} y={height - 6} class="tick" text-anchor="middle">{tick} km</text>
|
||||||
|
{/each}
|
||||||
|
<line
|
||||||
|
x1={pad.left}
|
||||||
|
x2={width - pad.right}
|
||||||
|
y1={height - pad.bottom}
|
||||||
|
y2={height - pad.bottom}
|
||||||
|
class="baseline"
|
||||||
|
/>
|
||||||
|
<path d={areaPath} class="area" />
|
||||||
|
<path d={linePath} class="line" />
|
||||||
|
{#if hover}
|
||||||
|
<line x1={x(hover.d)} x2={x(hover.d)} y1={pad.top} y2={height - pad.bottom} class="crosshair" />
|
||||||
|
<circle cx={x(hover.d)} cy={y(hover.v)} r="5" class="dot" />
|
||||||
|
{/if}
|
||||||
|
<rect
|
||||||
|
role="presentation"
|
||||||
|
x={pad.left}
|
||||||
|
y={pad.top}
|
||||||
|
width={width - pad.left - pad.right}
|
||||||
|
height={height - pad.top - pad.bottom}
|
||||||
|
fill="transparent"
|
||||||
|
onpointermove={onmove}
|
||||||
|
onpointerleave={() => (hover = null)}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{#if hover}
|
||||||
|
<div class="tooltip" style:left="{tooltipLeft}px">
|
||||||
|
<span class="tt-value">{hover.v.toFixed(1)} km/h</span>
|
||||||
|
<span class="tt-detail">at {(hover.d / 1000).toFixed(2)} km</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0 0.25rem 0.75rem;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.windows {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.win {
|
||||||
|
padding: 0.2rem 0.65rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.win:hover {
|
||||||
|
background: var(--wash);
|
||||||
|
}
|
||||||
|
.win.on {
|
||||||
|
background: var(--accent-wash);
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
.chart-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
stroke: var(--grid);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
.baseline {
|
||||||
|
stroke: var(--baseline);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
.tick {
|
||||||
|
fill: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.area {
|
||||||
|
fill: var(--accent);
|
||||||
|
opacity: 0.1;
|
||||||
|
}
|
||||||
|
.line {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--accent);
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
.crosshair {
|
||||||
|
stroke: var(--baseline);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
fill: var(--accent);
|
||||||
|
stroke: var(--surface-1);
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
.tooltip {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
.tt-value {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.tt-detail {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -300,7 +300,7 @@ export function recordAscent(
|
|||||||
return result.changes > 0;
|
return result.changes > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function baggedPeaks(activityId: number, userId: number): (PeakRow & { climbed_at: string | null })[] {
|
export function reachedPeaks(activityId: number, userId: number): (PeakRow & { climbed_at: string | null })[] {
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT p.*, a.climbed_at FROM ascents a JOIN peaks p ON p.id = a.peak_id
|
`SELECT p.*, a.climbed_at FROM ascents a JOIN peaks p ON p.id = a.peak_id
|
||||||
|
|||||||
+10
-13
@@ -34,10 +34,7 @@
|
|||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<nav class="topbar-inner">
|
<nav class="topbar-inner">
|
||||||
<a href="/" class="wordmark">
|
<a href="/" class="wordmark">
|
||||||
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
|
<img src="/logo.png" alt="Streba" height="34" />
|
||||||
<path d="M2 20 L9 6 L13 13 L16 9 L22 20 Z" fill="currentColor" />
|
|
||||||
</svg>
|
|
||||||
Streba
|
|
||||||
</a>
|
</a>
|
||||||
{#if data.user}
|
{#if data.user}
|
||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
@@ -105,15 +102,9 @@
|
|||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 1.15rem;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
color: var(--text-primary);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
.wordmark svg {
|
.wordmark img {
|
||||||
color: var(--accent);
|
display: block;
|
||||||
}
|
}
|
||||||
.nav-links {
|
.nav-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -136,9 +127,15 @@
|
|||||||
background: var(--accent-wash);
|
background: var(--accent-wash);
|
||||||
color: var(--accent-strong);
|
color: var(--accent-strong);
|
||||||
}
|
}
|
||||||
.signup-btn {
|
.nav-links a.signup-btn {
|
||||||
padding: 0.35rem 0.85rem;
|
padding: 0.35rem 0.85rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.nav-links a.signup-btn:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
filter: brightness(1.08);
|
||||||
}
|
}
|
||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+35
-64
@@ -19,7 +19,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<h1>The GPX analyser</h1>
|
<h1>The GPX analyser</h1>
|
||||||
<p class="page-sub">
|
<p class="page-sub">
|
||||||
Upload your tracks, analyse every climb, and bag Alpine peaks - together.
|
Upload your tracks, analyse every climb - and tick off Alpine peaks along the way.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
@@ -39,41 +39,31 @@
|
|||||||
<h2>Worldmap</h2>
|
<h2>Worldmap</h2>
|
||||||
<Map tracks={data.tracks} height="440px" />
|
<Map tracks={data.tracks} height="440px" />
|
||||||
|
|
||||||
<div class="lower">
|
<h2>Recent activities</h2>
|
||||||
<section>
|
{#if data.activities.length === 0}
|
||||||
<h2>Recent activities</h2>
|
<div class="card empty">
|
||||||
{#if data.activities.length === 0}
|
<p>Nothing here yet - be the first to upload a track.</p>
|
||||||
<div class="card empty">
|
{#if data.user}<a class="btn" href="/upload">Upload a GPX file</a>{/if}
|
||||||
<p>Nothing here yet - be the first to upload a track.</p>
|
</div>
|
||||||
{#if data.user}<a class="btn" href="/upload">Upload a GPX file</a>{/if}
|
{:else}
|
||||||
</div>
|
<div class="card list">
|
||||||
{:else}
|
{#each data.activities.slice(0, 10) as activity (activity.id)}
|
||||||
<div class="card list">
|
<ActivityItem {activity} />
|
||||||
{#each data.activities.slice(0, 8) as activity (activity.id)}
|
{/each}
|
||||||
<ActivityItem {activity} />
|
</div>
|
||||||
{/each}
|
{/if}
|
||||||
</div>
|
|
||||||
{/if}
|
{#if data.mine && data.mine.peaksClimbed > 0}
|
||||||
</section>
|
<div class="card peak-strip">
|
||||||
{#if data.mine}
|
<span>
|
||||||
<section>
|
⛰ You've reached <strong>{data.mine.peaksClimbed}</strong>
|
||||||
<h2>Your peak bagging</h2>
|
peak{data.mine.peaksClimbed === 1 ? '' : 's'}{#if data.mine.highestAscent},
|
||||||
<div class="card peak-progress">
|
highest: <strong>{data.mine.highestAscent.name}</strong>
|
||||||
<div class="meter-nums">
|
({Math.round(data.mine.highestAscent.elevation_m ?? 0).toLocaleString('en-US')} m){/if}.
|
||||||
<span class="big">{data.mine.peaksClimbed}</span>
|
</span>
|
||||||
<span class="of">peak{data.mine.peaksClimbed === 1 ? '' : 's'} climbed</span>
|
<a class="btn ghost" href="/peaks">Peak map</a>
|
||||||
</div>
|
</div>
|
||||||
{#if data.mine.highestAscent}
|
{/if}
|
||||||
<p class="highest">
|
|
||||||
Highest so far: <strong>{data.mine.highestAscent.name}</strong>
|
|
||||||
({Math.round(data.mine.highestAscent.elevation_m ?? 0).toLocaleString('en-US')} m)
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
<a class="btn ghost" href="/peaks">Open the peak map</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.hero {
|
.hero {
|
||||||
@@ -94,17 +84,6 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
.lower {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 3fr 2fr;
|
|
||||||
gap: 1.25rem;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.lower {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.list :global(.item:not(:last-child)) {
|
.list :global(.item:not(:last-child)) {
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
@@ -116,26 +95,18 @@
|
|||||||
margin: 0 0 1rem;
|
margin: 0 0 1rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
.peak-progress {
|
.peak-strip {
|
||||||
padding: 1.15rem;
|
margin-top: 1.25rem;
|
||||||
|
padding: 0.85rem 1.15rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 0.85rem;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
gap: 1rem;
|
||||||
}
|
flex-wrap: wrap;
|
||||||
.meter-nums .big {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
.meter-nums .of {
|
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
margin-left: 0.4rem;
|
|
||||||
}
|
}
|
||||||
.highest {
|
.peak-strip strong {
|
||||||
margin: 0;
|
color: var(--text-primary);
|
||||||
font-size: 0.88rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { error, redirect } from '@sveltejs/kit';
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
import { baggedPeaks, deleteActivity, getActivity } from '$lib/server/db';
|
import { 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,23 +16,26 @@ 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 elevation profile
|
// cumulative-distance profiles for the elevation and speed charts
|
||||||
const profile: { d: number; ele: number }[] = [];
|
const profile: { d: number; ele: number }[] = [];
|
||||||
|
const timed: { d: number; t: 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);
|
||||||
}
|
}
|
||||||
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! });
|
||||||
}
|
}
|
||||||
|
|
||||||
const bagged = baggedPeaks(activity.id, activity.user_id);
|
const bagged = reachedPeaks(activity.id, activity.user_id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activity: { ...activity, points: undefined },
|
activity: { ...activity, points: undefined },
|
||||||
canDelete: locals.user?.id === activity.user_id,
|
canDelete: locals.user?.id === activity.user_id,
|
||||||
latlngs,
|
latlngs,
|
||||||
profile,
|
profile,
|
||||||
|
timed: timed.length > 2 ? timed : [],
|
||||||
bagged: bagged.map((p) => ({
|
bagged: bagged.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import Map from '$lib/components/Map.svelte';
|
import Map from '$lib/components/Map.svelte';
|
||||||
import ElevationChart from '$lib/components/ElevationChart.svelte';
|
import ElevationChart from '$lib/components/ElevationChart.svelte';
|
||||||
|
import SpeedChart from '$lib/components/SpeedChart.svelte';
|
||||||
import StatTile from '$lib/components/StatTile.svelte';
|
import StatTile from '$lib/components/StatTile.svelte';
|
||||||
import { fmtDate, fmtDistance, fmtDuration, fmtElevation, fmtSpeed } from '$lib/format';
|
import { fmtDate, fmtDistance, fmtDuration, fmtElevation, fmtSpeed } from '$lib/format';
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@
|
|||||||
method="POST"
|
method="POST"
|
||||||
action="?/delete"
|
action="?/delete"
|
||||||
use:enhance={({ cancel }) => {
|
use:enhance={({ cancel }) => {
|
||||||
if (!confirm('Delete this activity? Peaks it bagged stay climbed.')) cancel();
|
if (!confirm('Delete this activity? Peaks it reached stay in your list.')) cancel();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button class="btn danger" type="submit">Delete</button>
|
<button class="btn danger" type="submit">Delete</button>
|
||||||
@@ -54,7 +55,7 @@
|
|||||||
|
|
||||||
{#if data.bagged.length > 0}
|
{#if data.bagged.length > 0}
|
||||||
<div class="card bagged">
|
<div class="card bagged">
|
||||||
⛰ This track bagged
|
⛰ Peaks reached:
|
||||||
{#each data.bagged as peak, i (peak.id)}{i > 0 ? ', ' : ' '}<strong>{peak.name}</strong> ({peak.elevation_m} m){/each}
|
{#each data.bagged as peak, i (peak.id)}{i > 0 ? ', ' : ' '}<strong>{peak.name}</strong> ({peak.elevation_m} m){/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -73,6 +74,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if data.timed.length > 2}
|
||||||
|
<h2>Speed</h2>
|
||||||
|
<div class="card chart">
|
||||||
|
<SpeedChart timed={data.timed} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.head {
|
.head {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
<p>
|
<p>
|
||||||
<a href="/activities/{item.id}"><strong>{item.name}</strong></a> analysed.
|
<a href="/activities/{item.id}"><strong>{item.name}</strong></a> analysed.
|
||||||
{#if item.newPeaks.length > 0}
|
{#if item.newPeaks.length > 0}
|
||||||
<span class="bagged">⛰ New peak{item.newPeaks.length > 1 ? 's' : ''} bagged: {item.newPeaks.join(', ')}!</span>
|
<span class="bagged">⛰ Peak{item.newPeaks.length > 1 ? 's' : ''} reached: {item.newPeaks.join(', ')}!</span>
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
||||||
<rect width="24" height="24" rx="5" fill="#1c5cab" />
|
|
||||||
<path d="M3 19 L9.5 7 L13 13 L15.5 9.5 L21 19 Z" fill="#fcfcfb" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 191 B |
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
Reference in New Issue
Block a user