Add route heat colouring and detailed running analytics
This commit is contained in:
+75
-16
@@ -1,32 +1,91 @@
|
||||
"use strict";
|
||||
const valid = v => typeof v === "number" && Number.isFinite(v);
|
||||
const paceLabel = v => {const s = Math.round(v); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;};
|
||||
async function showActivity() {
|
||||
const container = document.getElementById("route-map");
|
||||
const status = document.getElementById("map-status");
|
||||
const container = document.getElementById("route-map"), status = document.getElementById("map-status");
|
||||
try {
|
||||
const response = await fetch(container.dataset.url);
|
||||
if (!response.ok) throw new Error("Activity unavailable");
|
||||
const data = await response.json();
|
||||
let locate = () => {};
|
||||
if (data.segments.length && window.L) {
|
||||
container.replaceChildren();
|
||||
const map = L.map(container, {scrollWheelZoom: false});
|
||||
const map = L.map(container, {scrollWheelZoom: false, preferCanvas: true});
|
||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {maxZoom: 19, attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'}).addTo(map);
|
||||
const route = L.polyline(data.segments, {color: "#345e40", weight: 4, opacity: .95}).addTo(map);
|
||||
map.fitBounds(route.getBounds(), {padding: [35, 35], maxZoom: 16});
|
||||
status.textContent = "Scroll to explore your charts. Drag the map to explore the route.";
|
||||
const outline = L.polyline(data.segments, {color: "#263d32", weight: 8, opacity: .35}).addTo(map);
|
||||
const fit = () => map.fitBounds(outline.getBounds(), {padding: [40, 40], maxZoom: 16});
|
||||
fit();
|
||||
document.getElementById("fit-route").onclick = fit;
|
||||
const colours = ["#356ac3", "#319dc1", "#37b5a0", "#94bf6e", "#e2c954", "#eea044", "#de6744", "#b83d50"];
|
||||
const heat = L.layerGroup().addTo(map), arrows = L.layerGroup().addTo(map);
|
||||
const samples = data.route_segments.flat();
|
||||
const format = (v, key) => !valid(v) ? "Unavailable" : key === "speed" ? `${(v * 3.6).toFixed(1)} km/h` : key === "grade" ? `${v.toFixed(1)}%` : `${Math.round(v)} bpm`;
|
||||
const colourRoute = () => {
|
||||
heat.clearLayers();
|
||||
const key = document.getElementById("map-metric").value;
|
||||
const values = samples.map(s => s[key]).filter(valid).sort((a,b) => a-b);
|
||||
const low = values[Math.floor((values.length-1)*.1)], high = values[Math.floor((values.length-1)*.9)];
|
||||
document.getElementById("legend-low").textContent = format(low, key);
|
||||
document.getElementById("legend-high").textContent = format(high, key);
|
||||
document.getElementById("legend-ramp").style.background = `linear-gradient(to right, ${colours.join(",")})`;
|
||||
for (const segment of data.route_segments) {
|
||||
for (let i = 1; i < segment.length; i++) {
|
||||
const a = segment[i-1], b = segment[i], value = b[key];
|
||||
const ratio = high > low ? (value-low)/(high-low) : .5;
|
||||
const colour = valid(value) ? colours[Math.max(0,Math.min(7,Math.floor(ratio*8)))] : "#969c99";
|
||||
L.polyline([a.point,b.point], {color: colour, weight: 5, opacity: 1}).bindTooltip(format(value,key)).addTo(heat);
|
||||
}
|
||||
}
|
||||
};
|
||||
colourRoute();
|
||||
document.getElementById("map-metric").onchange = colourRoute;
|
||||
const spacing = Math.max(1, Math.floor(samples.length / 16));
|
||||
let passed = 0;
|
||||
for (const segment of data.route_segments) {
|
||||
for (let i = 1; i < segment.length; i++, passed++) {
|
||||
if (passed % spacing) continue;
|
||||
const a = map.project(segment[i-1].point, 14), b = map.project(segment[i].point, 14);
|
||||
if (a.distanceTo(b) < .01) continue;
|
||||
const angle = Math.atan2(b.y-a.y,b.x-a.x)*180/Math.PI;
|
||||
L.marker(segment[i].point, {interactive:false, icon:L.divIcon({className:"route-arrow", html:`<span style="transform:rotate(${angle}deg)">➤</span>`, iconSize:[20,20], iconAnchor:[10,10]})}).addTo(arrows);
|
||||
}
|
||||
}
|
||||
document.getElementById("show-directions").onchange = e => e.target.checked ? arrows.addTo(map) : map.removeLayer(arrows);
|
||||
for (const [row, label, name] of [[samples[0],"S","Start"],[samples.at(-1),"F","Finish"]]) {
|
||||
L.marker(row.point, {icon:L.divIcon({className:`route-end route-${label}`, html:label, iconSize:[25,25], iconAnchor:label === "S" ? [27,13] : [-2,13]})}).bindTooltip(name).addTo(map);
|
||||
}
|
||||
const cursor = L.circleMarker(samples[0].point,{radius:7,color:"#fff",weight:3,fillColor:"#263d32",fillOpacity:1,interactive:false});
|
||||
locate = distance => {
|
||||
let closest = null, delta = Infinity;
|
||||
for (const sample of samples) if (valid(sample.distance) && Math.abs(sample.distance-distance)<delta) {closest=sample;delta=Math.abs(sample.distance-distance);}
|
||||
if (closest) cursor.setLatLng(closest.point).addTo(map);
|
||||
};
|
||||
status.textContent = "Follow the arrows from S to F. Hover over the route for measurements, or over a chart to place a marker.";
|
||||
} else {
|
||||
container.querySelector("p").textContent = "No route to display. This activity may have been recorded indoors.";
|
||||
status.textContent = "Activity statistics are available below.";
|
||||
container.textContent = "No GPS route recorded for this activity.";
|
||||
status.textContent = "Recorded measurements are available below.";
|
||||
document.getElementById("fit-route").disabled = true;
|
||||
}
|
||||
if (!window.Chart) return;
|
||||
const config = (id, key, color, formatter, reverse = false) => {
|
||||
const points = data.chart.filter(s => s.distance !== null).map(s => ({x: s.distance / 1000, y: formatter(s[key])}));
|
||||
new Chart(document.getElementById(id), {type: "line", data: {datasets: [{data: points, borderColor: color, backgroundColor: `${color}12`, fill: true, borderWidth: 1.7, pointRadius: 0, pointHitRadius: 12, tension: .2, spanGaps: false}]}, options: {responsive: true, maintainAspectRatio: false, parsing: false, plugins: {legend: {display: false}}, scales: {x: {type: "linear", grid: {display: false}, border: {display: false}, ticks: {font: {size: 10}, color: "#949c8b", callback: v => `${v} km`}}, y: {reverse, grid: {color: "#f0f2eb"}, border: {display: false}, ticks: {font: {size: 10}, color: "#949c8b"}}}}});
|
||||
const line = (label,key,colour,unit,axis="y") => ({label,key,borderColor:colour,unit,yAxisID:axis});
|
||||
const chart = (id, series, reverse=false) => {
|
||||
const canvas = document.getElementById(id);
|
||||
if (!series.some(s=>data.chart.some(row=>valid(row[s.key])))) {
|
||||
const message=document.createElement("p"); message.className="chart-empty";message.textContent="Not recorded by your watch for this activity.";canvas.replaceWith(message);return;
|
||||
}
|
||||
const datasets = series.map(s=>({...s,data:data.chart.filter(r=>valid(r.distance)).map(r=>({x:r.distance/1000,y:valid(r[s.key])?r[s.key]:null})),borderWidth:1.6,pointRadius:0,pointHitRadius:8,tension:.15,spanGaps:false}));
|
||||
const axis = right => ({position:right?"right":"left",reverse:reverse&&!right,grid:{color:"#edf0e9",drawOnChartArea:!right},border:{display:false},ticks:{color:"#7d847b",font:{size:10},callback:v=>reverse&&!right?paceLabel(v):Number(v.toFixed(2))}});
|
||||
new Chart(canvas,{type:"line",data:{datasets},options:{responsive:true,maintainAspectRatio:false,animation:false,parsing:false,interaction:{mode:"index",intersect:false},onHover:(_event,items,instance)=>{if(items.length)locate(instance.data.datasets[items[0].datasetIndex].data[items[0].index].x*1000);},plugins:{legend:{position:"bottom",labels:{usePointStyle:true,boxWidth:7,font:{size:11}}},tooltip:{callbacks:{title:items=>`${items[0].parsed.x.toFixed(2)} km`,label:item=>`${item.dataset.label}: ${item.dataset.unit==="/km"?paceLabel(item.parsed.y):item.parsed.y.toFixed(1)} ${item.dataset.unit}`}}},scales:{x:{type:"linear",grid:{display:false},border:{display:false},ticks:{maxTicksLimit:7,color:"#7d847b",font:{size:10},callback:v=>`${v} km`}},y:axis(false),...(series.some(s=>s.yAxisID==="y2")?{y2:axis(true)}:{})}}});
|
||||
};
|
||||
config("pace-chart", "speed", "#668b47", v => v > .5 ? Math.min(30, 1000 / v / 60) : null, true);
|
||||
config("hr-chart", "heart_rate", "#bd8063", v => v > 0 ? v : null);
|
||||
} catch {
|
||||
container.querySelector("p").textContent = "We couldn’t load this activity. Please refresh and try again.";
|
||||
status.textContent = "The activity may no longer be available.";
|
||||
chart("pace-chart",[line("Pace","pace","#38795b","/km"),line("GAP estimate","gap","#bc9050","/km")],true);
|
||||
chart("hr-chart",[line("Heart rate","heart_rate","#bd685b","bpm")]);
|
||||
chart("elevation-chart",[line("Elevation","elevation","#668b47","m"),line("Gradient","grade","#ba965a","%","y2")]);
|
||||
chart("cadence-chart",[line("Cadence","cadence","#5a83ab","spm"),line("Step length","stride_length","#9b83b3","m","y2")]);
|
||||
chart("power-chart",[line("Power","power","#b48a38","W")]);
|
||||
chart("dynamics-chart",[line("Vertical oscillation","vertical_oscillation","#699f96","cm"),line("Ground contact","ground_contact","#a57b9c","ms","y2")]);
|
||||
} catch (error) {
|
||||
status.textContent = "We couldn’t finish loading this activity. Please refresh and try again.";
|
||||
console.error("Activity display failed", error);
|
||||
}
|
||||
}
|
||||
showActivity();
|
||||
|
||||
Reference in New Issue
Block a user