- Add travel-svc microservice (Amadeus, TravelPayouts, 2GIS, OpenRouteService) - Add travel orchestrator with parallel collectors (events, POI, hotels, flights) - Add 2GIS road routing with transport cost calculation (car/bus/taxi) - Add TravelMap (2GIS MapGL) and TravelWidgets components - Add useTravelChat hook for streaming travel agent responses - Add finance heatmap providers refactor - Add SearXNG settings, API proxy routes, Docker compose updates - Update Dockerfiles, config, types, and all UI pages for consistency Made-with: Cursor
336 lines
8.2 KiB
Go
336 lines
8.2 KiB
Go
package travel
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type OpenRouteClient struct {
|
|
apiKey string
|
|
baseURL string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func (c *OpenRouteClient) HasAPIKey() bool {
|
|
return c.apiKey != ""
|
|
}
|
|
|
|
type OpenRouteConfig struct {
|
|
APIKey string
|
|
BaseURL string
|
|
}
|
|
|
|
func NewOpenRouteClient(cfg OpenRouteConfig) *OpenRouteClient {
|
|
baseURL := cfg.BaseURL
|
|
if baseURL == "" {
|
|
baseURL = "https://api.openrouteservice.org"
|
|
}
|
|
|
|
return &OpenRouteClient{
|
|
apiKey: cfg.APIKey,
|
|
baseURL: baseURL,
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (c *OpenRouteClient) doRequest(ctx context.Context, method, path string, query url.Values) ([]byte, error) {
|
|
fullURL := c.baseURL + path
|
|
if len(query) > 0 {
|
|
fullURL += "?" + query.Encode()
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, fullURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Authorization", c.apiKey)
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("execute request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
return body, nil
|
|
}
|
|
|
|
func (c *OpenRouteClient) GetDirections(ctx context.Context, points []GeoLocation, profile string) (*RouteDirection, error) {
|
|
if len(points) < 2 {
|
|
return nil, fmt.Errorf("at least 2 points required")
|
|
}
|
|
|
|
if profile == "" {
|
|
profile = "driving-car"
|
|
}
|
|
|
|
coords := make([]string, len(points))
|
|
for i, p := range points {
|
|
coords[i] = fmt.Sprintf("%f,%f", p.Lng, p.Lat)
|
|
}
|
|
|
|
query := url.Values{}
|
|
query.Set("start", coords[0])
|
|
query.Set("end", coords[len(coords)-1])
|
|
|
|
body, err := c.doRequest(ctx, "GET", "/v2/directions/"+profile, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var response struct {
|
|
Features []struct {
|
|
Geometry struct {
|
|
Coordinates [][2]float64 `json:"coordinates"`
|
|
Type string `json:"type"`
|
|
} `json:"geometry"`
|
|
Properties struct {
|
|
Summary struct {
|
|
Distance float64 `json:"distance"`
|
|
Duration float64 `json:"duration"`
|
|
} `json:"summary"`
|
|
Segments []struct {
|
|
Distance float64 `json:"distance"`
|
|
Duration float64 `json:"duration"`
|
|
Steps []struct {
|
|
Instruction string `json:"instruction"`
|
|
Distance float64 `json:"distance"`
|
|
Duration float64 `json:"duration"`
|
|
Type int `json:"type"`
|
|
} `json:"steps"`
|
|
} `json:"segments"`
|
|
} `json:"properties"`
|
|
} `json:"features"`
|
|
}
|
|
|
|
if err := json.Unmarshal(body, &response); err != nil {
|
|
return nil, fmt.Errorf("unmarshal directions: %w", err)
|
|
}
|
|
|
|
if len(response.Features) == 0 {
|
|
return nil, fmt.Errorf("no route found")
|
|
}
|
|
|
|
feature := response.Features[0]
|
|
direction := &RouteDirection{
|
|
Geometry: RouteGeometry{
|
|
Coordinates: feature.Geometry.Coordinates,
|
|
Type: feature.Geometry.Type,
|
|
},
|
|
Distance: feature.Properties.Summary.Distance,
|
|
Duration: feature.Properties.Summary.Duration,
|
|
}
|
|
|
|
for _, seg := range feature.Properties.Segments {
|
|
for _, step := range seg.Steps {
|
|
direction.Steps = append(direction.Steps, RouteStep{
|
|
Instruction: step.Instruction,
|
|
Distance: step.Distance,
|
|
Duration: step.Duration,
|
|
Type: fmt.Sprintf("%d", step.Type),
|
|
})
|
|
}
|
|
}
|
|
|
|
return direction, nil
|
|
}
|
|
|
|
func (c *OpenRouteClient) Geocode(ctx context.Context, query string) (*GeoLocation, error) {
|
|
params := url.Values{}
|
|
params.Set("api_key", c.apiKey)
|
|
params.Set("text", query)
|
|
params.Set("size", "1")
|
|
|
|
body, err := c.doRequest(ctx, "GET", "/geocode/search", params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var response struct {
|
|
Features []struct {
|
|
Geometry struct {
|
|
Coordinates [2]float64 `json:"coordinates"`
|
|
} `json:"geometry"`
|
|
Properties struct {
|
|
Name string `json:"name"`
|
|
Country string `json:"country"`
|
|
Label string `json:"label"`
|
|
} `json:"properties"`
|
|
} `json:"features"`
|
|
}
|
|
|
|
if err := json.Unmarshal(body, &response); err != nil {
|
|
return nil, fmt.Errorf("unmarshal geocode: %w", err)
|
|
}
|
|
|
|
if len(response.Features) == 0 {
|
|
return nil, fmt.Errorf("location not found: %s", query)
|
|
}
|
|
|
|
feature := response.Features[0]
|
|
return &GeoLocation{
|
|
Lng: feature.Geometry.Coordinates[0],
|
|
Lat: feature.Geometry.Coordinates[1],
|
|
Name: feature.Properties.Name,
|
|
Country: feature.Properties.Country,
|
|
}, nil
|
|
}
|
|
|
|
func (c *OpenRouteClient) ReverseGeocode(ctx context.Context, lat, lng float64) (*GeoLocation, error) {
|
|
params := url.Values{}
|
|
params.Set("api_key", c.apiKey)
|
|
params.Set("point.lat", fmt.Sprintf("%f", lat))
|
|
params.Set("point.lon", fmt.Sprintf("%f", lng))
|
|
params.Set("size", "1")
|
|
|
|
body, err := c.doRequest(ctx, "GET", "/geocode/reverse", params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var response struct {
|
|
Features []struct {
|
|
Properties struct {
|
|
Name string `json:"name"`
|
|
Country string `json:"country"`
|
|
Label string `json:"label"`
|
|
} `json:"properties"`
|
|
} `json:"features"`
|
|
}
|
|
|
|
if err := json.Unmarshal(body, &response); err != nil {
|
|
return nil, fmt.Errorf("unmarshal reverse geocode: %w", err)
|
|
}
|
|
|
|
if len(response.Features) == 0 {
|
|
return nil, fmt.Errorf("location not found at %f,%f", lat, lng)
|
|
}
|
|
|
|
feature := response.Features[0]
|
|
return &GeoLocation{
|
|
Lat: lat,
|
|
Lng: lng,
|
|
Name: feature.Properties.Name,
|
|
Country: feature.Properties.Country,
|
|
}, nil
|
|
}
|
|
|
|
func (c *OpenRouteClient) SearchPOI(ctx context.Context, req POISearchRequest) ([]POI, error) {
|
|
params := url.Values{}
|
|
params.Set("api_key", c.apiKey)
|
|
params.Set("request", "pois")
|
|
params.Set("geometry", fmt.Sprintf(`{"geojson":{"type":"Point","coordinates":[%f,%f]},"buffer":%d}`, req.Lng, req.Lat, req.Radius))
|
|
|
|
if len(req.Categories) > 0 {
|
|
params.Set("filters", fmt.Sprintf(`{"category_ids":[%s]}`, strings.Join(req.Categories, ",")))
|
|
}
|
|
|
|
limit := req.Limit
|
|
if limit == 0 {
|
|
limit = 20
|
|
}
|
|
params.Set("limit", fmt.Sprintf("%d", limit))
|
|
|
|
body, err := c.doRequest(ctx, "POST", "/pois", params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var response struct {
|
|
Features []struct {
|
|
Geometry struct {
|
|
Coordinates [2]float64 `json:"coordinates"`
|
|
} `json:"geometry"`
|
|
Properties struct {
|
|
OSMId int64 `json:"osm_id"`
|
|
Name string `json:"osm_tags.name"`
|
|
Category struct {
|
|
ID int `json:"category_id"`
|
|
Name string `json:"category_name"`
|
|
} `json:"category"`
|
|
Distance float64 `json:"distance"`
|
|
} `json:"properties"`
|
|
} `json:"features"`
|
|
}
|
|
|
|
if err := json.Unmarshal(body, &response); err != nil {
|
|
return nil, fmt.Errorf("unmarshal POI: %w", err)
|
|
}
|
|
|
|
pois := make([]POI, 0, len(response.Features))
|
|
for _, f := range response.Features {
|
|
poi := POI{
|
|
ID: fmt.Sprintf("%d", f.Properties.OSMId),
|
|
Name: f.Properties.Name,
|
|
Lng: f.Geometry.Coordinates[0],
|
|
Lat: f.Geometry.Coordinates[1],
|
|
Category: f.Properties.Category.Name,
|
|
Distance: f.Properties.Distance,
|
|
}
|
|
pois = append(pois, poi)
|
|
}
|
|
|
|
return pois, nil
|
|
}
|
|
|
|
func (c *OpenRouteClient) GetIsochrone(ctx context.Context, lat, lng float64, timeMinutes int, profile string) (*RouteGeometry, error) {
|
|
if profile == "" {
|
|
profile = "driving-car"
|
|
}
|
|
|
|
params := url.Values{}
|
|
params.Set("api_key", c.apiKey)
|
|
params.Set("locations", fmt.Sprintf("%f,%f", lng, lat))
|
|
params.Set("range", fmt.Sprintf("%d", timeMinutes*60))
|
|
|
|
body, err := c.doRequest(ctx, "GET", "/v2/isochrones/"+profile, params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var response struct {
|
|
Features []struct {
|
|
Geometry struct {
|
|
Coordinates [][][2]float64 `json:"coordinates"`
|
|
Type string `json:"type"`
|
|
} `json:"geometry"`
|
|
} `json:"features"`
|
|
}
|
|
|
|
if err := json.Unmarshal(body, &response); err != nil {
|
|
return nil, fmt.Errorf("unmarshal isochrone: %w", err)
|
|
}
|
|
|
|
if len(response.Features) == 0 {
|
|
return nil, fmt.Errorf("no isochrone found")
|
|
}
|
|
|
|
coords := make([][2]float64, 0)
|
|
if len(response.Features[0].Geometry.Coordinates) > 0 {
|
|
coords = response.Features[0].Geometry.Coordinates[0]
|
|
}
|
|
|
|
return &RouteGeometry{
|
|
Coordinates: coords,
|
|
Type: response.Features[0].Geometry.Type,
|
|
}, nil
|
|
}
|