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)
|
reviewH := handlers.NewReviewHandlers(reviewRepo, chatRepo, auditRepo)
|
||||||
consentH := handlers.NewConsentHandlers(consentRepo, auditRepo)
|
consentH := handlers.NewConsentHandlers(consentRepo, auditRepo)
|
||||||
photoH := handlers.NewPhotoHandlers(usersRepo, st, auditRepo)
|
photoH := handlers.NewPhotoHandlers(usersRepo, st, auditRepo)
|
||||||
|
placesH := handlers.NewPlacesHandler()
|
||||||
|
|
||||||
_ = rdb
|
_ = rdb
|
||||||
|
|
||||||
@ -202,6 +203,7 @@ func main() {
|
|||||||
protected.Post("/me/photo", photoH.UploadPhoto)
|
protected.Post("/me/photo", photoH.UploadPhoto)
|
||||||
protected.Get("/search/nearby", h.SearchNearby)
|
protected.Get("/search/nearby", h.SearchNearby)
|
||||||
protected.Get("/users/:id", h.GetUserPublic)
|
protected.Get("/users/:id", h.GetUserPublic)
|
||||||
|
protected.Get("/places/nearby", placesH.Nearby)
|
||||||
|
|
||||||
protected.Post("/chats", chatH.EnsureChat)
|
protected.Post("/chats", chatH.EnsureChat)
|
||||||
protected.Get("/chats", chatH.ListChats)
|
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