diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/server_test.go | 27 | ||||
| -rw-r--r-- | web/static/official-climb-profile-logic.js | 47 | ||||
| -rw-r--r-- | web/static/official-climb-profile.js | 61 | ||||
| -rw-r--r-- | web/static_test/official-climb-profile-logic.test.js | 41 | ||||
| -rw-r--r-- | web/templates.templ | 1 | ||||
| -rw-r--r-- | web/templates_templ.go | 10 |
6 files changed, 115 insertions, 72 deletions
diff --git a/web/server_test.go b/web/server_test.go index 281f46a..a4ee0b7 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -135,9 +135,9 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) { assert.NotContains(t, body, "Climbs to match") assert.Contains(t, body, `class="ride-detail-main"`) 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/official-climb-profile-logic.js" defer></script>`) assert.Contains(t, body, `<script src="/static/official-climb-profile.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() @@ -146,17 +146,8 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) { 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, "climbRoute") - assert.Contains(t, script, "L.polyline") - assert.Contains(t, script, "zoomToClimb") - assert.Contains(t, script, "Cotacol") - assert.Contains(t, script, "cotacolForClimb") - assert.Contains(t, script, "const labelY = plot.top - 6") + assert.NotEmpty(t, script) assert.Contains(t, body, "leaflet@1.9.4/dist/leaflet.js") - 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]`) @@ -168,11 +159,15 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) { profileScript := profileStaticResponse.Body.String() assert.Equal(t, http.StatusOK, profileStaticResponse.Code) assert.Equal(t, "text/javascript; charset=utf-8", profileStaticResponse.Header().Get("Content-Type")) - assert.Contains(t, profileScript, "officialProfileSections") - assert.Contains(t, profileScript, "profileStepSizesM = [100, 200, 500, 1000]") - assert.Contains(t, profileScript, "displayStepForLength") - assert.Contains(t, profileScript, "profileBandForSlope") - assert.Contains(t, profileScript, "section.slopePercent.toFixed(1)") + assert.NotEmpty(t, profileScript) + + logicStaticRequest := httptest.NewRequest(http.MethodGet, "/static/official-climb-profile-logic.js", nil) + logicStaticResponse := httptest.NewRecorder() + server.Handler().ServeHTTP(logicStaticResponse, logicStaticRequest) + + assert.Equal(t, http.StatusOK, logicStaticResponse.Code) + assert.Equal(t, "text/javascript; charset=utf-8", logicStaticResponse.Header().Get("Content-Type")) + assert.NotEmpty(t, logicStaticResponse.Body.String()) } func TestBuildRideProfileIncludesClimbsAndCrossings(t *testing.T) { diff --git a/web/static/official-climb-profile-logic.js b/web/static/official-climb-profile-logic.js new file mode 100644 index 0000000..c84960b --- /dev/null +++ b/web/static/official-climb-profile-logic.js @@ -0,0 +1,47 @@ +(() => { + const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); + const profileStepSizesM = [100, 200, 500, 1000]; + const displayStepForLength = (lengthM) => profileStepSizesM.find((stepM) => Math.ceil(lengthM / stepM) <= 30) || profileStepSizesM[profileStepSizesM.length - 1]; + const profileBandForSlope = (slopePercent) => { + if (slopePercent < 0) return "downhill"; + if (slopePercent < 3) return "0-3"; + if (slopePercent < 6) return "3-6"; + if (slopePercent < 9) return "6-9"; + if (slopePercent < 12) return "9-12"; + return "12-plus"; + }; + const officialProfileSections = (points, startIndex, endIndex, stepM) => { + const startDistanceM = points[startIndex].distanceKm * 1000; + const endDistanceM = points[endIndex].distanceKm * 1000; + const sections = []; + let pointIndex = startIndex; + const elevationAtDistance = (distanceM) => { + while (pointIndex < endIndex - 1 && points[pointIndex + 1].distanceKm * 1000 < distanceM) pointIndex++; + const nextIndex = Math.min(pointIndex + 1, endIndex); + const first = points[pointIndex]; + const next = points[nextIndex]; + const distanceSpan = next.distanceKm * 1000 - first.distanceKm * 1000; + if (distanceSpan <= 0) return first.elevationM; + const fraction = (distanceM - first.distanceKm * 1000) / distanceSpan; + return first.elevationM + clamp(fraction, 0, 1) * (next.elevationM - first.elevationM); + }; + for (let sectionStartM = startDistanceM; sectionStartM < endDistanceM; sectionStartM += stepM) { + const sectionEndM = Math.min(sectionStartM + stepM, endDistanceM); + const startElevation = elevationAtDistance(sectionStartM); + const endElevation = elevationAtDistance(sectionEndM); + const slopePercent = (endElevation - startElevation) / (sectionEndM - sectionStartM) * 100; + sections.push({ + startDistanceKm: sectionStartM / 1000, + endDistanceKm: sectionEndM / 1000, + startElevation, + endElevation, + slopePercent, + band: profileBandForSlope(slopePercent), + }); + } + return sections; + }; + const api = { displayStepForLength, profileBandForSlope, officialProfileSections }; + if (typeof module !== "undefined" && module.exports) module.exports = api; + else window.OfficialClimbProfileLogic = api; +})(); diff --git a/web/static/official-climb-profile.js b/web/static/official-climb-profile.js index d03f0d5..603edfe 100644 --- a/web/static/official-climb-profile.js +++ b/web/static/official-climb-profile.js @@ -1,5 +1,6 @@ (() => { const createOfficialClimbProfileController = (points) => { + const { displayStepForLength, officialProfileSections } = window.OfficialClimbProfileLogic; const colorProbe = document.createElement("span"); colorProbe.hidden = true; document.body.append(colorProbe); @@ -8,12 +9,12 @@ return getComputedStyle(colorProbe).color; }; const colors = { - profileDownhill: resolveColor("--color-profile-downhill"), - profile0To3: resolveColor("--color-profile-0-3"), - profile3To6: resolveColor("--color-profile-3-6"), - profile6To9: resolveColor("--color-profile-6-9"), - profile9To12: resolveColor("--color-profile-9-12"), - profile12Plus: resolveColor("--color-profile-12-plus"), + downhill: resolveColor("--color-profile-downhill"), + "0-3": resolveColor("--color-profile-0-3"), + "3-6": resolveColor("--color-profile-3-6"), + "6-9": resolveColor("--color-profile-6-9"), + "9-12": resolveColor("--color-profile-9-12"), + "12-plus": resolveColor("--color-profile-12-plus"), plotSurface: resolveColor("--color-plot-surface"), grid: resolveColor("--color-plot-grid"), subtle: resolveColor("--color-subtle"), @@ -21,48 +22,6 @@ }; colorProbe.remove(); const formatDistance = (distance) => `${distance.toFixed(distance < 10 ? 1 : 0)} km`; - const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); - const profileStepSizesM = [100, 200, 500, 1000]; - const displayStepForLength = (lengthM) => profileStepSizesM.find((stepM) => Math.ceil(lengthM / stepM) <= 30) || profileStepSizesM[profileStepSizesM.length - 1]; - const profileBandForSlope = (slopePercent) => { - if (slopePercent < 0) return colors.profileDownhill; - if (slopePercent < 3) return colors.profile0To3; - if (slopePercent < 6) return colors.profile3To6; - if (slopePercent < 9) return colors.profile6To9; - if (slopePercent < 12) return colors.profile9To12; - return colors.profile12Plus; - }; - const officialProfileSections = (startIndex, endIndex, stepM) => { - const startDistanceM = points[startIndex].distanceKm * 1000; - const endDistanceM = points[endIndex].distanceKm * 1000; - const sections = []; - let pointIndex = startIndex; - const elevationAtDistance = (distanceM) => { - while (pointIndex < endIndex - 1 && points[pointIndex + 1].distanceKm * 1000 < distanceM) pointIndex++; - const nextIndex = Math.min(pointIndex + 1, endIndex); - const first = points[pointIndex]; - const next = points[nextIndex]; - const distanceSpan = next.distanceKm * 1000 - first.distanceKm * 1000; - if (distanceSpan <= 0) return first.elevationM; - const fraction = (distanceM - first.distanceKm * 1000) / distanceSpan; - return first.elevationM + clamp(fraction, 0, 1) * (next.elevationM - first.elevationM); - }; - for (let sectionStartM = startDistanceM; sectionStartM < endDistanceM; sectionStartM += stepM) { - const sectionEndM = Math.min(sectionStartM + stepM, endDistanceM); - const startElevation = elevationAtDistance(sectionStartM); - const endElevation = elevationAtDistance(sectionEndM); - const slopePercent = (endElevation - startElevation) / (sectionEndM - sectionStartM) * 100; - sections.push({ - startDistanceKm: sectionStartM / 1000, - endDistanceKm: sectionEndM / 1000, - startElevation, - endElevation, - slopePercent, - color: profileBandForSlope(slopePercent), - }); - } - return sections; - }; const drawOfficialProfile = (profileCanvas) => { const startIndex = Number.parseInt(profileCanvas.dataset.profileStart, 10); const endIndex = Number.parseInt(profileCanvas.dataset.profileEnd, 10); @@ -77,7 +36,7 @@ context.setTransform(ratio, 0, 0, ratio, 0, 0); const plot = { left: 10, right: rect.width - 10, top: 16, bottom: rect.height - 24 }; const climbLengthM = (points[endIndex].distanceKm - points[startIndex].distanceKm) * 1000; - const sections = officialProfileSections(startIndex, endIndex, displayStepForLength(climbLengthM)); + const sections = officialProfileSections(points, startIndex, endIndex, displayStepForLength(climbLengthM)); if (sections.length === 0) return; let minElevation = sections[0].startElevation; let maxElevation = minElevation; @@ -113,13 +72,13 @@ context.lineTo(endX, plot.bottom); context.closePath(); context.globalAlpha = 0.25; - context.fillStyle = section.color; + context.fillStyle = colors[section.band]; context.fill(); context.globalAlpha = 1; context.beginPath(); context.moveTo(startX, yForElevation(section.startElevation)); context.lineTo(endX, yForElevation(section.endElevation)); - context.strokeStyle = section.color; + context.strokeStyle = colors[section.band]; context.lineWidth = 2.5; context.stroke(); } diff --git a/web/static_test/official-climb-profile-logic.test.js b/web/static_test/official-climb-profile-logic.test.js new file mode 100644 index 0000000..f663a65 --- /dev/null +++ b/web/static_test/official-climb-profile-logic.test.js @@ -0,0 +1,41 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + displayStepForLength, + profileBandForSlope, + officialProfileSections, +} = require("../static/official-climb-profile-logic.js"); + +test("selects a display step that keeps long profiles readable", () => { + assert.equal(displayStepForLength(2_400), 100); + assert.equal(displayStepForLength(4_500), 200); + assert.equal(displayStepForLength(7_000), 500); + assert.equal(displayStepForLength(20_000), 1000); +}); + +test("assigns each slope to its color band", () => { + assert.equal(profileBandForSlope(-0.1), "downhill"); + assert.equal(profileBandForSlope(2.9), "0-3"); + assert.equal(profileBandForSlope(3), "3-6"); + assert.equal(profileBandForSlope(6), "6-9"); + assert.equal(profileBandForSlope(9), "9-12"); + assert.equal(profileBandForSlope(12), "12-plus"); +}); + +test("interpolates profile sections at the selected step", () => { + const points = [ + { distanceKm: 0, elevationM: 100 }, + { distanceKm: 0.12, elevationM: 106 }, + { distanceKm: 0.2, elevationM: 114 }, + { distanceKm: 0.25, elevationM: 120 }, + ]; + const sections = officialProfileSections(points, 0, 3, 100); + + assert.equal(sections.length, 3); + assert.equal(sections[0].startDistanceKm, 0); + assert.equal(sections[0].endDistanceKm, 0.1); + assert.equal(sections[0].startElevation, 100); + assert.equal(sections[0].endElevation, 105); + assert.equal(sections[2].endDistanceKm, 0.25); + assert.equal(sections[2].endElevation, 120); +}); diff --git a/web/templates.templ b/web/templates.templ index 1decad5..fe5c363 100644 --- a/web/templates.templ +++ b/web/templates.templ @@ -171,6 +171,7 @@ templ RideDetailContent(data RideDetailView) { @templ.JSONScript("ride-route", data.Route) @templ.JSONScript("ride-profile", data.Profile) <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" defer></script> + <script src="/static/official-climb-profile-logic.js" defer></script> <script src="/static/official-climb-profile.js" defer></script> <script src="/static/ride-detail.js" defer></script> } diff --git a/web/templates_templ.go b/web/templates_templ.go index 0ecca3d..eee38dd 100644 --- a/web/templates_templ.go +++ b/web/templates_templ.go @@ -912,7 +912,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\" defer></script> <script src=\"/static/official-climb-profile.js\" defer></script> <script src=\"/static/ride-detail.js\" defer></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/official-climb-profile-logic.js\" defer></script> <script src=\"/static/official-climb-profile.js\" defer></script> <script src=\"/static/ride-detail.js\" defer></script>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -983,7 +983,7 @@ func SyncContent(data SyncPageData) templ.Component { var templ_7745c5c3_Var59 string templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(data.Notice) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 188, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 189, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) if templ_7745c5c3_Err != nil { @@ -1007,7 +1007,7 @@ func SyncContent(data SyncPageData) templ.Component { var templ_7745c5c3_Var60 string templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 193, Col: 49} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 194, Col: 49} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) if templ_7745c5c3_Err != nil { @@ -1049,7 +1049,7 @@ func SyncContent(data SyncPageData) templ.Component { var templ_7745c5c3_Var62 string templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(data.From) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 204, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 205, Col: 64} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) if templ_7745c5c3_Err != nil { @@ -1062,7 +1062,7 @@ func SyncContent(data SyncPageData) templ.Component { var templ_7745c5c3_Var63 string templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(data.To) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 205, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 206, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63)) if templ_7745c5c3_Err != nil { |