feat(market): reverse geocoding — lat/lng to address

Complements the existing forward geocoder with Nominatim's /reverse
endpoint so the admin edit form can populate the address from
coordinates (useful when a crawl gave us lat/lng but no street,
e.g. after running crawl-enrich).

Backend:
- geocode.Reverse(ctx, lat, lng) hits Nominatim /reverse with
  addressdetails=1 and accept-language=de, reuses the 1 rps mutex
  already guarding forward calls. Falls through city → town →
  village → municipality → hamlet for small places. Returns nil
  when Nominatim has no match so callers can distinguish "no hit"
  from "all-empty address."
- New DTOs ReverseGeocodeRequest/Response.
- GeocodeHandler.ReverseGeocode wired at POST /reverse-geocode
  behind the same geocodeLimit middleware as /geocode.

Frontend:
- /api/reverse-geocode SvelteKit proxy mirrors /api/geocode.
- MarketForm gets a second button next to "Koordinaten aus Adresse
  ermitteln" — "Adresse aus Koordinaten ermitteln". Writes non-empty
  street/city/zip back into the form; empty result surfaces
  "Keine Adresse gefunden."
This commit is contained in:
2026-04-24 15:00:23 +02:00
parent a250fddbc2
commit c9a2f8622f
6 changed files with 253 additions and 1 deletions

View File

