Add activity type and a click-to-open track profile panel
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:
1 parent
cd4eef342b
commit
105651f005
14 files changed
+655
-42
No files matched your search
+16
-5
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user