Kartenmodule ergaenzen

Phase 6: alles, was einen Ort hat — Loeschwasser, Objekte mit Feuerwehrplan,
die Lagekarte eines Einsatzes und die geplante Foerderstrecke.

Keine Kacheln aus dem Netz. Wo eine Wehr ausrueckt und wo ihre Hydranten
liegen, ist niemandes Geschaeft; der Kartenserver laeuft selbst
(tileserver-gl im Compose-Profil "karte"). Ohne ihn zeigt die Karte einen
leeren Grund und sagt das auch — Punkte, Entfernungen und Umkreissuche
funktionieren trotzdem. Dasselbe gilt fuer einen eingetragenen, aber nicht
erreichbaren Server.

Die Umkreissuche laeuft ueber PostGIS: ST_DWithin auf der Geografie mit
GiST-Index rechnet in Metern und nutzt den Index. Gefragt wird waehrend der
Fahrt, und bei tausend Hydranten waere alles andere ein vollstaendiger
Tabellendurchlauf.

Der Import aus GeoJSON und Stadtwerke-CSV ist nachsichtig, aber ehrlich: er
erkennt "DN 100", deutsche Kommazahlen, Datumsangaben und vertauschte Achsen
— raet aber nichts. Fehlt die Position, wird die Zeile uebersprungen; ohne
Statusangabe steht "ungeprueft", nicht "einsatzbereit".

22 taktische Zeichen nach FwDV 102 selbst gezeichnet, die Lagekarte gleicht
ueber denselben Ereignisstrom ab wie die Atemschutzueberwachung.

Die Wasserfoerderung rechnet Reibung, Hoehe und Pumpenzahl; das Ergebnis wird
nie gespeichert, sondern bei jeder Anzeige neu gerechnet. Geht eine Strecke
nicht auf, sagt die Ausgabe das.

Behoben: die Reibungsbeiwerte lagen im ersten Wurf um den Faktor zehn daneben.
Aufgefallen ist es nur, weil die Tests gegen die Tabellenwerte der Ausbildung
pruefen und nicht gegen die eigene Implementierung.

Dazu ein Abruf fuer abgelegte Dateien (/api/dateien/[id]) — bis hierher gab es
nur den Upload.

