Files

125 lines
8.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
const shareButton = document.getElementById("copy-run-link");
if (shareButton) shareButton.addEventListener("click", async () => {
const status = document.getElementById("share-status");
const fallback = document.getElementById("share-fallback");
const input = document.getElementById("share-link-value");
shareButton.disabled = true;
fallback.hidden = true;
status.textContent = "Creating share link…";
try {
const response = await fetch(shareButton.dataset.url, {
method: "POST", headers: {"X-CSRFToken": shareButton.dataset.csrf},
});
if (!response.ok || response.redirected) throw new Error("Share unavailable");
const data = await response.json();
input.value = data.url;
document.getElementById("revoke-share").hidden = false;
try {
await navigator.clipboard.writeText(data.url);
status.textContent = "Link copied! Anyone with the link can view this run.";
} catch {
// Clipboard permissions and non-HTTPS hosts can prevent automatic copying.
fallback.hidden = false;
input.focus();
input.select();
status.textContent = "Link ready. Copy it below to share this run without sign-in.";
}
} catch {
status.textContent = "Couldn’t create a share link. Please try again.";
} finally {
shareButton.disabled = false;
}
});
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"), 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, 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 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.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;
Chart.defaults.color = "#a0aaac";
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:"#30383b",drawOnChartArea:!right},border:{display:false},ticks:{color:"#a0aaac",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:"#a0aaac",font:{size:10},callback:v=>`${v} km`}},y:axis(false),...(series.some(s=>s.yAxisID==="y2")?{y2:axis(true)}:{})}}});
};
chart("pace-chart",[line("Pace","pace","#ff9268","/km"),line("GAP estimate","gap","#bc9050","/km")],true);
chart("hr-chart",[line("Heart rate","heart_rate","#bd685b","bpm")]);
chart("elevation-chart",[line("Elevation","elevation","#a6c896","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();