import { useId, useCallback, useEffect } from 'react'; import { useSelector } from 'react-redux'; import { useMediaQuery } from '@mui/material'; import { useTheme } from '@mui/styles'; import { map } from './core/MapView'; import { formatTime, getStatusColor } from '../common/util/formatter'; import { mapIconKey } from './core/preloadImages'; import { findFonts } from './core/mapUtil'; import { useAttributePreference, usePreference } from '../common/util/preferences'; const MapPositions = ({ positions, onClick, showStatus, selectedPosition, titleField }) => { const id = useId(); const clusters = `${id}-clusters`; const selected = `${id}-selected`; const theme = useTheme(); const desktop = useMediaQuery(theme.breakpoints.up('md')); const iconScale = useAttributePreference('iconScale', desktop ? 0.75 : 1); const devices = useSelector((state) => state.devices.items); const selectedDeviceId = useSelector((state) => state.devices.selectedId); const mapCluster = useAttributePreference('mapCluster', true); const hours12 = usePreference('twelveHourFormat'); const directionType = useAttributePreference('mapDirection', 'selected'); const createFeature = (devices, position, selectedPositionId) => { const device = devices[position.deviceId]; let showDirection; switch (directionType) { case 'none': showDirection = false; break; case 'all': showDirection = true; break; default: showDirection = selectedPositionId === position.id; break; } return { id: position.id, deviceId: position.deviceId, name: device.name, fixTime: formatTime(position.fixTime, 'seconds', hours12), category: mapIconKey(device.category), color: showStatus ? position.attributes.color || getStatusColor(device.status) : 'neutral', rotation: position.course, direction: showDirection, }; }; const onMouseEnter = () => map.getCanvas().style.cursor = 'pointer'; const onMouseLeave = () => map.getCanvas().style.cursor = ''; const onMapClick = useCallback((event) => { if (!event.defaultPrevented && onClick) { onClick(); } }, [onClick]); const onMarkerClick = useCallback((event) => { event.preventDefault(); const feature = event.features[0]; if (onClick) { onClick(feature.properties.id, feature.properties.deviceId); } }, [onClick]); const onClusterClick = useCallback((event) => { event.preventDefault(); const features = map.queryRenderedFeatures(event.point, { layers: [clusters], }); const clusterId = features[0].properties.cluster_id; map.getSource(id).getClusterExpansionZoom(clusterId, (error, zoom) => { if (!error) { map.easeTo({ center: features[0].geometry.coordinates, zoom, }); } }); }, [clusters]); useEffect(() => { map.addSource(id, { type: 'geojson', data: { type: 'FeatureCollection', features: [], }, cluster: mapCluster, clusterMaxZoom: 14, clusterRadius: 50, }); map.addSource(selected, { type: 'geojson', data: { type: 'FeatureCollection', features: [], }, }); [id, selected].forEach((source) => { map.addLayer({ id: source, type: 'symbol', source, filter: ['!has', 'point_count'], layout: { 'icon-image': '{category}-{color}', 'icon-size': iconScale, 'icon-allow-overlap': true, 'text-field': `{${titleField || 'name'}}`, 'text-allow-overlap': true, 'text-anchor': 'bottom', 'text-offset': [0, -2 * iconScale], 'text-font': findFonts(map), 'text-size': 12, }, paint: { 'text-halo-color': 'white', 'text-halo-width': 1, }, }); map.addLayer({ id: `direction-${source}`, type: 'symbol', source, filter: [ 'all', ['!has', 'point_count'], ['==', 'direction', true], ], layout: { 'icon-image': 'direction', 'icon-size': iconScale, 'icon-allow-overlap': true, 'icon-rotate': ['get', 'rotation'], 'icon-rotation-alignment': 'map', }, }); map.on('mouseenter', source, onMouseEnter); map.on('mouseleave', source, onMouseLeave); map.on('click', source, onMarkerClick); }); map.addLayer({ id: clusters, type: 'symbol', source: id, filter: ['has', 'point_count'], layout: { 'icon-image': 'background', 'icon-size': iconScale, 'text-field': '{point_count_abbreviated}', 'text-font': findFonts(map), 'text-size': 14, }, }); map.on('mouseenter', clusters, onMouseEnter); map.on('mouseleave', clusters, onMouseLeave); map.on('click', clusters, onClusterClick); map.on('click', onMapClick); return () => { map.off('mouseenter', clusters, onMouseEnter); map.off('mouseleave', clusters, onMouseLeave); map.off('click', clusters, onClusterClick); map.off('click', onMapClick); if (map.getLayer(clusters)) { map.removeLayer(clusters); } [id, selected].forEach((source) => { map.off('mouseenter', source, onMouseEnter); map.off('mouseleave', source, onMouseLeave); map.off('click', source, onMarkerClick); if (map.getLayer(source)) { map.removeLayer(source); } if (map.getLayer(`direction-${source}`)) { map.removeLayer(`direction-${source}`); } if (map.getSource(source)) { map.removeSource(source); } }); }; }, [mapCluster, clusters, onMarkerClick, onClusterClick]); useEffect(() => { [id, selected].forEach((source) => { map.getSource(source)?.setData({ type: 'FeatureCollection', features: positions.filter((it) => devices.hasOwnProperty(it.deviceId)) .filter((it) => (source === id ? it.deviceId !== selectedDeviceId : it.deviceId === selectedDeviceId)) .map((position) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [position.longitude, position.latitude], }, properties: createFeature(devices, position, selectedPosition && selectedPosition.id), })), }); }); }, [mapCluster, clusters, onMarkerClick, onClusterClick, devices, positions, selectedPosition]); return null; }; export default MapPositions;