// Package traffic implements the live-traffic pipeline: external incident // feeds → OSM node-pair speeds → OSRM segment-speed CSV → re-customize. // // The matcher works on the OSM side (Overpass returns OSM node IDs directly), // so no OSRM binary parsing is needed: the segment-speed CSV is keyed by OSM // node pairs, exactly what the OSRM updater consumes. package traffic import ( "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "maps/router/internal/geo" ) const defaultOverpassURL = "https://overpass-api.de/api/interpreter" // WayNode is one OSM node on a way. type WayNode struct { ID int64 `json:"id"` Lat float64 `json:"lat"` Lon float64 `json:"lon"` } // Way is a highway way with its full node list. type Way struct { ID int64 `json:"id"` Highway string `json:"highway"` Ref string `json:"ref"` Nodes []WayNode `json:"-"` } // QueryWaysAround fetches highway ways within radiusM of (lat, lon). func QueryWaysAround(client *http.Client, overpassURL string, lat, lon, radiusM float64, timeoutSec int) ([]Way, error) { if client == nil { client = &http.Client{Timeout: time.Duration(timeoutSec+30) * time.Second} } if overpassURL == "" { overpassURL = defaultOverpassURL } ql := fmt.Sprintf("[out:json][timeout:%d];way(around:%d,%g,%g)[highway];out geom;", timeoutSec, int(radiusM), lat, lon) req, err := http.NewRequest(http.MethodPost, overpassURL, strings.NewReader(url.Values{"data": {ql}}.Encode())) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") // public Overpass WAFs reject the default Go user agent req.Header.Set("User-Agent", "voyage-router/0.1 (traffic pipeline; contact: gmp)") resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("overpass: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20)) if err != nil { return nil, fmt.Errorf("overpass read: %w", err) } if resp.StatusCode != 200 { return nil, fmt.Errorf("overpass: status %d: %s", resp.StatusCode, truncate(string(body), 200)) } var doc struct { Elements []struct { Type string `json:"type"` ID int64 `json:"id"` Tags map[string]string `json:"tags"` Geom []struct { Lat float64 `json:"lat"` Lon float64 `json:"lon"` } `json:"geometry"` NodeIDs []int64 `json:"nodes"` // parallel to Geom } `json:"elements"` } if err := json.Unmarshal(body, &doc); err != nil { return nil, fmt.Errorf("overpass json: %w (body: %s)", err, truncate(string(body), 120)) } var ways []Way for _, e := range doc.Elements { if e.Type != "way" || len(e.Geom) < 2 || len(e.NodeIDs) != len(e.Geom) { continue } w := Way{ID: e.ID, Highway: e.Tags["highway"], Ref: e.Tags["ref"]} for i, g := range e.Geom { w.Nodes = append(w.Nodes, WayNode{ID: e.NodeIDs[i], Lat: g.Lat, Lon: g.Lon}) } ways = append(ways, w) } return ways, nil } // classRank orders highway classes by importance (higher = more likely the // intended road for an incident reported near it). func classRank(h string) int { switch h { case "motorway": return 7 case "trunk": return 6 case "primary": return 5 case "secondary": return 4 case "tertiary": return 3 case "unclassified": return 2 default: return 1 } } // classWindowM is the distance within which a higher-class road beats a // closer lower-class one: an incident reported "at I-90" means the motorway // even when a service road or footway is nearer. const classWindowM = 150.0 // ClosestNodePair finds the highway segment the incident is on and returns // its two OSM endpoint node IDs. Selection: within classWindowM, the // highest-class road wins (ties → nearest); beyond it, plain nearest. func ClosestNodePair(ways []Way, lat, lon float64) (way Way, nodeA, nodeB int64, distM float64, ok bool) { p := geo.Point{Lat: lat, Lon: lon} type seg struct { way Way a, b int64 distM float64 rank int } var best *seg for _, w := range ways { rank := classRank(w.Highway) for i := 0; i+1 < len(w.Nodes); i++ { d := geo.DistToPolylineMeters(p, []geo.Point{{Lat: w.Nodes[i].Lat, Lon: w.Nodes[i].Lon}, {Lat: w.Nodes[i+1].Lat, Lon: w.Nodes[i+1].Lon}}) if d > classWindowM && best != nil && best.distM <= classWindowM { continue } better := false if best == nil { better = true } else if rank > best.rank && d <= classWindowM { better = true } else if rank == best.rank && d < best.distM { better = true } else if rank < best.rank && best.distM > classWindowM && d < best.distM { better = true // nothing in the window; fall back to nearest } if better { best = &seg{way: w, a: w.Nodes[i].ID, b: w.Nodes[i+1].ID, distM: d, rank: rank} } } } if best == nil { return } return best.way, best.a, best.b, best.distM, true } // ExpandToRadius returns node pairs along the matched way covering ±radiusM // of the incident point (incidents affect a stretch, not a single edge). func ExpandToRadius(way Way, hitA, hitB int64, radiusM float64) [][2]int64 { found := -1 for i := 0; i+1 < len(way.Nodes); i++ { if way.Nodes[i].ID == hitA && way.Nodes[i+1].ID == hitB { found = i break } } if found < 0 { if hitA != 0 { return [][2]int64{{hitA, hitB}} } return nil } start := found back := 0.0 for start > 0 && back+segmentLen(way, start-1) <= radiusM { back += segmentLen(way, start - 1) start-- } end := found fwd := 0.0 for end+1 < len(way.Nodes)-1 && fwd+segmentLen(way, end+1) <= radiusM { fwd += segmentLen(way, end+1) end++ } var pairs [][2]int64 for i := start; i <= end; i++ { pairs = append(pairs, [2]int64{way.Nodes[i].ID, way.Nodes[i+1].ID}) } return pairs } func segmentLen(w Way, i int) float64 { return geo.Meters(geo.Point{Lat: w.Nodes[i].Lat, Lon: w.Nodes[i].Lon}, geo.Point{Lat: w.Nodes[i+1].Lat, Lon: w.Nodes[i+1].Lon}) } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "…" }