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:
Matthias Hochmeister
2026-06-09 09:37:39 +02:00
parent ae5d3589c3
commit e8bb75412b
21 changed files with 1053 additions and 0 deletions

View 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,
});
}

View 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",
});
}

View 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>
);
}

View 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>
),
});

View 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>
);
}

View 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>
);
}

View 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([]);
});
});

View 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"]);
});
});

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>;