Merge branch 'feat/reverse-geocode' into 'main'
feat(market): reverse geocoding See merge request vikingowl/marktvogt.de!23
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
27
web/src/routes/api/reverse-geocode/+server.ts
Normal file
27
web/src/routes/api/reverse-geocode/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user