Files
bincio-activity/site/src/components/SegmentCreate.svelte
T
Davide Scaini c7f0013e57 SegmentCreate: prompt after save instead of immediate redirect; update plan
After saving, show "Saved! Add another from this activity?" with two
buttons: "Add another" (resets name/handles, keeps map loaded) and
"Done" (navigates to /segments/).
2026-05-13 01:03:34 +02:00

512 lines
21 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import type { ActivitySummary } from '../lib/types';
import { loadIndexPaged, loadActivity, loadTimeseries } from '../lib/dataloader';
import { formatDistance, formatDate, sportIcon } from '../lib/format';
export let base: string = '/';
// ── Activity picker ───────────────────────────────────────────────────────
let activities: ActivitySummary[] = [];
let pendingShards: string[] = [];
let loadingActivities = true;
let loadingMore = false;
let searchQuery = '';
let selectedActivity: ActivitySummary | null = null;
// ── Track data ────────────────────────────────────────────────────────────
let gpsPoints: [number, number][] = []; // [lat, lon]
let elevations: (number | null)[] = []; // parallel to gpsPoints
let startIdx = 0;
let endIdx = 0;
let loadingTrack = false;
let trackError = '';
// ── Segment form ──────────────────────────────────────────────────────────
let segName = '';
let segSport = '';
let saving = false;
let saveError = '';
let justSaved = false;
let lastSavedName = '';
// ── Map ───────────────────────────────────────────────────────────────────
let mapEl: HTMLDivElement;
let map: any;
let mapReady = false;
const TILE_STYLE = 'https://tiles.openfreemap.org/styles/positron';
const DIM_COLOR = '#71717a';
const SEL_COLOR = '#3b82f6';
// ── Derived ───────────────────────────────────────────────────────────────
$: filteredActivities = activities.filter(a =>
a.preview_coords != null && (
!searchQuery ||
(a.title ?? '').toLowerCase().includes(searchQuery.toLowerCase()) ||
formatDate(a.started_at).toLowerCase().includes(searchQuery.toLowerCase())
)
);
$: maxIdx = Math.max(0, gpsPoints.length - 1);
$: startPct = maxIdx > 0 ? (startIdx / maxIdx) * 100 : 0;
$: endPct = maxIdx > 0 ? (endIdx / maxIdx) * 100 : 100;
$: selectedPolyline = gpsPoints.slice(startIdx, endIdx + 1);
$: selectedDistance = polylineDistance(selectedPolyline);
$: canSave = segName.trim().length > 0 && selectedPolyline.length >= 2 && selectedDistance >= 500 && !saving;
// ── Init ──────────────────────────────────────────────────────────────────
onMount(async () => {
const params = new URLSearchParams(window.location.search);
const preId = params.get('activity');
try {
const { index, pendingShards: ps } = await loadIndexPaged(base);
activities = index.activities;
pendingShards = ps;
if (preId) {
const found = activities.find(a => a.id === preId);
if (found) await selectActivity(found);
}
} catch { /* ignore */ }
loadingActivities = false;
});
onDestroy(() => map?.remove());
// ── Activity selection ────────────────────────────────────────────────────
async function selectActivity(a: ActivitySummary) {
selectedActivity = a;
segSport = a.sport ?? '';
loadingTrack = true;
trackError = '';
gpsPoints = [];
mapReady = false;
map?.remove();
try {
const detail = await loadActivity(a.id, a.detail_url ?? '', base);
if (!detail?.timeseries_url) { trackError = 'No GPS data for this activity.'; loadingTrack = false; return; }
const ts = await loadTimeseries(detail.timeseries_url, a.detail_url ?? '', base);
if (!ts) { trackError = 'Could not load GPS track.'; loadingTrack = false; return; }
const pts: [number, number][] = [];
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++) {
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; }
gpsPoints = pts;
elevations = elevs;
startIdx = 0;
endIdx = pts.length - 1;
loadingTrack = false;
// Map init happens after the DOM updates (bind:this needs the element)
setTimeout(initMap, 0);
} catch {
trackError = 'Failed to load track.';
loadingTrack = false;
}
}
async function loadMoreActivities() {
if (!pendingShards.length || loadingMore) return;
loadingMore = true;
try {
const url = pendingShards[0];
pendingShards = pendingShards.slice(1);
const r = await fetch(url);
const shard = await r.json();
const shardBase = url.substring(0, url.lastIndexOf('/') + 1);
const newActs = (shard.activities ?? []).map((a: ActivitySummary) => ({
...a,
detail_url: a.detail_url?.startsWith('http') ? a.detail_url : shardBase + a.detail_url,
track_url: a.track_url?.startsWith('http') ? a.track_url : shardBase + a.track_url,
}));
activities = [...activities, ...newActs];
} catch { /* ignore */ }
loadingMore = false;
}
// ── Map ───────────────────────────────────────────────────────────────────
function toLine(pts: [number, number][]) {
return {
type: 'Feature' as const,
geometry: { type: 'LineString' as const, coordinates: pts.map(([lat, lon]) => [lon, lat]) },
};
}
function initMap() {
if (!mapEl || !gpsPoints.length) return;
const lats = gpsPoints.map(p => p[0]);
const lons = gpsPoints.map(p => p[1]);
map = new maplibregl.Map({
container: mapEl,
style: TILE_STYLE,
bounds: [[Math.min(...lons), Math.min(...lats)], [Math.max(...lons), Math.max(...lats)]],
fitBoundsOptions: { padding: 40 },
attributionControl: false,
});
map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right');
map.on('load', () => {
map.addSource('full', { type: 'geojson', data: toLine(gpsPoints) });
map.addSource('selected', { type: 'geojson', data: toLine(selectedPolyline) });
map.addLayer({ id: 'full-line', type: 'line', source: 'full',
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': DIM_COLOR, 'line-width': 2.5 } });
map.addLayer({ id: 'sel-line', type: 'line', source: 'selected',
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': SEL_COLOR, 'line-width': 4.5 } });
mapReady = true;
});
}
$: if (mapReady && map?.getSource('selected')) {
map.getSource('selected').setData(toLine(selectedPolyline));
}
// ── Slider guards ─────────────────────────────────────────────────────────
function onStartInput() { if (startIdx >= endIdx) startIdx = Math.max(0, endIdx - 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 ──────────────────────────────────────────────────────────────────
async function save() {
if (!canSave) return;
saving = true; saveError = '';
try {
const r = await fetch('/api/segments', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: segName.trim(),
sport: segSport || null,
polyline: selectedPolyline,
distance_m: selectedDistance,
}),
});
const d = await r.json();
if (r.ok) {
lastSavedName = segName.trim();
justSaved = true;
saving = false;
} else {
saveError = d.detail ?? 'Failed to save segment';
saving = false;
}
} catch {
saveError = 'Could not reach server';
saving = false;
}
}
function addAnother() {
justSaved = false;
segName = '';
segSport = selectedActivity?.sport ?? '';
startIdx = 0;
endIdx = maxIdx;
saveError = '';
}
// ── Helpers ───────────────────────────────────────────────────────────────
function polylineDistance(pts: [number, number][]): number {
let d = 0;
for (let i = 1; i < pts.length; i++) {
const [lat1, lon1] = pts[i-1], [lat2, lon2] = pts[i];
const R = 6371000;
const φ1 = lat1 * Math.PI/180, φ2 = lat2 * Math.PI/180;
const = (lat2-lat1)*Math.PI/180, = (lon2-lon1)*Math.PI/180;
const a = Math.sin(/2)**2 + Math.cos(φ1)*Math.cos(φ2)*Math.sin(/2)**2;
d += 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}
return d;
}
</script>
<div class="max-w-3xl mx-auto px-4 py-6">
<div class="flex items-center gap-3 mb-6">
<a href="{base}segments/" class="text-zinc-500 hover:text-white transition-colors text-sm">← Segments</a>
<h1 class="text-xl font-bold text-white">New Segment</h1>
</div>
<!-- ── Step 1: Activity picker ─────────────────────────────────────────── -->
{#if !selectedActivity}
<p class="text-zinc-400 text-sm mb-4">Pick an activity to crop the segment from.</p>
<input
type="text"
bind:value={searchQuery}
placeholder="Search by title or date…"
class="w-full mb-4 px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white placeholder-zinc-500 focus:outline-none focus:border-blue-500 text-sm"
/>
{#if loadingActivities}
<p class="text-zinc-500 text-sm">Loading activities…</p>
{:else if filteredActivities.length === 0}
<p class="text-zinc-500 text-sm">No GPS activities found{searchQuery ? ' matching your search' : ''}.</p>
{:else}
<div class="divide-y divide-zinc-800 rounded-xl border border-zinc-800 overflow-hidden">
{#each filteredActivities.slice(0, 100) as a (a.id)}
<button
class="w-full text-left px-4 py-3 hover:bg-zinc-800 transition-colors flex items-center gap-3"
on:click={() => selectActivity(a)}
>
<span class="text-lg shrink-0">{sportIcon(a.sport as any)}</span>
<div class="min-w-0 flex-1">
<p class="text-sm text-white truncate">{a.title ?? 'Untitled'}</p>
<p class="text-xs text-zinc-500">{formatDate(a.started_at)} · {formatDistance(a.distance_m)}</p>
</div>
</button>
{/each}
</div>
{#if filteredActivities.length > 100}
<p class="text-zinc-600 text-xs mt-2">Showing first 100 results — refine your search to narrow down.</p>
{/if}
{#if pendingShards.length > 0}
<button
class="mt-4 text-sm text-zinc-400 hover:text-white transition-colors"
on:click={loadMoreActivities}
disabled={loadingMore}
>{loadingMore ? 'Loading…' : `Load older activities (${pendingShards.length} more year${pendingShards.length > 1 ? 's' : ''})`}</button>
{/if}
{/if}
<!-- ── Step 2: Crop + form ──────────────────────────────────────────────── -->
{:else}
<!-- Activity label + change button -->
<div class="flex items-center gap-3 mb-4 p-3 rounded-lg bg-zinc-800 border border-zinc-700">
<span class="text-lg">{sportIcon(selectedActivity.sport as any)}</span>
<div class="flex-1 min-w-0">
<p class="text-sm text-white truncate">{selectedActivity.title ?? 'Untitled'}</p>
<p class="text-xs text-zinc-500">{formatDate(selectedActivity.started_at)}</p>
</div>
<button
class="text-xs text-zinc-400 hover:text-white transition-colors shrink-0"
on:click={() => { selectedActivity = null; gpsPoints = []; elevations = []; map?.remove(); mapReady = false; }}
>Change</button>
</div>
{#if loadingTrack}
<div class="h-64 flex items-center justify-center text-zinc-500 text-sm">Loading track…</div>
{:else if trackError}
<div class="h-32 flex items-center justify-center text-red-400 text-sm">{trackError}</div>
{:else}
<!-- Map -->
<div class="rounded-xl overflow-hidden mb-4" style="height: 320px;">
<div bind:this={mapEl} class="w-full h-full"></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 -->
<div class="mb-5">
<p class="text-xs text-zinc-500 mb-2">Drag the handles to set the segment start and end</p>
<div class="range-wrap">
<div class="range-track"></div>
<div class="range-fill" style="left:{startPct}%; right:{100-endPct}%"></div>
<input type="range" min={0} max={maxIdx} step={1}
bind:value={startIdx} on:input={onStartInput} />
<input type="range" min={0} max={maxIdx} step={1}
bind:value={endIdx} on:input={onEndInput} />
</div>
<p class="text-xs text-zinc-400 mt-2">
Selected: <span class="text-white font-medium">{formatDistance(selectedDistance)}</span>
({selectedPolyline.length} points)
{#if selectedDistance < 500}
<span class="text-amber-400 ml-1">— minimum 500 m</span>
{/if}
</p>
</div>
<!-- Form -->
<div class="flex gap-3 mb-4">
<input
type="text"
bind:value={segName}
placeholder="Segment name…"
maxlength="80"
class="flex-1 px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white placeholder-zinc-500 focus:outline-none focus:border-blue-500 text-sm"
/>
<select
bind:value={segSport}
class="px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white focus:outline-none focus:border-blue-500 text-sm"
>
<option value="">Any sport</option>
<option value="cycling">Cycling</option>
<option value="running">Running</option>
<option value="skiing">Skiing</option>
<option value="hiking">Hiking</option>
</select>
</div>
{#if justSaved}
<div class="rounded-lg bg-zinc-800 border border-zinc-700 px-4 py-3 flex flex-col gap-3">
<p class="text-sm text-white">
<span class="text-green-400 font-medium">✓ "{lastSavedName}" saved.</span>
Add another segment from this activity?
</p>
<div class="flex gap-2">
<button
on:click={addAnother}
class="flex-1 py-2 rounded-lg text-sm font-medium bg-blue-600 hover:bg-blue-500 text-white transition-colors"
>Add another</button>
<a
href="{base}segments/"
class="flex-1 py-2 rounded-lg text-sm font-medium text-center bg-zinc-700 hover:bg-zinc-600 text-white transition-colors"
>Done</a>
</div>
</div>
{:else}
{#if saveError}
<p class="text-red-400 text-sm mb-3">{saveError}</p>
{/if}
<button
on:click={save}
disabled={!canSave}
class="w-full py-2.5 rounded-lg text-sm font-medium transition-colors
bg-blue-600 hover:bg-blue-500 text-white
disabled:opacity-40 disabled:cursor-not-allowed"
>{saving ? 'Saving…' : 'Save segment'}</button>
{/if}
{/if}
{/if}
</div>
<style>
.range-wrap {
position: relative;
height: 28px;
display: flex;
align-items: center;
}
.range-track {
position: absolute;
left: 0; right: 0;
height: 4px;
border-radius: 2px;
background: #3f3f46;
}
.range-fill {
position: absolute;
height: 4px;
border-radius: 2px;
background: #3b82f6;
pointer-events: none;
}
.range-wrap input[type=range] {
position: absolute;
left: 0; right: 0;
width: 100%;
height: 100%;
margin: 0;
pointer-events: none;
-webkit-appearance: none;
appearance: none;
background: transparent;
}
.range-wrap input[type=range]::-webkit-slider-thumb {
pointer-events: all;
width: 20px; height: 20px;
border-radius: 50%;
background: white;
border: 2px solid #3b82f6;
cursor: pointer;
-webkit-appearance: none;
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
}
.range-wrap input[type=range]::-moz-range-thumb {
pointer-events: all;
width: 20px; height: 20px;
border-radius: 50%;
background: white;
border: 2px solid #3b82f6;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
}
</style>