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 = `
${escapeHtml(series.label)} (${escapeHtml(series.unit)})
${areaPath ? `` : ""} `; 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) => `
${escapeHtml(s.label)} ${formatValue(point[s.key], s)}
`) .join(""); this._tooltip.innerHTML = `
${(point.distance_meters / 1000).toFixed(2)} km
${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 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; }