diff --git a/docker-compose.geo.yml b/docker-compose.geo.yml new file mode 100644 index 0000000..c28865a --- /dev/null +++ b/docker-compose.geo.yml @@ -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 diff --git a/docker/osrm/Dockerfile b/docker/osrm/Dockerfile new file mode 100644 index 0000000..2fc3db3 --- /dev/null +++ b/docker/osrm/Dockerfile @@ -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 diff --git a/infra/geo/Makefile b/infra/geo/Makefile new file mode 100644 index 0000000..4da1c3f --- /dev/null +++ b/infra/geo/Makefile @@ -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 diff --git a/infra/geo/README.md b/infra/geo/README.md new file mode 100644 index 0000000..3456cba --- /dev/null +++ b/infra/geo/README.md @@ -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 +``` diff --git a/scripts/prepare-osm-data.sh b/scripts/prepare-osm-data.sh new file mode 100755 index 0000000..d868d4c --- /dev/null +++ b/scripts/prepare-osm-data.sh @@ -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." diff --git a/src/app/api/geo/geocode/route.ts b/src/app/api/geo/geocode/route.ts new file mode 100644 index 0000000..e0328c5 --- /dev/null +++ b/src/app/api/geo/geocode/route.ts @@ -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, + }); +} diff --git a/src/app/api/geo/health/route.ts b/src/app/api/geo/health/route.ts new file mode 100644 index 0000000..e3c2566 --- /dev/null +++ b/src/app/api/geo/health/route.ts @@ -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", + }); +} diff --git a/src/components/geo/eta-badge.tsx b/src/components/geo/eta-badge.tsx new file mode 100644 index 0000000..79840cc --- /dev/null +++ b/src/components/geo/eta-badge.tsx @@ -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 ( + + — + + ); + } + + const label = formatMinutes(eta.durationSec); + + if (eta.isFallback) { + return ( + + ≈ {label} + + {t("search.luftlinie")} + + + ); + } + + return ( + + {label} + + ); +} diff --git a/src/components/geo/karte-dynamic.tsx b/src/components/geo/karte-dynamic.tsx new file mode 100644 index 0000000..8180f25 --- /dev/null +++ b/src/components/geo/karte-dynamic.tsx @@ -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(() => import("./karte"), { + ssr: false, + loading: () => ( +
+ Karte wird geladen … +
+ ), +}); diff --git a/src/components/geo/karte.tsx b/src/components/geo/karte.tsx new file mode 100644 index 0000000..99839b3 --- /dev/null +++ b/src/components/geo/karte.tsx @@ -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 ( +
+
+

+ Zentrum: {center.lat.toFixed(4)}, {center.lng.toFixed(4)} +

+
    + {marker.map((m) => ( +
  • + {m.label} — {m.coords.lat.toFixed(4)}, {m.coords.lng.toFixed(4)} +
  • + ))} +
+
+
+ ); +} diff --git a/src/components/geo/standort-input.tsx b/src/components/geo/standort-input.tsx new file mode 100644 index 0000000..750f8b1 --- /dev/null +++ b/src/components/geo/standort-input.tsx @@ -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("idle"); + const [message, setMessage] = React.useState(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 ( +
+
+ setAddress(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void geocode(); + } + }} + placeholder="Adresse oder Ort" + aria-label="Adresse" + disabled={busy} + /> + +
+ + {busy ? ( +

{t("aktion.laden")}

+ ) : null} + {message ?

{message}

: null} +
+ ); +} diff --git a/src/lib/geo/__tests__/candidates.test.ts b/src/lib/geo/__tests__/candidates.test.ts new file mode 100644 index 0000000..70faefd --- /dev/null +++ b/src/lib/geo/__tests__/candidates.test.ts @@ -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) => + (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([]); + }); +}); diff --git a/src/lib/geo/__tests__/eintreffzeit.test.ts b/src/lib/geo/__tests__/eintreffzeit.test.ts new file mode 100644 index 0000000..fec4fbe --- /dev/null +++ b/src/lib/geo/__tests__/eintreffzeit.test.ts @@ -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"]); + }); +}); diff --git a/src/lib/geo/__tests__/haversine.test.ts b/src/lib/geo/__tests__/haversine.test.ts new file mode 100644 index 0000000..c1e6012 --- /dev/null +++ b/src/lib/geo/__tests__/haversine.test.ts @@ -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, + ); + }); +}); diff --git a/src/lib/geo/candidates.ts b/src/lib/geo/candidates.ts new file mode 100644 index 0000000..ea716de --- /dev/null +++ b/src/lib/geo/candidates.ts @@ -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( + 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( + 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( + 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, + ); +} diff --git a/src/lib/geo/config.ts b/src/lib/geo/config.ts new file mode 100644 index 0000000..08ab6cb --- /dev/null +++ b/src/lib/geo/config.ts @@ -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; +} diff --git a/src/lib/geo/eintreffzeit.ts b/src/lib/geo/eintreffzeit.ts new file mode 100644 index 0000000..5bdf982 --- /dev/null +++ b/src/lib/geo/eintreffzeit.ts @@ -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( + origin: Coordinates, + candidates: T[], + etaTable: EtaTableFn = defaultEtaTable, +): Promise[]> { + 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[] = 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[] = 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 { + 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, + }; +} diff --git a/src/lib/geo/haversine.ts b/src/lib/geo/haversine.ts new file mode 100644 index 0000000..add09dd --- /dev/null +++ b/src/lib/geo/haversine.ts @@ -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))); +} diff --git a/src/lib/geo/nominatim.ts b/src/lib/geo/nominatim.ts new file mode 100644 index 0000000..c7b6a26 --- /dev/null +++ b/src/lib/geo/nominatim.ts @@ -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 { + 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 { + const r = await geocodeAddress("St. Pölten"); + return r.status === "ok" || r.status === "not_found"; +} diff --git a/src/lib/geo/osrm.ts b/src/lib/geo/osrm.ts new file mode 100644 index 0000000..102cdc4 --- /dev/null +++ b/src/lib/geo/osrm.ts @@ -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 { + 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 { + try { + await etaTable({ lat: 48.2, lng: 15.6 }, [{ lat: 48.21, lng: 15.61 }]); + return true; + } catch { + return false; + } +} diff --git a/src/lib/geo/types.ts b/src/lib/geo/types.ts new file mode 100644 index 0000000..65020ef --- /dev/null +++ b/src/lib/geo/types.ts @@ -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 & { 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;