Add activity type and a click-to-open track profile panel
CI / check (push) Successful in 41s
CI / deploy (push) Successful in 15s

Track properties gain activity_type (FIT: sport enum from the
session/sport message, Garmin's own table; GPX: <trk><type> passed
through as-is) alongside the existing distance/elevation/duration.

Clicking a track line now opens a bottom panel with a chart per
metric the track actually has (elevation always; speed/cadence/heart
rate only when present — FIT's record fields or GPX's device
extensions). TrackStats.build_profile computes cumulative distance
the same way for both formats so they're directly comparable. One
pointermove listener drives a synchronized crosshair across every
chart, a single tooltip listing all series, and a map marker at the
hovered point's position — hovering the chart shows where on the
track that moment was.
This commit is contained in:
randy committed 2026-09-07 13:38:49 +02:00
1 parent cd4eef342b
commit 105651f005
14 files changed
+655 -42

No files matched your search

+112
View File
@@ -504,3 +504,115 @@ html, body {
color: #888;
font-size: 12px;
}
.profile-panel {
position: fixed;
left: 52px;
right: 0;
bottom: 0;
max-height: 45vh;
display: flex;
flex-direction: column;
background: #fcfcfb;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.15);
z-index: 10;
}
.profile-panel[hidden] {
display: none;
}
.profile-panel__header {
display: flex;
align-items: baseline;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #e1e0d9;
}
.profile-panel__title {
font-size: 14px;
}
.profile-panel__stats {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #52514e;
font-size: 13px;
}
.profile-panel__close {
border: none;
background: none;
font-size: 18px;
line-height: 1;
padding: 0 4px;
cursor: pointer;
color: #898781;
}
.profile-panel__charts {
position: relative;
overflow-y: auto;
padding: 8px 14px 14px;
}
.profile-chart {
padding-top: 8px;
}
.profile-chart + .profile-chart {
border-top: 1px solid #e1e0d9;
}
.profile-chart__label {
font-size: 11px;
color: #898781;
margin-bottom: 2px;
}
.profile-chart__unit {
font-weight: normal;
}
.profile-chart__svg {
display: block;
width: 100%;
cursor: crosshair;
}
.profile-chart__crosshair {
stroke: #898781;
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.profile-panel__tooltip {
position: absolute;
top: 0;
z-index: 1;
pointer-events: none;
background: #0b0b0b;
color: #fff;
border-radius: 4px;
padding: 6px 8px;
font-size: 12px;
white-space: nowrap;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
}
.profile-panel__tooltip[hidden] {
display: none;
}
.profile-panel__tooltip-distance {
font-weight: 600;
margin-bottom: 2px;
}
.profile-panel__tooltip-label {
color: #c3c2b7;
}
+16 -5
View File
@@ -1,6 +1,7 @@
import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs";
import { GPX_ICON, NEW_FOLDER_ICON, UPLOAD_ICON, WAYPOINT_ICON, TRACK_ICON } from "/js/icons.js";
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
import { formatActivityType } from "/js/track_profile.js";
const TREE_URL = "/api/gpx/tree";
const UPLOAD_URL = "/api/gpx";
@@ -18,8 +19,9 @@ const MOVE_MIME = "application/x-gpx-path";
// and reorganize by dragging), and toggling their tracks/waypoints on and
// off the map.
export class GpxPanelControl {
constructor(map) {
constructor(map, profilePanel) {
this._map = map;
this._profilePanel = profilePanel;
this._visible = new Set(); // file paths currently shown on the map
this._interactiveLayers = new Set(); // point layer ids with click/hover wired up
@@ -331,9 +333,12 @@ export class GpxPanelControl {
});
}
// Hover rather than click: unlike a waypoint, a track's properties
// (distance, elevation gain, duration) describe the whole line, not
// whatever point along it happens to get clicked.
// Hover shows a quick summary (unlike a waypoint popup, which is
// click-triggered — a track's summary describes the whole line, not
// whatever point along it happens to get hovered, so there's no single
// right anchor point worth waiting for a click on). Click opens the full
// profile panel, which does need the click gesture to avoid flashing
// open on every casual hover.
_registerLineInteractivity(layerId) {
if (this._interactiveLayers.has(layerId)) return;
this._interactiveLayers.add(layerId);
@@ -353,6 +358,10 @@ export class GpxPanelControl {
popup?.remove();
popup = null;
});
this._map.on("click", layerId, (event) => {
const feature = event.features[0];
if (feature) this._profilePanel?.show(feature.properties);
});
}
_removeLayers(path) {
@@ -488,15 +497,17 @@ function popupHtml({ name, description, time }) {
}
// properties come straight from an uploaded GPX/FIT file — untrusted input.
function trackPopupHtml({ name, distance_meters, elevation_gain_meters, duration_secs }) {
function trackPopupHtml({ name, activity_type, distance_meters, elevation_gain_meters, duration_secs }) {
const parts = [];
if (name) parts.push(`<strong>${escapeHtml(name)}</strong>`);
const stats = [];
if (activity_type) stats.push(escapeHtml(formatActivityType(activity_type)));
if (typeof distance_meters === "number") stats.push(`${(distance_meters / 1000).toFixed(1)} km`);
if (typeof elevation_gain_meters === "number") stats.push(`↑ ${Math.round(elevation_gain_meters)} m`);
if (typeof duration_secs === "number") stats.push(formatDuration(duration_secs));
if (stats.length > 0) parts.push(`<p>${stats.join(" · ")}</p>`);
parts.push(`<p class="gpx-popup__time">Click for the full profile</p>`);
return parts.length > 0 ? parts.join("") : "<em>No details</em>";
}
+4 -1
View File
@@ -8,6 +8,7 @@ import { RadarControl } from "/js/radar.js";
import { GpxPanelControl } from "/js/gpx.js";
import { GnssControl } from "/js/gnss.js";
import { LiveTrackPanelControl } from "/js/livetrack.js";
import { TrackProfilePanel } from "/js/track_profile.js";
import { LAYERS_ICON } from "/js/icons.js";
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
@@ -125,7 +126,9 @@ const layerToggle = new LayerToggleControl(applyStyle);
layerToggle.mount(sidebar);
layerToggle.setActive("online");
new GpxPanelControl(map).mount(sidebar);
const profilePanel = new TrackProfilePanel(map);
profilePanel.mount();
new GpxPanelControl(map, profilePanel).mount(sidebar);
new RadarControl(map).mount(sidebar);
new GnssControl(map).mount(sidebar);
new LiveTrackPanelControl(map).mount(sidebar);
+218
View File
@@ -0,0 +1,218 @@
import { Marker } from "/vendor/maplibre-gl/maplibre-gl.mjs";
// Small-multiples profile chart: one row per metric the track actually has
// (elevation always, speed/cadence/heart rate only when present — plenty of
// GPX files have none of those), sharing one x-axis (distance) and one
// crosshair. Elevation/speed/cadence/heart rate live on wildly different
// scales, so this is deliberately several single-series charts stacked
// rather than one chart with several y-axes (see dataviz skill: no
// dual-axis charts).
//
// SVG viewBox is a fixed 1000-unit-wide coordinate space (not real pixels)
// stretched to the container via CSS — mouse interaction converts
// clientX through the container's current bounding rect on every move, so
// this stays correct across container width without redrawing, but a
// browser window resize while the panel is open won't reflow the drawn
// paths until it's reopened. Acceptable for a detail panel that's normally
// open briefly; add a resize listener if that turns out to matter.
const VIEWBOX_WIDTH = 1000;
const SERIES = [
{ key: "elevation_meters", label: "Elevation", unit: "m", color: "#2a78d6", height: 110, area: true },
{ key: "speed_kmh", label: "Speed", unit: "km/h", color: "#1baf7a", height: 70, area: false },
{ key: "cadence_rpm", label: "Cadence", unit: "rpm", color: "#eda100", height: 70, area: false },
{ key: "heart_rate_bpm", label: "Heart rate", unit: "bpm", color: "#e34948", height: 70, area: false },
];
export class TrackProfilePanel {
constructor(map) {
this._map = map;
this._profile = null;
this._marker = new Marker({ color: "#7209b7" });
}
mount() {
this._panel = document.getElementById("profile-panel");
this._title = this._panel.querySelector(".profile-panel__title");
this._stats = this._panel.querySelector(".profile-panel__stats");
this._chartsContainer = this._panel.querySelector(".profile-panel__charts");
this._panel.querySelector(".profile-panel__close").addEventListener("click", () => this.hide());
}
show(properties) {
const profile = properties.profile;
if (!profile || profile.length === 0) return;
this._profile = profile.map((point) => ({ ...point, speed_kmh: typeof point.speed_mps === "number" ? point.speed_mps * 3.6 : null }));
this._maxDistance = this._profile.at(-1).distance_meters || 1;
this._title.textContent = properties.name || "Track";
this._stats.textContent = statsText(properties);
this._chartsContainer.innerHTML = "";
this._tooltip = document.createElement("div");
this._tooltip.className = "profile-panel__tooltip";
this._tooltip.hidden = true;
this._chartsContainer.appendChild(this._tooltip);
this._activeSeries = SERIES.filter((s) => this._profile.some((p) => typeof p[s.key] === "number"));
this._activeSeries.forEach((series) => this._chartsContainer.appendChild(this._renderChart(series)));
this._chartsContainer.addEventListener("pointermove", this._onPointerMove);
this._chartsContainer.addEventListener("pointerleave", this._onPointerLeave);
this._panel.hidden = false;
}
hide() {
this._panel.hidden = true;
this._chartsContainer.removeEventListener("pointermove", this._onPointerMove);
this._chartsContainer.removeEventListener("pointerleave", this._onPointerLeave);
this._marker.remove();
}
_renderChart(series) {
const values = this._profile.map((p) => p[series.key]).filter((v) => typeof v === "number");
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const toX = (d) => (d / this._maxDistance) * VIEWBOX_WIDTH;
const toY = (v) => series.height - 6 - ((v - min) / range) * (series.height - 12);
const points = this._profile
.map((p) => (typeof p[series.key] === "number" ? [toX(p.distance_meters), toY(p[series.key])] : null))
.filter(Boolean);
const linePath = points.map(([x, y], i) => `${i === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`).join(" ");
const areaPath = series.area && points.length > 0
? `${linePath} L${points.at(-1)[0].toFixed(1)},${series.height} L0,${series.height} Z`
: "";
const wrapper = document.createElement("div");
wrapper.className = "profile-chart";
wrapper.innerHTML = `
<div class="profile-chart__label">${escapeHtml(series.label)} <span class="profile-chart__unit">(${escapeHtml(series.unit)})</span></div>
<svg viewBox="0 0 ${VIEWBOX_WIDTH} ${series.height}" preserveAspectRatio="none" class="profile-chart__svg" style="height:${series.height}px">
${areaPath ? `<path d="${areaPath}" fill="${series.color}" fill-opacity="0.15" stroke="none"></path>` : ""}
<path d="${linePath}" fill="none" stroke="${series.color}" stroke-width="2" vector-effect="non-scaling-stroke"></path>
<line class="profile-chart__crosshair" x1="0" x2="0" y1="0" y2="${series.height}" hidden></line>
<circle class="profile-chart__dot" r="4" fill="${series.color}" stroke="#fff" stroke-width="1.5" hidden></circle>
</svg>
`;
wrapper.dataset.min = min;
wrapper.dataset.max = max;
return wrapper;
}
// Bound once (arrow fields, not prototype methods) so add/removeEventListener see the same reference.
_onPointerMove = (event) => {
const rect = this._chartsContainer.getBoundingClientRect();
const fraction = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
const targetDistance = fraction * this._maxDistance;
const index = nearestIndex(this._profile, targetDistance);
const point = this._profile[index];
this._updateCrosshair(point);
this._updateTooltip(point, event);
this._marker.setLngLat([point.lon, point.lat]).addTo(this._map);
};
_onPointerLeave = () => {
this._tooltip.hidden = true;
this._chartsContainer.querySelectorAll(".profile-chart__crosshair, .profile-chart__dot").forEach((el) => (el.hidden = true));
this._marker.remove();
};
_updateCrosshair(point) {
const x = (point.distance_meters / this._maxDistance) * VIEWBOX_WIDTH;
this._chartsContainer.querySelectorAll(".profile-chart").forEach((wrapper, i) => {
const series = this._activeSeries[i];
const value = point[series.key];
const svg = wrapper.querySelector("svg");
const crosshair = wrapper.querySelector(".profile-chart__crosshair");
const dot = wrapper.querySelector(".profile-chart__dot");
crosshair.setAttribute("x1", x.toFixed(1));
crosshair.setAttribute("x2", x.toFixed(1));
crosshair.hidden = false;
if (typeof value === "number") {
const min = Number(wrapper.dataset.min);
const max = Number(wrapper.dataset.max);
const range = max - min || 1;
const y = series.height - 6 - ((value - min) / range) * (series.height - 12);
dot.setAttribute("cx", x.toFixed(1));
dot.setAttribute("cy", y.toFixed(1));
dot.hidden = false;
} else {
dot.hidden = true;
}
void svg; // svg itself needs no direct update, its children were mutated above
});
}
_updateTooltip(point, event) {
const rows = this._activeSeries
.filter((s) => typeof point[s.key] === "number")
.map((s) => `<div><span class="profile-panel__tooltip-label">${escapeHtml(s.label)}</span> <strong>${formatValue(point[s.key], s)}</strong></div>`)
.join("");
this._tooltip.innerHTML = `<div class="profile-panel__tooltip-distance">${(point.distance_meters / 1000).toFixed(2)} km</div>${rows}`;
this._tooltip.hidden = false;
const containerRect = this._chartsContainer.getBoundingClientRect();
const left = Math.min(event.clientX - containerRect.left + 12, containerRect.width - this._tooltip.offsetWidth - 8);
this._tooltip.style.left = `${Math.max(8, left)}px`;
}
}
function nearestIndex(profile, targetDistance) {
// Distances are monotonically non-decreasing (see TrackStats.build_profile),
// so a binary search would work too — linear is simpler and profiles here
// top out in the low thousands of points, not worth the complexity.
let closest = 0;
let closestDiff = Infinity;
profile.forEach((p, i) => {
const diff = Math.abs(p.distance_meters - targetDistance);
if (diff < closestDiff) {
closestDiff = diff;
closest = i;
}
});
return closest;
}
function formatValue(value, series) {
if (series.key === "elevation_meters") return `${Math.round(value)} ${series.unit}`;
if (series.key === "speed_kmh") return `${value.toFixed(1)} ${series.unit}`;
return `${Math.round(value)} ${series.unit}`;
}
function statsText({ activity_type, distance_meters, elevation_gain_meters, duration_secs }) {
const parts = [];
if (activity_type) parts.push(formatActivityType(activity_type));
if (typeof distance_meters === "number") parts.push(`${(distance_meters / 1000).toFixed(1)} km`);
if (typeof elevation_gain_meters === "number") parts.push(`↑ ${Math.round(elevation_gain_meters)} m`);
if (typeof duration_secs === "number") parts.push(formatDuration(duration_secs));
return parts.join(" · ");
}
// FIT gives snake_case ("cross_country_skiing"); GPX's free-text <type> can
// be anything a device/app chose to write ("VirtualRide", "running", ...) —
// this reads reasonably for both without needing to know which format the
// track came from.
export function formatActivityType(text) {
return text
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
function formatDuration(totalSeconds) {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}