SegmentCreate: add elevation profile that zooms to selected portion

Shows a dim area for the visible range around the selection (4% padding)
and a blue overlay for the selected segment, with a light stroke on the
top edge. Both the x-domain and y-domain track the selection, so the
chart zooms in as the handles narrow. Elevation min/max labels overlaid
at top-left and bottom-left.
This commit is contained in:
Davide Scaini
2026-05-13 00:54:39 +02:00
parent 4d2df860ce
commit e9e7b5d0e7
+83 -3
View File
@@ -18,6 +18,7 @@
// ── Track data ──────────────────────────────────────────────────────────── // ── Track data ────────────────────────────────────────────────────────────
let gpsPoints: [number, number][] = []; // [lat, lon] let gpsPoints: [number, number][] = []; // [lat, lon]
let elevations: (number | null)[] = []; // parallel to gpsPoints
let startIdx = 0; let startIdx = 0;
let endIdx = 0; let endIdx = 0;
let loadingTrack = false; let loadingTrack = false;
@@ -89,13 +90,19 @@
if (!ts) { trackError = 'Could not load GPS track.'; loadingTrack = false; return; } if (!ts) { trackError = 'Could not load GPS track.'; loadingTrack = false; return; }
const pts: [number, number][] = []; const pts: [number, number][] = [];
const lats = ts.lat ?? [], lons = ts.lon ?? []; const elevs: (number | null)[] = [];
const lats = ts.lat ?? [], lons = ts.lon ?? [], tsElevs: (number | null)[] = ts.elevation_m ?? [];
for (let i = 0; i < lats.length; i++) { for (let i = 0; i < lats.length; i++) {
if (lats[i] != null && lons[i] != null) pts.push([lats[i]!, lons[i]!]); if (lats[i] != null && lons[i] != null) {
pts.push([lats[i]!, lons[i]!]);
const e = tsElevs[i];
elevs.push(typeof e === 'number' ? e : null);
}
} }
if (pts.length < 2) { trackError = 'Not enough GPS points.'; loadingTrack = false; return; } if (pts.length < 2) { trackError = 'Not enough GPS points.'; loadingTrack = false; return; }
gpsPoints = pts; gpsPoints = pts;
elevations = elevs;
startIdx = 0; startIdx = 0;
endIdx = pts.length - 1; endIdx = pts.length - 1;
loadingTrack = false; loadingTrack = false;
@@ -172,6 +179,62 @@
function onStartInput() { if (startIdx >= endIdx) startIdx = Math.max(0, endIdx - 1); } function onStartInput() { if (startIdx >= endIdx) startIdx = Math.max(0, endIdx - 1); }
function onEndInput() { if (endIdx <= startIdx) endIdx = Math.min(maxIdx, startIdx + 1); } function onEndInput() { if (endIdx <= startIdx) endIdx = Math.min(maxIdx, startIdx + 1); }
// ── Elevation chart ───────────────────────────────────────────────────────
const ECHART_W = 600;
const ECHART_H = 80;
const ECHART_PAD = 4; // px margin at top
$: chartPad = Math.max(1, Math.floor(maxIdx * 0.04));
$: viewLo = Math.max(0, startIdx - chartPad);
$: viewHi = Math.min(maxIdx, endIdx + chartPad);
$: elevStats = (() => {
const visible: number[] = [];
for (let i = viewLo; i <= viewHi; i++) {
const e = elevations[i];
if (e != null) visible.push(e);
}
if (!visible.length) return { min: 0, max: 0, range: 1, hasData: false };
let min = visible[0], max = visible[0];
for (const v of visible) { if (v < min) min = v; if (v > max) max = v; }
return { min, max, range: Math.max(1, max - min), hasData: true };
})();
function buildElevPaths(
elevs: (number | null)[],
si: number, ei: number,
vLo: number, vHi: number,
stats: { min: number; max: number; range: number; hasData: boolean },
): { dim: string; sel: string; selLine: string } {
if (!stats.hasData || !elevs.length) return { dim: '', sel: '', selLine: '' };
const span = Math.max(1, vHi - vLo);
const usableH = ECHART_H - ECHART_PAD;
function area(lo: number, hi: number): { area: string; line: string } {
let firstX = -1, lastX = -1;
const linePts: string[] = [];
for (let i = lo; i <= hi; i++) {
const e = elevs[i];
if (e == null) continue;
const x = ((i - vLo) / span) * ECHART_W;
const y = ECHART_PAD + usableH * (1 - (e - stats.min) / stats.range);
if (firstX < 0) firstX = x;
lastX = x;
linePts.push(`L${x.toFixed(1)},${y.toFixed(1)}`);
}
if (!linePts.length) return { area: '', line: '' };
const areaPath = `M${firstX.toFixed(1)},${ECHART_H} ${linePts.join(' ')} L${lastX.toFixed(1)},${ECHART_H} Z`;
const linePath = `M${linePts[0].slice(1)} ${linePts.slice(1).join(' ')}`;
return { area: areaPath, line: linePath };
}
const dimResult = area(vLo, vHi);
const selResult = area(Math.max(si, vLo), Math.min(ei, vHi));
return { dim: dimResult.area, sel: selResult.area, selLine: selResult.line };
}
$: elevPaths = buildElevPaths(elevations, startIdx, endIdx, viewLo, viewHi, elevStats);
// ── Save ────────────────────────────────────────────────────────────────── // ── Save ──────────────────────────────────────────────────────────────────
async function save() { async function save() {
if (!canSave) return; if (!canSave) return;
@@ -271,7 +334,7 @@
</div> </div>
<button <button
class="text-xs text-zinc-400 hover:text-white transition-colors shrink-0" class="text-xs text-zinc-400 hover:text-white transition-colors shrink-0"
on:click={() => { selectedActivity = null; gpsPoints = []; map?.remove(); mapReady = false; }} on:click={() => { selectedActivity = null; gpsPoints = []; elevations = []; map?.remove(); mapReady = false; }}
>Change</button> >Change</button>
</div> </div>
@@ -285,6 +348,23 @@
<div bind:this={mapEl} class="w-full h-full"></div> <div bind:this={mapEl} class="w-full h-full"></div>
</div> </div>
<!-- Elevation profile -->
{#if elevPaths.dim}
<div class="relative mb-4 rounded-lg overflow-hidden bg-zinc-900 border border-zinc-800" style="height:84px">
<svg viewBox="0 0 {ECHART_W} {ECHART_H}" preserveAspectRatio="none" class="w-full h-full">
<path d={elevPaths.dim} fill="#3f3f46" opacity="0.5" />
<path d={elevPaths.sel} fill="#3b82f6" opacity="0.55" />
{#if elevPaths.selLine}
<path d={elevPaths.selLine} fill="none" stroke="#93c5fd" stroke-width="1.5" />
{/if}
</svg>
<div class="absolute inset-0 flex flex-col justify-between pointer-events-none px-2 py-1">
<span class="text-zinc-500 text-xs leading-none">{Math.round(elevStats.max)}m</span>
<span class="text-zinc-500 text-xs leading-none">{Math.round(elevStats.min)}m</span>
</div>
</div>
{/if}
<!-- Dual-range slider --> <!-- Dual-range slider -->
<div class="mb-5"> <div class="mb-5">
<p class="text-xs text-zinc-500 mb-2">Drag the handles to set the segment start and end</p> <p class="text-xs text-zinc-500 mb-2">Drag the handles to set the segment start and end</p>