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 | |
| parent | 690ced7aea43c1187a66d9f7ef813bd6daa13195 (diff) | |
refactor(web): extract ride detail script
Diffstat (limited to 'web')
| -rw-r--r-- | web/server_test.go | 21 | ||||
| -rw-r--r-- | web/static/ride-detail.js | 245 | ||||
| -rw-r--r-- | web/templates.templ | 250 | ||||
| -rw-r--r-- | web/templates_templ.go | 10 |
4 files changed, 268 insertions, 258 deletions
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, `<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" defer></script>`) + assert.Contains(t, body, `<script src="/static/ride-detail.js" defer></script>`) + 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: '© <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(); +})(); 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) { </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 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(); - })(); - </script> + <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" defer></script> + <script src="/static/ride-detail.js" defer></script> } } 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(" <script src=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\"></script> <script>\n\t\t\t(() => {\n\t\t\t\tconst mapElement = document.getElementById(\"ride-map\");\n\t\t\t\tconst routeElement = document.getElementById(\"ride-route\");\n\t\t\t\tconst profileElement = document.getElementById(\"ride-profile\");\n\t\t\t\tconst canvas = document.getElementById(\"ride-profile-chart\");\n\t\t\t\tconst hoverOutput = document.getElementById(\"ride-profile-hover\");\n\t\t\t\tif (!mapElement || !routeElement || !profileElement || !canvas || !hoverOutput) return;\n\t\t\t\tconst colorProbe = document.createElement(\"span\");\n\t\t\t\tcolorProbe.hidden = true;\n\t\t\t\tdocument.body.append(colorProbe);\n\t\t\t\tconst resolveColor = (name) => {\n\t\t\t\t\tcolorProbe.style.color = `var(${name})`;\n\t\t\t\t\treturn getComputedStyle(colorProbe).color;\n\t\t\t\t};\n\t\t\t\tconst colors = {\n\t\t\t\t\taccent: resolveColor(\"--color-accent\"),\n\t\t\t\t\tforest: resolveColor(\"--color-forest\"),\n\t\t\t\t\tsubtle: resolveColor(\"--color-subtle\"),\n\t\t\t\t\tplotSurface: resolveColor(\"--color-plot-surface\"),\n\t\t\t\t\tplotSurfaceOverlay: resolveColor(\"--color-plot-surface-overlay\"),\n\t\t\t\t\tgrid: resolveColor(\"--color-plot-grid\"),\n\t\t\t\t\taccentFill: resolveColor(\"--color-accent-fill\"),\n\t\t\t\t\tclimbLabel: resolveColor(\"--color-climb-label\"),\n\t\t\t\t\tcrossing: resolveColor(\"--color-crossing\"),\n\t\t\t\t\tcrossingLabel: resolveColor(\"--color-crossing-label\"),\n\t\t\t\t\thoverLine: resolveColor(\"--color-hover-line\"),\n\t\t\t\t};\n\t\t\t\tcolorProbe.remove();\n\t\t\t\tconst route = JSON.parse(routeElement.textContent);\n\t\t\t\tconst profile = JSON.parse(profileElement.textContent);\n\t\t\t\tconst map = L.map(mapElement);\n\t\t\t\tconst tiles = L.tileLayer(\"https://tile.openstreetmap.org/{z}/{x}/{y}.png\", {\n\t\t\t\t\tmaxZoom: 19,\n\t\t\t\t\tattribution: '© <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors',\n\t\t\t\t});\n\t\t\t\ttiles.addTo(map);\n\t\t\t\tconst routeLayer = L.geoJSON(route, {\n\t\t\t\t\tstyle: { color: colors.accent, weight: 4, opacity: 0.9 },\n\t\t\t\t}).addTo(map);\n\t\t\t\tconst bounds = routeLayer.getBounds();\n\t\t\t\tif (bounds.isValid()) {\n\t\t\t\t\tmap.fitBounds(bounds, { padding: [24, 24], maxZoom: 15 });\n\t\t\t\t}\n\n\t\t\t\tconst points = profile.points || [];\n\t\t\t\tif (points.length === 0) return;\n\t\t\t\tconst routeCursor = L.circleMarker([points[0].latitude, points[0].longitude], {\n\t\t\t\t\tcolor: colors.forest,\n\t\t\t\t\tfillColor: colors.accent,\n\t\t\t\t\tfillOpacity: 0,\n\t\t\t\t\topacity: 0,\n\t\t\t\t\tradius: 7,\n\t\t\t\t\tweight: 3,\n\t\t\t\t\tinteractive: false,\n\t\t\t\t}).addTo(map);\n\n\t\t\t\tconst context = canvas.getContext(\"2d\");\n\t\t\t\tif (!context) return;\n\t\t\t\tconst state = { hoveredIndex: -1, plot: null, width: 0, height: 0 };\n\t\t\t\tconst minDistance = points[0].distanceKm;\n\t\t\t\tconst maxDistance = points[points.length - 1].distanceKm;\n\t\t\t\tlet minElevation = points[0].elevationM;\n\t\t\t\tlet maxElevation = points[0].elevationM;\n\t\t\t\tfor (const point of points) {\n\t\t\t\t\tminElevation = Math.min(minElevation, point.elevationM);\n\t\t\t\t\tmaxElevation = Math.max(maxElevation, point.elevationM);\n\t\t\t\t}\n\t\t\t\tconst elevationPadding = Math.max((maxElevation - minElevation) * 0.1, 20);\n\t\t\t\tminElevation -= elevationPadding;\n\t\t\t\tmaxElevation += elevationPadding;\n\n\t\t\t\tconst formatDistance = (distance) => `${distance.toFixed(distance < 10 ? 1 : 0)} km`;\n\t\t\t\tconst formatElevation = (elevation) => `${Math.round(elevation)} m`;\n\t\t\t\tconst clamp = (value, min, max) => Math.max(min, Math.min(max, value));\n\t\t\t\tconst xForDistance = (distance) => {\n\t\t\t\t\tconst span = Math.max(maxDistance - minDistance, 1);\n\t\t\t\t\treturn state.plot.left + (distance - minDistance) / span * (state.plot.right - state.plot.left);\n\t\t\t\t};\n\t\t\t\tconst yForElevation = (elevation) => {\n\t\t\t\t\tconst span = Math.max(maxElevation - minElevation, 1);\n\t\t\t\t\treturn state.plot.bottom - (elevation - minElevation) / span * (state.plot.bottom - state.plot.top);\n\t\t\t\t};\n\t\t\t\tconst nearestPointIndex = (distance) => {\n\t\t\t\t\tlet low = 0;\n\t\t\t\t\tlet high = points.length - 1;\n\t\t\t\t\twhile (low < high) {\n\t\t\t\t\t\tconst middle = Math.floor((low + high) / 2);\n\t\t\t\t\t\tif (points[middle].distanceKm < distance) low = middle + 1;\n\t\t\t\t\t\telse high = middle;\n\t\t\t\t\t}\n\t\t\t\t\tif (low === 0) return low;\n\t\t\t\t\tconst previous = points[low - 1];\n\t\t\t\t\treturn distance - previous.distanceKm < points[low].distanceKm - distance ? low - 1 : low;\n\t\t\t\t};\n\n\t\t\t\tconst draw = () => {\n\t\t\t\t\tconst rect = canvas.getBoundingClientRect();\n\t\t\t\t\tif (rect.width === 0 || rect.height === 0) return;\n\t\t\t\t\tconst ratio = window.devicePixelRatio || 1;\n\t\t\t\t\tcanvas.width = Math.floor(rect.width * ratio);\n\t\t\t\t\tcanvas.height = Math.floor(rect.height * ratio);\n\t\t\t\t\tcontext.setTransform(ratio, 0, 0, ratio, 0, 0);\n\t\t\t\t\tstate.width = rect.width;\n\t\t\t\t\tstate.height = rect.height;\n\t\t\t\t\tstate.plot = { left: 52, right: rect.width - 20, top: 24, bottom: rect.height - 34 };\n\t\t\t\t\tconst plot = state.plot;\n\t\t\t\t\tcontext.clearRect(0, 0, rect.width, rect.height);\n\t\t\t\t\tcontext.fillStyle = colors.plotSurface;\n\t\t\t\t\tcontext.fillRect(0, 0, rect.width, rect.height);\n\n\t\t\t\t\tcontext.font = \"12px system-ui, sans-serif\";\n\t\t\t\t\tcontext.textBaseline = \"middle\";\n\t\t\t\t\tfor (let step = 0; step <= 4; step++) {\n\t\t\t\t\t\tconst fraction = step / 4;\n\t\t\t\t\t\tconst y = plot.top + fraction * (plot.bottom - plot.top);\n\t\t\t\t\t\tconst elevation = maxElevation - fraction * (maxElevation - minElevation);\n\t\t\t\t\t\tcontext.strokeStyle = colors.grid;\n\t\t\t\t\t\tcontext.lineWidth = 1;\n\t\t\t\t\t\tcontext.beginPath();\n\t\t\t\t\t\tcontext.moveTo(plot.left, y);\n\t\t\t\t\t\tcontext.lineTo(plot.right, y);\n\t\t\t\t\t\tcontext.stroke();\n\t\t\t\t\t\tcontext.fillStyle = colors.subtle;\n\t\t\t\t\t\tcontext.textAlign = \"right\";\n\t\t\t\t\t\tcontext.fillText(formatElevation(elevation), plot.left - 8, y);\n\t\t\t\t\t}\n\n\t\t\t\t\tfor (const climb of profile.climbs || []) {\n\t\t\t\t\t\tconst startX = clamp(xForDistance(climb.startKm), plot.left, plot.right);\n\t\t\t\t\t\tconst endX = clamp(xForDistance(climb.endKm), plot.left, plot.right);\n\t\t\t\t\t\tcontext.fillStyle = colors.accentFill;\n\t\t\t\t\t\tcontext.fillRect(startX, plot.top, Math.max(endX - startX, 1), plot.bottom - plot.top);\n\t\t\t\t\t\tconst label = climb.name || `${climb.category} ${Math.round(climb.score)}`;\n\t\t\t\t\t\tcontext.fillStyle = colors.climbLabel;\n\t\t\t\t\t\tcontext.textAlign = \"center\";\n\t\t\t\t\t\tcontext.textBaseline = \"top\";\n\t\t\t\t\t\tcontext.fillText(label, clamp((startX + endX) / 2, plot.left + 24, plot.right - 24), plot.top + 4);\n\t\t\t\t\t}\n\n\t\t\t\t\tfor (const crossing of profile.crossings || []) {\n\t\t\t\t\t\tconst x = xForDistance(crossing.distanceKm);\n\t\t\t\t\t\tcontext.strokeStyle = colors.crossing;\n\t\t\t\t\t\tcontext.lineWidth = 1;\n\t\t\t\t\t\tcontext.setLineDash([4, 3]);\n\t\t\t\t\t\tcontext.beginPath();\n\t\t\t\t\t\tcontext.moveTo(x, plot.top);\n\t\t\t\t\t\tcontext.lineTo(x, plot.bottom);\n\t\t\t\t\t\tcontext.stroke();\n\t\t\t\t\t\tcontext.setLineDash([]);\n\t\t\t\t\t}\n\n\t\t\t\t\tcontext.beginPath();\n\t\t\t\t\tcontext.moveTo(xForDistance(points[0].distanceKm), plot.bottom);\n\t\t\t\t\tfor (const point of points) context.lineTo(xForDistance(point.distanceKm), yForElevation(point.elevationM));\n\t\t\t\t\tcontext.lineTo(xForDistance(points[points.length - 1].distanceKm), plot.bottom);\n\t\t\t\t\tcontext.closePath();\n\t\t\t\t\tcontext.fillStyle = colors.accentFill;\n\t\t\t\t\tcontext.fill();\n\t\t\t\t\tcontext.beginPath();\n\t\t\t\t\tfor (let i = 0; i < points.length; i++) {\n\t\t\t\t\t\tconst point = points[i];\n\t\t\t\t\t\tif (i === 0) context.moveTo(xForDistance(point.distanceKm), yForElevation(point.elevationM));\n\t\t\t\t\t\telse context.lineTo(xForDistance(point.distanceKm), yForElevation(point.elevationM));\n\t\t\t\t\t}\n\t\t\t\t\tcontext.strokeStyle = colors.accent;\n\t\t\t\t\tcontext.lineWidth = 2.5;\n\t\t\t\t\tcontext.stroke();\n\t\t\t\t\tfor (const crossing of profile.crossings || []) {\n\t\t\t\t\t\tconst x = xForDistance(crossing.distanceKm);\n\t\t\t\t\t\tconst label = `${crossing.name} ${Math.round(crossing.passElevationM)} m`;\n\t\t\t\t\t\tconst labelWidth = context.measureText(label).width;\n\t\t\t\t\t\tconst labelX = clamp(x, plot.left + labelWidth / 2 + 3, plot.right - labelWidth / 2 - 3);\n\t\t\t\t\t\tconst labelY = plot.top - 6;\n\t\t\t\t\t\tcontext.fillStyle = colors.plotSurfaceOverlay;\n\t\t\t\t\t\tcontext.fillRect(labelX - labelWidth / 2 - 3, labelY - 15, labelWidth + 6, 16);\n\t\t\t\t\t\tcontext.fillStyle = colors.crossingLabel;\n\t\t\t\t\t\tcontext.textAlign = \"center\";\n\t\t\t\t\t\tcontext.textBaseline = \"bottom\";\n\t\t\t\t\t\tcontext.fillText(label, labelX, labelY);\n\t\t\t\t\t}\n\n\t\t\t\t\tcontext.fillStyle = colors.subtle;\n\t\t\t\t\tcontext.textAlign = \"center\";\n\t\t\t\t\tcontext.textBaseline = \"top\";\n\t\t\t\t\tfor (let step = 0; step <= 4; step++) {\n\t\t\t\t\t\tconst distance = minDistance + step / 4 * (maxDistance - minDistance);\n\t\t\t\t\t\tcontext.fillText(formatDistance(distance), xForDistance(distance), plot.bottom + 10);\n\t\t\t\t\t}\n\n\t\t\t\t\tif (state.hoveredIndex >= 0) {\n\t\t\t\t\t\tconst point = points[state.hoveredIndex];\n\t\t\t\t\t\tconst x = xForDistance(point.distanceKm);\n\t\t\t\t\t\tconst y = yForElevation(point.elevationM);\n\t\t\t\t\t\tcontext.strokeStyle = colors.hoverLine;\n\t\t\t\t\t\tcontext.lineWidth = 1;\n\t\t\t\t\t\tcontext.setLineDash([3, 3]);\n\t\t\t\t\t\tcontext.beginPath();\n\t\t\t\t\t\tcontext.moveTo(x, plot.top);\n\t\t\t\t\t\tcontext.lineTo(x, plot.bottom);\n\t\t\t\t\t\tcontext.stroke();\n\t\t\t\t\t\tcontext.setLineDash([]);\n\t\t\t\t\t\tcontext.fillStyle = colors.forest;\n\t\t\t\t\t\tcontext.beginPath();\n\t\t\t\t\t\tcontext.arc(x, y, 5, 0, 2 * Math.PI);\n\t\t\t\t\t\tcontext.fill();\n\t\t\t\t\t}\n\t\t\t\t};\n\n\t\t\t\tconst clearHover = () => {\n\t\t\t\t\tstate.hoveredIndex = -1;\n\t\t\t\t\trouteCursor.setStyle({ opacity: 0, fillOpacity: 0 });\n\t\t\t\t\thoverOutput.textContent = \"Hover or focus the profile to inspect elevation.\";\n\t\t\t\t\tdraw();\n\t\t\t\t};\n\t\t\t\tconst showPoint = (index) => {\n\t\t\t\t\tstate.hoveredIndex = clamp(index, 0, points.length - 1);\n\t\t\t\t\tconst point = points[state.hoveredIndex];\n\t\t\t\t\trouteCursor.setLatLng([point.latitude, point.longitude]);\n\t\t\t\t\trouteCursor.setStyle({ opacity: 1, fillOpacity: 0.9 });\n\t\t\t\t\thoverOutput.textContent = `${formatDistance(point.distanceKm)} · ${formatElevation(point.elevationM)}`;\n\t\t\t\t\tdraw();\n\t\t\t\t};\n\t\t\t\tcanvas.addEventListener(\"pointermove\", (event) => {\n\t\t\t\t\tif (!state.plot) return;\n\t\t\t\t\tconst rect = canvas.getBoundingClientRect();\n\t\t\t\t\tconst x = event.clientX - rect.left;\n\t\t\t\t\tif (x < state.plot.left || x > state.plot.right) {\n\t\t\t\t\t\tclearHover();\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tconst distance = minDistance + (x - state.plot.left) / (state.plot.right - state.plot.left) * (maxDistance - minDistance);\n\t\t\t\t\tshowPoint(nearestPointIndex(distance));\n\t\t\t\t});\n\t\t\t\tcanvas.addEventListener(\"pointerleave\", clearHover);\n\t\t\t\tcanvas.addEventListener(\"pointercancel\", clearHover);\n\t\t\t\tcanvas.addEventListener(\"keydown\", (event) => {\n\t\t\t\t\tif (event.key !== \"ArrowLeft\" && event.key !== \"ArrowRight\") return;\n\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\tconst direction = event.key === \"ArrowRight\" ? 1 : -1;\n\t\t\t\t\tconst index = state.hoveredIndex < 0 ? (direction > 0 ? 0 : points.length - 1) : state.hoveredIndex + direction;\n\t\t\t\t\tshowPoint(index);\n\t\t\t\t});\n\t\t\t\twindow.addEventListener(\"resize\", draw);\n\t\t\t\tdraw();\n\t\t\t})();\n\t\t</script>") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" <script src=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\" defer></script> <script src=\"/static/ride-detail.js\" defer></script>") 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 { |