From 991ab2f179b2d84e8b0ce6d0634b4033f20e4b86 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Tue, 11 Aug 2026 22:19:22 +0200 Subject: refactor(web): extract ride detail script --- web/server_test.go | 21 +++- web/static/ride-detail.js | 245 +++++++++++++++++++++++++++++++++++++++++++++ web/templates.templ | 250 +--------------------------------------------- web/templates_templ.go | 10 +- 4 files changed, 268 insertions(+), 258 deletions(-) create mode 100644 web/static/ride-detail.js diff --git a/web/server_test.go b/web/server_test.go index 8e4c568..23270ed 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -161,12 +161,23 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) { assert.Contains(t, body, `id="ride-profile"`) assert.Contains(t, body, `id="ride-profile-chart"`) assert.Contains(t, body, `id="ride-map"`) - assert.Contains(t, body, "pointermove") - assert.Contains(t, body, "circleMarker") - assert.Contains(t, body, "crossing.passElevationM") - assert.Contains(t, body, "const labelY = plot.top - 6") + assert.Contains(t, body, ``) + assert.Contains(t, body, ``) + assert.NotContains(t, body, "pointermove") + + staticRequest := httptest.NewRequest(http.MethodGet, "/static/ride-detail.js", nil) + staticResponse := httptest.NewRecorder() + server.Handler().ServeHTTP(staticResponse, staticRequest) + + script := staticResponse.Body.String() + assert.Equal(t, http.StatusOK, staticResponse.Code) + assert.Equal(t, "text/javascript; charset=utf-8", staticResponse.Header().Get("Content-Type")) + assert.Contains(t, script, "pointermove") + assert.Contains(t, script, "circleMarker") + assert.Contains(t, script, "crossing.passElevationM") + assert.Contains(t, script, "const labelY = plot.top - 6") assert.Contains(t, body, "leaflet@1.9.4/dist/leaflet.js") - assert.Contains(t, body, "tile.openstreetmap.org/{z}/{x}/{y}.png") + assert.Contains(t, script, "tile.openstreetmap.org/{z}/{x}/{y}.png") assert.Contains(t, body, `"type":"FeatureCollection"`) assert.Contains(t, body, `"type":"LineString"`) assert.Contains(t, body, `"coordinates":[[5,43]`) 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: '© OpenStreetMap 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(); +})(); diff --git a/web/templates.templ b/web/templates.templ index 8e7c0f1..515c482 100644 --- a/web/templates.templ +++ b/web/templates.templ @@ -88,254 +88,8 @@ templ RideDetailContent(data RideDetailView) { @templ.JSONScript("ride-route", data.Route) @templ.JSONScript("ride-profile", data.Profile) - - + + } } diff --git a/web/templates_templ.go b/web/templates_templ.go index 6991c73..021de0b 100644 --- a/web/templates_templ.go +++ b/web/templates_templ.go @@ -498,7 +498,7 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -569,7 +569,7 @@ func SyncContent(data SyncPageData) templ.Component { var templ_7745c5c3_Var31 string templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(data.Notice) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 351, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 105, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) if templ_7745c5c3_Err != nil { @@ -593,7 +593,7 @@ func SyncContent(data SyncPageData) templ.Component { var templ_7745c5c3_Var32 string templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 356, Col: 49} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 110, Col: 49} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) if templ_7745c5c3_Err != nil { @@ -635,7 +635,7 @@ func SyncContent(data SyncPageData) templ.Component { var templ_7745c5c3_Var34 string templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(data.From) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 367, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 121, Col: 64} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) if templ_7745c5c3_Err != nil { @@ -648,7 +648,7 @@ func SyncContent(data SyncPageData) templ.Component { var templ_7745c5c3_Var35 string templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(data.To) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 368, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 122, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) if templ_7745c5c3_Err != nil { -- cgit v1.2.3