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
+165
View File
@@ -0,0 +1,165 @@
"""Recorded metrics and explicitly modelled grade-adjusted pace for running.
Minetti et al., J Appl Physiol 93 (2002), doi:10.1152/japplphysiol.01177.2001.
GAP = elapsed moving time / equivalent flat distance. No heart-rate adjustment.
"""
import bisect
import itertools
import math
import statistics
def numeric(value):
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
def running_cost(grade):
return (
155.4 * grade**5 - 30.4 * grade**4 - 43.3 * grade**3 + 46.3 * grade**2 + 19.5 * grade + 3.6
)
def pace_text(seconds):
if not numeric(seconds) or seconds <= 0:
return "—"
seconds = round(seconds)
return f"{seconds // 60}:{seconds % 60:02d}"
def enrich(samples, running=True):
rows = [dict(sample, grade=None, gap=None, pace=None) for sample in samples]
# Break smoothing at missing elevation/distance, distance resets and recording gaps.
chunks, chunk = [], []
for index, row in enumerate(rows):
valid = numeric(row.get("distance")) and numeric(row.get("elevation"))
if chunk:
previous = rows[chunk[-1]]
dt = (
row["time"] - previous["time"]
if numeric(row.get("time")) and numeric(previous.get("time"))
else 1
)
if not valid or row["distance"] < previous["distance"] or not 0 < dt <= 10:
chunks.append(chunk)
chunk = []
if valid:
chunk.append(index)
if chunk:
chunks.append(chunk)
for indices in chunks:
distances = [rows[i]["distance"] for i in indices]
altitude = [
statistics.median(rows[j]["elevation"] for j in indices[max(0, k - 2) : k + 3])
for k in range(len(indices))
]
for k, index in enumerate(indices):
low = max(0, bisect.bisect_left(distances, distances[k] - 30))
high = min(len(indices) - 1, bisect.bisect_left(distances, distances[k] + 30))
distance = distances[high] - distances[low]
if distance >= 20:
grade = (altitude[high] - altitude[low]) / distance
if abs(grade) <= 0.30:
rows[index]["grade"] = grade * 100
for row in rows:
speed = row.get("speed")
if numeric(speed) and 0.5 < speed < 15:
row["pace"] = 1000 / speed
if running and row["grade"] is not None:
row["gap"] = row["pace"] * running_cost(0) / running_cost(row["grade"] / 100)
return rows
def analyse(activity):
rows = enrich(activity.samples or [], activity.sport == "running")
weighted = {
key: [0.0, 0.0]
for key in (
"heart_rate",
"cadence",
"power",
"stride_length",
"vertical_oscillation",
"ground_contact",
)
}
terrains = {
key: {"label": label, "distance": 0.0, "time": 0.0, "hr_sum": 0.0, "hr_time": 0.0}
for key, label in (
("downhill", "Downhill < −2%"),
("flat", "Flat −2% to 2%"),
("uphill", "Uphill > 2%"),
)
}
equivalent, gap_time, covered = 0.0, 0.0, 0.0
laps = [dict(lap, gap=None, equivalent=0.0, gap_time=0.0) for lap in (activity.laps or [])]
ends, cumulative = [], 0.0
for lap in laps:
cumulative += lap.get("distance", 0)
ends.append(cumulative)
for previous, row in itertools.pairwise(rows):
if not numeric(row.get("time")) or not numeric(previous.get("time")):
continue
dt = row["time"] - previous["time"]
if not 0 < dt <= 10:
continue
for key, values in weighted.items():
value = row.get(key)
if numeric(value) and value > 0:
values[0] += value * dt
values[1] += dt
if not numeric(row.get("distance")) or not numeric(previous.get("distance")):
continue
ds = row["distance"] - previous["distance"]
if not 0 < ds <= dt * 15 or row["pace"] is None:
continue
grade = row["grade"]
if grade is not None:
terrain = terrains["uphill" if grade > 2 else "downhill" if grade < -2 else "flat"]
terrain["distance"] += ds
terrain["time"] += dt
if numeric(row.get("heart_rate")) and row["heart_rate"] > 0:
terrain["hr_sum"] += row["heart_rate"] * dt
terrain["hr_time"] += dt
if row["gap"] is not None:
flat_distance = ds * running_cost(grade / 100) / running_cost(0)
equivalent += flat_distance
gap_time += dt
covered += ds
# Split a sample crossing a lap boundary proportionally to distance.
start = previous["distance"]
while start < row["distance"]:
lap_index = bisect.bisect_right(ends, start)
if lap_index >= len(laps):
break
end = min(ends[lap_index], row["distance"])
fraction = (end - start) / ds
laps[lap_index]["equivalent"] += flat_distance * fraction
laps[lap_index]["gap_time"] += dt * fraction
start = end
for lap in laps:
lap["gap"] = 1000 * lap["gap_time"] / lap["equivalent"] if lap["equivalent"] > 0 else None
for terrain in terrains.values():
terrain["heart_rate"] = (
terrain["hr_sum"] / terrain["hr_time"] if terrain["hr_time"] else None
)
terrain["pace"] = (
terrain["time"] * 1000 / terrain["distance"] if terrain["distance"] else None
)
summary = {
key: total / duration if duration else None for key, (total, duration) in weighted.items()
}
summary.update(
{
"gap": 1000 * gap_time / equivalent if equivalent else None,
"gap_coverage": min(100, covered / activity.distance_m * 100)
if activity.distance_m
else 0,
"max_hr": max(
(r["heart_rate"] for r in rows if numeric(r.get("heart_rate"))), default=None
),
"max_power": max((r["power"] for r in rows if numeric(r.get("power"))), default=None),
"samples": len(rows),
}
)
return {"samples": rows, "summary": summary, "terrain": list(terrains.values()), "laps": laps}
+1
View File
@@ -38,6 +38,7 @@ class Activity(db.Model):
duration_s = db.Column(db.Float, nullable=False, default=0)
elevation_m = db.Column(db.Float, nullable=False, default=0)
avg_hr = db.Column(db.Float)
metrics = db.Column(db.JSON, nullable=False, default=dict, server_default="{}")
visibility = db.Column(db.String(12), nullable=False, default="private")
samples = db.Column(db.JSON, nullable=False, default=list)
laps = db.Column(db.JSON, nullable=False, default=list)
+5 -1
View File
@@ -24,6 +24,7 @@ from werkzeug.security import check_password_hash, generate_password_hash
from garmin_sync.service import SyncError, atomic_write
from .analytics import analyse, pace_text
from .extensions import db, limiter
from .models import Activity, Friendship, User
from .services import activity_data, import_cache, import_upload
@@ -295,7 +296,10 @@ def profile(username):
@web.get("/activities/<int:activity_id>")
def activity(activity_id):
row, owner = get_activity(activity_id)
return render_template("activity.html", activity=row, owner_view=owner)
analysis = analyse(row)
return render_template(
"activity.html", activity=row, owner_view=owner, analysis=analysis, pace_text=pace_text
)
@web.get("/activities/<int:activity_id>/data")
+77 -8
View File
@@ -23,9 +23,12 @@ def instant(value):
return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed.astimezone(UTC)
def activity_from_fit(owner_id, source_key, documents, metadata=None):
def activity_from_fit(owner_id, source_key, documents, metadata=None, refresh=False):
metadata = metadata or {}
if db.session.scalar(db.select(Activity).filter_by(owner_id=owner_id, source_key=source_key)):
existing = db.session.scalar(
db.select(Activity).filter_by(owner_id=owner_id, source_key=source_key)
)
if existing and not refresh:
return None
sessions = [s for doc in documents for s in doc.get("session", [])]
if not sessions:
@@ -52,6 +55,26 @@ def activity_from_fit(owner_id, source_key, documents, metadata=None):
"heart_rate": number(record.get("heart_rate"), None),
"speed": number(record.get("enhanced_speed", record.get("speed")), None),
"elevation": number(record.get("enhanced_altitude", record.get("altitude")), None),
"time": instant(record["timestamp"]).timestamp()
if record.get("timestamp")
else None,
"cadence": (
(number(record.get("cadence")) + number(record.get("fractional_cadence"))) * 2
if sport == "running" and record.get("cadence") is not None
else None
),
"power": number(record.get("power"), None),
"stride_length": (
number(record.get("step_length")) / 1000
if record.get("step_length") is not None
else None
),
"vertical_oscillation": (
number(record.get("vertical_oscillation")) / 10
if record.get("vertical_oscillation") is not None
else None
),
"ground_contact": number(record.get("stance_time"), None),
}
)
laps = []
@@ -67,6 +90,27 @@ def activity_from_fit(owner_id, source_key, documents, metadata=None):
"pace": duration * 1000 / distance if distance > 0 else None,
}
)
metrics = {
key: number(first.get(key), None)
for key in (
"total_elapsed_time",
"total_moving_time",
"max_heart_rate",
"avg_power",
"max_power",
"total_calories",
"total_descent",
"total_training_effect",
"total_anaerobic_training_effect",
"avg_vertical_oscillation",
"avg_stance_time",
"avg_step_length",
"avg_running_cadence",
)
}
if existing:
existing.samples, existing.laps, existing.metrics = samples, laps, metrics
return existing
row = Activity(
owner_id=owner_id,
source_key=source_key,
@@ -77,6 +121,7 @@ def activity_from_fit(owner_id, source_key, documents, metadata=None):
duration_s=sum(number(s.get("total_timer_time")) for s in sessions),
elevation_m=sum(number(s.get("total_ascent")) for s in sessions),
avg_hr=number(metadata.get("averageHR", first.get("avg_heart_rate")), None),
metrics=metrics,
samples=samples,
laps=laps,
)
@@ -84,7 +129,7 @@ def activity_from_fit(owner_id, source_key, documents, metadata=None):
return row
def import_cache(user, data_root):
def import_cache(user, data_root, refresh=False):
if not user.local_account_key:
return 0
directory = Path(data_root) / user.local_account_key
@@ -95,7 +140,7 @@ def import_cache(user, data_root):
metadata = json.loads((manifest.parent / "activity.json").read_text())
docs = [json.loads(p.read_text()) for p in sorted(manifest.parent.glob("activity-*.json"))]
source_key = f"garmin:{user.local_account_key}:{manifest.parent.name}"
if activity_from_fit(user.id, source_key, docs, metadata):
if activity_from_fit(user.id, source_key, docs, metadata, refresh=refresh):
count += 1
return count
@@ -112,19 +157,43 @@ def import_upload(user, payload):
def activity_data(activity, owner_view=False):
"""Call only after access checks. Owners and accepted friends see the full route."""
from .analytics import analyse
analysis = analyse(activity)
segments, segment = [], []
for sample in activity.samples:
route_segments, route = [], []
for sample in analysis["samples"]:
point = sample.get("point")
if point:
segment.append(point)
route.append(sample)
elif segment:
segments.append(segment)
segment = []
route_segments.append(route)
route = []
if segment:
segments.append(segment)
route_segments.append(route)
step = max(1, math.ceil(len(activity.samples) / 750))
chart = [
{k: sample.get(k) for k in ("distance", "heart_rate", "speed", "elevation")}
for sample in activity.samples[::step]
{
k: sample.get(k)
for k in (
"distance",
"heart_rate",
"speed",
"elevation",
"grade",
"gap",
"pace",
"cadence",
"power",
"stride_length",
"vertical_oscillation",
"ground_contact",
)
}
for sample in analysis["samples"][::step]
]
return {"segments": segments, "chart": chart}
return {"segments": segments, "route_segments": route_segments, "chart": chart}
+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}}
+40 -6
View File
@@ -2,13 +2,47 @@
{% block title %}{{ activity.title }} · Stride{% endblock %}
{% block breadcrumb %}ACTIVITY / {{ activity.sport|upper }}{% endblock %}
{% block head %}<link rel="stylesheet" href="{{ url_for('static',filename='vendor/leaflet.css') }}">{% endblock %}
{% macro metric(label, value, unit='', note='') %}
<article class="metric-tile"><span>{{ label }}</span><strong>{{ value }}<small>{{ unit }}</small></strong>{% if note %}<p>{{ note }}</p>{% endif %}</article>
{% endmacro %}
{% block content %}
<a class="back-link" href="{{ url_for('web.dashboard') if owner_view else url_for('web.profile',username=activity.owner.username) }}">← {{ 'Your activities' if owner_view else activity.owner.display_name }}</a>
<div class="page-heading"><div><span class="eyebrow">{{ activity.sport|upper }} · {{ activity.started_at|localdate(activity.owner.timezone, '%A, %d %B %Y') }}</span><h1>{{ activity.title }}</h1><p>By <a href="{{ url_for('web.profile',username=activity.owner.username) }}">{{ activity.owner.display_name }}</a> <span class="dot-separator">·</span> {{ activity.started_at|localdate(activity.owner.timezone, '%H:%M') }}</p></div><span class="pill {{ activity.visibility }}">{{ activity.visibility|title }}</span></div>
<div class="stats-grid"><article class="stat-card featured"><div class="stat-label">DISTANCE</div><div class="stat-value">{{ '%.2f'|format(activity.distance_km) }}<small>km</small></div></article><article class="stat-card"><div class="stat-label">TIMER TIME</div><div class="stat-value time-value">{{ activity.duration_s|duration }}</div></article><article class="stat-card"><div class="stat-label">AVERAGE PACE</div><div class="stat-value">{{ activity.pace }}<small>/km</small></div></article><article class="stat-card"><div class="stat-label">AVERAGE HEART RATE</div><div class="stat-value">{{ activity.avg_hr|int if activity.avg_hr else '—' }}<small>bpm</small></div></article></div>
<section class="panel map-panel"><div class="section-head"><h2>The route.</h2><span class="subtle-label">{{ 'Full recorded route' }}</span></div><div id="route-map" data-url="{{ url_for('web.data',activity_id=activity.id) }}"><p class="map-placeholder">Loading activity…</p></div><p id="map-status" class="map-status"></p></section>
<div class="detail-charts"><section class="panel"><div class="section-head"><h2>Pace</h2><span class="subtle-label">min/km</span></div><div class="chart-wrap"><canvas id="pace-chart" role="img" aria-label="Pace over distance"></canvas></div></section><section class="panel"><div class="section-head"><h2>Heart rate</h2><span class="subtle-label">bpm</span></div><div class="chart-wrap"><canvas id="hr-chart" role="img" aria-label="Heart rate over distance"></canvas></div></section></div>
<div class="detail-bottom"><section class="panel"><div class="section-head"><h2>Laps & splits</h2><span class="subtle-label">{{ activity.laps|length }} recorded laps</span></div><table class="splits-table"><thead><tr><th>LAP</th><th>DISTANCE</th><th>TIME</th><th>PACE /KM</th><th>HR</th></tr></thead><tbody>{% for lap in activity.laps %}<tr><td>{{ loop.index }}</td><td>{{ '%.2f'|format(lap.distance / 1000) }} km</td><td>{{ lap.duration|duration }}</td><td>{{ lap.pace|duration if lap.pace else '—' }}</td><td>{{ lap.heart_rate|int if lap.heart_rate else '—' }}</td></tr>{% else %}<tr><td colspan="5">No laps recorded.</td></tr>{% endfor %}</tbody></table></section>
{% if owner_view %}<section class="panel publishing-panel"><span class="eyebrow">YOUR RUN, YOUR CHOICE</span><h2>Sharing settings</h2><form method="post" action="{{ url_for('web.edit_activity',activity_id=activity.id) }}" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Activity title<input name="title" value="{{ activity.title }}" maxlength="160" required></label><label>Who can see this activity?<select name="visibility"><option value="private" {{ 'selected' if activity.visibility == 'private' }}>Only me</option><option value="friends" {{ 'selected' if activity.visibility == 'friends' }}>Accepted friends</option></select></label><p class="field-help">Accepted friends can see the full route, date, statistics and charts of shared activities. Choose Only me to keep this activity private.</p><button class="button primary">Save changes ↗</button></form></section>{% endif %}</div>
<div class="page-heading"><div><span class="eyebrow">{{ activity.sport|upper }} · {{ activity.started_at|localdate(activity.owner.timezone, '%A, %d %B %Y') }}</span><h1>{{ activity.title }}</h1><p>By <a href="{{ url_for('web.profile',username=activity.owner.username) }}">{{ activity.owner.display_name }}</a><span class="dot-separator">·</span>{{ activity.started_at|localdate(activity.owner.timezone, '%H:%M') }}<span class="dot-separator">·</span>{{ '{:,}'.format(analysis.summary.samples) }} recorded samples</p></div><span class="pill {{ activity.visibility }}">{{ activity.visibility|title }}</span></div>
<nav class="activity-nav" aria-label="Activity sections"><a href="#route-section">Route & speed</a><a href="#performance">Performance</a><a href="#terrain">Terrain & effort</a><a href="#splits">Laps & splits</a><a href="#method">About the numbers</a></nav>
<div class="stats-grid">
<article class="stat-card featured"><div class="stat-label">DISTANCE</div><div class="stat-value">{{ '%.2f'|format(activity.distance_km) }}<small>km</small></div><p>Recorded by your watch</p></article>
<article class="stat-card"><div class="stat-label">TIMER TIME</div><div class="stat-value time-value">{{ activity.duration_s|duration }}</div><p>Elapsed {{ activity.metrics.get('total_elapsed_time')|duration if activity.metrics.get('total_elapsed_time') else '—' }}</p></article>
<article class="stat-card"><div class="stat-label">AVERAGE PACE</div><div class="stat-value">{{ activity.pace }}<small>/km</small></div><p>GAP estimate <strong>{{ pace_text(analysis.summary.gap) }}/km</strong></p></article>
<article class="stat-card"><div class="stat-label">AVERAGE HEART RATE</div><div class="stat-value">{{ activity.avg_hr|int if activity.avg_hr else '—' }}<small>bpm</small></div><p>Peak {{ analysis.summary.max_hr|int if analysis.summary.max_hr else '—' }} bpm</p></article>
</div>
<section class="panel map-panel run-map" id="route-section">
<div class="section-head"><div><span class="eyebrow">FOLLOW YOUR FOOTSTEPS</span><h2>Your run, in colour.</h2></div><div class="map-controls"><label>Colour by <select id="map-metric"><option value="speed">Speed</option><option value="heart_rate">Heart rate</option><option value="grade">Gradient</option></select></label><label class="directions-toggle"><input type="checkbox" id="show-directions" checked> Direction arrows</label><button type="button" class="button secondary" id="fit-route">Fit route</button></div></div>
<div id="route-map" data-url="{{ url_for('web.data',activity_id=activity.id) }}"><p class="map-placeholder">Loading your route…</p></div>
<div class="route-legend"><span id="legend-low">Slower</span><div class="legend-ramp" id="legend-ramp"></div><span id="legend-high">Faster</span><span class="legend-note">S Start · F Finish · Grey = no measurement</span></div>
<p id="map-status" class="map-status" aria-live="polite"></p>
</section>
<section id="performance" class="performance-section">
<div class="section-title"><span class="eyebrow">A CLOSER LOOK</span><h2>The numbers behind the run.</h2><p>Hover over a chart to locate that part of the route.</p></div>
<div class="metrics-strip">
{{ metric('GRADE-ADJUSTED PACE', pace_text(analysis.summary.gap), '/km', 'Model estimate · %.0f%% distance coverage'|format(analysis.summary.gap_coverage)) }}
{{ metric('ELEVATION GAIN', activity.elevation_m|int, 'm', 'Descent %s m'|format(activity.metrics.get('total_descent')|int if activity.metrics.get('total_descent') is not none else '—')) }}
{{ metric('AVERAGE CADENCE', '%.0f'|format(analysis.summary.cadence) if analysis.summary.cadence else '—', 'spm', 'Both feet · sampled average') }}
{{ metric('AVERAGE POWER', activity.metrics.get('avg_power')|int if activity.metrics.get('avg_power') is not none else '—', 'W', 'Recorded running power') }}
{{ metric('ENERGY', activity.metrics.get('total_calories')|int if activity.metrics.get('total_calories') is not none else '—', 'kcal', 'Watch estimate') }}
{{ metric('TRAINING EFFECT', activity.metrics.get('total_training_effect') if activity.metrics.get('total_training_effect') is not none else '—', '', 'Aerobic · watch estimate') }}
</div>
<div class="detail-charts analytics-charts">
<section class="panel"><div class="section-head"><h2>Pace & grade-adjusted pace</h2><span class="subtle-label">min/km · lower is faster</span></div><div class="chart-wrap"><canvas id="pace-chart" role="img" aria-label="Pace and estimated grade adjusted pace over distance"></canvas></div></section>
<section class="panel"><div class="section-head"><h2>Heart rate</h2><span class="subtle-label">Recorded · bpm</span></div><div class="chart-wrap"><canvas id="hr-chart" role="img" aria-label="Recorded heart rate over distance"></canvas></div></section>
<section class="panel"><div class="section-head"><h2>Elevation & gradient</h2><span class="subtle-label">metres / % grade</span></div><div class="chart-wrap"><canvas id="elevation-chart" role="img" aria-label="Elevation and smoothed gradient"></canvas></div></section>
<section class="panel"><div class="section-head"><h2>Cadence & step length</h2><span class="subtle-label">steps/min / metres</span></div><div class="chart-wrap"><canvas id="cadence-chart" role="img" aria-label="Cadence and step length"></canvas></div></section>
<section class="panel"><div class="section-head"><h2>Running power</h2><span class="subtle-label">Recorded · watts</span></div><div class="chart-wrap"><canvas id="power-chart" role="img" aria-label="Running power"></canvas></div></section>
<section class="panel"><div class="section-head"><h2>Running dynamics</h2><span class="subtle-label">vertical movement / contact time</span></div><div class="chart-wrap"><canvas id="dynamics-chart" role="img" aria-label="Vertical oscillation and ground contact time"></canvas></div></section>
</div>
</section>
<section class="panel terrain-panel" id="terrain"><div class="section-head"><div><span class="eyebrow">HILLS CHANGE THE EFFORT</span><h2>Heart rate by terrain</h2></div><span class="subtle-label">Measured HR · not adjusted</span></div><p class="section-description">See how your recorded heart rate and pace vary uphill, downhill and on the flat. Heart rate responds with a delay, so this is context rather than a correction.</p><div class="terrain-grid">{% for t in analysis.terrain %}<article><span>{{ t.label }}</span><strong>{{ '%.0f'|format(t.heart_rate) if t.heart_rate else '—' }}<small>bpm</small></strong><div>{{ '%.2f'|format(t.distance/1000) }} km <span>·</span> {{ pace_text(t.pace) }}/km</div></article>{% endfor %}</div></section>
<div class="detail-bottom" id="splits"><section class="panel"><div class="section-head"><h2>Laps & splits</h2><span class="subtle-label">{{ activity.laps|length }} recorded laps</span></div><div class="activity-table-wrap"><table class="splits-table"><thead><tr><th>LAP</th><th>DISTANCE</th><th>TIME</th><th>PACE</th><th>GAP EST.</th><th>HR</th></tr></thead><tbody>{% for lap in analysis.laps %}<tr><td>{{ loop.index }}</td><td>{{ '%.2f'|format(lap.distance / 1000) }} km</td><td>{{ lap.duration|duration }}</td><td>{{ pace_text(lap.pace) }}</td><td>{{ pace_text(lap.gap) }}</td><td>{{ lap.heart_rate|int if lap.heart_rate else '—' }}</td></tr>{% else %}<tr><td colspan="6">No laps recorded.</td></tr>{% endfor %}</tbody></table></div></section>
{% if owner_view %}<section class="panel publishing-panel"><span class="eyebrow">YOUR RUN, YOUR CHOICE</span><h2>Sharing settings</h2><form method="post" action="{{ url_for('web.edit_activity',activity_id=activity.id) }}" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Activity title<input name="title" value="{{ activity.title }}" maxlength="160" required></label><label>Who can see this activity?<select name="visibility"><option value="private" {{ 'selected' if activity.visibility == 'private' }}>Only me</option><option value="friends" {{ 'selected' if activity.visibility == 'friends' }}>Accepted friends</option></select></label><p class="field-help">Accepted friends see the full route and measurements of shared activities. Choose Only me to keep this activity private.</p><button class="button primary">Save changes ↗</button></form></section>{% endif %}</div>
<details class="panel methodology" id="method"><summary>About these numbers <span>Recorded data vs. estimates +</span></summary><div><p><strong>GAP is an estimate, not a Garmin or Strava value.</strong> We use the <a href="https://doi.org/10.1152/japplphysiol.01177.2001" target="_blank" rel="noopener noreferrer">Minetti running energy-cost model</a> to estimate an equivalent flat pace from the recorded slope. Elevation uses a five-sample median and a roughly 60 m distance window. Grades outside ±30%, missing data, and recording gaps over 10 seconds are excluded. GAP is only calculated for running.</p><p>Average GAP divides time by equivalent flat distance over eligible moving samples. Coverage indicates how much recorded distance contributed; lap GAP uses the same method. Stops and missing data can make its sample coverage differ from timer pace. Wind, surface, heat, fatigue and individual running economy are not modelled.</p><p><strong>Heart rate stays measured.</strong> A universal grade correction would imply precision we don't have. Terrain summaries show time-weighted recorded HR, without trying to remove the effect of hills. Cadence is converted from running cycles/min to steps/min. Missing measurements are shown as unavailable, never zero-filled.</p><p>Map colours use this activity’s 10th–90th percentile range, with extremes saturated. Direction arrows follow recording order; they are not navigation instructions. Chart traces are sampled for display; computations use all available records.</p></div></details>
{% endblock %}
{% block scripts %}<script src="{{ url_for('static',filename='vendor/leaflet.js') }}"></script><script src="{{ url_for('static',filename='vendor/chart.umd.js') }}"></script><script src="{{ url_for('static',filename='activity.js') }}"></script>{% endblock %}