synced chart hover, fixed map position across pages
This commit is contained in:
@@ -1,10 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let {
|
let {
|
||||||
profile,
|
profile,
|
||||||
|
hoverD = null,
|
||||||
onhover,
|
onhover,
|
||||||
onselect
|
onselect
|
||||||
}: {
|
}: {
|
||||||
profile: { d: number; ele: number }[];
|
profile: { d: number; ele: number }[];
|
||||||
|
/** shared hover distance controlled by the page, so charts stay in sync */
|
||||||
|
hoverD?: number | null;
|
||||||
onhover?: (d: number | null) => void;
|
onhover?: (d: number | null) => void;
|
||||||
onselect?: (d: number) => void;
|
onselect?: (d: number) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
@@ -49,22 +52,23 @@
|
|||||||
`${linePath}L${x(totalDist).toFixed(1)},${height - pad.bottom}L${pad.left},${height - pad.bottom}Z`
|
`${linePath}L${x(totalDist).toFixed(1)},${height - pad.bottom}L${pad.left},${height - pad.bottom}Z`
|
||||||
);
|
);
|
||||||
|
|
||||||
let hover = $state<{ d: number; ele: number } | null>(null);
|
// snap the shared hover distance to this chart's nearest point
|
||||||
|
const hover = $derived.by(() => {
|
||||||
function onmove(event: PointerEvent) {
|
if (hoverD === null || profile.length === 0) return null;
|
||||||
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;
|
|
||||||
// binary search closest point
|
|
||||||
let lo = 0;
|
let lo = 0;
|
||||||
let hi = profile.length - 1;
|
let hi = profile.length - 1;
|
||||||
while (hi - lo > 1) {
|
while (hi - lo > 1) {
|
||||||
const mid = (lo + hi) >> 1;
|
const mid = (lo + hi) >> 1;
|
||||||
if (profile[mid].d < target) lo = mid;
|
if (profile[mid].d < hoverD) lo = mid;
|
||||||
else hi = mid;
|
else hi = mid;
|
||||||
}
|
}
|
||||||
hover = target - profile[lo].d < profile[hi].d - target ? profile[lo] : profile[hi];
|
return hoverD - profile[lo].d < profile[hi].d - hoverD ? profile[lo] : profile[hi];
|
||||||
onhover?.(hover.d);
|
});
|
||||||
|
|
||||||
|
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));
|
||||||
|
onhover?.(frac * totalDist);
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -105,10 +109,7 @@
|
|||||||
fill="transparent"
|
fill="transparent"
|
||||||
onpointermove={onmove}
|
onpointermove={onmove}
|
||||||
onclick={() => hover && onselect?.(hover.d)}
|
onclick={() => hover && onselect?.(hover.d)}
|
||||||
onpointerleave={() => {
|
onpointerleave={() => onhover?.(null)}
|
||||||
hover = null;
|
|
||||||
onhover?.(null);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{#if hover}
|
{#if hover}
|
||||||
|
|||||||
@@ -1,23 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { mapState } from '$lib/map-state.svelte';
|
import { mapState } from '$lib/map-state.svelte';
|
||||||
|
|
||||||
let { height = '440px' }: { height?: string } = $props();
|
// Marker component: pages render this to request the persistent map,
|
||||||
let el: HTMLDivElement;
|
// which the layout shows in its fixed spot below the topbar - the same
|
||||||
|
// position on every page, so navigation never moves it.
|
||||||
// move the persistent map into this page's slot; return it on leave
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (mapState.carrier && el) {
|
mapState.setWanted(true);
|
||||||
mapState.attach(el);
|
return () => mapState.setWanted(false);
|
||||||
return () => mapState.detach();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="map-slot" bind:this={el} style:height></div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.map-slot {
|
|
||||||
width: 100%;
|
|
||||||
border-radius: 0.875rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -3,10 +3,13 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
timed,
|
timed,
|
||||||
|
hoverD = null,
|
||||||
onhover,
|
onhover,
|
||||||
onselect
|
onselect
|
||||||
}: {
|
}: {
|
||||||
timed: { d: number; t: number }[];
|
timed: { d: number; t: number }[];
|
||||||
|
/** shared hover distance controlled by the page, so charts stay in sync */
|
||||||
|
hoverD?: number | null;
|
||||||
onhover?: (d: number | null) => void;
|
onhover?: (d: number | null) => void;
|
||||||
onselect?: (d: number) => void;
|
onselect?: (d: number) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
@@ -82,21 +85,23 @@
|
|||||||
: ''
|
: ''
|
||||||
);
|
);
|
||||||
|
|
||||||
let hover = $state<{ d: number; v: number } | null>(null);
|
// snap the shared hover distance to this chart's nearest point
|
||||||
|
const hover = $derived.by(() => {
|
||||||
function onmove(event: PointerEvent) {
|
if (hoverD === null || series.length === 0) return null;
|
||||||
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 lo = 0;
|
||||||
let hi = series.length - 1;
|
let hi = series.length - 1;
|
||||||
while (hi - lo > 1) {
|
while (hi - lo > 1) {
|
||||||
const mid = (lo + hi) >> 1;
|
const mid = (lo + hi) >> 1;
|
||||||
if (series[mid].d < target) lo = mid;
|
if (series[mid].d < hoverD) lo = mid;
|
||||||
else hi = mid;
|
else hi = mid;
|
||||||
}
|
}
|
||||||
hover = target - series[lo].d < series[hi].d - target ? series[lo] : series[hi];
|
return hoverD - series[lo].d < series[hi].d - hoverD ? series[lo] : series[hi];
|
||||||
onhover?.(hover.d);
|
});
|
||||||
|
|
||||||
|
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));
|
||||||
|
onhover?.(frac * totalDist);
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -144,10 +149,7 @@
|
|||||||
fill="transparent"
|
fill="transparent"
|
||||||
onpointermove={onmove}
|
onpointermove={onmove}
|
||||||
onclick={() => hover && onselect?.(hover.d)}
|
onclick={() => hover && onselect?.(hover.d)}
|
||||||
onpointerleave={() => {
|
onpointerleave={() => onhover?.(null)}
|
||||||
hover = null;
|
|
||||||
onhover?.(null);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{#if hover}
|
{#if hover}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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 carrier = $state<HTMLElement | null>(null);
|
let carrier = $state<HTMLElement | null>(null);
|
||||||
let active = $state(false);
|
let active = $state(false);
|
||||||
|
let wanted = $state(false);
|
||||||
|
|
||||||
let home: HTMLElement | null = null;
|
let home: HTMLElement | null = null;
|
||||||
let peakClickHandler: ((id: number) => void) | null = null;
|
let peakClickHandler: ((id: number) => void) | null = null;
|
||||||
@@ -42,6 +43,12 @@ export const mapState = {
|
|||||||
get active() {
|
get active() {
|
||||||
return active;
|
return active;
|
||||||
},
|
},
|
||||||
|
get wanted() {
|
||||||
|
return wanted;
|
||||||
|
},
|
||||||
|
setWanted(value: boolean) {
|
||||||
|
wanted = value;
|
||||||
|
},
|
||||||
|
|
||||||
setPeaks(value: MapPeak[]) {
|
setPeaks(value: MapPeak[]) {
|
||||||
peaks = value;
|
peaks = value;
|
||||||
|
|||||||
@@ -9,11 +9,20 @@
|
|||||||
|
|
||||||
let mapHome: HTMLDivElement;
|
let mapHome: HTMLDivElement;
|
||||||
let mapCarrier: HTMLDivElement;
|
let mapCarrier: HTMLDivElement;
|
||||||
|
let layoutSlot: HTMLDivElement;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (mapCarrier && mapHome) mapState.registerCarrier(mapCarrier, mapHome);
|
if (mapCarrier && mapHome) mapState.registerCarrier(mapCarrier, mapHome);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// show the persistent map in its fixed spot whenever the page asks for it
|
||||||
|
$effect(() => {
|
||||||
|
if (mapState.wanted && layoutSlot && mapState.carrier) {
|
||||||
|
mapState.attach(layoutSlot);
|
||||||
|
return () => mapState.detach();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let mapComponent: Map | undefined = $state();
|
let mapComponent: Map | undefined = $state();
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (mapComponent) {
|
if (mapComponent) {
|
||||||
@@ -136,6 +145,11 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
<div
|
||||||
|
class="layout-map-slot"
|
||||||
|
bind:this={layoutSlot}
|
||||||
|
style:display={mapState.wanted ? 'block' : 'none'}
|
||||||
|
></div>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -378,4 +392,8 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
.layout-map-slot {
|
||||||
|
height: min(440px, 52vh);
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -30,8 +30,7 @@
|
|||||||
<StatTile label="Total ascent" value={fmtElevation(data.totals.elev_gain_m)} />
|
<StatTile label="Total ascent" value={fmtElevation(data.totals.elev_gain_m)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Worldmap</h2>
|
<MapSlot />
|
||||||
<MapSlot height="440px" />
|
|
||||||
|
|
||||||
<h2>Recent activities</h2>
|
<h2>Recent activities</h2>
|
||||||
{#if data.activities.length === 0}
|
{#if data.activities.length === 0}
|
||||||
|
|||||||
@@ -1,282 +1,328 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from "$app/forms";
|
||||||
import MapSlot from '$lib/components/MapSlot.svelte';
|
import MapSlot from "$lib/components/MapSlot.svelte";
|
||||||
import ElevationChart from '$lib/components/ElevationChart.svelte';
|
import ElevationChart from "$lib/components/ElevationChart.svelte";
|
||||||
import SpeedChart from '$lib/components/SpeedChart.svelte';
|
import SpeedChart from "$lib/components/SpeedChart.svelte";
|
||||||
import StatTile from '$lib/components/StatTile.svelte';
|
import StatTile from "$lib/components/StatTile.svelte";
|
||||||
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, form } = $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 editing = $state(false);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
|
||||||
// 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;
|
||||||
let lo = 0;
|
let lo = 0;
|
||||||
let hi = trackD.length - 1;
|
let hi = trackD.length - 1;
|
||||||
while (hi - lo > 1) {
|
while (hi - lo > 1) {
|
||||||
const mid = (lo + hi) >> 1;
|
const mid = (lo + hi) >> 1;
|
||||||
if (trackD[mid] < d) lo = mid;
|
if (trackD[mid] < d) lo = mid;
|
||||||
else hi = mid;
|
else hi = mid;
|
||||||
}
|
}
|
||||||
return data.latlngs[d - trackD[lo] < trackD[hi] - d ? lo : hi];
|
return data.latlngs[d - trackD[lo] < trackD[hi] - d ? lo : hi];
|
||||||
}
|
}
|
||||||
|
|
||||||
function chartHover(d: number | null) {
|
let hoverD = $state<number | null>(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 chartHover(d: number | null) {
|
||||||
function chartSelect(d: number) {
|
hoverD = d;
|
||||||
const [lat, lon] = pointAt(d);
|
if (d === null) {
|
||||||
mapState.flyTo(lat, lon, 14.5);
|
mapState.setHoverPoint(null);
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
const [lat, lon] = pointAt(d);
|
||||||
|
mapState.setHoverPoint({ lat, lon });
|
||||||
|
}
|
||||||
|
|
||||||
// the persistent map keeps the overview visible, dims the rest,
|
// chart click -> fly the map to that spot
|
||||||
// and animates toward this activity
|
function chartSelect(d: number) {
|
||||||
$effect(() => {
|
const [lat, lon] = pointAt(d);
|
||||||
mapState.setDetailTrack({
|
mapState.flyTo(lat, lon);
|
||||||
id: a.id,
|
}
|
||||||
name: a.name,
|
|
||||||
username: a.username,
|
// the persistent map keeps the overview visible, dims the rest,
|
||||||
type: a.type,
|
// and animates toward this activity
|
||||||
date: a.date,
|
$effect(() => {
|
||||||
distance_m: a.distance_m,
|
mapState.setDetailTrack({
|
||||||
latlngs: data.latlngs
|
id: a.id,
|
||||||
});
|
name: a.name,
|
||||||
mapState.setHighlight(a.id);
|
username: a.username,
|
||||||
mapState.setPeaks(data.bagged);
|
type: a.type,
|
||||||
mapState.setFocus(JSON.parse(a.bounds));
|
date: a.date,
|
||||||
return () => mapState.reset();
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{a.name} · Streba</title>
|
<title>{a.name} · Streba</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<a class="back" href="/">← Overview</a>
|
<a class="back" href="/">← Overview</a>
|
||||||
|
|
||||||
<div class="head">
|
<div class="head">
|
||||||
{#if editing}
|
{#if editing}
|
||||||
<form
|
<form
|
||||||
class="edit-form"
|
class="edit-form"
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/update"
|
action="?/update"
|
||||||
use:enhance={() => {
|
use:enhance={() => {
|
||||||
saving = true;
|
saving = true;
|
||||||
return async ({ update, result }) => {
|
return async ({ update, result }) => {
|
||||||
saving = false;
|
saving = false;
|
||||||
if (result.type === 'success') editing = false;
|
if (result.type === "success") editing = false;
|
||||||
await update();
|
await update();
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input class="edit-name" name="name" required maxlength="120" value={a.name} aria-label="Activity name" />
|
<input
|
||||||
<input
|
class="edit-name"
|
||||||
class="edit-type"
|
name="name"
|
||||||
name="type"
|
required
|
||||||
required
|
maxlength="120"
|
||||||
maxlength="40"
|
value={a.name}
|
||||||
value={a.type}
|
aria-label="Activity name"
|
||||||
list="activity-types"
|
/>
|
||||||
aria-label="Activity type"
|
<input
|
||||||
/>
|
class="edit-type"
|
||||||
<datalist id="activity-types">
|
name="type"
|
||||||
{#each ['hike', 'run', 'ride', 'ski', 'climbing', 'walk', 'snowshoe'] as t (t)}
|
required
|
||||||
<option value={t}></option>
|
maxlength="40"
|
||||||
{/each}
|
value={a.type}
|
||||||
</datalist>
|
list="activity-types"
|
||||||
<button class="btn" type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
|
aria-label="Activity type"
|
||||||
<button class="btn ghost" type="button" onclick={() => (editing = false)}>Cancel</button>
|
/>
|
||||||
{#if form?.error}<span class="edit-error">{form.error}</span>{/if}
|
<datalist id="activity-types">
|
||||||
</form>
|
{#each ["hike", "run", "ride", "ski", "climbing", "walk", "snowshoe"] as t (t)}
|
||||||
{:else}
|
<option value={t}></option>
|
||||||
<div>
|
{/each}
|
||||||
<h1>
|
</datalist>
|
||||||
{a.name}
|
<button class="btn" type="submit" disabled={saving}
|
||||||
{#if data.canDelete}
|
>{saving ? "Saving…" : "Save"}</button
|
||||||
<button class="edit-btn" title="Edit name and type" aria-label="Edit name and type" onclick={() => (editing = true)}>
|
>
|
||||||
✎
|
<button class="btn ghost" type="button" onclick={() => (editing = false)}
|
||||||
</button>
|
>Cancel</button
|
||||||
{/if}
|
>
|
||||||
</h1>
|
{#if form?.error}<span class="edit-error">{form.error}</span>{/if}
|
||||||
<p class="page-sub">
|
</form>
|
||||||
<a class="who" href="/users/{a.username}">{a.username}</a> · {fmtDate(a.date)} ·
|
{:else}
|
||||||
<span class="type">{a.type}</span>
|
<div>
|
||||||
</p>
|
<h1>
|
||||||
</div>
|
{a.name}
|
||||||
{/if}
|
{#if data.canDelete}
|
||||||
{#if data.canDelete && !editing}
|
<button
|
||||||
<form
|
class="edit-btn"
|
||||||
method="POST"
|
title="Edit name and type"
|
||||||
action="?/delete"
|
aria-label="Edit name and type"
|
||||||
use:enhance={({ cancel }) => {
|
onclick={() => (editing = true)}
|
||||||
if (!confirm('Delete this activity? Peaks it reached stay in your list.')) cancel();
|
>
|
||||||
}}
|
✎
|
||||||
>
|
</button>
|
||||||
<button class="btn danger" type="submit">Delete</button>
|
{/if}
|
||||||
</form>
|
</h1>
|
||||||
{/if}
|
<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>
|
||||||
|
|
||||||
<div class="kpis">
|
<div class="kpis">
|
||||||
<StatTile label="Distance" value={fmtDistance(a.distance_m)} />
|
<StatTile label="Distance" value={fmtDistance(a.distance_m)} />
|
||||||
<StatTile label="Ascent" value={fmtElevation(a.elev_gain_m)} detail="↓ {fmtElevation(a.elev_loss_m)}" />
|
<StatTile
|
||||||
<StatTile
|
label="Ascent"
|
||||||
label="Moving time"
|
value={fmtElevation(a.elev_gain_m)}
|
||||||
value={fmtDuration(a.moving_s ?? a.duration_s)}
|
detail="↓ {fmtElevation(a.elev_loss_m)}"
|
||||||
detail={a.duration_s ? `total ${fmtDuration(a.duration_s)}` : ''}
|
/>
|
||||||
/>
|
<StatTile
|
||||||
{#if avgSpeed}
|
label="Moving time"
|
||||||
<StatTile label="Avg moving speed" value={avgSpeed} />
|
value={fmtDuration(a.moving_s ?? a.duration_s)}
|
||||||
{:else}
|
detail={a.duration_s ? `total ${fmtDuration(a.duration_s)}` : ""}
|
||||||
<StatTile label="Highest point" value={fmtElevation(a.elev_max_m)} />
|
/>
|
||||||
{/if}
|
{#if avgSpeed}
|
||||||
|
<StatTile label="Avg moving speed" value={avgSpeed} />
|
||||||
|
{:else}
|
||||||
|
<StatTile label="Highest point" value={fmtElevation(a.elev_max_m)} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if data.bagged.length > 0}
|
{#if data.bagged.length > 0}
|
||||||
<div class="card bagged">
|
<div class="card bagged">
|
||||||
⛰ Peaks reached:
|
⛰ 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
|
||||||
</div>
|
>{peak.name}</strong
|
||||||
|
>
|
||||||
|
({peak.elevation_m} m){/each}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<h2>Map</h2>
|
<MapSlot />
|
||||||
<MapSlot height="440px" />
|
|
||||||
|
|
||||||
{#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} onhover={chartHover} onselect={chartSelect} />
|
<ElevationChart
|
||||||
<div class="chart-meta">
|
profile={data.profile}
|
||||||
<span>Low {fmtElevation(a.elev_min_m)}</span>
|
{hoverD}
|
||||||
<span>High {fmtElevation(a.elev_max_m)}</span>
|
onhover={chartHover}
|
||||||
</div>
|
onselect={chartSelect}
|
||||||
</div>
|
/>
|
||||||
|
<div class="chart-meta">
|
||||||
|
<span>Low {fmtElevation(a.elev_min_m)}</span>
|
||||||
|
<span>High {fmtElevation(a.elev_max_m)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#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} onhover={chartHover} onselect={chartSelect} />
|
<SpeedChart
|
||||||
</div>
|
timed={data.timed}
|
||||||
|
{hoverD}
|
||||||
|
onhover={chartHover}
|
||||||
|
onselect={chartSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.back {
|
.back {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 0.25rem 0.6rem;
|
padding: 0.25rem 0.6rem;
|
||||||
margin-left: -0.6rem;
|
margin-left: -0.6rem;
|
||||||
border-radius: 0.45rem;
|
border-radius: 0.45rem;
|
||||||
}
|
}
|
||||||
.back:hover {
|
.back:hover {
|
||||||
background: var(--wash);
|
background: var(--wash);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
.head {
|
.head {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
.edit-btn {
|
.edit-btn {
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.15rem 0.4rem;
|
padding: 0.15rem 0.4rem;
|
||||||
border-radius: 0.4rem;
|
border-radius: 0.4rem;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
.edit-btn:hover {
|
.edit-btn:hover {
|
||||||
background: var(--wash);
|
background: var(--wash);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
.edit-form {
|
.edit-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.edit-form input {
|
.edit-form input {
|
||||||
padding: 0.5rem 0.7rem;
|
padding: 0.5rem 0.7rem;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
background: var(--surface-1);
|
background: var(--surface-1);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
.edit-name {
|
.edit-name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.edit-type {
|
.edit-type {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
.edit-form input:focus {
|
.edit-form input:focus {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
.edit-error {
|
.edit-error {
|
||||||
color: var(--critical);
|
color: var(--critical);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.type {
|
.type {
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
.who {
|
.who {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-strong);
|
color: var(--accent-strong);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.who:hover {
|
.who:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
.kpis {
|
.kpis {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
.bagged {
|
.bagged {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
padding: 0.85rem 1.15rem;
|
padding: 0.85rem 1.15rem;
|
||||||
border-left: 3px solid var(--good);
|
border-left: 3px solid var(--good);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
.bagged strong {
|
.bagged strong {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
.chart {
|
.chart {
|
||||||
padding: 1rem 1rem 0.5rem;
|
padding: 1rem 1rem 0.5rem;
|
||||||
}
|
}
|
||||||
.chart-meta {
|
.chart-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
padding: 0.5rem 0.25rem 0.6rem;
|
padding: 0.5rem 0.25rem 0.6rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -158,7 +158,7 @@
|
|||||||
Zoom in to reveal less prominent summits; click a peak to cross it off.
|
Zoom in to reveal less prominent summits; click a peak to cross it off.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<MapSlot height="480px" />
|
<MapSlot />
|
||||||
|
|
||||||
<div class="search">
|
<div class="search">
|
||||||
<input
|
<input
|
||||||
|
|||||||
Reference in New Issue
Block a user