TrackStats.build_profile now also computes elapsed_secs per point (relative to the first known timestamp), alongside the existing cumulative distance. Point x-positions stay distance-based either way — only the shared axis strip's 5 tick labels (and the tooltip) switch between the two, so toggling is a cheap re-render rather than rebuilding the charts. The toggle only appears when a track actually has elapsed-time data to switch to.
269 lines
12 KiB
JavaScript
269 lines
12 KiB
JavaScript
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);
|
|
|
|
// Only offer the toggle when there's actually elapsed-time data to
|
|
// switch to — plenty of GPX files have no <time> at all.
|
|
this._hasTime = this._profile.some((p) => typeof p.elapsed_secs === "number");
|
|
this._xAxisMode = "distance";
|
|
if (this._hasTime) this._chartsContainer.appendChild(this._renderXAxisToggle());
|
|
|
|
this._activeSeries = SERIES.filter((s) => this._profile.some((p) => typeof p[s.key] === "number"));
|
|
this._activeSeries.forEach((series) => this._chartsContainer.appendChild(this._renderChart(series)));
|
|
|
|
// One shared strip below the whole stack, not per-chart — every chart
|
|
// above uses the same x-scale (distance-position), so a separate axis
|
|
// per chart would just repeat itself.
|
|
this._xAxisStripEl = document.createElement("div");
|
|
this._xAxisStripEl.className = "profile-panel__xaxis";
|
|
this._chartsContainer.appendChild(this._xAxisStripEl);
|
|
this._renderXAxisTicks();
|
|
|
|
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>
|
|
<div class="profile-chart__plot" style="height:${series.height}px">
|
|
<svg viewBox="0 0 ${VIEWBOX_WIDTH} ${series.height}" preserveAspectRatio="none" class="profile-chart__svg">
|
|
${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>
|
|
<span class="profile-chart__axis-value profile-chart__axis-value--max">${formatValue(max, series)}</span>
|
|
<span class="profile-chart__axis-value profile-chart__axis-value--min">${formatValue(min, series)}</span>
|
|
</div>
|
|
`;
|
|
wrapper.dataset.min = min;
|
|
wrapper.dataset.max = max;
|
|
return wrapper;
|
|
}
|
|
|
|
_renderXAxisToggle() {
|
|
const row = document.createElement("div");
|
|
row.className = "profile-panel__xaxis-toggle";
|
|
row.innerHTML = `
|
|
<button type="button" class="profile-panel__xaxis-toggle-btn" data-mode="distance">Distance</button>
|
|
<button type="button" class="profile-panel__xaxis-toggle-btn" data-mode="time">Time</button>
|
|
`;
|
|
row.querySelectorAll("button").forEach((btn) => {
|
|
btn.classList.toggle("active", btn.dataset.mode === this._xAxisMode);
|
|
btn.addEventListener("click", () => {
|
|
if (btn.dataset.mode === this._xAxisMode) return;
|
|
this._xAxisMode = btn.dataset.mode;
|
|
row.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b === btn));
|
|
this._renderXAxisTicks();
|
|
});
|
|
});
|
|
return row;
|
|
}
|
|
|
|
_renderXAxisTicks() {
|
|
const TICK_COUNT = 5;
|
|
this._xAxisStripEl.innerHTML = Array.from({ length: TICK_COUNT }, (_, i) => {
|
|
const fraction = i / (TICK_COUNT - 1);
|
|
const point = this._profile[nearestIndex(this._profile, fraction * this._maxDistance)];
|
|
const label = this._xAxisMode === "time" && typeof point.elapsed_secs === "number"
|
|
? formatDuration(point.elapsed_secs)
|
|
: `${(point.distance_meters / 1000).toFixed(1)} km`;
|
|
return `<span class="profile-panel__xaxis-tick" style="left:${(fraction * 100).toFixed(2)}%">${escapeHtml(label)}</span>`;
|
|
}).join("");
|
|
}
|
|
|
|
// 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("");
|
|
const timeSuffix = typeof point.elapsed_secs === "number" ? ` · ${formatDuration(point.elapsed_secs)}` : "";
|
|
this._tooltip.innerHTML = `<div class="profile-panel__tooltip-distance">${(point.distance_meters / 1000).toFixed(2)} km${timeSuffix}</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;
|
|
}
|