59 neue Tests (48 Unit, 11 E2E je Browser).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-04 01:41:24 +02:00
co-authored by Claude Opus 5
parent e1480c0c3e
commit 1c1e141666
53 changed files with 7804 additions and 40 deletions
+367
View File
@@ -0,0 +1,367 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { Droplets, MapPin, Plus, Search } from "lucide-react";
import { HydrantStatus, HydrantTyp } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
import { Karte, type KartenPunkt } from "@/components/karte/karte";
import { alsSvg } from "@/lib/karte/taktische-zeichen";
import { alsDatum } from "@/lib/utils";
/**
* Die Karte des Ausrückegebiets.
*
* Zwei Ebenen: Hydranten und Objekte. Beide sind ein- und ausschaltbar, denn
* wer nach Löschwasser sucht, will keine Feuerwehrpläne sehen und umgekehrt.
*
* Die Umkreissuche ist der eigentliche Zweck: **was liegt in der Nähe?** Sie
* geht von einem angetippten Punkt aus — im Einsatz von der Einsatzstelle, am
* Schreibtisch von dort, wo man gerade hinschaut.
*/
const TYP_TEXT: Record<HydrantTyp, string> = {
UNTERFLUR: "Unterflur",
UEBERFLUR: "Überflur",
SAUGSTELLE: "Saugstelle",
ZISTERNE: "Zisterne",
OFFENES_GEWAESSER: "offenes Gewässer",
SONSTIGES: "sonstige",
};
const STATUS_TEXT: Record<HydrantStatus, string> = {
EINSATZBEREIT: "einsatzbereit",
EINGESCHRAENKT: "eingeschränkt",
DEFEKT: "defekt",
UNBEKANNT: "ungeprüft",
};
const STATUS_FARBE: Record<HydrantStatus, string> = {
EINSATZBEREIT: "#15803d",
EINGESCHRAENKT: "#ca8a04",
DEFEKT: "#b91c1c",
UNBEKANNT: "#6b7280",
};
export function KartenAnsicht({
stilUrl,
mittelpunkt,
zoom,
darfBearbeiten,
}: {
stilUrl: string | null;
mittelpunkt: { breitengrad: number; laengengrad: number };
zoom: number;
darfBearbeiten: boolean;
}) {
const [suche, setSuche] = React.useState("");
const [typ, setTyp] = React.useState<HydrantTyp | "alle">("alle");
const [status, setStatus] = React.useState<HydrantStatus | "alle">("alle");
const [zeigeObjekte, setZeigeObjekte] = React.useState(true);
const [zeigeHydranten, setZeigeHydranten] = React.useState(true);
const [gewaehlt, setGewaehlt] = React.useState<{ art: "hydrant" | "objekt"; id: string } | null>(
null,
);
const [umkreisPunkt, setUmkreisPunkt] = React.useState<{
breitengrad: number;
laengengrad: number;
} | null>(null);
const [meldung, setMeldung] = React.useState<string | null>(null);
const hydranten = trpc.hydrant.liste.useQuery({
suche: suche || undefined,
typ: typ === "alle" ? undefined : typ,
status: status === "alle" ? undefined : status,
});
const objekte = trpc.objekt.liste.useQuery({ suche: suche || undefined });
const kennzahlen = trpc.hydrant.kennzahlen.useQuery();
const umkreis = trpc.hydrant.umkreis.useQuery(
umkreisPunkt ? { ...umkreisPunkt, umkreisMeter: 500 } : { breitengrad: 0, laengengrad: 0 },
{ enabled: umkreisPunkt !== null },
);
const hydrantSvg = React.useMemo(() => alsSvg("hydrant", 26), []);
const punkte = React.useMemo<KartenPunkt[]>(() => {
const liste: KartenPunkt[] = [];
if (zeigeHydranten) {
for (const hydrant of hydranten.data ?? []) {
liste.push({
id: `h-${hydrant.id}`,
breitengrad: Number(hydrant.breitengrad),
laengengrad: Number(hydrant.laengengrad),
svg: hydrantSvg.replace("color:#1d4ed8", `color:${STATUS_FARBE[hydrant.status]}`),
titel: `${hydrant.bezeichnung ?? TYP_TEXT[hydrant.typ]} — ${STATUS_TEXT[hydrant.status]}`,
beiKlick: () => setGewaehlt({ art: "hydrant", id: hydrant.id }),
});
}
}
if (zeigeObjekte) {
for (const objekt of objekte.data ?? []) {
if (objekt.breitengrad === null || objekt.laengengrad === null) continue;
liste.push({
id: `o-${objekt.id}`,
breitengrad: Number(objekt.breitengrad),
laengengrad: Number(objekt.laengengrad),
farbe: "#1d4ed8",
titel: objekt.name,
beiKlick: () => setGewaehlt({ art: "objekt", id: objekt.id }),
});
}
}
return liste;
}, [hydranten.data, objekte.data, zeigeHydranten, zeigeObjekte, hydrantSvg]);
const gewaehlterHydrant =
gewaehlt?.art === "hydrant"
? hydranten.data?.find((eintrag) => eintrag.id === gewaehlt.id)
: undefined;
const gewaehltesObjekt =
gewaehlt?.art === "objekt"
? objekte.data?.find((eintrag) => eintrag.id === gewaehlt.id)
: undefined;
return (
<div className="space-y-4" data-testid="kartenansicht">
{meldung && <Fehlermeldung meldung={meldung} kennung="kartenfehler" />}
{/* Filter */}
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-52 flex-1 space-y-1">
<Label htmlFor="kartensuche">Suchen</Label>
<div className="relative">
<Search
className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--muted-foreground)]"
aria-hidden
/>
<Input
id="kartensuche"
className="pl-9"
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Straße, Nummer, Objektname, Gefahr …"
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="kartentyp">Art</Label>
<Select value={typ} onValueChange={(wert) => setTyp(wert as HydrantTyp | "alle")}>
<SelectTrigger id="kartentyp" className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">alle Arten</SelectItem>
{(Object.keys(TYP_TEXT) as HydrantTyp[]).map((wert) => (
<SelectItem key={wert} value={wert}>
{TYP_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="kartenstatus">Zustand</Label>
<Select
value={status}
onValueChange={(wert) => setStatus(wert as HydrantStatus | "alle")}
>
<SelectTrigger id="kartenstatus" className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">alle Zustände</SelectItem>
{(Object.keys(STATUS_TEXT) as HydrantStatus[]).map((wert) => (
<SelectItem key={wert} value={wert}>
{STATUS_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
<Button
type="button"
variant={zeigeHydranten ? "default" : "outline"}
onClick={() => setZeigeHydranten((wert) => !wert)}
>
<Droplets aria-hidden />
Hydranten ({hydranten.data?.length ?? 0})
</Button>
<Button
type="button"
variant={zeigeObjekte ? "default" : "outline"}
onClick={() => setZeigeObjekte((wert) => !wert)}
>
<MapPin aria-hidden />
Objekte ({objekte.data?.length ?? 0})
</Button>
</div>
{darfBearbeiten && (
<Button variant="outline" asChild>
<Link href="/karte/hydranten">
<Plus aria-hidden />
Hydranten pflegen
</Link>
</Button>
)}
</div>
<div className="grid gap-4 lg:grid-cols-[2fr_1fr]">
<Karte
stilUrl={stilUrl}
mittelpunkt={mittelpunkt}
zoom={zoom}
punkte={punkte}
beiKartenklick={(ort) => {
setUmkreisPunkt(ort);
setGewaehlt(null);
}}
hoehe="68vh"
/>
<div className="space-y-4">
{/* Was gerade angetippt wurde. */}
{gewaehlterHydrant && (
<Card data-testid="hydrant-details">
<CardHeader>
<CardTitle>
{gewaehlterHydrant.bezeichnung ?? TYP_TEXT[gewaehlterHydrant.typ]}
</CardTitle>
<CardDescription>
{TYP_TEXT[gewaehlterHydrant.typ]} · {STATUS_TEXT[gewaehlterHydrant.status]}
</CardDescription>
</CardHeader>
<CardContent className="space-y-1 text-sm">
{gewaehlterHydrant.strasse && (
<p>
{gewaehlterHydrant.strasse} {gewaehlterHydrant.hausnummer ?? ""}
</p>
)}
<p className="tabellenzahlen">
{gewaehlterHydrant.nennweite ? `DN ${gewaehlterHydrant.nennweite}` : "Nennweite unbekannt"}
{gewaehlterHydrant.durchfluss
? ` · ${gewaehlterHydrant.durchfluss} l/min`
: ""}
</p>
<p className="text-[var(--muted-foreground)]">
{gewaehlterHydrant.letztePruefung
? `zuletzt geprüft ${alsDatum(gewaehlterHydrant.letztePruefung)}`
: "noch nie geprüft"}
</p>
{gewaehlterHydrant.hinweis && <p>{gewaehlterHydrant.hinweis}</p>}
</CardContent>
</Card>
)}
{gewaehltesObjekt && (
<Card data-testid="objekt-details">
<CardHeader>
<CardTitle>{gewaehltesObjekt.name}</CardTitle>
<CardDescription>
{gewaehltesObjekt.strasse} {gewaehltesObjekt.hausnummer}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{gewaehltesObjekt.gefahren && (
<p>
<strong>Gefahren:</strong> {gewaehltesObjekt.gefahren}
</p>
)}
<Button variant="outline" size="sm" asChild>
<Link href={`/karte/objekte/${gewaehltesObjekt.id}`}>Feuerwehrplan öffnen</Link>
</Button>
</CardContent>
</Card>
)}
{/* Umkreissuche */}
<Card>
<CardHeader>
<CardTitle>In der Nähe</CardTitle>
<CardDescription>
{umkreisPunkt
? "Löschwasser im Umkreis von 500 Metern, nach Entfernung."
: "In die Karte tippen — dann steht hier, was in 500 Metern erreichbar ist."}
</CardDescription>
</CardHeader>
<CardContent>
{umkreisPunkt && (umkreis.data?.length ?? 0) === 0 && !umkreis.isPending && (
<p className="text-sm text-[var(--gesperrt)]">
Kein Löschwasser im Umkreis von 500 Metern. Das ist eine Angabe, die man vor
dem Einsatz wissen will, nicht währenddessen.
</p>
)}
<ul className="space-y-2 text-sm">
{umkreis.data?.map((treffer) => (
<li key={treffer.id} className="flex items-start justify-between gap-3">
<span>
<strong>{treffer.bezeichnung ?? TYP_TEXT[treffer.typ]}</strong>
<span className="block text-[var(--muted-foreground)]">
{treffer.strasse ?? "ohne Straße"}
{treffer.nennweite ? ` · DN ${treffer.nennweite}` : ""}
{treffer.durchfluss ? ` · ${treffer.durchfluss} l/min` : ""}
</span>
</span>
<span
className="tabellenzahlen whitespace-nowrap"
style={{ color: STATUS_FARBE[treffer.status] }}
>
{treffer.entfernung} m
</span>
</li>
))}
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Bestand</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-3 gap-3 text-sm">
<div>
<p className="text-[var(--muted-foreground)]">gesamt</p>
<p className="tabellenzahlen text-2xl font-semibold">
{kennzahlen.data?.gesamt ?? 0}
</p>
</div>
<div>
<p className="text-[var(--muted-foreground)]">defekt</p>
<p className="tabellenzahlen text-2xl font-semibold text-[var(--gesperrt)]">
{kennzahlen.data?.defekt ?? 0}
</p>
</div>
<div>
<p className="text-[var(--muted-foreground)]">ungeprüft</p>
<p className="tabellenzahlen text-2xl font-semibold text-[var(--pruefung-faellig)]">
{kennzahlen.data?.ungeprueft ?? 0}
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}