app/javascript/react/components/Map/Markers/MobileMarkers.tsx
"use client";
import { useMap } from "@vis.gl/react-google-maps";
import { useCallback, useEffect, useRef, useState } from "react";
import { useSelector } from "react-redux";
import { useAppDispatch } from "../../../store/hooks";
import { setMarkersLoading } from "../../../store/markersLoadingSlice";
import {
selectMobileStreamData,
selectMobileStreamStatus,
} from "../../../store/mobileStreamSelectors";
import { selectThresholds } from "../../../store/thresholdSlice";
import { StatusEnum } from "../../../types/api";
import { LatLngLiteral } from "../../../types/googleMaps";
import { Point, Session } from "../../../types/sessionType";
import { useMapParams } from "../../../utils/mapParamsHandler";
import { getColorForValue } from "../../../utils/thresholdColors";
import { CustomMarker } from "./CustomMarker";
import { LabelOverlay } from "./customMarkerLabel";
import { CustomMarkerOverlay } from "./customMarkerOverlay";
type Props = {
sessions: Session[];
onMarkerClick: (streamId: number | null, id: number | null) => void;
selectedStreamId: number | null;
pulsatingSessionId: number | null;
};
const MobileMarkers = ({
sessions,
onMarkerClick,
selectedStreamId,
pulsatingSessionId,
}: Props) => {
const DISTANCE_THRESHOLD = 21;
const ZOOM_FOR_SELECTED_SESSION = 16;
const LAT_DIFF_SMALL = 0.00001;
const LAT_DIFF_MEDIUM = 0.0001;
const LAT_ADJUST_SMALL = 0.005;
const BASE_Z_INDEX = 1;
const OVERLAY_Z_INDEX = 2;
const map = useMap();
const dispatch = useAppDispatch();
const thresholds = useSelector(selectThresholds);
const { unitSymbol } = useMapParams();
const mobileStreamData = useSelector(selectMobileStreamData);
const mobileStreamStatus = useSelector(selectMobileStreamStatus);
const markerRefs = useRef<Map<string, CustomMarker>>(new Map());
const markerOverlays = useRef<Map<string, CustomMarkerOverlay>>(new Map());
const labelOverlays = useRef<Map<string, LabelOverlay>>(new Map());
const [selectedMarkerKey, setSelectedMarkerKey] = useState<string | null>(
null
);
const areMarkersTooClose = useCallback(
(marker1: LatLngLiteral, marker2: LatLngLiteral) => {
if (!map) return false;
const zoom = map.getZoom() ?? 0;
const latDiff = marker1.lat - marker2.lat;
const lngDiff = marker1.lng - marker2.lng;
const distance = Math.sqrt(latDiff * latDiff + lngDiff * lngDiff);
const pixelSize = Math.pow(2, -zoom);
const distanceInPixels = distance / pixelSize;
return distanceInPixels < DISTANCE_THRESHOLD;
},
[map]
);
const centerMapOnBounds = useCallback(
(
minLatitude: number,
maxLatitude: number,
minLongitude: number,
maxLongitude: number
) => {
if (map && !selectedMarkerKey) {
const latDiff = maxLatitude - minLatitude;
const lngDiff = maxLongitude - minLongitude;
if (latDiff < LAT_DIFF_SMALL && lngDiff < LAT_DIFF_SMALL) {
const centerLat = (maxLatitude + minLatitude) / 2;
const centerLng = (maxLongitude + minLongitude) / 2;
map.setCenter({ lat: centerLat, lng: centerLng });
map.setZoom(ZOOM_FOR_SELECTED_SESSION);
} else {
let adjustedLat: number;
if (latDiff >= 0 && latDiff < LAT_DIFF_SMALL) {
adjustedLat = minLatitude - LAT_ADJUST_SMALL;
} else if (latDiff >= LAT_DIFF_SMALL && latDiff < LAT_DIFF_MEDIUM) {
adjustedLat = minLatitude - latDiff * 2;
} else {
adjustedLat = minLatitude - latDiff;
}
const bounds = new google.maps.LatLngBounds(
new google.maps.LatLng(adjustedLat, minLongitude),
new google.maps.LatLng(maxLatitude, maxLongitude)
);
map.fitBounds(bounds);
if (latDiff === 0) {
map.setZoom(ZOOM_FOR_SELECTED_SESSION);
}
}
setSelectedMarkerKey(null);
}
},
[map, selectedMarkerKey]
);
const centerMapOnMarker = useCallback(
(position: Point) => {
if (map && !selectedMarkerKey) {
map.setCenter(position);
map.setZoom(ZOOM_FOR_SELECTED_SESSION);
}
setSelectedMarkerKey(null);
},
[map, selectedMarkerKey]
);
const createMarker = useCallback(
(session: Session): CustomMarker => {
const color = getColorForValue(thresholds, session.lastMeasurementValue);
const shouldPulse = session.id === pulsatingSessionId;
const size = 12;
const marker = new CustomMarker(
session.point,
color,
"",
size,
undefined,
() => {
onMarkerClick(Number(session.point.streamId), Number(session.id));
centerMapOnMarker(session.point);
},
size,
"overlayMouseTarget"
);
marker.setPulsating(shouldPulse);
marker.setZIndex(BASE_Z_INDEX);
return marker;
},
[thresholds, pulsatingSessionId, onMarkerClick, centerMapOnMarker]
);
const updateMarkers = useCallback(() => {
markerRefs.current.forEach((marker, streamId) => {
const session = sessions.find((s) => s.point.streamId === streamId);
if (!session) return;
const isSelected = streamId === selectedStreamId?.toString();
const shouldPulse = session.id === pulsatingSessionId;
const isOverlapping = sessions.some(
(otherSession) =>
otherSession.point.streamId !== streamId &&
areMarkersTooClose(session.point, otherSession.point)
);
const color = getColorForValue(thresholds, session.lastMeasurementValue);
const size = 12;
marker.setColor(color);
marker.setSize(size);
marker.setPulsating(shouldPulse);
marker.setClickableAreaSize(size);
marker.setZIndex(BASE_Z_INDEX);
if (isOverlapping) {
const existingOverlay = markerOverlays.current.get(streamId);
if (existingOverlay) {
existingOverlay.setMap(null);
markerOverlays.current.delete(streamId);
}
const existingLabel = labelOverlays.current.get(streamId);
if (existingLabel) {
existingLabel.setMap(null);
labelOverlays.current.delete(streamId);
}
} else {
let overlay = markerOverlays.current.get(streamId);
if (!overlay) {
overlay = new CustomMarkerOverlay(
marker.getPosition()!,
color,
isSelected,
shouldPulse
);
overlay.setMap(map);
markerOverlays.current.set(streamId, overlay);
} else {
overlay.setIsSelected(isSelected);
overlay.setShouldPulse(shouldPulse);
overlay.setColor(color);
overlay.update();
}
let labelOverlay = labelOverlays.current.get(streamId);
if (!labelOverlay) {
labelOverlay = new LabelOverlay(
marker.getPosition()!,
color,
session.lastMeasurementValue,
unitSymbol,
isSelected,
() => {
onMarkerClick(Number(streamId), Number(session.id));
centerMapOnMarker(session.point);
}
);
labelOverlay.setMap(map);
labelOverlays.current.set(streamId, labelOverlay);
} else {
labelOverlay.update(
isSelected,
color,
session.lastMeasurementValue,
unitSymbol
);
}
labelOverlay.setZIndex(OVERLAY_Z_INDEX);
}
});
}, [
sessions,
selectedStreamId,
pulsatingSessionId,
thresholds,
unitSymbol,
areMarkersTooClose,
map,
onMarkerClick,
centerMapOnMarker,
]);
useEffect(() => {
if (selectedStreamId && mobileStreamStatus !== StatusEnum.Pending) {
const { minLatitude, maxLatitude, minLongitude, maxLongitude } =
mobileStreamData;
if (minLatitude && maxLatitude && minLongitude && maxLongitude) {
centerMapOnBounds(minLatitude, maxLatitude, minLongitude, maxLongitude);
}
}
if (selectedStreamId === null) {
setSelectedMarkerKey(null);
}
}, [
selectedStreamId,
mobileStreamData,
mobileStreamStatus,
centerMapOnBounds,
]);
useEffect(() => {
if (!selectedStreamId) {
dispatch(setMarkersLoading(true));
}
}, [dispatch, sessions.length, selectedStreamId]);
useEffect(() => {
if (!map) return;
const updatedMarkers = new Set<string>();
sessions.forEach((session) => {
const markerId = session.point.streamId;
updatedMarkers.add(markerId);
let marker = markerRefs.current.get(markerId);
if (!marker) {
marker = createMarker(session);
marker.setMap(map);
markerRefs.current.set(markerId, marker);
} else {
marker.setPosition(session.point);
}
});
markerRefs.current.forEach((marker, markerId) => {
if (!updatedMarkers.has(markerId)) {
marker.setMap(null);
markerRefs.current.delete(markerId);
const overlay = markerOverlays.current.get(markerId);
if (overlay) {
overlay.setMap(null);
markerOverlays.current.delete(markerId);
}
const labelOverlay = labelOverlays.current.get(markerId);
if (labelOverlay) {
labelOverlay.setMap(null);
labelOverlays.current.delete(markerId);
}
}
});
updateMarkers();
if (!selectedStreamId && markerRefs.current.size >= sessions.length) {
dispatch(setMarkersLoading(false));
}
}, [sessions, map, createMarker, updateMarkers, selectedStreamId, dispatch]);
useEffect(() => {
return () => {
markerRefs.current.forEach((marker) => marker.setMap(null));
markerRefs.current.clear();
markerOverlays.current.forEach((overlay) => overlay.setMap(null));
markerOverlays.current.clear();
labelOverlays.current.forEach((overlay) => overlay.setMap(null));
labelOverlays.current.clear();
};
}, []);
return null;
};
export { MobileMarkers };