diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/frontend/ride-detail-map.ts | 18 | ||||
| -rw-r--r-- | web/frontend/ride-detail.ts | 8 | ||||
| -rw-r--r-- | web/frontend/types.d.ts | 7 | ||||
| -rw-r--r-- | web/server.go | 5 | ||||
| -rw-r--r-- | web/server_test.go | 11 | ||||
| -rw-r--r-- | web/static/style.css | 2 | ||||
| -rw-r--r-- | web/templates.templ | 1 | ||||
| -rw-r--r-- | web/templates_templ.go | 16 | ||||
| -rw-r--r-- | web/views.go | 25 |
9 files changed, 83 insertions, 10 deletions
diff --git a/web/frontend/ride-detail-map.ts b/web/frontend/ride-detail-map.ts index dcc2b60..81367f3 100644 --- a/web/frontend/ride-detail-map.ts +++ b/web/frontend/ride-detail-map.ts @@ -1,5 +1,5 @@ import { clamp } from "./ride-detail-logic.js"; -import type { ClimbBounds, RideMapColors, RideProfilePoint, RideRoute } from "./types.js"; +import type { ClimbBounds, RideMapColors, RideMapPass, RideProfilePoint, RideRoute } from "./types.js"; import type { CircleMarker, LeafletMouseEvent, Map as LeafletMap, Polyline } from "leaflet"; type LeafletApi = typeof import("leaflet"); @@ -10,6 +10,7 @@ interface RideDetailMapOptions { route: RideRoute; points: RideProfilePoint[]; climbs: ClimbBounds[]; + passes: RideMapPass[]; colors: RideMapColors; } @@ -21,7 +22,7 @@ export class RideDetailMap { private readonly climbLayers: (Polyline | null)[]; private readonly routeCursor: CircleMarker; - constructor({ leaflet, element, route, points, climbs, colors }: RideDetailMapOptions) { + constructor({ leaflet, element, route, points, climbs, passes, colors }: RideDetailMapOptions) { this.leaflet = leaflet; this.points = points; this.colors = colors; @@ -37,6 +38,19 @@ export class RideDetailMap { }) .addTo(this.map); const bounds = routeLayer.getBounds(); + const passIcon = leaflet.divIcon({ + className: "ride-map-pass-icon", + html: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 19 10.5 6l3.25 5.5L16 8l5 11H3Z" fill="currentColor"/></svg>', + iconSize: [28, 28], + iconAnchor: [14, 14], + }); + for (const pass of passes) { + const marker = leaflet.marker([pass.latitude, pass.longitude], { icon: passIcon, alt: pass.name || "Mountain pass" }).addTo(this.map); + const tooltip = document.createElement("span"); + tooltip.textContent = `${pass.name || "Mountain pass"} | ${Math.round(pass.elevationM)} m`; + marker.bindTooltip(tooltip); + bounds.extend(marker.getLatLng()); + } if (bounds.isValid()) this.map.fitBounds(bounds, { padding: [24, 24], maxZoom: 15 }); this.climbLayers = climbs.map((climb) => this.createClimbLayer(climb)); this.routeCursor = leaflet diff --git a/web/frontend/ride-detail.ts b/web/frontend/ride-detail.ts index 7394d5a..045372e 100644 --- a/web/frontend/ride-detail.ts +++ b/web/frontend/ride-detail.ts @@ -3,17 +3,19 @@ import { RideBoundaryController } from "./ride-detail-boundaries.js"; import { RideProfileCanvas } from "./ride-detail-canvas.js"; import { RideDetailMap } from "./ride-detail-map.js"; import { formatDistance, formatElevation } from "./ride-detail-logic.js"; -import type { ClimbBounds, RideMapColors, RideProfile, RideProfileColors, RideProfilePoint, RideRoute } from "./types.js"; +import type { ClimbBounds, RideMapColors, RideMapPass, RideProfile, RideProfileColors, RideProfilePoint, RideRoute } from "./types.js"; export const mountRideDetail = (): void => { const mapElement = document.getElementById("ride-map"); const routeElement = document.getElementById("ride-route"); const profileElement = document.getElementById("ride-profile"); + const passesElement = document.getElementById("ride-passes"); const canvas = document.getElementById("ride-profile-chart"); const hoverOutput = document.getElementById("ride-profile-hover"); - if (!mapElement || !routeElement || !profileElement || !canvas || !hoverOutput) return; + if (!mapElement || !routeElement || !profileElement || !passesElement || !canvas || !hoverOutput) return; const routeScript = routeElement as HTMLScriptElement; const profileScript = profileElement as HTMLScriptElement; + const passesScript = passesElement as HTMLScriptElement; const profileCanvasElement = canvas as HTMLCanvasElement; const leaflet = window.L; if (!leaflet) return; @@ -49,6 +51,7 @@ export const mountRideDetail = (): void => { const route = JSON.parse(routeScript.textContent ?? "") as RideRoute; const profile = JSON.parse(profileScript.textContent ?? "") as RideProfile; + const passes = JSON.parse(passesScript.textContent ?? "") as RideMapPass[]; const points: RideProfilePoint[] = profile.points; if (points.length === 0) return; @@ -69,6 +72,7 @@ export const mountRideDetail = (): void => { route, points, climbs: climbBounds, + passes, colors: mapColors, }); const officialProfileController = new OfficialClimbProfileController(points); diff --git a/web/frontend/types.d.ts b/web/frontend/types.d.ts index 0b59dac..07c8204 100644 --- a/web/frontend/types.d.ts +++ b/web/frontend/types.d.ts @@ -57,6 +57,13 @@ export interface RideRouteFeature { properties?: Record<string, unknown>; } +export interface RideMapPass { + name: string; + elevationM: number; + latitude: number; + longitude: number; +} + export interface ClimbBounds { startIndex: number; endIndex: number; diff --git a/web/server.go b/web/server.go index 42ee21d..496e44d 100644 --- a/web/server.go +++ b/web/server.go @@ -108,9 +108,9 @@ func (s *Server) handleRide(w http.ResponseWriter, r *http.Request) { }), w) return } - passes, err := mountain_pass.LoadMountainPasses(s.db) + passes, err := mountain_pass.LoadMountainPassesAroundRide(s.db, parsed, rideDetailMountainPassMarginM) if err != nil { - slog.Warn("Failed to load mountain passes for ride detail", "ride_id", id, "error", err) + slog.Warn("Failed to load nearby mountain passes for ride detail", "ride_id", id, "error", err) } officialClimbs, err := official_climb.List(s.db) if err != nil { @@ -204,6 +204,7 @@ func (s *Server) listRides(rideSort RideSort) ([]rides.Ride, error) { } const minimumDisplayedDistanceM = 10_000 +const rideDetailMountainPassMarginM = 10_000 func buildRideViews(items []rides.Ride) []RideView { views := make([]RideView, 0, len(items)) diff --git a/web/server_test.go b/web/server_test.go index f408d33..57f0d4a 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -113,6 +113,14 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) { StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: 20_000, })) + _, err := db.Exec(` + INSERT INTO mountain_passes (external_id, name, country_code, department_code, elevation, latitude, longitude) + VALUES (?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?) + `, + "550e8400-e29b-41d4-a716-446655440000", "Nearby pass", "FR", "13", 400, 43.05, 5.05, + "6ba7b810-9dad-41d1-80b4-00c04fd430c8", "Distant pass", "FR", "13", 500, 43.2, 5.2, + ) + require.NoError(t, err) item, found, err := rides.GetByExternalID(db, "strava:550e8400-e29b-41d4-a716-446655440000") require.NoError(t, err) require.True(t, found) @@ -128,6 +136,7 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) { assert.NotContains(t, body, `<p class="eyebrow">Ride detail</p>`) assert.Contains(t, body, `id="ride-route"`) assert.Contains(t, body, `id="ride-profile"`) + assert.Contains(t, body, `id="ride-passes"`) assert.Contains(t, body, `id="ride-profile-chart"`) assert.Contains(t, body, `id="ride-map"`) assert.Contains(t, body, `class="ride-detail-grid"`) @@ -151,6 +160,8 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) { assert.Contains(t, body, `"type":"FeatureCollection"`) assert.Contains(t, body, `"type":"LineString"`) assert.Contains(t, body, `"coordinates":[[5,43]`) + assert.Contains(t, body, "Nearby pass") + assert.NotContains(t, body, "Distant pass") assert.NotContains(t, body, `"score":`) profileStaticRequest := httptest.NewRequest(http.MethodGet, "/static/official-climb-profile.js", nil) diff --git a/web/static/style.css b/web/static/style.css index 8c4f20c..002407b 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -88,6 +88,8 @@ th { color: var(--color-subtle); font-size: .78rem; letter-spacing: .08em; text- .empty { padding: 2.5rem 1rem; text-align: center; color: var(--color-subtle); } .map-panel { padding: 0; overflow: hidden; } .ride-map { min-height: 24rem; height: min(65vh, 40rem); } +.ride-map-pass-icon { display: grid; place-items: center; width: 1.75rem !important; height: 1.75rem !important; border: 2px solid var(--color-surface); border-radius: 50%; background: var(--color-leaf); color: var(--color-on-dark); box-shadow: 0 .15rem .4rem var(--color-panel-shadow); } +.ride-map-pass-icon svg { width: 1.15rem; height: 1.15rem; } .profile-panel { padding-bottom: 1rem; } .profile-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } .profile-heading h2 { margin: 0; font-size: 1.2rem; } diff --git a/web/templates.templ b/web/templates.templ index 795af43..f3f07e7 100644 --- a/web/templates.templ +++ b/web/templates.templ @@ -169,6 +169,7 @@ templ RideDetailContent(data RideDetailView) { if data.HasRoute { @templ.JSONScript("ride-route", data.Route) @templ.JSONScript("ride-profile", data.Profile) + @templ.JSONScript("ride-passes", data.Passes) <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script type="module" src="/static/ride-detail.js"></script> } diff --git a/web/templates_templ.go b/web/templates_templ.go index 882107a..b16554d 100644 --- a/web/templates_templ.go +++ b/web/templates_templ.go @@ -912,6 +912,14 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.JSONScript("ride-passes", data.Passes).Render(ctx, templ_7745c5c3_Buffer) + 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 type=\"module\" src=\"/static/ride-detail.js\"></script>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err @@ -983,7 +991,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: 186, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 187, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) if templ_7745c5c3_Err != nil { @@ -1007,7 +1015,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: 191, Col: 49} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 192, Col: 49} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) if templ_7745c5c3_Err != nil { @@ -1049,7 +1057,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: 202, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 203, Col: 64} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) if templ_7745c5c3_Err != nil { @@ -1062,7 +1070,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: 203, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 204, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63)) if templ_7745c5c3_Err != nil { diff --git a/web/views.go b/web/views.go index 551a74f..b728de8 100644 --- a/web/views.go +++ b/web/views.go @@ -21,6 +21,7 @@ type RideView struct { type RideDetailView struct { RideView Route GeoJSONFeatureCollection + Passes []RideMapPass HasRoute bool Profile RideProfile RouteError string @@ -28,6 +29,13 @@ type RideDetailView struct { Notice string } +type RideMapPass struct { + Name string `json:"name"` + ElevationM int `json:"elevationM"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` +} + type RideProfile struct { Points []RideProfilePoint `json:"points"` Climbs []RideProfileClimb `json:"climbs"` @@ -228,6 +236,7 @@ func buildRideDetailView(item rides.Ride, parsed ride.Ride, passes []mountain_pa profile := buildRideProfile(parsed, passes, officialClimbs, matchPolicy) return RideDetailView{ RideView: buildRideView(item), + Passes: buildRideMapPasses(passes), Route: GeoJSONFeatureCollection{ Type: "FeatureCollection", Features: []GeoJSONFeature{{ @@ -243,6 +252,22 @@ func buildRideDetailView(item rides.Ride, parsed ride.Ride, passes []mountain_pa } } +func buildRideMapPasses(passes []mountain_pass.MountainPass) []RideMapPass { + mapPasses := make([]RideMapPass, 0, len(passes)) + for _, mountainPass := range passes { + if mountainPass.Coord == nil { + continue + } + mapPasses = append(mapPasses, RideMapPass{ + Name: mountainPass.Name, + ElevationM: mountainPass.Elevation, + Latitude: mountainPass.Coord.Lat, + Longitude: mountainPass.Coord.Lon, + }) + } + return mapPasses +} + func buildRideProfile(parsed ride.Ride, passes []mountain_pass.MountainPass, officialClimbs []official_climb.OfficialClimb, matchPolicy official_climb.MatchPolicy) RideProfile { profile := RideProfile{ Points: make([]RideProfilePoint, parsed.Len()), |