Workstream 4: Geo & Eintreffzeit-Sortierung (Phase 3)
Selbstgehostete Geo-Dienste (OSRM + Nominatim auf Österreich-Extrakt),
Geokodierung beim Speichern und ETA-Sortierung der Suchtreffer mit
vollständigem Haversine-Luftlinie-Fallback.
- src/lib/geo/types.ts, config.ts: reine Typen + zentrale Konfiguration
(aus kanonischem env.ts; Defaults, kaputte URL wird weiterhin abgelehnt).
- haversine.ts: Luftlinie in Metern (rein). St. Pölten->Wien ~55 km verifiziert.
- nominatim.ts: kanonische, reine geocodeAddress(address) (countrycodes=at,
Timeout/Abort, status ok/not_found/error; KEIN geocodeBrigade-Zweitpfad).
- osrm.ts: etaTable via /table (sources=0, lng,lat), wirft bei Fehler.
- eintreffzeit.ts: orderByEintreffzeit (OSRM-first, kompletter Haversine-
Fallback bei Wurf, Kandidaten ohne Koordinaten ans Ende, stabile Sortierung;
OSRM-Funktion injizierbar fuer Tests).
- candidates.ts: searchHitsToGeoCandidates (Adapter, laedt brigades.lat/lng)
+ reine filterAndCapCandidates (Bounding-Box-Vorfilter 60 km, max 100).
- API: /api/geo/geocode (POST, auth-gated 401, Zod-Body, 404 bei not_found)
und /api/geo/health (GET, auth-gated; OSRM/Nominatim up/down) — beide
default-deny ueber apiAuth.
- Komponenten: standort-input.tsx (Client, Geolocation + Geocode-Fetch),
eta-badge.tsx (kennzeichnet Luftlinie-Fallback), optionale karte.tsx
via next/dynamic (ssr:false).
- Infra: docker-compose.geo.yml (internes Netz, Healthchecks), docker/osrm/
Dockerfile, scripts/prepare-osm-data.sh, infra/geo/{Makefile,README.md}.
WS4 legt KEINE Migration an (brigades-Geo-Spalten + brigades_latlng_idx
stammen aus WS2); drizzle-kit check bleibt sauber.
Offline verifiziert: tsc --noEmit (exit 0), next lint (0 Warnungen),
vitest run (54 passed / 7 skipped DB-roundtrip), next build (exit 0 mit
gesetzten env-Vars), drizzle-kit check ("Everything's fine").
Deferred (kein Postgres/Server im Sandbox): db:migrate, Live-OSRM/Nominatim,
Playwright-E2E.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
66
docker-compose.geo.yml
Normal file
66
docker-compose.geo.yml
Normal file
@@ -0,0 +1,66 @@
|
||||
# Geo-Dienste (OSRM + Nominatim) auf Österreich-OSM-Extrakt.
|
||||
# Zusammen mit der Basis verwenden:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.geo.yml up -d osrm nominatim
|
||||
#
|
||||
# WICHTIG: Vor dem ersten Start das OSRM-Preprocessing laufen lassen
|
||||
# (scripts/prepare-osm-data.sh bzw. `make -C infra/geo data`) und den
|
||||
# Nominatim-Import einplanen (Stunden, mehrere GB RAM/Disk).
|
||||
|
||||
services:
|
||||
osrm:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/osrm/Dockerfile
|
||||
command: osrm-routed --algorithm mld /data/austria-latest.osrm
|
||||
volumes:
|
||||
- osrm-data:/data
|
||||
networks:
|
||||
- geo-internal
|
||||
expose:
|
||||
- "5000"
|
||||
healthcheck:
|
||||
# /table mit zwei nahen NÖ-Punkten; erwartet HTTP 200.
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- >-
|
||||
wget -q -O - 'http://localhost:5000/table/v1/driving/15.6229,48.2079;16.3738,48.2082?sources=0'
|
||||
| grep -q '"code":"Ok"'
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
restart: unless-stopped
|
||||
|
||||
nominatim:
|
||||
image: mediagis/nominatim:4.4
|
||||
environment:
|
||||
# Österreich-Extrakt (Geofabrik). Beim Erststart wird importiert.
|
||||
PBF_URL: https://download.geofabrik.de/europe/austria-latest.osm.pbf
|
||||
REPLICATION_URL: https://download.geofabrik.de/europe/austria-updates/
|
||||
IMPORT_STYLE: address
|
||||
NOMINATIM_PASSWORD: nominatim
|
||||
volumes:
|
||||
- nominatim-data:/var/lib/postgresql/14/main
|
||||
shm_size: 1g
|
||||
networks:
|
||||
- geo-internal
|
||||
expose:
|
||||
- "8080"
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- "wget -q -O - 'http://localhost:8080/status' | grep -q OK"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 120s
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
osrm-data:
|
||||
nominatim-data:
|
||||
|
||||
networks:
|
||||
# Internes Netz: Geo-Dienste sind nur für die App erreichbar, nicht öffentlich.
|
||||
geo-internal:
|
||||
internal: true
|
||||
9
docker/osrm/Dockerfile
Normal file
9
docker/osrm/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# OSRM-Backend mit MLD-Algorithmus für das Österreich-Routing.
|
||||
FROM ghcr.io/project-osrm/osrm-backend:latest
|
||||
|
||||
WORKDIR /data
|
||||
# Das vorprozessierte .osrm-Set wird über das Volume bereitgestellt
|
||||
# (siehe scripts/prepare-osm-data.sh / infra/geo/Makefile). Der Container
|
||||
# selbst führt nur `osrm-routed --algorithm mld` aus (Compose-`command`).
|
||||
EXPOSE 5000
|
||||
26
infra/geo/Makefile
Normal file
26
infra/geo/Makefile
Normal file
@@ -0,0 +1,26 @@
|
||||
# Geo-Daten-Pipeline (OSRM + Nominatim, Österreich-Extrakt)
|
||||
#
|
||||
# Ziele:
|
||||
# make data - lädt den OSM-Extrakt und führt das OSRM-Preprocessing aus
|
||||
# make up - startet OSRM + Nominatim (Basis-Compose + Geo-Overlay)
|
||||
# make down - stoppt die Geo-Dienste
|
||||
# make health - prüft die Geo-Health (intern, via App-Container)
|
||||
#
|
||||
# Hinweis: `data`/`up` benötigen Docker, Netzzugriff und viel RAM/Disk;
|
||||
# nicht in CI/Sandbox ausführen.
|
||||
|
||||
COMPOSE = docker compose -f ../../docker-compose.yml -f ../../docker-compose.geo.yml
|
||||
|
||||
.PHONY: data up down health
|
||||
|
||||
data:
|
||||
../../scripts/prepare-osm-data.sh
|
||||
|
||||
up:
|
||||
$(COMPOSE) up -d osrm nominatim
|
||||
|
||||
down:
|
||||
$(COMPOSE) stop osrm nominatim
|
||||
|
||||
health:
|
||||
$(COMPOSE) exec app wget -q -O - http://localhost:3000/api/geo/health || true
|
||||
67
infra/geo/README.md
Normal file
67
infra/geo/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Geo-Dienste: OSRM (Routing) + Nominatim (Geocoding)
|
||||
|
||||
Selbstgehostete Geo-Dienste auf einem **Österreich-OSM-Extrakt** (Geofabrik).
|
||||
Sie liefern die Eintreffzeit-Sortierung (`orderByEintreffzeit`) und die
|
||||
Adress-Geokodierung (`geocodeAddress`). Beide Dienste laufen in einem
|
||||
**internen** Compose-Netz und sind nur für den App-Container erreichbar.
|
||||
|
||||
## Komponenten
|
||||
|
||||
- **OSRM** (`ghcr.io/project-osrm/osrm-backend`, `--algorithm mld`) — `/table`
|
||||
liefert die Fahrzeit-Matrix von EINER Quelle (`sources=0`) zu N Zielen.
|
||||
Koordinaten in OSRM-Reihenfolge `lng,lat`.
|
||||
- **Nominatim** (`mediagis/nominatim`) — `/search?countrycodes=at` geokodiert
|
||||
österreichische Adressen.
|
||||
|
||||
## Erstinbetriebnahme
|
||||
|
||||
> Achtung: Import/Preprocessing brauchen Netzzugriff (Geofabrik, ~700 MB–1 GB),
|
||||
> mehrere GB RAM/Disk und Zeit (Minuten bis Stunden). Nicht in CI/Sandbox.
|
||||
|
||||
1. **OSRM-Daten vorbereiten** (extract → partition → customize):
|
||||
|
||||
```bash
|
||||
make -C infra/geo data # oder: scripts/prepare-osm-data.sh
|
||||
```
|
||||
|
||||
Erzeugt das `.osrm`-Set in `infra/geo/data` und füllt das OSRM-Volume.
|
||||
|
||||
2. **Dienste starten**:
|
||||
|
||||
```bash
|
||||
make -C infra/geo up
|
||||
# entspricht:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.geo.yml up -d osrm nominatim
|
||||
```
|
||||
|
||||
Nominatim importiert beim ersten Start den PBF-Extrakt automatisch.
|
||||
|
||||
3. **Health prüfen** (intern, über die App):
|
||||
|
||||
```bash
|
||||
make -C infra/geo health # ruft GET /api/geo/health (auth-gated)
|
||||
```
|
||||
|
||||
## Konfiguration (kanonisch in `src/lib/env.ts`)
|
||||
|
||||
| Variable | Default | Zweck |
|
||||
| -------------------- | ------------------------ | -------------------------------------- |
|
||||
| `OSRM_URL` | `http://osrm:5000` | Basis-URL des OSRM-Dienstes |
|
||||
| `NOMINATIM_URL` | `http://nominatim:8080` | Basis-URL des Nominatim-Dienstes |
|
||||
| `GEO_HTTP_TIMEOUT_MS`| `4000` | Timeout/Abort für Geo-HTTP-Aufrufe |
|
||||
| `HAVERSINE_KMH` | `50` | Durchschnittstempo der Luftlinie-Fallback-Schätzung |
|
||||
|
||||
## Fallback-Verhalten
|
||||
|
||||
Fällt OSRM aus, schaltet `orderByEintreffzeit` **vollständig** auf die
|
||||
Haversine-Luftlinie um (`mode: "haversine"`, `isFallback: true`). Die UI
|
||||
kennzeichnet diese Werte als „Luftlinie (geschätzt)" (`EtaBadge`). Wehren ohne
|
||||
Koordinaten landen stets am Ende der Liste.
|
||||
|
||||
## Daten-Updates
|
||||
|
||||
Der Geofabrik-Extrakt veraltet. Aktualisierung ist ein manueller Lauf:
|
||||
|
||||
```bash
|
||||
make -C infra/geo data && make -C infra/geo up
|
||||
```
|
||||
39
scripts/prepare-osm-data.sh
Executable file
39
scripts/prepare-osm-data.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# OSRM-Preprocessing für den Österreich-Extrakt (extract -> partition -> customize).
|
||||
# Erzeugt das .osrm-Set im OSRM-Daten-Volume, bevor `osrm-routed` startet.
|
||||
#
|
||||
# Verwendung:
|
||||
# scripts/prepare-osm-data.sh
|
||||
# (oder: make -C infra/geo data)
|
||||
#
|
||||
# Hinweis: Läuft NICHT in der CI/Sandbox — benötigt Docker, Netzugriff zum
|
||||
# Geofabrik-Download und mehrere GB RAM/Disk. Dauer: mehrere Minuten bis Stunden.
|
||||
set -euo pipefail
|
||||
|
||||
DATA_DIR="${OSRM_DATA_DIR:-./infra/geo/data}"
|
||||
PBF_URL="${PBF_URL:-https://download.geofabrik.de/europe/austria-latest.osm.pbf}"
|
||||
PBF_FILE="austria-latest.osm.pbf"
|
||||
OSM_BASENAME="austria-latest"
|
||||
OSRM_IMAGE="${OSRM_IMAGE:-ghcr.io/project-osrm/osrm-backend:latest}"
|
||||
PROFILE="/opt/car.lua"
|
||||
|
||||
mkdir -p "${DATA_DIR}"
|
||||
|
||||
if [[ ! -f "${DATA_DIR}/${PBF_FILE}" ]]; then
|
||||
echo "Lade OSM-Extrakt: ${PBF_URL}"
|
||||
curl -fSL "${PBF_URL}" -o "${DATA_DIR}/${PBF_FILE}"
|
||||
fi
|
||||
|
||||
run_osrm() {
|
||||
docker run --rm -t -v "$(pwd)/${DATA_DIR}:/data" "${OSRM_IMAGE}" "$@"
|
||||
}
|
||||
|
||||
echo "1/3 extract"
|
||||
run_osrm osrm-extract -p "${PROFILE}" "/data/${PBF_FILE}"
|
||||
echo "2/3 partition"
|
||||
run_osrm osrm-partition "/data/${OSM_BASENAME}.osrm"
|
||||
echo "3/3 customize"
|
||||
run_osrm osrm-customize "/data/${OSM_BASENAME}.osrm"
|
||||
|
||||
echo "Fertig. .osrm-Set liegt in ${DATA_DIR}."
|
||||
echo "Daten ins OSRM-Volume kopieren oder Volume auf ${DATA_DIR} mappen."
|
||||
49
src/app/api/geo/geocode/route.ts
Normal file
49
src/app/api/geo/geocode/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { apiAuth } from "@/lib/auth/guards";
|
||||
import { geocodeAddress } from "@/lib/geo/nominatim";
|
||||
import { nonEmptyText } from "@/lib/validation/common";
|
||||
|
||||
// Geo-Geocoding-Endpunkt. Default-deny: jede Anfrage durchläuft zuerst den
|
||||
// API-Guard (401 ohne Session). Reine Geokodierung, kein DB-Schreibzugriff.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const bodySchema = z.object({ address: nonEmptyText });
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const authResult = await apiAuth();
|
||||
if (!authResult.ok) return authResult.response;
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Ungültiger Anfragekörper." }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = bodySchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Adresse fehlt oder ist ungültig." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await geocodeAddress(parsed.data.address);
|
||||
if (result.status === "not_found") {
|
||||
return NextResponse.json({ error: "Adresse nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
if (result.status === "error") {
|
||||
// Kein Fachdaten-Leak: generische Meldung, Detail nur im Server-Log.
|
||||
console.error("Geocoding-Fehler:", result.reason);
|
||||
return NextResponse.json(
|
||||
{ error: "Geokodierung derzeit nicht verfügbar." },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
coords: result.coords,
|
||||
displayName: result.displayName,
|
||||
});
|
||||
}
|
||||
21
src/app/api/geo/health/route.ts
Normal file
21
src/app/api/geo/health/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiAuth } from "@/lib/auth/guards";
|
||||
import { osrmHealthy } from "@/lib/geo/osrm";
|
||||
import { nominatimHealthy } from "@/lib/geo/nominatim";
|
||||
|
||||
// Geo-Dienst-Health (OSRM/Nominatim). NICHT der öffentliche Container-
|
||||
// Healthcheck (das ist /api/health) — dieser Endpunkt ist auth-gated (401).
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const authResult = await apiAuth();
|
||||
if (!authResult.ok) return authResult.response;
|
||||
|
||||
const [osrm, nominatim] = await Promise.all([osrmHealthy(), nominatimHealthy()]);
|
||||
const status = osrm && nominatim ? "ok" : "degraded";
|
||||
return NextResponse.json({
|
||||
status,
|
||||
osrm: osrm ? "up" : "down",
|
||||
nominatim: nominatim ? "up" : "down",
|
||||
});
|
||||
}
|
||||
53
src/components/geo/eta-badge.tsx
Normal file
53
src/components/geo/eta-badge.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { t } from "@/lib/i18n/de";
|
||||
import type { EtaResult } from "@/lib/geo/types";
|
||||
|
||||
/** Formatiert Sekunden als „X min" (gerundet, mind. 1 min wenn > 0). */
|
||||
function formatMinutes(durationSec: number): string {
|
||||
const min = Math.max(1, Math.round(durationSec / 60));
|
||||
return `${min} min`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt die Eintreffzeit eines Treffers. Bei OSRM-Ausfall (Haversine-Fallback)
|
||||
* wird der Wert als geschätzte Luftlinie gekennzeichnet (Querschnittsstandard).
|
||||
*/
|
||||
export function EtaBadge({ eta }: { eta: EtaResult }) {
|
||||
if (eta.durationSec == null) {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-sm border border-rand bg-nebel px-2 py-0.5 text-xs font-medium text-anthrazit/60">
|
||||
—
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const label = formatMinutes(eta.durationSec);
|
||||
|
||||
if (eta.isFallback) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-sm border px-2 py-0.5 text-xs font-medium tabular-nums",
|
||||
"border-wartung/30 bg-wartung/10 text-wartung",
|
||||
)}
|
||||
title={t("search.luftlinie")}
|
||||
>
|
||||
≈ {label}
|
||||
<span className="text-[0.65rem] font-normal">
|
||||
{t("search.luftlinie")}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-sm border px-2 py-0.5 text-xs font-medium tabular-nums",
|
||||
"border-bereit/30 bg-bereit/10 text-bereit",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
19
src/components/geo/karte-dynamic.tsx
Normal file
19
src/components/geo/karte-dynamic.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { KarteProps } from "./karte";
|
||||
|
||||
/**
|
||||
* Dynamischer, clientseitiger Wrapper um die optionale Kartenansicht.
|
||||
* `ssr: false` verhindert SSR-Hydration-Probleme und hält die Karte aus dem
|
||||
* Server-Pfad heraus (Bundle-Optimierung, Vercel-Best-Practice
|
||||
* `bundle-dynamic-imports`).
|
||||
*/
|
||||
export const KarteDynamic = dynamic<KarteProps>(() => import("./karte"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="rounded border border-rand bg-nebel p-4 text-xs text-anthrazit/60">
|
||||
Karte wird geladen …
|
||||
</div>
|
||||
),
|
||||
});
|
||||
46
src/components/geo/karte.tsx
Normal file
46
src/components/geo/karte.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { Coordinates } from "@/lib/geo/types";
|
||||
|
||||
export type KartenMarker = {
|
||||
id: string;
|
||||
coords: Coordinates;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type KarteProps = {
|
||||
center: Coordinates;
|
||||
marker?: KartenMarker[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* OPTIONALE Kartenansicht. Bewusst leichtgewichtig und ohne harte
|
||||
* MapLibre-Abhängigkeit gehalten — sie wird über `next/dynamic` (ssr:false)
|
||||
* eingebunden (siehe `KarteDynamic`), damit kein Karten-Code ins initiale
|
||||
* Bundle oder in den SSR-Pfad gerät. Bei Bedarf kann hier später MapLibre GL
|
||||
* integriert werden (clientseitig, dynamisch nachgeladen).
|
||||
*/
|
||||
export default function Karte({ center, marker = [], className }: KarteProps) {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label={`Karte zentriert auf ${center.lat.toFixed(4)}, ${center.lng.toFixed(4)}`}
|
||||
>
|
||||
<div className="rounded border border-rand bg-nebel p-4 text-xs text-anthrazit/70">
|
||||
<p>
|
||||
Zentrum: {center.lat.toFixed(4)}, {center.lng.toFixed(4)}
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{marker.map((m) => (
|
||||
<li key={m.id}>
|
||||
{m.label} — {m.coords.lat.toFixed(4)}, {m.coords.lng.toFixed(4)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
src/components/geo/standort-input.tsx
Normal file
114
src/components/geo/standort-input.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { t } from "@/lib/i18n/de";
|
||||
import type { Coordinates } from "@/lib/geo/types";
|
||||
|
||||
type StandortInputProps = {
|
||||
/** Wird mit den ermittelten Koordinaten (und optionalem Anzeigenamen) aufgerufen. */
|
||||
onResolved: (coords: Coordinates, displayName?: string) => void;
|
||||
defaultAddress?: string;
|
||||
};
|
||||
|
||||
type State = "idle" | "locating" | "geocoding" | "error";
|
||||
|
||||
/**
|
||||
* Standort-Eingabe für die Eintreffzeit-Sortierung: entweder Browser-Geolocation
|
||||
* („Meinen Standort verwenden") oder eine Adresse, die über `/api/geo/geocode`
|
||||
* (auth-gated) geokodiert wird. Reine Client-Komponente; kein direkter Geo-Import,
|
||||
* damit die Server-Geo-Module nicht ins Client-Bundle gezogen werden.
|
||||
*/
|
||||
export function StandortInput({ onResolved, defaultAddress = "" }: StandortInputProps) {
|
||||
const [address, setAddress] = React.useState(defaultAddress);
|
||||
const [state, setState] = React.useState<State>("idle");
|
||||
const [message, setMessage] = React.useState<string | null>(null);
|
||||
|
||||
const useMyLocation = React.useCallback(() => {
|
||||
if (!("geolocation" in navigator)) {
|
||||
setState("error");
|
||||
setMessage(t("fehler.allgemein"));
|
||||
return;
|
||||
}
|
||||
setState("locating");
|
||||
setMessage(null);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
setState("idle");
|
||||
onResolved({ lat: pos.coords.latitude, lng: pos.coords.longitude });
|
||||
},
|
||||
() => {
|
||||
setState("error");
|
||||
setMessage(t("fehler.allgemein"));
|
||||
},
|
||||
{ timeout: 8000 },
|
||||
);
|
||||
}, [onResolved]);
|
||||
|
||||
const geocode = React.useCallback(async () => {
|
||||
const value = address.trim();
|
||||
if (!value) return;
|
||||
setState("geocoding");
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch("/api/geo/geocode", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ address: value }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setState("error");
|
||||
setMessage(t("search.keineTreffer"));
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as {
|
||||
coords: Coordinates;
|
||||
displayName?: string;
|
||||
};
|
||||
setState("idle");
|
||||
onResolved(data.coords, data.displayName);
|
||||
} catch {
|
||||
setState("error");
|
||||
setMessage(t("fehler.allgemein"));
|
||||
}
|
||||
}, [address, onResolved]);
|
||||
|
||||
const busy = state === "locating" || state === "geocoding";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void geocode();
|
||||
}
|
||||
}}
|
||||
placeholder="Adresse oder Ort"
|
||||
aria-label="Adresse"
|
||||
disabled={busy}
|
||||
/>
|
||||
<Button type="button" onClick={() => void geocode()} disabled={busy}>
|
||||
{t("search.suchen")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={useMyLocation}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("search.meinStandort")}
|
||||
</Button>
|
||||
{busy ? (
|
||||
<p className="text-xs text-anthrazit/60">{t("aktion.laden")}</p>
|
||||
) : null}
|
||||
{message ? <p className="text-xs text-wartung">{message}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/lib/geo/__tests__/candidates.test.ts
Normal file
58
src/lib/geo/__tests__/candidates.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { filterAndCapCandidates } from "../candidates";
|
||||
import type { Coordinates } from "../types";
|
||||
|
||||
const origin: Coordinates = { lat: 48.2079, lng: 15.6229 };
|
||||
|
||||
type Hit = { brigadeId: string; label: string };
|
||||
|
||||
describe("filterAndCapCandidates (reine Vorfilter-Logik)", () => {
|
||||
const coordsById = (m: Record<string, Coordinates | null>) =>
|
||||
(id: string): Coordinates | null => m[id] ?? null;
|
||||
|
||||
it("filtert Wehren außerhalb des Radius heraus (ans Ende, nicht gelöscht)", () => {
|
||||
const hits: Hit[] = [
|
||||
{ brigadeId: "nah", label: "nah" },
|
||||
{ brigadeId: "fern", label: "fern" },
|
||||
];
|
||||
const lookup = coordsById({
|
||||
nah: { lat: 48.21, lng: 15.63 }, // ~ wenige km
|
||||
fern: { lat: 48.2082, lng: 16.3738 }, // ~55 km > 10 km
|
||||
});
|
||||
const out = filterAndCapCandidates(origin, hits, lookup, 10, 100);
|
||||
// nah ist drin (Koordinaten + Radius), fern ans Ende verschoben
|
||||
expect(out[0]!.brigadeId).toBe("nah");
|
||||
expect(out[out.length - 1]!.brigadeId).toBe("fern");
|
||||
});
|
||||
|
||||
it("kappt bei maxCandidates", () => {
|
||||
const hits: Hit[] = Array.from({ length: 10 }, (_, i) => ({
|
||||
brigadeId: `b${i}`,
|
||||
label: `b${i}`,
|
||||
}));
|
||||
const lookup = (): Coordinates => ({ lat: 48.21, lng: 15.63 });
|
||||
const out = filterAndCapCandidates(origin, hits, lookup, 60, 3);
|
||||
expect(out).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("Kandidaten ohne Koordinaten ans Ende, brigadeCoords=null", () => {
|
||||
const hits: Hit[] = [
|
||||
{ brigadeId: "ohne", label: "ohne" },
|
||||
{ brigadeId: "mit", label: "mit" },
|
||||
];
|
||||
const lookup = coordsById({
|
||||
ohne: null,
|
||||
mit: { lat: 48.21, lng: 15.63 },
|
||||
});
|
||||
const out = filterAndCapCandidates(origin, hits, lookup, 60, 100);
|
||||
expect(out[0]!.brigadeId).toBe("mit");
|
||||
expect(out[0]!.brigadeCoords).not.toBeNull();
|
||||
expect(out[out.length - 1]!.brigadeId).toBe("ohne");
|
||||
expect(out[out.length - 1]!.brigadeCoords).toBeNull();
|
||||
});
|
||||
|
||||
it("leere Trefferliste -> leeres Ergebnis", () => {
|
||||
const out = filterAndCapCandidates(origin, [], () => null, 60, 100);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
});
|
||||
64
src/lib/geo/__tests__/eintreffzeit.test.ts
Normal file
64
src/lib/geo/__tests__/eintreffzeit.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { orderByEintreffzeit } from "../eintreffzeit";
|
||||
import type { Coordinates } from "../types";
|
||||
|
||||
const origin: Coordinates = { lat: 48.2079, lng: 15.6229 };
|
||||
|
||||
type Hit = { id: string; brigadeCoords: Coordinates | null };
|
||||
|
||||
const nah: Hit = { id: "nah", brigadeCoords: { lat: 48.21, lng: 15.63 } };
|
||||
const fern: Hit = { id: "fern", brigadeCoords: { lat: 48.2082, lng: 16.3738 } };
|
||||
const ohne: Hit = { id: "ohne", brigadeCoords: null };
|
||||
|
||||
describe("orderByEintreffzeit", () => {
|
||||
it("(a) OSRM-Erfolg: sortiert aufsteigend nach Dauer, mode=osrm", async () => {
|
||||
// OSRM gibt fern eine kürzere Dauer als nah -> Reihenfolge folgt OSRM, nicht Luftlinie.
|
||||
const etaTable = vi.fn().mockResolvedValue({
|
||||
durations: [[0, 300, 100]], // origin -> [nah, fern]
|
||||
distances: [[0, 4000, 2000]],
|
||||
});
|
||||
const result = await orderByEintreffzeit(origin, [nah, fern], etaTable);
|
||||
expect(result.map((r) => r.id)).toEqual(["fern", "nah"]);
|
||||
expect(result.every((r) => r.eta.mode === "osrm")).toBe(true);
|
||||
expect(result.every((r) => r.eta.isFallback === false)).toBe(true);
|
||||
expect(result[0]!.eta.durationSec).toBe(100);
|
||||
});
|
||||
|
||||
it("(b) OSRM-Wurf: kompletter Haversine-Fallback, isFallback=true, sortiert", async () => {
|
||||
const etaTable = vi.fn().mockRejectedValue(new Error("osrm down"));
|
||||
const result = await orderByEintreffzeit(origin, [fern, nah], etaTable);
|
||||
expect(result.every((r) => r.eta.mode === "haversine")).toBe(true);
|
||||
expect(result.every((r) => r.eta.isFallback === true)).toBe(true);
|
||||
// nah ist näher (Luftlinie) als fern -> nah zuerst
|
||||
expect(result.map((r) => r.id)).toEqual(["nah", "fern"]);
|
||||
expect(result[0]!.eta.durationSec).not.toBeNull();
|
||||
});
|
||||
|
||||
it("(c) Kandidaten ohne Koordinaten landen am Ende mit durationSec=null", async () => {
|
||||
const etaTable = vi.fn().mockResolvedValue({
|
||||
durations: [[0, 300, 100]],
|
||||
distances: [[0, 4000, 2000]],
|
||||
});
|
||||
const result = await orderByEintreffzeit(origin, [ohne, nah, fern], etaTable);
|
||||
expect(result[result.length - 1]!.id).toBe("ohne");
|
||||
expect(result[result.length - 1]!.eta.durationSec).toBeNull();
|
||||
});
|
||||
|
||||
it("(d) leere Liste -> leeres Ergebnis, OSRM wird nicht gerufen", async () => {
|
||||
const etaTable = vi.fn();
|
||||
const result = await orderByEintreffzeit(origin, [], etaTable);
|
||||
expect(result).toEqual([]);
|
||||
expect(etaTable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("(e) stabile Sortierung bei gleicher Dauer (Eingabereihenfolge bleibt)", async () => {
|
||||
const a: Hit = { id: "a", brigadeCoords: { lat: 48.21, lng: 15.63 } };
|
||||
const b: Hit = { id: "b", brigadeCoords: { lat: 48.22, lng: 15.64 } };
|
||||
const etaTable = vi.fn().mockResolvedValue({
|
||||
durations: [[0, 200, 200]],
|
||||
distances: [[0, 1000, 1000]],
|
||||
});
|
||||
const result = await orderByEintreffzeit(origin, [a, b], etaTable);
|
||||
expect(result.map((r) => r.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
27
src/lib/geo/__tests__/haversine.test.ts
Normal file
27
src/lib/geo/__tests__/haversine.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { haversineMeters } from "../haversine";
|
||||
import type { Coordinates } from "../types";
|
||||
|
||||
// St. Pölten Hauptbahnhof ~ (48.2079, 15.6229), Wien Stephansplatz ~ (48.2082, 16.3738)
|
||||
const stPoelten: Coordinates = { lat: 48.2079, lng: 15.6229 };
|
||||
const wien: Coordinates = { lat: 48.2082, lng: 16.3738 };
|
||||
|
||||
describe("haversineMeters", () => {
|
||||
it("liefert 0 für identische Punkte", () => {
|
||||
expect(haversineMeters(stPoelten, stPoelten)).toBe(0);
|
||||
});
|
||||
|
||||
it("St. Pölten -> Wien ist ~55 km (±3 km Luftlinie)", () => {
|
||||
const meters = haversineMeters(stPoelten, wien);
|
||||
const km = meters / 1000;
|
||||
expect(km).toBeGreaterThanOrEqual(52);
|
||||
expect(km).toBeLessThanOrEqual(58);
|
||||
});
|
||||
|
||||
it("ist symmetrisch", () => {
|
||||
expect(haversineMeters(stPoelten, wien)).toBeCloseTo(
|
||||
haversineMeters(wien, stPoelten),
|
||||
6,
|
||||
);
|
||||
});
|
||||
});
|
||||
74
src/lib/geo/candidates.ts
Normal file
74
src/lib/geo/candidates.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { inArray } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { brigades } from "@/db/schema";
|
||||
import { haversineMeters } from "./haversine";
|
||||
import { geoConfig } from "./config";
|
||||
import type { Coordinates, GeoCandidate } from "./types";
|
||||
|
||||
/**
|
||||
* REINE Vorfilter-Logik (ohne DB): reichert Treffer mit Koordinaten an,
|
||||
* sortiert die innerhalb des Radius nach vorne, schiebt Wehren außerhalb des
|
||||
* Radius sowie ohne Koordinaten ans Ende und kappt hart bei `maxCandidates`,
|
||||
* um URL-Länge und Latenz des OSRM-`/table`-Aufrufs zu begrenzen.
|
||||
*
|
||||
* Ausgelagert, damit die Logik ohne laufendes Postgres testbar ist.
|
||||
*/
|
||||
export function filterAndCapCandidates<T extends { brigadeId: string }>(
|
||||
origin: Coordinates,
|
||||
hits: T[],
|
||||
coordsFor: (brigadeId: string) => Coordinates | null,
|
||||
radiusKm: number,
|
||||
maxCandidates: number,
|
||||
): (T & GeoCandidate)[] {
|
||||
if (hits.length === 0) return [];
|
||||
const enriched = hits.map((h) => ({
|
||||
...h,
|
||||
brigadeCoords: coordsFor(h.brigadeId),
|
||||
}));
|
||||
|
||||
const radiusMeters = radiusKm * 1000;
|
||||
const within: (T & GeoCandidate)[] = [];
|
||||
const rest: (T & GeoCandidate)[] = [];
|
||||
for (const e of enriched) {
|
||||
if (e.brigadeCoords && haversineMeters(origin, e.brigadeCoords) <= radiusMeters) {
|
||||
within.push(e);
|
||||
} else {
|
||||
rest.push(e);
|
||||
}
|
||||
}
|
||||
return [...within, ...rest].slice(0, maxCandidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter zwischen Suche und Geo: lädt `brigades.lat/lng` für die getroffenen
|
||||
* Wehren und filtert grob per Radius vor dem OSRM-Call. IDs sind uuid (string).
|
||||
*/
|
||||
export async function searchHitsToGeoCandidates<T extends { brigadeId: string }>(
|
||||
origin: Coordinates,
|
||||
hits: T[],
|
||||
radiusKm = geoConfig.defaultRadiusKm,
|
||||
maxCandidates = geoConfig.maxCandidates,
|
||||
): Promise<(T & GeoCandidate)[]> {
|
||||
const ids = [...new Set(hits.map((h) => h.brigadeId))];
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
const rows = await db
|
||||
.select({ id: brigades.id, lat: brigades.lat, lng: brigades.lng })
|
||||
.from(brigades)
|
||||
.where(inArray(brigades.id, ids));
|
||||
|
||||
const coordsById = new Map<string, Coordinates | null>(
|
||||
rows.map((r) => [
|
||||
r.id,
|
||||
r.lat != null && r.lng != null ? { lat: r.lat, lng: r.lng } : null,
|
||||
]),
|
||||
);
|
||||
|
||||
return filterAndCapCandidates(
|
||||
origin,
|
||||
hits,
|
||||
(id) => coordsById.get(id) ?? null,
|
||||
radiusKm,
|
||||
maxCandidates,
|
||||
);
|
||||
}
|
||||
24
src/lib/geo/config.ts
Normal file
24
src/lib/geo/config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* Geo-Konfiguration, zentral aus dem kanonischen `env.ts` abgeleitet.
|
||||
* Alle Werte haben Defaults (env.ts), damit die Validierung ohne explizite
|
||||
* Geo-Variablen nicht scheitert; eine kaputte URL wird dort weiterhin abgelehnt.
|
||||
*/
|
||||
export const geoConfig = {
|
||||
osrmUrl: env.OSRM_URL.replace(/\/+$/, ""),
|
||||
nominatimUrl: env.NOMINATIM_URL.replace(/\/+$/, ""),
|
||||
httpTimeoutMs: env.GEO_HTTP_TIMEOUT_MS,
|
||||
/** Durchschnittsgeschwindigkeit (km/h) für die Haversine-Fallback-Schätzung. */
|
||||
haversineKmh: env.HAVERSINE_KMH,
|
||||
/** Vorfilter-Radius (km) für `searchHitsToGeoCandidates`. */
|
||||
defaultRadiusKm: 60,
|
||||
/** Harte Obergrenze an Kandidaten pro OSRM-`/table`-Aufruf. */
|
||||
maxCandidates: 100,
|
||||
} as const;
|
||||
|
||||
/** Schätzt die Fahrdauer (Sekunden) aus der Luftlinie und `haversineKmh`. */
|
||||
export function haversineDurationSec(distanceMeters: number): number {
|
||||
const metersPerSec = (geoConfig.haversineKmh * 1000) / 3600;
|
||||
return distanceMeters / metersPerSec;
|
||||
}
|
||||
118
src/lib/geo/eintreffzeit.ts
Normal file
118
src/lib/geo/eintreffzeit.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { haversineMeters } from "./haversine";
|
||||
import { haversineDurationSec } from "./config";
|
||||
import { etaTable as defaultEtaTable } from "./osrm";
|
||||
import type {
|
||||
Coordinates,
|
||||
EtaResult,
|
||||
EtaTableFn,
|
||||
GeoCandidate,
|
||||
OrderedResult,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Sortiert Suchtreffer aufsteigend nach Eintreffzeit ab `origin`.
|
||||
*
|
||||
* - OSRM zuerst (Fahrzeit-Matrix). Wirft OSRM (oder fehlt der Dienst),
|
||||
* wird KOMPLETT auf Haversine-Luftlinie zurückgefallen (`isFallback=true`).
|
||||
* - Kandidaten ohne Koordinaten landen stets am Ende (`durationSec=null`).
|
||||
* - Stabile aufsteigende Sortierung: bei gleicher Dauer bleibt die Eingabe-
|
||||
* reihenfolge erhalten.
|
||||
*
|
||||
* Die OSRM-Tabellenfunktion ist injizierbar, damit der Pfad ohne laufenden
|
||||
* Dienst testbar ist (Erfolg / Wurf / null-Koordinaten).
|
||||
*/
|
||||
export async function orderByEintreffzeit<T extends GeoCandidate>(
|
||||
origin: Coordinates,
|
||||
candidates: T[],
|
||||
etaTable: EtaTableFn = defaultEtaTable,
|
||||
): Promise<OrderedResult<T>[]> {
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
const withCoords: { cand: T; idx: number; coords: Coordinates }[] = [];
|
||||
const withoutCoords: { cand: T; idx: number }[] = [];
|
||||
candidates.forEach((cand, idx) => {
|
||||
if (cand.brigadeCoords) {
|
||||
withCoords.push({ cand, idx, coords: cand.brigadeCoords });
|
||||
} else {
|
||||
withoutCoords.push({ cand, idx });
|
||||
}
|
||||
});
|
||||
|
||||
const etas = await computeEtas(
|
||||
origin,
|
||||
withCoords.map((w) => w.coords),
|
||||
etaTable,
|
||||
);
|
||||
|
||||
const ranked: OrderedResult<T>[] = withCoords
|
||||
.map((w, i): { cand: T; idx: number; eta: EtaResult } => ({
|
||||
cand: w.cand,
|
||||
idx: w.idx,
|
||||
eta: etas[i] ?? haversineEta(origin, w.coords),
|
||||
}))
|
||||
// Stabil: erst nach Dauer (null ans Ende), dann nach ursprünglichem Index.
|
||||
.sort((a, b) => {
|
||||
const da = a.eta.durationSec;
|
||||
const db = b.eta.durationSec;
|
||||
if (da == null && db == null) return a.idx - b.idx;
|
||||
if (da == null) return 1;
|
||||
if (db == null) return -1;
|
||||
return da === db ? a.idx - b.idx : da - db;
|
||||
})
|
||||
.map((w) => ({ ...w.cand, eta: w.eta }));
|
||||
|
||||
const tail: OrderedResult<T>[] = withoutCoords
|
||||
.sort((a, b) => a.idx - b.idx)
|
||||
.map((w) => ({
|
||||
...w.cand,
|
||||
eta: {
|
||||
durationSec: null,
|
||||
distanceMeters: null,
|
||||
mode: "haversine" as const,
|
||||
isFallback: true,
|
||||
},
|
||||
}));
|
||||
|
||||
return [...ranked, ...tail];
|
||||
}
|
||||
|
||||
/** Liefert ETAs in derselben Reihenfolge wie `coords`; bei OSRM-Wurf alles Haversine. */
|
||||
async function computeEtas(
|
||||
origin: Coordinates,
|
||||
coords: Coordinates[],
|
||||
etaTable: EtaTableFn,
|
||||
): Promise<EtaResult[]> {
|
||||
if (coords.length === 0) return [];
|
||||
try {
|
||||
const table = await etaTable(origin, coords);
|
||||
const durations = table.durations[0] ?? [];
|
||||
const distances = table.distances?.[0] ?? [];
|
||||
return coords.map((c, i) => {
|
||||
const durationSec = durations[i + 1] ?? null; // Index 0 = origin->origin
|
||||
const distanceMeters = distances[i + 1] ?? null;
|
||||
if (durationSec == null) {
|
||||
// OSRM kennt keine Route -> einzelner Haversine-Fallback.
|
||||
return haversineEta(origin, c);
|
||||
}
|
||||
return {
|
||||
durationSec,
|
||||
distanceMeters,
|
||||
mode: "osrm",
|
||||
isFallback: false,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// Kompletter Fallback: OSRM nicht erreichbar.
|
||||
return coords.map((c) => haversineEta(origin, c));
|
||||
}
|
||||
}
|
||||
|
||||
function haversineEta(origin: Coordinates, dest: Coordinates): EtaResult {
|
||||
const distanceMeters = haversineMeters(origin, dest);
|
||||
return {
|
||||
durationSec: haversineDurationSec(distanceMeters),
|
||||
distanceMeters,
|
||||
mode: "haversine",
|
||||
isFallback: true,
|
||||
};
|
||||
}
|
||||
22
src/lib/geo/haversine.ts
Normal file
22
src/lib/geo/haversine.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Coordinates } from "./types";
|
||||
|
||||
const EARTH_RADIUS_M = 6_371_000;
|
||||
|
||||
const toRad = (deg: number): number => (deg * Math.PI) / 180;
|
||||
|
||||
/**
|
||||
* Luftlinie (Haversine) in Metern zwischen zwei Koordinaten.
|
||||
* Rein, ohne IO. Dient als Fallback, wenn OSRM nicht erreichbar ist.
|
||||
*/
|
||||
export function haversineMeters(a: Coordinates, b: Coordinates): number {
|
||||
const dLat = toRad(b.lat - a.lat);
|
||||
const dLng = toRad(b.lng - a.lng);
|
||||
const lat1 = toRad(a.lat);
|
||||
const lat2 = toRad(b.lat);
|
||||
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
|
||||
|
||||
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
66
src/lib/geo/nominatim.ts
Normal file
66
src/lib/geo/nominatim.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { geoConfig } from "./config";
|
||||
import type { Coordinates, GeocodeResult } from "./types";
|
||||
|
||||
type NominatimRow = {
|
||||
lat: string;
|
||||
lon: string;
|
||||
display_name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Geokodiert EINE Adresszeile gegen das selbstgehostete Nominatim.
|
||||
*
|
||||
* REIN: kein DB-Zugriff. Admin/Wehr-CRUD rufen diese Funktion und schreiben
|
||||
* `lat/lng` selbst (kein zweiter `geocodeBrigade`-Pfad). `countrycodes=at`
|
||||
* begrenzt auf Österreich. Timeout/Abort über `geoConfig.httpTimeoutMs`.
|
||||
*
|
||||
* Wirft NIE — meldet `status: "ok" | "not_found" | "error"`.
|
||||
*/
|
||||
export async function geocodeAddress(address: string): Promise<GeocodeResult> {
|
||||
const query = address.trim();
|
||||
if (!query) return { status: "not_found" };
|
||||
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
format: "jsonv2",
|
||||
addressdetails: "0",
|
||||
limit: "1",
|
||||
countrycodes: "at",
|
||||
});
|
||||
const url = `${geoConfig.nominatimUrl}/search?${params.toString()}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), geoConfig.httpTimeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: { "User-Agent": "FlorianNetz/1.0 (self-hosted)" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { status: "error", reason: `HTTP ${res.status}` };
|
||||
}
|
||||
const rows = (await res.json()) as NominatimRow[];
|
||||
const top = rows[0];
|
||||
if (!top) return { status: "not_found" };
|
||||
|
||||
const coords: Coordinates = {
|
||||
lat: Number.parseFloat(top.lat),
|
||||
lng: Number.parseFloat(top.lon),
|
||||
};
|
||||
if (Number.isNaN(coords.lat) || Number.isNaN(coords.lng)) {
|
||||
return { status: "error", reason: "Ungültige Koordinaten." };
|
||||
}
|
||||
return { status: "ok", coords, displayName: top.display_name };
|
||||
} catch (e) {
|
||||
const reason = e instanceof Error ? e.message : "Unbekannter Fehler";
|
||||
return { status: "error", reason };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Health-Probe für Nominatim. */
|
||||
export async function nominatimHealthy(): Promise<boolean> {
|
||||
const r = await geocodeAddress("St. Pölten");
|
||||
return r.status === "ok" || r.status === "not_found";
|
||||
}
|
||||
52
src/lib/geo/osrm.ts
Normal file
52
src/lib/geo/osrm.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { geoConfig } from "./config";
|
||||
import type { Coordinates, OsrmTableResult } from "./types";
|
||||
|
||||
/**
|
||||
* Ruft die OSRM-`/table`-Matrix von genau EINER Quelle zu mehreren Zielen ab.
|
||||
* Koordinaten in OSRM-Reihenfolge `lng,lat`. `sources=0` + Annotationen
|
||||
* `duration,distance`. Wirft bei Netz-/HTTP-Fehler oder `code != "Ok"`, damit
|
||||
* der Aufrufer (orderByEintreffzeit) auf Haversine zurückfallen kann.
|
||||
*/
|
||||
export async function etaTable(
|
||||
origin: Coordinates,
|
||||
destinations: Coordinates[],
|
||||
): Promise<OsrmTableResult> {
|
||||
const all: Coordinates[] = [origin, ...destinations];
|
||||
const coordParam = all.map((c) => `${c.lng},${c.lat}`).join(";");
|
||||
const url =
|
||||
`${geoConfig.osrmUrl}/table/v1/driving/${coordParam}` +
|
||||
`?sources=0&annotations=duration,distance`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), geoConfig.httpTimeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
if (!res.ok) {
|
||||
throw new Error(`OSRM HTTP ${res.status}`);
|
||||
}
|
||||
const json = (await res.json()) as {
|
||||
code?: string;
|
||||
durations?: (number | null)[][];
|
||||
distances?: (number | null)[][];
|
||||
};
|
||||
if (json.code !== "Ok" || !json.durations) {
|
||||
throw new Error(`OSRM code ${json.code ?? "unbekannt"}`);
|
||||
}
|
||||
return {
|
||||
durations: json.durations,
|
||||
distances: json.distances ?? [],
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Health-Probe für OSRM: liefert true bei erreichbarem `/table`-Endpunkt. */
|
||||
export async function osrmHealthy(): Promise<boolean> {
|
||||
try {
|
||||
await etaTable({ lat: 48.2, lng: 15.6 }, [{ lat: 48.21, lng: 15.61 }]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
39
src/lib/geo/types.ts
Normal file
39
src/lib/geo/types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/** Gemeinsame Typen für den Geo-Workstream (rein, keine DB-/IO-Abhängigkeit). */
|
||||
|
||||
export type Coordinates = { lat: number; lng: number };
|
||||
|
||||
export type RoutingMode = "osrm" | "haversine";
|
||||
|
||||
export type EtaResult = {
|
||||
durationSec: number | null;
|
||||
distanceMeters: number | null;
|
||||
mode: RoutingMode;
|
||||
isFallback: boolean;
|
||||
};
|
||||
|
||||
export type OrderedResult<T> = T & { eta: EtaResult };
|
||||
|
||||
/** Ein Suchtreffer, der bereits mit den Koordinaten seiner Wehr angereichert ist. */
|
||||
export type GeoCandidate = { brigadeCoords: Coordinates | null };
|
||||
|
||||
/** Ergebnis einer Geokodierung (rein; siehe nominatim.ts). */
|
||||
export type GeocodeResult =
|
||||
| { status: "ok"; coords: Coordinates; displayName: string }
|
||||
| { status: "not_found" }
|
||||
| { status: "error"; reason: string };
|
||||
|
||||
/**
|
||||
* Rückgabe einer OSRM-`/table`-Abfrage mit `sources=0`.
|
||||
* `durations[0][i]` ist die Fahrzeit (Sekunden) von der Quelle zum i-ten Ziel,
|
||||
* `distances[0][i]` die Strecke (Meter). Einträge können `null` sein.
|
||||
*/
|
||||
export type OsrmTableResult = {
|
||||
durations: (number | null)[][];
|
||||
distances: (number | null)[][];
|
||||
};
|
||||
|
||||
/** Injizierbare OSRM-Tabellenfunktion (für Tests mockbar). */
|
||||
export type EtaTableFn = (
|
||||
origin: Coordinates,
|
||||
destinations: Coordinates[],
|
||||
) => Promise<OsrmTableResult>;
|
||||
Reference in New Issue
Block a user