From 8f829816e35a41844808d1d338fd02dcc0320981 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Wed, 5 Aug 2026 15:09:44 +0200 Subject: refactor: Extract CLI and chart packages --- README.md | 18 ++++-- chart.go | 81 --------------------------- chart/chart.go | 81 +++++++++++++++++++++++++++ cli/cli.go | 169 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 158 +---------------------------------------------------- 5 files changed, 265 insertions(+), 242 deletions(-) delete mode 100644 chart.go create mode 100644 chart/chart.go create mode 100644 cli/cli.go diff --git a/README.md b/README.md index 611266c..22cc372 100644 --- a/README.md +++ b/README.md @@ -87,13 +87,15 @@ Standard apps are rate-limited to 100 calls per 15 minutes and 1,000 per day. - `rides` — SQLite persistence for imported ride metadata - `config` — typed YAML configuration and atomic persistence - `web` — HTTP server, OAuth callback, sync orchestration, and templ pages +- `cli` — legacy mountain-pass, OSM, demo, and chart command handlers +- `chart` — elevation chart rendering - **Notable choices** — the difficulty score follows the Cotacol method: the ride is split into fixed 100 m segments and each scores `distance_km × slope²`, so steep sections weigh exponentially more than long flat ones ```mermaid flowchart TB %% Arrow X --> Y means: X depends on Y - main["main (CLI + server: main.go, chart.go)"] + main["main (bootstrap: main.go)"] ride["ride (GPX parsing, climbs, Cotacol)"] mpass["mountain_pass (centcols, crossings)"] @@ -102,11 +104,15 @@ flowchart TB rides["rides (SQLite persistence)"] config["config (YAML)"] web["web (HTTP + templ)"] - - main --> ride - main --> mpass - main --> osmpass - main --> web + cli["cli (legacy commands)"] + chart["chart (renderer)"] + + main --> cli + cli --> ride + cli --> mpass + cli --> osmpass + cli --> web + cli --> chart mpass --> ride web --> strava web --> rides diff --git a/chart.go b/chart.go deleted file mode 100644 index 063b495..0000000 --- a/chart.go +++ /dev/null @@ -1,81 +0,0 @@ -package main - -import ( - "fmt" - "image/color" - - "github.com/martinlehoux/biking_home/mountain_pass" - "github.com/martinlehoux/biking_home/ride" - "github.com/martinlehoux/kagamigo/kcore" - "gonum.org/v1/plot" - "gonum.org/v1/plot/plotter" - "gonum.org/v1/plot/vg" -) - -func renderChart(r ride.Ride, climbs []ride.Climb, crossings []mountain_pass.Crossing, output string) { - p := plot.New() - p.Title.Text = "Ride profile" - p.X.Label.Text = "Distance (km)" - p.Y.Label.Text = "Elevation (m)" - - maxElev := 0.0 - elevation := make(plotter.XYs, len(r.Points())) - for i, point := range r.Points() { - elevation[i].X = point.DistanceM / 1000 - elevation[i].Y = point.ElevationM - if point.ElevationM > maxElev { - maxElev = point.ElevationM - } - } - - for _, climb := range climbs { - x0 := climb.Start().DistanceM / 1000 - x1 := climb.End().DistanceM / 1000 - fill, err := plotter.NewPolygon(plotter.XYs{ - {X: x0, Y: 0}, - {X: x1, Y: 0}, - {X: x1, Y: maxElev}, - {X: x0, Y: maxElev}, - }) - kcore.Expect(err, "failed to create climb band") - fill.Color = color.RGBA{R: 230, G: 160, B: 0, A: 70} - fill.LineStyle.Width = 0 - p.Add(fill) - p.Add(plotLabels(plotter.XY{X: (x0 + x1) / 2, Y: climb.Top().ElevationM * 1.01}, climbLabel(climb))) - } - - for _, crossing := range crossings { - x := crossing.RideDistanceM / 1000 - line, err := plotter.NewLine(plotter.XYs{ - {X: x, Y: 0}, - {X: x, Y: maxElev}, - }) - kcore.Expect(err, "failed to create pass marker") - line.LineStyle.Dashes = []vg.Length{vg.Points(4), vg.Points(2)} - p.Add(line) - p.Add(plotLabels(plotter.XY{X: x, Y: maxElev * 0.96}, - fmt.Sprintf("%s %dm", crossing.Pass.Name, crossing.Pass.Elevation))) - } - - elevLine, err := plotter.NewLine(elevation) - kcore.Expect(err, "failed to create elevation line") - p.Add(elevLine) - - kcore.Expect(p.Save(10*vg.Inch, 4*vg.Inch, output), "failed to save chart") -} - -func climbLabel(climb ride.Climb) string { - if climb.Name != "" { - return climb.Name - } - return fmt.Sprintf("%s %d", ride.Category(climb.Score()), int(climb.Score())) -} - -func plotLabels(xy plotter.XY, label string) *plotter.Labels { - labels, err := plotter.NewLabels(plotter.XYLabels{ - XYs: plotter.XYs{xy}, - Labels: []string{label}, - }) - kcore.Expect(err, "failed to create labels") - return labels -} diff --git a/chart/chart.go b/chart/chart.go new file mode 100644 index 0000000..a185378 --- /dev/null +++ b/chart/chart.go @@ -0,0 +1,81 @@ +package chart + +import ( + "fmt" + "image/color" + + "github.com/martinlehoux/biking_home/mountain_pass" + "github.com/martinlehoux/biking_home/ride" + "github.com/martinlehoux/kagamigo/kcore" + "gonum.org/v1/plot" + "gonum.org/v1/plot/plotter" + "gonum.org/v1/plot/vg" +) + +func Render(r ride.Ride, climbs []ride.Climb, crossings []mountain_pass.Crossing, output string) { + p := plot.New() + p.Title.Text = "Ride profile" + p.X.Label.Text = "Distance (km)" + p.Y.Label.Text = "Elevation (m)" + + maxElev := 0.0 + elevation := make(plotter.XYs, len(r.Points())) + for i, point := range r.Points() { + elevation[i].X = point.DistanceM / 1000 + elevation[i].Y = point.ElevationM + if point.ElevationM > maxElev { + maxElev = point.ElevationM + } + } + + for _, climb := range climbs { + x0 := climb.Start().DistanceM / 1000 + x1 := climb.End().DistanceM / 1000 + fill, err := plotter.NewPolygon(plotter.XYs{ + {X: x0, Y: 0}, + {X: x1, Y: 0}, + {X: x1, Y: maxElev}, + {X: x0, Y: maxElev}, + }) + kcore.Expect(err, "failed to create climb band") + fill.Color = color.RGBA{R: 230, G: 160, B: 0, A: 70} + fill.LineStyle.Width = 0 + p.Add(fill) + p.Add(plotLabels(plotter.XY{X: (x0 + x1) / 2, Y: climb.Top().ElevationM * 1.01}, climbLabel(climb))) + } + + for _, crossing := range crossings { + x := crossing.RideDistanceM / 1000 + line, err := plotter.NewLine(plotter.XYs{ + {X: x, Y: 0}, + {X: x, Y: maxElev}, + }) + kcore.Expect(err, "failed to create pass marker") + line.LineStyle.Dashes = []vg.Length{vg.Points(4), vg.Points(2)} + p.Add(line) + p.Add(plotLabels(plotter.XY{X: x, Y: maxElev * 0.96}, + fmt.Sprintf("%s %dm", crossing.Pass.Name, crossing.Pass.Elevation))) + } + + elevLine, err := plotter.NewLine(elevation) + kcore.Expect(err, "failed to create elevation line") + p.Add(elevLine) + + kcore.Expect(p.Save(10*vg.Inch, 4*vg.Inch, output), "failed to save chart") +} + +func climbLabel(climb ride.Climb) string { + if climb.Name != "" { + return climb.Name + } + return fmt.Sprintf("%s %d", ride.Category(climb.Score()), int(climb.Score())) +} + +func plotLabels(xy plotter.XY, label string) *plotter.Labels { + labels, err := plotter.NewLabels(plotter.XYLabels{ + XYs: plotter.XYs{xy}, + Labels: []string{label}, + }) + kcore.Expect(err, "failed to create labels") + return labels +} diff --git a/cli/cli.go b/cli/cli.go new file mode 100644 index 0000000..a25d35d --- /dev/null +++ b/cli/cli.go @@ -0,0 +1,169 @@ +package cli + +import ( + "context" + "database/sql" + "flag" + "log/slog" + "os" + "path/filepath" + "runtime/pprof" + "strings" + "time" + + "github.com/martinlehoux/biking_home/chart" + "github.com/martinlehoux/biking_home/config" + "github.com/martinlehoux/biking_home/mountain_pass" + "github.com/martinlehoux/biking_home/osmpass" + "github.com/martinlehoux/biking_home/ride" + "github.com/martinlehoux/biking_home/web" + "github.com/martinlehoux/kagamigo/kcore" +) + +var ( + download = flag.Bool("download", false, "download mountain passes into the database") + resume = flag.Bool("resume", false, "skip departments already cached on disk") + importCache = flag.Bool("import-cached", false, "import cached department CSVs into the database") + fetchOSM = flag.Bool("fetch-osm", false, "download the France OSM PBF (resumable) into france-latest.osm.pbf") + extractOSM = flag.String("extract-osm", "", "extract mountain passes from an OSM PBF file into the database") + enrich = flag.Bool("enrich", false, "backfill mountain pass coordinates from OSM data") + demo = flag.Bool("demo", false, "run the climb similarity demo") + chartFile = flag.String("chart", "", "render a climb/pass chart for a GPX file") + cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file") + parser = ride.GPXRideParser{} +) + +func Run(db *sql.DB, configPath string, appConfig config.Config) { + switch { + case *download: + err := mountain_pass.DownloadMountainPasses(db, 5*time.Second, *resume) + kcore.Expect(err, "failed to download mountain passes") + case *importCache: + _, err := mountain_pass.ImportCachedDepartments(db, []string{"06", "13"}) + kcore.Expect(err, "failed to import cached departments") + case *fetchOSM: + err := osmpass.FetchFrancePBF("france-latest.osm.pbf") + kcore.Expect(err, "failed to download France OSM PBF") + case *extractOSM != "": + _, err := osmpass.ExtractMountainPasses(context.Background(), *extractOSM, db) + kcore.Expect(err, "failed to extract mountain passes from OSM") + case *enrich: + _, err := osmpass.EnrichMountainPasses(db) + kcore.Expect(err, "failed to enrich mountain passes") + case *demo: + runDemo(db) + case *chartFile != "": + runChart(db, *chartFile) + default: + runServer(db, configPath, appConfig) + } +} + +func runServer(db *sql.DB, configPath string, appConfig config.Config) { + server := web.NewServer(db, configPath) + slog.Info("Starting web server", "address", appConfig.Server.PublicURL) + kcore.Expect(server.ListenAndServe(appConfig.Server.Address), "web server stopped") +} + +func runChart(db *sql.DB, filename string) { + r, err := ride.ParseFile(parser, filename) + kcore.Expect(err, "failed to parse ride") + passes, err := mountain_pass.LoadMountainPasses(db) + kcore.Expect(err, "failed to load mountain passes") + + climbs := r.AllClimbs() + for i := range climbs { + if matched, ok := mountain_pass.MatchClimb(climbs[i], passes, 300, 50); ok { + climbs[i].Name = matched.Name + } + } + crossings := mountain_pass.DetectCrossings(r, passes, 100, 25) + + output := strings.TrimSuffix(filename, filepath.Ext(filename)) + "-chart.png" + chart.Render(r, climbs, crossings, output) + slog.Info("Chart written", "file", output) +} + +func runDemo(db *sql.DB) { + if *cpuprofile != "" { + f, err := os.Create(*cpuprofile) + kcore.Expect(err, "failed to create CPU profile") + pprof.StartCPUProfile(f) + defer pprof.StopCPUProfile() + } + ride19Jan, _ := ride.ParseFile(parser, "examples/activity_18043356988.gpx") + ride30Mar, _ := ride.ParseFile(parser, "examples/activity_18679866717.gpx") + ride20Apr, _ := ride.ParseFile(parser, "examples/activity_18880605641.gpx") + for _, r := range []*ride.Ride{&ride19Jan, &ride30Mar, &ride20Apr} { + slog.Info("Ride", "difficulty", r.DifficultyScore()) + } + passes, passesErr := mountain_pass.LoadMountainPasses(db) + if passesErr != nil { + slog.Warn("No mountain passes in database, skipping crossings and climb naming", "error", passesErr) + passes = nil + } else { + rides := []struct { + name string + ride ride.Ride + }{ + {"2022-07-21.Pogacar", func() ride.Ride { + r, _ := ride.ParseFile(parser, "examples/2022-07-21.Pogacar.gpx") + return r + }()}, + {"2023-06-17.AlpesVerdonTour", func() ride.Ride { + r, _ := ride.ParseFile(parser, "examples/2023-06-17.AlpesVerdonTour.gpx") + return r + }()}, + {"2024-12-29.MimetArbois", func() ride.Ride { + r, _ := ride.ParseFile(parser, "examples/2024-12-29.MimetArbois.gpx") + return r + }()}, + {"activity_18043356988", ride19Jan}, + {"activity_18679866717", ride30Mar}, + {"activity_18880605641", ride20Apr}, + } + for _, entry := range rides { + crossings := mountain_pass.DetectCrossings(entry.ride, passes, 100, 25) + if len(crossings) > 0 { + slog.Info("Crossings", "ride", entry.name, "count", len(crossings)) + for _, crossing := range crossings { + slog.Info("Crossing", "pass", crossing.String()) + } + } + } + } + + nameClimbs := func(climbs []ride.Climb) { + for i := range climbs { + if matched, ok := mountain_pass.MatchClimb(climbs[i], passes, 300, 50); ok { + climbs[i].Name = matched.Name + } + } + } + verdon, _ := ride.ParseFile(parser, "examples/2023-06-17.AlpesVerdonTour.gpx") + verdonClimbs := verdon.AllClimbs() + nameClimbs(verdonClimbs) + for _, climb := range verdonClimbs { + slog.Info("Climb", "climb", climb, "elevation", climb.Top().ElevationM, "duration", climb.Duration(), "speed", climb.Speed()*3.6) + } + + index := ride.NewFlatRideClimbsIndex(30) + index.Insert(ride19Jan) + index.Insert(ride30Mar) + ride20AprilClimbs := ride20Apr.AllClimbs() + nameClimbs(ride20AprilClimbs) + for _, climb := range ride20AprilClimbs { + slog.Info("Climb", "climb", climb, "duration", climb.Duration(), "speed", climb.Speed()*3.6) + if len(index.Similar(ride20Apr, climb)) > 0 { + for _, similar := range index.Similar(ride20Apr, climb) { + slog.Info("Similar", "climb", similar, "duration", similar.Duration(), "speed", similar.Speed()*3.6) + } + } + } + ride30MarchClimbs := ride30Mar.AllClimbs() + nameClimbs(ride30MarchClimbs) + for _, climb := range ride30MarchClimbs { + slog.Info("Climb", "climb", climb, "duration", climb.Duration(), "speed", climb.Speed()*3.6) + } + ride.PlotScore(&ride30Mar, 61.2, 66.7, "Ride 30 Mar.png") +} diff --git a/main.go b/main.go index 36b3d28..4404acf 100644 --- a/main.go +++ b/main.go @@ -1,37 +1,17 @@ package main import ( - "context" "database/sql" "flag" - "log/slog" - "os" - "path/filepath" - "runtime/pprof" - "strings" - "time" + "github.com/martinlehoux/biking_home/cli" "github.com/martinlehoux/biking_home/config" - "github.com/martinlehoux/biking_home/mountain_pass" - "github.com/martinlehoux/biking_home/osmpass" - "github.com/martinlehoux/biking_home/ride" - "github.com/martinlehoux/biking_home/web" "github.com/martinlehoux/kagamigo/kcore" _ "github.com/mattn/go-sqlite3" ) var ( - download = flag.Bool("download", false, "download mountain passes into the database") - resume = flag.Bool("resume", false, "skip departments already cached on disk") - importCache = flag.Bool("import-cached", false, "import cached department CSVs into the database") - fetchOSM = flag.Bool("fetch-osm", false, "download the France OSM PBF (resumable) into france-latest.osm.pbf") - extractOSM = flag.String("extract-osm", "", "extract mountain passes from an OSM PBF file into the database") - enrich = flag.Bool("enrich", false, "backfill mountain pass coordinates from OSM data") - demo = flag.Bool("demo", false, "run the climb similarity demo") - chartFile = flag.String("chart", "", "render a climb/pass chart for a GPX file") - cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file") - configFile = flag.String("config", "config.yaml", "path to the YAML configuration file") - parser = ride.GPXRideParser{} + configFile = flag.String("config", "config.yaml", "path to the YAML configuration file") ) func main() { @@ -41,137 +21,5 @@ func main() { db, err := sql.Open("sqlite3", appConfig.Database.Path) kcore.Expect(err, "failed to open database") defer db.Close() - switch { - case *download: - err = mountain_pass.DownloadMountainPasses(db, 5*time.Second, *resume) - kcore.Expect(err, "failed to download mountain passes") - case *importCache: - _, err = mountain_pass.ImportCachedDepartments(db, []string{"06", "13"}) - kcore.Expect(err, "failed to import cached departments") - case *fetchOSM: - err = osmpass.FetchFrancePBF("france-latest.osm.pbf") - kcore.Expect(err, "failed to download France OSM PBF") - case *extractOSM != "": - _, err = osmpass.ExtractMountainPasses(context.Background(), *extractOSM, db) - kcore.Expect(err, "failed to extract mountain passes from OSM") - case *enrich: - _, err = osmpass.EnrichMountainPasses(db) - kcore.Expect(err, "failed to enrich mountain passes") - case *demo: - runDemo(db) - case *chartFile != "": - runChart(db, *chartFile) - default: - runServer(db, *configFile, appConfig) - } -} - -func runServer(db *sql.DB, configPath string, appConfig config.Config) { - server := web.NewServer(db, configPath) - slog.Info("Starting web server", "address", appConfig.Server.PublicURL) - kcore.Expect(server.ListenAndServe(appConfig.Server.Address), "web server stopped") -} - -func runChart(db *sql.DB, filename string) { - r, err := ride.ParseFile(parser, filename) - kcore.Expect(err, "failed to parse ride") - passes, err := mountain_pass.LoadMountainPasses(db) - kcore.Expect(err, "failed to load mountain passes") - - climbs := r.AllClimbs() - for i := range climbs { - if matched, ok := mountain_pass.MatchClimb(climbs[i], passes, 300, 50); ok { - climbs[i].Name = matched.Name - } - } - crossings := mountain_pass.DetectCrossings(r, passes, 100, 25) - - output := strings.TrimSuffix(filename, filepath.Ext(filename)) + "-chart.png" - renderChart(r, climbs, crossings, output) - slog.Info("Chart written", "file", output) -} - -func runDemo(db *sql.DB) { - if *cpuprofile != "" { - f, err := os.Create(*cpuprofile) - kcore.Expect(err, "failed to create CPU profile") - pprof.StartCPUProfile(f) - defer pprof.StopCPUProfile() - } - ride19Jan, _ := ride.ParseFile(parser, "examples/activity_18043356988.gpx") - ride30Mar, _ := ride.ParseFile(parser, "examples/activity_18679866717.gpx") - ride20Apr, _ := ride.ParseFile(parser, "examples/activity_18880605641.gpx") - for _, r := range []*ride.Ride{&ride19Jan, &ride30Mar, &ride20Apr} { - slog.Info("Ride", "difficulty", r.DifficultyScore()) - } - passes, passesErr := mountain_pass.LoadMountainPasses(db) - if passesErr != nil { - slog.Warn("No mountain passes in database, skipping crossings and climb naming", "error", passesErr) - passes = nil - } else { - rides := []struct { - name string - ride ride.Ride - }{ - {"2022-07-21.Pogacar", func() ride.Ride { - r, _ := ride.ParseFile(parser, "examples/2022-07-21.Pogacar.gpx") - return r - }()}, - {"2023-06-17.AlpesVerdonTour", func() ride.Ride { - r, _ := ride.ParseFile(parser, "examples/2023-06-17.AlpesVerdonTour.gpx") - return r - }()}, - {"2024-12-29.MimetArbois", func() ride.Ride { - r, _ := ride.ParseFile(parser, "examples/2024-12-29.MimetArbois.gpx") - return r - }()}, - {"activity_18043356988", ride19Jan}, - {"activity_18679866717", ride30Mar}, - {"activity_18880605641", ride20Apr}, - } - for _, entry := range rides { - crossings := mountain_pass.DetectCrossings(entry.ride, passes, 100, 25) - if len(crossings) > 0 { - slog.Info("Crossings", "ride", entry.name, "count", len(crossings)) - for _, crossing := range crossings { - slog.Info("Crossing", "pass", crossing.String()) - } - } - } - } - - nameClimbs := func(climbs []ride.Climb) { - for i := range climbs { - if matched, ok := mountain_pass.MatchClimb(climbs[i], passes, 300, 50); ok { - climbs[i].Name = matched.Name - } - } - } - - verdon, _ := ride.ParseFile(parser, "examples/2023-06-17.AlpesVerdonTour.gpx") - verdonClimbs := verdon.AllClimbs() - nameClimbs(verdonClimbs) - for _, climb := range verdonClimbs { - slog.Info("Climb", "climb", climb, "elevation", climb.Top().ElevationM, "duration", climb.Duration(), "speed", climb.Speed()*3.6) - } - - index := ride.NewFlatRideClimbsIndex(30) - index.Insert(ride19Jan) - index.Insert(ride30Mar) - ride20AprilClimbs := ride20Apr.AllClimbs() - nameClimbs(ride20AprilClimbs) - for _, climb := range ride20AprilClimbs { - slog.Info("Climb", "climb", climb, "duration", climb.Duration(), "speed", climb.Speed()*3.6) - if len(index.Similar(ride20Apr, climb)) > 0 { - for _, similar := range index.Similar(ride20Apr, climb) { - slog.Info("Similar", "climb", similar, "duration", similar.Duration(), "speed", similar.Speed()*3.6) - } - } - } - ride30MarchClimbs := ride30Mar.AllClimbs() - nameClimbs(ride30MarchClimbs) - for _, climb := range ride30MarchClimbs { - slog.Info("Climb", "climb", climb, "duration", climb.Duration(), "speed", climb.Speed()*3.6) - } - ride.PlotScore(&ride30Mar, 61.2, 66.7, "Ride 30 Mar.png") + cli.Run(db, *configFile, appConfig) } -- cgit v1.2.3