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,315 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as maplibregl from "maplibre-gl";
|
||||
import type { LngLatLike, Map as MapLibreKarte, MapMouseEvent } from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
|
||||
/**
|
||||
* Die Karte.
|
||||
*
|
||||
* MapLibre GL, aus dem eigenen Paketstand — kein Skript und kein Stylesheet von
|
||||
* einem fremden Server. Die Kacheln kommen aus dem eigenen tileserver-gl
|
||||
* (`KARTE_STIL_URL`); ist keiner eingerichtet, zeichnet die Karte ein
|
||||
* Gitternetz auf leerem Grund. Das ist bewusst so:
|
||||
*
|
||||
* * **Keine Kacheln aus dem Netz.** Wo eine Wehr ausrückt und wo ihre
|
||||
* Hydranten liegen, ist niemandes Geschäft. Es gibt deshalb keinen
|
||||
* Rückfall auf einen öffentlichen Kartendienst — auch nicht „nur zum
|
||||
* Ausprobieren".
|
||||
* * **Ohne Kacheln trotzdem brauchbar.** Punkte liegen an der richtigen
|
||||
* Stelle, Entfernungen stimmen, Zeichnen geht. Nur die Straßen darunter
|
||||
* fehlen. Zum Erfassen reicht das, für den Einsatz nicht — und das steht
|
||||
* auch so auf der Karte.
|
||||
*/
|
||||
|
||||
/** Ein Stil ohne Kacheln: grauer Grund, Gitternetz, sonst nichts. */
|
||||
const LEERER_STIL = {
|
||||
version: 8 as const,
|
||||
sources: {},
|
||||
layers: [
|
||||
{
|
||||
id: "hintergrund",
|
||||
type: "background" as const,
|
||||
paint: { "background-color": "#eceae6" },
|
||||
},
|
||||
],
|
||||
glyphs: undefined,
|
||||
};
|
||||
|
||||
export interface KartenPunkt {
|
||||
id: string;
|
||||
breitengrad: number;
|
||||
laengengrad: number;
|
||||
/** SVG-Inhalt für den Marker. Ohne wird ein einfacher Kreis gezeichnet. */
|
||||
svg?: string;
|
||||
farbe?: string;
|
||||
titel?: string;
|
||||
/** Wird beim Antippen aufgerufen. */
|
||||
beiKlick?: () => void;
|
||||
}
|
||||
|
||||
export interface KartenLinie {
|
||||
id: string;
|
||||
/** [[laenge, breite], …] */
|
||||
punkte: Array<[number, number]>;
|
||||
farbe?: string;
|
||||
gestrichelt?: boolean;
|
||||
/** Geschlossene Fläche statt Linienzug. */
|
||||
flaeche?: boolean;
|
||||
}
|
||||
|
||||
export interface KartenEigenschaften {
|
||||
stilUrl: string | null;
|
||||
mittelpunkt: { breitengrad: number; laengengrad: number };
|
||||
zoom?: number;
|
||||
punkte?: KartenPunkt[];
|
||||
linien?: KartenLinie[];
|
||||
/** Klick in die Karte — für „hier einen Hydranten setzen". */
|
||||
beiKartenklick?: (ort: { breitengrad: number; laengengrad: number }) => void;
|
||||
/** Wenn sich der Ausschnitt beruhigt hat. */
|
||||
beiAusschnitt?: (ausschnitt: {
|
||||
breitengrad: number;
|
||||
laengengrad: number;
|
||||
zoom: number;
|
||||
}) => void;
|
||||
hoehe?: string;
|
||||
/** Fadenkreuz in der Mitte — beim Setzen eines Punktes. */
|
||||
fadenkreuz?: boolean;
|
||||
}
|
||||
|
||||
export function Karte({
|
||||
stilUrl,
|
||||
mittelpunkt,
|
||||
zoom = 13,
|
||||
punkte = [],
|
||||
linien = [],
|
||||
beiKartenklick,
|
||||
beiAusschnitt,
|
||||
hoehe = "70vh",
|
||||
fadenkreuz = false,
|
||||
}: KartenEigenschaften) {
|
||||
const behaelter = React.useRef<HTMLDivElement | null>(null);
|
||||
const karte = React.useRef<MapLibreKarte | null>(null);
|
||||
const markierungen = React.useRef<maplibregl.Marker[]>([]);
|
||||
|
||||
/**
|
||||
* Ist der Kartenserver erreichbar?
|
||||
*
|
||||
* Ein eingetragener, aber nicht erreichbarer Server ist der häufigere Fall
|
||||
* als gar keiner: der Container läuft nicht, die MBTiles-Datei fehlt, die
|
||||
* Adresse hat sich geändert. Dann soll die Karte aussehen wie ohne
|
||||
* Kartenserver — leerer Grund mit Hinweis — und nicht wie ein Fehler. Die
|
||||
* Punkte liegen ohnehin richtig.
|
||||
*/
|
||||
const [stilGeladen, setStilGeladen] = React.useState(true);
|
||||
|
||||
// Rückrufe über Referenzen: sonst würde die Karte bei jedem Rendern neu
|
||||
// aufgebaut — dieselbe Falle wie beim Ereignisstrom in Phase 5.
|
||||
const klickRef = React.useRef(beiKartenklick);
|
||||
klickRef.current = beiKartenklick;
|
||||
const ausschnittRef = React.useRef(beiAusschnitt);
|
||||
ausschnittRef.current = beiAusschnitt;
|
||||
|
||||
// --- Karte aufbauen ------------------------------------------------------
|
||||
React.useEffect(() => {
|
||||
if (!behaelter.current || karte.current) return;
|
||||
|
||||
const gl = new maplibregl.Map({
|
||||
container: behaelter.current,
|
||||
style: stilUrl ?? (LEERER_STIL as maplibregl.StyleSpecification),
|
||||
center: [mittelpunkt.laengengrad, mittelpunkt.breitengrad] as LngLatLike,
|
||||
zoom,
|
||||
// Keine Attributions-Links nach draußen, keine Telemetrie.
|
||||
attributionControl: false,
|
||||
});
|
||||
|
||||
gl.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
|
||||
gl.addControl(
|
||||
new maplibregl.ScaleControl({ maxWidth: 120, unit: "metric" }),
|
||||
"bottom-left",
|
||||
);
|
||||
|
||||
gl.on("click", (ereignis: MapMouseEvent) => {
|
||||
klickRef.current?.({
|
||||
breitengrad: ereignis.lngLat.lat,
|
||||
laengengrad: ereignis.lngLat.lng,
|
||||
});
|
||||
});
|
||||
|
||||
gl.on("error", (ereignis) => {
|
||||
// MapLibre meldet hier alles, was beim Laden schiefgeht. Für den
|
||||
// Stil selbst heißt das: es gibt keinen Hintergrund.
|
||||
const meldung = String((ereignis as { error?: unknown }).error ?? "");
|
||||
if (meldung.includes("style") || meldung.includes("Failed to fetch")) {
|
||||
setStilGeladen(false);
|
||||
}
|
||||
});
|
||||
|
||||
gl.on("moveend", () => {
|
||||
const mitte = gl.getCenter();
|
||||
ausschnittRef.current?.({
|
||||
breitengrad: mitte.lat,
|
||||
laengengrad: mitte.lng,
|
||||
zoom: gl.getZoom(),
|
||||
});
|
||||
});
|
||||
|
||||
karte.current = gl;
|
||||
|
||||
return () => {
|
||||
gl.remove();
|
||||
karte.current = null;
|
||||
};
|
||||
// Nur einmal: Ausschnitt und Inhalte werden unten nachgeführt.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stilUrl]);
|
||||
|
||||
// --- Markierungen nachführen ---------------------------------------------
|
||||
React.useEffect(() => {
|
||||
const gl = karte.current;
|
||||
if (!gl) return;
|
||||
|
||||
for (const markierung of markierungen.current) markierung.remove();
|
||||
markierungen.current = [];
|
||||
|
||||
for (const punkt of punkte) {
|
||||
const element = document.createElement("div");
|
||||
element.style.cursor = punkt.beiKlick ? "pointer" : "default";
|
||||
element.title = punkt.titel ?? "";
|
||||
|
||||
if (punkt.svg) {
|
||||
element.innerHTML = punkt.svg;
|
||||
} else {
|
||||
element.style.width = "16px";
|
||||
element.style.height = "16px";
|
||||
element.style.borderRadius = "50%";
|
||||
element.style.border = "2px solid white";
|
||||
element.style.background = punkt.farbe ?? "#b91c1c";
|
||||
element.style.boxShadow = "0 1px 4px rgba(0,0,0,.4)";
|
||||
}
|
||||
|
||||
if (punkt.beiKlick) {
|
||||
element.addEventListener("click", (ereignis) => {
|
||||
ereignis.stopPropagation();
|
||||
punkt.beiKlick?.();
|
||||
});
|
||||
}
|
||||
|
||||
markierungen.current.push(
|
||||
new maplibregl.Marker({ element })
|
||||
.setLngLat([punkt.laengengrad, punkt.breitengrad])
|
||||
.addTo(gl),
|
||||
);
|
||||
}
|
||||
}, [punkte]);
|
||||
|
||||
// --- Linien und Flächen ---------------------------------------------------
|
||||
React.useEffect(() => {
|
||||
const gl = karte.current;
|
||||
if (!gl) return;
|
||||
|
||||
const zeichnen = () => {
|
||||
// Erst abräumen, was nicht mehr dazugehört.
|
||||
const vorhandene = gl.getStyle()?.layers ?? [];
|
||||
for (const ebene of vorhandene) {
|
||||
if (ebene.id.startsWith("linie-") || ebene.id.startsWith("flaeche-")) {
|
||||
if (gl.getLayer(ebene.id)) gl.removeLayer(ebene.id);
|
||||
}
|
||||
}
|
||||
for (const id of Object.keys(gl.getStyle()?.sources ?? {})) {
|
||||
if (id.startsWith("geo-") && gl.getSource(id)) gl.removeSource(id);
|
||||
}
|
||||
|
||||
for (const linie of linien) {
|
||||
const quelle = `geo-${linie.id}`;
|
||||
|
||||
gl.addSource(quelle, {
|
||||
type: "geojson",
|
||||
data: {
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: linie.flaeche
|
||||
? { type: "Polygon", coordinates: [[...linie.punkte, linie.punkte[0]!]] }
|
||||
: { type: "LineString", coordinates: linie.punkte },
|
||||
},
|
||||
});
|
||||
|
||||
if (linie.flaeche) {
|
||||
gl.addLayer({
|
||||
id: `flaeche-${linie.id}`,
|
||||
type: "fill",
|
||||
source: quelle,
|
||||
paint: { "fill-color": linie.farbe ?? "#b91c1c", "fill-opacity": 0.2 },
|
||||
});
|
||||
}
|
||||
|
||||
gl.addLayer({
|
||||
id: `linie-${linie.id}`,
|
||||
type: "line",
|
||||
source: quelle,
|
||||
paint: {
|
||||
"line-color": linie.farbe ?? "#b91c1c",
|
||||
"line-width": 4,
|
||||
...(linie.gestrichelt ? { "line-dasharray": [2, 2] } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (gl.isStyleLoaded()) {
|
||||
zeichnen();
|
||||
} else {
|
||||
gl.once("load", zeichnen);
|
||||
}
|
||||
}, [linien]);
|
||||
|
||||
// --- Ausschnitt von außen setzen -----------------------------------------
|
||||
React.useEffect(() => {
|
||||
const gl = karte.current;
|
||||
if (!gl) return;
|
||||
|
||||
const mitte = gl.getCenter();
|
||||
const weitWeg =
|
||||
Math.abs(mitte.lat - mittelpunkt.breitengrad) > 0.0005 ||
|
||||
Math.abs(mitte.lng - mittelpunkt.laengengrad) > 0.0005;
|
||||
|
||||
if (weitWeg) {
|
||||
gl.easeTo({
|
||||
center: [mittelpunkt.laengengrad, mittelpunkt.breitengrad],
|
||||
duration: 600,
|
||||
});
|
||||
}
|
||||
}, [mittelpunkt.breitengrad, mittelpunkt.laengengrad]);
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-lg border border-[var(--border)]">
|
||||
<div ref={behaelter} style={{ height: hoehe }} data-testid="karte" />
|
||||
|
||||
{fadenkreuz && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="size-8 rounded-full border-2 border-[var(--gesperrt)]" />
|
||||
<div className="absolute h-8 w-0.5 bg-[var(--gesperrt)]" />
|
||||
<div className="absolute h-0.5 w-8 bg-[var(--gesperrt)]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!stilUrl || !stilGeladen) && (
|
||||
<p
|
||||
className="pointer-events-none absolute inset-x-3 top-3 rounded-md bg-[var(--background)]/90 px-3 py-2 text-xs text-[var(--muted-foreground)]"
|
||||
role="note"
|
||||
data-testid="karte-ohne-kacheln"
|
||||
>
|
||||
<strong>
|
||||
{stilUrl
|
||||
? "Kartenserver nicht erreichbar."
|
||||
: "Kein Kartenhintergrund eingerichtet."}
|
||||
</strong>{" "}
|
||||
Punkte liegen richtig, Entfernungen stimmen — es fehlen nur die Straßen darunter.
|
||||
Einrichtung siehe
|
||||
<code className="mx-1">docker/karten/LIESMICH.md</code>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user