Files
var-monorepo/apps/dispatch/app/_components/map/BaseMaps.tsx
2025-07-16 00:49:25 -07:00

576 lines
16 KiB
TypeScript

"use client";
import { Control, divIcon, Icon, LatLngExpression } from "leaflet";
import { useEffect, useRef, useState } from "react";
import {
LayerGroup,
LayersControl,
TileLayer,
useMap,
WMSTileLayer,
GeoJSON,
Circle,
useMapEvent,
FeatureGroup,
Marker,
Tooltip,
} from "react-leaflet";
// @ts-expect-error geojson hat keine Typen
import type { FeatureCollection, Geometry } from "geojson";
import L from "leaflet";
import LEITSTELLENBERECHE from "./_geojson/Leitstellen.json";
import WINDFARMS from "./_geojson/Windfarms.json";
import { createCustomMarker } from "_components/map/_components/createCustomMarker";
import { Heliport, Station } from "@repo/db";
import { useQuery } from "@tanstack/react-query";
import { getStationsAPI } from "_querys/stations";
import "./darkMapStyles.css";
import { getHeliportsAPI } from "_querys/heliports";
const RadioAreaLayer = () => {
const getColor = (randint: number) => {
switch (randint) {
case 1:
return "#2b6eff";
case 2:
return "#233ee5";
case 3:
return "#7BA5FF";
case 4:
return "#5087FF";
default:
return "#7f7f7f";
}
};
return (
<GeoJSON
data={LEITSTELLENBERECHE as FeatureCollection<Geometry>}
style={(feature) => {
if (!feature || !feature.properties) return {}; // Early return if feature or its properties are undefined
return {
color: getColor(feature.properties.randint),
weight: 1.5,
className: "no-pointer",
};
}}
onEachFeature={(feature, layer) => {
if (feature && feature.properties && feature.properties.name) {
layer.bindTooltip(
new L.Tooltip({
content: feature.properties.name,
direction: "top",
sticky: true,
}),
);
}
}}
/>
);
};
const HeliportsLayer = () => {
const { data: heliports } = useQuery({
queryKey: ["heliports"],
queryFn: () => getHeliportsAPI(),
});
console.log("Heliports Layer", heliports);
const [heliportsWithIcon, setHeliportsWithIcon] = useState<(Heliport & { icon?: string })[]>([]);
const map = useMap();
const [isVisible, setIsVisible] = useState(true);
const [boxContent, setBoxContent] = useState<React.ReactNode>(null);
// Übergangslösung
const formatDate = (date: Date): string => {
const year = date.getFullYear().toString().slice(-2); // Letzte 2 Stellen des Jahres
const month = (date.getMonth() + 1).toString().padStart(2, "0"); // Monat (mit führender Null, falls notwendig)
const day = date.getDate().toString().padStart(2, "0"); // Tag (mit führender Null, falls notwendig)
return `${year}${month}${day}`;
};
const replaceWithYesterdayDate = (url: string): string => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 2); // Einen Tag zurücksetzen
const formattedDate = formatDate(yesterday);
return url.replace(/\.at\/lo\/\d{6}/, `.at/lo/${formattedDate}`);
};
const resetSelection = () => {
setBoxContent(null);
};
useMapEvent("click", () => {
resetSelection();
});
useEffect(() => {
const handleZoom = () => {
setIsVisible(map.getZoom() > 9);
};
handleZoom();
map.on("zoomend", handleZoom);
const fetchIcons = async () => {
if (!heliports) return;
const urls = await Promise.all(
heliports.map(async (heliport) => {
return createCustomMarker(heliport.type);
}),
);
setHeliportsWithIcon(
heliports.map((heliport, index) => ({ ...heliport, icon: urls[index] })),
);
};
const filterVisibleHeliports = () => {
const bounds = map.getBounds();
if (!heliports?.length) return;
// Filtere die Heliports, die innerhalb der Kartenansicht liegen
const visibleHeliports = heliports.filter((heliport) => {
const coordinates: LatLngExpression = [heliport.lat, heliport.lng];
return bounds.contains(coordinates); // Überprüft, ob der Heliport innerhalb der aktuellen Bounds liegt
});
setHeliportsWithIcon(visibleHeliports);
};
if (heliports?.length) {
fetchIcons();
filterVisibleHeliports();
}
handleZoom();
map.on("zoomend", handleZoom);
map.on("moveend", filterVisibleHeliports);
return () => {
map.off("zoomend", handleZoom);
map.off("moveend", filterVisibleHeliports);
};
}, [map, heliports]);
const createCustomIcon = (heliportType: string) => {
if (heliportType === "POI") {
return divIcon({
className: "custom-marker no-pointer", // CSS-Klasse für Styling
html: '<div style="width: 15px; height: 15px; border-radius: 50%; background-color: white; border: 2px solid #7f7f7f; display: flex; align-items: center; justify-content: center;"><span style="font-size: 12px; color: #7f7f7f;">H</span></div>',
iconSize: [15, 15], // Größe des Icons
iconAnchor: [7.5, 15], // Ankerpunkt des Icons
});
}
// Heliport Typ: H-Icon
if (heliportType === "HELIPAD") {
return divIcon({
className: "custom-marker no-pointer", // CSS-Klasse für Styling
html: '<div style="width: 15px; height: 15px; background-color: white; border: 2px solid #7f7f7f; display: flex; align-items: center; justify-content: center;"><span style="font-size: 12px; color: #7f7f7f;">H</span></div>',
iconSize: [15, 15], // Größe des Icons (15x15 px Viereck)
iconAnchor: [7.5, 15], // Ankerpunkt des Icons
});
}
// Mountain Typ: Kreis mit "M"
if (heliportType === "MOUNTAIN") {
return divIcon({
className: "custom-marker no-pointer",
html: '<div style="width: 15px; height: 15px; border-radius: 50%; background-color: white; border: 2px solid #7f7f7f; display: flex; align-items: center; justify-content: center;"><span style="font-size: 12px; color: #7f7f7f;">M</span></div>',
iconSize: [15, 15], // Größe des Icons
iconAnchor: [7.5, 15], // Ankerpunkt des Icons
});
}
// Falls kein Typ übereinstimmt, standardmäßig das POI-Icon mit Fragezeichen verwenden
return divIcon({
className: "custom-marker no-pointer",
html: '<div style="width: 15px; height: 15px; border-radius: 50%; background-color: white; border: 2px solid #7f7f7f; display: flex; align-items: center; justify-content: center;"><span style="font-size: 12px; color: #7f7f7f;">?</span></div>',
iconSize: [15, 15],
iconAnchor: [7.5, 15],
});
};
return (
<>
<FeatureGroup attribution="">
{isVisible &&
heliportsWithIcon.map((heliport) => {
const coordinates: LatLngExpression = [heliport.lat, heliport.lng];
const designatorLabel = heliport.designator.charAt(0).toUpperCase();
const heliportType = heliport.type;
return (
<Marker
key={heliport.id}
position={coordinates}
icon={createCustomIcon(heliportType)}
eventHandlers={{
mouseover: (e) => {
const tooltipContent = `${heliport.siteNameSub26} (${heliport.designator})`;
e.target
.bindTooltip(tooltipContent, {
direction: "top",
offset: [4, -15],
})
.openTooltip();
},
mouseout: (e) => {
e.target.closeTooltip();
},
click: () => {
setBoxContent(
<div>
<h4>{heliport.siteNameSub26}</h4>
<p>
<strong>Designator:</strong> {heliport.designator}
</p>
{heliport.info?.startsWith("http") ? (
<p>
<a
href={replaceWithYesterdayDate(heliport.info)}
target="_blank"
rel="noopener noreferrer"
>
{heliport.info}
</a>
</p>
) : (
<p>{heliport.info}</p>
)}
<p>
{heliport.lat} °N, {heliport.lng} °E
</p>
</div>,
);
},
}}
>
<Tooltip direction="top" sticky>
<div style={{ textAlign: "center" }}>
<strong>{heliport.designator}</strong>
<small style={{ fontWeight: "bold", fontSize: "0.7em" }}>
{` (${designatorLabel})`}
</small>
<br />
</div>
</Tooltip>
</Marker>
);
})}
</FeatureGroup>
{boxContent && <div className="modal-box">{boxContent}</div>}
</>
);
};
const StationsLayer = ({ attribution }: { attribution: Control.Attribution }) => {
const { data: stations } = useQuery({
queryKey: ["stations"],
queryFn: () => getStationsAPI(),
});
const [selectedStations, setSelectedStations] = useState<Station["id"][]>([]);
const attributionText = "";
const resetSelection = () => {
setSelectedStations([]);
};
useMapEvent("click", () => {
resetSelection();
});
const handleMarkerClick = (stationId: number) => {
if (selectedStations.includes(stationId)) {
setSelectedStations((prevStations) => prevStations.filter((s) => s !== stationId));
} else {
setSelectedStations((prevStations) => [...prevStations, stationId]);
}
};
const [stationsWithIcon, setStationsWithIcon] = useState<(Station & { icon: string })[]>([]);
useEffect(() => {
if (!stations) {
setStationsWithIcon([]);
return;
}
const fetchIcons = async () => {
const urls = await Promise.all(
stations.map(async (station) => {
return await createCustomMarker(station.operator);
}),
);
setStationsWithIcon(stations.map((station, index) => ({ ...station, icon: urls[index]! })));
};
fetchIcons();
}, [stations]);
return (
<FeatureGroup>
{stationsWithIcon?.map((station) => {
const coordinates: LatLngExpression = [station.latitude, station.longitude];
const typeLabel = station.bosUse?.charAt(0).toUpperCase();
return (
<Marker
key={`marker-${station.id}`}
position={coordinates}
icon={
new Icon({
iconUrl: station.icon,
iconSize: [30, 30],
iconAnchor: [15, 15],
tooltipAnchor: [0, 20],
className: station.hideRangeRings ? "no-pointer" : "pointer",
})
}
eventHandlers={{
click: () => {
if (!station.hideRangeRings) handleMarkerClick(station.id);
},
add: () => attribution.addAttribution(attributionText),
remove: () => attribution.removeAttribution(attributionText),
}}
>
<Tooltip direction="top" sticky>
<div style={{ textAlign: "center" }}>
<strong>{station.bosCallsign}</strong>
<small style={{ fontWeight: "bold", fontSize: "0.7em" }}>{` (${typeLabel})`}</small>
<br />
<small>
{[
station.hasWinch ? "W" : null,
station.is24h ? "24h" : null,
station.hasNvg ? "N" : null,
]
.filter(Boolean)
.join(", ")}
</small>
</div>
</Tooltip>
</Marker>
);
})}
{selectedStations.map((stationId) => {
const station = stations?.find((s) => s.id === stationId);
if (!station) return null;
const center: LatLngExpression = [station.latitude, station.longitude];
return (
<div key={`marker-${stationId}`}>
<Circle
center={center}
radius={(station.aircraftSpeed * 1000) / 6}
color="#0e0ecf"
fillOpacity={0}
weight={2}
/>
<Circle
center={center}
radius={(station.aircraftSpeed * 1000) / 3}
color="navy"
fillOpacity={0}
weight={2}
/>
{(station.bosUse === "SECONDARY" || station.bosUse === "DUAL_USE") && (
<Circle
center={center}
radius={station.aircraftSpeed * 1000}
color="maroon"
fillOpacity={0}
weight={1}
dashArray="40,30"
/>
)}
</div>
);
})}
</FeatureGroup>
);
};
const EsriSatellite = () => {
const accessToken = process.env.NEXT_PUBLIC_ESRI_ACCESS;
return (
<>
{/* Satellite Imagery Layer; API KEY PROVIDED BY VAR0002 */}
<TileLayer
attribution="Sources: Esri, TomTom, Garmin, FAO, NOAA, USGS"
url={`https://services.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}?token=${accessToken}`}
tileSize={256}
/>
</>
);
};
const StrassentexteEsri = () => {
return (
<WMSTileLayer
url="https://tiledbasemaps.arcgis.com/arcgis/rest/services/Reference/World_Transportation/MapServer/tile/{z}/{y}/{x}"
format="image/png"
transparent
/>
);
};
const OpenAIP = () => {
const ref = useRef<L.TileLayer | null>(null);
return (
<TileLayer
eventHandlers={{
add: () => {
if (ref.current) {
ref.current.bringToFront();
}
},
}}
ref={ref}
attribution='© <a href="https://www.openaip.net" target="_blank">OpenAIP</a>'
url={`https://api.tiles.openaip.net/api/data/openaip/{z}/{x}/{y}.png?apiKey=${process.env.NEXT_PUBLIC_OPENAIP_ACCESS}`}
/>
);
};
const NiederschlagOverlay = () => {
const tileLayerRef = useRef<L.TileLayer.WMS | null>(null);
return (
<WMSTileLayer
ref={tileLayerRef}
eventHandlers={{
add: () => {
tileLayerRef.current?.bringToFront();
},
}}
attribution="Quelle: Deutscher Wetterdienst"
url="https://maps.dwd.de/geoserver/wms?"
format="image/png"
layers="dwd:Niederschlagsradar"
transparent
opacity={0.7}
/>
);
};
const SlopesOverlay = () => {
const tileLayerRef = useRef<L.TileLayer.WMS | null>(null);
return (
<WMSTileLayer
ref={tileLayerRef}
eventHandlers={{
add: () => {
tileLayerRef.current?.bringToFront();
},
}}
attribution="Opensnowmap.org (CC-BY-SA)"
url="http://tiles.opensnowmap.org/pistes/{z}/{x}/{y}.png?"
transparent
zIndex={1000}
/>
);
};
const WindfarmOutlineLayer = () => {
const map = useMap();
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const handleZoom = () => {
setIsVisible(map.getZoom() < 13);
};
// Initial check and event listener
handleZoom();
map.on("zoomend", handleZoom);
// Cleanup on unmount
return () => {
map.off("zoomend", handleZoom);
};
}, [map]);
return isVisible ? (
<GeoJSON
data={WINDFARMS as FeatureCollection<Geometry>}
style={() => ({
color: "#233EE5",
weight: 1.5,
dashArray: "7, 10",
className: "no-pointer",
})}
onEachFeature={(feature, layer) => {
layer.bindTooltip(
new L.Tooltip({
content: feature.properties.Cluster_NR,
direction: "top",
sticky: true,
}),
);
}}
/>
) : null;
};
export const BaseMaps = () => {
const map = useMap();
return (
<LayersControl position="bottomright">
<LayersControl.Overlay name={"Leitstellenbereiche"}>
<RadioAreaLayer />
</LayersControl.Overlay>
<LayersControl.Overlay name={"Niederschlag"}>
<NiederschlagOverlay />
</LayersControl.Overlay>
<LayersControl.Overlay name={"Windkraftanlagen offshore"}>
<WindfarmOutlineLayer />
</LayersControl.Overlay>
<LayersControl.Overlay name={"LRZs"}>
<StationsLayer attribution={map.attributionControl} />
</LayersControl.Overlay>
<LayersControl.Overlay name={"Heliports"}>
<HeliportsLayer />
</LayersControl.Overlay>
<LayersControl.Overlay name={"OpenAIP"}>
<OpenAIP />
</LayersControl.Overlay>
<LayersControl.Overlay name={"Skigebiete"}>
<SlopesOverlay />
</LayersControl.Overlay>
<LayersControl.BaseLayer name="OpenStreetMap Dark" checked>
<TileLayer
zIndex={-1}
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
className="invert-100 grayscale"
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name="OpenStreetMap">
<TileLayer
zIndex={-1}
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name="OpenTopoMap">
<TileLayer
attribution='map data: © <a href="https://opentopomap.org/copyright">OpenStreetMap</a> contributors, <a href="http://viewfinderpanoramas.org">SRTM</a> | map style: © <a href="https://opentopomap.org">OpenTopoMap</a> (<a href="https://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>)'
url="https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png"
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name="ESRI Satellite">
<LayerGroup>
<EsriSatellite />
<StrassentexteEsri />
</LayerGroup>
</LayersControl.BaseLayer>
</LayersControl>
);
};