diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/climbs.go | 81 | ||||
| -rw-r--r-- | web/climbs_test.go | 56 | ||||
| -rw-r--r-- | web/frontend/climb-profile.ts | 49 | ||||
| -rw-r--r-- | web/frontend/official-climb-profile-logic.test.ts | 102 | ||||
| -rw-r--r-- | web/frontend/official-climb-profile-logic.ts | 150 | ||||
| -rw-r--r-- | web/frontend/official-climb-profile.ts | 53 | ||||
| -rw-r--r-- | web/frontend/ride-detail.ts | 2 | ||||
| -rw-r--r-- | web/frontend/types.d.ts | 21 | ||||
| -rw-r--r-- | web/server_test.go | 9 | ||||
| -rw-r--r-- | web/templates.templ | 4 | ||||
| -rw-r--r-- | web/views.go | 54 |
11 files changed, 157 insertions, 424 deletions
diff --git a/web/climbs.go b/web/climbs.go index 5cd6db9..02f6eb6 100644 --- a/web/climbs.go +++ b/web/climbs.go @@ -2,8 +2,6 @@ package web import ( "database/sql" - "github.com/jftuga/geodist" - "github.com/martinlehoux/biking_home/climb" "log/slog" "math" "net/http" @@ -12,8 +10,10 @@ import ( "strconv" "strings" + "github.com/martinlehoux/biking_home/climb" "github.com/martinlehoux/biking_home/official_climb" "github.com/martinlehoux/biking_home/ride" + "github.com/martinlehoux/biking_home/rideanalysis" "github.com/martinlehoux/kagamigo/kcore" ) @@ -21,15 +21,9 @@ func climbDetailURL(id int64) string { return "/climbs/" + strconv.FormatInt(id, 10) } -type routeCandidate struct { - coords []geodist.Coord - distance float64 -} - type ClimbProfileData struct { - DistanceKm float64 `json:"distanceKm"` - Elevations []float64 `json:"elevations"` - Route [][]float64 `json:"route,omitempty"` + Sections []ProfileSectionView `json:"sections"` + Route [][]float64 `json:"route,omitempty"` } type ClimbAttemptView struct { RideID int64 @@ -116,15 +110,22 @@ func (s *Server) handleClimb(w http.ResponseWriter, r *http.Request) { }), w) } -// buildClimbProfile computes the median normalized elevation profile from the linked rides, resampling every occurrence onto a shared axis. +// buildClimbProfile merges the recorded passages of a climb into one chart profile. func (s *Server) buildClimbProfile(officialClimb official_climb.OfficialClimb, aggregate official_climb.Aggregate, attempts []official_climb.Attempt) ClimbProfileData { - profile := ClimbProfileData{DistanceKm: aggregate.DistanceM / 1000} - if len(attempts) == 0 { - return profile + profile := rideanalysis.MergeClimbProfile(officialClimb, s.climbAttemptRides(attempts), aggregate, s.officialClimbMatchPolicy(), climb.DefaultProfilePolicy()) + data := ClimbProfileData{Sections: profileSectionsView(profile.Sections)} + if len(profile.Route) > 1 { + data.Route = make([][]float64, len(profile.Route)) + for index, coord := range profile.Route { + data.Route[index] = []float64{coord.Lon, coord.Lat} + } } - matchPolicy := s.officialClimbMatchPolicy() - elevationSamples := make([][]float64, 0, len(attempts)) - routeCandidates := make([]routeCandidate, 0, len(attempts)) + return data +} + +// climbAttemptRides loads and parses the rides that recorded the attempts, once per ride. +func (s *Server) climbAttemptRides(attempts []official_climb.Attempt) []ride.Ride { + parsedRides := make([]ride.Ride, 0, len(attempts)) seen := make(map[int64]bool) for _, attempt := range attempts { if seen[attempt.RideID] { @@ -137,52 +138,12 @@ func (s *Server) buildClimbProfile(officialClimb official_climb.OfficialClimb, a } parsed, err := ride.ParseStoredRide(item) if err != nil { - slog.Warn("Failed to parse ride for climb profile", "climb_id", officialClimb.ID, "ride_id", attempt.RideID, "error", err) + slog.Warn("Failed to parse ride for climb profile", "ride_id", attempt.RideID, "error", err) continue } - for _, matched := range official_climb.MatchClimbs(parsed.Route, officialClimb, matchPolicy.EndpointRadiusM) { - segment := climb.Climb{ - Distances: parsed.Distances[matched.StartIndex : matched.EndIndex+1], - Elevations: parsed.Elevations[matched.StartIndex : matched.EndIndex+1], - Coords: parsed.Coords[matched.StartIndex : matched.EndIndex+1]} - if sample := official_climb.NormalizeProfile(segment); len(sample) > 0 { - elevationSamples = append(elevationSamples, sample) - } - routeCandidates = append(routeCandidates, routeCandidate{ - coords: segment.Coords, - distance: segment.Distances[len(segment.Distances)-1] - segment.Distances[0], - }) - } - } - profile.Elevations = official_climb.MedianProfile(elevationSamples) - if route := medianRoute(routeCandidates); len(route) > 1 { - profile.Route = make([][]float64, len(route)) - for i, coord := range route { - profile.Route[i] = []float64{coord.Lon, coord.Lat} - } - } - return profile -} - -// medianRoute picks the recorded route of the attempt whose distance is the median: full precision, no resampling, robust to one drifted ride. -func medianRoute(candidates []routeCandidate) []geodist.Coord { - if len(candidates) == 0 { - return nil - } - distances := make([]float64, len(candidates)) - for i, candidate := range candidates { - distances[i] = candidate.distance - } - median := official_climb.Median(distances) - best := candidates[0] - bestDiff := math.Abs(best.distance - median) - for _, candidate := range candidates[1:] { - if diff := math.Abs(candidate.distance - median); diff < bestDiff { - best = candidate - bestDiff = diff - } + parsedRides = append(parsedRides, parsed) } - return best.coords + return parsedRides } func buildClimbStatsView(aggregate official_climb.Aggregate) ClimbStatsView { diff --git a/web/climbs_test.go b/web/climbs_test.go index d79f1d8..97c7111 100644 --- a/web/climbs_test.go +++ b/web/climbs_test.go @@ -2,10 +2,12 @@ package web import ( "database/sql" + "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" + "regexp" "testing" "github.com/martinlehoux/biking_home/internal/ridetest" @@ -34,6 +36,16 @@ func storeRideFixtureFile(t *testing.T, db *sql.DB, rideID int64) { require.NoError(t, err) } +// climbProfileData reads the profile JSON the climb page embeds. +func climbProfileData(t *testing.T, body string) ClimbProfileData { + t.Helper() + match := regexp.MustCompile(`(?s)id="climb-profile-data"[^>]*>(.*?)</script>`).FindStringSubmatch(body) + require.Len(t, match, 2, "climb page must embed its profile JSON") + var profile ClimbProfileData + require.NoError(t, json.Unmarshal([]byte(match[1]), &profile)) + return profile +} + func TestHandleClimbRendersAttemptsAndProfile(t *testing.T) { server, db := newWebTestServer(t) _, climbFixture := testRideForClimb(t) @@ -49,6 +61,13 @@ func TestHandleClimbRendersAttemptsAndProfile(t *testing.T) { body := recorder.Body.String() assert.Contains(t, body, "Col de Test") assert.Contains(t, body, "climb-profile-data") + profile := climbProfileData(t, body) + require.NotEmpty(t, profile.Sections) + assert.InDelta(t, 0, profile.Sections[0].StartKm, 0.01) + assert.InDelta(t, 300, profile.Sections[0].StartElevationM, 1) + lastSection := profile.Sections[len(profile.Sections)-1] + assert.InDelta(t, 1, lastSection.EndKm, 0.01) + assert.InDelta(t, 447, lastSection.EndElevationM, 2) assert.Contains(t, body, "Test Ride") // A ride without a power meter renders a dash, not a fabricated value. assert.Contains(t, body, `<td class="numeric">-</td>`) @@ -94,40 +113,3 @@ func TestHandleClimbNotFound(t *testing.T) { server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/climbs/999", nil)) assert.Equal(t, http.StatusNotFound, recorder.Code) } - -func TestBuildClimbProfileMediansAcrossRides(t *testing.T) { - server, db := newWebTestServer(t) - _, climbFixture := testRideForClimb(t) - created, err := official_climb.Create(db, climbFixture) - require.NoError(t, err) - rideID := insertTestRide(t, db) - storeRideFixtureFile(t, db, rideID) - parsed, _ := testRideForClimb(t) - analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{created}, official_climb.DefaultMatchPolicy()) - attempts := rideanalysis.Attempts(rideID, analysis, parsed) - - profile := server.buildClimbProfile(created, official_climb.AggregateAttempts(attempts), attempts) - assert.InDelta(t, 1.0, profile.DistanceKm, 0.01) - require.Len(t, profile.Elevations, 50) - assert.InDelta(t, 300, profile.Elevations[0], 1) - assert.InDelta(t, 447, profile.Elevations[49], 2) -} - -func TestBuildClimbProfileRouteKeepsRecordedPoints(t *testing.T) { - server, db := newWebTestServer(t) - parsed, climbFixture := testRideForClimb(t) - created, err := official_climb.Create(db, climbFixture) - require.NoError(t, err) - rideID := insertTestRide(t, db) - storeRideFixtureFile(t, db, rideID) - analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{created}, official_climb.DefaultMatchPolicy()) - attempts := rideanalysis.Attempts(rideID, analysis, parsed) - - profile := server.buildClimbProfile(created, official_climb.AggregateAttempts(attempts), attempts) - require.NotEmpty(t, profile.Route) - // Full recorded precision: every matched segment point is present, not a fixed resample. - assert.InDelta(t, parsed.Coords[0].Lon, profile.Route[0][0], 1e-6) - assert.InDelta(t, parsed.Coords[0].Lat, profile.Route[0][1], 1e-6) - assert.InDelta(t, parsed.Coords[1].Lon, profile.Route[1][0], 1e-6) - assert.InDelta(t, parsed.Coords[1].Lat, profile.Route[1][1], 1e-6) -} diff --git a/web/frontend/climb-profile.ts b/web/frontend/climb-profile.ts index 6051ec1..d06d291 100644 --- a/web/frontend/climb-profile.ts +++ b/web/frontend/climb-profile.ts @@ -1,32 +1,21 @@ -import { drawOfficialSections, officialProfilePlot, resolveOfficialProfileColors } from "./official-climb-profile.js"; -import { officialProfileSections } from "./official-climb-profile-logic.js"; -import type { OfficialProfileSection } from "./official-climb-profile-logic.js"; -import type { ProfilePoint } from "./types.js"; +import { + drawOfficialSections, + officialProfilePlot, + officialProfileSections, + resolveOfficialProfileColors, +} from "./official-climb-profile.js"; +import type { OfficialProfileSection, ProfileSectionPayload } from "./types.js"; interface ClimbProfilePayload { - distanceKm: number; - elevations: number[]; + sections: ProfileSectionPayload[]; } interface ClimbProfileState { canvas: HTMLCanvasElement; - payload: ClimbProfilePayload; sections: OfficialProfileSection[]; hoverOutput: HTMLOutputElement; } -const climbProfilePoints = (payload: ClimbProfilePayload): ProfilePoint[] => - payload.elevations.map((elevationM, index) => ({ - distanceKm: (payload.distanceKm * index) / (payload.elevations.length - 1), - elevationM, - })); - -const climbProfileSections = (payload: ClimbProfilePayload): OfficialProfileSection[] => { - const points = climbProfilePoints(payload); - if (points.length < 2) return []; - return officialProfileSections(points, 0, points.length - 1); -}; - const elevationAtDistance = (sections: OfficialProfileSection[], distanceKm: number): number | null => { for (const section of sections) { if (distanceKm >= section.startDistanceKm && distanceKm <= section.endDistanceKm) { @@ -64,15 +53,13 @@ const drawHoverMarker = (state: ClimbProfileState, distanceKm: number): void => state.hoverOutput.textContent = `${distanceKm.toFixed(2)} km · ${Math.round(elevation)} m`; }; -const clearHover = (state: ClimbProfileState): void => { - drawClimbProfile(state.canvas, state.payload, state.sections); - state.hoverOutput.textContent = ""; +const drawClimbProfile = (state: ClimbProfileState): void => { + drawOfficialSections(state.canvas, state.sections, resolveOfficialProfileColors()); }; -const drawClimbProfile = (canvas: HTMLCanvasElement, payload: ClimbProfilePayload, sections?: OfficialProfileSection[]): void => { - const profileSections = sections ?? climbProfileSections(payload); - if (profileSections.length === 0) return; - drawOfficialSections(canvas, profileSections, resolveOfficialProfileColors()); +const clearHover = (state: ClimbProfileState): void => { + drawClimbProfile(state); + state.hoverOutput.textContent = ""; }; const bindHover = (state: ClimbProfileState): void => { @@ -85,7 +72,7 @@ const bindHover = (state: ClimbProfileState): void => { return; } const distanceKm = plot.minDistanceKm + ((x - plot.left) / (plot.right - plot.left)) * plot.distanceSpan; - drawClimbProfile(state.canvas, state.payload, state.sections); + drawClimbProfile(state); drawHoverMarker(state, distanceKm); }); state.canvas.addEventListener("pointerleave", () => clearHover(state)); @@ -97,13 +84,11 @@ const init = (): void => { const canvas = document.querySelector<HTMLCanvasElement>("[data-climb-profile]"); if (!payloadElement || !canvas) return; const payload = JSON.parse(payloadElement.textContent ?? "null") as ClimbProfilePayload | null; - if (!payload || payload.elevations.length < 2) return; - const sections = climbProfileSections(payload); - if (sections.length === 0) return; + if (!payload || payload.sections.length === 0) return; const hoverOutput = document.querySelector<HTMLOutputElement>("[data-climb-profile-hover]"); if (!hoverOutput) return; - const state: ClimbProfileState = { canvas, payload, sections, hoverOutput }; - drawClimbProfile(canvas, payload, sections); + const state: ClimbProfileState = { canvas, sections: officialProfileSections(payload.sections), hoverOutput }; + drawClimbProfile(state); bindHover(state); }; diff --git a/web/frontend/official-climb-profile-logic.test.ts b/web/frontend/official-climb-profile-logic.test.ts deleted file mode 100644 index ca8dea2..0000000 --- a/web/frontend/official-climb-profile-logic.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { officialProfileSections, profileBandForSlope } from "./official-climb-profile-logic.ts"; -import type { ProfilePoint } from "./types.js"; - -test("assigns each slope to its color band", () => { - assert.equal(profileBandForSlope(-0.1), "downhill"); - assert.equal(profileBandForSlope(0), "downhill"); - assert.equal(profileBandForSlope(0.1), "0-3"); - assert.equal(profileBandForSlope(2.9), "0-3"); - assert.equal(profileBandForSlope(3), "3-6"); - assert.equal(profileBandForSlope(6), "6-9"); - assert.equal(profileBandForSlope(9), "9-plus"); - assert.equal(profileBandForSlope(12), "9-plus"); -}); - -// buildProfile lays out one recorded point every 50 m along the given [length in km, slope in %] stretches. -const buildProfile = (stretches: [number, number][]): ProfilePoint[] => { - const points: ProfilePoint[] = [{ distanceKm: 0, elevationM: 0 }]; - let distanceKm = 0; - let elevationM = 0; - for (const [lengthKm, slopePercent] of stretches) { - const pointCount = Math.round(lengthKm * 20); - for (let index = 1; index <= pointCount; index++) { - const distanceStepKm = (lengthKm * index) / pointCount; - points.push({ distanceKm: distanceKm + distanceStepKm, elevationM: elevationM + distanceStepKm * 1000 * (slopePercent / 100) }); - } - distanceKm += lengthKm; - elevationM += lengthKm * 1000 * (slopePercent / 100); - } - return points; -}; - -test("keeps a short steep ramp as its own section", () => { - const points = buildProfile([ - [0.3, 4], - [0.3, 12], - [0.3, 4], - ]); - const sections = officialProfileSections(points, 0, points.length - 1); - - assert.equal(sections.length, 3); - assert.ok(Math.abs(sections[1].slopePercent - 12) < 0.01); - assert.equal(sections[1].band, "9-plus"); - assert.equal(Math.round((sections[1].endDistanceKm - sections[1].startDistanceKm) * 1000), 300); - assert.ok(Math.abs(sections[0].slopePercent - 4) < 0.01); - assert.ok(Math.abs(sections[2].slopePercent - 4) < 0.01); -}); - -test("collapses a constant slope into a single section", () => { - const points = buildProfile([[5, 6]]); - const sections = officialProfileSections(points, 0, points.length - 1); - - assert.equal(sections.length, 1); - assert.ok(Math.abs(sections[0].slopePercent - 6) < 0.01); - assert.equal(sections[0].startDistanceKm, 0); - assert.equal(sections[0].endDistanceKm, 5); -}); - -test("reports a descending stretch as downhill", () => { - const points = buildProfile([[2, -5]]); - const sections = officialProfileSections(points, 0, points.length - 1); - - assert.equal(sections.length, 1); - assert.equal(sections[0].band, "downhill"); -}); - -test("keeps a noisy profile within the readable section budget", () => { - const points: ProfilePoint[] = []; - for (let index = 0; index <= 200; index++) { - const distanceKm = index * 0.05; - points.push({ distanceKm, elevationM: distanceKm * 1000 * 0.05 + Math.sin(index) * 4 }); - } - const sections = officialProfileSections(points, 0, points.length - 1); - - assert.ok(sections.length > 10 && sections.length <= 24, `expected 10-24 sections, got ${sections.length}`); - assert.equal(sections[0].startDistanceKm, 0); - assert.equal(sections[sections.length - 1].endDistanceKm, 10); -}); - -test("reads a slice of a longer profile", () => { - const points = buildProfile([ - [1, 20], - [1, 8], - [1, 20], - ]); - const startIndex = points.findIndex((point) => point.distanceKm >= 1); - const endIndex = points.findIndex((point) => point.distanceKm >= 2); - const sections = officialProfileSections(points, startIndex, endIndex); - - assert.equal(sections.length, 1); - assert.equal(Math.round(sections[0].slopePercent), 8); - assert.equal(sections[0].startDistanceKm, points[startIndex].distanceKm); - assert.equal(sections[0].endDistanceKm, points[endIndex].distanceKm); -}); - -test("returns no section for an empty range", () => { - const points = buildProfile([[1, 6]]); - - assert.deepEqual(officialProfileSections(points, 3, 3), []); - assert.deepEqual(officialProfileSections(points, 0, points.length), []); -}); diff --git a/web/frontend/official-climb-profile-logic.ts b/web/frontend/official-climb-profile-logic.ts deleted file mode 100644 index 005e46b..0000000 --- a/web/frontend/official-climb-profile-logic.ts +++ /dev/null @@ -1,150 +0,0 @@ -import type { ProfileBand, ProfilePoint } from "./types.js"; - -export interface OfficialProfileSection { - startDistanceKm: number; - endDistanceKm: number; - startElevation: number; - endElevation: number; - slopePercent: number; - band: ProfileBand; -} - -interface ProfileNode { - distanceM: number; - elevationM: number; -} - -const profileBandForSlope = (slopePercent: number): ProfileBand => { - if (slopePercent <= 0) return "downhill"; - if (slopePercent < 3) return "0-3"; - if (slopePercent < 6) return "3-6"; - if (slopePercent < 9) return "6-9"; - return "9-plus"; -}; - -// The base grid the profile is simplified from: fine enough to localize a ramp, -// coarse enough to average GPS noise out. -const baseStepSizesM: number[] = [25, 50, 100, 200, 500, 1000]; -const maxBaseCells = 120; -const baseStepForLength = (lengthM: number): number => - baseStepSizesM.find((stepM) => Math.ceil(lengthM / stepM) <= maxBaseCells) ?? Math.ceil(lengthM / maxBaseCells); - -// Simplification: a bend smaller than the tolerance cannot be seen on the chart, so it is -// averaged away. The tolerance tracks the base grid (1 % of a base cell, at least a meter), so a -// gentle 4 km climb keeps the same relative detail as a 400 m wall. -const minToleranceM = 1; -const tolerancePerBaseStep = 0.01; -const maxProfileSections = 24; - -const elevationAtDistance = (points: ProfilePoint[], startIndex: number, endIndex: number): ((distanceM: number) => number) => { - let index = startIndex; - return (distanceM: number): number => { - while (index < endIndex - 1 && points[index + 1].distanceKm * 1000 < distanceM) index++; - const nextIndex = Math.min(index + 1, endIndex); - const first = points[index]; - const next = points[nextIndex]; - const distanceSpanM = next.distanceKm * 1000 - first.distanceKm * 1000; - if (distanceSpanM <= 0) return first.elevationM; - const fraction = (distanceM - first.distanceKm * 1000) / distanceSpanM; - return first.elevationM + Math.max(0, Math.min(1, fraction)) * (next.elevationM - first.elevationM); - }; -}; - -const baseNodes = (points: ProfilePoint[], startIndex: number, endIndex: number, stepM: number): ProfileNode[] => { - const startM = points[startIndex].distanceKm * 1000; - const endM = points[endIndex].distanceKm * 1000; - const elevationAt = elevationAtDistance(points, startIndex, endIndex); - const nodes: ProfileNode[] = []; - for (let distanceM = startM; distanceM < endM; distanceM += stepM) { - nodes.push({ distanceM, elevationM: elevationAt(distanceM) }); - } - nodes.push({ distanceM: endM, elevationM: elevationAt(endM) }); - return nodes; -}; - -const chordDeviationM = (first: ProfileNode, last: ProfileNode, node: ProfileNode): number => { - const spanM = last.distanceM - first.distanceM; - if (spanM <= 0) return node.elevationM - first.elevationM; - const chordElevationM = first.elevationM + ((node.distanceM - first.distanceM) / spanM) * (last.elevationM - first.elevationM); - return node.elevationM - chordElevationM; -}; - -interface PendingSegment { - firstIndex: number; - lastIndex: number; - splitIndex: number; - deviationM: number; -} - -const bestSplit = (nodes: ProfileNode[], firstIndex: number, lastIndex: number): PendingSegment => { - let splitIndex = -1; - let deviationM = 0; - for (let index = firstIndex + 1; index < lastIndex; index++) { - const candidateM = Math.abs(chordDeviationM(nodes[firstIndex], nodes[lastIndex], nodes[index])); - if (candidateM > deviationM) { - deviationM = candidateM; - splitIndex = index; - } - } - return { firstIndex, lastIndex, splitIndex, deviationM }; -}; - -// simplifyNodes keeps splitting the segment with the widest bend until the profile either fits the -// section budget or has no bend left above toleranceM: a constant stretch collapses into one long -// section, a ramp keeps its own short ones, and the cuts land on the most prominent bends first. -const simplifyNodes = (nodes: ProfileNode[], toleranceM: number): ProfileNode[] => { - if (nodes.length <= 2) return nodes; - const finalIndex = nodes.length - 1; - const keep = new Array<boolean>(nodes.length).fill(false); - keep[0] = true; - keep[finalIndex] = true; - const pending: PendingSegment[] = [bestSplit(nodes, 0, finalIndex)]; - let sectionCount = 1; - while (sectionCount < maxProfileSections) { - let widestIndex = -1; - for (let index = 0; index < pending.length; index++) { - if (pending[index].splitIndex === -1) continue; - if (widestIndex === -1 || pending[index].deviationM > pending[widestIndex].deviationM) widestIndex = index; - } - if (widestIndex === -1 || pending[widestIndex].deviationM <= toleranceM) break; - const [widest] = pending.splice(widestIndex, 1); - keep[widest.splitIndex] = true; - sectionCount++; - pending.push(bestSplit(nodes, widest.firstIndex, widest.splitIndex), bestSplit(nodes, widest.splitIndex, widest.lastIndex)); - } - return nodes.filter((_, index) => keep[index]); -}; - -const sectionsFromNodes = (nodes: ProfileNode[]): OfficialProfileSection[] => { - const sections: OfficialProfileSection[] = []; - for (let index = 1; index < nodes.length; index++) { - const start = nodes[index - 1]; - const end = nodes[index]; - const spanM = end.distanceM - start.distanceM; - if (spanM <= 0) continue; - const slopePercent = ((end.elevationM - start.elevationM) / spanM) * 100; - sections.push({ - startDistanceKm: start.distanceM / 1000, - endDistanceKm: end.distanceM / 1000, - startElevation: start.elevationM, - endElevation: end.elevationM, - slopePercent, - band: profileBandForSlope(slopePercent), - }); - } - return sections; -}; - -// officialProfileSections turns the recorded profile into chart sections whose width -// follows the profile's bends, so short steep ramps survive instead of being averaged -// into a fixed distance bucket. -const officialProfileSections = (points: ProfilePoint[], startIndex: number, endIndex: number): OfficialProfileSection[] => { - if (startIndex < 0 || endIndex >= points.length || endIndex - startIndex < 1) return []; - const lengthM = (points[endIndex].distanceKm - points[startIndex].distanceKm) * 1000; - if (lengthM <= 0) return []; - const baseStepM = baseStepForLength(lengthM); - const nodes = baseNodes(points, startIndex, endIndex, baseStepM); - return sectionsFromNodes(simplifyNodes(nodes, Math.max(minToleranceM, tolerancePerBaseStep * baseStepM))); -}; - -export { officialProfileSections, profileBandForSlope }; diff --git a/web/frontend/official-climb-profile.ts b/web/frontend/official-climb-profile.ts index 7e93cc7..72bc8d2 100644 --- a/web/frontend/official-climb-profile.ts +++ b/web/frontend/official-climb-profile.ts @@ -1,13 +1,18 @@ -import { officialProfileSections } from "./official-climb-profile-logic.js"; -import type { OfficialProfileSection } from "./official-climb-profile-logic.js"; -import type { OfficialProfileColors, RideProfilePoint } from "./types.js"; +import type { + OfficialProfileColors, + OfficialProfileSection, + ProfileBand, + ProfileSectionPayload, + RideProfile, + RideProfileClimb, +} from "./types.js"; export class OfficialClimbProfileController { - readonly points: RideProfilePoint[]; + readonly climbs: RideProfileClimb[]; readonly colors: OfficialProfileColors; readonly cards: HTMLDetailsElement[]; - constructor(points: RideProfilePoint[]) { - this.points = points; + constructor(profile: RideProfile) { + this.climbs = profile.climbs; this.colors = this.resolveColors(); this.cards = [...document.querySelectorAll("[data-official-climb-card]")].map((card) => card as HTMLDetailsElement); for (const card of this.cards) { @@ -24,18 +29,10 @@ export class OfficialClimbProfileController { } drawOfficialProfile(profileCanvas: HTMLCanvasElement): void { - const { points } = this; - const startIndex = Number.parseInt(profileCanvas.dataset.profileStart ?? "", 10); - const endIndex = Number.parseInt(profileCanvas.dataset.profileEnd ?? "", 10); - if ( - !Number.isInteger(startIndex) || - !Number.isInteger(endIndex) || - startIndex < 0 || - endIndex >= points.length || - startIndex >= endIndex - ) - return; - const sections = officialProfileSections(points, startIndex, endIndex); + const climbIndex = Number.parseInt(profileCanvas.dataset.profileClimbIndex ?? "", 10); + const climb = this.climbs.find((candidate) => candidate.index === climbIndex); + if (!climb?.sections) return; + const sections = officialProfileSections(climb.sections); if (sections.length === 0) return; drawOfficialSections(profileCanvas, sections, this.colors); } @@ -76,6 +73,26 @@ export function resolveOfficialProfileColors(): OfficialProfileColors { return colors; } +const profileBandForSlope = (slopePercent: number): ProfileBand => { + if (slopePercent <= 0) return "downhill"; + if (slopePercent < 3) return "0-3"; + if (slopePercent < 6) return "3-6"; + if (slopePercent < 9) return "6-9"; + return "9-plus"; +}; + +// officialProfileSections turns the sections the server cut out of the trace into the chart shape, +// tagging each step with the band its slope falls in. +export const officialProfileSections = (sections: ProfileSectionPayload[]): OfficialProfileSection[] => + sections.map((section) => ({ + startDistanceKm: section.startKm, + endDistanceKm: section.endKm, + startElevation: section.startElevationM, + endElevation: section.endElevationM, + slopePercent: section.slopePercent, + band: profileBandForSlope(section.slopePercent), + })); + export interface OfficialProfilePlot { left: number; right: number; diff --git a/web/frontend/ride-detail.ts b/web/frontend/ride-detail.ts index dcebbe4..2bd6622 100644 --- a/web/frontend/ride-detail.ts +++ b/web/frontend/ride-detail.ts @@ -75,7 +75,7 @@ export const mountRideDetail = (): void => { passes, colors: mapColors, }); - const officialProfileController = new OfficialClimbProfileController(points); + const officialProfileController = new OfficialClimbProfileController(profile); let boundaryController: RideBoundaryController | undefined; const profileCanvas = new RideProfileCanvas({ canvas: profileCanvasElement, diff --git a/web/frontend/types.d.ts b/web/frontend/types.d.ts index a933d7d..a97d07d 100644 --- a/web/frontend/types.d.ts +++ b/web/frontend/types.d.ts @@ -2,6 +2,25 @@ export type ProfileBand = "downhill" | "0-3" | "3-6" | "6-9" | "9-plus"; export type BoundaryTarget = "start" | "end"; export type BoundarySource = "profile" | "map"; +// ProfileSectionPayload is one step of a climb profile as the server cuts it. +export interface ProfileSectionPayload { + startKm: number; + endKm: number; + startElevationM: number; + endElevationM: number; + slopePercent: number; +} + +// OfficialProfileSection is that step tagged with the colour band its slope falls in. +export interface OfficialProfileSection { + startDistanceKm: number; + endDistanceKm: number; + startElevation: number; + endElevation: number; + slopePercent: number; + band: ProfileBand; +} + export interface ProfilePoint { distanceKm: number; elevationM: number; @@ -14,6 +33,7 @@ export interface RideProfilePoint extends ProfilePoint { } export interface RideProfileClimb { + index: number; startKm: number; endKm: number; topKm: number; @@ -27,6 +47,7 @@ export interface RideProfileClimb { officialName?: string; startIndex: number; endIndex: number; + sections?: ProfileSectionPayload[]; } export interface RideProfileCrossing { diff --git a/web/server_test.go b/web/server_test.go index c468e4b..24ff581 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -169,7 +169,6 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) { assert.Contains(t, body, `class="ride-detail-main"`) assert.Contains(t, body, `<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>`) assert.Contains(t, body, `<script type="module" src="/static/ride-detail.js"></script>`) - assert.NotContains(t, body, `<script src="/static/official-climb-profile-logic.js"`) assert.NotContains(t, body, `<script src="/static/official-climb-profile.js"`) staticRequest := httptest.NewRequest(http.MethodGet, "/static/ride-detail.js", nil) @@ -197,14 +196,6 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) { assert.Equal(t, "text/javascript; charset=utf-8", profileStaticResponse.Header().Get("Content-Type")) 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()) - syncStaticRequest := httptest.NewRequest(http.MethodGet, "/static/sync.js", nil) syncStaticResponse := httptest.NewRecorder() server.Handler().ServeHTTP(syncStaticResponse, syncStaticRequest) diff --git a/web/templates.templ b/web/templates.templ index 7c326eb..dbdeeac 100644 --- a/web/templates.templ +++ b/web/templates.templ @@ -304,7 +304,7 @@ templ RideDetailContent(data RideDetailView) { <span class="official-climb-chevron" aria-hidden="true">⌄</span> </summary> <div class="official-climb-card-body"> - <canvas class="official-climb-profile" data-official-profile data-profile-start={ formatRouteIndex(climb.StartIndex) } data-profile-end={ formatRouteIndex(climb.EndIndex) } tabindex="0" role="img" aria-label={ officialProfileLabel(climb.OfficialName) }></canvas> + <canvas class="official-climb-profile" data-official-profile data-profile-climb-index={ formatRouteIndex(climb.Index) } tabindex="0" role="img" aria-label={ officialProfileLabel(climb.OfficialName) }></canvas> <div class="official-climb-profile-legend" role="list" aria-label="Average slope color scale"> <span role="listitem"><i class="profile-band-swatch profile-band-downhill" aria-hidden="true"></i><=0%</span> <span role="listitem"><i class="profile-band-swatch profile-band-0-3" aria-hidden="true"></i>0–3%</span> @@ -542,7 +542,7 @@ templ ClimbDetailContent(data ClimbDetailPageData) { <section class="panel map-panel"> <div id="climb-map" class="ride-map" role="img" aria-label="Official climb map"></div> </section> - if len(data.Profile.Elevations) > 0 { + if len(data.Profile.Sections) > 0 { <section class="panel"> <div class="section-heading"><div><p class="eyebrow">Profile</p><h2>Median profile</h2></div></div> <div class="profile-chart"><canvas id="climb-profile" data-climb-profile tabindex="0" role="img" aria-label={ officialProfileLabel(data.Climb.Name) }></canvas></div> diff --git a/web/views.go b/web/views.go index af3dbc2..f895fc5 100644 --- a/web/views.go +++ b/web/views.go @@ -123,20 +123,44 @@ type RideProfilePoint struct { } type RideProfileClimb struct { - Index int `json:"-"` + Index int `json:"index"` + StartKm float64 `json:"startKm"` + EndKm float64 `json:"endKm"` + TopKm float64 `json:"topKm"` + TopElevationM float64 `json:"topElevationM"` + Name string `json:"name"` + Category string `json:"category"` + DistanceKm float64 `json:"distanceKm"` + SlopePercent float64 `json:"slopePercent"` + Cotacol float64 `json:"cotacol"` + OfficialClimbID int64 `json:"officialClimbId,omitempty"` + OfficialName string `json:"officialName,omitempty"` + StartIndex int `json:"startIndex"` + EndIndex int `json:"endIndex"` + Sections []ProfileSectionView `json:"sections,omitempty"` +} + +// ProfileSectionView is one step of a drawn climb profile: the stretch between two bends of the trace. +type ProfileSectionView struct { StartKm float64 `json:"startKm"` EndKm float64 `json:"endKm"` - TopKm float64 `json:"topKm"` - TopElevationM float64 `json:"topElevationM"` - Name string `json:"name"` - Category string `json:"category"` - DistanceKm float64 `json:"distanceKm"` + StartElevationM float64 `json:"startElevationM"` + EndElevationM float64 `json:"endElevationM"` SlopePercent float64 `json:"slopePercent"` - Cotacol float64 `json:"cotacol"` - OfficialClimbID int64 `json:"officialClimbId,omitempty"` - OfficialName string `json:"officialName,omitempty"` - StartIndex int `json:"startIndex"` - EndIndex int `json:"endIndex"` +} + +func profileSectionsView(sections []climb.ProfileSection) []ProfileSectionView { + views := make([]ProfileSectionView, 0, len(sections)) + for _, section := range sections { + views = append(views, ProfileSectionView{ + StartKm: section.StartDistanceM / 1000, + EndKm: section.EndDistanceM / 1000, + StartElevationM: section.StartElevationM, + EndElevationM: section.EndElevationM, + SlopePercent: section.SlopePercent, + }) + } + return views } type RideProfileCrossing struct { @@ -366,10 +390,11 @@ func buildRideProfile(parsed ride.Ride, passes []mountain_pass.MountainPass, off } } analysis := rideanalysis.Analyze(parsed, passes, officialClimbs, matchPolicy) + profilePolicy := climb.DefaultProfilePolicy() for _, analyzedClimb := range analysis.Climbs { segment := analyzedClimb.Segment topIndex, topElevationM := segment.Top() - climb := RideProfileClimb{ + climbView := RideProfileClimb{ Index: len(profile.Climbs), StartKm: segment.Distances[0] / 1000, EndKm: segment.Distances[len(segment.Distances)-1] / 1000, @@ -385,7 +410,10 @@ func buildRideProfile(parsed ride.Ride, passes []mountain_pass.MountainPass, off StartIndex: analyzedClimb.Interval.Start, EndIndex: analyzedClimb.Interval.End, } - profile.Climbs = append(profile.Climbs, climb) + if analyzedClimb.OfficialClimbID > 0 { + climbView.Sections = profileSectionsView(segment.ProfileSections(profilePolicy)) + } + profile.Climbs = append(profile.Climbs, climbView) } for _, crossing := range analysis.Crossings { profile.Crossings = append(profile.Crossings, RideProfileCrossing{ |
