feat: record cadence from step detector during GPS workouts
Use Android TYPE_STEP_DETECTOR sensor in LocationForegroundService to count steps in a 15s rolling window. Cadence (spm) is computed at each GPS point and stored alongside lat/lng/altitude/speed. Session detail page shows cadence chart when data is available. No additional permissions required — step detector is not a restricted sensor. Gracefully skipped on devices without the sensor.
This commit is contained in:
@@ -108,6 +108,8 @@ const translations: Translations = {
|
||||
elevation_unit: { en: 'm', de: 'm' },
|
||||
elevation_gain: { en: 'Gain', de: 'Anstieg' },
|
||||
elevation_loss: { en: 'Loss', de: 'Abstieg' },
|
||||
cadence: { en: 'Cadence', de: 'Kadenz' },
|
||||
cadence_unit: { en: 'spm', de: 'spm' },
|
||||
personal_records: { en: 'Personal Records', de: 'Persönliche Rekorde' },
|
||||
delete_session_confirm: { en: 'Delete this workout session?', de: 'Dieses Training löschen?' },
|
||||
remove_gps_confirm: { en: 'Remove GPS track from this exercise?', de: 'GPS-Track von dieser Übung entfernen?' },
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface GpsPoint {
|
||||
lng: number;
|
||||
altitude?: number;
|
||||
speed?: number;
|
||||
cadence?: number; // steps per minute, from step detector sensor
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface IGpsPoint {
|
||||
lng: number;
|
||||
altitude?: number;
|
||||
speed?: number;
|
||||
cadence?: number; // steps per minute, from step detector sensor
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@@ -108,6 +109,7 @@ const GpsPointSchema = new mongoose.Schema({
|
||||
lng: { type: Number, required: true },
|
||||
altitude: Number,
|
||||
speed: Number,
|
||||
cadence: Number,
|
||||
timestamp: { type: Number, required: true }
|
||||
}, { _id: false });
|
||||
|
||||
|
||||
@@ -473,6 +473,47 @@
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute cadence samples over distance from GPS track.
|
||||
* Returns array of { dist (km), cadence (spm) } — only for points with cadence data.
|
||||
* @param {any[]} track
|
||||
*/
|
||||
function computeCadenceSamples(track) {
|
||||
/** @type {Array<{dist: number, cadence: number}>} */
|
||||
const samples = [];
|
||||
let cumDist = 0;
|
||||
for (let i = 0; i < track.length; i++) {
|
||||
if (track[i].cadence == null) continue;
|
||||
if (i > 0) cumDist += haversine(track[i - 1], track[i]);
|
||||
samples.push({ dist: cumDist, cadence: Math.round(track[i].cadence) });
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Chart.js data for cadence over distance
|
||||
* @param {Array<{dist: number, cadence: number}>} samples
|
||||
*/
|
||||
function buildCadenceChartData(samples) {
|
||||
const step = Math.max(1, Math.floor(samples.length / 50));
|
||||
const filtered = samples.filter((_, i) => i % step === 0 || i === samples.length - 1);
|
||||
const color = dark ? '#B48EAD' : '#5E81AC';
|
||||
const fill = dark ? 'rgba(180, 142, 173, 0.12)' : 'rgba(94, 129, 172, 0.12)';
|
||||
return {
|
||||
labels: filtered.map(s => s.dist.toFixed(2)),
|
||||
datasets: [{
|
||||
label: t('cadence', lang),
|
||||
data: filtered.map(s => s.cadence),
|
||||
borderColor: color,
|
||||
backgroundColor: fill,
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
fill: true
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
/** @param {number} exIdx */
|
||||
async function uploadGpx(exIdx) {
|
||||
const input = document.createElement('input');
|
||||
@@ -754,6 +795,19 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{@const cadenceSamples = computeCadenceSamples(ex.gpsTrack)}
|
||||
{#if cadenceSamples.length > 1}
|
||||
{@const avgCadence = Math.round(cadenceSamples.reduce((a, s) => a + s.cadence, 0) / cadenceSamples.length)}
|
||||
<div class="chart-section">
|
||||
<FitnessChart
|
||||
data={buildCadenceChartData(cadenceSamples)}
|
||||
title="{t('cadence', lang)} ({t('cadence_unit', lang)}) · {t('avg', lang)} {avgCadence}"
|
||||
height="160px"
|
||||
yUnit=" spm"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if splits.length > 1}
|
||||
{@const avgPace = splits.reduce((a, s) => a + s.pace, 0) / splits.length}
|
||||
<div class="splits-section">
|
||||
|
||||
Reference in New Issue
Block a user