433 lines
11 KiB
Go
433 lines
11 KiB
Go
// backend\address_reverse.go
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type reverseAddressSuggestion struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
Lat float64 `json:"lat"`
|
|
Lon float64 `json:"lon"`
|
|
Type string `json:"type,omitempty"`
|
|
HasHouseNumber bool `json:"hasHouseNumber"`
|
|
GeoJSON json.RawMessage `json:"geojson,omitempty"`
|
|
}
|
|
|
|
type nominatimReverseAddress struct {
|
|
HouseNumber string `json:"house_number"`
|
|
Road string `json:"road"`
|
|
Footway string `json:"footway"`
|
|
Pedestrian string `json:"pedestrian"`
|
|
Path string `json:"path"`
|
|
Suburb string `json:"suburb"`
|
|
Quarter string `json:"quarter"`
|
|
CityDistrict string `json:"city_district"`
|
|
Borough string `json:"borough"`
|
|
City string `json:"city"`
|
|
Town string `json:"town"`
|
|
Village string `json:"village"`
|
|
Municipality string `json:"municipality"`
|
|
County string `json:"county"`
|
|
State string `json:"state"`
|
|
Postcode string `json:"postcode"`
|
|
Country string `json:"country"`
|
|
}
|
|
|
|
type nominatimReverseResponse struct {
|
|
PlaceID int64 `json:"place_id"`
|
|
OSMType string `json:"osm_type"`
|
|
OSMID int64 `json:"osm_id"`
|
|
DisplayName string `json:"display_name"`
|
|
Lat string `json:"lat"`
|
|
Lon string `json:"lon"`
|
|
Type string `json:"type"`
|
|
Address nominatimReverseAddress `json:"address"`
|
|
GeoJSON json.RawMessage `json:"geojson,omitempty"`
|
|
}
|
|
|
|
func appendUnique(parts []string, value string) []string {
|
|
value = strings.TrimSpace(value)
|
|
|
|
if value == "" {
|
|
return parts
|
|
}
|
|
|
|
for _, part := range parts {
|
|
if strings.EqualFold(part, value) {
|
|
return parts
|
|
}
|
|
}
|
|
|
|
return append(parts, value)
|
|
}
|
|
|
|
func formatReverseAddressLabel(result nominatimReverseResponse) string {
|
|
address := result.Address
|
|
|
|
street := firstNonEmpty(
|
|
address.Road,
|
|
address.Footway,
|
|
address.Pedestrian,
|
|
address.Path,
|
|
)
|
|
|
|
streetLine := street
|
|
|
|
if street != "" && strings.TrimSpace(address.HouseNumber) != "" {
|
|
streetLine = fmt.Sprintf("%s %s", street, strings.TrimSpace(address.HouseNumber))
|
|
}
|
|
|
|
city := firstNonEmpty(
|
|
address.City,
|
|
address.Town,
|
|
address.Village,
|
|
address.Municipality,
|
|
)
|
|
|
|
postcodeCity := city
|
|
|
|
if strings.TrimSpace(address.Postcode) != "" && city != "" {
|
|
postcodeCity = fmt.Sprintf("%s %s", strings.TrimSpace(address.Postcode), city)
|
|
}
|
|
|
|
parts := []string{}
|
|
|
|
parts = appendUnique(parts, streetLine)
|
|
parts = appendUnique(parts, postcodeCity)
|
|
parts = appendUnique(parts, address.State)
|
|
parts = appendUnique(parts, address.Country)
|
|
|
|
if len(parts) > 0 {
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
return strings.TrimSpace(result.DisplayName)
|
|
}
|
|
|
|
func isPolygonGeoJSON(value json.RawMessage) bool {
|
|
if len(value) == 0 || string(value) == "null" {
|
|
return false
|
|
}
|
|
|
|
var object struct {
|
|
Type string `json:"type"`
|
|
Geometry json.RawMessage `json:"geometry"`
|
|
Features []json.RawMessage `json:"features"`
|
|
Geometries []json.RawMessage `json:"geometries"`
|
|
}
|
|
|
|
if err := json.Unmarshal(value, &object); err != nil {
|
|
return false
|
|
}
|
|
|
|
switch object.Type {
|
|
case "Polygon", "MultiPolygon":
|
|
return true
|
|
case "Feature":
|
|
return isPolygonGeoJSON(object.Geometry)
|
|
case "FeatureCollection":
|
|
for _, feature := range object.Features {
|
|
if isPolygonGeoJSON(feature) {
|
|
return true
|
|
}
|
|
}
|
|
case "GeometryCollection":
|
|
for _, geometry := range object.Geometries {
|
|
if isPolygonGeoJSON(geometry) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func coordinateDistanceMeters(firstLat, firstLon, secondLat, secondLon float64) float64 {
|
|
const earthRadius = 6371000
|
|
|
|
firstLatitude := firstLat * math.Pi / 180
|
|
secondLatitude := secondLat * math.Pi / 180
|
|
latitudeDifference := (secondLat - firstLat) * math.Pi / 180
|
|
longitudeDifference := (secondLon - firstLon) * math.Pi / 180
|
|
|
|
a := math.Sin(latitudeDifference/2)*math.Sin(latitudeDifference/2) +
|
|
math.Cos(firstLatitude)*math.Cos(secondLatitude)*
|
|
math.Sin(longitudeDifference/2)*math.Sin(longitudeDifference/2)
|
|
|
|
return earthRadius * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
|
}
|
|
|
|
type addressPolygonLookupResult struct {
|
|
GeoJSON json.RawMessage
|
|
HasHouseNumber bool
|
|
}
|
|
|
|
func lookupAddressPolygon(
|
|
ctx context.Context,
|
|
query string,
|
|
latitude float64,
|
|
longitude float64,
|
|
) addressPolygonLookupResult {
|
|
query = strings.TrimSpace(query)
|
|
if query == "" {
|
|
return addressPolygonLookupResult{}
|
|
}
|
|
|
|
params := url.Values{}
|
|
params.Set("format", "jsonv2")
|
|
params.Set("addressdetails", "1")
|
|
params.Set("polygon_geojson", "1")
|
|
params.Set("limit", "10")
|
|
params.Set("accept-language", "de")
|
|
params.Set("q", query)
|
|
|
|
searchURL := fmt.Sprintf(
|
|
"https://nominatim.openstreetmap.org/search?%s",
|
|
params.Encode(),
|
|
)
|
|
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil)
|
|
if err != nil {
|
|
return addressPolygonLookupResult{}
|
|
}
|
|
|
|
request.Header.Set("Accept", "application/json")
|
|
request.Header.Set("User-Agent", addressSearchUserAgent())
|
|
|
|
response, err := httpClient().Do(request)
|
|
if err != nil {
|
|
return addressPolygonLookupResult{}
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
return addressPolygonLookupResult{}
|
|
}
|
|
|
|
var results []nominatimSearchResult
|
|
if err := json.NewDecoder(response.Body).Decode(&results); err != nil {
|
|
return addressPolygonLookupResult{}
|
|
}
|
|
|
|
var bestResult addressPolygonLookupResult
|
|
bestDistance := math.Inf(1)
|
|
|
|
for _, result := range results {
|
|
if !isPolygonGeoJSON(result.GeoJSON) {
|
|
continue
|
|
}
|
|
|
|
resultLatitude, latitudeErr := strconv.ParseFloat(result.Lat, 64)
|
|
resultLongitude, longitudeErr := strconv.ParseFloat(result.Lon, 64)
|
|
if latitudeErr != nil || longitudeErr != nil {
|
|
continue
|
|
}
|
|
|
|
distance := coordinateDistanceMeters(
|
|
latitude,
|
|
longitude,
|
|
resultLatitude,
|
|
resultLongitude,
|
|
)
|
|
if distance > 250 || distance >= bestDistance {
|
|
continue
|
|
}
|
|
|
|
bestDistance = distance
|
|
bestResult = addressPolygonLookupResult{
|
|
GeoJSON: result.GeoJSON,
|
|
HasHouseNumber: strings.TrimSpace(result.Address.HouseNumber) != "",
|
|
}
|
|
}
|
|
|
|
return bestResult
|
|
}
|
|
|
|
func normalizeOSMIdentifier(value string) string {
|
|
value = strings.ToUpper(strings.TrimSpace(value))
|
|
value = strings.ReplaceAll(value, ":", "")
|
|
|
|
if len(value) < 2 {
|
|
return ""
|
|
}
|
|
|
|
if value[0] != 'N' && value[0] != 'W' && value[0] != 'R' {
|
|
return ""
|
|
}
|
|
|
|
if _, err := strconv.ParseInt(value[1:], 10, 64); err != nil {
|
|
return ""
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
func lookupOSMPolygon(ctx context.Context, osmIdentifier string) addressPolygonLookupResult {
|
|
osmIdentifier = normalizeOSMIdentifier(osmIdentifier)
|
|
if osmIdentifier == "" {
|
|
return addressPolygonLookupResult{}
|
|
}
|
|
|
|
params := url.Values{}
|
|
params.Set("format", "jsonv2")
|
|
params.Set("polygon_geojson", "1")
|
|
params.Set("osm_ids", osmIdentifier)
|
|
|
|
lookupURL := fmt.Sprintf(
|
|
"https://nominatim.openstreetmap.org/lookup?%s",
|
|
params.Encode(),
|
|
)
|
|
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, lookupURL, nil)
|
|
if err != nil {
|
|
return addressPolygonLookupResult{}
|
|
}
|
|
|
|
request.Header.Set("Accept", "application/json")
|
|
request.Header.Set("User-Agent", addressSearchUserAgent())
|
|
|
|
response, err := httpClient().Do(request)
|
|
if err != nil {
|
|
return addressPolygonLookupResult{}
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
return addressPolygonLookupResult{}
|
|
}
|
|
|
|
var results []nominatimSearchResult
|
|
if err := json.NewDecoder(response.Body).Decode(&results); err != nil {
|
|
return addressPolygonLookupResult{}
|
|
}
|
|
|
|
for _, result := range results {
|
|
if isPolygonGeoJSON(result.GeoJSON) {
|
|
return addressPolygonLookupResult{
|
|
GeoJSON: result.GeoJSON,
|
|
HasHouseNumber: strings.TrimSpace(result.Address.HouseNumber) != "",
|
|
}
|
|
}
|
|
}
|
|
|
|
return addressPolygonLookupResult{}
|
|
}
|
|
|
|
func (s *Server) handleAddressReverse(w http.ResponseWriter, r *http.Request) {
|
|
latRaw := strings.TrimSpace(r.URL.Query().Get("lat"))
|
|
lonRaw := strings.TrimSpace(r.URL.Query().Get("lon"))
|
|
|
|
lat, err := strconv.ParseFloat(latRaw, 64)
|
|
if err != nil || lat < -90 || lat > 90 {
|
|
writeError(w, http.StatusBadRequest, "Ungültige Latitude")
|
|
return
|
|
}
|
|
|
|
lon, err := strconv.ParseFloat(lonRaw, 64)
|
|
if err != nil || lon < -180 || lon > 180 {
|
|
writeError(w, http.StatusBadRequest, "Ungültige Longitude")
|
|
return
|
|
}
|
|
|
|
params := url.Values{}
|
|
params.Set("format", "jsonv2")
|
|
params.Set("addressdetails", "1")
|
|
params.Set("polygon_geojson", "1")
|
|
params.Set("lat", strconv.FormatFloat(lat, 'f', 7, 64))
|
|
params.Set("lon", strconv.FormatFloat(lon, 'f', 7, 64))
|
|
params.Set("zoom", "18")
|
|
params.Set("accept-language", "de")
|
|
|
|
reverseURL := fmt.Sprintf(
|
|
"https://nominatim.openstreetmap.org/reverse?%s",
|
|
params.Encode(),
|
|
)
|
|
|
|
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, reverseURL, nil)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Adresse konnte nicht vorbereitet werden")
|
|
return
|
|
}
|
|
|
|
request.Header.Set("User-Agent", "TEG/1.0 address reverse lookup")
|
|
|
|
client := &http.Client{
|
|
Timeout: 8 * time.Second,
|
|
}
|
|
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "Adresse konnte nicht ermittelt werden")
|
|
return
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
writeError(w, http.StatusBadGateway, "Adresse konnte nicht ermittelt werden")
|
|
return
|
|
}
|
|
|
|
var result nominatimReverseResponse
|
|
|
|
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
|
writeError(w, http.StatusBadGateway, "Adresse konnte nicht gelesen werden")
|
|
return
|
|
}
|
|
|
|
label := formatReverseAddressLabel(result)
|
|
if label == "" {
|
|
writeError(w, http.StatusNotFound, "Für diese Position wurde keine Adresse gefunden")
|
|
return
|
|
}
|
|
|
|
geoJSON := result.GeoJSON
|
|
hasHouseNumber := strings.TrimSpace(result.Address.HouseNumber) != ""
|
|
if !isPolygonGeoJSON(geoJSON) {
|
|
lookupResult := lookupOSMPolygon(
|
|
r.Context(),
|
|
r.URL.Query().Get("osm_id"),
|
|
)
|
|
geoJSON = lookupResult.GeoJSON
|
|
hasHouseNumber = hasHouseNumber || lookupResult.HasHouseNumber
|
|
}
|
|
|
|
if !isPolygonGeoJSON(geoJSON) {
|
|
searchQuery := strings.TrimSpace(r.URL.Query().Get("q"))
|
|
if searchQuery == "" {
|
|
searchQuery = label
|
|
}
|
|
|
|
lookupResult := lookupAddressPolygon(
|
|
r.Context(),
|
|
searchQuery,
|
|
lat,
|
|
lon,
|
|
)
|
|
geoJSON = lookupResult.GeoJSON
|
|
hasHouseNumber = hasHouseNumber || lookupResult.HasHouseNumber
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"suggestion": reverseAddressSuggestion{
|
|
ID: fmt.Sprintf("reverse-%d-%d", result.PlaceID, time.Now().UnixNano()),
|
|
Label: label,
|
|
Lat: lat,
|
|
Lon: lon,
|
|
Type: result.Type,
|
|
HasHouseNumber: hasHouseNumber,
|
|
GeoJSON: geoJSON,
|
|
},
|
|
})
|
|
}
|