package routes import ( "context" "encoding/json" "errors" "fmt" "io" "math" "net/http" "net/url" "slices" "strconv" "strings" "sync" "time" ) // BRouter's round trips come out about this many times longer than the // roundTripDistance asked for (it's closer to a radius than a length). const brouterStretch = 5.1 var brouterProfile = map[Kind]string{Foot: "hiking-mountain", Bike: "trekking"} // Loops asks BRouter for round trips of about targetKm that start and end // at p, heading out in three directions. The first heads out into the wind // (windFrom, in degrees) so the way home is the easy part. The start point // is rounded to ~100 m before it leaves the machine. func (c *Client) Loops(ctx context.Context, kind Kind, p Point, targetKm float64, windFrom int) ([]Suggestion, error) { if c.BRouterURL == "" || c.BRouterURL == "-" || targetKm <= 0 { return nil, nil } p = Point{math.Round(p.Lat*1000) / 1000, math.Round(p.Lon*1000) / 1000} key := fmt.Sprintf("%s_%.3f_%.3f_%.1f_%d", kind, p.Lat, p.Lon, targetKm, windFrom/30) c.mu.Lock() c.initLocked() if hit, ok := c.loops[key]; ok && time.Since(hit.at) < cacheFor { c.mu.Unlock() return hit.s, nil } c.mu.Unlock() type result struct { r *Route err error } results := make([]result, 3) var wg sync.WaitGroup for i := range results { wg.Go(func() { dir := (windFrom + i*120) % 360 r, err := c.roundTrip(ctx, kind, p, targetKm, dir) results[i] = result{r, err} }) } wg.Wait() var out []Suggestion var errs []error for _, res := range results { if res.err != nil { errs = append(errs, res.err) continue } out = append(out, Suggestion{Route: res.r, Plan: "loop", Laps: 1, TotalKm: res.r.LengthKm, JoinAt: p}) } if len(out) == 0 { return nil, fmt.Errorf("brouter: %w", errors.Join(errs...)) } // Closest to the target first; into-the-wind wins ties within 10%. slices.SortStableFunc(out, func(a, b Suggestion) int { da, db := math.Abs(a.TotalKm-targetKm), math.Abs(b.TotalKm-targetKm) if math.Abs(da-db) < 0.1*targetKm { return 0 } if da < db { return -1 } return 1 }) c.mu.Lock() c.loops[key] = cachedLoops{out, time.Now()} for _, s := range out { c.byID[s.Route.ID] = s.Route } c.mu.Unlock() return out, nil } type cachedLoops struct { s []Suggestion at time.Time } func (c *Client) roundTrip(ctx context.Context, kind Kind, p Point, targetKm float64, dir int) (*Route, error) { q := url.Values{ "lonlats": {fmt.Sprintf("%.3f,%.3f", p.Lon, p.Lat)}, "profile": {brouterProfile[kind]}, "alternativeidx": {"0"}, "format": {"geojson"}, "engineMode": {"4"}, // round trip "roundTripDistance": {strconv.Itoa(int(targetKm * 1000 / brouterStretch))}, "direction": {strconv.Itoa(dir)}, } req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BRouterURL+"?"+q.Encode(), nil) if err != nil { return nil, err } req.Header.Set("User-Agent", userAgent) hc := c.HTTP if hc == nil { hc = http.DefaultClient } resp, err := hc.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { msg, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) return nil, fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(msg))) } r, err := parseBRouter(resp.Body) if err != nil { return nil, err } r.Kind, r.Heading = kind, dir r.ID = c.nextLoopID() r.Name = fmt.Sprintf("%.1f km loop heading %s", r.LengthKm, compass(dir)) return r, nil } // nextLoopID hands out negative ids so generated loops never collide with // OpenStreetMap relation ids. func (c *Client) nextLoopID() int64 { c.mu.Lock() defer c.mu.Unlock() c.loopSeq-- return c.loopSeq } func parseBRouter(r io.Reader) (*Route, error) { var out struct { Features []struct { Properties struct { Length string `json:"track-length"` Ascent string `json:"filtered ascend"` Messages [][]string `json:"messages"` } `json:"properties"` Geometry struct { Coordinates [][]float64 `json:"coordinates"` // lon, lat, elevation } `json:"geometry"` } `json:"features"` } if err := json.NewDecoder(r).Decode(&out); err != nil { return nil, fmt.Errorf("decode brouter: %w", err) } if len(out.Features) == 0 || len(out.Features[0].Geometry.Coordinates) < 2 { return nil, errors.New("brouter: empty route") } f := out.Features[0] m, _ := strconv.Atoi(f.Properties.Length) ascent, _ := strconv.Atoi(f.Properties.Ascent) pts := make([]Point, len(f.Geometry.Coordinates)) for i, c := range f.Geometry.Coordinates { pts[i] = Point{c[1], c[0]} } return &Route{ LengthKm: math.Round(float64(m)/100) / 10, Loop: true, Generated: true, AscentM: ascent, PavedPct: pavedPct(f.Properties.Messages), Ways: [][]Point{simplify(pts, 0.015)}, }, nil } var ( pavedSurfaces = []string{"asphalt", "paved", "concrete", "concrete:plates", "concrete:lanes", "paving_stones", "sett", "metal", "wood"} unpavedHighways = []string{"track", "path", "bridleway"} ) // pavedPct estimates how much of the route is paved from BRouter's per- // segment way tags: the surface tag when there is one, else the road type. func pavedPct(msgs [][]string) int { if len(msgs) < 2 { return -1 } dist, tags := slices.Index(msgs[0], "Distance"), slices.Index(msgs[0], "WayTags") if dist < 0 || tags < 0 { return -1 } var paved, total float64 for _, row := range msgs[1:] { if len(row) <= max(dist, tags) { continue } d, _ := strconv.ParseFloat(row[dist], 64) kv := map[string]string{} for _, f := range strings.Fields(row[tags]) { k, v, _ := strings.Cut(f, "=") kv[k] = v } total += d if s, ok := kv["surface"]; ok { if slices.Contains(pavedSurfaces, s) { paved += d } } else if !slices.Contains(unpavedHighways, kv["highway"]) { paved += d } } if total == 0 { return -1 } return int(math.Round(100 * paved / total)) } func compass(deg int) string { dirs := []string{"north", "north-east", "east", "south-east", "south", "south-west", "west", "north-west"} return dirs[((deg%360+360)%360+22)/45%8] } // IntoWind reports whether a loop heads out roughly into the wind, so the // way home has a tailwind. func (r *Route) IntoWind(windFrom int) bool { if !r.Generated { return false } d := ((r.Heading-windFrom)%360 + 360) % 360 return d <= 45 || d >= 315 }