teg/backend/address_reverse.go
2026-06-13 10:15:57 +02:00

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,
},
})
}