summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/frontend/ride-detail-map.ts18
-rw-r--r--web/frontend/ride-detail.ts8
-rw-r--r--web/frontend/types.d.ts7
-rw-r--r--web/server.go5
-rw-r--r--web/server_test.go11
-rw-r--r--web/static/style.css2
-rw-r--r--web/templates.templ1
-rw-r--r--web/templates_templ.go16
-rw-r--r--web/views.go25
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()),