The targeted /search + /kinds approach is the only POI search now; nothing consumes embeddings. Drop: - poi_vec data + the 'vector' extension (DB) - embed/ (embedpoi), 20-vector.sql, db/ (the pgvector image workaround — compose goes back to plain postgis/postgis:16-3.4) - /semantic endpoint + embed client code from spatiald - EMBED_* env vars and README sections - .gitignore: ignore the spatial/ go build artifact
632 lines
18 KiB
Go
632 lines
18 KiB
Go
// spatiald — the PostGIS query service for the trip planner.
|
|
//
|
|
// Native spatial queries over the osm2pgsql-loaded OSM extracts:
|
|
//
|
|
// GET /health { ok, extracts: {name: poi_count} }
|
|
// GET /near?lat=&lng=&r= ST_DWithin radius search
|
|
// GET /nearest?lat=&lng= KNN (ORDER BY geom <-> point)
|
|
// GET /corridor?points=&r= corridor buffering along a route
|
|
// GET /search?kinds=&terms=&lat=&lng=&r= targeted tag+name search
|
|
// GET /kinds?q=&extract= the OSM tag vocabulary of the extracts
|
|
//
|
|
// Optional filters on the spatial three:
|
|
//
|
|
// extract=nh restrict to one loaded extract
|
|
// kind=amenity=restaurant exact kind match, or one of the bare values
|
|
// (restaurant, museum, cafe, …)
|
|
// name=café ILIKE substring on the POI name
|
|
// limit=20 default 20, max 200
|
|
//
|
|
// /search is the agent's main POI lookup: the LLM translates the user's
|
|
// concept into kinds (OSM tags) + terms (local-language name keywords) and
|
|
// the GIN kind/FTS/trigram indexes do the retrieval — no embedding model.
|
|
//
|
|
// /corridor's `points` is "lng,lat;lng,lat;…" (a router polyline, downsampled
|
|
// is fine — the buffer does the smoothing work).
|
|
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "github.com/lib/pq"
|
|
)
|
|
|
|
var (
|
|
dsn *sql.DB
|
|
addr string
|
|
)
|
|
|
|
type poi struct {
|
|
Extract string `json:"extract"`
|
|
OsmID int64 `json:"osm_id"`
|
|
OsmType string `json:"osm_type"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
OpenH *string `json:"opening_hours,omitempty"`
|
|
Fee *string `json:"fee,omitempty"`
|
|
Website *string `json:"website,omitempty"`
|
|
AddrCity *string `json:"addr_city,omitempty"`
|
|
Lat float64 `json:"lat"`
|
|
Lng float64 `json:"lng"`
|
|
DistM float64 `json:"dist_m"`
|
|
}
|
|
|
|
// ---- targeted search (/search, /kinds) ---------------------------------
|
|
// The LLM agent is the semantic layer: it translates the user's concept into
|
|
// OSM kinds ("historic=fort") and local-language name terms ("vino"). Postgres
|
|
// does the retrieval with indexed kind/FTS/trigram filters — no embedding
|
|
// model, no extra VRAM. /kinds exposes the real tag vocabulary so the agent
|
|
// grounds its choices in this extract instead of guessing.
|
|
|
|
// splitList: "a|b|c" → [a b c] (empty segments dropped)
|
|
func splitList(s string) []string {
|
|
var out []string
|
|
for _, p := range strings.Split(s, "|") {
|
|
if p = strings.TrimSpace(p); p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// kindCond → WHERE fragment for one kind token: "amenity=restaurant" (exact),
|
|
// a family name ("tourism"), or a bare tag value ("restaurant" → any family).
|
|
func kindCond(k string, param func(any) string) string {
|
|
if strings.Contains(k, "=") {
|
|
return "poi.kind = " + param(k)
|
|
}
|
|
if isFamily(k) {
|
|
return "poi.kind LIKE '" + k + "=%'" // family names are a closed list
|
|
}
|
|
v := param(k)
|
|
var parts []string
|
|
for _, fam := range []string{"amenity", "tourism", "shop", "leisure", "historic", "place"} {
|
|
parts = append(parts, "poi.kind = '"+fam+"='||"+v)
|
|
}
|
|
return "(" + strings.Join(parts, " OR ") + ")"
|
|
}
|
|
|
|
type searchHit struct {
|
|
poi
|
|
Score float64 `json:"score"`
|
|
}
|
|
|
|
func handleKinds(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
var conds []string
|
|
var args []any
|
|
param := func(a any) string {
|
|
args = append(args, a)
|
|
return "@@" + strconv.Itoa(len(args)) + "@@"
|
|
}
|
|
if e := first(q, "extract"); e != "" {
|
|
conds = append(conds, "poi.extract = "+param(e))
|
|
}
|
|
if s := first(q, "q"); s != "" { // substring filter on the tag string
|
|
conds = append(conds, "poi.kind ILIKE "+param("%"+s+"%"))
|
|
}
|
|
l, err := strconv.Atoi(first(q, "limit"))
|
|
if err != nil || l < 1 {
|
|
l = 40
|
|
}
|
|
if l > 200 {
|
|
l = 200
|
|
}
|
|
args = append(args, l)
|
|
limTok := "@@" + strconv.Itoa(len(args)) + "@@"
|
|
query := `SELECT kind, count(*) FROM poi` + whereJoin(conds) + `
|
|
GROUP BY kind ORDER BY count(*) DESC, kind LIMIT ` + limTok
|
|
rows, err := dsn.Query(renumber(query, 0), args...)
|
|
if err != nil {
|
|
jerr(w, 502, "query: "+err.Error())
|
|
return
|
|
}
|
|
type kc struct {
|
|
Kind string `json:"kind"`
|
|
Count int64 `json:"count"`
|
|
}
|
|
var out []kc
|
|
for rows.Next() {
|
|
var k kc
|
|
if err := rows.Scan(&k.Kind, &k.Count); err != nil {
|
|
rows.Close()
|
|
jerr(w, 502, err.Error())
|
|
return
|
|
}
|
|
out = append(out, k)
|
|
}
|
|
rows.Close()
|
|
if out == nil {
|
|
out = []kc{}
|
|
}
|
|
cors(w)
|
|
b, _ := json.Marshal(out)
|
|
fmt.Fprintf(w, `{"count":%d,"kinds":`, len(out))
|
|
w.Write(b)
|
|
w.Write([]byte("}"))
|
|
}
|
|
|
|
func whereJoin(conds []string) string {
|
|
if len(conds) == 0 {
|
|
return ""
|
|
}
|
|
return " WHERE " + strings.Join(conds, " AND ")
|
|
}
|
|
|
|
// /search?kinds=a|b&terms=x|y&lat=&lng=&r=&extract=&limit=
|
|
//
|
|
// kinds: OSM tags ("historic=fort", family "tourism", or bare "fort")
|
|
// terms: name keywords — give local-language variants ("wine|vino|cava")
|
|
// lat,lng: optional radius search (r default 10 km, max 50 km)
|
|
func handleSearch(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
kinds := splitList(first(q, "kinds"))
|
|
terms := splitList(first(q, "terms"))
|
|
if len(kinds) == 0 && len(terms) == 0 {
|
|
jerr(w, 400, "kinds and/or terms is required")
|
|
return
|
|
}
|
|
var conds []string
|
|
var args []any
|
|
param := func(a any) string {
|
|
args = append(args, a)
|
|
return "@@" + strconv.Itoa(len(args)) + "@@"
|
|
}
|
|
if e := first(q, "extract"); e != "" {
|
|
conds = append(conds, "poi.extract = "+param(e))
|
|
}
|
|
if len(kinds) > 0 {
|
|
parts := make([]string, len(kinds))
|
|
for i, k := range kinds {
|
|
parts[i] = kindCond(k, param)
|
|
}
|
|
conds = append(conds, "("+strings.Join(parts, " OR ")+")")
|
|
}
|
|
if len(terms) > 0 {
|
|
parts := make([]string, len(terms))
|
|
for i, t := range terms {
|
|
parts[i] = "poi.name ILIKE " + param("%"+t+"%") // gin_trgm index
|
|
}
|
|
conds = append(conds, "("+strings.Join(parts, " OR ")+")")
|
|
}
|
|
// score: max trigram similarity across the terms (1.0 when there are none)
|
|
var scoreExpr = "1.0"
|
|
if len(terms) > 0 {
|
|
parts := make([]string, len(terms))
|
|
for i, t := range terms {
|
|
parts[i] = "similarity(poi.name, " + param(t) + ")"
|
|
}
|
|
scoreExpr = "GREATEST(" + strings.Join(parts, ",") + ")"
|
|
}
|
|
// optional radius: rank by relevance first, distance as tiebreaker
|
|
spatial := first(q, "lat") != "" && first(q, "lng") != ""
|
|
if spatial {
|
|
lat, lng, err := point(q)
|
|
if err != nil {
|
|
jerr(w, 400, err.Error())
|
|
return
|
|
}
|
|
var rad float64 = 10000
|
|
if s := first(q, "r"); s != "" {
|
|
if rad, err = strconv.ParseFloat(s, 64); err != nil || rad < 50 || rad > 50000 {
|
|
jerr(w, 400, "r must be 50..50000 metres")
|
|
return
|
|
}
|
|
}
|
|
args = append(args, lat, lng, rad) // positions i-2, i-1, i
|
|
i := len(args)
|
|
// ST_MakePoint wants (lng, lat)
|
|
ptTok := "@@" + strconv.Itoa(i-1) + "@@" + ", @@" + strconv.Itoa(i-2) + "@@"
|
|
conds = append(conds, "ST_DWithin(poi.geom::geography, "+
|
|
"ST_SetSRID(ST_MakePoint("+ptTok+"), 4326)::geography, @@"+strconv.Itoa(i)+"@@)")
|
|
}
|
|
args = append(args, limitOf(q))
|
|
limTok := "@@" + strconv.Itoa(len(args)) + "@@"
|
|
var distExpr = "0 AS dist_m"
|
|
if spatial {
|
|
// limit was appended after lat/lng → they sit at len-3/len-2; MakePoint wants (lng, lat)
|
|
i := len(args) // limit is the last arg
|
|
distExpr = "ST_Distance(poi.geom::geography, ST_SetSRID(ST_MakePoint(@@" + strconv.Itoa(i-2) + "@@, @@" + strconv.Itoa(i-3) + "@@), 4326)::geography) AS dist_m"
|
|
}
|
|
order := " ORDER BY score DESC"
|
|
if spatial {
|
|
order += ", dist_m"
|
|
}
|
|
order += ", poi.kind, poi.name LIMIT " + limTok
|
|
query := `SELECT ` + poiCols + `, ` + scoreExpr + ` AS score, ` + distExpr + `
|
|
FROM poi` + whereJoin(conds) + order
|
|
rows, err := dsn.Query(renumber(query, 0), args...)
|
|
if err != nil {
|
|
jerr(w, 502, "query: "+err.Error())
|
|
return
|
|
}
|
|
var out []searchHit
|
|
for rows.Next() {
|
|
var h searchHit
|
|
if err := rows.Scan(&h.Extract, &h.OsmID, &h.OsmType, &h.Name, &h.Kind,
|
|
&h.OpenH, &h.Fee, &h.Website, &h.AddrCity, &h.Lat, &h.Lng, &h.Score, &h.DistM); err != nil {
|
|
rows.Close()
|
|
jerr(w, 502, err.Error())
|
|
return
|
|
}
|
|
h.Score = float64(int64(h.Score*1000)) / 1000
|
|
out = append(out, h)
|
|
}
|
|
rows.Close()
|
|
if out == nil {
|
|
out = []searchHit{}
|
|
}
|
|
cors(w)
|
|
b, _ := json.Marshal(out)
|
|
fmt.Fprintf(w, `{"count":%d,"results":`, len(out))
|
|
w.Write(b)
|
|
w.Write([]byte("}"))
|
|
}
|
|
|
|
func main() {
|
|
dsnFlag := flag.String("dsn",
|
|
"host=localhost port=5432 user=trips password=trips dbname=trips sslmode=disable",
|
|
"PostgreSQL/PostGIS DSN")
|
|
addrFlag := flag.String("addr", ":5005", "listen address")
|
|
flag.Parse()
|
|
if env := os.Getenv("SPATIAL_DSN"); env != "" {
|
|
*dsnFlag = env // docker-compose sets the in-network DSN
|
|
}
|
|
addr = *addrFlag
|
|
|
|
var err error
|
|
dsn, err = sql.Open("postgres", *dsnFlag)
|
|
if err != nil {
|
|
log.Fatalf("sql.Open: %v", err)
|
|
}
|
|
dsn.SetMaxOpenConns(8)
|
|
dsn.SetConnMaxLifetime(30 * time.Minute)
|
|
if err = dsn.Ping(); err != nil {
|
|
log.Printf("spatiald: DB not reachable yet (%v) — serving /health as down until it is", err)
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/health", handleHealth)
|
|
mux.HandleFunc("/near", handleNear)
|
|
mux.HandleFunc("/nearest", handleNearest)
|
|
mux.HandleFunc("/corridor", handleCorridor)
|
|
mux.HandleFunc("/search", handleSearch)
|
|
mux.HandleFunc("/kinds", handleKinds)
|
|
log.Printf("spatiald: listening on %s (dsn: %s)", addr, *dsnFlag)
|
|
srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
|
if err = srv.ListenAndServe(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func cors(w http.ResponseWriter) {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
}
|
|
|
|
func jerr(w http.ResponseWriter, code int, msg string) {
|
|
cors(w)
|
|
w.WriteHeader(code)
|
|
fmt.Fprintf(w, `{"error":%q}`, strings.ReplaceAll(msg, `"`, `\"`))
|
|
}
|
|
|
|
// common filters → (where clause, args). kind accepts "amenity=restaurant"
|
|
// (exact) or "restaurant" (match the tag value across all kind families).
|
|
// filters → (WHERE clause with @@i@@ placeholder tokens, args). Tokens are
|
|
// resolved to $i by renumber(); the query body's own $1..$4 placeholders
|
|
// would otherwise collide with the filter params.
|
|
func filters(q map[string][]string) (string, []any) {
|
|
var conds []string
|
|
var args []any
|
|
param := func(arg any) string {
|
|
args = append(args, arg)
|
|
return "@@" + strconv.Itoa(len(args)) + "@@"
|
|
}
|
|
if e := first(q, "extract"); e != "" {
|
|
conds = append(conds, "poi.extract = "+param(e))
|
|
}
|
|
if k := first(q, "kind"); k != "" {
|
|
if strings.Contains(k, "=") {
|
|
conds = append(conds, "poi.kind = "+param(k))
|
|
} else if isFamily(k) {
|
|
// bare family name: the whole family ("tourism", "leisure", …)
|
|
conds = append(conds, "poi.kind LIKE '"+k+"=%'")
|
|
} else {
|
|
// bare tag value: exact match in any kind family
|
|
v := param(k)
|
|
var parts []string
|
|
for _, fam := range []string{"amenity", "tourism", "shop", "leisure", "historic", "place"} {
|
|
parts = append(parts, "poi.kind = '"+fam+"='||"+v)
|
|
}
|
|
conds = append(conds, "("+strings.Join(parts, " OR ")+")")
|
|
}
|
|
}
|
|
if n := first(q, "name"); n != "" {
|
|
conds = append(conds, "poi.name ILIKE "+param("%"+n+"%"))
|
|
}
|
|
if len(conds) == 0 {
|
|
return "", args
|
|
}
|
|
return " WHERE " + strings.Join(conds, " AND "), args
|
|
}
|
|
func isFamily(k string) bool {
|
|
for _, f := range []string{"amenity", "tourism", "shop", "leisure", "historic", "place"} {
|
|
if k == f {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func first(q map[string][]string, k string) string {
|
|
if v := q[k]; len(v) > 0 {
|
|
return strings.TrimSpace(v[0])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func limitOf(q map[string][]string) int {
|
|
l, err := strconv.Atoi(first(q, "limit"))
|
|
if err != nil || l < 1 {
|
|
return 20
|
|
}
|
|
if l > 200 {
|
|
return 200
|
|
}
|
|
return l
|
|
}
|
|
|
|
// rows → pois; the SELECT must end with a distance column in metres.
|
|
func scanPois(rs *sql.Rows) ([]poi, error) {
|
|
var out []poi
|
|
for rs.Next() {
|
|
var p poi
|
|
if err := rs.Scan(&p.Extract, &p.OsmID, &p.OsmType, &p.Name, &p.Kind,
|
|
&p.OpenH, &p.Fee, &p.Website, &p.AddrCity, &p.Lat, &p.Lng, &p.DistM); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out, rs.Err()
|
|
}
|
|
|
|
func writePois(w http.ResponseWriter, pois []poi) {
|
|
if pois == nil {
|
|
pois = []poi{}
|
|
}
|
|
cors(w)
|
|
fmt.Fprintf(w, `{"count":%d,"results":`, len(pois))
|
|
b, _ := json.Marshal(pois)
|
|
w.Write(b)
|
|
w.Write([]byte("}"))
|
|
}
|
|
|
|
func handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
cors(w)
|
|
type ext struct {
|
|
Name string `json:"name"`
|
|
PoiCount *int64 `json:"poi_count"`
|
|
Imported *string `json:"imported_at,omitempty"`
|
|
}
|
|
healthy := dsn.Ping() == nil
|
|
var extracts []ext
|
|
if healthy {
|
|
rows, err := dsn.Query(`SELECT name, poi_count, to_char(imported_at, 'YYYY-MM-DD"T"HH24:MI:SSOF') FROM spatial_extract ORDER BY name`)
|
|
if err == nil {
|
|
for rows.Next() {
|
|
var e ext
|
|
rows.Scan(&e.Name, &e.PoiCount, &e.Imported)
|
|
extracts = append(extracts, e)
|
|
}
|
|
rows.Close()
|
|
}
|
|
}
|
|
if extracts == nil {
|
|
extracts = []ext{}
|
|
}
|
|
fmt.Fprintf(w, `{"ok":%v,"postgis":%v,"extracts":`, healthy, healthy)
|
|
b, _ := json.Marshal(extracts)
|
|
w.Write(b)
|
|
w.Write([]byte("}"))
|
|
}
|
|
|
|
// +lat/lng so the client can drop map pins on the results
|
|
const poiCols = `poi.extract, poi.osm_id, poi.osm_type, poi.name, poi.kind,
|
|
poi.opening_hours, poi.fee, poi.website, poi.addr_city,
|
|
ST_Y(poi.geom) AS lat, ST_X(poi.geom) AS lng`
|
|
|
|
func handleNear(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
lat, lng, err := point(q)
|
|
if err != nil {
|
|
jerr(w, 400, err.Error())
|
|
return
|
|
}
|
|
rad, err := radius(q)
|
|
if err != nil {
|
|
jerr(w, 400, err.Error())
|
|
return
|
|
}
|
|
where, args := filters(q)
|
|
args = append(args, lat, lng, rad, limitOf(q))
|
|
// point geometry first, then geography cast for the radius test, then KNN
|
|
// ordering among hits — ST_DWithin(geography) uses the GIST index.
|
|
query := `SELECT ` + poiCols + `,
|
|
ST_Distance(poi.geom::geography, ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography) AS dist_m
|
|
FROM poi` + where + `
|
|
AND ST_DWithin(poi.geom::geography, ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography, $3)
|
|
ORDER BY poi.geom <-> ST_SetSRID(ST_MakePoint($2, $1), 4326)
|
|
LIMIT $4`
|
|
query = renumber(query, len(args)-4)
|
|
rows, err := dsn.Query(query, args...)
|
|
if err != nil {
|
|
jerr(w, 502, "query: "+err.Error())
|
|
return
|
|
}
|
|
pois, err := scanPois(rows)
|
|
rows.Close()
|
|
if err != nil {
|
|
jerr(w, 502, err.Error())
|
|
return
|
|
}
|
|
writePois(w, pois)
|
|
}
|
|
|
|
func handleNearest(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
lat, lng, err := point(q)
|
|
if err != nil {
|
|
jerr(w, 400, err.Error())
|
|
return
|
|
}
|
|
where, args := filters(q)
|
|
args = append(args, lat, lng, limitOf(q))
|
|
query := `SELECT ` + poiCols + `,
|
|
ST_Distance(poi.geom::geography, ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography) AS dist_m
|
|
FROM poi` + where + `
|
|
ORDER BY poi.geom <-> ST_SetSRID(ST_MakePoint($2, $1), 4326)
|
|
LIMIT $3`
|
|
query = renumber(query, len(args)-3)
|
|
rows, err := dsn.Query(query, args...)
|
|
if err != nil {
|
|
jerr(w, 502, "query: "+err.Error())
|
|
return
|
|
}
|
|
pois, err := scanPois(rows)
|
|
rows.Close()
|
|
if err != nil {
|
|
jerr(w, 502, err.Error())
|
|
return
|
|
}
|
|
writePois(w, pois)
|
|
}
|
|
|
|
func handleCorridor(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
rad, err := radius(q)
|
|
if err != nil {
|
|
jerr(w, 400, err.Error())
|
|
return
|
|
}
|
|
pts := parsePoints(first(q, "points"))
|
|
if len(pts) < 2 {
|
|
jerr(w, 400, "points must be 'lng,lat;lng,lat;…' with at least 2 points")
|
|
return
|
|
}
|
|
where, args := filters(q)
|
|
var coords []string
|
|
for _, p := range pts {
|
|
coords = append(coords, fmt.Sprintf("%s %s", strconv.FormatFloat(p[0], 'f', 6, 64), strconv.FormatFloat(p[1], 'f', 6, 64))) // WKT: lng lat
|
|
}
|
|
line := "LINESTRING(" + strings.Join(coords, ",") + ")"
|
|
args = append(args, line, rad, limitOf(q))
|
|
// distance to a line uses ST_Distance(geography) (ST_Distance_Sphere is
|
|
// point-to-point only); the corridor is a buffered geography band.
|
|
query := `SELECT ` + poiCols + `,
|
|
ST_Distance(poi.geom::geography, ST_SetSRID(ST_GeomFromText($1), 4326)::geography) AS dist_m
|
|
FROM poi` + where + `
|
|
AND ST_DWithin(poi.geom::geography,
|
|
ST_Buffer(ST_SetSRID(ST_GeomFromText($1), 4326)::geography, $2), 0, true)
|
|
ORDER BY dist_m
|
|
LIMIT $3`
|
|
query = renumber(query, len(args)-3)
|
|
rows, err := dsn.Query(query, args...)
|
|
if err != nil {
|
|
jerr(w, 502, "query: "+err.Error())
|
|
return
|
|
}
|
|
pois, err := scanPois(rows)
|
|
rows.Close()
|
|
if err != nil {
|
|
jerr(w, 502, err.Error())
|
|
return
|
|
}
|
|
writePois(w, pois)
|
|
}
|
|
|
|
// point/radius parse the lat/lng (degrees) and r (metres) query params.
|
|
func point(q map[string][]string) (lat, lng float64, err error) {
|
|
lat, err = strconv.ParseFloat(first(q, "lat"), 64)
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("lat is required (decimal degrees)")
|
|
}
|
|
lng, err = strconv.ParseFloat(first(q, "lng"), 64)
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("lng is required (decimal degrees)")
|
|
}
|
|
if lat < -90 || lat > 90 || lng < -180 || lng > 180 {
|
|
return 0, 0, fmt.Errorf("lat/lng out of range")
|
|
}
|
|
return lat, lng, nil
|
|
}
|
|
|
|
func radius(q map[string][]string) (float64, error) {
|
|
s := first(q, "r")
|
|
if s == "" {
|
|
return 500, nil
|
|
}
|
|
v, err := strconv.ParseFloat(s, 64)
|
|
if err != nil || v < 50 || v > 50000 {
|
|
return 0, fmt.Errorf("r must be 50..50000 metres")
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// parsePoints: "lng,lat;lng,lat" → [[lng,lat],…] in that order.
|
|
func parsePoints(s string) [][2]float64 {
|
|
var out [][2]float64
|
|
for _, pair := range strings.Split(s, ";") {
|
|
pair = strings.TrimSpace(pair)
|
|
if pair == "" {
|
|
continue
|
|
}
|
|
xy := strings.Split(pair, ",")
|
|
if len(xy) != 2 {
|
|
continue
|
|
}
|
|
lng, e1 := strconv.ParseFloat(strings.TrimSpace(xy[0]), 64)
|
|
lat, e2 := strconv.ParseFloat(strings.TrimSpace(xy[1]), 64)
|
|
if e1 == nil && e2 == nil {
|
|
out = append(out, [2]float64{lng, lat})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// renumber resolves the placeholder scheme: the query body was written with
|
|
// $1..$4 for its own params (lat, lng, radius, limit) and the WHERE clause
|
|
// carries @@i@@ tokens for the n filter args, which come FIRST in the arg
|
|
// list. So: body $d → $(n+d), then @@i@@ → $i. n = number of filter args
|
|
// (= len(args) minus the body params, known at the call site).
|
|
func renumber(query string, n int) string {
|
|
var b strings.Builder
|
|
for i := 0; i < len(query); i++ {
|
|
if query[i] == '$' && i+1 < len(query) && query[i+1] >= '1' && query[i+1] <= '4' {
|
|
fmt.Fprintf(&b, "$%d", n+int(query[i+1]-'0'))
|
|
i++
|
|
continue
|
|
}
|
|
if strings.HasPrefix(query[i:], "@@") {
|
|
end := strings.Index(query[i+2:], "@@")
|
|
if end >= 0 {
|
|
if d, err := strconv.Atoi(query[i+2 : i+2+end]); err == nil {
|
|
fmt.Fprintf(&b, "$%d", d)
|
|
i += 2 + end + 1 // loop's i++ finishes the last '@'
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
b.WriteByte(query[i])
|
|
}
|
|
return b.String()
|
|
}
|