From ef93275d9cdf1b4aef09799cbd8fa5ae14a8ded4 Mon Sep 17 00:00:00 2001 From: PxlLoewe <72106766+PxlLoewe@users.noreply.github.com> Date: Fri, 25 Apr 2025 12:02:59 -0700 Subject: [PATCH] Added marker clustering when zoomed out --- apps/dispatch/app/_components/SmartPopup.tsx | 30 ++- apps/dispatch/app/_store/aircraftsStore.ts | 10 +- .../_components/map/AircraftMarker.tsx | 27 +- .../app/dispatch/_components/map/Map.tsx | 6 +- .../_components/map/MissionMarkers.tsx | 18 +- .../map/_components/MarkerCluster.tsx | 241 ++++++++++++++++++ grafana/grafana.db | Bin 1122304 -> 1122304 bytes 7 files changed, 300 insertions(+), 32 deletions(-) create mode 100644 apps/dispatch/app/dispatch/_components/map/_components/MarkerCluster.tsx diff --git a/apps/dispatch/app/_components/SmartPopup.tsx b/apps/dispatch/app/_components/SmartPopup.tsx index b0418bb2..5923a2fb 100644 --- a/apps/dispatch/app/_components/SmartPopup.tsx +++ b/apps/dispatch/app/_components/SmartPopup.tsx @@ -1,8 +1,13 @@ import { cn } from "helpers/cn"; -import { RefAttributes, useEffect, useImperativeHandle } from "react"; +import { + RefAttributes, + useCallback, + useEffect, + useImperativeHandle, +} from "react"; import { createContext, Ref, useContext, useState } from "react"; import { Popup, PopupProps, useMap } from "react-leaflet"; -import { Popup as LPopup, popup } from "leaflet"; +import { Popup as LPopup } from "leaflet"; const PopupContext = createContext({ anchor: "topleft", @@ -16,7 +21,11 @@ export const useSmartPopup = () => { return context; }; -export const calculateAnchor = (id: string, mode: "popup" | "marker") => { +export const calculateAnchor = ( + id: string, + mode: "popup" | "marker", + ignoreMarker?: boolean, +) => { const otherMarkers = document.querySelectorAll(".map-collision"); // get markers and check if they are overlapping const ownMarker = @@ -28,6 +37,7 @@ export const calculateAnchor = (id: string, mode: "popup" | "marker") => { const marksersInCluster = Array.from(otherMarkers).filter((marker) => { if (mode === "popup" && marker.id === `marker-${id}`) return false; + if (ignoreMarker && marker.id.startsWith("marker")) return false; const rect1 = (marker as HTMLElement).getBoundingClientRect(); const rect2 = (ownMarker as HTMLElement).getBoundingClientRect(); @@ -90,24 +100,24 @@ export interface SmartPopupRef { } export const SmartPopup = ( - props: PopupProps & - RefAttributes & { + props: PopupProps & { ignoreMarker?: boolean } & RefAttributes & { smartPopupRef?: Ref; id: string; wrapperClassName?: string; }, ) => { const [showContent, setShowContent] = useState(false); - const { smartPopupRef, id, className, wrapperClassName } = props; + const { smartPopupRef, id, className, wrapperClassName, ignoreMarker } = + props; const [anchor, setAnchor] = useState< "topleft" | "topright" | "bottomleft" | "bottomright" >("topleft"); - const handleConflict = () => { - const newAnchor = calculateAnchor(id, "popup"); + const handleConflict = useCallback(() => { + const newAnchor = calculateAnchor(id, "popup", ignoreMarker); setAnchor(newAnchor); - }; + }, [id, ignoreMarker]); useImperativeHandle(smartPopupRef, () => ({ handleConflict, @@ -125,7 +135,7 @@ export const SmartPopup = ( return () => { map.off("zoom", handleConflict); }; - }, [map, anchor]); + }, [map, handleConflict]); return ( diff --git a/apps/dispatch/app/_store/aircraftsStore.ts b/apps/dispatch/app/_store/aircraftsStore.ts index f3f2721c..87223ee0 100644 --- a/apps/dispatch/app/_store/aircraftsStore.ts +++ b/apps/dispatch/app/_store/aircraftsStore.ts @@ -13,7 +13,7 @@ export interface Aircraft { }[]; location: { lat: number; - lon: number; + lng: number; altitude: number; speed: number; }; @@ -43,7 +43,7 @@ export const useAircraftsStore = create((set) => ({ fmsLog: [], location: { lat: 52.546781040592776, - lon: 13.369535209542219, + lng: 13.369535209542219, altitude: 0, speed: 0, }, @@ -58,7 +58,7 @@ export const useAircraftsStore = create((set) => ({ fmsLog: [], location: { lat: 52.54588546048977, - lon: 13.46470691054384, + lng: 13.46470691054384, altitude: 0, speed: 0, }, @@ -73,7 +73,7 @@ export const useAircraftsStore = create((set) => ({ fmsLog: [], location: { lat: 52.497519717230155, - lon: 13.342040806552554, + lng: 13.342040806552554, altitude: 0, speed: 0, }, @@ -88,7 +88,7 @@ export const useAircraftsStore = create((set) => ({ fmsLog: [], location: { lat: 52.50175041192073, - lon: 13.478628701227349, + lng: 13.478628701227349, altitude: 0, speed: 0, }, diff --git a/apps/dispatch/app/dispatch/_components/map/AircraftMarker.tsx b/apps/dispatch/app/dispatch/_components/map/AircraftMarker.tsx index b22692a1..496f1f1d 100644 --- a/apps/dispatch/app/dispatch/_components/map/AircraftMarker.tsx +++ b/apps/dispatch/app/dispatch/_components/map/AircraftMarker.tsx @@ -225,6 +225,7 @@ const AircraftPopupContent = ({ aircraft }: { aircraft: Aircraft }) => { const AircraftMarker = ({ aircraft }: { aircraft: Aircraft }) => { const aircrafts = useAircraftsStore((state) => state.aircrafts); + const [hideMarker, setHideMarker] = useState(false); const map = useMap(); const markerRef = useRef(null); const popupRef = useRef(null); @@ -269,20 +270,25 @@ const AircraftMarker = ({ aircraft }: { aircraft: Aircraft }) => { setAnchor(newAnchor); }, [aircraft.id]); - useEffect(() => { + /* useEffect(() => { handleConflict(); - }, [aircrafts, openAircraftMarker, handleConflict]); - - useEffect(() => {}); + }, [aircrafts, openAircraftMarker, handleConflict]); */ useEffect(() => { setTimeout(() => { - handleConflict(); + handleZoom(); }, 100); - map.on("zoom", handleConflict); + const handleZoom = () => { + const zoom = map.getZoom(); + console.log("zoom", zoom); + setHideMarker(zoom < 9); + handleConflict(); + }; + + map.on("zoomend", handleZoom); return () => { - map.off("zoom", handleConflict); + map.off("zoomend", handleZoom); }; }, [map, openAircraftMarker, handleConflict]); @@ -296,6 +302,7 @@ const AircraftMarker = ({ aircraft }: { aircraft: Aircraft }) => { "relative w-auto transform inline-flex items-center gap-2 px-2 z-100", anchor.includes("right") && "-translate-x-full", anchor.includes("bottom") && "-translate-y-full", + hideMarker ? "opacity-0 pointer-events-none" : "opacity-100", )}" style=" background-color: ${FMS_STATUS_COLORS[aircraft.fmsStatus]}; @@ -338,7 +345,7 @@ const AircraftMarker = ({ aircraft }: { aircraft: Aircraft }) => { { }) } /> - {openAircraftMarker.some((m) => m.id === aircraft.id) && ( + {openAircraftMarker.some((m) => m.id === aircraft.id) && !hideMarker && ( { +const Map = () => { const { map } = useMapStore(); return ( @@ -21,8 +22,11 @@ export default ({}) => { + ); }; + +export default Map; diff --git a/apps/dispatch/app/dispatch/_components/map/MissionMarkers.tsx b/apps/dispatch/app/dispatch/_components/map/MissionMarkers.tsx index a8ee23cf..2519fe97 100644 --- a/apps/dispatch/app/dispatch/_components/map/MissionMarkers.tsx +++ b/apps/dispatch/app/dispatch/_components/map/MissionMarkers.tsx @@ -180,6 +180,7 @@ const MissionPopupContent = ({ mission }: { mission: Mission }) => { const MissionMarker = ({ mission }: { mission: Mission }) => { const map = useMap(); + const [hideMarker, setHideMarker] = useState(false); const markerRef = useRef(null); const popupRef = useRef(null); @@ -227,16 +228,20 @@ const MissionMarker = ({ mission }: { mission: Mission }) => { handleConflict(); }, [handleConflict]); - useEffect(() => {}); - useEffect(() => { setTimeout(() => { - handleConflict(); + handleZoom(); }, 100); - map.on("zoom", handleConflict); + const handleZoom = () => { + const zoom = map.getZoom(); + setHideMarker(zoom < 9); + handleConflict(); + }; + + map.on("zoomend", handleZoom); return () => { - map.off("zoom", handleConflict); + map.off("zoomend", handleZoom); }; }, [map, openMissionMarker, handleConflict]); @@ -250,6 +255,7 @@ const MissionMarker = ({ mission }: { mission: Mission }) => { "relative w-auto transform inline-flex items-center gap-2 px-2 z-100", anchor.includes("right") && "-translate-x-full", anchor.includes("bottom") && "-translate-y-full", + hideMarker ? "opacity-0 pointer-events-none" : "opacity-100", )}" style=" background-color: ${MISSION_STATUS_COLORS[mission.state]}; @@ -294,7 +300,7 @@ const MissionMarker = ({ mission }: { mission: Mission }) => { }) } /> - {openMissionMarker.some((m) => m.id === mission.id) && ( + {openMissionMarker.some((m) => m.id === mission.id) && !hideMarker && ( { + const { anchor } = useSmartPopup(); + + let borderColor = ""; + + if (anchor.includes("top")) { + if (missions.length > 0) { + borderColor = MISSION_STATUS_TEXT_COLORS[missions[0]!.state]; + } else if (aircrafts.length > 0) { + borderColor = FMS_STATUS_TEXT_COLORS[aircrafts[0]!.fmsStatus] || "white"; + } + } else if (anchor.includes("bottom")) { + if (aircrafts.length > 0) { + borderColor = FMS_STATUS_TEXT_COLORS[aircrafts[0]!.fmsStatus] || "white"; + } else if (missions.length > 0) { + borderColor = MISSION_STATUS_TEXT_COLORS[missions[0]!.state]; + } + } + + return ( + <> +
+
+ {missions.map((mission) => ( +
+ + {mission.missionKeywordAbbreviation} + +
+ ))} + {aircrafts.map((aircraft) => ( +
+ + {aircraft.fmsStatus} + + {aircraft.bosName} +
+ ))} +
+ + ); +}; + +export const MarkerCluster = () => { + const map = useMap(); + const aircrafts = useAircraftsStore((state) => state.aircrafts); + const missions = useMissionsStore((state) => state.missions); + const [cluster, setCluster] = useState< + { + aircrafts: Aircraft[]; + missions: Mission[]; + lat: number; + lng: number; + }[] + >([]); + + useEffect(() => { + const handleZoom = () => { + const zoom = map.getZoom(); + let newCluster: typeof cluster = []; + aircrafts.forEach((aircraft) => { + const lat = aircraft.location.lat; + const lng = aircraft.location.lng; + + const existingClusterIndex = newCluster.findIndex( + (c) => Math.abs(c.lat - lat) < 1 && Math.abs(c.lng - lng) < 1, + ); + console.log("existingClusterIndex", existingClusterIndex); + const existingCluster = newCluster[existingClusterIndex]; + console.log("existingCluster", existingCluster, lat, lng); + if (existingCluster) { + newCluster = [...newCluster].map((c, i) => { + if (i === existingClusterIndex) { + return { + ...c, + aircrafts: [...c.aircrafts, aircraft], + }; + } + return c; + }); + } else { + newCluster = [ + ...newCluster, + { + aircrafts: [aircraft], + missions: [], + lat, + lng, + }, + ]; + } + }); + missions.forEach((mission) => { + const lat = mission.addressLat; + const lng = mission.addressLng; + const existingClusterIndex = newCluster.findIndex( + (c) => Math.abs(c.lat - lat) < 1 && Math.abs(c.lng - lng) < 1, + ); + const existingCluster = newCluster[existingClusterIndex]; + + if (existingCluster) { + newCluster = [...newCluster].map((c, i) => { + if (i === existingClusterIndex) { + return { + ...c, + missions: [...c.missions, mission], + }; + } + return c; + }); + } else { + newCluster = [ + ...newCluster, + { + aircrafts: [], + missions: [mission], + lat, + lng, + }, + ]; + } + }); + + setCluster((prev) => { + return prev.map((c) => { + const aircraftPos = c.aircrafts.map((a) => [ + a.location.lat, + a.location.lng, + ]); + const missionPos = c.missions.map((m) => [ + m.addressLat, + m.addressLng, + ]); + const allPos = [...aircraftPos, ...missionPos]; + + // Calculate the average position of all markers in the cluster + const avgLat = + allPos.reduce((sum, pos) => sum + pos[0]!, 0) / allPos.length; + const avgLng = + allPos.reduce((sum, pos) => sum + pos[1]!, 0) / allPos.length; + + return { + ...c, + lat: avgLat, + lng: avgLng, + }; + }); + }); + + if (zoom >= 9) { + setCluster([]); + } else { + setCluster(newCluster); + } + }; + handleZoom(); + map.on("zoomend", handleZoom); + return () => { + map.off("zoomend", handleZoom); + }; + }, [map, aircrafts, missions]); + console.log("cluster", cluster); + return ( + <> + {cluster.map((c, i) => ( + +
+ +
+
+ ))} + + ); +}; diff --git a/grafana/grafana.db b/grafana/grafana.db index 4139ebd5dd005962814d45ff02964c7861d97c57..6f93118e76d3303cd66ea4f1f23d79bc5149686b 100644 GIT binary patch delta 191 zcmZoT;L>owWr7qFEB{0pCm^{o;hQd-jGP#=Gw1Zi&78v11+2I@n#=Xt%k>z6mv4!N@`z?|pXp;@U{JK=V42=;&mqQ=!M!Mc`euC&CDuI|+_&aU zzopM1&&rp&qP0AkMVhwQnm16Y6>dABk$G7Ge` aEZ_oSZXo6XVqPHT17iN|EDHqw)B^z3wK)m^ delta 191 zcmZoT;L>owWr7qF%jbzQPC#;F!Z%$uX*n@wXU^%3n>mH23s`Y+G?(kOm+LVCF%u9o z12GE_vjQ<25VLPD*W(almhNI;U{JK=V42=;&mqQ=!F5Ps`euC&CDx@G+_I0R z-_qxhXXVY{`k%f1pFYQOepYE2W?RPT5A``D+7H=t05RwGL-t(O0W3g`yjvL=nFZQe Z7H|PEHxTmxF)tAF0Wtq}mIVTT>H&?EIdlL3