diff --git a/src/BuoyTrawlLineLayer/index.js b/src/BuoyTrawlLineLayer/index.js new file mode 100644 index 000000000..738c1d72e --- /dev/null +++ b/src/BuoyTrawlLineLayer/index.js @@ -0,0 +1,73 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { lineString } from '@turf/turf'; + +import { getMapSubjectFeatureCollectionWithVirtualPositioning } from '../selectors/subjects'; +import useMapLayers from '../hooks/useMapLayers'; +import useMapSources from '../hooks/useMapSources'; + +const lineLayout = { + 'line-join': 'round', + 'line-cap': 'round', +}; + +const linePaint = { + 'line-color': 'black', + 'line-opacity': 0.7, + 'line-gap-width': 1, + 'line-width': 2, +}; + +const TRAWL_SOURCE_ID = 'trawl-lines-source'; +const TRAWL_LAYER_ID = 'trawl-lines-layer'; + +const subjectIsBuoyLineEligible = (subjectFeature = {}, _index, allSubjects = []) => { + const subject = subjectFeature.properties; + + const isBuoy = subject.subject_subtype === 'ropeless_buoy_device'; + if (!isBuoy) return false; + + const devices = subject.additional?.devices ?? []; + const isLine = devices.length > 1; + + if (!isLine) return false; + + const lineContainsValidSubjects = devices.every(({ device_id }) => allSubjects.find(({ properties }) => properties.name === device_id)); + + return lineContainsValidSubjects; +}; + +const createTrawlLineGeoJSON = (buoySubjectFeatures) => { + return buoySubjectFeatures.reduce((accumulator, { properties }) => { + const coordinates = // build the coordinates from each subject's location + properties.additional.devices.map(({ device_id }) => + buoySubjectFeatures.find(({ properties }) => + properties.name === device_id)?.geometry?.coordinates ?? [] + ); + + accumulator.features.push(lineString(coordinates)); + return accumulator; + + }, { type: 'FeatureCollection', features: [] }); +}; + +const BuoyLineLayer = (_props) => { + const mapSubjects = useSelector(getMapSubjectFeatureCollectionWithVirtualPositioning); + + const buoySubjects = mapSubjects.features.filter(subjectIsBuoyLineEligible); + const trawlLineGeoJSON = createTrawlLineGeoJSON(buoySubjects); + + useMapSources([{ id: TRAWL_SOURCE_ID, data: trawlLineGeoJSON }]); + useMapLayers([{ + id: TRAWL_LAYER_ID, + type: 'line', + sourceId: TRAWL_SOURCE_ID, + layout: lineLayout, + paint: linePaint, + }]); + + return null; + +}; + +export default BuoyLineLayer; diff --git a/src/BuoyTrawlLineLayer/index.test.js b/src/BuoyTrawlLineLayer/index.test.js new file mode 100644 index 000000000..b489219c4 --- /dev/null +++ b/src/BuoyTrawlLineLayer/index.test.js @@ -0,0 +1,127 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { useSelector } from 'react-redux'; +import BuoyLineLayer from './'; +import useMapLayers from '../hooks/useMapLayers'; +import useMapSources from '../hooks/useMapSources'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../hooks/useMapLayers', () => jest.fn()); +jest.mock('../hooks/useMapSources', () => jest.fn()); + +const createMockSubject = (name, subtype, devices, coordinates) => ({ + type: 'Feature', + properties: { + name, + subject_subtype: subtype, + additional: { devices }, + }, + geometry: { + type: 'Point', + coordinates, + }, +}); + +describe('BuoyLineLayer', () => { + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should not render any lines when no buoy subjects are present', () => { + useSelector.mockReturnValue({ + features: [], + }); + + render(); + + expect(useMapSources).toHaveBeenCalledWith([ + { + id: 'trawl-lines-source', + data: { type: 'FeatureCollection', features: [] }, + }, + ]); + expect(useMapLayers).toHaveBeenCalled(); + }); + + test('should filter out non-buoy subjects', () => { + const mockFeatures = [ + createMockSubject('buoy1', 'other_type', [], [10, 20]), + createMockSubject('buoy2', 'ropeless_buoy_device', [{ device_id: 'device1' }], [30, 40]), + ]; + + useSelector.mockReturnValue({ + features: mockFeatures, + }); + + render(); + + expect(useMapSources).toHaveBeenCalledWith([ + { + id: 'trawl-lines-source', + data: { type: 'FeatureCollection', features: [] }, + }, + ]); + }); + + test('should create trawl lines for valid buoy subjects', () => { + const device1 = createMockSubject('device1', 'ropeless_buoy_device', [{ device_id: 'device1' }, { device_id: 'device2' }], [10, 20]); + const device2 = createMockSubject('device2', 'ropeless_buoy_device', [{ device_id: 'device1' }, { device_id: 'device2' }], [30, 40]); + + const mockFeatures = [ + device1, + device2, + ]; + + useSelector.mockReturnValue({ + features: mockFeatures, + }); + + render(); + + expect(useMapSources).toHaveBeenCalledWith([ + { + id: 'trawl-lines-source', + data: { + type: 'FeatureCollection', + features: expect.arrayContaining([ + { + type: 'Feature', + properties: {}, + geometry: { + type: 'LineString', + coordinates: [[10, 20], [30, 40]] + } + } + ]) + } + } + ]); + }); + + test('should handle invalid device references', () => { + const mockFeatures = [ + createMockSubject('device1', 'ropeless_buoy_device', [], [10, 20]), + createMockSubject('buoy1', 'ropeless_buoy_device', [ + { device_id: 'device1' }, + { device_id: 'device2' }, // invalid reference + ], [50, 60]), + ]; + + useSelector.mockReturnValue({ + features: mockFeatures, + }); + + render(); + + expect(useMapSources).toHaveBeenCalledWith([ + { + id: 'trawl-lines-source', + data: { type: 'FeatureCollection', features: [] }, + }, + ]); + }); +}); \ No newline at end of file diff --git a/src/Map/index.js b/src/Map/index.js index 6892cba83..841831064 100644 --- a/src/Map/index.js +++ b/src/Map/index.js @@ -45,6 +45,7 @@ import DelayedUnmount from '../DelayedUnmount'; import EarthRangerMap, { withMap } from '../EarthRangerMap'; import EventsLayer from '../EventsLayer'; import SubjectsLayer from '../SubjectsLayer'; +import BuoyTrawlLineLayer from '../BuoyTrawlLineLayer'; import StaticSensorsLayer from '../StaticSensorsLayer'; import TracksLayer from '../TracksLayer'; import PatrolStartStopLayer from '../PatrolStartStopLayer'; @@ -150,7 +151,7 @@ const Map = ({ const timeSliderActive = timeSliderState.active; const isDrawingEventGeometry = mapLocationSelection.isPickingLocation - && mapLocationSelection.mode === MAP_LOCATION_SELECTION_MODES.EVENT_GEOMETRY; + && mapLocationSelection.mode === MAP_LOCATION_SELECTION_MODES.EVENT_GEOMETRY; const isSelectingEventLocation = mapLocationSelection.isPickingLocation && mapLocationSelection.event @@ -216,7 +217,7 @@ const Map = ({ .then((latestMapSubjects) => timeSliderActive ? fetchMapSubjectTracksForTimeslider(latestMapSubjects) : Promise.resolve(latestMapSubjects)) - .catch(() => {}); + .catch(() => { }); }, [ dispatch, @@ -363,8 +364,8 @@ const Map = ({ ); }, [dispatch]); - const onCloseReportHeatmap = useCallback(() => { - dispatch ( + const onCloseReportHeatmap = useCallback(() => { + dispatch( setReportHeatmapVisibility(false) ); }, [dispatch]); @@ -547,7 +548,7 @@ const Map = ({ hidePopup(popup.id); } } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [map, timeSliderState.virtualDate]); useEffect(() => { @@ -612,10 +613,10 @@ const Map = ({ + mapImages={mapImages} + onEventClick={onSelectEvent} + bounceEventIDs={bounceEventIDs} + /> @@ -629,7 +630,7 @@ const Map = ({
- +
@@ -657,6 +658,8 @@ const Map = ({ {subjectTracksVisible && } {patrolTracksVisible && } + + {patrolTracksVisible && }