Add route heat colouring and detailed running analytics

This commit is contained in:
Peter Stockings
2026-10-01 18:43:33 +10:00
parent e37569ad09
commit 24c7fc2c6a
11 changed files with 526 additions and 31 deletions
+75 -16
View File
@@ -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: '&copy; <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();
+3
View File
@@ -5,3 +5,6 @@
@media(max-width:1180px){.sidebar{width:205px;padding:30px 17px}main{margin-left:205px}.page{padding:30px 25px 20px}.topbar{padding:0 25px}.stat-card{padding:18px 16px}.stat-value{font-size:32px}.time-value{font-size:28px}.stat-label{font-size:8px}.auth-layout{grid-template-columns:1fr 390px;gap:20px}.auth-intro h1{font-size:45px}.section-head{padding:20px}.subtle-label{white-space:normal}.activity-name strong{max-width:215px}}
@media(max-width:950px){.stats-grid{grid-template-columns:repeat(2,1fr)}.overview-grid{grid-template-columns:1fr}.milestone-card{min-height:210px}.mountain{width:45%;left:50%;top:20px}.milestone-card>.eyebrow{position:static;margin-bottom:15px}.auth-layout{grid-template-columns:1fr;max-width:480px;margin:auto;min-height:0}.auth-intro{padding:0}.auth-intro h1{font-size:39px}.auth-intro p,.auth-art{display:none}.detail-bottom,.settings-grid{grid-template-columns:1fr}.connection-card{grid-template-columns:1fr}.detail-charts{grid-template-columns:1fr}.page-heading h1{font-size:32px}.page-heading{align-items:flex-start}.activity-table td{padding:15px 12px}}
@media(max-width:680px){.sidebar{position:relative;width:100%;height:auto;padding:17px 19px;border-right:0;border-bottom:1px solid var(--line);display:block}.brand{font-size:27px}.brand-mark{width:30px;height:30px;font-size:26px}.sidebar-label,.sidebar-note,.sidebar-bottom{display:none}.sidebar nav{display:flex;gap:5px;margin-top:12px;overflow-x:auto}.nav-item{white-space:nowrap;font-size:10px;padding:7px 10px;margin:0}.nav-item span{display:none}main{margin:0}.topbar{height:49px;padding:0 20px}.local-badge{display:none}.page{padding:25px 18px}.page-heading{flex-direction:column;gap:16px}.page-heading h1{font-size:32px}.page-heading p{font-size:12px}.stats-grid{gap:11px}.stat-card{padding:17px}.stat-value{font-size:32px}.time-value{font-size:27px}.stat-value small{font-size:11px}.stat-label{font-size:8px}.stat-card p{font-size:9px}.section-head{padding:19px;align-items:flex-start}.section-head h2{font-size:18px}.section-head .subtle-label{max-width:110px;text-align:right}.chart-wrap{padding:10px 15px 20px;height:200px}.auth-card{padding:24px}.auth-intro h1{font-size:38px}.auth-intro{padding-bottom:10px}.footer{font-size:8px;gap:10px}.footer span:last-child{display:none}#route-map{height:290px}.publishing-panel,.settings-card{padding:22px}.splits-table td,.splits-table th{padding:10px;font-size:10px}}
.activity-nav{display:flex;gap:22px;overflow-x:auto;margin:-8px 0 25px;border-bottom:1px solid var(--line);padding-bottom:13px;font-size:12px;white-space:nowrap}.activity-nav a:hover{color:#67934c}.run-map .section-head{flex-wrap:wrap}.map-controls{display:flex;align-items:center;gap:16px;flex-wrap:wrap;font-size:11px}.map-controls label{display:flex;gap:8px;align-items:center}.map-controls select{padding:8px;border:1px solid var(--line);border-radius:6px;color:var(--ink);background:#f6f7f2}.directions-toggle input{accent-color:var(--green)}.run-map #route-map{height:440px;background:#ecf0e7}.route-legend{display:flex;align-items:center;gap:10px;padding:17px 25px 0;font-size:11px;flex-wrap:wrap}.legend-ramp{height:8px;width:180px;border-radius:8px}.legend-note{margin-left:auto;color:var(--muted);font-size:10px}.route-arrow span{display:block;color:white;font-size:18px;line-height:20px;text-shadow:0 0 3px #263d32,0 1px 2px #263d32}.route-end{background:#263d32;color:white;border:2px solid white;border-radius:50%;text-align:center;font-weight:700;line-height:21px;box-shadow:0 1px 6px #263d3266}.route-F{background:#ac654f}.performance-section{margin-top:34px}.section-title{margin:0 0 20px}.section-title p{margin-top:7px;font-size:12px;color:var(--muted)}.metrics-strip{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1px;background:var(--line);border:1px solid var(--line);border-radius:14px;overflow:hidden;margin-bottom:22px}.metric-tile{padding:20px 23px;background:#fff}.metric-tile>span{font-size:9px;letter-spacing:1px;color:var(--muted)}.metric-tile strong{display:block;font-weight:500;font-size:29px;letter-spacing:-1px;font-variant-numeric:tabular-nums;margin:5px 0}.metric-tile small,.terrain-grid small{font-size:12px;font-weight:400;margin-left:7px;letter-spacing:0;color:var(--muted)}.metric-tile p{font-size:10px;color:var(--muted)}.analytics-charts .chart-wrap{height:275px}.analytics-charts .section-head{align-items:flex-start}.analytics-charts h2{font-size:17px}.chart-empty{padding:55px 15px;text-align:center;color:var(--muted);font-size:12px}.terrain-panel{margin:25px 0}.section-description{padding:0 25px;color:var(--muted);font-size:12px;max-width:850px}.terrain-grid{display:grid;grid-template-columns:repeat(3,1fr);padding:22px 25px;gap:20px}.terrain-grid article{padding:17px;background:#f6f8f0;border-radius:10px}.terrain-grid article>span{font-size:12px}.terrain-grid strong{display:block;font-size:30px;font-weight:500;margin:7px 0}.terrain-grid article>div{font-size:11px;color:var(--muted)}.terrain-grid article>div span{margin:0 7px}.methodology{margin-top:25px;padding:22px 25px;font-size:12px;color:var(--muted)}.methodology summary{cursor:pointer;color:var(--ink);font-weight:600}.methodology summary span{float:right;font-weight:400;font-size:11px}.methodology p{margin-top:16px;max-width:1000px;line-height:1.8}.methodology a{text-decoration:underline}section[id],details[id]{scroll-margin-top:20px}
@media(max-width:680px){.metrics-strip{grid-template-columns:repeat(2,minmax(0,1fr))}.metric-tile{padding:15px}.metric-tile strong{font-size:25px}.terrain-grid{grid-template-columns:1fr;gap:10px;padding:18px}.terrain-grid article{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}.terrain-grid strong{font-size:24px}.run-map #route-map{height:340px}.legend-note{margin-left:0;width:100%}.route-legend{padding:15px 18px 0}.map-controls{gap:10px}.methodology summary span{display:none}.analytics-charts .chart-wrap{height:250px}}