Added marker clustering when zoomed out
This commit is contained in:
@@ -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)}>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user