@@ -409,6 +409,23 @@ type GeocodeResult struct {
Longitude *float64 `json:"longitude"`
}
type ReverseGeocodeRequest struct {
Latitude float64 `json:"latitude" validate:"required,min=-90,max=90"`
Longitude float64 `json:"longitude" validate:"required,min=-180,max=180"`
}
type ReverseGeocodeResult struct {
Street string `json:"street"`
City string `json:"city"`
Zip string `json:"zip"`
State string `json:"state"`
Country string `json:"country"`
}
type ReverseGeocodeResponse struct {
Data ReverseGeocodeResult `json:"data"`
}
type ResearchResult struct {
Suggestions []FieldSuggestion `json:"suggestions"`
Sources []string `json:"sources"`

View File

@@ -39,3 +39,35 @@ func (h *GeocodeHandler) Geocode(c *gin.Context) {
},
})
}
// ReverseGeocode resolves a lat/lng pair into an address. Returns an empty
// result object (all fields "") when Nominatim has no match — callers
// render a "nothing found" message.
func (h *GeocodeHandler) ReverseGeocode(c *gin.Context) {
var req ReverseGeocodeRequest
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
result, err := h.geocoder.Reverse(c.Request.Context(), req.Latitude, req.Longitude)
if err != nil {
apiErr := apierror.Internal("reverse geocoding failed")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
if result == nil {
c.JSON(http.StatusOK, ReverseGeocodeResponse{Data: ReverseGeocodeResult{}})
return
}
c.JSON(http.StatusOK, ReverseGeocodeResponse{
Data: ReverseGeocodeResult{
Street: result.Street,
City: result.City,
Zip: result.Zip,
State: result.State,
Country: result.Country,
},
})
}

View File

@@ -11,6 +11,7 @@ func RegisterRoutes(rg *gin.RouterGroup, h *Handler, subH *SubmissionHandler, ge
}
rg.POST("/geocode", geocodeLimit, geoH.Geocode)
rg.POST("/reverse-geocode", geocodeLimit, geoH.ReverseGeocode)
}
func RegisterAdminRoutes(rg *gin.RouterGroup, h *AdminHandler, rh *ResearchHandler, requireAuth, requireAdmin gin.HandlerFunc) {

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
@@ -27,6 +28,31 @@ type nominatimResult struct {
Lon string `json:"lon"`
}
// ReverseResult is the subset of Nominatim's address breakdown we expose.
// All fields may be empty; callers decide what to do with partial data.
type ReverseResult struct {
Street string
City string
Zip string
State string
Country string
}
type nominatimReverseResult struct {
Address struct {
HouseNumber string `json:"house_number"`
Road string `json:"road"`
Postcode string `json:"postcode"`
City string `json:"city"`
Town string `json:"town"`
Village string `json:"village"`
Municipality string `json:"municipality"`
Hamlet string `json:"hamlet"`
State string `json:"state"`
Country string `json:"country"`
} `json:"address"`
}
func (g *Geocoder) Geocode(ctx context.Context, street, city, zip, country string) (*float64, *float64, error) {
if city == "" && zip == "" {
return nil, nil, fmt.Errorf("city or zip is required for geocoding")
@@ -91,3 +117,78 @@ func (g *Geocoder) Geocode(ctx context.Context, street, city, zip, country strin
return &lat, &lon, nil
}
// Reverse looks up an address for a lat/lng pair. Returns (nil, nil) when
// Nominatim has no match (e.g. coordinates in the middle of a lake). Obeys
// the same 1 rps rate limit as forward geocoding via the shared mutex.
func (g *Geocoder) Reverse(ctx context.Context, lat, lng float64) (*ReverseResult, error) {
g.mu.Lock()
if since := time.Since(g.lastReq); since < time.Second {
time.Sleep(time.Second - since)
}
g.lastReq = time.Now()
g.mu.Unlock()
params := url.Values{}
params.Set("format", "json")
params.Set("lat", fmt.Sprintf("%f", lat))
params.Set("lon", fmt.Sprintf("%f", lng))
params.Set("addressdetails", "1")
params.Set("accept-language", "de")
reqURL := "https://nominatim.openstreetmap.org/reverse?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("User-Agent", "Marktvogt/1.0")
resp, err := g.client.Do(req)
if err != nil {
return nil, fmt.Errorf("nominatim request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("nominatim returned status %d", resp.StatusCode)
}
var body nominatimReverseResult
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, fmt.Errorf("decoding response: %w", err)
}
addr := body.Address
// Small municipalities may lack "city"; fall through the narrower tags
// until we find something.
city := firstNonEmpty(addr.City, addr.Town, addr.Village, addr.Municipality, addr.Hamlet)
// Street = road + optional house number (German convention: "Straße 12").
street := addr.Road
if addr.Road != "" && addr.HouseNumber != "" {
street = addr.Road + " " + addr.HouseNumber
}
// All-empty response means "no match" — return nil so caller can show a
// friendly "nothing found" message instead of an all-blank address.
if street == "" && city == "" && addr.Postcode == "" && addr.State == "" && addr.Country == "" {
return nil, nil
}
return &ReverseResult{
Street: strings.TrimSpace(street),
City: city,
Zip: addr.Postcode,
State: addr.State,
Country: addr.Country,
}, nil
}
func firstNonEmpty(candidates ...string) string {
for _, c := range candidates {
if c != "" {
return c
}
}
return ""
}

View File

@@ -62,6 +62,8 @@
let geocoding = $state(false);
let geocodeError = $state('');
let reverseGeocoding = $state(false);
let reverseGeocodeError = $state('');
function addHoursRow() {
hours = [...hours, { day: 'Samstag', open: '10:00', close: '18:00' }];
@@ -121,6 +123,66 @@
}
}
async function reverseGeocode() {
reverseGeocoding = true;
reverseGeocodeError = '';
const latRaw = document.querySelector<HTMLInputElement>('[name="latitude"]')?.value ?? '';
const lonRaw = document.querySelector<HTMLInputElement>('[name="longitude"]')?.value ?? '';
const lat = parseFloat(latRaw);
const lon = parseFloat(lonRaw);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
reverseGeocodeError = 'Koordinaten fehlen oder sind ungültig.';
reverseGeocoding = false;
return;
}
try {
const res = await fetch('/api/reverse-geocode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ latitude: lat, longitude: lon })
});
const data = await res.json();
if (!res.ok) {
reverseGeocodeError = data.error?.message ?? 'Adress-Lookup fehlgeschlagen.';
return;
}
const fieldsWritten = writeReverseResult(data);
if (fieldsWritten === 0) {
reverseGeocodeError = 'Keine Adresse gefunden.';
}
} catch {
reverseGeocodeError = 'Adress-Lookup fehlgeschlagen.';
} finally {
reverseGeocoding = false;
}
}
// Writes non-empty reverse-geocode fields into the form. Returns the
// count of fields written so the caller can detect an all-empty response.
function writeReverseResult(data: Record<string, string>): number {
const writes: Array<[string, string]> = [
['street', data.street],
['city', data.city],
['zip', data.zip]
];
let count = 0;
for (const [name, value] of writes) {
if (!value) continue;
const el = document.querySelector<HTMLInputElement>(`[name="${name}"]`);
if (el) {
el.value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
count++;
}
}
return count;
}
export function setHours(newHours: OpeningHoursEntry[]) {
hours = newHours;
}
@@ -287,7 +349,7 @@
/>
</div>
<div class="flex items-center gap-3">
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
<button
type="button"
onclick={geocodeAddress}
@@ -299,6 +361,18 @@
{#if geocodeError}
<span class="text-danger-600 dark:text-danger-400 text-xs">{geocodeError}</span>
{/if}
<span class="text-stone-300 dark:text-stone-600" aria-hidden="true">·</span>
<button
type="button"
onclick={reverseGeocode}
disabled={reverseGeocoding}
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 text-sm font-medium disabled:opacity-50"
>
{reverseGeocoding ? 'Ermittle...' : 'Adresse aus Koordinaten ermitteln'}
</button>
{#if reverseGeocodeError}
<span class="text-danger-600 dark:text-danger-400 text-xs">{reverseGeocodeError}</span>
{/if}
</div>
</fieldset>

View File

@@ -0,0 +1,27 @@
import { json } from '@sveltejs/kit';
import { apiFetch } from '$lib/api/client.js';
import type { RequestHandler } from './$types.js';
type ReverseResult = {
street: string;
city: string;
zip: string;
state: string;
country: string;
};
export const POST: RequestHandler = async ({ request, fetch }) => {
const body = await request.json();
try {
const res = await apiFetch<ReverseResult>('/reverse-geocode', {
method: 'POST',
body: JSON.stringify(body),
fetch
});
return json(res.data);
} catch (err) {
const message = err instanceof Error ? err.message : 'Reverse geocoding failed';
return json({ error: { message } }, { status: 500 });
}
};