summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--mountain_pass/detection.go54
-rw-r--r--mountain_pass/detection_test.go30
-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
11 files changed, 163 insertions, 14 deletions
diff --git a/mountain_pass/detection.go b/mountain_pass/detection.go
index 4be2f94..4c54493 100644
--- a/mountain_pass/detection.go
+++ b/mountain_pass/detection.go
@@ -3,6 +3,7 @@ package mountain_pass
import (
"database/sql"
"fmt"
+ "math"
"github.com/jftuga/geodist"
"github.com/martinlehoux/biking_home/ride"
@@ -17,11 +18,56 @@ type Crossing struct {
}
func LoadMountainPasses(db *sql.DB) ([]MountainPass, error) {
- rows, err := db.Query(`
+ return loadMountainPasses(db, nil)
+}
+
+func LoadMountainPassesAroundRide(db *sql.DB, route ride.Ride, marginM float64) ([]MountainPass, error) {
+ return loadMountainPasses(db, rideBounds(route, marginM))
+}
+
+type coordinateBounds struct {
+ minLatitude float64
+ minLongitude float64
+ maxLatitude float64
+ maxLongitude float64
+}
+
+func rideBounds(route ride.Ride, marginM float64) *coordinateBounds {
+ minLatitude, maxLatitude := route.Coord(0).Lat, route.Coord(0).Lat
+ minLongitude, maxLongitude := route.Coord(0).Lon, route.Coord(0).Lon
+ for index := 1; index < route.Len(); index++ {
+ coordinate := route.Coord(index)
+ minLatitude = math.Min(minLatitude, coordinate.Lat)
+ maxLatitude = math.Max(maxLatitude, coordinate.Lat)
+ minLongitude = math.Min(minLongitude, coordinate.Lon)
+ maxLongitude = math.Max(maxLongitude, coordinate.Lon)
+ }
+ latitudeMargin := marginM / 111_320
+ longitudeScale := math.Cos((minLatitude + maxLatitude) / 2 * math.Pi / 180)
+ longitudeMargin := marginM / (111_320 * math.Max(longitudeScale, 0.01))
+ return &coordinateBounds{
+ minLatitude: minLatitude - latitudeMargin,
+ minLongitude: minLongitude - longitudeMargin,
+ maxLatitude: maxLatitude + latitudeMargin,
+ maxLongitude: maxLongitude + longitudeMargin,
+ }
+}
+
+func loadMountainPasses(db *sql.DB, bounds *coordinateBounds) ([]MountainPass, error) {
+ query := `
SELECT external_id, name, country_code, department_code, elevation, latitude, longitude
- FROM mountain_passes
- ORDER BY elevation
- `)
+ FROM mountain_passes`
+ args := make([]any, 0, 4)
+ if bounds != nil {
+ query += `
+ WHERE latitude IS NOT NULL AND longitude IS NOT NULL
+ AND latitude BETWEEN ? AND ?
+ AND longitude BETWEEN ? AND ?`
+ args = append(args, bounds.minLatitude, bounds.maxLatitude, bounds.minLongitude, bounds.maxLongitude)
+ }
+ query += `
+ ORDER BY elevation`
+ rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
diff --git a/mountain_pass/detection_test.go b/mountain_pass/detection_test.go
index d6cba9f..d86e67b 100644
--- a/mountain_pass/detection_test.go
+++ b/mountain_pass/detection_test.go
@@ -5,9 +5,11 @@ import (
"time"
"github.com/jftuga/geodist"
+ "github.com/martinlehoux/biking_home/internal/dbtest"
"github.com/martinlehoux/biking_home/mountain_pass"
"github.com/martinlehoux/biking_home/ride"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestDetectCrossingsFindsPass(t *testing.T) {
@@ -23,6 +25,34 @@ func TestDetectCrossingsFindsPass(t *testing.T) {
assert.Less(t, crossings[0].ElevationDiff, 25.0)
}
+func TestLoadMountainPassesAroundRideUsesExpandedBounds(t *testing.T) {
+ db := dbtest.New(t)
+ _, err := db.Exec(`
+ INSERT INTO mountain_passes (external_id, name, country_code, department_code, elevation, latitude, longitude)
+ VALUES (?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?)
+ `,
+ "550e8400-e29b-41d4-a716-446655440000", "On route", "FR", "13", 400, 43.05, 5.05,
+ "6ba7b810-9dad-41d1-80b4-00c04fd430c8", "Within margin", "FR", "13", 500, 43.18, 5.20,
+ "6ba7b811-9dad-41d1-80b4-00c04fd430c8", "Outside margin", "FR", "13", 600, 43.21, 5.20,
+ "6ba7b812-9dad-41d1-80b4-00c04fd430c8", "Missing coordinates", "FR", "13", 700, nil, nil,
+ "6ba7b813-9dad-41d1-80b4-00c04fd430c8", "On route edge", "FR", "13", 300, 43.10, 5.10,
+ )
+ require.NoError(t, err)
+
+ route := ride.FromColumns(
+ []float64{0, 1000},
+ []float64{100, 200},
+ []geodist.Coord{{Lat: 43.0, Lon: 5.0}, {Lat: 43.1, Lon: 5.1}},
+ make([]time.Time, 2),
+ )
+ passes, err := mountain_pass.LoadMountainPassesAroundRide(db, route, 10_000)
+ require.NoError(t, err)
+ require.Len(t, passes, 3)
+ assert.Equal(t, "On route edge", passes[0].Name)
+ assert.Contains(t, []string{passes[1].Name, passes[2].Name}, "On route")
+ assert.Contains(t, []string{passes[1].Name, passes[2].Name}, "Within margin")
+}
+
func TestDetectCrossingsIgnoresDistantPass(t *testing.T) {
r := rideFromPoint(t, 43.62, 5.43, 447)
far := mountain_pass.MountainPass{
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()),