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:
co-authored by
Claude Opus 5
parent
e1480c0c3e
commit
1c1e141666
@@ -0,0 +1,387 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Radio, Undo2, WifiOff } from "lucide-react";
|
||||
import { LageelementArt } from "@prisma/client";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
|
||||
import { Karte, type KartenLinie, type KartenPunkt } from "@/components/karte/karte";
|
||||
import { useEreignisstrom } from "@/components/atemschutz/stand";
|
||||
import { GRUPPE_TEXT, ZEICHEN, alsSvg, type Zeichengruppe } from "@/lib/karte/taktische-zeichen";
|
||||
|
||||
/**
|
||||
* Die Lagekarte eines Einsatzes.
|
||||
*
|
||||
* Der gemeinsame Zeichenblock: Zeichen setzen, Linien ziehen, beschriften —
|
||||
* und alle sehen dasselbe. Verteilt über denselben Ereignisstrom wie die
|
||||
* Atemschutzüberwachung aus Phase 5, mit demselben Hook.
|
||||
*
|
||||
* Drei Entscheidungen, die sich im Einsatz bewähren müssen:
|
||||
*
|
||||
* 1. **Ein Werkzeug, ein Klick.** Zeichen wählen, in die Karte tippen,
|
||||
* fertig. Kein Dialog dazwischen — wer im Einsatz zeichnet, hat eine Hand
|
||||
* am Funkgerät.
|
||||
* 2. **Zurücknehmen statt löschen.** Was um 14:12 eingezeichnet wurde,
|
||||
* gehört zur Mitschrift, auch wenn es um 14:20 nicht mehr stimmte. Es
|
||||
* verschwindet von der Karte, nicht aus dem Datenbestand.
|
||||
* 3. **Der Verbindungszustand steht sichtbar da.** Wer nicht sieht, dass der
|
||||
* Draht weg ist, hält eine alte Lage für die aktuelle.
|
||||
*/
|
||||
|
||||
/** Wie oft ohne Ereignis nachgefragt wird. */
|
||||
const NACHFRAGE_MS = 5000;
|
||||
|
||||
type Werkzeug = { art: "zeichen"; schluessel: string } | { art: "linie" } | { art: "text" };
|
||||
|
||||
export function Lagekarte({
|
||||
einsatzId,
|
||||
stilUrl,
|
||||
mittelpunkt,
|
||||
zoom,
|
||||
darfBearbeiten,
|
||||
}: {
|
||||
einsatzId: string;
|
||||
stilUrl: string | null;
|
||||
mittelpunkt: { breitengrad: number; laengengrad: number };
|
||||
zoom: number;
|
||||
darfBearbeiten: boolean;
|
||||
}) {
|
||||
const [werkzeug, setWerkzeug] = React.useState<Werkzeug | null>(null);
|
||||
const [linienpunkte, setLinienpunkte] = React.useState<Array<[number, number]>>([]);
|
||||
const [beschriftung, setBeschriftung] = React.useState("");
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
|
||||
const werkzeuge = trpc.useUtils();
|
||||
|
||||
const karte = trpc.lagekarte.fuerEinsatz.useQuery(
|
||||
{ einsatzId },
|
||||
{ refetchInterval: NACHFRAGE_MS },
|
||||
);
|
||||
|
||||
const neuLaden = React.useCallback(() => {
|
||||
void werkzeuge.lagekarte.fuerEinsatz.invalidate({ einsatzId });
|
||||
}, [einsatzId, werkzeuge]);
|
||||
|
||||
// Derselbe Hook wie beim Atemschutz — nur eine andere Adresse.
|
||||
const { verbunden } = useEreignisstrom(einsatzId, neuLaden, "/api/lagekarte/strom?einsatz=");
|
||||
|
||||
const eintragen = trpc.lagekarte.eintragen.useMutation({
|
||||
onSuccess: () => {
|
||||
setLinienpunkte([]);
|
||||
setBeschriftung("");
|
||||
neuLaden();
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const zuruecknehmen = trpc.lagekarte.zuruecknehmen.useMutation({
|
||||
onSuccess: neuLaden,
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const elemente = karte.data?.elemente ?? [];
|
||||
|
||||
const punkte = React.useMemo<KartenPunkt[]>(() => {
|
||||
const liste: KartenPunkt[] = [];
|
||||
|
||||
for (const element of elemente) {
|
||||
const geometrie = element.geometrie as Array<[number, number]>;
|
||||
if (!Array.isArray(geometrie) || geometrie.length === 0) continue;
|
||||
|
||||
if (element.art === LageelementArt.ZEICHEN && element.zeichen) {
|
||||
liste.push({
|
||||
id: element.id,
|
||||
laengengrad: geometrie[0]![0],
|
||||
breitengrad: geometrie[0]![1],
|
||||
svg: alsSvg(element.zeichen, 36, element.farbe ?? undefined),
|
||||
titel: element.text ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (element.art === LageelementArt.TEXT) {
|
||||
liste.push({
|
||||
id: element.id,
|
||||
laengengrad: geometrie[0]![0],
|
||||
breitengrad: geometrie[0]![1],
|
||||
farbe: element.farbe ?? "#111827",
|
||||
titel: element.text ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Punkte einer Linie, die gerade gezogen wird.
|
||||
linienpunkte.forEach((punkt, index) => {
|
||||
liste.push({
|
||||
id: `entwurf-${index}`,
|
||||
laengengrad: punkt[0],
|
||||
breitengrad: punkt[1],
|
||||
farbe: "#ca8a04",
|
||||
titel: `Punkt ${index + 1}`,
|
||||
});
|
||||
});
|
||||
|
||||
return liste;
|
||||
}, [elemente, linienpunkte]);
|
||||
|
||||
const linien = React.useMemo<KartenLinie[]>(() => {
|
||||
const liste: KartenLinie[] = [];
|
||||
|
||||
for (const element of elemente) {
|
||||
if (element.art !== LageelementArt.LINIE && element.art !== LageelementArt.FLAECHE) continue;
|
||||
|
||||
const geometrie = element.geometrie as Array<[number, number]>;
|
||||
if (!Array.isArray(geometrie) || geometrie.length < 2) continue;
|
||||
|
||||
liste.push({
|
||||
id: element.id,
|
||||
punkte: geometrie,
|
||||
farbe: element.farbe ?? "#b91c1c",
|
||||
flaeche: element.art === LageelementArt.FLAECHE,
|
||||
});
|
||||
}
|
||||
|
||||
if (linienpunkte.length >= 2) {
|
||||
liste.push({ id: "entwurf", punkte: linienpunkte, farbe: "#ca8a04", gestrichelt: true });
|
||||
}
|
||||
|
||||
return liste;
|
||||
}, [elemente, linienpunkte]);
|
||||
|
||||
function kartenklick(ort: { breitengrad: number; laengengrad: number }) {
|
||||
if (!darfBearbeiten || !werkzeug) return;
|
||||
|
||||
if (werkzeug.art === "zeichen") {
|
||||
eintragen.mutate({
|
||||
einsatzId,
|
||||
art: LageelementArt.ZEICHEN,
|
||||
zeichen: werkzeug.schluessel,
|
||||
geometrie: [[ort.laengengrad, ort.breitengrad]],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (werkzeug.art === "text") {
|
||||
if (!beschriftung.trim()) {
|
||||
setMeldung("Erst den Text eintippen, dann in die Karte tippen.");
|
||||
return;
|
||||
}
|
||||
eintragen.mutate({
|
||||
einsatzId,
|
||||
art: LageelementArt.TEXT,
|
||||
text: beschriftung.trim(),
|
||||
geometrie: [[ort.laengengrad, ort.breitengrad]],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Linie: Punkte sammeln, bis jemand abschließt.
|
||||
setLinienpunkte((bisher) => [...bisher, [ort.laengengrad, ort.breitengrad]]);
|
||||
}
|
||||
|
||||
const nachGruppe = React.useMemo(() => {
|
||||
const karten = new Map<Zeichengruppe, typeof ZEICHEN>();
|
||||
for (const zeichen of ZEICHEN) {
|
||||
const liste = karten.get(zeichen.gruppe) ?? [];
|
||||
liste.push(zeichen);
|
||||
karten.set(zeichen.gruppe, liste);
|
||||
}
|
||||
return karten;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-testid="lagekarte">
|
||||
{meldung && <Fehlermeldung meldung={meldung} kennung="lagekartenfehler" />}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{elemente.length === 0
|
||||
? "Noch nichts eingezeichnet."
|
||||
: `${elemente.length} ${elemente.length === 1 ? "Eintrag" : "Einträge"} — alle Geräte sehen dasselbe.`}
|
||||
</p>
|
||||
|
||||
<span
|
||||
className={`flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm ${
|
||||
verbunden
|
||||
? "border-[var(--einsatzbereit)]/40 bg-[var(--einsatzbereit)]/10 text-[var(--einsatzbereit)]"
|
||||
: "border-[var(--gesperrt)]/40 bg-[var(--gesperrt)]/10 text-[var(--gesperrt)]"
|
||||
}`}
|
||||
role="status"
|
||||
data-testid="lagekarte-verbindung"
|
||||
data-verbunden={verbunden}
|
||||
>
|
||||
{verbunden ? <Radio className="size-4" aria-hidden /> : <WifiOff className="size-4" aria-hidden />}
|
||||
{verbunden ? "Abgleich läuft" : "Abgleich unterbrochen"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{darfBearbeiten && (
|
||||
<div className="space-y-3 rounded-lg border border-[var(--border)] p-3">
|
||||
{/* Zeichenauswahl nach Gruppen. */}
|
||||
{[...nachGruppe.entries()].map(([gruppe, zeichenliste]) => (
|
||||
<div key={gruppe} className="space-y-1">
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
{GRUPPE_TEXT[gruppe]}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{zeichenliste.map((zeichen) => {
|
||||
const gewaehlt =
|
||||
werkzeug?.art === "zeichen" && werkzeug.schluessel === zeichen.schluessel;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={zeichen.schluessel}
|
||||
type="button"
|
||||
title={zeichen.name}
|
||||
aria-label={zeichen.name}
|
||||
aria-pressed={gewaehlt}
|
||||
data-testid={`zeichen-${zeichen.schluessel}`}
|
||||
onClick={() =>
|
||||
setWerkzeug(
|
||||
gewaehlt ? null : { art: "zeichen", schluessel: zeichen.schluessel },
|
||||
)
|
||||
}
|
||||
className={`rounded-md border p-1 ${
|
||||
gewaehlt
|
||||
? "border-[var(--primary)] bg-[var(--primary)]/10"
|
||||
: "border-[var(--border)]"
|
||||
}`}
|
||||
dangerouslySetInnerHTML={{ __html: alsSvg(zeichen.schluessel, 32) }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex flex-wrap items-end gap-2 border-t border-[var(--border)] pt-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant={werkzeug?.art === "linie" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setWerkzeug(werkzeug?.art === "linie" ? null : { art: "linie" });
|
||||
setLinienpunkte([]);
|
||||
}}
|
||||
>
|
||||
Linie ziehen
|
||||
</Button>
|
||||
|
||||
{werkzeug?.art === "linie" && linienpunkte.length >= 2 && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
eintragen.mutate({
|
||||
einsatzId,
|
||||
art: LageelementArt.LINIE,
|
||||
geometrie: linienpunkte,
|
||||
})
|
||||
}
|
||||
>
|
||||
Linie übernehmen ({linienpunkte.length} Punkte)
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
eintragen.mutate({
|
||||
einsatzId,
|
||||
art: LageelementArt.FLAECHE,
|
||||
geometrie: linienpunkte,
|
||||
})
|
||||
}
|
||||
>
|
||||
Als Fläche
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<Input
|
||||
className="w-52"
|
||||
value={beschriftung}
|
||||
onChange={(ereignis) => setBeschriftung(ereignis.target.value)}
|
||||
placeholder="Beschriftung"
|
||||
aria-label="Beschriftung"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant={werkzeug?.art === "text" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setWerkzeug(werkzeug?.art === "text" ? null : { art: "text" })}
|
||||
>
|
||||
Text setzen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{werkzeug && (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{werkzeug.art === "linie"
|
||||
? "In die Karte tippen setzt Punkte."
|
||||
: "In die Karte tippen setzt das Gewählte."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Karte
|
||||
stilUrl={stilUrl}
|
||||
mittelpunkt={
|
||||
karte.data?.breitengrad && karte.data?.laengengrad
|
||||
? {
|
||||
breitengrad: Number(karte.data.breitengrad),
|
||||
laengengrad: Number(karte.data.laengengrad),
|
||||
}
|
||||
: mittelpunkt
|
||||
}
|
||||
zoom={karte.data?.zoomstufe ?? zoom}
|
||||
punkte={punkte}
|
||||
linien={linien}
|
||||
beiKartenklick={darfBearbeiten ? kartenklick : undefined}
|
||||
hoehe="60vh"
|
||||
fadenkreuz={Boolean(werkzeug)}
|
||||
/>
|
||||
|
||||
{/* Was eingetragen wurde — mit der Möglichkeit, es zurückzunehmen. */}
|
||||
{elemente.length > 0 && (
|
||||
<ul className="space-y-1 text-sm">
|
||||
{elemente.map((element) => (
|
||||
<li key={element.id} className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{new Date(element.erstelltAm).toLocaleTimeString("de-DE", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{element.zeichen ?? element.art.toLowerCase()}
|
||||
{element.text ? ` — ${element.text}` : ""}
|
||||
</span>
|
||||
{element.erstelltVon && (
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{element.erstelltVon.nachname}
|
||||
</span>
|
||||
)}
|
||||
{darfBearbeiten && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={() => zuruecknehmen.mutate({ id: element.id })}
|
||||
>
|
||||
<Undo2 aria-hidden />
|
||||
zurücknehmen
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,11 @@ import { ArrowLeft } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { env } from "@/lib/env";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { EinsatzDetail } from "@/app/(app)/einsaetze/[id]/einsatz-detail";
|
||||
import { Lagekarte } from "@/app/(app)/einsaetze/[id]/lagekarte";
|
||||
|
||||
export const metadata = { title: "Einsatz" };
|
||||
|
||||
@@ -21,6 +25,32 @@ export default async function EinsatzSeite({ params }: { params: Promise<{ id: s
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// Mittelpunkt der Lagekarte: die Einsatzstelle, sonst das Gerätehaus, sonst
|
||||
// die Vorgabe aus der Konfiguration.
|
||||
const orte = await fuerOrganisation(sitzung.organisationId, async (db) => {
|
||||
const [einsatz, standort] = await Promise.all([
|
||||
db.einsatz.findUnique({
|
||||
where: { id },
|
||||
select: { breitengrad: true, laengengrad: true },
|
||||
}),
|
||||
db.standort.findFirst({
|
||||
where: { aktiv: true, geloeschtAm: null, breitengrad: { not: null } },
|
||||
select: { breitengrad: true, laengengrad: true },
|
||||
orderBy: { erstelltAm: "asc" },
|
||||
}),
|
||||
]);
|
||||
return { einsatz, standort };
|
||||
});
|
||||
|
||||
const mittelpunkt = {
|
||||
breitengrad: Number(
|
||||
orte.einsatz?.breitengrad ?? orte.standort?.breitengrad ?? env.KARTE_BREITENGRAD,
|
||||
),
|
||||
laengengrad: Number(
|
||||
orte.einsatz?.laengengrad ?? orte.standort?.laengengrad ?? env.KARTE_LAENGENGRAD,
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -37,6 +67,30 @@ export default async function EinsatzSeite({ params }: { params: Promise<{ id: s
|
||||
darfBearbeiten={hatRecht(sitzung, "einsatz.bearbeiten")}
|
||||
darfFreigeben={hatRecht(sitzung, "einsatz.bericht")}
|
||||
/>
|
||||
|
||||
{/*
|
||||
Die Lagekarte (Phase 6). Sie steht unter dem Einsatz und nicht in einem
|
||||
eigenen Reiter: wer sie braucht, braucht sie zusammen mit den Zeiten und
|
||||
den Lagemeldungen, nicht statt ihnen.
|
||||
*/}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lagekarte</CardTitle>
|
||||
<CardDescription>
|
||||
Taktische Zeichen nach FwDV 102. Was einer einträgt, sehen alle — auch auf einem
|
||||
zweiten Gerät.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Lagekarte
|
||||
einsatzId={id}
|
||||
stilUrl={env.KARTE_STIL_URL ?? null}
|
||||
mittelpunkt={mittelpunkt}
|
||||
zoom={env.KARTE_ZOOM + 3}
|
||||
darfBearbeiten={hatRecht(sitzung, "einsatz.bearbeiten")}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user