- DESIGN.md: full design incl. driving-trip requirements (R1-R4), stays model, focus mode, mobile, provenance rules - SURVEY.md: open-source landscape - mock/: interaction mock (Florence itinerary, focus mode, stays, region stops, mobile layout) - router/: Go module (stdlib-only) with Router interface (Valhalla + OSRM backends), stop_cost, optimize_stops, corridor, routectl CLI, bench (5 real NE-corridor tasks, 26 checks passing), integration tests, and setup-osrm.sh for the self-hosted router - osm/: NH+MA+CT+NY PBFs (gitignored) + setup artifacts
219 lines
5.8 KiB
Go
219 lines
5.8 KiB
Go
// routectl is the manual/CI front end for the router + plan primitives.
|
|
//
|
|
// Usage:
|
|
//
|
|
// routectl route --backend valhalla --from 44.054,-71.650 --to 40.729,-73.966
|
|
// routectl stopcost --backend valhalla --from A --to C --stop 42.277,-71.806:60:CascadeFalls
|
|
// routectl optimize --backend valhalla --from A --to C --k 2 \
|
|
// --stop 42.277,-71.806:60:CascadeFalls --stop 43.507,-71.548:50:Ladd
|
|
//
|
|
// stop format: lat,lon[:dwellMin[:name]]
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"maps/router/internal/geo"
|
|
"maps/router/internal/plan"
|
|
"maps/router/internal/route"
|
|
)
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
usage()
|
|
}
|
|
var err error
|
|
switch os.Args[1] {
|
|
case "route":
|
|
err = cmdRoute(os.Args[2:])
|
|
case "stopcost":
|
|
err = cmdStopCost(os.Args[2:])
|
|
case "optimize":
|
|
err = cmdOptimize(os.Args[2:])
|
|
default:
|
|
usage()
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "error:", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func usage() {
|
|
fmt.Fprintln(os.Stderr, "usage: routectl {route|stopcost|optimize} [flags]")
|
|
os.Exit(2)
|
|
}
|
|
|
|
type common struct {
|
|
backend string
|
|
from, to string
|
|
}
|
|
|
|
func (c *common) parse(fs *flag.FlagSet) {
|
|
fs.StringVar(&c.backend, "backend", "valhalla", "valhalla|osrm")
|
|
fs.StringVar(&c.from, "from", "", "lat,lon origin")
|
|
fs.StringVar(&c.to, "to", "", "lat,lon destination")
|
|
}
|
|
|
|
func (c *common) router() route.Router {
|
|
switch c.backend {
|
|
case "valhalla":
|
|
return route.NewValhalla("https://valhalla1.openstreetmap.de")
|
|
case "osrm":
|
|
return route.NewOSRM("http://localhost:5000")
|
|
default:
|
|
panic("bad backend " + c.backend)
|
|
}
|
|
}
|
|
|
|
func parsePt(s string) (lat, lon float64) {
|
|
parts := strings.Split(s, ",")
|
|
lat, _ = strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
|
|
lon, _ = strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
|
return
|
|
}
|
|
|
|
func point(s string) geo.Point {
|
|
lat, lon := parsePt(s)
|
|
return geo.Point{Lat: lat, Lon: lon}
|
|
}
|
|
|
|
func parseStops(args []string) []plan.Stop {
|
|
var stops []plan.Stop
|
|
for _, a := range args {
|
|
parts := strings.Split(a, ":")
|
|
lat, lon := parsePt(parts[0])
|
|
s := plan.Stop{At: geo.Point{Lat: lat, Lon: lon}, ID: parts[0]}
|
|
if len(parts) > 1 && parts[1] != "" {
|
|
s.DwellMin, _ = strconv.Atoi(parts[1])
|
|
}
|
|
if len(parts) > 2 && parts[2] != "" {
|
|
s.Name = parts[2]
|
|
s.ID = parts[2]
|
|
}
|
|
stops = append(stops, s)
|
|
}
|
|
return stops
|
|
}
|
|
|
|
func fmtDur(sec float64) string {
|
|
m := int(0.5 + sec/60)
|
|
return fmt.Sprintf("%dh%02dm", m/60, m%60)
|
|
}
|
|
|
|
// ---- commands -------------------------------------------------------
|
|
|
|
func cmdRoute(args []string) error {
|
|
fs := flag.NewFlagSet("route", flag.ExitOnError)
|
|
var c common
|
|
c.parse(fs)
|
|
var walk bool
|
|
fs.BoolVar(&walk, "walk", false, "walking profile")
|
|
fs.Parse(args)
|
|
if c.from == "" || c.to == "" {
|
|
return fmt.Errorf("--from and --to required")
|
|
}
|
|
r, err := c.router().Route(context.Background(), prof(walk), []geo.Point{point(c.from), point(c.to)})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("backend: %s\n", c.router().Name())
|
|
fmt.Printf("time: %s (%.0f s)\n", fmtDur(r.Duration), r.Duration)
|
|
fmt.Printf("distance: %.1f km\n", r.Distance/1000)
|
|
if len(r.Geometry) > 0 {
|
|
fmt.Printf("geometry: %d points (exact corridor available)\n", len(r.Geometry))
|
|
} else {
|
|
fmt.Printf("geometry: none (chord-corridor fallback, low confidence)\n")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func cmdStopCost(args []string) error {
|
|
fs := flag.NewFlagSet("stopcost", flag.ExitOnError)
|
|
var c common
|
|
c.parse(fs)
|
|
var stopArgs []string
|
|
fs.Func("stop", "stop lat,lon[:dwellMin[:name]]", func(v string) error {
|
|
stopArgs = append(stopArgs, v)
|
|
return nil
|
|
})
|
|
fs.Parse(args)
|
|
if c.from == "" || c.to == "" || len(stopArgs) == 0 {
|
|
return fmt.Errorf("--from, --to, and --stop required")
|
|
}
|
|
stops := parseStops(stopArgs)
|
|
m, err := buildMatrix(c.router(), c.from, c.to, stops)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i, s := range stops {
|
|
cost := plan.StopCost(m, i, s)
|
|
fmt.Printf("%-18s detour=%3d min dwell=%3d min overhead=%2d min total=%3d min (direct %d min)\n",
|
|
s.ID, cost.DetourMin, cost.DwellMin, cost.OverheadMin, cost.TotalMin, cost.DirectMin)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func cmdOptimize(args []string) error {
|
|
fs := flag.NewFlagSet("optimize", flag.ExitOnError)
|
|
var c common
|
|
c.parse(fs)
|
|
var stopArgs []string
|
|
var k, budget int
|
|
fs.Func("stop", "stop lat,lon[:dwellMin[:name]]", func(v string) error {
|
|
stopArgs = append(stopArgs, v)
|
|
return nil
|
|
})
|
|
fs.IntVar(&k, "k", 2, "number of stops")
|
|
fs.IntVar(&budget, "budget", 0, "max total minutes (0 = none)")
|
|
fs.Parse(args)
|
|
if c.from == "" || c.to == "" || len(stopArgs) == 0 {
|
|
return fmt.Errorf("--from, --to, and --stop required")
|
|
}
|
|
stops := parseStops(stopArgs)
|
|
m, err := buildMatrix(c.router(), c.from, c.to, stops)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res := plan.OptimizeStops(m, stops, k, budget)
|
|
names := make([]string, len(res.Order))
|
|
for i, si := range res.Order {
|
|
names[i] = stops[si].ID
|
|
}
|
|
fmt.Printf("order: %s\n", strings.Join(names, " -> "))
|
|
fmt.Printf("detour: %d min\n", res.DetourMin)
|
|
fmt.Printf("total: %d min (budget %d, feasible=%v, relaxed=%v, exhaustive=%v)\n",
|
|
res.TotalMin, budget, res.Feasible, res.Relaxed, res.Exhaustive)
|
|
return nil
|
|
}
|
|
|
|
// ---- helpers --------------------------------------------------------
|
|
|
|
func prof(walk bool) route.Profile {
|
|
if walk {
|
|
return route.ProfileWalk
|
|
}
|
|
return route.ProfileDrive
|
|
}
|
|
|
|
// buildMatrix builds the (n+2)-layout matrix: stops, then A, then C.
|
|
func buildMatrix(r route.Router, fromS, toS string, stops []plan.Stop) (route.Matrix, error) {
|
|
pts := make([]geo.Point, 0, len(stops)+2)
|
|
for _, s := range stops {
|
|
pts = append(pts, s.At)
|
|
}
|
|
pts = append(pts, point(fromS), point(toS))
|
|
// Politeness delay for hosted backends.
|
|
delay := 150 * time.Millisecond
|
|
if _, ok := r.(*route.OSRM); ok {
|
|
delay = 0
|
|
}
|
|
return route.BuildMatrix(context.Background(), r, route.ProfileDrive, pts, delay)
|
|
}
|