Add route heat colouring and detailed running analytics
This commit is contained in:
@@ -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}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user