diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-11 20:36:48 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-11 20:36:48 +0200 |
| commit | 89467f19b929b742c35ae14d89abb3ca816ec435 (patch) | |
| tree | 70c39698fd6d58c94de3d29774a0e3bcacb2909c /web/templates.templ | |
| parent | a487d2410b485fe5234ab3066620f2fb999bdd1d (diff) | |
feat: add interactive ride profile
Diffstat (limited to 'web/templates.templ')
| -rw-r--r-- | web/templates.templ | 225 |
1 files changed, 225 insertions, 0 deletions
diff --git a/web/templates.templ b/web/templates.templ index 78591e1..b1304a5 100644 --- a/web/templates.templ +++ b/web/templates.templ @@ -43,6 +43,14 @@ templ Layout(title string, content templ.Component) { .empty { padding: 2.5rem 1rem; text-align: center; color: #68746c; } .map-panel { padding: 0; overflow: hidden; } .ride-map { min-height: 24rem; height: min(65vh, 40rem); } + .profile-panel { padding-bottom: 1rem; } + .profile-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } + .profile-heading h2 { margin: 0; font-size: 1.2rem; } + .profile-heading p { margin: 0; color: #68746c; font-size: .9rem; } + .profile-chart { margin-top: 1rem; } + .profile-chart canvas { display: block; width: 100%; height: 22rem; border-radius: .65rem; background: #fbfcfa; outline: none; } + .profile-chart canvas:focus-visible { box-shadow: 0 0 0 .2rem #18794e66; } + .profile-hover { display: block; min-height: 1.4rem; margin: .7rem 0 0; color: #5b675f; font-size: .9rem; } dl { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin: 0; } dt { color: #68746c; font-size: .78rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } dd { margin: .25rem 0 0; font-size: 1.2rem; font-weight: 700; } @@ -113,16 +121,32 @@ templ RideDetailContent(data RideDetailView) { <div class="error">{ data.RouteError }</div> } if data.HasRoute { + <section class="panel profile-panel"> + <div class="profile-heading"> + <h2>Elevation profile</h2> + <p>Hover or focus the profile to inspect the ride at that distance.</p> + </div> + <div class="profile-chart"> + <canvas id="ride-profile-chart" tabindex="0" role="img" aria-describedby="ride-profile-hover" aria-label="Interactive ride elevation profile"></canvas> + </div> + <output id="ride-profile-hover" class="profile-hover" aria-live="polite">Hover or focus the profile to inspect elevation.</output> + </section> <section class="panel map-panel"> <div id="ride-map" class="ride-map" role="img" aria-label="Recorded ride route map"></div> </section> @templ.JSONScript("ride-route", data.Route) + @templ.JSONScript("ride-profile", data.Profile) <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script> (() => { const mapElement = document.getElementById("ride-map"); const routeElement = document.getElementById("ride-route"); + const profileElement = document.getElementById("ride-profile"); + const canvas = document.getElementById("ride-profile-chart"); + const hoverOutput = document.getElementById("ride-profile-hover"); + if (!mapElement || !routeElement || !profileElement || !canvas || !hoverOutput) return; const route = JSON.parse(routeElement.textContent); + const profile = JSON.parse(profileElement.textContent); const map = L.map(mapElement); const tiles = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, @@ -136,6 +160,207 @@ templ RideDetailContent(data RideDetailView) { if (bounds.isValid()) { map.fitBounds(bounds, { padding: [24, 24], maxZoom: 15 }); } + + const points = profile.points || []; + if (points.length === 0) return; + const routeCursor = L.circleMarker([points[0].latitude, points[0].longitude], { + color: "#173b2a", + fillColor: "#d96932", + fillOpacity: 0, + opacity: 0, + radius: 7, + weight: 3, + interactive: false, + }).addTo(map); + + const context = canvas.getContext("2d"); + if (!context) return; + const state = { hoveredIndex: -1, plot: null, width: 0, height: 0 }; + const minDistance = points[0].distanceKm; + const maxDistance = points[points.length - 1].distanceKm; + let minElevation = points[0].elevationM; + let maxElevation = points[0].elevationM; + for (const point of points) { + minElevation = Math.min(minElevation, point.elevationM); + maxElevation = Math.max(maxElevation, point.elevationM); + } + const elevationPadding = Math.max((maxElevation - minElevation) * 0.1, 20); + minElevation -= elevationPadding; + maxElevation += elevationPadding; + + const formatDistance = (distance) => `${distance.toFixed(distance < 10 ? 1 : 0)} km`; + const formatElevation = (elevation) => `${Math.round(elevation)} m`; + const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); + const xForDistance = (distance) => { + const span = Math.max(maxDistance - minDistance, 1); + return state.plot.left + (distance - minDistance) / span * (state.plot.right - state.plot.left); + }; + const yForElevation = (elevation) => { + const span = Math.max(maxElevation - minElevation, 1); + return state.plot.bottom - (elevation - minElevation) / span * (state.plot.bottom - state.plot.top); + }; + const nearestPointIndex = (distance) => { + let low = 0; + let high = points.length - 1; + while (low < high) { + const middle = Math.floor((low + high) / 2); + if (points[middle].distanceKm < distance) low = middle + 1; + else high = middle; + } + if (low === 0) return low; + const previous = points[low - 1]; + return distance - previous.distanceKm < points[low].distanceKm - distance ? low - 1 : low; + }; + + const draw = () => { + const rect = canvas.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + const ratio = window.devicePixelRatio || 1; + canvas.width = Math.floor(rect.width * ratio); + canvas.height = Math.floor(rect.height * ratio); + context.setTransform(ratio, 0, 0, ratio, 0, 0); + state.width = rect.width; + state.height = rect.height; + state.plot = { left: 52, right: rect.width - 20, top: 24, bottom: rect.height - 34 }; + const plot = state.plot; + context.clearRect(0, 0, rect.width, rect.height); + context.fillStyle = "#fbfcfa"; + context.fillRect(0, 0, rect.width, rect.height); + + context.font = "12px system-ui, sans-serif"; + context.textBaseline = "middle"; + for (let step = 0; step <= 4; step++) { + const fraction = step / 4; + const y = plot.top + fraction * (plot.bottom - plot.top); + const elevation = maxElevation - fraction * (maxElevation - minElevation); + context.strokeStyle = "#e5e9e5"; + context.lineWidth = 1; + context.beginPath(); + context.moveTo(plot.left, y); + context.lineTo(plot.right, y); + context.stroke(); + context.fillStyle = "#68746c"; + context.textAlign = "right"; + context.fillText(formatElevation(elevation), plot.left - 8, y); + } + + for (const climb of profile.climbs || []) { + const startX = clamp(xForDistance(climb.startKm), plot.left, plot.right); + const endX = clamp(xForDistance(climb.endKm), plot.left, plot.right); + context.fillStyle = "#d9693230"; + context.fillRect(startX, plot.top, Math.max(endX - startX, 1), plot.bottom - plot.top); + const label = climb.name || `${climb.category} ${Math.round(climb.score)}`; + context.fillStyle = "#8c421f"; + context.textAlign = "center"; + context.textBaseline = "top"; + context.fillText(label, clamp((startX + endX) / 2, plot.left + 24, plot.right - 24), plot.top + 4); + } + + for (const crossing of profile.crossings || []) { + const x = xForDistance(crossing.distanceKm); + context.strokeStyle = "#18794e99"; + context.lineWidth = 1; + context.setLineDash([4, 3]); + context.beginPath(); + context.moveTo(x, plot.top); + context.lineTo(x, plot.bottom); + context.stroke(); + context.setLineDash([]); + } + + context.beginPath(); + context.moveTo(xForDistance(points[0].distanceKm), plot.bottom); + for (const point of points) context.lineTo(xForDistance(point.distanceKm), yForElevation(point.elevationM)); + context.lineTo(xForDistance(points[points.length - 1].distanceKm), plot.bottom); + context.closePath(); + context.fillStyle = "#d9693220"; + context.fill(); + context.beginPath(); + for (let i = 0; i < points.length; i++) { + const point = points[i]; + if (i === 0) context.moveTo(xForDistance(point.distanceKm), yForElevation(point.elevationM)); + else context.lineTo(xForDistance(point.distanceKm), yForElevation(point.elevationM)); + } + context.strokeStyle = "#d96932"; + context.lineWidth = 2.5; + context.stroke(); + for (const crossing of profile.crossings || []) { + const x = xForDistance(crossing.distanceKm); + const label = `${crossing.name} ${Math.round(crossing.passElevationM)} m`; + const labelWidth = context.measureText(label).width; + const labelX = clamp(x, plot.left + labelWidth / 2 + 3, plot.right - labelWidth / 2 - 3); + const labelY = plot.top - 6; + context.fillStyle = "#fbfcfae6"; + context.fillRect(labelX - labelWidth / 2 - 3, labelY - 15, labelWidth + 6, 16); + context.fillStyle = "#17633f"; + context.textAlign = "center"; + context.textBaseline = "bottom"; + context.fillText(label, labelX, labelY); + } + + context.fillStyle = "#68746c"; + context.textAlign = "center"; + context.textBaseline = "top"; + for (let step = 0; step <= 4; step++) { + const distance = minDistance + step / 4 * (maxDistance - minDistance); + context.fillText(formatDistance(distance), xForDistance(distance), plot.bottom + 10); + } + + if (state.hoveredIndex >= 0) { + const point = points[state.hoveredIndex]; + const x = xForDistance(point.distanceKm); + const y = yForElevation(point.elevationM); + context.strokeStyle = "#173b2a99"; + context.lineWidth = 1; + context.setLineDash([3, 3]); + context.beginPath(); + context.moveTo(x, plot.top); + context.lineTo(x, plot.bottom); + context.stroke(); + context.setLineDash([]); + context.fillStyle = "#173b2a"; + context.beginPath(); + context.arc(x, y, 5, 0, 2 * Math.PI); + context.fill(); + } + }; + + const clearHover = () => { + state.hoveredIndex = -1; + routeCursor.setStyle({ opacity: 0, fillOpacity: 0 }); + hoverOutput.textContent = "Hover or focus the profile to inspect elevation."; + draw(); + }; + const showPoint = (index) => { + state.hoveredIndex = clamp(index, 0, points.length - 1); + const point = points[state.hoveredIndex]; + routeCursor.setLatLng([point.latitude, point.longitude]); + routeCursor.setStyle({ opacity: 1, fillOpacity: 0.9 }); + hoverOutput.textContent = `${formatDistance(point.distanceKm)} ยท ${formatElevation(point.elevationM)}`; + draw(); + }; + canvas.addEventListener("pointermove", (event) => { + if (!state.plot) return; + const rect = canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + if (x < state.plot.left || x > state.plot.right) { + clearHover(); + return; + } + const distance = minDistance + (x - state.plot.left) / (state.plot.right - state.plot.left) * (maxDistance - minDistance); + showPoint(nearestPointIndex(distance)); + }); + canvas.addEventListener("pointerleave", clearHover); + canvas.addEventListener("pointercancel", clearHover); + canvas.addEventListener("keydown", (event) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + event.preventDefault(); + const direction = event.key === "ArrowRight" ? 1 : -1; + const index = state.hoveredIndex < 0 ? (direction > 0 ? 0 : points.length - 1) : state.hoveredIndex + direction; + showPoint(index); + }); + window.addEventListener("resize", draw); + draw(); })(); </script> } |