improved OSM Element selection

This commit is contained in:
PxlLoewe
2025-06-03 15:50:20 -07:00
parent 56856664a7
commit 0f50bf1db8
10 changed files with 118 additions and 154 deletions

View File

@@ -318,6 +318,17 @@ router.post("/:id/validate-hpg", async (req, res) => {
}); });
return; return;
} }
const newMission = await prisma.mission.update({
where: {
id: Number(id),
},
data: {
hpgValidationState: "PENDING",
},
});
io.to("dispatchers").emit("update-mission", newMission);
res.json({ res.json({
message: "HPG validierung gestartet", message: "HPG validierung gestartet",
}); });

View File

@@ -11,7 +11,14 @@ import { Popup, useMap } from "react-leaflet";
export const ContextMenu = () => { export const ContextMenu = () => {
const map = useMap(); const map = useMap();
const { contextMenu, setContextMenu, setSearchElements, setSearchPopup } = useMapStore(); const {
contextMenu,
searchElements,
setContextMenu,
setSearchElements,
setSearchPopup,
toggleSearchElementSelection,
} = useMapStore();
const { missionFormValues, setMissionFormValues, setOpen, isOpen } = usePannelStore( const { missionFormValues, setMissionFormValues, setOpen, isOpen } = usePannelStore(
(state) => state, (state) => state,
); );
@@ -64,7 +71,9 @@ export const ContextMenu = () => {
const parsed: OSMWay[] = data.elements const parsed: OSMWay[] = data.elements
.filter((e: any) => e.type === "way") .filter((e: any) => e.type === "way")
.map((e: any) => { .map((e: any) => {
const elementInMap = searchElements.find((el) => el.wayID === e.id);
return { return {
isSelected: elementInMap?.isSelected ?? false,
wayID: e.id, wayID: e.id,
nodes: e.nodes.map((nodeId: string) => { nodes: e.nodes.map((nodeId: string) => {
const node = data.elements.find((element: any) => element.id === nodeId); const node = data.elements.find((element: any) => element.id === nodeId);
@@ -125,12 +134,15 @@ export const ContextMenu = () => {
nodeWay.push([node.lat, node.lon]), nodeWay.push([node.lat, node.lon]),
); );
if (closestToContext) {
toggleSearchElementSelection(closestToContext.wayID);
}
setMissionFormValues({ setMissionFormValues({
...parsed, ...parsed,
state: "draft", state: "draft",
addressLat: contextMenu.lat, addressLat: contextMenu.lat,
addressLng: contextMenu.lng, addressLng: contextMenu.lng,
addressOSMways: [closestToContext],
}); });
map.setView([contextMenu.lat, contextMenu.lng], 18, { map.setView([contextMenu.lat, contextMenu.lng], 18, {

View File

@@ -175,7 +175,7 @@ const MissionPopupContent = ({
hpgLocationLat: mission.hpgLocationLat ?? undefined, hpgLocationLat: mission.hpgLocationLat ?? undefined,
hpgLocationLng: mission.hpgLocationLng ?? undefined, hpgLocationLng: mission.hpgLocationLng ?? undefined,
}); });
setEditingMission(true, String(mission.id)); setEditingMission(true, mission.id);
setOpen(true); setOpen(true);
}} }}
> >

View File

@@ -1,18 +1,15 @@
import { useMapStore } from "_store/mapStore"; import { useMapStore } from "_store/mapStore";
import { Marker as LMarker } from "leaflet";
import { Fragment, useEffect, useRef } from "react";
import { Marker, Polygon, Popup } from "react-leaflet"; import { Marker, Polygon, Popup } from "react-leaflet";
import L from "leaflet";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getMissionsAPI } from "_querys/missions"; import { getMissionsAPI } from "_querys/missions";
import { OSMWay } from "@repo/db";
import { usePannelStore } from "_store/pannelStore"; import { usePannelStore } from "_store/pannelStore";
import { OSMWay } from "@repo/db";
import { useEffect } from "react";
export const SearchElements = () => { export const SearchElements = () => {
const { searchElements, searchPopup, setSearchPopup, setContextMenu, openMissionMarker } = const { searchElements, openMissionMarker, setSearchElements } = useMapStore();
useMapStore(); const { isOpen: pannelOpen, editingMissionId } = usePannelStore((state) => state);
const { missionFormValues, setMissionFormValues } = usePannelStore((state) => state); const { data: missions } = useQuery({
const missions = useQuery({
queryKey: ["missions"], queryKey: ["missions"],
queryFn: () => queryFn: () =>
getMissionsAPI({ getMissionsAPI({
@@ -26,131 +23,59 @@ export const SearchElements = () => {
], ],
}), }),
}); });
const poppupRef = useRef<LMarker>(null);
const searchPopupElement = searchElements.find(
(element) => element.wayID === searchPopup?.elementId,
);
const SearchElement = ({ useEffect(() => {
element, if (pannelOpen) {
isActive = false, const missionEdited = missions?.find((m) => m.id === editingMissionId);
}: { console.log("Mission Edited", missionEdited, editingMissionId, missions);
element: (typeof searchElements)[1]; if (missionEdited) {
isActive?: boolean; const elements = missionEdited.addressOSMways.map((e) => ({
}) => { ...(e as unknown as OSMWay),
const ref = useRef<L.Polygon>(null); isSelected: true,
}));
useEffect(() => { setSearchElements(elements);
if (ref.current) { } else {
ref.current.on("click", () => { setSearchElements([]);
const center = ref.current?.getBounds().getCenter();
if (center && searchPopup?.elementId !== element.wayID) {
setSearchPopup({
lat: center.lat,
lng: center.lng,
elementId: element.wayID,
});
} else {
setSearchPopup(null);
}
setContextMenu(null);
});
} }
}, [element.wayID]); } else {
const openMissions = openMissionMarker.map((m) => {
const mission = missions?.find((mi) => mi.id === m.id);
return mission;
});
const elements = openMissions
.filter((m) => m?.addressOSMways)
.flatMap((m) =>
m?.addressOSMways.map((e) => ({
...(e as unknown as OSMWay),
isSelected: true,
})),
);
setSearchElements(elements.filter((e) => !!e));
}
}, [openMissionMarker, pannelOpen, missions]);
const SearchElement = ({ element }: { element: OSMWay }) => {
const { toggleSearchElementSelection } = useMapStore();
if (!element.nodes) return null; if (!element.nodes) return null;
return ( return (
<Polygon <Polygon
positions={element.nodes.map((node) => [node.lat, node.lon])} positions={element.nodes.map((node) => [node.lat, node.lon])}
color={searchPopup?.elementId === element.wayID || isActive ? "#ff4500" : "#46b7a3"} color={element.isSelected ? "#ff4500" : "#46b7a3"}
eventHandlers={{ eventHandlers={{
click: () => { click: () => {
const addressOSMways = missionFormValues?.addressOSMways || []; if (!pannelOpen) return;
toggleSearchElementSelection(element.wayID);
addressOSMways.push(JSON.parse(JSON.stringify(element)));
setMissionFormValues({
...missionFormValues,
addressOSMways,
});
}, },
}} }}
ref={ref}
/> />
); );
}; };
const SearchElementPopup = ({ element }: { element: (typeof searchElements)[1] }) => {
if (!searchPopup) return null;
return (
<Popup
autoPan={false}
position={[searchPopup.lat, searchPopup.lng]}
autoClose={false}
closeOnClick={false}
>
<div className="bg-base-100/70 border border-rescuetrack w-[250px] text-white pointer-events-auto p-2">
<h3 className="text-lg font-bold">
{element.tags?.building === "yes" ? "Gebäude" : element.tags?.building}
{!element.tags?.building && "unbekannt"}
</h3>
<p className="">
{element.tags?.["addr:street"]} {element.tags?.["addr:housenumber"]}
</p>
<p className="">
{element.tags?.["addr:suburb"]} {element.tags?.["addr:postcode"]}
</p>
<div className="flex flex-col gap-2 mt-2">
<button className="btn bg-rescuetrack-highlight">Zum Einsatz Hinzufügen</button>
</div>
</div>
</Popup>
);
};
return ( return (
<> <>
{openMissionMarker.map(({ id }) => {
const mission = missions.data?.find((m) => m.id === id);
if (!mission) return null;
return (
<Fragment key={`mission-osm-${mission.id}`}>
{(mission.addressOSMways as (OSMWay | null)[])
.filter((element): element is OSMWay => element !== null)
.map((element: OSMWay, i) => (
<SearchElement
key={`mission-elem-${element.wayID}-${i}`}
element={element}
isActive
/>
))}
</Fragment>
);
})}
{searchElements.map((element, i) => { {searchElements.map((element, i) => {
if (
missions.data?.some(
(mission) =>
(mission.addressOSMways as (OSMWay | null)[])
.filter((e): e is OSMWay => e !== null)
.some((e) => e.wayID === element.wayID) &&
openMissionMarker.some((m) => m.id === mission.id),
)
)
return null;
return <SearchElement key={`mission-elem-${element.wayID}-${i}`} element={element} />; return <SearchElement key={`mission-elem-${element.wayID}-${i}`} element={element} />;
})} })}
{searchPopup && (
<Marker
position={[searchPopup.lat, searchPopup.lng]}
ref={poppupRef}
icon={new L.DivIcon()}
opacity={0}
>
{!searchPopupElement && <div className="w-20 border border-rescuetrack"></div>}
</Marker>
)}
{searchPopupElement && <SearchElementPopup element={searchPopupElement} />}
</> </>
); );
}; };

View File

@@ -89,6 +89,7 @@ const Einsatzdetails = ({
}); });
}, },
}); });
const dispatcherConnected = useDispatchConnectionStore((s) => s.status) === "connected"; const dispatcherConnected = useDispatchConnectionStore((s) => s.status) === "connected";
const { setMissionFormValues, setOpen, setEditingMission } = usePannelStore((state) => state); const { setMissionFormValues, setOpen, setEditingMission } = usePannelStore((state) => state);
const [ignoreHpg, setIgnoreHpg] = useState(false); const [ignoreHpg, setIgnoreHpg] = useState(false);
@@ -191,11 +192,7 @@ const Einsatzdetails = ({
<div> <div>
<div className="divider mt-0 mb-0" /> <div className="divider mt-0 mb-0" />
{HPGValidationRequired( {hpgNeedsAttention && (
mission.missionStationIds,
aircrafts,
mission.hpgMissionString,
) && (
<div className="form-control mb-2"> <div className="form-control mb-2">
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<input <input

View File

@@ -15,10 +15,7 @@ export interface MapStore {
id: number; id: number;
tab: "home" | "details" | "patient" | "log"; tab: "home" | "details" | "patient" | "log";
}[]; }[];
setOpenMissionMarker: (mission: { setOpenMissionMarker: (mission: { open: MapStore["openMissionMarker"]; close: number[] }) => void;
open: MapStore["openMissionMarker"];
close: number[];
}) => void;
openAircraftMarker: { openAircraftMarker: {
id: number; id: number;
tab: "home" | "fms" | "aircraft" | "mission" | "chat"; tab: "home" | "fms" | "aircraft" | "mission" | "chat";
@@ -29,6 +26,7 @@ export interface MapStore {
}) => void; }) => void;
searchElements: OSMWay[]; searchElements: OSMWay[];
setSearchElements: (elements: MapStore["searchElements"]) => void; setSearchElements: (elements: MapStore["searchElements"]) => void;
toggleSearchElementSelection: (elementId: number) => void;
setContextMenu: (popup: MapStore["contextMenu"]) => void; setContextMenu: (popup: MapStore["contextMenu"]) => void;
searchPopup: { searchPopup: {
lat: number; lat: number;
@@ -39,10 +37,7 @@ export interface MapStore {
aircraftTabs: { aircraftTabs: {
[aircraftId: string]: "home" | "fms" | "aircraft" | "mission" | "chat"; [aircraftId: string]: "home" | "fms" | "aircraft" | "mission" | "chat";
}; };
setAircraftTab: ( setAircraftTab: (aircraftId: number, tab: MapStore["aircraftTabs"][number]) => void;
aircraftId: number,
tab: MapStore["aircraftTabs"][number],
) => void;
} }
export const useMapStore = create<MapStore>((set, get) => ({ export const useMapStore = create<MapStore>((set, get) => ({
@@ -88,6 +83,15 @@ export const useMapStore = create<MapStore>((set, get) => ({
set(() => ({ set(() => ({
searchElements: elements, searchElements: elements,
})), })),
toggleSearchElementSelection: (elementId) => {
const searchElements = get().searchElements;
const element = searchElements.find((e) => e.wayID === elementId);
if (!element) return;
element.isSelected = !element.isSelected;
set(() => ({
searchElements: [...searchElements],
}));
},
aircraftTabs: {}, aircraftTabs: {},
setAircraftTab: (aircraftId, tab) => setAircraftTab: (aircraftId, tab) =>
set((state) => ({ set((state) => ({

View File

@@ -8,8 +8,8 @@ interface PannelStore {
missionFormValues?: Partial<MissionOptionalDefaults>; missionFormValues?: Partial<MissionOptionalDefaults>;
setMissionFormValues: (values: Partial<MissionOptionalDefaults>) => void; setMissionFormValues: (values: Partial<MissionOptionalDefaults>) => void;
isEditingMission: boolean; isEditingMission: boolean;
editingMissionId: string | null; editingMissionId: number | null;
setEditingMission: (isEditing: boolean, missionId: string | null) => void; setEditingMission: (isEditing: boolean, missionId: number | null) => void;
} }
export const usePannelStore = create<PannelStore>((set) => ({ export const usePannelStore = create<PannelStore>((set) => ({

View File

@@ -5,7 +5,11 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { BellRing, BookmarkPlus } from "lucide-react"; import { BellRing, BookmarkPlus } from "lucide-react";
import { Select } from "_components/Select"; import { Select } from "_components/Select";
import { KEYWORD_CATEGORY, Mission, missionType, Prisma } from "@repo/db"; import { KEYWORD_CATEGORY, Mission, missionType, Prisma } from "@repo/db";
import { MissionOptionalDefaults, MissionOptionalDefaultsSchema } from "@repo/db/zod"; import {
JsonValueType,
MissionOptionalDefaults,
MissionOptionalDefaultsSchema,
} from "@repo/db/zod";
import { usePannelStore } from "_store/pannelStore"; import { usePannelStore } from "_store/pannelStore";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
@@ -23,11 +27,12 @@ import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { HPGValidationRequired } from "_helpers/hpgValidationRequired"; import { HPGValidationRequired } from "_helpers/hpgValidationRequired";
import { selectRandomHPGMissionSzenery } from "_helpers/selectRandomHPGMission"; import { selectRandomHPGMissionSzenery } from "_helpers/selectRandomHPGMission";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { cn } from "_helpers/cn";
export const MissionForm = () => { export const MissionForm = () => {
const { isEditingMission, editingMissionId, setEditingMission } = usePannelStore(); const { isEditingMission, editingMissionId, setEditingMission } = usePannelStore();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const setSeachOSMElements = useMapStore((s) => s.setSearchElements); const { setSearchElements, searchElements } = useMapStore((s) => s);
const { data: keywords } = useQuery({ const { data: keywords } = useQuery({
queryKey: ["keywords"], queryKey: ["keywords"],
@@ -120,6 +125,13 @@ export const MissionForm = () => {
} }
}, [session.data?.user.id, form]); }, [session.data?.user.id, form]);
useEffect(() => {
form.setValue(
"addressOSMways",
searchElements.filter((e) => e.isSelected) as unknown as JsonValueType[],
);
}, [searchElements, form]);
useEffect(() => { useEffect(() => {
if (missionFormValues) { if (missionFormValues) {
if (Object.keys(missionFormValues).length === 0) { if (Object.keys(missionFormValues).length === 0) {
@@ -135,8 +147,6 @@ export const MissionForm = () => {
} }
}, [missionFormValues, form, defaultFormValues]); }, [missionFormValues, form, defaultFormValues]);
console.log("Mission HPG String", form.watch("hpgMissionString"));
const saveMission = async ( const saveMission = async (
mission: MissionOptionalDefaults, mission: MissionOptionalDefaults,
{ alertWhenValid = false, createNewMission = false } = {}, { alertWhenValid = false, createNewMission = false } = {},
@@ -265,7 +275,7 @@ export const MissionForm = () => {
onChange={(e) => { onChange={(e) => {
form.setValue("type", e.target.value as missionType); form.setValue("type", e.target.value as missionType);
form.setValue("missionKeywordName", KEYWORD_CATEGORY.AB_ATMUNG); form.setValue("missionKeywordName", KEYWORD_CATEGORY.AB_ATMUNG);
form.setValue("missionKeywordAbbreviation", ""); form.setValue("missionKeywordAbbreviation", null as any);
form.setValue("hpgMissionString", null); form.setValue("hpgMissionString", null);
}} }}
> >
@@ -278,15 +288,9 @@ export const MissionForm = () => {
{...form.register("missionKeywordCategory")} {...form.register("missionKeywordCategory")}
className="select select-primary select-bordered w-full mb-4" className="select select-primary select-bordered w-full mb-4"
onChange={(e) => { onChange={(e) => {
const firstKeyword = keywords?.find(
(k) => k.category === form.watch("missionKeywordCategory"),
);
form.setValue("missionKeywordCategory", e.target.value as string); form.setValue("missionKeywordCategory", e.target.value as string);
form.setValue("missionKeywordName", firstKeyword?.name || (null as any)); form.setValue("missionKeywordName", null as any);
form.setValue( form.setValue("missionKeywordAbbreviation", null as any);
"missionKeywordAbbreviation",
firstKeyword?.abreviation || (null as any),
);
form.setValue("hpgMissionString", ""); form.setValue("hpgMissionString", "");
}} }}
value={form.watch("missionKeywordCategory") || "please_select"} value={form.watch("missionKeywordCategory") || "please_select"}
@@ -310,7 +314,7 @@ export const MissionForm = () => {
const keyword = keywords?.find((k) => k.abreviation === e.target.value); const keyword = keywords?.find((k) => k.abreviation === e.target.value);
form.setValue("missionKeywordName", keyword?.name || (null as any)); form.setValue("missionKeywordName", keyword?.name || (null as any));
form.setValue("missionKeywordAbbreviation", keyword?.abreviation || (null as any)); form.setValue("missionKeywordAbbreviation", keyword?.abreviation || (null as any));
form.setValue("hpgMissionString", "default"); form.setValue("hpgMissionString", null);
}} }}
value={form.watch("missionKeywordAbbreviation") || "please_select"} value={form.watch("missionKeywordAbbreviation") || "please_select"}
> >
@@ -332,8 +336,18 @@ export const MissionForm = () => {
<div className="mb-4"> <div className="mb-4">
<select <select
{...form.register("hpgMissionString")} {...form.register("hpgMissionString")}
onChange={(e) => {
form.setValue("hpgMissionString", e.target.value);
if (
form.watch("missionAdditionalInfo").length === 0 ||
form.watch("missionAdditionalInfo").startsWith("HPG-Szenario:")
) {
const [name] = e.target.value.split(":");
form.setValue("missionAdditionalInfo", `HPG-Szenario: ${name}`);
}
}}
className="select select-primary select-bordered w-full mb-2" className="select select-primary select-bordered w-full mb-2"
value={form.watch("hpgMissionString") || ""} value={form.watch("hpgMissionString") || "please_select"}
> >
<option disabled value="please_select"> <option disabled value="please_select">
Einsatz Szenario auswählen... Einsatz Szenario auswählen...
@@ -379,11 +393,11 @@ export const MissionForm = () => {
/> />
</div> </div>
{missionFormValues?.addressOSMways?.length && ( <p
<p className="text-sm text-info"> className={cn("text-sm text-gray-500", form.watch("addressOSMways").length && "text-info")}
In diesem Einsatz gibt es {missionFormValues?.addressOSMways?.length} Gebäude >
</p> In diesem Einsatz gibt es {form.watch("addressOSMways").length} Gebäude
)} </p>
<div className="form-control min-h-[140px]"> <div className="form-control min-h-[140px]">
<div className="flex gap-2"> <div className="flex gap-2">
@@ -395,7 +409,7 @@ export const MissionForm = () => {
try { try {
const newMission = await saveMission(mission); const newMission = await saveMission(mission);
toast.success(`Einsatz ${newMission.id} erfolgreich aktualisiert`); toast.success(`Einsatz ${newMission.id} erfolgreich aktualisiert`);
setSeachOSMElements([]); // Reset search elements setSearchElements([]); // Reset search elements
setEditingMission(false, null); // Reset editing state setEditingMission(false, null); // Reset editing state
form.reset(); // Reset the form form.reset(); // Reset the form
setOpen(false); setOpen(false);
@@ -425,7 +439,7 @@ export const MissionForm = () => {
if (!validationRequired) { if (!validationRequired) {
await sendAlertMutation.mutateAsync(newMission.id); await sendAlertMutation.mutateAsync(newMission.id);
} }
setSeachOSMElements([]); // Reset search elements setSearchElements([]); // Reset search elements
setOpen(false); setOpen(false);
} catch (error) { } catch (error) {
if (error instanceof AxiosError) { if (error instanceof AxiosError) {
@@ -451,7 +465,7 @@ export const MissionForm = () => {
createNewMission: true, createNewMission: true,
}); });
setSeachOSMElements([]); // Reset search elements setSearchElements([]); // Reset search elements
toast.success(`Einsatz ${newMission.publicId} erstellt`); toast.success(`Einsatz ${newMission.publicId} erstellt`);
form.reset(); form.reset();
setOpen(false); setOpen(false);

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@@ -1,4 +1,5 @@
export interface OSMWay { export interface OSMWay {
isSelected: boolean;
wayID: number; wayID: number;
tags: Record<string, string>; tags: Record<string, string>;
nodes: { nodes: {