diff --git a/web/src/app/pages/create-survey/survey-loi/survey-loi.component.spec.ts b/web/src/app/pages/create-survey/survey-loi/survey-loi.component.spec.ts index 70544bade..dd8b96889 100644 --- a/web/src/app/pages/create-survey/survey-loi/survey-loi.component.spec.ts +++ b/web/src/app/pages/create-survey/survey-loi/survey-loi.component.spec.ts @@ -33,6 +33,7 @@ import {LocationOfInterest} from 'app/models/loi.model'; import {Survey} from 'app/models/survey.model'; import {AuthService} from 'app/services/auth/auth.service'; import {LocationOfInterestService} from 'app/services/loi/loi.service'; +import {NavigationService} from 'app/services/navigation/navigation.service'; import {SurveyService} from 'app/services/survey/survey.service'; import {SurveyLoiComponent} from './survey-loi.component'; @@ -42,6 +43,7 @@ describe('SurveyLoiComponent', () => { let component: SurveyLoiComponent; let loiServiceSpy: jasmine.SpyObj; + let navigationServiceSpy: jasmine.SpyObj; let surveyServiceSpy: jasmine.SpyObj; const mockLois$ = new BehaviorSubject>( @@ -60,6 +62,11 @@ describe('SurveyLoiComponent', () => { ['getLocationsOfInterest$', 'getLoisWithLabels$'] ); + navigationServiceSpy = jasmine.createSpyObj( + 'NavigationService', + ['getLocationOfInterestId$', 'getSubmissionId$'] + ); + surveyServiceSpy = jasmine.createSpyObj('SurveyService', [ 'getActiveSurvey', 'getActiveSurvey$', @@ -67,6 +74,11 @@ describe('SurveyLoiComponent', () => { ]); loiServiceSpy.getLoisWithLabels$.and.returnValue(mockLois$); + + navigationServiceSpy.getSubmissionId$.and.returnValue( + of(null) + ); + surveyServiceSpy.canManageSurvey.and.returnValue(true); surveyServiceSpy.getActiveSurvey.and.returnValue(mockSurvey); surveyServiceSpy.getActiveSurvey$.and.returnValue(mockSurvey$); @@ -79,6 +91,7 @@ describe('SurveyLoiComponent', () => { {provide: AngularFireAuth, useValue: {}}, {provide: AuthService, useValue: {}}, {provide: LocationOfInterestService, useValue: loiServiceSpy}, + {provide: NavigationService, useValue: navigationServiceSpy}, {provide: SurveyService, useValue: surveyServiceSpy}, {provide: MatDialog, useValue: {}}, ], diff --git a/web/src/app/pages/main-page-container/main-page/map/map.component.spec.ts b/web/src/app/pages/main-page-container/main-page/map/map.component.spec.ts index 48cd6777f..24bf8819e 100644 --- a/web/src/app/pages/main-page-container/main-page/map/map.component.spec.ts +++ b/web/src/app/pages/main-page-container/main-page/map/map.component.spec.ts @@ -21,6 +21,8 @@ import { tick, waitForAsync, } from '@angular/core/testing'; +import {AngularFireAuth} from '@angular/fire/compat/auth'; +import {AngularFirestore} from '@angular/fire/compat/firestore'; import {GoogleMapsModule} from '@angular/google-maps'; import {List, Map} from 'immutable'; import {BehaviorSubject, of} from 'rxjs'; @@ -33,14 +35,18 @@ import { GenericLocationOfInterest, LocationOfInterest, } from 'app/models/loi.model'; +import {Submission} from 'app/models/submission/submission.model'; import {Survey} from 'app/models/survey.model'; +import {AuthService} from 'app/services/auth/auth.service'; import { DrawingToolsService, EditMode, } from 'app/services/drawing-tools/drawing-tools.service'; import {GroundPinService} from 'app/services/ground-pin/ground-pin.service'; +import {LoadingState} from 'app/services/loading-state.model'; import {LocationOfInterestService} from 'app/services/loi/loi.service'; import {NavigationService} from 'app/services/navigation/navigation.service'; +import {SubmissionService} from 'app/services/submission/submission.service'; import {SurveyService} from 'app/services/survey/survey.service'; import {polygonShellCoordsToPolygon} from 'testing/helpers'; @@ -54,6 +60,7 @@ describe('MapComponent', () => { let loiServiceSpy: jasmine.SpyObj; let mockLocationOfInterestId$: BehaviorSubject; let navigationServiceSpy: jasmine.SpyObj; + let submissionServiceSpy: jasmine.SpyObj; let mockEditMode$: BehaviorSubject; let drawingToolsServiceSpy: jasmine.SpyObj; @@ -174,6 +181,7 @@ describe('MapComponent', () => { 'getSubmissionId$', 'selectLocationOfInterest', 'clearLocationOfInterestId', + 'showSubmissionDetailWithHighlightedTask', ] ); mockLocationOfInterestId$ = new BehaviorSubject(null); @@ -184,6 +192,14 @@ describe('MapComponent', () => { of(null) ); + submissionServiceSpy = jasmine.createSpyObj( + 'SubmissionService', + ['getSelectedSubmission$'] + ); + submissionServiceSpy.getSelectedSubmission$.and.returnValue( + new BehaviorSubject(LoadingState.LOADING) + ); + mockEditMode$ = new BehaviorSubject(EditMode.None); drawingToolsServiceSpy = jasmine.createSpyObj( 'DrawingToolsService', @@ -202,7 +218,20 @@ describe('MapComponent', () => { useValue: loiServiceSpy, }, {provide: NavigationService, useValue: navigationServiceSpy}, + {provide: SubmissionService, useValue: submissionServiceSpy}, {provide: DrawingToolsService, useValue: drawingToolsServiceSpy}, + {provide: AuthService, useValue: {}}, + { + provide: AngularFireAuth, + useValue: { + authState: of({ + displayName: null, + isAnonymous: true, + uid: '', + }), + }, + }, + {provide: AngularFirestore, useValue: {}}, ], }).compileComponents(); })); diff --git a/web/src/app/pages/main-page-container/main-page/map/map.component.ts b/web/src/app/pages/main-page-container/main-page/map/map.component.ts index 2622c3b2a..188e53015 100644 --- a/web/src/app/pages/main-page-container/main-page/map/map.component.ts +++ b/web/src/app/pages/main-page-container/main-page/map/map.component.ts @@ -28,6 +28,7 @@ import {Map as ImmutableMap, List} from 'immutable'; import {Observable, Subscription, combineLatest} from 'rxjs'; import {Coordinate} from 'app/models/geometry/coordinate'; +import {Geometry, GeometryType} from 'app/models/geometry/geometry'; import {MultiPolygon} from 'app/models/geometry/multi-polygon'; import {Point} from 'app/models/geometry/point'; import {Polygon} from 'app/models/geometry/polygon'; @@ -35,7 +36,9 @@ import { GenericLocationOfInterest, LocationOfInterest, } from 'app/models/loi.model'; +import {Submission} from 'app/models/submission/submission.model'; import {Survey} from 'app/models/survey.model'; +import {TaskType} from 'app/models/task/task.model'; import { DrawingToolsService, EditMode, @@ -43,6 +46,7 @@ import { import {GroundPinService} from 'app/services/ground-pin/ground-pin.service'; import {LocationOfInterestService} from 'app/services/loi/loi.service'; import {NavigationService} from 'app/services/navigation/navigation.service'; +import {SubmissionService} from 'app/services/submission/submission.service'; import {SurveyService} from 'app/services/survey/survey.service'; // To make ESLint happy: @@ -95,6 +99,10 @@ export class MapComponent implements AfterViewInit, OnDestroy { markerToReposition?: google.maps.Marker; disableMapClicks = false; lastFitSurveyId = ''; + submission: Submission | null = null; + showSubmissionGeometry = false; + + readonly DEFAULT_MARKER_COLOR = 'black'; @ViewChild(GoogleMap) map!: GoogleMap; @@ -106,6 +114,7 @@ export class MapComponent implements AfterViewInit, OnDestroy { private loiService: LocationOfInterestService, private navigationService: NavigationService, private groundPinService: GroundPinService, + private submissionService: SubmissionService, private zone: NgZone, private changeDetectorRef: ChangeDetectorRef ) { @@ -164,10 +173,70 @@ export class MapComponent implements AfterViewInit, OnDestroy { combineLatest([ this.navigationService.getLocationOfInterestId$(), this.navigationService.getSubmissionId$(), - ]).subscribe(() => this.cancelReposition()) + this.submissionService.getSelectedSubmission$(), + ]).subscribe(([, submissionId, submission]) => { + this.showSubmissionGeometry = !!submissionId; + if (submission instanceof Submission) { + this.submission = submission; + this.removeSubmissionResultsOnMap(); + if (this.showSubmissionGeometry) { + this.addSubmissionResultsOnMap(); + } + } + this.cancelReposition(); + }) ); } + /** + * Add submission geometry based task results to existing map + */ + addSubmissionResultsOnMap() { + this.submission?.job?.tasks?.forEach(task => { + // TODO(#1477): Add geometry annotations in map as well + if ( + task.type === TaskType.DRAW_AREA || + task.type === TaskType.DROP_PIN || + task.type === TaskType.CAPTURE_LOCATION + ) { + const taskResult = this.submission?.data.get(task.id); + const geometryType = (taskResult?.value as Geometry)?.geometryType; + if (geometryType === GeometryType.POINT) { + const marker = this.addSubmissionMarkerToMap( + task.id, + taskResult!.value as Point, + this.submission?.job?.color + ); + this.markers.set(task.id, marker); + } else if (geometryType === GeometryType.POLYGON) { + const polygon = this.addSubmissionPolygonToMap( + task.id, + taskResult!.value as Polygon, + this.submission?.job?.color + ); + this.polygons.set(task.id, [polygon]); + } + } + }); + } + + /** + * Remove submission geometry based task results from existing map + */ + removeSubmissionResultsOnMap() { + let idsToRemove = List(); + this.submission?.job?.tasks?.forEach(task => { + if ( + task.type === TaskType.DRAW_AREA || + task.type === TaskType.DROP_PIN || + task.type === TaskType.CAPTURE_LOCATION + ) { + idsToRemove = idsToRemove.push(task.id); + } + }); + this.removeDeletedLocationsOfInterest(idsToRemove); + } + ngOnDestroy() { this.subscription.unsubscribe(); } @@ -266,20 +335,20 @@ export class MapComponent implements AfterViewInit, OnDestroy { if (loi.geometry instanceof Point) { const {id, jobId, geometry} = loi; - const marker = this.addPointOfInterestToMap({ + const marker = this.addLocationOfInterestMarkerToMap( id, jobId, - color, geometry, - }); + color + ); this.markers.set(id, marker); } if (loi.geometry instanceof Polygon) { - const polygon = this.addPolygonToMap( + const polygon = this.addLocationOfInterestPolygonToMap( loi.id, - color, jobName, - loi.geometry + loi.geometry, + color ); this.polygons.set(loi.id, [polygon]); } @@ -287,7 +356,14 @@ export class MapComponent implements AfterViewInit, OnDestroy { const geometry: MultiPolygon = loi.geometry; const polygons: google.maps.Polygon[] = []; for (const polygon of geometry.polygons) { - polygons.push(this.addPolygonToMap(loi.id, color, jobName, polygon)); + polygons.push( + this.addLocationOfInterestPolygonToMap( + loi.id, + jobName, + polygon, + color + ) + ); } this.polygons.set(loi.id, polygons); } @@ -303,18 +379,19 @@ export class MapComponent implements AfterViewInit, OnDestroy { } } - private addPointOfInterestToMap({ - id, - jobId, - geometry, - color, - }: { - id: string; - jobId: string; - geometry: Point; - color: string | undefined; - }): google.maps.Marker { + /** + * Adds new marker to existing map + */ + private addMarkerToMap( + id: string, + geometry: Point, + color: string | undefined + ): google.maps.Marker { const {y: latitude, x: longitude} = geometry.coord; + // Default color on Google Maps marker is red if unspecified + if (color === undefined) { + color = this.DEFAULT_MARKER_COLOR; + } const icon = { url: this.groundPinService.getPinImageSource(color), scaledSize: { @@ -329,26 +406,61 @@ export class MapComponent implements AfterViewInit, OnDestroy { draggable: false, title: id, }; - const marker = new google.maps.Marker(options); - marker.addListener('click', () => this.onMarkerClick(id)); + + return new google.maps.Marker(options); + } + + /** + * Adds new marker that represents a location of interest to existing map + */ + private addLocationOfInterestMarkerToMap( + loiId: string, + jobId: string, + geometry: Point, + color: string | undefined + ): google.maps.Marker { + const marker = this.addMarkerToMap(loiId, geometry, color); + marker.addListener('click', () => + this.onLocationOfInterestMarkerClick(loiId) + ); if (this.shouldEnableDrawingTools) { marker.addListener('dragstart', (event: google.maps.Data.MouseEvent) => this.onMarkerDragStart(event, marker) ); marker.addListener('dragend', (event: google.maps.Data.MouseEvent) => - this.onMarkerDragEnd(event, id, jobId) + this.onMarkerDragEnd(event, loiId, jobId) ); } return marker; } - private onMarkerClick(loiId: string) { + /** + * Adds new marker that represents a geometry based task submission to existing map + */ + private addSubmissionMarkerToMap( + taskId: string, + geometry: Point, + color: string | undefined + ): google.maps.Marker { + const marker = this.addMarkerToMap(taskId, geometry, color); + marker.addListener('click', () => this.onSubmissionGeometryClick(taskId)); + return marker; + } + + private onLocationOfInterestMarkerClick(loiId: string) { if (this.disableMapClicks) { return; } this.navigationService.selectLocationOfInterest(loiId); } + private onSubmissionGeometryClick(taskId: string) { + if (this.disableMapClicks) { + return; + } + this.navigationService.showSubmissionDetailWithHighlightedTask(taskId); + } + private onMarkerDragStart( event: google.maps.Data.MouseEvent, marker: google.maps.Marker @@ -493,11 +605,12 @@ export class MapComponent implements AfterViewInit, OnDestroy { ); } + /** + * Adds new polygon to existing map + */ private addPolygonToMap( - loiId: string, - color: string | undefined, - jobName: string | undefined, - polygonModel: Polygon + polygonModel: Polygon, + color: string | undefined ): google.maps.Polygon { const linearRings = [polygonModel.shell, ...polygonModel.holes]; const paths = linearRings.map(linearRing => @@ -505,7 +618,7 @@ export class MapComponent implements AfterViewInit, OnDestroy { .map(({x, y}: {x: number; y: number}) => new google.maps.LatLng(y, x)) .toJS() ); - const polygon = new google.maps.Polygon({ + return new google.maps.Polygon({ paths: paths, clickable: true, strokeColor: color, @@ -514,15 +627,42 @@ export class MapComponent implements AfterViewInit, OnDestroy { fillOpacity: 0, map: this.map.googleMap, }); + } + + /** + * Adds new polygon that represents a location of interest to existing map + */ + private addLocationOfInterestPolygonToMap( + loiId: string, + jobName: string | undefined, + polygonModel: Polygon, + color: string | undefined + ): google.maps.Polygon { + const polygon = this.addPolygonToMap(polygonModel, color); polygon.set('id', loiId); polygon.set('color', color); polygon.set('jobName', jobName); polygon.addListener('click', (event: google.maps.PolyMouseEvent) => { - this.onPolygonClick(event); + this.onLocationOfInterestPolygonClick(event); }); return polygon; } + /** + * Adds new polygon that represents a geometry based task submission to existing map + */ + private addSubmissionPolygonToMap( + taskId: string, + polygonModel: Polygon, + color: string | undefined + ): google.maps.Polygon { + const polygon = this.addPolygonToMap(polygonModel, color); + polygon.set('id', taskId); + polygon.set('color', color); + polygon.addListener('click', () => this.onSubmissionGeometryClick(taskId)); + return polygon; + } + private getIntersectingPolygons( latLng: google.maps.LatLng ): List { @@ -539,7 +679,7 @@ export class MapComponent implements AfterViewInit, OnDestroy { return ids.map(id => this.loisMap.get(id)!); } - private onPolygonClick(event: google.maps.PolyMouseEvent) { + private onLocationOfInterestPolygonClick(event: google.maps.PolyMouseEvent) { if (this.disableMapClicks) { return; } diff --git a/web/src/app/pages/main-page-container/main-page/secondary-side-panel/submission-panel/submission-panel.component.html b/web/src/app/pages/main-page-container/main-page/secondary-side-panel/submission-panel/submission-panel.component.html index d55555612..54f2b7588 100644 --- a/web/src/app/pages/main-page-container/main-page/secondary-side-panel/submission-panel/submission-panel.component.html +++ b/web/src/app/pages/main-page-container/main-page/secondary-side-panel/submission-panel/submission-panel.component.html @@ -39,22 +39,31 @@ Skipped
-
-
- - {{option.label}} - -
-
- -
- -
-
-
- {{getTaskSubmissionResult(task)!.value.toString()}} + +
+ + {{option.label}} + +
+ +
+ +
+ +
+
+
+ {{i}} +
+
+ {{task.id}} +
+ +
+ {{getTaskSubmissionResult(task)!.value.toString()}} +
diff --git a/web/src/app/pages/main-page-container/main-page/secondary-side-panel/submission-panel/submission-panel.component.scss b/web/src/app/pages/main-page-container/main-page/secondary-side-panel/submission-panel/submission-panel.component.scss index c65ae98a4..0b07e39d4 100644 --- a/web/src/app/pages/main-page-container/main-page/secondary-side-panel/submission-panel/submission-panel.component.scss +++ b/web/src/app/pages/main-page-container/main-page/secondary-side-panel/submission-panel/submission-panel.component.scss @@ -64,4 +64,33 @@ ::ng-deep .mat-mdc-checkbox-disabled label { color: inherit; } +} + +.submission-response-geometry { + display: flex; + align-items: center; + height: 40px; + padding-inline-start: 8px; + margin-top: 8px; +} + +.selected-submission-response-geometry { + background-color: #e8e8e3; +} + +.submission-response-geometry-number { + display: flex; + border-radius: 6px; + width: 24px; + height: 24px; + justify-content: center; + align-items: center; + font-weight: 800; + font-size: 15px; + color: white; +} + +.submission-response-geometry-label { + padding-left: 16px; + font-weight: 500; } \ No newline at end of file diff --git a/web/src/app/pages/main-page-container/main-page/secondary-side-panel/submission-panel/submission-panel.component.ts b/web/src/app/pages/main-page-container/main-page/secondary-side-panel/submission-panel/submission-panel.component.ts index 314242dca..e20e31f82 100644 --- a/web/src/app/pages/main-page-container/main-page/secondary-side-panel/submission-panel/submission-panel.component.ts +++ b/web/src/app/pages/main-page-container/main-page/secondary-side-panel/submission-panel/submission-panel.component.ts @@ -15,7 +15,7 @@ */ import {Component, Input, OnDestroy, OnInit} from '@angular/core'; -import {List, Map} from 'immutable'; +import {List} from 'immutable'; import {Subscription} from 'rxjs'; import {Result} from 'app/models/submission/result.model'; @@ -36,6 +36,7 @@ export class SubmissionPanelComponent implements OnInit, OnDestroy { @Input() submissionId!: string; submission: Submission | null = null; tasks?: List; + selectedTaskId: string | null = null; public taskType = TaskType; @@ -54,6 +55,11 @@ export class SubmissionPanelComponent implements OnInit, OnDestroy { } }) ); + this.subscription.add( + this.navigationService.getTaskId$().subscribe(taskId => { + this.selectedTaskId = taskId; + }) + ); } navigateToSubmissionList() { diff --git a/web/src/app/services/navigation/navigation.service.ts b/web/src/app/services/navigation/navigation.service.ts index 3b0d9626f..a22ef6f09 100644 --- a/web/src/app/services/navigation/navigation.service.ts +++ b/web/src/app/services/navigation/navigation.service.ts @@ -37,6 +37,7 @@ export class NavigationService { private static readonly LOI_ID_FRAGMENT_PARAM = 'f'; private static readonly LOI_JOB_ID_FRAGMENT_PARAM = 'fl'; private static readonly SUBMISSION_ID_FRAGMENT_PARAM = 'o'; + private static readonly TASK_ID_FRAGMENT_PARAM = 't'; static readonly JOB_ID_NEW = 'new'; static readonly SUBMISSION_ID_NEW = 'new'; static readonly SURVEY_ID_NEW = 'new'; @@ -68,6 +69,7 @@ export class NavigationService { private jobId$?: Observable; private loiId$?: Observable; private submissionId$?: Observable; + private taskId$?: Observable; private sideNavMode$?: Observable; constructor(private router: Router) {} @@ -95,6 +97,9 @@ export class NavigationService { this.submissionId$ = fragmentParams$.pipe( map(params => params.get(NavigationService.SUBMISSION_ID_FRAGMENT_PARAM)) ); + this.taskId$ = fragmentParams$.pipe( + map(params => params.get(NavigationService.TASK_ID_FRAGMENT_PARAM)) + ); this.sideNavMode$ = fragmentParams$.pipe( map(params => NavigationService.fragmentParamsToSideNavMode(params)) ); @@ -116,6 +121,10 @@ export class NavigationService { return this.submissionId$!; } + getTaskId$(): Observable { + return this.taskId$!; + } + getSideNavMode$(): Observable { return this.sideNavMode$!; } @@ -179,6 +188,16 @@ export class NavigationService { this.setFragmentParams(new HttpParams({fromObject: newParam})); } + showSubmissionDetailWithHighlightedTask(taskId: string) { + const newParam: {[key: string]: string} = {}; + newParam[NavigationService.LOI_ID_FRAGMENT_PARAM] = + this.getLocationOfInterestId()!; + newParam[NavigationService.SUBMISSION_ID_FRAGMENT_PARAM] = + this.getSubmissionId()!; + newParam[NavigationService.TASK_ID_FRAGMENT_PARAM] = taskId; + this.setFragmentParams(new HttpParams({fromObject: newParam})); + } + clearLocationOfInterestId() { this.setFragmentParams(new HttpParams({fromString: ''})); }