diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-11 22:19:22 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-11 22:19:22 +0200 |
| commit | 991ab2f179b2d84e8b0ce6d0634b4033f20e4b86 (patch) | |
| tree | 225d8b5e8b46245ece9f2d8002b7db47727c3ee3 /web/static/ride-detail.js | |
| parent | 690ced7aea43c1187a66d9f7ef813bd6daa13195 (diff) | |
refactor(web): extract ride detail script
Diffstat (limited to 'web/static/ride-detail.js')
| -rw-r--r-- | web/static/ride-detail.js | 245 |
1 files changed, 245 insertions, 0 deletions
diff --git a/web/static/ride-detail.js b/web/static/ride-detail.js new file mode 100644 index 0000000..178b486 --- /dev/null +++ b/web/static/ride-detail.js @@ -0,0 +1,245 @@ +(() => { + 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 colorProbe = document.createElement("span"); + colorProbe.hidden = true; + document.body.append(colorProbe); + const resolveColor = (name) => { + colorProbe.style.color = `var(${name})`; + return getComputedStyle(colorProbe).color; + }; + const colors = { + accent: resolveColor("--color-accent"), + forest: resolveColor("--color-forest"), + subtle: resolveColor("--color-subtle"), + plotSurface: resolveColor("--color-plot-surface"), + plotSurfaceOverlay: resolveColor("--color-plot-surface-overlay"), + grid: resolveColor("--color-plot-grid"), + accentFill: resolveColor("--color-accent-fill"), + climbLabel: resolveColor("--color-climb-label"), + crossing: resolveColor("--color-crossing"), + crossingLabel: resolveColor("--color-crossing-label"), + hoverLine: resolveColor("--color-hover-line"), + }; + colorProbe.remove(); + 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, + attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', + }); + tiles.addTo(map); + const routeLayer = L.geoJSON(route, { + style: { color: colors.accent, weight: 4, opacity: 0.9 }, + }).addTo(map); + const bounds = routeLayer.getBounds(); + 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: colors.forest, + fillColor: colors.accent, + 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 = colors.plotSurface; + 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 = colors.grid; + context.lineWidth = 1; + context.beginPath(); + context.moveTo(plot.left, y); + context.lineTo(plot.right, y); + context.stroke(); + context.fillStyle = colors.subtle; + 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 = colors.accentFill; + 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 = colors.climbLabel; + 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 = colors.crossing; + 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 = colors.accentFill; + 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 = colors.accent; + 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 = colors.plotSurfaceOverlay; + context.fillRect(labelX - labelWidth / 2 - 3, labelY - 15, labelWidth + 6, 16); + context.fillStyle = colors.crossingLabel; + context.textAlign = "center"; + context.textBaseline = "bottom"; + context.fillText(label, labelX, labelY); + } + + context.fillStyle = colors.subtle; + 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 = colors.hoverLine; + 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 = colors.forest; + 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(); +})(); |