This commit is contained in:
PxlLoewe
2025-05-18 23:21:14 -07:00
6 changed files with 365 additions and 132 deletions

View File

@@ -42,6 +42,16 @@ export const FMS_STATUS_COLORS: { [key: string]: string } = {
"7": "rgb(140,10,10)", "7": "rgb(140,10,10)",
"8": "rgb(186,105,0)", "8": "rgb(186,105,0)",
"9": "rgb(10,134,25)", "9": "rgb(10,134,25)",
E: "rgb(186,105,0)",
C: "rgb(186,105,0)",
F: "rgb(186,105,0)",
J: "rgb(186,105,0)",
L: "rgb(186,105,0)",
c: "rgb(186,105,0)",
d: "rgb(186,105,0)",
h: "rgb(186,105,0)",
o: "rgb(186,105,0)",
u: "rgb(186,105,0)",
}; };
export const FMS_STATUS_TEXT_COLORS: { [key: string]: string } = { export const FMS_STATUS_TEXT_COLORS: { [key: string]: string } = {
@@ -55,7 +65,17 @@ export const FMS_STATUS_TEXT_COLORS: { [key: string]: string } = {
"7": "rgb(243,27,25)", "7": "rgb(243,27,25)",
"8": "rgb(255,143,0)", "8": "rgb(255,143,0)",
"9": "rgb(9,212,33)", "9": "rgb(9,212,33)",
N: "rgb(153,153,153)", N: "rgb(9,212,33)",
E: "rgb(255,143,0)",
C: "rgb(255,143,0)",
F: "rgb(255,143,0)",
J: "rgb(255,143,0)",
L: "rgb(255,143,0)",
c: "rgb(255,143,0)",
d: "rgb(255,143,0)",
h: "rgb(255,143,0)",
o: "rgb(255,143,0)",
u: "rgb(255,143,0)",
}; };
const AircraftPopupContent = ({ const AircraftPopupContent = ({

View File

@@ -2,8 +2,16 @@
import { OSMWay } from "@repo/db"; import { OSMWay } from "@repo/db";
import { useMapStore } from "_store/mapStore"; import { useMapStore } from "_store/mapStore";
import { usePannelStore } from "_store/pannelStore"; import { usePannelStore } from "_store/pannelStore";
import { MapPinned, Search } from "lucide-react"; import {
import { useEffect } from "react"; MapPin,
MapPinned,
Radius,
Ruler,
Search,
RulerDimensionLine,
Scan,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Popup, useMap } from "react-leaflet"; import { Popup, useMap } from "react-leaflet";
export const ContextMenu = () => { export const ContextMenu = () => {
@@ -11,11 +19,19 @@ export const ContextMenu = () => {
const { contextMenu, setContextMenu, setSearchElements, setSearchPopup } = const { contextMenu, setContextMenu, setSearchElements, setSearchPopup } =
useMapStore(); useMapStore();
const { setMissionFormValues, setOpen } = usePannelStore((state) => state); const { setMissionFormValues, setOpen } = usePannelStore((state) => state);
const [showRulerOptions, setShowRulerOptions] = useState(false);
const [rulerHover, setRulerHover] = useState(false);
const [rulerOptionsHover, setRulerOptionsHover] = useState(false);
useEffect(() => {
setShowRulerOptions(rulerHover || rulerOptionsHover);
}, [rulerHover, rulerOptionsHover]);
useEffect(() => { useEffect(() => {
const handleContextMenu = (e: any) => { const handleContextMenu = (e: any) => {
setContextMenu({ lat: e.latlng.lat, lng: e.latlng.lng }); setContextMenu({ lat: e.latlng.lat, lng: e.latlng.lng });
}; };
const handleClick = (e: any) => { const handleClick = () => {
setContextMenu(null); setContextMenu(null);
setSearchPopup(null); setSearchPopup(null);
}; };
@@ -27,7 +43,7 @@ export const ContextMenu = () => {
map.off("contextmenu", handleContextMenu); map.off("contextmenu", handleContextMenu);
map.off("click", handleClick); map.off("click", handleClick);
}; };
}, [contextMenu]); }, [map, contextMenu, setContextMenu, setSearchPopup]);
if (!contextMenu) return null; if (!contextMenu) return null;
@@ -76,86 +92,242 @@ export const ContextMenu = () => {
autoPan={false} autoPan={false}
> >
{/* // TODO: maske: */} {/* // TODO: maske: */}
<div className="absolute transform -translate-y-1/2 z-1000 opacity-100 pointer-events-auto p-3"> <div
<button className="absolute opacity-100 pointer-events-none p-3 flex items-center justify-center"
className="btn btn-sm rounded-full bg-rescuetrack aspect-square" style={{ left: "-13px", top: "-13px" }}
onClick={async () => { >
const address = await fetch( <div className="relative w-38 h-38 flex items-center justify-center -translate-x-1/2 -translate-y-1/2 pointer-events-none">
`https://nominatim.openstreetmap.org/reverse?lat=${contextMenu.lat}&lon=${contextMenu.lng}&format=json`, {/* Top Button */}
); <button
const data = (await address.json()) as { className="btn btn-circle bg-rescuetrack w-10 h-10 absolute left-1/2 top-0 pointer-events-auto opacity-80 tooltip tooltip-top tooltip-accent"
address: { data-tip="Nächstes Element übernehmen"
ISO3166_2_lvl4: string; style={{ transform: "translateX(-50%)" }}
country: string; onClick={async () => {
country_code: string; const address = await fetch(
county: string; `https://nominatim.openstreetmap.org/reverse?lat=${contextMenu.lat}&lon=${contextMenu.lng}&format=json`,
house_number: string; );
municipality: string; const data = (await address.json()) as {
postcode: string; address: {
road: string; ISO3166_2_lvl4: string;
state: string; country: string;
city: string; country_code: string;
town: string; county: string;
house_number: string;
municipality: string;
postcode: string;
road: string;
state: string;
city: string;
town: string;
};
display_name: string;
importance: number;
lat: string;
licence: string;
lon: string;
name: string;
osm_id: number;
osm_type: string;
place_id: number;
place_rank: number;
type: string;
}; };
display_name: string; const objects = await addOSMobjects();
importance: number; const exactAddress = objects.find((object) => {
lat: string; return (
licence: string; object.tags["addr:street"] == data.address.road &&
lon: string; object.tags["addr:housenumber"]?.includes(
name: string; data.address.house_number,
osm_id: number; )
osm_type: string; );
place_id: number; });
place_rank: number; const closestToContext = objects.reduce((prev, curr) => {
type: string; const prevLat = prev.nodes?.[0]?.lat ?? 0;
}; const prevLon = prev.nodes?.[0]?.lon ?? 0;
const objects = await addOSMobjects(); const currLat = curr.nodes?.[0]?.lat ?? 0;
const exactAddress = objects.find((object) => { const currLon = curr.nodes?.[0]?.lon ?? 0;
return ( const prevDistance = Math.sqrt(
object.tags["addr:street"] == data.address.road && Math.pow(prevLat - contextMenu.lat, 2) +
object.tags["addr:housenumber"]?.includes( Math.pow(prevLon - contextMenu.lng, 2),
data.address.house_number, );
) const currDistance = Math.sqrt(
); Math.pow(currLat - contextMenu.lat, 2) +
}); Math.pow(currLon - contextMenu.lng, 2),
const closestToContext = objects.reduce((prev, curr) => { );
const prevLat = prev.nodes?.[0]?.lat ?? 0; return prevDistance < currDistance ? prev : curr;
const prevLon = prev.nodes?.[0]?.lon ?? 0; });
const currLat = curr.nodes?.[0]?.lat ?? 0;
const currLon = curr.nodes?.[0]?.lon ?? 0;
const prevDistance = Math.sqrt(
Math.pow(prevLat - contextMenu.lat, 2) +
Math.pow(prevLon - contextMenu.lng, 2),
);
const currDistance = Math.sqrt(
Math.pow(currLat - contextMenu.lat, 2) +
Math.pow(currLon - contextMenu.lng, 2),
);
return prevDistance < currDistance ? prev : curr;
});
setOpen(true); setOpen(true);
setMissionFormValues({ setMissionFormValues({
addressLat: contextMenu.lat, addressLat: contextMenu.lat,
addressLng: contextMenu.lng, addressLng: contextMenu.lng,
addressCity: data.address.city || data.address.town, addressCity: data.address.city || data.address.town,
addressStreet: `${data.address.road}, ${data.address.house_number || "keine HN"}`, addressStreet: `${data.address.road}, ${data.address.house_number || "keine HN"}`,
addressZip: data.address.postcode, addressZip: data.address.postcode,
state: "draft", state: "draft",
addressOSMways: [(exactAddress || closestToContext) as any], addressOSMways: [(exactAddress || closestToContext) as any],
}); });
}} }}
> >
<MapPinned size={20} /> <MapPinned size={20} />
</button> </button>
<button {/* Left Button */}
className="btn btn-sm rounded-full bg-rescuetrack aspect-square" <button
onClick={async () => { className="btn btn-circle bg-rescuetrack w-10 h-10 absolute top-1/2 left-0 pointer-events-auto opacity-80"
addOSMobjects(); style={{ transform: "translateY(-50%)" }}
}} onMouseEnter={() => setRulerHover(true)}
> onMouseLeave={() => setRulerHover(false)}
<Search size={20} /> >
</button> <Ruler size={20} />
</button>
{/* Bottom Button */}
<button
className="btn btn-circle bg-rescuetrack w-10 h-10 absolute left-1/2 bottom-0 pointer-events-auto opacity-80 tooltip tooltip-bottom tooltip-accent"
data-tip="Koordinaten kopieren"
style={{ transform: "translateX(-50%)" }}
onClick={async () => {
const address = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${contextMenu.lat}&lon=${contextMenu.lng}&format=json`,
);
const data = (await address.json()) as {
address: {
ISO3166_2_lvl4: string;
country: string;
country_code: string;
county: string;
house_number: string;
municipality: string;
postcode: string;
road: string;
state: string;
city: string;
town: string;
};
display_name: string;
importance: number;
lat: string;
licence: string;
lon: string;
name: string;
osm_id: number;
osm_type: string;
place_id: number;
place_rank: number;
type: string;
};
const objects = await addOSMobjects();
const exactAddress = objects.find((object) => {
return (
object.tags["addr:street"] == data.address.road &&
object.tags["addr:housenumber"]?.includes(
data.address.house_number,
)
);
});
const closestToContext = objects.reduce((prev, curr) => {
const prevLat = prev.nodes?.[0]?.lat ?? 0;
const prevLon = prev.nodes?.[0]?.lon ?? 0;
const currLat = curr.nodes?.[0]?.lat ?? 0;
const currLon = curr.nodes?.[0]?.lon ?? 0;
const prevDistance = Math.sqrt(
Math.pow(prevLat - contextMenu.lat, 2) +
Math.pow(prevLon - contextMenu.lng, 2),
);
const currDistance = Math.sqrt(
Math.pow(currLat - contextMenu.lat, 2) +
Math.pow(currLon - contextMenu.lng, 2),
);
return prevDistance < currDistance ? prev : curr;
});
setOpen(true);
setMissionFormValues({
addressLat: contextMenu.lat,
addressLng: contextMenu.lng,
addressCity: data.address.city || data.address.town,
addressStreet: `${data.address.road}, ${data.address.house_number || "keine HN"}`,
addressZip: data.address.postcode,
state: "draft",
addressOSMways: [(exactAddress || closestToContext) as any],
});
}}
>
<MapPin size={20} />
</button>
{/* Right Button (original Search button) */}
<button
className="btn btn-circle bg-rescuetrack w-10 h-10 absolute top-1/2 right-0 pointer-events-auto opacity-80 tooltip tooltip-right tooltip-accent"
data-tip="Gebäude suchen"
style={{ transform: "translateY(-50%)" }}
onClick={async () => {
addOSMobjects();
}}
>
<Search size={20} />
</button>
{/* Ruler Options - shown when Ruler button is hovered or options are hovered */}
{showRulerOptions && (
<div
className="absolute flex flex-col items-center pointer-events-auto"
style={{
left: "-100px", // position to the right of the left button
top: "50%",
transform: "translateY(-50%)",
zIndex: 10,
width: "120px", // Make the hover area wider
height: "200px", // Make the hover area taller
padding: "20px 0", // Add vertical padding
display: "flex",
justifyContent: "center",
pointerEvents: "auto",
}}
onMouseEnter={() => setRulerOptionsHover(true)}
onMouseLeave={() => setRulerOptionsHover(false)}
>
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
}}
>
<button
className="btn btn-circle bg-rescuetrack w-10 h-10 mb-2 opacity-80 tooltip tooltip-left tooltip-accent"
data-tip="Strecke Messen"
style={{
transform: "translateX(100%)",
}}
onClick={() => {
/* ... */
}}
>
<RulerDimensionLine size={20} />
</button>
<button
className="btn btn-circle bg-rescuetrack w-10 h-10 mb-2 opacity-80 tooltip tooltip-left tooltip-accent"
data-tip="Radius Messen"
onClick={() => {
/* ... */
}}
>
<Radius size={20} />
</button>
<button
className="btn btn-circle bg-rescuetrack w-10 h-10 opacity-80 tooltip tooltip-left tooltip-accent"
data-tip="Fläche Messen"
style={{
transform: "translateX(100%)",
}}
onClick={() => {
/* ... */
}}
>
<Scan size={20} />
</button>
</div>
</div>
)}
</div>
</div> </div>
</Popup> </Popup>
); );

View File

@@ -161,9 +161,21 @@ const FMSStatusSelector = ({
!dispatcherConnected && "cursor-not-allowed", !dispatcherConnected && "cursor-not-allowed",
)} )}
style={{ style={{
backgroundColor: "var(--color-base-200)", backgroundColor:
color: "gray", hoveredStatus === status
? FMS_STATUS_COLORS[6]
: aircraft.fmsStatus === status
? FMS_STATUS_COLORS[status]
: "var(--color-base-200)",
color:
aircraft.fmsStatus === status
? "white"
: hoveredStatus === status
? "white"
: "gray",
}} }}
onMouseEnter={() => setHoveredStatus(status)}
onMouseLeave={() => setHoveredStatus(null)}
onClick={async () => { onClick={async () => {
await changeAircraftMutation.mutateAsync({ await changeAircraftMutation.mutateAsync({
id: aircraft.id, id: aircraft.id,

View File

@@ -61,9 +61,36 @@ const Einsatzdetails = ({ mission }: { mission: Mission }) => {
const { setMissionFormValues, setOpen } = usePannelStore((state) => state); const { setMissionFormValues, setOpen } = usePannelStore((state) => state);
return ( return (
<div className="p-4 text-base-content"> <div className="p-4 text-base-content">
<h2 className="flex items-center gap-2 text-lg font-bold mb-3"> <div className="flex items-center justify-between mb-3">
<Flag /> Einsatzdetails <h2 className="flex items-center gap-2 text-lg font-bold">
</h2> <Flag /> Einsatzdetails
</h2>
{mission.state !== "draft" && (
<div
className="tooltip tooltip-primary tooltip-left font-semibold"
data-tip="Einsatzdaten übernehmen"
>
<button
className="btn btn-xs btn-primary btn-dash flex items-center gap-2"
onClick={() => {
setMissionFormValues({
...mission,
id: undefined,
hpgAmbulanceState: null,
hpgFireEngineState: null,
hpgPoliceState: null,
hpgLocationLat: undefined,
hpgLocationLng: undefined,
state: "draft",
});
setOpen(true);
}}
>
<Repeat2 size={16} />
</button>
</div>
)}
</div>
<ul className="text-base-content font-semibold"> <ul className="text-base-content font-semibold">
<li className="flex items-center gap-2 mb-1"> <li className="flex items-center gap-2 mb-1">
<BellRing size={16} /> {mission.missionKeywordCategory} <BellRing size={16} /> {mission.missionKeywordCategory}
@@ -90,46 +117,48 @@ const Einsatzdetails = ({ mission }: { mission: Mission }) => {
<LocateFixed size={16} /> {mission.addressZip} {mission.addressCity} <LocateFixed size={16} /> {mission.addressZip} {mission.addressCity}
</p> </p>
</div> </div>
<div className="divider mt-0 mb-0" /> {mission.state === "draft" && (
<div className="flex items-center gap-2 w-full"> <div>
<button <div className="divider mt-0 mb-0" />
className="btn btn-sm btn-info btn-outline flex-3" <div className="flex items-center gap-2 w-full">
onClick={() => sendAlertMutation.mutate(mission.id)} <button
> className="btn btn-sm btn-info btn-outline flex-3"
<span className="flex items-center gap-2"> onClick={() => sendAlertMutation.mutate(mission.id)}
<BellRing size={16} /> Alarmieren >
</span> <span className="flex items-center gap-2">
</button> <BellRing size={16} /> Alarmieren
</span>
</button>
<button <button
className="btn btn-sm btn-primary btn-dash flex items-center gap-2" className="btn btn-sm btn-primary btn-dash flex items-center gap-2"
onClick={() => { onClick={() => {
setMissionFormValues({ setMissionFormValues({
...mission, ...mission,
id: undefined, id: undefined,
hpgAmbulanceState: null, hpgAmbulanceState: null,
hpgFireEngineState: null, hpgFireEngineState: null,
hpgPoliceState: null, hpgPoliceState: null,
hpgLocationLat: undefined, hpgLocationLat: undefined,
hpgLocationLng: undefined, hpgLocationLng: undefined,
state: "draft", state: "draft",
}); });
setOpen(true); setOpen(true);
}} }}
> >
<Repeat2 size={18} /> Daten übernehmen <Repeat2 size={18} /> Daten übernehmen
</button> </button>
{mission.state === "draft" && ( <button
<button className="btn btn-sm btn-error btn-outline"
className="btn btn-sm btn-error btn-outline" onClick={() => {
onClick={() => { deleteMissionMutation.mutate(mission.id);
deleteMissionMutation.mutate(mission.id); }}
}} >
> <Trash size={18} />
<Trash size={18} /> </button>
</button> </div>
)} </div>
</div> )}
</div> </div>
); );
}; };

View File

@@ -22,7 +22,7 @@
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"livekit-client": "^2.9.7", "livekit-client": "^2.9.7",
"livekit-server-sdk": "^2.10.2", "livekit-server-sdk": "^2.10.2",
"lucide-react": "^0.482.0", "lucide-react": "^0.511.0",
"next": "^15.1.0", "next": "^15.1.0",
"next-auth": "^4.24.11", "next-auth": "^4.24.11",
"postcss": "^8.5.1", "postcss": "^8.5.1",

8
package-lock.json generated
View File

@@ -32,7 +32,7 @@
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"livekit-client": "^2.9.7", "livekit-client": "^2.9.7",
"livekit-server-sdk": "^2.10.2", "livekit-server-sdk": "^2.10.2",
"lucide-react": "^0.482.0", "lucide-react": "^0.511.0",
"next": "^15.1.0", "next": "^15.1.0",
"next-auth": "^4.24.11", "next-auth": "^4.24.11",
"postcss": "^8.5.1", "postcss": "^8.5.1",
@@ -84,9 +84,9 @@
} }
}, },
"apps/dispatch/node_modules/lucide-react": { "apps/dispatch/node_modules/lucide-react": {
"version": "0.482.0", "version": "0.511.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.482.0.tgz", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.511.0.tgz",
"integrity": "sha512-XM8PzHzSrg8ATmmO+fzf+JyYlVVdQnJjuyLDj2p4V2zEtcKeBNAqAoJIGFv1x2HSBa7kT8gpYUxwdQ0g7nypfw==", "integrity": "sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"