Added marker clustering when zoomed out

This commit is contained in:
PxlLoewe
2025-04-25 12:02:59 -07:00
parent 2eb2f4bf2b
commit ef93275d9c
7 changed files with 300 additions and 32 deletions

View File

@@ -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<LPopup> & {
props: PopupProps & { ignoreMarker?: boolean } & RefAttributes<LPopup> & {
smartPopupRef?: Ref<SmartPopupRef>;
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 (
<Popup {...props} className={cn("relative", wrapperClassName)}>

View File

@@ -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<AircraftStore>((set) => ({
fmsLog: [],
location: {
lat: 52.546781040592776,
lon: 13.369535209542219,
lng: 13.369535209542219,
altitude: 0,
speed: 0,
},
@@ -58,7 +58,7 @@ export const useAircraftsStore = create<AircraftStore>((set) => ({
fmsLog: [],
location: {
lat: 52.54588546048977,
lon: 13.46470691054384,
lng: 13.46470691054384,
altitude: 0,
speed: 0,
},
@@ -73,7 +73,7 @@ export const useAircraftsStore = create<AircraftStore>((set) => ({
fmsLog: [],
location: {
lat: 52.497519717230155,
lon: 13.342040806552554,
lng: 13.342040806552554,
altitude: 0,
speed: 0,
},
@@ -88,7 +88,7 @@ export const useAircraftsStore = create<AircraftStore>((set) => ({
fmsLog: [],
location: {
lat: 52.50175041192073,
lon: 13.478628701227349,
lng: 13.478628701227349,
altitude: 0,
speed: 0,
},

View File

@@ -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<LMarker>(null);
const popupRef = useRef<LPopup>(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 }) => {
<Fragment key={aircraft.id}>
<Marker
ref={markerRef}
position={[aircraft.location.lat, aircraft.location.lon]}
position={[aircraft.location.lat, aircraft.location.lng]}
icon={
new DivIcon({
iconAnchor: [0, 0],
@@ -346,11 +353,11 @@ const AircraftMarker = ({ aircraft }: { aircraft: Aircraft }) => {
})
}
/>
{openAircraftMarker.some((m) => m.id === aircraft.id) && (
{openAircraftMarker.some((m) => m.id === aircraft.id) && !hideMarker && (
<SmartPopup
id={aircraft.id}
ref={popupRef}
position={[aircraft.location.lat, aircraft.location.lon]}
position={[aircraft.location.lat, aircraft.location.lng]}
autoClose={false}
closeOnClick={false}
autoPan={false}

View File

@@ -7,8 +7,9 @@ import { ContextMenu } from "dispatch/_components/map/ContextMenu";
import { MissionLayer } from "dispatch/_components/map/MissionMarkers";
import { SearchElements } from "dispatch/_components/map/SearchElements";
import { AircraftLayer } from "dispatch/_components/map/AircraftMarker";
import { MarkerCluster } from "dispatch/_components/map/_components/MarkerCluster";
export default ({}) => {
const Map = () => {
const { map } = useMapStore();
return (
@@ -21,8 +22,11 @@ export default ({}) => {
<BaseMaps />
<SearchElements />
<ContextMenu />
<MarkerCluster />
<MissionLayer />
<AircraftLayer />
</MapContainer>
);
};
export default Map;

View File

@@ -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<LMarker>(null);
const popupRef = useRef<LPopup>(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 && (
<SmartPopup
id={mission.id.toString()}
ref={popupRef}

View File

@@ -0,0 +1,241 @@
import { Mission } from "@repo/db";
import { SmartPopup, useSmartPopup } from "_components/SmartPopup";
import { Aircraft, useAircraftsStore } from "_store/aircraftsStore";
import { useMissionsStore } from "_store/missionsStore";
import {
FMS_STATUS_COLORS,
FMS_STATUS_TEXT_COLORS,
} from "dispatch/_components/map/AircraftMarker";
import {
MISSION_STATUS_COLORS,
MISSION_STATUS_TEXT_COLORS,
} from "dispatch/_components/map/MissionMarkers";
import { cn } from "helpers/cn";
import { useEffect, useState } from "react";
import { useMap } from "react-leaflet";
const PopupContent = ({
aircrafts,
missions,
}: {
aircrafts: Aircraft[];
missions: Mission[];
}) => {
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 (
<>
<div className="relative flex flex-col text-white min-w-[200px]">
<div
className={cn(
"absolute w-[calc(100%+2px)] h-4 z-99",
anchor.includes("left") ? "-left-[2px]" : "-right-[2px]",
anchor.includes("top") ? "-top-[2px]" : "-bottom-[2px]",
)}
style={{
borderLeft: anchor.includes("left")
? `3px solid ${borderColor}`
: "",
borderRight: anchor.includes("right")
? `3px solid ${borderColor}`
: "",
borderTop: anchor.includes("top") ? `3px solid ${borderColor}` : "",
borderBottom: anchor.includes("bottom")
? `3px solid ${borderColor}`
: "",
}}
/>
{missions.map((mission) => (
<div
key={mission.id}
className={cn(
"relative inline-flex items-center gap-2 text-nowrap w-full",
)}
style={{
backgroundColor: MISSION_STATUS_COLORS[mission.state],
}}
>
<span className="mx-2 my-0.5">
{mission.missionKeywordAbbreviation}
</span>
</div>
))}
{aircrafts.map((aircraft) => (
<div
key={aircraft.id}
className="relative w-auto inline-flex items-center gap-2 text-nowrap"
style={{
backgroundColor: FMS_STATUS_COLORS[aircraft.fmsStatus],
}}
>
<span
className="mx-2 my-0.5 text-gt font-bold"
style={{
color: FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus],
}}
>
{aircraft.fmsStatus}
</span>
<span>{aircraft.bosName}</span>
</div>
))}
</div>
</>
);
};
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) => (
<SmartPopup
ignoreMarker
id={`cluster-${i}`}
wrapperClassName="relative"
key={i}
position={[c.lat, c.lng]}
autoPan={false}
autoClose={false}
>
<div>
<PopupContent aircrafts={c.aircrafts} missions={c.missions} />
</div>
</SmartPopup>
))}
</>
);
};