Sprint 8: 2GIS tiles + places (бары/рестораны) nearby
- internal/places/overpass.go: запрос к Overpass API (OSM), bar/pub/restaurant/cafe/biergarten/nightclub - internal/handlers/places.go: GET /api/v1/places/nearby (radius, limit) - POST через application/x-www-form-urlencoded (раньше был broken — query params) - 2GIS tile layer в map.js (вместо OSM) - Кнопка 'Показать/Скрыть бары' + список под картой - Invite to place: шлёт предложение встретиться в чат с собеседником - Карточки с эмодзи по типу заведения, расстояние, время работы
This commit is contained in:
parent
0f9e0ce596
commit
748b4a31fb
@ -105,6 +105,7 @@ func main() {
|
||||
reviewH := handlers.NewReviewHandlers(reviewRepo, chatRepo, auditRepo)
|
||||
consentH := handlers.NewConsentHandlers(consentRepo, auditRepo)
|
||||
photoH := handlers.NewPhotoHandlers(usersRepo, st, auditRepo)
|
||||
placesH := handlers.NewPlacesHandler()
|
||||
|
||||
_ = rdb
|
||||
|
||||
@ -202,6 +203,7 @@ func main() {
|
||||
protected.Post("/me/photo", photoH.UploadPhoto)
|
||||
protected.Get("/search/nearby", h.SearchNearby)
|
||||
protected.Get("/users/:id", h.GetUserPublic)
|
||||
protected.Get("/places/nearby", placesH.Nearby)
|
||||
|
||||
protected.Post("/chats", chatH.EnsureChat)
|
||||
protected.Get("/chats", chatH.ListChats)
|
||||
|
||||
51
internal/handlers/places.go
Normal file
51
internal/handlers/places.go
Normal file
@ -0,0 +1,51 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/buhapp/backend/internal/places"
|
||||
)
|
||||
|
||||
type PlacesHandler struct {
|
||||
Places *places.Client
|
||||
}
|
||||
|
||||
func NewPlacesHandler() *PlacesHandler {
|
||||
return &PlacesHandler{Places: places.New()}
|
||||
}
|
||||
|
||||
// GET /api/v1/places/nearby?lat=..&lng=..&radius=..&limit=..
|
||||
func (h *PlacesHandler) Nearby(c *fiber.Ctx) error {
|
||||
lat, err := strconv.ParseFloat(c.Query("lat"), 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "lat required"})
|
||||
}
|
||||
lng, err := strconv.ParseFloat(c.Query("lng"), 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "lng required"})
|
||||
}
|
||||
radius, _ := strconv.ParseFloat(c.Query("radius", "3"), 64)
|
||||
if radius < 0.5 {
|
||||
radius = 0.5
|
||||
}
|
||||
if radius > 25 {
|
||||
radius = 25
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.Query("limit", "30"))
|
||||
|
||||
results, err := h.Places.NearbyPlaces(c.UserContext(), lat, lng, radius, limit)
|
||||
if err != nil {
|
||||
// не критично — отдаём пустой массив с сообщением
|
||||
return c.JSON(fiber.Map{
|
||||
"places": []places.Place{},
|
||||
"error": err.Error(),
|
||||
"count": 0,
|
||||
})
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"places": results,
|
||||
"count": len(results),
|
||||
})
|
||||
}
|
||||
143
internal/places/overpass.go
Normal file
143
internal/places/overpass.go
Normal file
@ -0,0 +1,143 @@
|
||||
package places
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Place — бар/ресторан/кафе в радиусе
|
||||
type Place struct {
|
||||
OSMID int64 `json:"osm_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Cuisine string `json:"cuisine,omitempty"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Distance float64 `json:"distance_m"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
Hours string `json:"hours,omitempty"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
endpoint string
|
||||
}
|
||||
|
||||
func New() *Client {
|
||||
return &Client{
|
||||
httpClient: &http.Client{Timeout: 8 * time.Second},
|
||||
endpoint: "https://overpass-api.de/api/interpreter",
|
||||
}
|
||||
}
|
||||
|
||||
// NearbyPlaces — бары/рестораны/кафе рядом (Overpass API)
|
||||
func (c *Client) NearbyPlaces(ctx context.Context, lat, lng, radiusKm float64, limit int) ([]Place, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 30
|
||||
}
|
||||
// bounding box
|
||||
latDelta := radiusKm / 111.0
|
||||
lngDelta := radiusKm / (111.0 * math.Cos(lat*math.Pi/180))
|
||||
south := lat - latDelta
|
||||
north := lat + latDelta
|
||||
west := lng - lngDelta
|
||||
east := lng + lngDelta
|
||||
|
||||
query := fmt.Sprintf(`[out:json][timeout:8];
|
||||
(
|
||||
node["amenity"~"bar|pub|restaurant|cafe|biergarten|nightclub"](%f,%f,%f,%f);
|
||||
way["amenity"~"bar|pub|restaurant|cafe|biergarten|nightclub"](%f,%f,%f,%f);
|
||||
);
|
||||
out center %d;`, south, west, north, east, south, west, north, east, limit)
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("data", query)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "BuhApp/0.7 (https://buhapp.mygoodservice.ru)")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("overpass status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var raw struct {
|
||||
Elements []struct {
|
||||
Type string `json:"type"`
|
||||
ID int64 `json:"id"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
Center *struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
} `json:"center"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
} `json:"elements"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]Place, 0, len(raw.Elements))
|
||||
for _, e := range raw.Elements {
|
||||
plat, plng := e.Lat, e.Lon
|
||||
if e.Center != nil {
|
||||
plat, plng = e.Center.Lat, e.Center.Lon
|
||||
}
|
||||
if plat == 0 && plng == 0 {
|
||||
continue
|
||||
}
|
||||
name := e.Tags["name"]
|
||||
if name == "" {
|
||||
name = e.Tags["name:ru"]
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
// расстояние от точки юзера
|
||||
dist := haversine(lat, lng, plat, plng) * 1000
|
||||
if dist > radiusKm*1000 {
|
||||
continue
|
||||
}
|
||||
out = append(out, Place{
|
||||
OSMID: e.ID,
|
||||
Name: name,
|
||||
Type: e.Tags["amenity"],
|
||||
Cuisine: e.Tags["cuisine"],
|
||||
Lat: plat,
|
||||
Lng: plng,
|
||||
Distance: dist,
|
||||
Address: e.Tags["addr:full"],
|
||||
Phone: e.Tags["phone"],
|
||||
Website: e.Tags["website"],
|
||||
Hours: e.Tags["opening_hours"],
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func haversine(lat1, lng1, lat2, lng2 float64) float64 {
|
||||
const R = 6371.0
|
||||
dLat := (lat2 - lat1) * math.Pi / 180
|
||||
dLng := (lng2 - lng1) * math.Pi / 180
|
||||
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
||||
math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*math.Sin(dLng/2)*math.Sin(dLng/2)
|
||||
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||
return R * c
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user