many updates

This commit is contained in:
2026-01-13 09:42:16 -08:00
parent 4bb86b603e
commit 362f4cb5aa
81 changed files with 3106 additions and 336 deletions

View File

@@ -65,6 +65,9 @@
<button class="btn btn-success" id="create-segment-btn" onclick="toggleSegmentMode()">
<i class="bi bi-bezier2"></i> Create Segment
</button>
<a href="/discovery?activity_id={{ activity_id }}" class="btn btn-outline-info" title="Discover Segments">
<i class="bi bi-search"></i> Discovery
</a>
</div>
</div>
@@ -231,6 +234,19 @@
</div>
</div>
<!-- Respiration -->
<div class="col-md-4">
<div class="card h-100 metric-card border-info">
<div class="card-header bg-info text-white">Respiration</div>
<div class="card-body">
<div class="d-flex justify-content-between mb-2"><span>Avg:</span> <strong id="m-avg-resp">-</strong>
br/min</div>
<div class="d-flex justify-content-between"><span>Max:</span> <strong id="m-max-resp">-</strong> br/min
</div>
</div>
</div>
</div>
<!-- Bike Info (New) -->
<div class="col-md-4">
<div class="card h-100 metric-card border-light shadow-sm">
@@ -249,11 +265,78 @@
<div class="row g-3 mb-4">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header">
Activity Streams
<div class="card-header d-flex justify-content-between align-items-center">
<span class="h5 mb-0">Activity Streams</span>
<div class="d-flex align-items-center">
<div class="form-check form-switch me-3">
<input class="form-check-input" type="checkbox" id="smooth-toggle" onchange="toggleSmoothing()">
<label class="form-check-label" for="smooth-toggle">Smooth Data</label>
</div>
<button class="btn btn-sm btn-outline-primary" onclick="openChartModal()">
<i class="bi bi-arrows-fullscreen"></i> Full Screen
</button>
</div>
</div>
<div class="card-body">
<canvas id="streams-chart" style="max-height: 400px;"></canvas>
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="card-title fw-bold mb-1">Cycling Workout Analysis</h4>
<p class="text-muted small mb-0">Toggle metrics to customize view</p>
</div>
<div class="d-flex align-items-center gap-2">
<div class="form-check form-switch me-3">
<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()">
<i class="bi bi-arrows-fullscreen"></i>
</button>
</div>
</div>
<!-- Metric Toggles -->
<div class="d-flex flex-wrap gap-2 mb-4" id="chart-toggles">
<!-- Buttons injected by JS -->
</div>
<!-- Chart -->
<div style="height: 600px; position: relative;">
<canvas id="streams-chart"></canvas>
</div>
<!-- Summary Footer -->
<div class="row g-3 mt-4 pt-4 border-top" id="chart-footer">
<!-- Stats injected by JS -->
</div>
</div>
</div>
</div>
</div>
<!-- Full Screen Chart 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 (Full Screen)</h5>
<div class="d-flex align-items-center">
<div class="form-check form-switch me-3">
<input class="form-check-input" type="checkbox" id="modal-smooth-toggle"
onchange="toggleModalSmoothing()">
<label class="form-check-label" for="modal-smooth-toggle">Smooth Data</label>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
</div>
<div class="modal-body bg-light">
<div class="card h-100 shadow-sm">
<div class="card-body">
<div style="height: 100%; width: 100%;">
<canvas id="modal-chart"></canvas>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -309,156 +392,338 @@
}
let chartInstance = null;
let modalChartInstance = null;
let streamData = null; // Store fetched data
async function loadCharts() {
try {
const res = await fetch(`/api/activities/${activityId}/streams`);
if (!res.ok) return; // No streams
if (!res.ok) return;
const data = await res.json();
if (!data.time || data.time.length === 0) return;
streamData = data; // Cache for modal
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 }
}
}
}
});
renderChart('streams-chart', data);
renderToggles();
renderFooter(data);
} catch (e) {
console.error("Chart load failed", e);
}
}
let isSmoothingEnabled = false;
// Visibility state (managed manually to sync custom buttons with chart)
let metricVisibility = {
heart_rate: true,
speed: true,
power: true,
altitude: false, // Match user preference
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' }
};
function toggleMetric(key) {
metricVisibility[key] = !metricVisibility[key];
renderToggles();
if (chartInstance) updateChartVisibility(chartInstance);
if (modalChartInstance) updateChartVisibility(modalChartInstance);
}
function renderToggles() {
const container = document.getElementById('chart-toggles');
if (!container) return;
let html = '';
Object.keys(metricConfig).forEach(key => {
// Only show toggle if data exists
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';
const shadow = active ? '0 4px 6px rgba(0,0,0,0.1)' : 'none';
html += `<button onclick="toggleMetric('${key}')" style="
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: 500;
border: none;
cursor: pointer;
background-color: ${bg};
color: ${fg};
box-shadow: ${shadow};
transition: all 0.2s;
">${cfg.icon} ${cfg.label}</button>`;
}
});
container.innerHTML = html;
}
function renderFooter(data) {
const container = document.getElementById('chart-footer');
if (!container) return;
function avg(arr) {
const valid = arr.filter(x => x !== null);
return valid.length ? (valid.reduce((a, b) => a + b, 0) / valid.length) : 0;
}
const stats = [];
if (data.heart_rate) stats.push({ label: 'Avg HR', val: Math.round(avg(data.heart_rate)) + ' bpm', color: metricConfig.heart_rate.color });
if (data.speed) stats.push({ label: 'Avg Speed', val: (avg(data.speed) * 3.6).toFixed(1) + ' km/h', color: metricConfig.speed.color });
if (data.power) stats.push({ label: 'Avg Power', val: Math.round(avg(data.power)) + ' W', color: metricConfig.power.color });
// Elevation Gain (Approx)
if (data.altitude) {
let gain = 0;
for (let i = 1; i < data.altitude.length; i++) {
if (data.altitude[i] > data.altitude[i - 1]) gain += (data.altitude[i] - data.altitude[i - 1]);
}
stats.push({ label: 'Total Climb', val: Math.round(gain) + ' m', color: metricConfig.altitude.color });
}
if (data.cadence) stats.push({ label: 'Avg Cadence', val: Math.round(avg(data.cadence)) + ' rpm', color: metricConfig.cadence.color });
container.innerHTML = stats.map(s => `
<div class="col-md-2 text-center">
<p style="font-size: 12px; color: #6b7280; margin-bottom: 4px;">${s.label}</p>
<p style="font-size: 18px; font-weight: bold; color: ${s.color}; margin: 0;">${s.val}</p>
</div>
`).join('');
}
function updateChartVisibility(chart) {
chart.data.datasets.forEach(ds => {
ds.hidden = !metricVisibility[ds.rawKey];
});
chart.update();
}
function toggleSmoothing() {
isSmoothingEnabled = document.getElementById('smooth-toggle').checked;
const modalToggle = document.getElementById('modal-smooth-toggle');
if (modalToggle) modalToggle.checked = isSmoothingEnabled;
if (streamData) {
renderChart('streams-chart', streamData);
if (modalChartInstance) renderChart('modal-chart', streamData, true);
}
}
function toggleModalSmoothing() {
isSmoothingEnabled = document.getElementById('modal-smooth-toggle').checked;
const mainToggle = document.getElementById('smooth-toggle');
if (mainToggle) mainToggle.checked = isSmoothingEnabled;
if (streamData) renderChart('modal-chart', streamData, true);
}
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 openChartModal() {
if (!streamData) return;
const mt = document.getElementById('modal-smooth-toggle');
if (mt) mt.checked = isSmoothingEnabled;
const modal = new bootstrap.Modal(document.getElementById('chartModal'));
modal.show();
document.getElementById('chartModal').addEventListener('shown.bs.modal', () => {
renderChart('modal-chart', streamData, true);
}, { once: true });
}
function renderChart(canvasId, rawData, isModal = false) {
const ctx = document.getElementById(canvasId).getContext('2d');
if (canvasId === 'streams-chart' && chartInstance) { chartInstance.destroy(); chartInstance = null; }
else if (canvasId === 'modal-chart' && modalChartInstance) { modalChartInstance.destroy(); modalChartInstance = null; }
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 commonOptions = { pointRadius: 0, borderWidth: 2, tension: 0.4 };
if (data.heart_rate && data.heart_rate.some(x => x)) {
datasets.push({
...commonOptions,
label: 'Heart Rate',
rawKey: 'heart_rate',
data: data.heart_rate,
borderColor: metricConfig.heart_rate.color,
backgroundColor: metricConfig.heart_rate.color,
yAxisID: 'left',
borderWidth: 2.5,
hidden: !metricVisibility.heart_rate
});
}
if (data.speed && data.speed.some(x => x)) {
datasets.push({
...commonOptions,
label: 'Speed',
rawKey: 'speed',
data: data.speed.map(s => s ? s * 3.6 : null),
borderColor: metricConfig.speed.color,
backgroundColor: metricConfig.speed.color,
yAxisID: 'right',
hidden: !metricVisibility.speed
});
}
if (data.power && data.power.some(x => x)) {
datasets.push({
...commonOptions,
label: 'Power',
rawKey: 'power',
data: data.power,
borderColor: metricConfig.power.color,
backgroundColor: metricConfig.power.color,
yAxisID: 'right',
hidden: !metricVisibility.power
});
}
if (data.altitude && data.altitude.some(x => x)) {
datasets.push({
...commonOptions,
label: 'Elevation',
rawKey: 'altitude',
data: data.altitude,
borderColor: metricConfig.altitude.color,
backgroundColor: metricConfig.altitude.color,
yAxisID: 'right',
borderWidth: 2,
hidden: !metricVisibility.altitude
});
}
if (data.cadence && data.cadence.some(x => x)) {
datasets.push({
...commonOptions,
label: 'Cadence',
rawKey: 'cadence',
data: data.cadence,
borderColor: metricConfig.cadence.color,
backgroundColor: metricConfig.cadence.color,
yAxisID: 'left',
borderDash: [5, 5],
borderWidth: 1.5,
hidden: !metricVisibility.cadence
});
}
if (data.respiration_rate && data.respiration_rate.some(x => x)) {
datasets.push({
...commonOptions,
label: 'Respiration',
rawKey: 'respiration_rate',
data: data.respiration_rate,
borderColor: metricConfig.respiration_rate.color,
backgroundColor: metricConfig.respiration_rate.color,
yAxisID: 'left',
hidden: !metricVisibility.respiration_rate
});
}
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 }, // Using custom toggles
tooltip: {
backgroundColor: 'white',
titleColor: '#374151',
bodyColor: '#4b5563',
borderColor: '#d1d5db',
borderWidth: 1,
padding: 12,
boxPadding: 4,
callbacks: {
labelColor: function (context) {
return {
borderColor: context.dataset.borderColor,
backgroundColor: context.dataset.borderColor
};
}
}
}
},
scales: {
x: {
ticks: { maxTicksLimit: isModal ? 20 : 10, maxRotation: 0 },
grid: { display: true, drawOnChartArea: true, drawTicks: true, color: '#f3f4f6' }
},
left: {
type: 'linear',
position: 'left',
grid: { display: true, color: '#e5e7eb', borderDash: [3, 3] },
min: 0
},
right: {
type: 'linear',
position: 'right',
grid: { display: false },
min: 0
}
}
}
});
if (canvasId === 'streams-chart') chartInstance = newChart;
else if (canvasId === 'modal-chart') modalChartInstance = newChart;
}
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
window.currentActivityType = data.activity_type;
// Header
document.getElementById('act-name').textContent = data.activity_name || 'Untitled Activity';
@@ -494,6 +759,9 @@
// Cadence
document.getElementById('m-avg-cad').textContent = data.avg_cadence || '-';
document.getElementById('m-max-cad').textContent = data.max_cadence || '-';
// Respiration
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
if (data.bike_setup) {
@@ -798,6 +1066,7 @@
body: JSON.stringify({
name: name,
activity_id: window.currentDbId,
activity_type: window.currentActivityType,
start_index: startIndex,
end_index: endIndex
})