858 lines
34 KiB
HTML
858 lines
34 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block head %}
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
|
|
<style>
|
|
#map {
|
|
height: 500px;
|
|
width: 100%;
|
|
border-radius: 8px;
|
|
margin-bottom: 20px;
|
|
z-index: 1;
|
|
/* Ensure it stays below navbars if any */
|
|
}
|
|
|
|
.metric-card {
|
|
transition: transform 0.2s;
|
|
}
|
|
|
|
.metric-card:hover {
|
|
transform: translateY(-5px);
|
|
}
|
|
</style>
|
|
</style>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<div>
|
|
<h2 id="act-name">Loading...</h2>
|
|
<p class="text-muted mb-0">
|
|
<span id="act-time">-</span> |
|
|
<span id="act-type">-</span> |
|
|
<span id="act-id">#</span>
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<div class="btn-group me-2">
|
|
<a href="#" class="btn btn-outline-secondary disabled" id="nav-prev-type" title="Previous of same type">
|
|
<i class="bi bi-chevron-double-left"></i>
|
|
</a>
|
|
<a href="#" class="btn btn-outline-secondary disabled" id="nav-prev" title="Previous">
|
|
<i class="bi bi-chevron-left"></i>
|
|
</a>
|
|
<a href="#" class="btn btn-outline-secondary disabled" id="nav-next" title="Next">
|
|
<i class="bi bi-chevron-right"></i>
|
|
</a>
|
|
<a href="#" class="btn btn-outline-secondary disabled" id="nav-next-type" title="Next of same type">
|
|
<i class="bi bi-chevron-double-right"></i>
|
|
</a>
|
|
</div>
|
|
<button class="btn btn-outline-secondary" onclick="window.close()">
|
|
<i class="bi bi-x-lg"></i> Close
|
|
</button>
|
|
<button class="btn btn-primary" id="download-btn">
|
|
<i class="bi bi-download"></i> Download
|
|
</button>
|
|
<button class="btn btn-outline-warning" id="refresh-btn" onclick="refreshActivity()">
|
|
<i class="bi bi-arrow-clockwise"></i> Refresh & Match
|
|
</button>
|
|
<button class="btn btn-warning" id="estimate-power-btn" onclick="estimatePower()">
|
|
<i class="bi bi-lightning-fill"></i> Estimate Power
|
|
</button>
|
|
<button class="btn btn-success" id="create-segment-btn" onclick="toggleSegmentMode()">
|
|
<i class="bi bi-bezier2"></i> Create Segment
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Map Section -->
|
|
<div class="card mb-4 shadow-sm">
|
|
<div class="card-body p-0">
|
|
<div id="map"></div>
|
|
<div id="no-map-msg" class="text-center p-5 text-muted" style="display:none;">
|
|
<i class="bi bi-geo-alt-fill" style="font-size: 2rem;"></i>
|
|
<p class="mt-2">No GPS data available for this activity.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Metrics Overview -->
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-md-3">
|
|
<div class="card h-100 bg-light">
|
|
<div class="card-body text-center">
|
|
<i class="bi bi-cursor text-primary mb-2" style="font-size: 1.5rem;"></i>
|
|
<h6 class="card-subtitle text-muted mb-1">Distance</h6>
|
|
<h3 class="card-title text-primary" id="metric-dist">-</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card h-100 bg-light">
|
|
<div class="card-body text-center">
|
|
<i class="bi bi-stopwatch text-success mb-2" style="font-size: 1.5rem;"></i>
|
|
<h6 class="card-subtitle text-muted mb-1">Duration</h6>
|
|
<h3 class="card-title text-success" id="metric-dur">-</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card h-100 bg-light">
|
|
<div class="card-body text-center">
|
|
<i class="bi bi-heart text-danger mb-2" style="font-size: 1.5rem;"></i>
|
|
<h6 class="card-subtitle text-muted mb-1">Avg HR</h6>
|
|
<h3 class="card-title text-danger" id="metric-hr">-</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card h-100 bg-light">
|
|
<div class="card-body text-center">
|
|
<i class="bi bi-fire text-warning mb-2" style="font-size: 1.5rem;"></i>
|
|
<h6 class="card-subtitle text-muted mb-1">Calories</h6>
|
|
<h3 class="card-title text-warning" id="metric-cal">-</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Detailed Metrics Grid -->
|
|
<h4 class="mb-3">Detailed Metrics</h4>
|
|
<div class="row g-3">
|
|
<!-- Segments Card -->
|
|
<div class="col-12 mb-4">
|
|
<div class="card">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h5 class="mb-0">Matched Segments</h5>
|
|
<!-- Could trigger re-scan here -->
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0" id="efforts-table">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>Segment</th>
|
|
<th>Time</th>
|
|
<th>Awards</th>
|
|
<th>Rank</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr id="efforts-loading">
|
|
<td colspan="4" class="text-center text-muted">Loading segments...</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Heart Rate -->
|
|
<div class="col-md-4">
|
|
<div class="card h-100 metric-card border-danger">
|
|
<div class="card-header bg-danger text-white">Heart Rate</div>
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between mb-2"><span>Average:</span> <strong id="m-avg-hr">-</strong>
|
|
bpm</div>
|
|
<div class="d-flex justify-content-between"><span>Max:</span> <strong id="m-max-hr">-</strong> bpm</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Speed/Pace -->
|
|
<div class="col-md-4">
|
|
<div class="card h-100 metric-card border-primary">
|
|
<div class="card-header bg-primary text-white">Speed / Pace</div>
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between mb-2"><span>Avg Speed:</span> <strong
|
|
id="m-avg-spd">-</strong> km/h</div>
|
|
<div class="d-flex justify-content-between"><span>Max Speed:</span> <strong id="m-max-spd">-</strong>
|
|
km/h</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Power -->
|
|
<div class="col-md-4">
|
|
<div class="card h-100 metric-card border-warning">
|
|
<div class="card-header bg-warning text-dark">Power</div>
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between mb-2"><span>Avg Power:</span> <strong
|
|
id="m-avg-pwr">-</strong> W</div>
|
|
<div class="d-flex justify-content-between mb-2"><span>Max Power:</span> <strong
|
|
id="m-max-pwr">-</strong> W</div>
|
|
<div class="d-flex justify-content-between mb-2"><span>Norm Power:</span> <strong
|
|
id="m-norm-pwr">-</strong> W</div>
|
|
<div class="d-flex justify-content-between"><span>VO2 Max:</span> <strong id="m-vo2">-</strong></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Elevation -->
|
|
<div class="col-md-4">
|
|
<div class="card h-100 metric-card border-success">
|
|
<div class="card-header bg-success text-white">Elevation</div>
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between mb-2"><span>Gain:</span> <strong id="m-ele-gain">-</strong> m
|
|
</div>
|
|
<div class="d-flex justify-content-between"><span>Loss:</span> <strong id="m-ele-loss">-</strong> m
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Training Effect -->
|
|
<div class="col-md-4">
|
|
<div class="card h-100 metric-card border-info">
|
|
<div class="card-header bg-info text-white">Training Effect</div>
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between mb-2"><span>Aerobic:</span> <strong id="m-aerobic">-</strong>
|
|
</div>
|
|
<div class="d-flex justify-content-between mb-2"><span>Anaerobic:</span> <strong
|
|
id="m-anaerobic">-</strong></div>
|
|
<div class="d-flex justify-content-between"><span>TSS:</span> <strong id="m-tss">-</strong></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Cadence -->
|
|
<div class="col-md-4">
|
|
<div class="card h-100 metric-card border-secondary">
|
|
<div class="card-header bg-secondary text-white">Cadence</div>
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between mb-2"><span>Avg:</span> <strong id="m-avg-cad">-</strong>
|
|
</div>
|
|
<div class="d-flex justify-content-between"><span>Max:</span> <strong id="m-max-cad">-</strong></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bike Info (New) -->
|
|
<div class="col-md-4">
|
|
<div class="card h-100 metric-card border-light shadow-sm">
|
|
<div class="card-header bg-light text-dark d-flex justify-content-between align-items-center">
|
|
<span>Bike Setup</span>
|
|
<button class="btn btn-sm btn-link p-0 text-decoration-none" onclick="editBikeSetup()">Edit</button>
|
|
</div>
|
|
<div class="card-body">
|
|
<div id="m-bike-info" class="text-center text-muted">No Setup</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Charts Section -->
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-12">
|
|
<div class="card shadow-sm">
|
|
<div class="card-header">
|
|
Activity Streams
|
|
</div>
|
|
<div class="card-body">
|
|
<canvas id="streams-chart" style="max-height: 400px;"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
|
<script>
|
|
const activityId = "{{ activity_id }}";
|
|
let map = null;
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
// Init Map
|
|
map = L.map('map').setView([0, 0], 2);
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
}).addTo(map);
|
|
|
|
await loadDetails();
|
|
await loadMapData();
|
|
|
|
document.getElementById('download-btn').onclick = () => {
|
|
window.location.href = `/api/activities/download/${activityId}`;
|
|
};
|
|
|
|
loadNavigation();
|
|
loadCharts();
|
|
});
|
|
|
|
async function loadNavigation() {
|
|
try {
|
|
const res = await fetch(`/api/activities/${activityId}/navigation`);
|
|
if (res.ok) {
|
|
const nav = await res.json();
|
|
|
|
function setBtn(id, targetId) {
|
|
const el = document.getElementById(id);
|
|
if (targetId) {
|
|
el.href = `/activity/${targetId}`;
|
|
el.classList.remove('disabled');
|
|
}
|
|
}
|
|
|
|
setBtn('nav-prev', nav.prev_id);
|
|
setBtn('nav-next', nav.next_id);
|
|
setBtn('nav-prev-type', nav.prev_type_id);
|
|
setBtn('nav-next-type', nav.next_type_id);
|
|
}
|
|
} catch (e) { console.error("Nav load failed", e); }
|
|
}
|
|
|
|
let chartInstance = null;
|
|
|
|
async function loadCharts() {
|
|
try {
|
|
const res = await fetch(`/api/activities/${activityId}/streams`);
|
|
if (!res.ok) return; // No streams
|
|
const data = await res.json();
|
|
|
|
if (!data.time || data.time.length === 0) return;
|
|
|
|
const ctx = document.getElementById('streams-chart').getContext('2d');
|
|
|
|
// Prepare datasets
|
|
const datasets = [];
|
|
|
|
if (data.heart_rate && data.heart_rate.some(x => x)) {
|
|
datasets.push({
|
|
label: 'Heart Rate (bpm)',
|
|
data: data.heart_rate,
|
|
borderColor: 'rgb(220, 53, 69)',
|
|
backgroundColor: 'rgba(220, 53, 69, 0.1)',
|
|
yAxisID: 'y-hr',
|
|
tension: 0.2,
|
|
pointRadius: 0
|
|
});
|
|
}
|
|
|
|
if (data.speed && data.speed.some(x => x)) {
|
|
// Convert m/s to km/h
|
|
const speedKmh = data.speed.map(s => s ? s * 3.6 : null);
|
|
datasets.push({
|
|
label: 'Speed (km/h)',
|
|
data: speedKmh,
|
|
borderColor: 'rgb(13, 110, 253)',
|
|
backgroundColor: 'rgba(13, 110, 253, 0.1)',
|
|
yAxisID: 'y-speed',
|
|
tension: 0.2,
|
|
pointRadius: 0
|
|
});
|
|
}
|
|
|
|
if (data.power && data.power.some(x => x)) {
|
|
datasets.push({
|
|
label: 'Power (W)',
|
|
data: data.power,
|
|
borderColor: 'rgb(255, 193, 7)',
|
|
backgroundColor: 'rgba(255, 193, 7, 0.1)',
|
|
yAxisID: 'y-power',
|
|
tension: 0.2,
|
|
pointRadius: 0
|
|
});
|
|
}
|
|
|
|
if (data.altitude && data.altitude.some(x => x)) {
|
|
datasets.push({
|
|
label: 'Elevation (m)',
|
|
data: data.altitude,
|
|
borderColor: 'rgb(25, 135, 84)',
|
|
backgroundColor: 'rgba(25, 135, 84, 0.1)',
|
|
yAxisID: 'y-ele',
|
|
tension: 0.2,
|
|
pointRadius: 0,
|
|
fill: true
|
|
});
|
|
}
|
|
|
|
if (data.cadence && data.cadence.some(x => x)) {
|
|
datasets.push({
|
|
label: 'Cadence (rpm)',
|
|
data: data.cadence,
|
|
borderColor: 'rgb(108, 117, 125)',
|
|
backgroundColor: 'rgba(108, 117, 125, 0.1)',
|
|
yAxisID: 'y-cad',
|
|
tension: 0.2,
|
|
pointRadius: 0,
|
|
hidden: true
|
|
});
|
|
}
|
|
|
|
// Seconds to H:M:S format for labels
|
|
const labels = data.time.map(t => {
|
|
const h = Math.floor(t / 3600);
|
|
const m = Math.floor((t % 3600) / 60);
|
|
return h > 0 ? `${h}h${m}m` : `${m}m`;
|
|
});
|
|
|
|
chartInstance = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: datasets
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
interaction: {
|
|
mode: 'index',
|
|
intersect: false,
|
|
},
|
|
scales: {
|
|
x: {
|
|
ticks: { maxTicksLimit: 10 }
|
|
},
|
|
'y-hr': {
|
|
type: 'linear',
|
|
display: !!datasets.find(d => d.yAxisID === 'y-hr'),
|
|
position: 'right',
|
|
title: { display: true, text: 'Heart Rate' },
|
|
grid: { drawOnChartArea: false }
|
|
},
|
|
'y-power': {
|
|
type: 'linear',
|
|
display: !!datasets.find(d => d.yAxisID === 'y-power'),
|
|
position: 'left',
|
|
title: { display: true, text: 'Power' }
|
|
},
|
|
'y-speed': {
|
|
type: 'linear',
|
|
display: !!datasets.find(d => d.yAxisID === 'y-speed'),
|
|
position: 'right',
|
|
title: { display: true, text: 'Speed' },
|
|
grid: { drawOnChartArea: false }
|
|
},
|
|
'y-ele': {
|
|
type: 'linear',
|
|
display: !!datasets.find(d => d.yAxisID === 'y-ele'),
|
|
position: 'right',
|
|
title: { display: true, text: 'Elevation' },
|
|
grid: { drawOnChartArea: false }
|
|
},
|
|
'y-cad': {
|
|
type: 'linear',
|
|
display: !!datasets.find(d => d.yAxisID === 'y-cad'),
|
|
position: 'right',
|
|
title: { display: true, text: 'Cadence' },
|
|
grid: { drawOnChartArea: false }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
} catch (e) {
|
|
console.error("Chart load failed", e);
|
|
}
|
|
}
|
|
|
|
async function loadDetails() {
|
|
try {
|
|
const res = await fetch(`/api/activities/${activityId}/details`);
|
|
if (!res.ok) throw new Error("Failed to load details");
|
|
const data = await res.json();
|
|
window.currentDbId = data.id; // Store for segment creation
|
|
|
|
// Header
|
|
document.getElementById('act-name').textContent = data.activity_name || 'Untitled Activity';
|
|
document.getElementById('act-time').textContent = new Date(data.start_time).toLocaleString();
|
|
document.getElementById('act-type').textContent = data.activity_type;
|
|
document.getElementById('act-id').textContent = data.garmin_activity_id;
|
|
|
|
// Overview Cards
|
|
document.getElementById('metric-dist').textContent = data.distance ? (data.distance / 1000).toFixed(2) + ' km' : '-';
|
|
document.getElementById('metric-dur').textContent = formatDuration(data.duration);
|
|
document.getElementById('metric-hr').textContent = data.avg_hr ? data.avg_hr + ' bpm' : '-';
|
|
document.getElementById('metric-cal').textContent = data.calories || '-';
|
|
|
|
// Detail Cards
|
|
// HR
|
|
document.getElementById('m-avg-hr').textContent = data.avg_hr || '-';
|
|
document.getElementById('m-max-hr').textContent = data.max_hr || '-';
|
|
// Speed
|
|
document.getElementById('m-avg-spd').textContent = data.avg_speed ? (data.avg_speed * 3.6).toFixed(1) : '-';
|
|
document.getElementById('m-max-spd').textContent = data.max_speed ? (data.max_speed * 3.6).toFixed(1) : '-';
|
|
// Power
|
|
document.getElementById('m-avg-pwr').textContent = data.avg_power || '-';
|
|
document.getElementById('m-max-pwr').textContent = data.max_power || '-';
|
|
document.getElementById('m-norm-pwr').textContent = data.norm_power || '-';
|
|
document.getElementById('m-vo2').textContent = data.vo2_max || '-';
|
|
// Elevation
|
|
document.getElementById('m-ele-gain').textContent = data.elevation_gain || '-';
|
|
document.getElementById('m-ele-loss').textContent = data.elevation_loss || '-';
|
|
// TE
|
|
document.getElementById('m-aerobic').textContent = data.aerobic_te || '-';
|
|
document.getElementById('m-anaerobic').textContent = data.anaerobic_te || '-';
|
|
document.getElementById('m-tss').textContent = data.tss || '-';
|
|
// Cadence
|
|
document.getElementById('m-avg-cad').textContent = data.avg_cadence || '-';
|
|
document.getElementById('m-max-cad').textContent = data.max_cadence || '-';
|
|
|
|
// Bike
|
|
if (data.bike_setup) {
|
|
const b = data.bike_setup;
|
|
const txt = b.name ? `<strong>${b.name}</strong><br>${b.frame} ${b.chainring}/${b.rear_cog}` : `${b.frame} ${b.chainring}/${b.rear_cog}`;
|
|
document.getElementById('m-bike-info').innerHTML = txt;
|
|
}
|
|
|
|
// Load Efforts
|
|
if (window.currentDbId) {
|
|
loadEfforts(window.currentDbId);
|
|
}
|
|
|
|
} catch (e) {
|
|
console.error(e);
|
|
showToast("Error", "Failed to load activity details", "error");
|
|
}
|
|
}
|
|
|
|
async function loadMapData() {
|
|
try {
|
|
const res = await fetch(`/api/activities/${activityId}/geojson`);
|
|
if (res.ok) {
|
|
const geojson = await res.json();
|
|
if (geojson.features && geojson.features.length > 0 && geojson.features[0].geometry.coordinates.length > 0) {
|
|
// GeoJSON coords are [lon, lat]. Leaflet wants [lat, lon]
|
|
const coords = geojson.features[0].geometry.coordinates;
|
|
trackPoints = coords.map(p => [p[1], p[0]]);
|
|
|
|
const layer = L.geoJSON(geojson, {
|
|
style: { color: 'red', weight: 4, opacity: 0.7 }
|
|
}).addTo(map);
|
|
map.fitBounds(layer.getBounds());
|
|
} else {
|
|
document.getElementById('map').style.display = 'none';
|
|
document.getElementById('no-map-msg').style.display = 'block';
|
|
}
|
|
} else {
|
|
throw new Error("Failed to load map data");
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
document.getElementById('map').style.display = 'none';
|
|
document.getElementById('no-map-msg').style.display = 'block';
|
|
document.getElementById('no-map-msg').querySelector('p').textContent = "Map data unavailable.";
|
|
}
|
|
}
|
|
|
|
function formatDuration(s) { if (!s) return '-'; const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60; return `${h}h ${m}m ${sec}s`; }
|
|
|
|
// Segment Creation Logic
|
|
let segmentMode = false;
|
|
let startMarker = null;
|
|
let endMarker = null;
|
|
let trackPoints = []; // List of [lat, lon] from GeoJSON
|
|
let startIndex = 0;
|
|
let endIndex = 0;
|
|
|
|
function toggleSegmentMode() {
|
|
segmentMode = !segmentMode;
|
|
const btn = document.getElementById('create-segment-btn');
|
|
if (segmentMode) {
|
|
btn.classList.add('active');
|
|
btn.innerHTML = '<i class="bi bi-check-lg"></i> Save Segment';
|
|
btn.onclick = saveSegment;
|
|
initSegmentMarkers();
|
|
} else {
|
|
// Cancelled
|
|
btn.classList.remove('active');
|
|
btn.innerHTML = '<i class="bi bi-bezier2"></i> Create Segment';
|
|
btn.onclick = toggleSegmentMode;
|
|
removeSegmentMarkers();
|
|
}
|
|
}
|
|
|
|
function removeSegmentMarkers() {
|
|
if (startMarker) map.removeLayer(startMarker);
|
|
if (endMarker) map.removeLayer(endMarker);
|
|
startMarker = null;
|
|
endMarker = null;
|
|
}
|
|
|
|
function initSegmentMarkers() {
|
|
if (trackPoints.length < 2) {
|
|
alert("Not enough points to create a segment.");
|
|
toggleSegmentMode();
|
|
return;
|
|
}
|
|
|
|
// Default positions: 20% and 80%
|
|
startIndex = Math.floor(trackPoints.length * 0.2);
|
|
endIndex = Math.floor(trackPoints.length * 0.8);
|
|
|
|
const startIcon = L.divIcon({ className: 'bg-success rounded-circle border border-white', iconSize: [12, 12] });
|
|
const endIcon = L.divIcon({ className: 'bg-danger rounded-circle border border-white', iconSize: [12, 12] });
|
|
|
|
startMarker = L.marker(trackPoints[startIndex], { draggable: true, icon: startIcon }).addTo(map);
|
|
endMarker = L.marker(trackPoints[endIndex], { draggable: true, icon: endIcon }).addTo(map);
|
|
|
|
// Snap logic
|
|
function setupDrag(marker, isStart) {
|
|
marker.on('drag', function (e) {
|
|
const ll = e.latlng;
|
|
let closestDist = Infinity;
|
|
let closestIdx = -1;
|
|
// Simple snap for visual feedback during drag
|
|
for (let i = 0; i < trackPoints.length; i++) {
|
|
const d = map.distance(ll, trackPoints[i]);
|
|
if (d < closestDist) {
|
|
closestDist = d;
|
|
closestIdx = i;
|
|
}
|
|
}
|
|
// Optional: visual snap? Leaflet handles drag msg.
|
|
});
|
|
|
|
marker.on('dragend', function (e) {
|
|
const ll = e.target.getLatLng();
|
|
let closestDist = Infinity;
|
|
let closestIdx = -1;
|
|
|
|
// constrain search
|
|
let searchStart = 0;
|
|
let searchEnd = trackPoints.length;
|
|
|
|
if (isStart) {
|
|
// Start marker: can search 0 to trackPoints.length
|
|
// Heuristic: If we are modifying Start, look for points < endIndex (if valid).
|
|
if (endIndex > 0) searchEnd = endIndex;
|
|
} else {
|
|
// End marker
|
|
if (startIndex >= 0) searchStart = startIndex;
|
|
}
|
|
|
|
// "Stickiness" logic
|
|
const currentIndex = isStart ? startIndex : endIndex;
|
|
const indexPenalty = 0.0001;
|
|
|
|
for (let i = searchStart; i < searchEnd; i++) {
|
|
const d_spatial = map.distance(ll, trackPoints[i]);
|
|
const d_index = Math.abs(i - currentIndex);
|
|
|
|
const score = d_spatial + (d_index * indexPenalty);
|
|
|
|
if (score < closestDist) {
|
|
closestDist = score;
|
|
closestIdx = i;
|
|
}
|
|
}
|
|
|
|
if (closestIdx !== -1) {
|
|
marker.setLatLng(trackPoints[closestIdx]);
|
|
if (isStart) startIndex = closestIdx;
|
|
else endIndex = closestIdx;
|
|
}
|
|
});
|
|
}
|
|
|
|
setupDrag(startMarker, true);
|
|
setupDrag(endMarker, false);
|
|
}
|
|
|
|
async function refreshActivity() {
|
|
if (!confirm("Are you sure you want to re-download this activity from Garmin and run bike matching? This will overwrite any manual bike selection.")) {
|
|
return;
|
|
}
|
|
|
|
const btn = document.getElementById('refresh-btn');
|
|
const origHtml = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Refreshing...';
|
|
|
|
// Helper toast fallback if showToast not defined in this view (it inherits from base.html usually?)
|
|
// base.html usually has showToast.
|
|
if (typeof showToast === 'function') showToast("Processing...", "Refreshing activity data...", "info");
|
|
|
|
try {
|
|
const res = await fetch(`/api/activities/${activityId}/redownload`, { method: 'POST' });
|
|
const data = await res.json();
|
|
|
|
if (res.ok) {
|
|
if (typeof showToast === 'function') showToast("Success", data.message, "success");
|
|
else alert(data.message);
|
|
|
|
// Reload details
|
|
loadDetails();
|
|
loadCharts();
|
|
} else {
|
|
throw new Error(data.detail || "Refresh failed");
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
if (typeof showToast === 'function') showToast("Error", e.message, "error");
|
|
else alert("Error: " + e.message);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = origHtml;
|
|
}
|
|
}
|
|
|
|
let allBikes = [];
|
|
async function fetchAllBikes() {
|
|
try {
|
|
const res = await fetch('/api/bike-setups');
|
|
if (res.ok) allBikes = await res.json();
|
|
} catch (e) { console.error(e); }
|
|
}
|
|
document.addEventListener('DOMContentLoaded', fetchAllBikes);
|
|
|
|
function editBikeSetup() {
|
|
const container = document.getElementById('m-bike-info');
|
|
if (container.querySelector('select')) return;
|
|
|
|
// Current text
|
|
const currentHtml = container.innerHTML;
|
|
|
|
let initialSelect = `<div class="input-group input-group-sm">
|
|
<select class="form-select" id="bike-select">
|
|
<option value="">-- No Bike --</option>`;
|
|
|
|
allBikes.forEach(b => {
|
|
initialSelect += `<option value="${b.id}">${b.name || b.frame} (${b.chainring}/${b.rear_cog})</option>`;
|
|
});
|
|
|
|
initialSelect += `</select>
|
|
<button class="btn btn-success" onclick="saveBikeSetup()"><i class="bi bi-check"></i></button>
|
|
<button class="btn btn-outline-secondary" onclick="loadDetails()"><i class="bi bi-x"></i></button>
|
|
</div>`;
|
|
|
|
container.innerHTML = initialSelect;
|
|
}
|
|
|
|
async function saveBikeSetup() {
|
|
const sel = document.getElementById('bike-select');
|
|
const newId = sel.value ? parseInt(sel.value) : null;
|
|
|
|
try {
|
|
const res = await fetch(`/api/activities/${activityId}/bike`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ bike_setup_id: newId, manual_override: true })
|
|
});
|
|
|
|
if (res.ok) {
|
|
if (typeof showToast === 'function') showToast("Success", "Bike setup updated", "success");
|
|
else alert("Bike setup updated");
|
|
loadDetails();
|
|
} else {
|
|
const err = await res.json();
|
|
alert("Error: " + err.detail);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
alert("Save failed");
|
|
}
|
|
}
|
|
|
|
async function estimatePower() {
|
|
if (!confirm("Estimate power for this activity using physics usage calculation? This will update average/max power stats.")) return;
|
|
|
|
const btn = document.getElementById('estimate-power-btn');
|
|
const origText = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Estimating...';
|
|
|
|
try {
|
|
const res = await fetch(`/api/activities/${window.currentDbId}/estimate_power`, {
|
|
method: 'POST'
|
|
});
|
|
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
alert("Power estimation complete! Avg: " + data.stats.avg_power + " W");
|
|
loadDetails(); // Refresh stats
|
|
loadCharts(); // Refresh charts if stream updated (Service returns stream but we'd need to reload)
|
|
} else {
|
|
const err = await res.json();
|
|
alert("Error: " + err.detail);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
alert("Estimate failed: " + e.message);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = origText;
|
|
}
|
|
}
|
|
|
|
async function saveSegment() {
|
|
if (startIndex >= endIndex) {
|
|
alert("Start point must be before End point.");
|
|
return;
|
|
}
|
|
|
|
const name = prompt("Enter Segment Name:");
|
|
if (!name) return;
|
|
|
|
try {
|
|
const res = await fetch('/api/segments/create', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name: name,
|
|
activity_id: window.currentDbId,
|
|
start_index: startIndex,
|
|
end_index: endIndex
|
|
})
|
|
});
|
|
|
|
if (res.ok) {
|
|
alert("Segment created!");
|
|
toggleSegmentMode(); // Reset UI
|
|
} else {
|
|
const err = await res.json();
|
|
alert("Error: " + err.detail);
|
|
}
|
|
// Load Segments
|
|
loadEfforts(window.currentDbId);
|
|
|
|
} catch (e) {
|
|
console.error(e);
|
|
alert("Error loading activity: " + e.message);
|
|
}
|
|
}
|
|
|
|
async function loadEfforts(dbId) {
|
|
const tbody = document.querySelector('#efforts-table tbody');
|
|
try {
|
|
const res = await fetch(`/api/activities/${dbId}/efforts`);
|
|
if (res.ok) {
|
|
const efforts = await res.json();
|
|
tbody.innerHTML = '';
|
|
if (efforts.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted">No segments matched.</td></tr>';
|
|
return;
|
|
}
|
|
|
|
efforts.forEach(eff => {
|
|
const tr = document.createElement('tr');
|
|
let awards = '';
|
|
if (eff.is_kom) awards += '<span class="badge bg-warning text-dark me-1"><i class="bi bi-trophy-fill"></i> CR</span>';
|
|
if (eff.is_pr) awards += '<span class="badge bg-success me-1"><i class="bi bi-award-fill"></i> PR</span>';
|
|
|
|
tr.innerHTML = `
|
|
<td><strong>${eff.segment_name}</strong></td>
|
|
<td>${formatDuration(eff.elapsed_time)}</td>
|
|
<td>${awards}</td>
|
|
<td>${eff.kom_rank ? '#' + eff.kom_rank : '-'}</td>
|
|
`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
} else {
|
|
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted">Failed to load segments.</td></tr>';
|
|
}
|
|
} catch (e) {
|
|
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted">Error loading segments.</td></tr>';
|
|
}
|
|
}
|
|
|
|
// Leaflet Map Init
|
|
</script>
|
|
{% endblock %} |