BangleApps_old/apps/recorder/interface.html

926 lines
42 KiB
HTML

<html>
<head>
<link rel="stylesheet" href="../../css/spectre.min.css">
<link rel="stylesheet" href="../../css/spectre-icons.min.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
.leaflet-map { width: 100%; max-width: 600px; aspect-ratio: 1/1; margin: 0 auto; }
.chart-canvas { width: 100%; height: 100%; display: block; }
.chart-wrapper { position: relative; width: 100%; max-width: 600px; aspect-ratio: 1/1; margin: 0 auto 10px; }
.leaflet-map, .chart-container {
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.chart-canvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
.track-stats { font-size: 0.9em; color: #666; margin-top: 10px; padding: 8px; background: #f8f9fa; border-radius: 4px; }
.charts-container { display: grid; grid-template-columns: 1fr; gap: 15px; margin-top: 15px; }
@media (min-width: 768px) { .charts-container { grid-template-columns: 1fr 1fr; } }
.chart-container {
position: relative;
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.chart-header {
padding: 12px 15px;
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
border-radius: 4px 4px 0 0;
cursor: pointer;
user-select: none;
display: flex;
justify-content: space-between;
align-items: center;
}
.chart-header:hover { background: #e9ecef; }
.chart-title { font-weight: 600; color: #333; margin: 0; }
.chart-toggle { float: right; font-size: 14px; color: #666; }
.chart-content { padding: 15px; }
.chart-content.collapsed { display: none; }
.chart-export { text-align: right; margin-top: 10px; }
.chart-export button { font-size: 0.85em; padding: 4px 8px; }
.no-data-message { text-align: center; color: #666; font-style: italic; padding: 20px; }
#track-selector { margin-bottom: 20px; }
.text-center { text-align: center; }
.accordion-header i { transition: transform 0.2s; }
input:checked ~ .accordion-header i { transition: transform 0.2s; }
.accordion { margin-bottom: 20px; }
.accordion-item { position: relative; }
.accordion-header { cursor: pointer; padding: 10px; background: #f8f9fa; border-radius: 4px; margin-bottom: 5px; }
.accordion-header:hover { background: #e9ecef; }
.accordion-body { overflow: visible; }
.track-content-wrapper {
max-height: 85vh;
overflow-y: auto;
padding: 10px;
border-top: 1px solid #eee;
}
.track-loading { text-align: center; padding: 20px; color: #666; }
html, body { height: auto; min-height: 100%; }
body { margin: 0; padding-bottom: 50px; }
</style>
</head>
<body>
<div id="tracks"></div>
<div class="container" id="toastcontainer" style="position:fixed; bottom:8px; left:0px; right:0px; z-index: 100;"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet-geometryutil@0.10.3/src/leaflet.geometryutil.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
<script src="../../core/lib/interface.js"></script>
<script src="../../core/js/ui.js"></script>
<script src="../../core/js/utils.js"></script>
<script>
const domTracks = document.getElementById("tracks"),fileCache = new Map();
let leafletMaps = {},trackCharts = {};
function getLocalizationSettings() {
const userLang = navigator.language || navigator.userLanguage || 'en-US';
const stored = localStorage.getItem('recorder-units');
if (stored) return JSON.parse(stored);
const isMetric = !['en-US', 'en-LR', 'en-MM'].includes(userLang) && !userLang.startsWith('en-US');
return {speed: isMetric ? 'kmh' : 'mph', distance: isMetric ? 'km' : 'mi', temperature: isMetric ? 'celsius' : 'fahrenheit', elevation: isMetric ? 'm' : 'ft', auto: true};
}
function saveLocalizationSettings(settings) {
settings.auto = false;
localStorage.setItem('recorder-units', JSON.stringify(settings));
}
const unitConversions = {
speed: {
metric: { unit: 'km/h', label: 'Speed (km/h)', convert: v => v },
imperial: { unit: 'mph', label: 'Speed (mph)', convert: v => v * 0.621371 }
},
distance: {
metric: { unit: 'km', label: 'km', convert: v => v },
imperial: { unit: 'mi', label: 'miles', convert: v => v * 0.621371 }
},
temperature: {
metric: { unit: '°C', label: 'Temperature (°C)', convert: v => v },
imperial: { unit: '°F', label: 'Temperature (°F)', convert: v => (v * 9/5) + 32 }
},
elevation: {
metric: { unit: 'm', label: 'Elevation (m)', convert: v => v },
imperial: { unit: 'ft', label: 'Elevation (ft)', convert: v => v * 3.28084 }
}
};
function convertUnit(type, value, targetUnit = null) {
const settings = getLocalizationSettings();
const unit = targetUnit || settings[type];
const isImperial = (type === 'temperature' ? unit === 'fahrenheit' : unit !== settings[type] || !['kmh', 'km', 'celsius', 'm'].includes(unit));
const conversion = unitConversions[type][isImperial ? 'imperial' : 'metric'];
return {value: conversion.convert(value), unit: conversion.unit, label: conversion.label};
}
const convertSpeed = (v, t) => convertUnit('speed', v, t),
convertDistance = (v, t) => convertUnit('distance', v, t),
convertTemperature = (v, t) => convertUnit('temperature', v, t),
convertElevation = (v, t) => convertUnit('elevation', v, t);
function calculateDistance(lat1, lon1, lat2, lon2) {
const R = 6371000, dLat = (lat2 - lat1) * Math.PI / 180, dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon/2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function createChartContainer(chartId, title, collapsed = true) {
return `
<div class="chart-container">
<div class="chart-header" onclick="toggleChart('${chartId}')">
<div class="chart-title">${title}</div>
<div class="chart-toggle" id="toggle-${chartId}">${collapsed ? '▼' : '▲'}</div>
</div>
<div class="chart-content ${collapsed ? 'collapsed' : ''}" id="content-${chartId}">
<div class="chart-wrapper">
<canvas class="chart-canvas" id="canvas-${chartId}"></canvas>
</div>
<div class="chart-export">
<button class="btn btn-sm" onclick="exportChart('${chartId}')">Export PNG</button>
</div>
</div>
</div>`;
}
const toggleChart = (chartId) => {
const content = document.getElementById(`content-${chartId}`);
const toggle = document.getElementById(`toggle-${chartId}`);
const chartContainer = content.closest('.chart-container');
const trackContainer = content.closest('.accordion-item');
const isCollapsed = content.classList.toggle('collapsed');
toggle.textContent = isCollapsed ? '▼' : '▲';
// Z-index management prevents expanded charts from being hidden behind other tracks
// When expanding: find highest z-index and place this track above it
// When collapsing: reset z-index unless other charts in this track are still expanded
if (!isCollapsed) {
let maxZ = 4;
document.querySelectorAll('.accordion-item').forEach(item => {
const z = parseInt(item.style.zIndex, 10);
if (!isNaN(z) && z > maxZ) maxZ = z;
});
if (trackContainer) trackContainer.style.zIndex = maxZ + 1;
if (chartContainer) chartContainer.style.zIndex = '10';
} else {
if (chartContainer) chartContainer.style.zIndex = '';
if (!trackContainer.querySelector('.chart-content:not(.collapsed)')) trackContainer.style.zIndex = '';
}
if (!isCollapsed) {
const parts = chartId.split('-'), trackNumber = parts[parts.length - 1], chartType = parts.slice(0, -1).join('-');
if (trackCharts[trackNumber]?.[chartType]) requestAnimationFrame(() => trackCharts[trackNumber][chartType].resize());
}
};
const exportChart = (chartId) => {
const canvas = document.getElementById(`canvas-${chartId}`);
if (canvas) {
const link = document.createElement('a');
link.download = `chart-${chartId}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
showToast('Chart exported successfully', 'success');
}
};
const chartTypes = {
heartrate: {
filter: d => (d.Heartrate && d.Heartrate !== "") || (d['BT Heartrate'] && d['BT Heartrate'] !== ""),
title: 'Heart Rate Over Time',
datasets: [
{ key: 'Heartrate', label: 'Built-in HR (BPM)', color: '#dc3545', yAxis: 'y' },
{ key: 'BT Heartrate', label: 'Bluetooth HR (BPM)', color: '#007bff', yAxis: 'y' }
],
scales: { y: { title: 'Heart Rate (BPM)' } }
},
battery: {
filter: d => d['Battery Percentage'] !== undefined && d['Battery Percentage'] !== "",
title: 'Battery Level Over Time',
datasets: [
{ key: 'Battery Percentage', label: 'Battery %', color: '#28a745', yAxis: 'y' },
{ key: 'Battery Voltage', label: 'Voltage (V)', color: '#ffc107', yAxis: 'y1' }
],
scales: { y: { min: 0, max: 100, title: 'Battery %' }, y1: { position: 'right', min: 3.0, max: 4.2, title: 'Voltage (V)', grid: false } }
},
steps: { filter: d => d.Steps !== undefined && d.Steps !== "", data: d => parseInt(d.Steps) || 0, label: 'Steps per Interval', color: '#36a2eb', title: 'Step Count Over Time', type: 'bar', cumulative: true },
elevation: {
filter: d => (d['Barometer Altitude'] !== undefined && d['Barometer Altitude'] !== "") || (d.Altitude !== undefined && d.Altitude !== ""),
title: 'Elevation Profile',
datasets: [
{ key: 'Barometer Altitude', label: () => 'Barometer (' + convertElevation(1).unit + ')', color: '#14b8a6', yAxis: 'y', convert: convertElevation },
{ key: 'Altitude', label: () => 'GPS (' + convertElevation(1).unit + ')', color: '#9333ea', yAxis: 'y', convert: convertElevation }
],
scales: { y: { title: () => convertElevation(1).label } }
},
speed: { filter: d => d.Latitude && d.Longitude && d.Latitude !== "" && d.Longitude !== "", calculate: true, label: () => convertSpeed(1).label, color: '#10b981', title: 'Speed Over Time' },
barometer: {
filter: d => d['Barometer Temperature'] !== undefined || d['Barometer Pressure'] !== undefined,
title: 'Barometer Data Over Time',
datasets: [
{ key: 'Barometer Temperature', label: () => convertTemperature(1).label, color: '#ef4444', yAxis: 'y', convert: convertTemperature },
{ key: 'Barometer Pressure', label: 'Pressure (hPa)', color: '#3b82f6', yAxis: 'y1' }
],
scales: { y: { title: () => convertTemperature(1).label }, y1: { position: 'right', title: 'Pressure (hPa)', grid: false } }
}
};
const createChart = (type, canvasId, trackData) => {
const config = chartTypes[type];
let data = trackData.filter(config.filter);
if (data.length === 0) {
document.getElementById(`content-${canvasId.replace('canvas-', '')}`).innerHTML = '<div class="no-data-message">No data available</div>';
return null;
}
const makeDataset = (label, data, color, opts = {}) => ({
label: typeof label === 'function' ? label() : label,
data, borderColor: color, backgroundColor: color + '20',
borderWidth: 2, fill: opts.fill !== false, tension: 0.1, ...opts
});
let datasets = [];
if (config.datasets) {
datasets = config.datasets
.filter(ds => data.some(pt => pt[ds.key] !== undefined && pt[ds.key] !== ""))
.map(ds => makeDataset(ds.label, data.map(pt => {
const val = parseFloat(pt[ds.key]);
return val ? (ds.convert ? parseFloat(ds.convert(val).value.toFixed(1)) : val) : null;
}), ds.color, { fill: false, yAxisID: ds.yAxis }));
} else if (type === 'speed') {
// Calculate speed from GPS coordinates
// Speed = distance between points / time between points
const speeds = [], times = [];
for (let i = 1; i < data.length; i++) {
const [prev, curr] = [data[i-1], data[i]];
if (prev.Time && curr.Time) {
const [lat1, lon1, lat2, lon2] = [prev.Latitude, prev.Longitude, curr.Latitude, curr.Longitude].map(parseFloat);
const distance = calculateDistance(lat1, lon1, lat2, lon2);
const timeSeconds = (curr.Time - prev.Time) / 1000;
const speedKmh = (distance / timeSeconds) * 3.6; // m/s to km/h
speeds.push(convertSpeed(speedKmh).value);
times.push(curr.Time.toLocaleTimeString());
}
}
datasets = [makeDataset(config.label, speeds, config.color)];
data = times.map(time => ({ Time: { toLocaleTimeString: () => time } }));
} else if (config.cumulative) {
let cumulative = 0;
const cumulativeData = data.map(point => cumulative += config.data(point));
datasets = [
makeDataset(config.label, data.map(config.data), config.color, { backgroundColor: config.color + 'A0', type: 'bar', yAxisID: 'y', fill: false }),
makeDataset('Cumulative Steps', cumulativeData, '#ff6384', { fill: false, type: 'line', yAxisID: 'y1' })
];
} else {
datasets = [makeDataset(config.label, data.map(config.data), config.color)];
}
const chartConfig = {
type: config.type || 'line',
data: { labels: data.map(pt => pt.Time?.toLocaleTimeString() || ''), datasets },
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 300 },
interaction: { intersect: false, mode: 'point' },
plugins: {
title: { display: true, text: config.getTitle ? config.getTitle(data) : config.title },
legend: { display: true },
tooltip: { mode: 'nearest', intersect: false }
},
scales: {
x: { title: { display: true, text: 'Time' } },
y: { beginAtZero: type !== 'heartrate', title: { display: true, text: typeof config.label === 'function' ? config.label() : config.label || 'Value' } }
},
elements: { point: { radius: 1, hoverRadius: 3 } }
}
};
if (config.scales) {
Object.entries(config.scales).forEach(([axis, scale]) => {
chartConfig.options.scales[axis] = { ...scale };
if (scale.title) chartConfig.options.scales[axis].title = { display: true, text: typeof scale.title === 'function' ? scale.title() : scale.title };
if (scale.grid === false) chartConfig.options.scales[axis].grid = { drawOnChartArea: false };
});
}
if (config.cumulative) {
chartConfig.options.scales.y1 = { type: 'linear', display: true, position: 'right', beginAtZero: true, title: { display: true, text: 'Cumulative Steps' }, grid: { drawOnChartArea: false } };
}
return new Chart(document.getElementById(canvasId).getContext('2d'), chartConfig);
};
const createChartsForTrack = (trackNumber, trackData) => {
const chartsContainer = document.getElementById(`charts-${trackNumber}`);
if (!chartsContainer || !trackData.length) return;
const chartDefs = [
{type:'heartrate',id:'hr',title:'Heart Rate'},{type:'battery',id:'battery',title:'Battery Level'},
{type:'steps',id:'steps',title:'Step Count'},{type:'elevation',id:'elevation',title:'Elevation Profile'},
{type:'speed',id:'speed',title:'Speed'},{type:'barometer',id:'barometer',title:'Barometer Data'}
];
const availableCharts = chartDefs.filter(({type}) => trackData.some(chartTypes[type].filter));
if (!availableCharts.length) {
chartsContainer.innerHTML = '<div class="no-data-message">No sensor data available for visualization</div>';
return;
}
chartsContainer.innerHTML = availableCharts.map(({id,title}) => createChartContainer(`${id}-${trackNumber}`, title)).join('');
trackCharts[trackNumber] = {};
requestAnimationFrame(() => {
availableCharts.forEach(({type,id}) => {
try { trackCharts[trackNumber][id] = createChart(type, `canvas-${id}-${trackNumber}`, trackData); }
catch (e) { console.error(`Error creating ${type} chart:`, e); }
});
});
};
const cleanupCharts = (trackNumber = null) => {
const destroyCharts = charts => Object.values(charts).forEach(c => c?.destroy?.());
if (trackNumber && trackCharts[trackNumber]) {
destroyCharts(trackCharts[trackNumber]);
delete trackCharts[trackNumber];
} else {
Object.values(trackCharts).forEach(destroyCharts);
trackCharts = {};
}
};
const hasValidGPS = pt => pt.Latitude && pt.Longitude && pt.Latitude !== "" && pt.Longitude !== "";
function filterGPSCoordinates(track) {
const allowNoGPS = localStorage.getItem("recorder-allow-no-gps")=="true";
if (!allowNoGPS) return track.filter(hasValidGPS);
return track.map(pt => {
['Latitude', 'Longitude', 'Altitude'].forEach(k => { if (!isFinite(parseFloat(pt[k]))) pt[k] = 0; });
return pt;
});
}
function saveKML(track,title) {
track = filterGPSCoordinates(track);
const fields = [
{key:'Heartrate', name:'heartrate', display:'Heart Rate'},
{key:'Steps', name:'steps', display:'Step Count'},
{key:'Core', name:'core', display:'Core Temp'},
{key:'Skin', name:'skin', display:'Skin Temp'}
];
const hasField = f => track[0][f.key] !== undefined;
const kmlField = (f,type) => hasField(f) ? `<gx:${type} name="${f.name}"${type==='SimpleArrayField'?' type="int"':''}>${
type==='SimpleArrayField' ? `\n <displayName>${f.display}</displayName>\n ` :
track.map(pt=>`\n <gx:value>${0|pt[f.key]}</gx:value>`).join("")+'\n '
}</gx:${type}>` : '';
const kml = `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2">
<Document>
<Schema id="schema">
${fields.map(f=>kmlField(f,'SimpleArrayField')).join('\n')}
</Schema>
<Folder>
<name>Tracks</name>
<Placemark>
<name>${title}</name>
<gx:Track>
${track.map(pt=>` <when>${pt.Time.toISOString()}</when>`).join('\n')}
${track.map(pt=>` <gx:coord>${pt.Longitude} ${pt.Latitude} ${pt.Altitude}</gx:coord>`).join('\n')}
<ExtendedData>
<SchemaData schemaUrl="#schema">
${fields.map(f=>kmlField(f,'SimpleArrayData')).join('\n')}
</SchemaData>
</ExtendedData>
</gx:Track>
</Placemark>
</Folder>
</Document>
</kml>`;
Util.saveFile(title+".kml", "application/vnd.google-earth.kml+xml", kml);
showToast("Download finished.", "success");
}
function saveGPX(track, title) {
if (!track?.[0]?.Time) return showToast("Error in trackfile.", "error");
track = filterGPSCoordinates(track);
let lastTime = 0;
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx creator="Bangle.js" version="1.1" xmlns="http://www.topografix.com/GPX/1/1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd" xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1" xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3">
<metadata><time>${track[0].Time.toISOString()}</time></metadata>
<trk><name>${title}</name><trkseg>${track.map(pt => {
// Calculate cadence (steps per minute) from step count
// Formula: (steps in interval * 60 seconds * 1000ms) / time elapsed in ms
// Simplified to: steps * 30000 / time_ms (since 60 * 1000 / 2 = 30000)
const cadence = pt.Steps && lastTime ? pt.Steps * 30000 / (pt.Time.getTime() - lastTime) : 0;
lastTime = pt.Time.getTime();
return `
<trkpt lat="${pt.Latitude}" lon="${pt.Longitude}">
<ele>${pt.Altitude}</ele>
<time>${pt.Time.toISOString()}</time>
<extensions><gpxtpx:TrackPointExtension>
${pt.Heartrate ? `<gpxtpx:hr>${pt.Heartrate}</gpxtpx:hr>` : ''}
${cadence ? `<gpxtpx:cad>${cadence}</gpxtpx:cad>` : ''}
</gpxtpx:TrackPointExtension></extensions>
</trkpt>`;
}).join('')}
</trkseg>
</trk>
</gpx>`;
Util.saveFile(title+".gpx", "application/gpx+xml", gpx);
showToast("Download finished.", "success");
}
function saveCSV(track, title) {
if(!track[0]) return showToast(`Can't save empty csv "${title}" (no headers)`, "error");
const headers = Object.keys(track[0]);
const csv = headers.join(",") + "\n" + track.map(t =>
headers.map(k => t[k] instanceof Date ? t[k].toISOString() : t[k]).join(",")
).join("\n");
Util.saveCSV(title, csv);
showToast("Download finished.", "success");
}
function trackLineToObject(headers, l) {
if (l===undefined) return {};
const t = l.trim().split(","), o = {};
headers.forEach((header,i) => o[header] = t[i]);
if (o.Time) o.Time = new Date(o.Time*1000);
return o;
}
function downloadTrack(filename, callback) {
function onData(data) {
const lines = data.trim().split("\n"), headers = lines.shift().split(",");
callback(lines.map(l=>trackLineToObject(headers, l)).filter(t => t.Time));
}
const data = fileCache.get(filename);
if (data) onData(data);
else {
Util.showModal(`Downloading ${filename}...`);
Util.readStorageFile(filename, data => {
fileCache.set(filename, data);
onData(data);
Util.hideModal();
});
}
}
function downloadAll(trackList, cb) {
const tracks = [...trackList];
const downloadOne = () => {
const track = tracks.pop();
if(!track) return showToast("Finished downloading all.", "success");
downloadTrack(track.filename, lines => {
cb(lines, `Bangle.js Track ${track.number}`);
downloadOne();
});
};
downloadOne();
}
// ========================================
// Map Visualization
// ========================================
function createLeafletMap(containerId, coordinates, fullTrack) {
try {
const map = L.map(containerId, {
scrollWheelZoom: false,
zoomControl: true
}).setView([0, 0], 13);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: 'Map data from <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19
}).addTo(map);
if (coordinates && coordinates.length > 0) {
const polyline = L.polyline(coordinates, {
color: '#2563eb',
weight: 3,
opacity: 0.8,
interactive: false
}).addTo(map);
// Get start and end times from full track data
const startTime = fullTrack && fullTrack[0] && fullTrack[0].Time ?
fullTrack[0].Time.toLocaleTimeString() : '';
const endTime = fullTrack && fullTrack[fullTrack.length-1] && fullTrack[fullTrack.length-1].Time ?
fullTrack[fullTrack.length-1].Time.toLocaleTimeString() : '';
// Create clickable markers for every GPS point
if (fullTrack && fullTrack.length > 0) {
const gpsPoints = fullTrack.filter(hasValidGPS);
const allMarkers = []; // Track all markers for style management
const allIndicators = []; // Track small indicator markers
let activeMarkerIndex = null; // Track which marker is currently active
// Generate popup content for any point
const generatePopupContent = (pointData, pointIndex) => {
const items = [];
if (pointData.Time) items.push(`<strong>🕐 ${pointData.Time.toLocaleTimeString()}</strong>`);
if (pointData.Heartrate) items.push(`❤️ ${pointData.Heartrate} BPM`);
if (pointData['Barometer Altitude'] && pointData['Barometer Altitude'] !== "" && !isNaN(parseFloat(pointData['Barometer Altitude']))) {
const e = convertElevation(parseFloat(pointData['Barometer Altitude']));
items.push(`⛰️ ${e.value.toFixed(0)} ${e.unit}`);
} else if (pointData.Altitude) {
const e = convertElevation(parseFloat(pointData.Altitude));
items.push(`⛰️ ${e.value.toFixed(0)} ${e.unit}`);
}
if (pointIndex > 0 && gpsPoints[pointIndex-1]?.Time && pointData.Time) {
const prev = gpsPoints[pointIndex-1];
const [lat1, lon1, lat2, lon2] = [prev.Latitude, prev.Longitude, pointData.Latitude, pointData.Longitude].map(parseFloat);
if ([lat1, lon1, lat2, lon2].every(v => !isNaN(v))) {
const speedKmh = (calculateDistance(lat1, lon1, lat2, lon2) / ((pointData.Time - prev.Time) / 1000)) * 3.6;
const s = convertSpeed(speedKmh);
items.push(`🏃 ${s.value.toFixed(1)} ${s.unit}`);
}
}
if (pointData['Battery Percentage']) {
let b = `🔋 ${pointData['Battery Percentage']}%`;
if (pointData['Battery Voltage']) b += ` (${parseFloat(pointData['Battery Voltage']).toFixed(2)}V)`;
items.push(b);
}
if (pointData.Steps) {
const cumSteps = fullTrack.reduce((sum, pt) => pt.Time?.getTime() <= pointData.Time.getTime() ? sum + (parseInt(pt.Steps) || 0) : sum, 0);
items.push(`👣 ${cumSteps.toLocaleString()} total steps`);
}
if (pointData['Barometer Temperature']) {
const t = convertTemperature(parseFloat(pointData['Barometer Temperature']));
items.push(`🌡️ ${t.value.toFixed(1)} ${t.unit}`);
}
if (pointData['Barometer Pressure']) items.push(`🌀 ${parseFloat(pointData['Barometer Pressure']).toFixed(1)} hPa`);
return `<div style="font-size: 12px; line-height: 1.4;">${items.join('<br>')}</div>`;
};
gpsPoints.forEach((point, index) => {
const lat = parseFloat(point.Latitude);
const lon = parseFloat(point.Longitude);
if (!isNaN(lat) && !isNaN(lon)) {
const marker = L.circleMarker([lat, lon], { radius: 10, weight: 1, fillOpacity: 0.01, opacity: 0.01, color: '#2563eb', fillColor: '#2563eb' }).addTo(map);
const indicator = L.circleMarker([lat, lon], { radius: 3, weight: 1, fillOpacity: 0.8, opacity: 1, color: '#999', fillColor: '#999', interactive: false }).addTo(map);
allMarkers.push(marker);
allIndicators.push(indicator);
Object.assign(marker, { _index: index, _pointData: point });
}
});
// Map click handler for marker selection
// We detect clicks in two ways:
// 1. Clicks within 20 pixels of the GPS track line (for clicking between points)
// 2. Clicks within 15 pixels of a marker (for precise point selection)
map.on('click', e => {
const clickLatLng = e.latlng;
// Check if click is near the GPS track line (within 20 pixels)
const closestPointOnLine = L.GeometryUtil.closest(map, polyline.getLatLngs(), clickLatLng);
const nearPolyline = closestPointOnLine?.distance <= 20;
// Find closest marker to click location
let [closestMarker, closestIndex, closestDistance] = [null, -1, Infinity];
allMarkers.forEach((marker, i) => {
const distance = map.distance(clickLatLng, marker.getLatLng());
if (distance < closestDistance) [closestMarker, closestIndex, closestDistance] = [marker, i, distance];
});
// Convert geographic distance to pixel distance based on current zoom level
// Formula: Earth's circumference (40075km) * cos(latitude) / (2^(zoom+8))
// This gives us meters per pixel, allowing accurate click detection
const zoom = map.getZoom();
const pixelDistance = closestDistance / (40075016.686 * Math.abs(Math.cos(clickLatLng.lat * Math.PI / 180)) / Math.pow(2, zoom + 8));
const setMarkerStyle = (idx, active) => idx !== null &&
allMarkers[idx].setStyle({ fillOpacity: active ? 0.5 : 0.01, opacity: active ? 0.8 : 0.01, radius: active ? 12 : 10 });
if (closestMarker && (nearPolyline || pixelDistance <= 15)) {
if (activeMarkerIndex !== null && activeMarkerIndex !== closestIndex) setMarkerStyle(activeMarkerIndex, false);
setMarkerStyle(closestIndex, true);
activeMarkerIndex = closestIndex;
closestMarker.bindPopup(generatePopupContent(closestMarker._pointData, closestIndex)).openPopup();
} else if (activeMarkerIndex !== null) {
setMarkerStyle(activeMarkerIndex, false);
activeMarkerIndex = null;
}
});
// Handle dragging near track line
map.on('mousemove', e => {
const nearLine = L.GeometryUtil.closest(map, polyline.getLatLngs(), e.latlng)?.distance <= 20;
map.dragging[nearLine ? 'disable' : 'enable']();
map._container.style.cursor = nearLine ? 'pointer' : '';
}).on('mouseout', () => {
map.dragging.enable();
map._container.style.cursor = '';
});
}
// Start/end markers
const markerStyle = { radius: 6, color: '#fff', weight: 2, opacity: 1, fillOpacity: 0.8, bubblingMouseEvents: false };
const addMarker = (coord, color, text) => {
const marker = L.circleMarker(coord, { ...markerStyle, fillColor: color }).addTo(map).bindPopup(text);
marker.on('click dblclick mousedown mouseup touchstart touchend', L.DomEvent.stopPropagation);
return marker;
};
addMarker(coordinates[0], '#22c55e', startTime ? `Start - ${startTime}` : 'Start');
if (coordinates.length > 1) {
addMarker(coordinates[coordinates.length - 1], '#ef4444', endTime ? `End - ${endTime}` : 'End');
}
map.fitBounds(polyline.getBounds().pad(0.1));
}
leafletMaps[containerId] = map;
return map;
} catch (error) {
console.error('Error creating map:', error);
return null;
}
}
function cleanupMaps(mapId = null) {
const removeMaps = m => m?.remove?.();
if (mapId && leafletMaps[mapId]) {
removeMaps(leafletMaps[mapId]);
delete leafletMaps[mapId];
}
else {
Object.values(leafletMaps).forEach(removeMaps);
leafletMaps = {};
}
}
function getTrackList() {
Util.showModal("Loading Track List...");
domTracks.innerHTML = "";
cleanupMaps();
cleanupCharts();
Puck.eval(`require("Storage").list(/^recorder\\.log.*\\.csv$/,{sf:1})`,files=>{
let trackList = [];
// Use promise chain to load tracks sequentially - this prevents overwhelming the Bangle
// with too many concurrent requests and ensures stable Bluetooth communication
let promise = Promise.resolve();
files.forEach(filename => {
promise = promise.then(()=>new Promise(resolve => {
const matches = filename.match(/^recorder\.log(.*)\.csv$/);
const trackNo = matches ? matches[1] : '';
Util.showModal(`Loading Track ${trackNo}...`);
// This function runs on the Bangle to quickly scan for valid GPS data
// Many tracks start recording before GPS lock, so we search up to 100 lines
// to find the first coordinate for the track preview
Puck.eval(`(function(fn) {
var f = require("Storage").open(fn,"r");
var headers = f.readLine().trim();
var data = f.readLine();
var lIdx = headers.split(",").indexOf("Latitude");
if (lIdx >= 0) {
var tries = 100;
var l = data;
while (l && l.split(",")[lIdx]=="" && tries--)
l = f.readLine();
if (l) data = l;
}
return {headers:headers,l:data};
})(${JSON.stringify(filename)})`, trackInfo=>{
if (!trackInfo || !("headers" in trackInfo)) {
showToast("Error loading track list.", "error");
resolve();
}
trackInfo.headers = trackInfo.headers.split(",");
trackList.push({
filename : filename,
number : trackNo,
info : trackInfo
});
resolve();
});
}));
});
promise.then(() => {
trackList.sort((a, b) => b.number.localeCompare(a.number));
let html = `
<div class="container">
<h2>GPS Tracks</h2>`;
if (trackList.length > 0) {
html += `<div class="accordion">`;
trackList.forEach((track, index) => {
const trackData = trackLineToObject(track.info.headers, track.info.l);
const dateStr = trackData.Time ?
trackData.Time.toLocaleDateString(undefined, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) +
' at ' + trackData.Time.toLocaleTimeString() :
"No date info";
html += `
<div class="accordion-item">
<input type="checkbox" id="accordion-track-${track.number}" name="accordion-tracks" hidden>
<label class="accordion-header" for="accordion-track-${track.number}" data-track-index="${index}">
<i class="icon icon-arrow-right mr-1"></i>
<strong>Track ${track.number}</strong> - ${dateStr}
</label>
<div class="accordion-body" id="track-content-${track.number}">
<div class="track-loading">Click to load track data...</div>
</div>
</div>`;
});
html += `</div>`;
}
if (trackList.length==0) {
html += `
<div class="column col-12">
<div class="card">
<div class="card-header">
<div class="card-title h5">No tracks</div>
<div class="card-subtitle text-gray">No GPS tracks found</div>
</div>
</div>
</div>`;
}
html += `
<h2>Batch Operations</h2>
<div class="form-group">
${['KML', 'GPX', 'CSV'].map(fmt => `<button class="btn btn-primary" task="download${fmt.toLowerCase()}_all">Download all ${fmt}</button>`).join(' ')}
</div>
<h2>Settings</h2>
<div class="form-group">
<label class="form-switch">
<input type="checkbox" id="settings-allow-no-gps" ${localStorage.getItem("recorder-allow-no-gps")=="true" ? "checked" : ""}>
<i class="form-icon"></i> Include GPX/KML entries even when there's no GPS info
</label>
</div>
<div class="form-group">
<label class="form-label">Units</label>
<select class="form-select" id="settings-units">
<option value="auto">Auto-detect from locale</option>
<option value="metric">Metric (km/h, °C, m)</option>
<option value="imperial">Imperial (mph, °F, ft)</option>
</select>
</div>
</div>`; // Close container div here to include everything
domTracks.innerHTML = html;
window.currentTrackList = trackList;
function displayTrack(trackIndex, trackNumber) {
const trackContainer = document.getElementById(`track-content-${trackNumber}`);
if (!trackContainer || trackIndex >= trackList.length) return;
if (trackContainer.dataset.loaded === 'true') return;
trackContainer.innerHTML = '<div class="track-loading">Loading track data...</div>';
const track = trackList[trackIndex];
const trackData = trackLineToObject(track.info.headers, track.info.l);
let trackHtml = `
<div class="track-content-wrapper">
<div class="card">
<div class="card-body">
${trackData.Latitude ? `
<div id="map-${track.number}" class="leaflet-map"></div>
<div class="track-stats">
<span id="stats-${track.number}">Loading...</span>
</div>` : ''}
<div id="charts-${track.number}" class="charts-container"></div>
</div>
<div class="card-footer">
<button class="btn btn-primary" filename="${track.filename}" trackid="${track.number}" task="downloadkml">Download KML</button>
<button class="btn btn-primary" filename="${track.filename}" trackid="${track.number}" task="downloadgpx">Download GPX</button>
<button class="btn btn-primary" filename="${track.filename}" trackid="${track.number}" task="downloadcsv">Download CSV</button>
<button class="btn btn-default" filename="${track.filename}" trackid="${track.number}" task="delete">Delete</button>
</div>
</div>
</div>`;
trackContainer.innerHTML = trackHtml;
trackContainer.dataset.loaded = 'true';
attachTrackButtonListeners(trackContainer);
downloadTrack(track.filename, fullTrack => {
if (trackData.Latitude) {
const coordinates = fullTrack
.filter(hasValidGPS)
.map(pt => [parseFloat(pt.Latitude), parseFloat(pt.Longitude)]);
if (coordinates.length > 0) {
createLeafletMap(`map-${track.number}`, coordinates, fullTrack);
let distance = 0;
for (let i = 1; i < coordinates.length; i++) distance += L.latLng(coordinates[i-1]).distanceTo(L.latLng(coordinates[i]));
const duration = fullTrack[fullTrack.length-1].Time - fullTrack[0].Time;
const hours = Math.floor(duration / 3600000), minutes = Math.floor((duration % 3600000) / 60000);
const statsEl = document.getElementById(`stats-${track.number}`);
if (statsEl) {
const d = convertDistance(distance/1000);
statsEl.innerHTML = `<strong>Distance:</strong> ${d.value.toFixed(2)} ${d.unit} | <strong>Duration:</strong> ${hours}h ${minutes}m | <strong>Points:</strong> ${coordinates.length}`;
}
}
}
createChartsForTrack(track.number, fullTrack);
});
}
function attachTrackButtonListeners(container) {
const buttons = container.querySelectorAll("button[task]");
buttons.forEach(button => {
button.addEventListener("click", event => {
const button = event.currentTarget;
const filename = button.getAttribute("filename");
const trackid = button.getAttribute("trackid");
const task = button.getAttribute("task");
if (!filename || !trackid) return;
switch(task) {
case "delete":
if (button.dataset.confirmDelete === "true") {
// Second click - proceed with deletion
Util.showModal(`Deleting ${filename}...`);
Util.eraseStorageFile(filename, () => {
Util.hideModal();
getTrackList();
});
} else {
// First click - change to confirm state
const originalText = button.textContent;
button.textContent = "Confirm Delete";
button.classList.add("btn-error");
button.dataset.confirmDelete = "true";
// Reset after 3 seconds
setTimeout(() => {
if (button.dataset.confirmDelete === "true") {
button.textContent = originalText;
button.classList.remove("btn-error");
delete button.dataset.confirmDelete;
}
}, 3000);
}
break;
case "downloadkml":
downloadTrack(filename, track => saveKML(track, `Bangle.js Track ${trackid}`));
break;
case "downloadgpx":
downloadTrack(filename, track => saveGPX(track, `Bangle.js Track ${trackid}`));
break;
case "downloadcsv":
downloadTrack(filename, track => saveCSV(track, `Bangle.js Track ${trackid}`));
break;
}
});
});
}
if (trackList.length > 0) {
document.querySelectorAll('.accordion-header').forEach(header => {
header.addEventListener('click', e => {
const trackIndex = parseInt(header.dataset.trackIndex);
const trackNumber = trackList[trackIndex].number;
if (!document.getElementById(`accordion-track-${trackNumber}`).checked) {
setTimeout(() => displayTrack(trackIndex, trackNumber), 10);
}
});
});
}
document.getElementById("settings-allow-no-gps").addEventListener("change",event=>{
localStorage.setItem("recorder-allow-no-gps", event.target.checked);
});
const unitsSelector = document.getElementById("settings-units");
const currentSettings = getLocalizationSettings();
unitsSelector.value = currentSettings.auto ? "auto" : (currentSettings.speed === 'kmh' ? "metric" : "imperial");
unitsSelector.addEventListener("change", e => {
const val = e.target.value;
if (val === "auto") localStorage.removeItem("recorder-units");
else saveLocalizationSettings({
speed: val === "metric" ? 'kmh' : 'mph',
distance: val === "metric" ? 'km' : 'mi',
temperature: val === "metric" ? 'celsius' : 'fahrenheit',
elevation: val === "metric" ? 'm' : 'ft'
});
getTrackList();
});
Util.hideModal();
domTracks.querySelectorAll("button[task$='_all']").forEach(button => {
button.addEventListener("click", e => {
const task = e.currentTarget.getAttribute("task");
downloadAll(trackList, task.includes('kml') ? saveKML : task.includes('gpx') ? saveGPX : saveCSV);
});
});
});
});
}
function onInit() {
getTrackList();
document.addEventListener('click', e => {
if (e.target.tagName === 'A' && (e.target.href.includes('leafletjs.com') || e.target.href.includes('openstreetmap.org'))) {
e.preventDefault();
alert(e.target.href.includes('openstreetmap.org') ?
'Map data from OpenStreetMap\nLicense: Open Database License (ODbL)\nWebsite: openstreetmap.org/copyright' :
'Leaflet © Volodymyr Agafonkin\nLicense: BSD 2-Clause\nWebsite: leafletjs.com');
}
}, true);
}
</script>
</body>
</html>