751 lines
28 KiB
HTML
751 lines
28 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="" />
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<style>
|
|
/* Scoped styles for activity view */
|
|
.activity-view-header {
|
|
background: white;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
padding: 1rem 0;
|
|
margin-bottom: 2rem;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 100;
|
|
}
|
|
|
|
.header-content {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.title-section h1 {
|
|
font-size: 1.5rem;
|
|
font-weight: 600;
|
|
color: #1a202c;
|
|
margin: 0;
|
|
}
|
|
|
|
.title-section p {
|
|
font-size: 0.875rem;
|
|
color: #718096;
|
|
margin: 0;
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.layout {
|
|
display: grid;
|
|
grid-template-columns: 1fr 400px;
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
.av-card {
|
|
background: white;
|
|
border-radius: 8px;
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
padding: 1.5rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.av-card-header {
|
|
font-size: 1.125rem;
|
|
font-weight: 600;
|
|
margin-bottom: 1rem;
|
|
color: #1a202c;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.map-container {
|
|
height: 400px;
|
|
border-radius: 6px;
|
|
overflow: hidden;
|
|
border: 1px solid #e2e8f0;
|
|
}
|
|
|
|
#map {
|
|
height: 100%;
|
|
width: 100%;
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.stat-card {
|
|
background: #f7fafc;
|
|
padding: 1rem;
|
|
border-radius: 6px;
|
|
border: 1px solid #e2e8f0;
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 0.75rem;
|
|
color: #718096;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 1.5rem;
|
|
font-weight: 700;
|
|
color: #1a202c;
|
|
}
|
|
|
|
.stat-unit {
|
|
font-size: 0.875rem;
|
|
color: #4a5568;
|
|
font-weight: 400;
|
|
}
|
|
|
|
.metrics-section {
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.metric-group {
|
|
background: #f7fafc;
|
|
padding: 1rem;
|
|
border-radius: 6px;
|
|
border: 1px solid #e2e8f0;
|
|
}
|
|
|
|
.metric-group h4 {
|
|
font-size: 0.875rem;
|
|
color: #4a5568;
|
|
margin-bottom: 0.75rem;
|
|
font-weight: 600;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
padding-bottom: 0.5rem;
|
|
}
|
|
|
|
.metric-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 0.25rem 0;
|
|
}
|
|
|
|
.metric-label {
|
|
color: #718096;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.metric-value {
|
|
font-weight: 600;
|
|
color: #1a202c;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.segment-item {
|
|
padding: 0.75rem;
|
|
background: #f7fafc;
|
|
border-radius: 6px;
|
|
margin-bottom: 0.5rem;
|
|
border: 1px solid #e2e8f0;
|
|
}
|
|
|
|
.segment-name {
|
|
font-weight: 600;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.segment-time {
|
|
color: #4a5568;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.chart-container-large {
|
|
height: 500px;
|
|
position: relative;
|
|
}
|
|
|
|
@media (max-width: 1024px) {
|
|
.layout {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.metrics-section {
|
|
grid-template-columns: 1fr 1fr;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.metrics-section {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<!-- Header -->
|
|
<div class="activity-view-header">
|
|
<div class="header-content">
|
|
<div class="title-section">
|
|
<h1 id="act-name">Loading Activity...</h1>
|
|
<p>
|
|
<span id="act-time"></span> |
|
|
<span id="act-type"></span> |
|
|
ID: <span id="act-id"></span>
|
|
</p>
|
|
</div>
|
|
<div class="actions">
|
|
<!-- Navigation -->
|
|
<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()">Close</button>
|
|
<button class="btn btn-outline-primary" id="download-btn">Download</button>
|
|
<button class="btn btn-outline-warning" id="refresh-btn" onclick="refreshActivity()">Refresh &
|
|
Match</button>
|
|
<button class="btn btn-warning" id="estimate-power-btn" onclick="estimatePower()">Est. Power</button>
|
|
<a href="/discovery?activity_id={{ activity_id }}" class="btn btn-outline-info">My Segments</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="layout">
|
|
<!-- Left Column: Map, Segments, Streams -->
|
|
<div class="left-column">
|
|
<!-- Map -->
|
|
<div class="av-card">
|
|
<div class="av-card-header">
|
|
Route Map
|
|
<button class="btn btn-sm btn-link" onclick="toggleSegmentMode()">+ Create Segment</button>
|
|
</div>
|
|
<div class="map-container">
|
|
<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.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Segments -->
|
|
<div class="av-card">
|
|
<div class="av-card-header">Matched Segments</div>
|
|
<div id="segments-list" class="segment-list">
|
|
<div class="text-center text-muted p-2">Loading segments...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Charts -->
|
|
<div class="av-card">
|
|
<div class="av-card-header">
|
|
Activity Streams
|
|
<div>
|
|
<div class="form-check form-switch d-inline-block me-2">
|
|
<input class="form-check-input" type="checkbox" id="smooth-toggle" onchange="toggleSmoothing()">
|
|
<label class="form-check-label" for="smooth-toggle">Smooth</label>
|
|
</div>
|
|
<button class="btn btn-sm btn-outline-secondary" onclick="openChartModal()">Full Screen</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Toggles -->
|
|
<div class="d-flex flex-wrap gap-2 mb-3" id="chart-toggles"></div>
|
|
|
|
<div class="chart-container-large">
|
|
<canvas id="streams-chart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right Column: Stats & Metrics -->
|
|
<div class="right-column">
|
|
<!-- Overview -->
|
|
<div class="av-card">
|
|
<div class="av-card-header">Overview</div>
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-label">Distance</div>
|
|
<div class="stat-value" id="metric-dist">-</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Duration</div>
|
|
<div class="stat-value" id="metric-dur">-</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Avg HR</div>
|
|
<div class="stat-value" id="metric-hr">-</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Detailed Metrics -->
|
|
<div class="av-card">
|
|
<div class="av-card-header">Performance Metrics</div>
|
|
<div class="metrics-section">
|
|
<!-- Heart Rate -->
|
|
<div class="metric-group">
|
|
<h4>Heart Rate</h4>
|
|
<div class="metric-row"><span class="metric-label">Average</span><span class="metric-value"><span
|
|
id="m-avg-hr">-</span> bpm</span></div>
|
|
<div class="metric-row"><span class="metric-label">Max</span><span class="metric-value"><span
|
|
id="m-max-hr">-</span> bpm</span></div>
|
|
</div>
|
|
|
|
<!-- Speed -->
|
|
<div class="metric-group">
|
|
<h4>Speed</h4>
|
|
<div class="metric-row"><span class="metric-label">Average</span><span class="metric-value"><span
|
|
id="m-avg-spd">-</span> km/h</span></div>
|
|
<div class="metric-row"><span class="metric-label">Max</span><span class="metric-value"><span
|
|
id="m-max-spd">-</span> km/h</span></div>
|
|
</div>
|
|
|
|
<!-- Power -->
|
|
<div class="metric-group">
|
|
<h4>Power</h4>
|
|
<div class="metric-row"><span class="metric-label">Average</span><span class="metric-value"><span
|
|
id="m-avg-pwr">-</span> W</span></div>
|
|
<div class="metric-row"><span class="metric-label">Max</span><span class="metric-value"><span
|
|
id="m-max-pwr">-</span> W</span></div>
|
|
<div class="metric-row"><span class="metric-label">Normalized</span><span class="metric-value"><span
|
|
id="m-norm-pwr">-</span> W</span></div>
|
|
<div class="metric-row"><span class="metric-label">VO2 Max</span><span class="metric-value"><span
|
|
id="m-vo2">-</span></span></div>
|
|
</div>
|
|
|
|
<!-- Elevation -->
|
|
<div class="metric-group">
|
|
<h4>Elevation</h4>
|
|
<div class="metric-row"><span class="metric-label">Gain</span><span class="metric-value"><span
|
|
id="m-ele-gain">-</span> m</span></div>
|
|
<div class="metric-row"><span class="metric-label">Loss</span><span class="metric-value"><span
|
|
id="m-ele-loss">-</span> m</span></div>
|
|
</div>
|
|
|
|
<!-- Training Effect -->
|
|
<div class="metric-group">
|
|
<h4>Training Effect</h4>
|
|
<div class="metric-row"><span class="metric-label">Aerobic</span><span class="metric-value"
|
|
id="m-aerobic">-</span></div>
|
|
<div class="metric-row"><span class="metric-label">Anaerobic</span><span class="metric-value"
|
|
id="m-anaerobic">-</span></div>
|
|
<div class="metric-row"><span class="metric-label">TSS</span><span class="metric-value"
|
|
id="m-tss">-</span></div>
|
|
</div>
|
|
|
|
<!-- Cadence -->
|
|
<div class="metric-group">
|
|
<h4>Cadence</h4>
|
|
<div class="metric-row"><span class="metric-label">Average</span><span class="metric-value"><span
|
|
id="m-avg-cad">-</span> rpm</span></div>
|
|
<div class="metric-row"><span class="metric-label">Max</span><span class="metric-value"><span
|
|
id="m-max-cad">-</span> rpm</span></div>
|
|
</div>
|
|
|
|
<!-- Respiration -->
|
|
<div class="metric-group">
|
|
<h4>Respiration</h4>
|
|
<div class="metric-row"><span class="metric-label">Average</span><span class="metric-value"><span
|
|
id="m-avg-resp">-</span> br/min</span></div>
|
|
<div class="metric-row"><span class="metric-label">Max</span><span class="metric-value"><span
|
|
id="m-max-resp">-</span> br/min</span></div>
|
|
</div>
|
|
|
|
<!-- Bike Setup -->
|
|
<div class="metric-group">
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<h4 class="mb-0" style="border:none">Bike Setup</h4>
|
|
<button class="btn btn-sm btn-link p-0" onclick="editBikeSetup()">Edit</button>
|
|
</div>
|
|
<div id="m-bike-info" class="small text-muted">No Setup</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Full Screen Modal -->
|
|
<div class="modal fade" id="chartModal" tabindex="-1">
|
|
<div class="modal-dialog modal-fullscreen">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Activity Streams</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div style="height: 100%; width: 100%;">
|
|
<canvas id="modal-chart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
const activityId = "{{ activity_id }}";
|
|
let map = null;
|
|
let chartInstance = null;
|
|
let modalChartInstance = null;
|
|
let streamData = null;
|
|
let isSmoothingEnabled = false;
|
|
|
|
// Visibility state
|
|
let metricVisibility = {
|
|
heart_rate: true,
|
|
speed: true,
|
|
power: true,
|
|
altitude: false,
|
|
cadence: false,
|
|
respiration_rate: false
|
|
};
|
|
|
|
const metricConfig = {
|
|
heart_rate: { label: 'Heart Rate', icon: '❤️', color: '#ef4444' },
|
|
speed: { label: 'Speed', icon: '🚴', color: '#3b82f6' },
|
|
power: { label: 'Power', icon: '⚡', color: '#f97316' },
|
|
altitude: { label: 'Elevation', icon: '⛰️', color: '#16a34a' },
|
|
cadence: { label: 'Cadence', icon: '🔄', color: '#a855f7' },
|
|
respiration_rate: { label: 'Resp', icon: '🫁', color: '#0dcaf0' }
|
|
};
|
|
|
|
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: '© OpenStreetMap contributors'
|
|
}).addTo(map);
|
|
|
|
await loadDetails();
|
|
await loadMapData();
|
|
loadNavigation();
|
|
loadCharts();
|
|
|
|
document.getElementById('download-btn').onclick = () => {
|
|
window.location.href = `/api/activities/download/${activityId}`;
|
|
};
|
|
});
|
|
|
|
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); }
|
|
}
|
|
|
|
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;
|
|
window.currentActivityType = data.activity_type;
|
|
|
|
// 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
|
|
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' : '-';
|
|
|
|
// Details
|
|
document.getElementById('m-avg-hr').textContent = data.avg_hr || '-';
|
|
document.getElementById('m-max-hr').textContent = data.max_hr || '-';
|
|
|
|
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) : '-';
|
|
|
|
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 || '-';
|
|
|
|
document.getElementById('m-ele-gain').textContent = data.elevation_gain || '-';
|
|
document.getElementById('m-ele-loss').textContent = data.elevation_loss || '-';
|
|
|
|
document.getElementById('m-aerobic').textContent = data.aerobic_te || '-';
|
|
document.getElementById('m-anaerobic').textContent = data.anaerobic_te || '-';
|
|
document.getElementById('m-tss').textContent = data.tss || '-';
|
|
|
|
document.getElementById('m-avg-cad').textContent = data.avg_cadence || '-';
|
|
document.getElementById('m-max-cad').textContent = data.max_cadence || '-';
|
|
|
|
document.getElementById('m-avg-resp').textContent = data.avg_respiration_rate ? data.avg_respiration_rate.toFixed(1) : '-';
|
|
document.getElementById('m-max-resp').textContent = data.max_respiration_rate ? data.max_respiration_rate.toFixed(1) : '-';
|
|
|
|
// Bike Setup
|
|
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;
|
|
}
|
|
|
|
// Segments
|
|
if (window.currentDbId) {
|
|
loadSegmentsEfforts(window.currentDbId);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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';
|
|
}
|
|
}
|
|
} catch (e) { }
|
|
}
|
|
|
|
// Load Segments (Efforts)
|
|
async function loadSegmentsEfforts(dbId) {
|
|
const container = document.getElementById('segments-list');
|
|
try {
|
|
const res = await fetch(`/api/segments/efforts/${dbId}`);
|
|
if (!res.ok) throw new Error('Failed to load efforts');
|
|
const data = await res.json();
|
|
|
|
if (!data || data.length === 0) {
|
|
container.innerHTML = '<div class="text-center text-muted p-2">No matched segments.</div>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
data.forEach(eff => {
|
|
html += `
|
|
<div class="segment-item">
|
|
<div class="d-flex justify-content-between">
|
|
<span class="segment-name">${eff.segment_name}</span>
|
|
<span class="badge bg-secondary">${eff.rank_text || ''}</span>
|
|
</div>
|
|
<div class="segment-time">Time: ${formatDuration(eff.elapsed_time)}</div>
|
|
</div>`;
|
|
});
|
|
container.innerHTML = html;
|
|
} catch (e) {
|
|
container.innerHTML = '<div class="text-danger small p-2">Error loading segments</div>';
|
|
}
|
|
}
|
|
|
|
async function loadCharts() {
|
|
try {
|
|
const res = await fetch(`/api/activities/${activityId}/streams`);
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
if (!data.time || data.time.length === 0) return;
|
|
streamData = data;
|
|
renderChart('streams-chart', data);
|
|
renderToggles();
|
|
} catch (e) { console.error(e); }
|
|
}
|
|
|
|
function renderToggles() {
|
|
const container = document.getElementById('chart-toggles');
|
|
let html = '';
|
|
Object.keys(metricConfig).forEach(key => {
|
|
if (streamData && streamData[key] && streamData[key].some(x => x)) {
|
|
const cfg = metricConfig[key];
|
|
const active = metricVisibility[key];
|
|
const bg = active ? cfg.color : '#e5e7eb';
|
|
const fg = active ? 'white' : '#4b5563';
|
|
html += `<button onclick="toggleMetric('${key}')" style="background:${bg};color:${fg};border:none;border-radius:20px;padding:5px 12px;font-size:12px;">${cfg.icon} ${cfg.label}</button>`;
|
|
}
|
|
});
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function toggleMetric(key) {
|
|
metricVisibility[key] = !metricVisibility[key];
|
|
renderToggles();
|
|
if (chartInstance) updateChartVisibility(chartInstance);
|
|
if (modalChartInstance) updateChartVisibility(modalChartInstance);
|
|
}
|
|
|
|
function updateChartVisibility(chart) {
|
|
chart.data.datasets.forEach(ds => {
|
|
ds.hidden = !metricVisibility[ds.rawKey];
|
|
});
|
|
chart.update();
|
|
}
|
|
|
|
function toggleSmoothing() {
|
|
isSmoothingEnabled = document.getElementById('smooth-toggle').checked;
|
|
if (streamData) {
|
|
renderChart('streams-chart', streamData);
|
|
if (modalChartInstance) renderChart('modal-chart', streamData);
|
|
}
|
|
}
|
|
|
|
function movingAverage(data, windowSize) {
|
|
if (!data || data.length === 0) return [];
|
|
const result = [];
|
|
for (let i = 0; i < data.length; i++) {
|
|
let sum = 0;
|
|
let count = 0;
|
|
const offset = Math.floor(windowSize / 2);
|
|
for (let j = i - offset; j < i - offset + windowSize; j++) {
|
|
if (j >= 0 && j < data.length && data[j] !== null) {
|
|
sum += data[j];
|
|
count++;
|
|
}
|
|
}
|
|
result.push(count > 0 ? sum / count : null);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function renderChart(canvasId, rawData) {
|
|
const ctx = document.getElementById(canvasId).getContext('2d');
|
|
if (canvasId === 'streams-chart' && chartInstance) { chartInstance.destroy(); }
|
|
if (canvasId === 'modal-chart' && modalChartInstance) { modalChartInstance.destroy(); }
|
|
|
|
const data = { ...rawData };
|
|
if (isSmoothingEnabled) {
|
|
const window = 10;
|
|
Object.keys(metricConfig).forEach(k => {
|
|
if (data[k]) data[k] = movingAverage(data[k], window);
|
|
});
|
|
}
|
|
|
|
const datasets = [];
|
|
const common = { pointRadius: 0, borderWidth: 2, tension: 0.4 };
|
|
|
|
// Helper to add dataset
|
|
const addDs = (key, label, color, yAxis, factor = 1) => {
|
|
if (data[key] && data[key].some(x => x)) {
|
|
datasets.push({
|
|
...common,
|
|
label: label,
|
|
rawKey: key,
|
|
data: factor === 1 ? data[key] : data[key].map(v => v ? v * factor : null),
|
|
borderColor: color,
|
|
backgroundColor: color,
|
|
yAxisID: yAxis,
|
|
hidden: !metricVisibility[key]
|
|
});
|
|
}
|
|
};
|
|
|
|
addDs('heart_rate', 'Heart Rate', metricConfig.heart_rate.color, 'left');
|
|
addDs('speed', 'Speed', metricConfig.speed.color, 'right', 3.6);
|
|
addDs('power', 'Power', metricConfig.power.color, 'right');
|
|
addDs('altitude', 'Elevation', metricConfig.altitude.color, 'right');
|
|
addDs('cadence', 'Cadence', metricConfig.cadence.color, 'left');
|
|
addDs('respiration_rate', 'Respiration', metricConfig.respiration_rate.color, 'left');
|
|
|
|
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`;
|
|
});
|
|
|
|
const newChart = new Chart(ctx, {
|
|
type: 'line',
|
|
data: { labels, datasets },
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
interaction: { mode: 'index', intersect: false },
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
x: { ticks: { maxTicksLimit: 12 }, grid: { display: false } },
|
|
left: { type: 'linear', position: 'left', grid: { display: true, borderDash: [3, 3] } },
|
|
right: { type: 'linear', position: 'right', grid: { display: false } }
|
|
}
|
|
}
|
|
});
|
|
|
|
if (canvasId === 'streams-chart') chartInstance = newChart;
|
|
if (canvasId === 'modal-chart') modalChartInstance = newChart;
|
|
}
|
|
|
|
function openChartModal() {
|
|
const modal = new bootstrap.Modal(document.getElementById('chartModal'));
|
|
modal.show();
|
|
document.getElementById('chartModal').addEventListener('shown.bs.modal', () => {
|
|
if (streamData) renderChart('modal-chart', streamData);
|
|
}, { once: true });
|
|
}
|
|
|
|
function formatDuration(seconds) {
|
|
if (!seconds) return "-";
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
return h > 0 ? `${h}h ${m}m ${s}s` : `${m}m ${s}s`;
|
|
}
|
|
|
|
// Stub functions for buttons
|
|
function refreshActivity() {
|
|
if (confirm("Refresh activity data from Garmin?")) {
|
|
fetch(`/api/activities/${activityId}/refresh`, { method: 'POST' })
|
|
.then(r => r.json())
|
|
.then(d => { alert(d.message); window.location.reload(); })
|
|
.catch(e => alert("Error refreshing"));
|
|
}
|
|
}
|
|
|
|
function estimatePower() {
|
|
fetch(`/api/activities/${activityId}/estimate_power`, { method: 'POST' })
|
|
.then(r => r.json())
|
|
.then(d => { alert("Power estimation queued"); })
|
|
.catch(e => alert("Error"));
|
|
}
|
|
|
|
function toggleSegmentMode() {
|
|
alert("Segment creation mode (stub)");
|
|
}
|
|
|
|
function editBikeSetup() {
|
|
alert("Edit bike setup (stub)");
|
|
}
|
|
</script>
|
|
{% endblock %} |