synced chart hover, fixed map position across pages

This commit is contained in:
Vincent van der Wal
2026-07-22 15:54:19 +02:00
parent 3d97c118b4
commit 279469cd65
8 changed files with 357 additions and 296 deletions
+15 -14
View File
@@ -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}
+5 -17
View File
@@ -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>
+15 -13
View File
@@ -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}
+7
View File
@@ -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;
+18
View File
@@ -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>
+1 -2
View File
@@ -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}
+295 -249
View File
@@ -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>
+1 -1
View File
@@ -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