From 558f755f5746a4ee819de87579951b7533688e5d Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Thu, 27 Aug 2026 18:05:59 +0200 Subject: Categorise OSM passes by highway and colour them on the ride detail map Passes are extracted from OSM PBF nodes (mountain_pass=yes) and linked to containing ways; highway road values map to road, track to track, path/footway to path, plus hiking for rock/climbing. All passes are kept, coloured distinctly (road/track/path/hiking/unknown) for road-bike validation on ride 397. Single squashed migration adds category column, cheap to re-extract. --- web/frontend/ride-detail-map.ts | 27 +++++++++++++++++++-------- web/frontend/types.d.ts | 1 + web/static/style.css | 10 ++++++++++ web/views.go | 2 ++ 4 files changed, 32 insertions(+), 8 deletions(-) (limited to 'web') diff --git a/web/frontend/ride-detail-map.ts b/web/frontend/ride-detail-map.ts index a9b9c06..508829c 100644 --- a/web/frontend/ride-detail-map.ts +++ b/web/frontend/ride-detail-map.ts @@ -41,16 +41,27 @@ export class RideDetailMap { .addTo(this.map); const bounds = routeLayer.getBounds(); if (bounds.isValid()) this.map.fitBounds(bounds, { padding: [24, 24], maxZoom: 15 }); - const passIcon = leaflet.divIcon({ - className: "ride-map-pass-icon", - html: '', - iconSize: [28, 28], - iconAnchor: [14, 14], - }); + const passIconCache = new Map(); + const getPassIcon = (category: string): DivIcon => { + const cat = (category || "unknown").toLowerCase(); + const valid = ["road", "track", "path", "hiking", "unknown"].includes(cat) ? cat : "unknown"; + const cached = passIconCache.get(valid); + if (cached) return cached; + const icon = leaflet.divIcon({ + className: `ride-map-pass-icon ride-map-pass-icon-${valid}`, + html: '', + iconSize: [28, 28], + iconAnchor: [14, 14], + }); + passIconCache.set(valid, icon); + return icon; + }; for (const pass of passes) { - const marker = leaflet.marker([pass.latitude, pass.longitude], { icon: passIcon, alt: pass.name || "Mountain pass" }).addTo(this.map); + const icon = getPassIcon(pass.category); + const marker = leaflet.marker([pass.latitude, pass.longitude], { icon, alt: pass.name || "Mountain pass" }).addTo(this.map); const tooltip = document.createElement("span"); - tooltip.textContent = `${pass.name || "Mountain pass"} | ${Math.round(pass.elevationM)} m`; + const catLabel = pass.category ? ` ยท ${pass.category}` : ""; + tooltip.textContent = `${pass.name || "Mountain pass"} | ${Math.round(pass.elevationM)} m${catLabel}`; marker.bindTooltip(tooltip); } this.climbLayers = climbs.map((climb) => this.createClimbLayer(climb)); diff --git a/web/frontend/types.d.ts b/web/frontend/types.d.ts index d4c4bdf..790abe6 100644 --- a/web/frontend/types.d.ts +++ b/web/frontend/types.d.ts @@ -62,6 +62,7 @@ export interface RideMapPass { elevationM: number; latitude: number; longitude: number; + category: string; } export interface ClimbBounds { diff --git a/web/static/style.css b/web/static/style.css index 755a5cb..61e482a 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -47,6 +47,11 @@ --color-profile-3-6: oklch(58% 0.17 255deg); --color-profile-6-9: oklch(58% 0.2 25deg); --color-profile-9-plus: oklch(0% 0 0deg); + --color-pass-road: oklch(51% 0.14 154deg); + --color-pass-track: oklch(58% 0.11 85deg); + --color-pass-path: oklch(65% 0.17 48deg); + --color-pass-hiking: oklch(52% 0.07 265deg); + --color-pass-unknown: oklch(70% 0.04 155deg); --color-category-1: oklch(65% 0.16 15deg); --color-category-2: oklch(65% 0.16 60deg); --color-category-3: oklch(65% 0.16 105deg); @@ -129,6 +134,11 @@ select { border: 1px solid var(--color-input-border); border-radius: .55rem; pad .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; } +.ride-map-pass-icon-road { background: var(--color-pass-road); } +.ride-map-pass-icon-track { background: var(--color-pass-track); } +.ride-map-pass-icon-path { background: var(--color-pass-path); } +.ride-map-pass-icon-hiking { background: var(--color-pass-hiking); } +.ride-map-pass-icon-unknown { background: var(--color-pass-unknown); } .ride-map-climb-start-icon, .ride-map-climb-end-icon { display: grid; place-items: center; width: 1.625rem !important; height: 1.625rem !important; border: 2px solid var(--color-surface); box-shadow: 0 .15rem .4rem var(--color-panel-shadow); } .ride-map-climb-start-icon { border-radius: 50%; background: var(--color-forest); color: var(--color-on-dark); } .ride-map-climb-end-icon { border-radius: .3rem; background: var(--color-accent); color: var(--color-on-dark); } diff --git a/web/views.go b/web/views.go index 5c8d452..9a79906 100644 --- a/web/views.go +++ b/web/views.go @@ -82,6 +82,7 @@ type RideMapPass struct { ElevationM int `json:"elevationM"` Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"` + Category string `json:"category"` } type RideProfile struct { @@ -322,6 +323,7 @@ func buildRideMapPasses(passes []mountain_pass.MountainPass) []RideMapPass { ElevationM: mountainPass.Elevation, Latitude: mountainPass.Coord.Lat, Longitude: mountainPass.Coord.Lon, + Category: mountainPass.Category, }) } return mapPasses -- cgit v1.3