planning
This commit is contained in:
@@ -104,6 +104,8 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
|
||||
<a href={baseUrl} class="text-sm text-zinc-400 hover:text-white transition-colors">Feed</a>
|
||||
<a href={`${baseUrl}stats/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Stats</a>
|
||||
<a href={`${baseUrl}athlete/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Athlete</a>
|
||||
<a href={`${baseUrl}record/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Record</a>
|
||||
<a href={`${baseUrl}convert/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Convert</a>
|
||||
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
{editUrl && (
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
|
||||
const baseUrl = import.meta.env.BASE_URL ?? '/';
|
||||
---
|
||||
<Base title="Convert activity — BincioActivity">
|
||||
<div class="max-w-lg mx-auto">
|
||||
<h1 class="text-xl font-bold text-white mb-1">Convert activity</h1>
|
||||
<p class="text-sm text-zinc-400 mb-6">
|
||||
Convert a GPX, FIT, or TCX file to BAS format — entirely in your browser, nothing uploaded.
|
||||
</p>
|
||||
|
||||
<!-- Step 1: pick file -->
|
||||
<div id="step-pick">
|
||||
<div
|
||||
id="conv-drop"
|
||||
class="border-2 border-dashed border-zinc-700 rounded-xl p-10 text-center text-zinc-500 text-sm cursor-pointer hover:border-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
<div class="text-4xl mb-3">📁</div>
|
||||
<div id="conv-drop-label">Drop a FIT, GPX, or TCX file<br/>or tap to browse</div>
|
||||
<input id="conv-input" type="file" accept=".fit,.gpx,.tcx,.fit.gz,.gpx.gz,.tcx.gz" class="hidden" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: loading / converting -->
|
||||
<div id="step-loading" style="display:none" class="text-center py-12">
|
||||
<div id="conv-spinner" class="text-4xl mb-4 animate-spin inline-block">⚙</div>
|
||||
<p id="conv-loading-msg" class="text-zinc-400 text-sm">Loading converter…</p>
|
||||
<div class="mt-4 w-full bg-zinc-800 rounded-full h-1.5">
|
||||
<div id="conv-progress-bar" class="bg-blue-500 h-1.5 rounded-full transition-all duration-300" style="width:0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: result -->
|
||||
<div id="step-result" style="display:none">
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-5 mb-4">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span id="res-sport-icon" class="text-2xl"></span>
|
||||
<div>
|
||||
<h2 id="res-title" class="font-semibold text-white"></h2>
|
||||
<p id="res-date" class="text-xs text-zinc-500"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3 text-center mb-4">
|
||||
<div>
|
||||
<p id="res-dist" class="text-lg font-bold text-white">—</p>
|
||||
<p class="text-xs text-zinc-500">Distance</p>
|
||||
</div>
|
||||
<div>
|
||||
<p id="res-time" class="text-lg font-bold text-white">—</p>
|
||||
<p class="text-xs text-zinc-500">Duration</p>
|
||||
</div>
|
||||
<div>
|
||||
<p id="res-elev" class="text-lg font-bold text-white">—</p>
|
||||
<p class="text-xs text-zinc-500">Elevation</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<button id="btn-download-json" class="w-full py-2 px-4 rounded-lg text-sm font-medium bg-zinc-700 hover:bg-zinc-600 text-white transition-colors">
|
||||
⬇ Download BAS JSON
|
||||
</button>
|
||||
<button id="btn-download-geojson" class="w-full py-2 px-4 rounded-lg text-sm font-medium bg-zinc-700 hover:bg-zinc-600 text-white transition-colors" style="display:none">
|
||||
⬇ Download GeoJSON track
|
||||
</button>
|
||||
{editUrl && (
|
||||
<button id="btn-save" class="w-full py-2 px-4 rounded-lg text-sm font-medium text-white transition-colors" style="background:#3b82f6">
|
||||
☁ Save to my bincio
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p id="conv-save-status" class="mt-2 text-xs text-center" style="min-height:1.25rem"></p>
|
||||
</div>
|
||||
<button id="btn-convert-another" class="text-sm text-zinc-500 hover:text-white transition-colors">
|
||||
← Convert another file
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: error -->
|
||||
<div id="step-error" style="display:none" class="text-center py-12">
|
||||
<div class="text-4xl mb-3">⚠</div>
|
||||
<p id="conv-error-msg" class="text-red-400 text-sm mb-4"></p>
|
||||
<button id="btn-retry" class="text-sm text-zinc-500 hover:text-white transition-colors">← Try another file</button>
|
||||
</div>
|
||||
</div>
|
||||
</Base>
|
||||
|
||||
<script define:vars={{ editUrl, baseUrl }}>
|
||||
// ── DOM refs ────────────────────────────────────────────────────────────────
|
||||
const stepPick = document.getElementById('step-pick');
|
||||
const stepLoading = document.getElementById('step-loading');
|
||||
const stepResult = document.getElementById('step-result');
|
||||
const stepError = document.getElementById('step-error');
|
||||
const drop = document.getElementById('conv-drop');
|
||||
const input = document.getElementById('conv-input');
|
||||
const dropLabel = document.getElementById('conv-drop-label');
|
||||
const loadingMsg = document.getElementById('conv-loading-msg');
|
||||
const progressBar = document.getElementById('conv-progress-bar');
|
||||
const errorMsg = document.getElementById('conv-error-msg');
|
||||
const saveStatus = document.getElementById('conv-save-status');
|
||||
|
||||
function showStep(name) {
|
||||
stepPick.style.display = name === 'pick' ? '' : 'none';
|
||||
stepLoading.style.display = name === 'loading' ? '' : 'none';
|
||||
stepResult.style.display = name === 'result' ? '' : 'none';
|
||||
stepError.style.display = name === 'error' ? '' : 'none';
|
||||
}
|
||||
|
||||
function setProgress(pct, msg) {
|
||||
progressBar.style.width = pct + '%';
|
||||
if (msg) loadingMsg.textContent = msg;
|
||||
}
|
||||
|
||||
// ── Auto-load from recorder (sessionStorage handoff) ───────────────────────
|
||||
window.addEventListener('load', () => {
|
||||
const pending = sessionStorage.getItem('pending_convert');
|
||||
if (pending) {
|
||||
sessionStorage.removeItem('pending_convert');
|
||||
try {
|
||||
const { name, dataUrl } = JSON.parse(pending);
|
||||
fetch(dataUrl).then(r => r.blob()).then(blob => {
|
||||
startConversion(new File([blob], name, { type: 'application/gpx+xml' }));
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
// ── File picking ────────────────────────────────────────────────────────────
|
||||
drop.addEventListener('click', () => input.click());
|
||||
drop.addEventListener('dragover', e => { e.preventDefault(); drop.style.borderColor = 'var(--accent)'; });
|
||||
drop.addEventListener('dragleave', () => { drop.style.borderColor = ''; });
|
||||
drop.addEventListener('drop', e => {
|
||||
e.preventDefault(); drop.style.borderColor = '';
|
||||
if (e.dataTransfer?.files[0]) startConversion(e.dataTransfer.files[0]);
|
||||
});
|
||||
input.addEventListener('change', () => { if (input.files?.[0]) startConversion(input.files[0]); });
|
||||
document.getElementById('btn-retry').addEventListener('click', reset);
|
||||
document.getElementById('btn-convert-another').addEventListener('click', reset);
|
||||
|
||||
function reset() {
|
||||
input.value = '';
|
||||
dropLabel.innerHTML = 'Drop a FIT, GPX, or TCX file<br/>or tap to browse';
|
||||
showStep('pick');
|
||||
}
|
||||
|
||||
// ── Pyodide state ───────────────────────────────────────────────────────────
|
||||
let pyodide = null;
|
||||
let pyodideReady = false;
|
||||
let pyodideLoading = false;
|
||||
|
||||
async function ensurePyodide() {
|
||||
if (pyodideReady) return;
|
||||
if (pyodideLoading) {
|
||||
// Wait for the ongoing load
|
||||
while (!pyodideReady) await new Promise(r => setTimeout(r, 100));
|
||||
return;
|
||||
}
|
||||
pyodideLoading = true;
|
||||
|
||||
setProgress(5, 'Loading Python runtime…');
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide.js';
|
||||
await new Promise((resolve, reject) => {
|
||||
script.onload = resolve; script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
setProgress(20, 'Initialising Python…');
|
||||
pyodide = await window.loadPyodide({ indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.26.4/full/' });
|
||||
|
||||
setProgress(40, 'Loading packages (lxml, pyyaml)…');
|
||||
await pyodide.loadPackage(['lxml', 'pyyaml']);
|
||||
|
||||
setProgress(60, 'Installing fitdecode, gpxpy, rdp…');
|
||||
await pyodide.runPythonAsync(`
|
||||
import micropip
|
||||
await micropip.install(['fitdecode', 'gpxpy', 'rdp'])
|
||||
`);
|
||||
|
||||
setProgress(80, 'Loading bincio extract pipeline…');
|
||||
const wheelUrl = new URL('/bincio.whl', window.location.origin).href;
|
||||
await pyodide.runPythonAsync(`
|
||||
import micropip
|
||||
await micropip.install('${wheelUrl}', deps=False)
|
||||
`);
|
||||
|
||||
setProgress(100, 'Ready.');
|
||||
pyodideReady = true;
|
||||
pyodideLoading = false;
|
||||
}
|
||||
|
||||
// ── Conversion ──────────────────────────────────────────────────────────────
|
||||
let lastResult = null;
|
||||
|
||||
async function startConversion(file) {
|
||||
showStep('loading');
|
||||
setProgress(0, 'Loading converter…');
|
||||
|
||||
let arrayBuffer;
|
||||
try {
|
||||
arrayBuffer = await file.arrayBuffer();
|
||||
} catch (e) {
|
||||
showError('Could not read file: ' + e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ensurePyodide();
|
||||
} catch (e) {
|
||||
showError('Failed to load Python runtime: ' + e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setProgress(100, 'Converting…');
|
||||
|
||||
try {
|
||||
// Write file to Pyodide virtual filesystem
|
||||
const fname = file.name;
|
||||
pyodide.FS.writeFile('/tmp/' + fname, new Uint8Array(arrayBuffer));
|
||||
|
||||
const resultJson = await pyodide.runPythonAsync(`
|
||||
import json, os, shutil
|
||||
from pathlib import Path
|
||||
from bincio.extract.parsers.factory import parse_file
|
||||
from bincio.extract.metrics import compute
|
||||
from bincio.extract.writer import build_summary, make_activity_id, write_activity
|
||||
|
||||
outdir = Path('/tmp/bincio_out')
|
||||
if outdir.exists(): shutil.rmtree(outdir)
|
||||
outdir.mkdir()
|
||||
|
||||
activity = parse_file(Path('/tmp/${fname.replace(/'/g, "\\'")}'))
|
||||
metrics = compute(activity)
|
||||
act_id = make_activity_id(activity)
|
||||
write_activity(activity, metrics, outdir, privacy='public', rdp_epsilon=0.0001)
|
||||
|
||||
detail_path = outdir / 'activities' / f'{act_id}.json'
|
||||
geojson_path = outdir / 'activities' / f'{act_id}.geojson'
|
||||
|
||||
detail = json.loads(detail_path.read_text())
|
||||
geojson = json.loads(geojson_path.read_text()) if geojson_path.exists() else None
|
||||
|
||||
json.dumps({'id': act_id, 'detail': detail, 'geojson': geojson})
|
||||
`);
|
||||
|
||||
lastResult = JSON.parse(resultJson);
|
||||
showResult(lastResult);
|
||||
} catch (e) {
|
||||
showError(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Result display ──────────────────────────────────────────────────────────
|
||||
const SPORT_ICONS = { cycling: '🚴', running: '🏃', hiking: '🥾', walking: '🚶', swimming: '🏊', skiing: '⛷️', other: '⚡' };
|
||||
|
||||
function showResult(r) {
|
||||
const d = r.detail;
|
||||
document.getElementById('res-sport-icon').textContent = SPORT_ICONS[d.sport] ?? '⚡';
|
||||
document.getElementById('res-title').textContent = d.title || 'Untitled';
|
||||
document.getElementById('res-date').textContent = d.started_at ? new Date(d.started_at).toLocaleString() : '';
|
||||
|
||||
const distKm = d.distance_m ? (d.distance_m / 1000).toFixed(1) + ' km' : '—';
|
||||
const dur = d.moving_time_s ?? d.duration_s;
|
||||
const durStr = dur ? `${Math.floor(dur/3600)}h ${Math.floor((dur%3600)/60)}m` : '—';
|
||||
const elevStr = d.elevation_gain_m ? Math.round(d.elevation_gain_m) + ' m' : '—';
|
||||
document.getElementById('res-dist').textContent = distKm;
|
||||
document.getElementById('res-time').textContent = durStr;
|
||||
document.getElementById('res-elev').textContent = elevStr;
|
||||
|
||||
document.getElementById('btn-download-geojson').style.display = r.geojson ? '' : 'none';
|
||||
showStep('result');
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
errorMsg.textContent = msg;
|
||||
showStep('error');
|
||||
}
|
||||
|
||||
// ── Downloads ───────────────────────────────────────────────────────────────
|
||||
function downloadJson(data, filename) {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = filename; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
document.getElementById('btn-download-json').addEventListener('click', () => {
|
||||
if (lastResult) downloadJson(lastResult.detail, lastResult.id + '.json');
|
||||
});
|
||||
|
||||
document.getElementById('btn-download-geojson').addEventListener('click', () => {
|
||||
if (lastResult?.geojson) downloadJson(lastResult.geojson, lastResult.id + '.geojson');
|
||||
});
|
||||
|
||||
// ── Save to bincio ──────────────────────────────────────────────────────────
|
||||
const saveBtn = document.getElementById('btn-save');
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
if (!lastResult) return;
|
||||
saveBtn.disabled = true;
|
||||
saveStatus.textContent = 'Saving…';
|
||||
saveStatus.style.color = 'var(--text-4)';
|
||||
|
||||
// Reconstruct a file blob from the detail JSON and POST as a virtual upload
|
||||
// The edit server expects a raw activity file, so we use a dedicated endpoint instead
|
||||
const target = (localStorage.getItem('bincio_edit_url') || editUrl).replace(/\/$/, '');
|
||||
try {
|
||||
const r = await fetch(`${target}/api/import-bas`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ detail: lastResult.detail, geojson: lastResult.geojson }),
|
||||
});
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
const data = await r.json();
|
||||
saveStatus.textContent = 'Saved!';
|
||||
saveStatus.style.color = '#4ade80';
|
||||
setTimeout(() => { window.location.href = `${baseUrl}activity/${data.id}/`; }, 800);
|
||||
} catch (e) {
|
||||
saveStatus.textContent = 'Error: ' + e.message;
|
||||
saveStatus.style.color = '#f87171';
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,248 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
const baseUrl = import.meta.env.BASE_URL ?? '/';
|
||||
---
|
||||
<Base title="Record activity — BincioActivity">
|
||||
<div class="max-w-lg mx-auto">
|
||||
<h1 class="text-xl font-bold text-white mb-1">Record activity</h1>
|
||||
<p class="text-sm text-zinc-400 mb-6">
|
||||
Record a GPS track and convert it to BAS format directly on your device.
|
||||
</p>
|
||||
|
||||
<!-- Sport selector -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs text-zinc-500 mb-1">Sport</label>
|
||||
<div class="flex flex-wrap gap-2" id="sport-picker">
|
||||
{[
|
||||
['cycling', '🚴'], ['running', '🏃'], ['hiking', '🥾'],
|
||||
['walking', '🚶'], ['swimming', '🏊'], ['skiing', '⛷️'], ['other', '⚡'],
|
||||
].map(([s, icon]) => (
|
||||
<button
|
||||
data-sport={s}
|
||||
class="sport-btn px-3 py-1.5 rounded-full text-sm border border-zinc-700 text-zinc-400 transition-colors"
|
||||
>{icon} {s.charAt(0).toUpperCase() + s.slice(1)}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs text-zinc-500 mb-1">Activity title (optional)</label>
|
||||
<input
|
||||
id="rec-title"
|
||||
type="text"
|
||||
placeholder="Morning ride"
|
||||
class="w-full px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-white placeholder-zinc-600 outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Live stats -->
|
||||
<div class="grid grid-cols-3 gap-3 text-center mb-4">
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-3">
|
||||
<p id="stat-dist" class="text-2xl font-bold text-white">0.0</p>
|
||||
<p class="text-xs text-zinc-500">km</p>
|
||||
</div>
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-3">
|
||||
<p id="stat-time" class="text-2xl font-bold text-white">0:00</p>
|
||||
<p class="text-xs text-zinc-500">duration</p>
|
||||
</div>
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-3">
|
||||
<p id="stat-pts" class="text-2xl font-bold text-white">0</p>
|
||||
<p class="text-xs text-zinc-500">points</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GPS accuracy indicator -->
|
||||
<p id="gps-accuracy" class="text-xs text-zinc-500 text-center mb-4"></p>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
id="btn-start"
|
||||
class="flex-1 py-3 rounded-xl font-semibold text-sm text-white transition-colors"
|
||||
style="background:#22c55e"
|
||||
>▶ Start</button>
|
||||
<button
|
||||
id="btn-pause"
|
||||
style="display:none"
|
||||
class="flex-1 py-3 rounded-xl font-semibold text-sm text-white bg-yellow-600 hover:bg-yellow-500 transition-colors"
|
||||
>⏸ Pause</button>
|
||||
<button
|
||||
id="btn-stop"
|
||||
style="display:none"
|
||||
class="flex-1 py-3 rounded-xl font-semibold text-sm text-white bg-red-600 hover:bg-red-500 transition-colors"
|
||||
>⏹ Stop & convert</button>
|
||||
</div>
|
||||
|
||||
<p id="rec-status" class="mt-3 text-xs text-zinc-500 text-center" style="min-height:1.25rem"></p>
|
||||
</div>
|
||||
</Base>
|
||||
|
||||
<script define:vars={{ baseUrl }}>
|
||||
// ── Sport picker ────────────────────────────────────────────────────────────
|
||||
let selectedSport = 'cycling';
|
||||
document.querySelectorAll('.sport-btn').forEach(btn => {
|
||||
if (btn.dataset.sport === selectedSport) btn.classList.add('border-blue-500', 'text-white');
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.sport-btn').forEach(b => b.classList.remove('border-blue-500', 'text-white'));
|
||||
btn.classList.add('border-blue-500', 'text-white');
|
||||
selectedSport = btn.dataset.sport;
|
||||
});
|
||||
});
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
let recording = false;
|
||||
let paused = false;
|
||||
let points = []; // {lat, lon, ele, speed, time, accuracy}
|
||||
let watchId = null;
|
||||
let startTime = null;
|
||||
let timerInterval = null;
|
||||
let totalDistance = 0;
|
||||
|
||||
const btnStart = document.getElementById('btn-start');
|
||||
const btnPause = document.getElementById('btn-pause');
|
||||
const btnStop = document.getElementById('btn-stop');
|
||||
const recStatus = document.getElementById('rec-status');
|
||||
|
||||
// ── GPS ─────────────────────────────────────────────────────────────────────
|
||||
async function getGeolocation() {
|
||||
// Use Capacitor Geolocation if available, fall back to browser API
|
||||
if (window.Capacitor?.isNativePlatform()) {
|
||||
const { Geolocation } = await import('@capacitor/geolocation');
|
||||
return Geolocation;
|
||||
}
|
||||
// Browser fallback — wraps navigator.geolocation in the same interface
|
||||
return {
|
||||
watchPosition: (opts, callback) => {
|
||||
const id = navigator.geolocation.watchPosition(
|
||||
pos => callback(pos, null),
|
||||
err => callback(null, err),
|
||||
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 0 },
|
||||
);
|
||||
return Promise.resolve(id);
|
||||
},
|
||||
clearWatch: ({ id }) => navigator.geolocation.clearWatch(id),
|
||||
};
|
||||
}
|
||||
|
||||
function haversineM(lat1, lon1, lat2, lon2) {
|
||||
const R = 6371000, r = Math.PI / 180;
|
||||
const dLat = (lat2 - lat1) * r, dLon = (lon2 - lon1) * r;
|
||||
const a = Math.sin(dLat/2)**2 + Math.cos(lat1*r)*Math.cos(lat2*r)*Math.sin(dLon/2)**2;
|
||||
return 2 * R * Math.asin(Math.sqrt(a));
|
||||
}
|
||||
|
||||
// ── Controls ─────────────────────────────────────────────────────────────────
|
||||
btnStart.addEventListener('click', async () => {
|
||||
if (!navigator.geolocation && !window.Capacitor?.isNativePlatform()) {
|
||||
recStatus.textContent = 'GPS not available on this device.';
|
||||
return;
|
||||
}
|
||||
btnStart.style.display = 'none';
|
||||
btnPause.style.display = '';
|
||||
btnStop.style.display = '';
|
||||
recording = true;
|
||||
paused = false;
|
||||
points = [];
|
||||
totalDistance = 0;
|
||||
startTime = Date.now();
|
||||
|
||||
timerInterval = setInterval(updateTimer, 1000);
|
||||
recStatus.textContent = 'Waiting for GPS fix…';
|
||||
|
||||
try {
|
||||
const geo = await getGeolocation();
|
||||
watchId = await geo.watchPosition(
|
||||
{ enableHighAccuracy: true, timeout: 10000 },
|
||||
(pos, err) => {
|
||||
if (err) { recStatus.textContent = 'GPS error: ' + (err.message || err); return; }
|
||||
if (!recording || paused) return;
|
||||
const { latitude: lat, longitude: lon, altitude: ele, speed, accuracy } = pos.coords;
|
||||
if (points.length > 0) {
|
||||
const prev = points[points.length - 1];
|
||||
totalDistance += haversineM(prev.lat, prev.lon, lat, lon);
|
||||
}
|
||||
points.push({ lat, lon, ele: ele ?? null, speed: speed ?? null, time: new Date().toISOString(), accuracy });
|
||||
document.getElementById('stat-pts').textContent = points.length;
|
||||
document.getElementById('stat-dist').textContent = (totalDistance / 1000).toFixed(2);
|
||||
document.getElementById('gps-accuracy').textContent = accuracy ? `GPS accuracy: ±${Math.round(accuracy)} m` : '';
|
||||
recStatus.textContent = 'Recording…';
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
recStatus.textContent = 'Could not start GPS: ' + e.message;
|
||||
}
|
||||
});
|
||||
|
||||
btnPause.addEventListener('click', () => {
|
||||
paused = !paused;
|
||||
btnPause.textContent = paused ? '▶ Resume' : '⏸ Pause';
|
||||
recStatus.textContent = paused ? 'Paused' : 'Recording…';
|
||||
});
|
||||
|
||||
btnStop.addEventListener('click', async () => {
|
||||
recording = false;
|
||||
clearInterval(timerInterval);
|
||||
if (watchId !== null) {
|
||||
try {
|
||||
const geo = await getGeolocation();
|
||||
await geo.clearWatch({ id: watchId });
|
||||
} catch (_) {}
|
||||
watchId = null;
|
||||
}
|
||||
if (points.length < 2) {
|
||||
recStatus.textContent = 'Not enough points recorded (minimum 2).';
|
||||
btnStart.style.display = '';
|
||||
btnPause.style.display = 'none';
|
||||
btnStop.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
exportAndConvert();
|
||||
});
|
||||
|
||||
// ── Timer ───────────────────────────────────────────────────────────────────
|
||||
function updateTimer() {
|
||||
if (!startTime) return;
|
||||
const s = Math.floor((Date.now() - startTime) / 1000);
|
||||
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
|
||||
document.getElementById('stat-time').textContent =
|
||||
h > 0 ? `${h}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`
|
||||
: `${m}:${String(sec).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
// ── GPX export → /convert/ ──────────────────────────────────────────────────
|
||||
function exportAndConvert() {
|
||||
const title = document.getElementById('rec-title').value.trim() || 'Recorded activity';
|
||||
const gpx = buildGPX(points, title, selectedSport);
|
||||
const blob = new Blob([gpx], { type: 'application/gpx+xml' });
|
||||
|
||||
// Pass the GPX to the convert page via a temporary object URL stored in sessionStorage
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
sessionStorage.setItem('pending_convert', JSON.stringify({
|
||||
name: `${title.replace(/[^a-z0-9]/gi, '_')}.gpx`,
|
||||
dataUrl: reader.result,
|
||||
}));
|
||||
window.location.href = `${baseUrl}convert/`;
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
}
|
||||
|
||||
function buildGPX(pts, name, sport) {
|
||||
const lines = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<gpx version="1.1" creator="BincioActivity" xmlns="http://www.topografix.com/GPX/1/1">',
|
||||
` <metadata><name>${escXml(name)}</name></metadata>`,
|
||||
` <trk><name>${escXml(name)}</name><type>${escXml(sport)}</type><trkseg>`,
|
||||
];
|
||||
for (const p of pts) {
|
||||
const ele = p.ele !== null ? `<ele>${p.ele.toFixed(1)}</ele>` : '';
|
||||
const spd = p.speed !== null ? `<extensions><speed>${p.speed.toFixed(3)}</speed></extensions>` : '';
|
||||
lines.push(` <trkpt lat="${p.lat}" lon="${p.lon}"><time>${p.time}</time>${ele}${spd}</trkpt>`);
|
||||
}
|
||||
lines.push(' </trkseg></trk>', '</gpx>');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function escXml(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
</script>
|
||||
Reference in New Issue
Block a user