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