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 ?? []