From 524aab20ff64f4226d9669884f5d2ec6af608ccd Mon Sep 17 00:00:00 2001 From: JoshuaVulcan <38018017+JoshuaVulcan@users.noreply.github.com> Date: Tue, 8 Apr 2025 11:12:10 -0700 Subject: [PATCH 1/4] feat:connecting trawl lines between linked buoys --- src/BuoyTrawlLineLayer/index.js | 70 +++++++++++++++ src/BuoyTrawlLineLayer/index.test.js | 127 +++++++++++++++++++++++++++ src/Map/index.js | 23 ++--- 3 files changed, 210 insertions(+), 10 deletions(-) create mode 100644 src/BuoyTrawlLineLayer/index.js create mode 100644 src/BuoyTrawlLineLayer/index.test.js diff --git a/src/BuoyTrawlLineLayer/index.js b/src/BuoyTrawlLineLayer/index.js new file mode 100644 index 000000000..0c59498ef --- /dev/null +++ b/src/BuoyTrawlLineLayer/index.js @@ -0,0 +1,70 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; + +import { getMapSubjectFeatureCollectionWithVirtualPositioning } from '../selectors/subjects'; +import { lineString } from '@turf/turf'; +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 subjectIsBuoyLineEligible = (subjectFeature = {}, _index, allSubjects = []) => { + const subject = subjectFeature.properties; + + const is_buoy = subject.subject_subtype === 'ropeless_buoy_device'; + if (!is_buoy) return false; + + const devices = subject.additional?.devices ?? []; + const is_line = devices.length > 1; + + if (!is_line) return false; + + const line_contains_valid_subjects = devices.every(({ device_id }) => allSubjects.find(({ properties }) => properties.name === device_id)); + + return line_contains_valid_subjects; +}; + +const createTrawlLineGeoJSON = (buoySubjectFeatures) => { + return buoySubjectFeatures.reduce((accumulator, { properties }) => { + const coordinates = + 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-lines-source', data: trawlLineGeoJSON }]); + useMapLayers([{ + id: 'trawl-lines-layer', + type: 'line', + sourceId: 'trawl-lines-source', + 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 && } Date: Tue, 8 Apr 2025 11:47:28 -0700 Subject: [PATCH 2/4] fix:PR feedback for naming convention and constants --- src/BuoyTrawlLineLayer/index.js | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/BuoyTrawlLineLayer/index.js b/src/BuoyTrawlLineLayer/index.js index 0c59498ef..f86e10237 100644 --- a/src/BuoyTrawlLineLayer/index.js +++ b/src/BuoyTrawlLineLayer/index.js @@ -18,20 +18,23 @@ const linePaint = { '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 is_buoy = subject.subject_subtype === 'ropeless_buoy_device'; - if (!is_buoy) return false; + const isBuoy = subject.subject_subtype === 'ropeless_buoy_device'; + if (!isBuoy) return false; const devices = subject.additional?.devices ?? []; - const is_line = devices.length > 1; + const isLine = devices.length > 1; - if (!is_line) return false; + if (!isLine) return false; - const line_contains_valid_subjects = devices.every(({ device_id }) => allSubjects.find(({ properties }) => properties.name === device_id)); + const lineContainsValidSubjects = devices.every(({ device_id }) => allSubjects.find(({ properties }) => properties.name === device_id)); - return line_contains_valid_subjects; + return lineContainsValidSubjects; }; const createTrawlLineGeoJSON = (buoySubjectFeatures) => { @@ -54,11 +57,11 @@ const BuoyLineLayer = (_props) => { const buoySubjects = mapSubjects.features.filter(subjectIsBuoyLineEligible); const trawlLineGeoJSON = createTrawlLineGeoJSON(buoySubjects); - useMapSources([{ id: 'trawl-lines-source', data: trawlLineGeoJSON }]); + useMapSources([{ id: TRAWL_SOURCE_ID, data: trawlLineGeoJSON }]); useMapLayers([{ - id: 'trawl-lines-layer', + id: TRAWL_LAYER_ID, type: 'line', - sourceId: 'trawl-lines-source', + sourceId: TRAWL_SOURCE_ID, layout: lineLayout, paint: linePaint, }]); From 5f8516a382670f655133d95fb47f86c0e2de812f Mon Sep 17 00:00:00 2001 From: JoshuaVulcan <38018017+JoshuaVulcan@users.noreply.github.com> Date: Tue, 8 Apr 2025 11:48:38 -0700 Subject: [PATCH 3/4] fix:PR feedback for import block --- src/BuoyTrawlLineLayer/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BuoyTrawlLineLayer/index.js b/src/BuoyTrawlLineLayer/index.js index f86e10237..3db64c0cd 100644 --- a/src/BuoyTrawlLineLayer/index.js +++ b/src/BuoyTrawlLineLayer/index.js @@ -1,8 +1,8 @@ import React from 'react'; import { useSelector } from 'react-redux'; +import { lineString } from '@turf/turf'; import { getMapSubjectFeatureCollectionWithVirtualPositioning } from '../selectors/subjects'; -import { lineString } from '@turf/turf'; import useMapLayers from '../hooks/useMapLayers'; import useMapSources from '../hooks/useMapSources'; From c95d9f25ac8451009c62cf4e6557caa4c4557150 Mon Sep 17 00:00:00 2001 From: JoshuaVulcan <38018017+JoshuaVulcan@users.noreply.github.com> Date: Tue, 8 Apr 2025 11:55:43 -0700 Subject: [PATCH 4/4] fix:descriptive comment as per PR suggestion --- src/BuoyTrawlLineLayer/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BuoyTrawlLineLayer/index.js b/src/BuoyTrawlLineLayer/index.js index 3db64c0cd..738c1d72e 100644 --- a/src/BuoyTrawlLineLayer/index.js +++ b/src/BuoyTrawlLineLayer/index.js @@ -39,7 +39,7 @@ const subjectIsBuoyLineEligible = (subjectFeature = {}, _index, allSubjects = [] const createTrawlLineGeoJSON = (buoySubjectFeatures) => { return buoySubjectFeatures.reduce((accumulator, { properties }) => { - const coordinates = + 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 ?? []