diff --git a/web/pretest.sh b/web/pretest.sh index ac786e955..492b71590 100755 --- a/web/pretest.sh +++ b/web/pretest.sh @@ -16,5 +16,5 @@ echo "Running 'pretest.sh' script to download Google Maps API for testing..." rm google-maps-api.js date +"// Download time: %Y-%m-%d %H:%M" >> google-maps-api.js -curl 'https://maps.googleapis.com/maps/api/js?sensor=false&libraries=geometry' >> google-maps-api.js +curl 'https://maps.googleapis.com/maps/api/js?sensor=false&libraries=geometry,marker' >> google-maps-api.js echo "'pretest.sh' finished" diff --git a/web/src/app/app.component.ts b/web/src/app/app.component.ts index 7ff1b34df..f0460ea22 100644 --- a/web/src/app/app.component.ts +++ b/web/src/app/app.component.ts @@ -39,7 +39,7 @@ export class AppComponent { private initGoogleMap(): void { const script = this.doc.createElement('script'); script.type = 'text/javascript'; - script.src = `https://maps.googleapis.com/maps/api/js?key=${environment.googleMapsApiKey}`; + script.src = `https://maps.googleapis.com/maps/api/js?key=${environment.googleMapsApiKey}&libraries=marker`; const head = this.doc.getElementsByTagName('head')[0]; head.appendChild(script); } 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 b0af7b8bb..53ebf8d89 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 @@ -32,15 +32,12 @@ import {MultiPolygon} from 'app/models/geometry/multi-polygon'; import {Point} from 'app/models/geometry/point'; import {Job} from 'app/models/job.model'; import {LocationOfInterest} from 'app/models/loi.model'; -import {Submission} from 'app/models/submission/submission.model'; import {DataSharingType, 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'; @@ -56,6 +53,7 @@ describe('MapComponent', () => { let mockLois$: BehaviorSubject>; let loiServiceSpy: jasmine.SpyObj; let mockLocationOfInterestId$: BehaviorSubject; + let mockTaskId$: BehaviorSubject; let navigationServiceSpy: jasmine.SpyObj; let submissionServiceSpy: jasmine.SpyObj; let mockEditMode$: BehaviorSubject; @@ -179,6 +177,7 @@ describe('MapComponent', () => { 'NavigationService', [ 'getLocationOfInterestId$', + 'getTaskId$', 'getSubmissionId$', 'selectLocationOfInterest', 'clearLocationOfInterestId', @@ -189,6 +188,8 @@ describe('MapComponent', () => { navigationServiceSpy.getLocationOfInterestId$.and.returnValue( mockLocationOfInterestId$ ); + mockTaskId$ = new BehaviorSubject(null); + navigationServiceSpy.getTaskId$.and.returnValue(mockTaskId$); navigationServiceSpy.getSubmissionId$.and.returnValue( of(null) ); @@ -258,12 +259,12 @@ describe('MapComponent', () => { expect(component.markers.size).toEqual(2); const marker1 = component.markers.get(poiId1)!; assertMarkerLatLng(marker1, new google.maps.LatLng(4.56, 1.23)); - assertMarkerIcon(marker1, jobColor1, 30); - expect(marker1.getMap()).toEqual(component.map.googleMap!); + expect(marker1.element.innerHTML).toContain(`fill="${jobColor1}"`); + expect(marker1.map).toEqual(component.map.googleMap!); const marker2 = component.markers.get(poiId2)!; assertMarkerLatLng(marker2, new google.maps.LatLng(45.6, 12.3)); - assertMarkerIcon(marker2, jobColor2, 30); - expect(marker2.getMap()).toEqual(component.map.googleMap!); + expect(marker2.element.innerHTML).toContain(`fill="${jobColor2}"`); + expect(marker2.map).toEqual(component.map.googleMap!); }); it('should render polygons on map - polygon loi', () => { @@ -324,8 +325,8 @@ describe('MapComponent', () => { expect(component.markers.size).toEqual(1); const marker1 = component.markers.get(poiId1)!; assertMarkerLatLng(marker1, new google.maps.LatLng(4.56, 1.23)); - assertMarkerIcon(marker1, jobColor1, 30); - expect(marker1.getMap()).toEqual(component.map.googleMap!); + expect(marker1.element.innerHTML).toContain(`fill="${jobColor1}"`); + expect(marker1.map).toEqual(component.map.googleMap!); })); it('should fit the map when survey changed', fakeAsync(() => { @@ -362,12 +363,12 @@ describe('MapComponent', () => { expect(component.markers.size).toEqual(2); const marker1 = component.markers.get(poiId2)!; assertMarkerLatLng(marker1, new google.maps.LatLng(45.7, 12.3)); - assertMarkerIcon(marker1, jobColor2, 30); - expect(marker1.getMap()).toEqual(component.map.googleMap!); + expect(marker1.element.innerHTML).toContain(`fill="${jobColor2}"`); + expect(marker1.map).toEqual(component.map.googleMap!); const marker2 = component.markers.get(poiId3)!; assertMarkerLatLng(marker2, new google.maps.LatLng(78.9, 78.9)); - assertMarkerIcon(marker2, jobColor2, 30); - expect(marker2.getMap()).toEqual(component.map.googleMap!); + expect(marker2.element.innerHTML).toContain(`fill="${jobColor2}"`); + expect(marker2.map).toEqual(component.map.googleMap!); expect(component.polygons.size).toEqual(1); const [polygon] = component.polygons.get(polygonLoiId1)!; assertPolygonPaths(polygon, [ @@ -393,14 +394,6 @@ describe('MapComponent', () => { ).toHaveBeenCalledOnceWith(poiId1); }); - it('should enlarge the marker when loi is selected', fakeAsync(() => { - mockLocationOfInterestId$.next(poiId1); - tick(); - - const marker1 = component.markers.get(poiId1)!; - assertMarkerIcon(marker1, jobColor1, 50); - })); - it('should select loi when polygon is clicked', () => { const [polygon] = component.polygons.get(polygonLoiId1)!; google.maps.event.trigger(polygon, 'click', { @@ -435,9 +428,9 @@ describe('MapComponent', () => { it('markers are not draggable by default', () => { const marker1 = component.markers.get(poiId1)!; - expect(marker1.getDraggable()).toBeFalse(); + expect(marker1.gmpDraggable).toBeFalse(); const marker2 = component.markers.get(poiId2)!; - expect(marker2.getDraggable()).toBeFalse(); + expect(marker2.gmpDraggable).toBeFalse(); }); it('should set marker draggable when loi is selected', fakeAsync(() => { @@ -445,7 +438,7 @@ describe('MapComponent', () => { tick(); const marker1 = component.markers.get(poiId1)!; - expect(marker1.getDraggable()).toBeTrue(); + expect(marker1.gmpDraggable).toBeTrue(); })); it('should not set marker draggable when loi is selected and drawing tools turned off', fakeAsync(() => { @@ -454,7 +447,7 @@ describe('MapComponent', () => { tick(); const marker1 = component.markers.get(poiId1)!; - expect(marker1.getDraggable()).toBeFalse(); + expect(marker1.gmpDraggable).toBeFalse(); })); it('reposition dialog is not displayed by default', () => { @@ -662,24 +655,11 @@ describe('MapComponent', () => { })); function assertMarkerLatLng( - marker: google.maps.Marker, + marker: google.maps.marker.AdvancedMarkerElement, latLng: google.maps.LatLng ): void { - expect(marker.getPosition()?.lat()).toEqual(latLng.lat()); - expect(marker.getPosition()?.lng()).toEqual(latLng.lng()); - } - - function assertMarkerIcon( - marker: google.maps.Marker, - iconColor: string, - iconSize: number - ): void { - const icon = marker.getIcon() as google.maps.Icon; - expect(atob(icon.url.slice(GroundPinService.urlPrefix.length))).toContain( - iconColor - ); - expect(icon.scaledSize?.height).toEqual(iconSize); - expect(icon.scaledSize?.width).toEqual(iconSize); + expect(marker.position?.lat).toEqual(latLng.lat()); + expect(marker.position?.lng).toEqual(latLng.lng()); } function assertPolygonPaths( 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 c9d89a872..7c6029e4a 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 @@ -52,7 +52,6 @@ import {SurveyService} from 'app/services/survey/survey.service'; /*global google*/ const normalIconScale = 30; -const enlargedIconScale = 50; const zoomedInLevel = 13; const normalPolygonStrokeWeight = 3; const enlargedPolygonStrokeWeight = 6; @@ -77,11 +76,13 @@ export class MapComponent implements AfterViewInit, OnChanges, OnDestroy { mapTypeControl: true, streetViewControl: false, mapTypeId: google.maps.MapTypeId.HYBRID, + mapId: 'ground-map', // Need to set to use AdvancedMarkerElement }; private selectedLocationOfInterestId: string | null = null; - markers: Map = new Map< + private selectedSubmissionTaskId: string | null = null; + markers: Map = new Map< string, - google.maps.Marker + google.maps.marker.AdvancedMarkerElement >(); polygons: Map = new Map< string, @@ -98,7 +99,7 @@ export class MapComponent implements AfterViewInit, OnChanges, OnDestroy { newLocationOfInterestToReposition?: LocationOfInterest; oldLatLng?: google.maps.LatLng; newLatLng?: google.maps.LatLng; - markerToReposition?: google.maps.Marker; + markerToReposition?: google.maps.marker.AdvancedMarkerElement; disableMapClicks = false; lastFitSurveyId = ''; lastFitJobId = ''; @@ -137,52 +138,29 @@ export class MapComponent implements AfterViewInit, OnChanges, OnDestroy { this.activeSurvey$, this.lois$, this.navigationService.getLocationOfInterestId$(), + this.navigationService.getTaskId$(), this.selectedJob$, - ]).subscribe(([survey, lois, locationOfInterestId, selectedJob]) => { - const loisMap = ImmutableMap( - lois - .filter(loi => - this.showPredefinedLoisOnly ? loi.predefined : true - ) - .filter( - loi => selectedJob === undefined || loi.jobId === selectedJob.id - ) - .map(loi => [loi.id, loi]) - ); - - const loiIdsToRemove = this.loisMap - .filter( - (value, key) => - !( - loisMap.has(key) && - this.isLocationOfInterestEqual(loisMap.get(key)!, value) - ) - ) - .keySeq() - .toList(); - const loisToAdd = loisMap - .filter( - (value, key) => - !( - this.loisMap.has(key) && - this.isLocationOfInterestEqual(this.loisMap.get(key)!, value) - ) - ) - .toList(); - this.loisMap = loisMap; - - this.removeDeletedLocationsOfInterest(loiIdsToRemove); - this.addNewLocationsOfInterest(survey, loisToAdd); - if ( - this.lastFitSurveyId !== survey.id || - this.lastFitJobId !== (selectedJob?.id ?? '') - ) { - this.fitMapToLocationsOfInterest(List(this.loisMap.values())); - this.lastFitSurveyId = survey.id; - this.lastFitJobId = selectedJob?.id ?? ''; + ]).subscribe( + ([survey, lois, locationOfInterestId, taskId, selectedJob]) => { + const loisMap = this.getLoiMap(lois, selectedJob); + const loiIdsToRemove = this.getLoiIdsToRemove(loisMap); + const loisToAdd = this.getLoiIdsToAdd(loisMap); + this.loisMap = loisMap; + + this.removeDeletedLocationsOfInterest(loiIdsToRemove); + this.addNewLocationsOfInterest(survey, loisToAdd); + if ( + this.lastFitSurveyId !== survey.id || + this.lastFitJobId !== (selectedJob?.id ?? '') + ) { + this.fitMapToLocationsOfInterest(List(this.loisMap.values())); + this.lastFitSurveyId = survey.id; + this.lastFitJobId = selectedJob?.id ?? ''; + } + this.selectLocationOfInterest(locationOfInterestId); + this.selectSubmissionTask(taskId); } - this.selectLocationOfInterest(locationOfInterestId); - }) + ) ); if (this.shouldEnableDrawingTools) { @@ -212,12 +190,68 @@ export class MapComponent implements AfterViewInit, OnChanges, OnDestroy { ); } + /** + * Gets map of LOIs for the selected job + * @param lois List of all locations of interest + * @param selectedJob Currently selected Job + * @returns Map of loi ids to loi objects + */ + private getLoiMap( + lois: List, + selectedJob: Job | undefined + ): ImmutableMap { + return ImmutableMap( + lois + .filter(loi => (this.showPredefinedLoisOnly ? loi.predefined : true)) + .filter( + loi => selectedJob === undefined || loi.jobId === selectedJob.id + ) + .map(loi => [loi.id, loi]) + ); + } + + /** + * Gets list of loi ids to remove from the map + * @param loisMap Map of loi ids to loi objects + * @returns List of loi ids to remove + */ + private getLoiIdsToRemove( + loisMap: ImmutableMap + ): List { + return this.loisMap + .filter( + (value, key) => + !( + loisMap.has(key) && + this.isLocationOfInterestEqual(loisMap.get(key)!, value) + ) + ) + .keySeq() + .toList(); + } + + /** + * Gets list of loi ids to add to the map + * @param loisMap Map of loi ids to loi objects + * @returns List of loi ids to add + */ + private getLoiIdsToAdd(loisMap: ImmutableMap) { + return loisMap + .filter( + (value, key) => + !( + this.loisMap.has(key) && + this.isLocationOfInterestEqual(this.loisMap.get(key)!, value) + ) + ) + .toList(); + } + /** * 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 || @@ -229,7 +263,8 @@ export class MapComponent implements AfterViewInit, OnChanges, OnDestroy { const marker = this.addSubmissionMarkerToMap( task.id, taskResult!.value as Point, - this.submission?.job?.color + this.submission?.job?.color, + task.index.toString() ); this.markers.set(task.id, marker); } else if (geometryType === GeometryType.POLYGON) { @@ -323,7 +358,7 @@ export class MapComponent implements AfterViewInit, OnChanges, OnDestroy { private removeDeletedMarkers(idsToRemove: List) { for (const id of this.markers.keys()) { if (idsToRemove.includes(id)) { - this.markers.get(id)!.setMap(null); + this.markers.get(id)!.map = null; this.markers.delete(id); } } @@ -413,29 +448,33 @@ export class MapComponent implements AfterViewInit, OnChanges, OnDestroy { private addMarkerToMap( id: string, geometry: Point, - color: string | undefined - ): google.maps.Marker { + color: string | undefined, + markerText?: string | undefined + ): google.maps.marker.AdvancedMarkerElement { 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: { - width: normalIconScale, - height: normalIconScale, - }, - } as google.maps.Icon; - const options: google.maps.MarkerOptions = { + + // TODO(#2108): Switch to custom HTML and CSS markers. Having a custom HTML will + // improve text wrapping, allow for a more square shape, and custom styles for + // selected markers (like increasing the scale). + const markerGlyph = new google.maps.marker.PinElement({ + glyph: markerText, + glyphColor: 'white', + background: color, + }); + + const options: google.maps.marker.AdvancedMarkerElementOptions = { map: this.map.googleMap, position: new google.maps.LatLng(latitude, longitude), - icon, - draggable: false, + content: markerGlyph.element, title: id, + gmpClickable: !this.disableMapClicks, }; - return new google.maps.Marker(options); + return new google.maps.marker.AdvancedMarkerElement(options); } /** @@ -446,7 +485,7 @@ export class MapComponent implements AfterViewInit, OnChanges, OnDestroy { jobId: string, geometry: Point, color: string | undefined - ): google.maps.Marker { + ): google.maps.marker.AdvancedMarkerElement { const marker = this.addMarkerToMap(loiId, geometry, color); marker.addListener('click', () => this.onLocationOfInterestMarkerClick(loiId) @@ -468,9 +507,10 @@ export class MapComponent implements AfterViewInit, OnChanges, OnDestroy { private addSubmissionMarkerToMap( taskId: string, geometry: Point, - color: string | undefined - ): google.maps.Marker { - const marker = this.addMarkerToMap(taskId, geometry, color); + color: string | undefined, + markerText: string + ): google.maps.marker.AdvancedMarkerElement { + const marker = this.addMarkerToMap(taskId, geometry, color, markerText); marker.addListener('click', () => this.onSubmissionGeometryClick(taskId)); return marker; } @@ -491,7 +531,7 @@ export class MapComponent implements AfterViewInit, OnChanges, OnDestroy { private onMarkerDragStart( event: google.maps.Data.MouseEvent, - marker: google.maps.Marker + marker: google.maps.marker.AdvancedMarkerElement ) { // TODO: Show confirm dialog and disable other components when entering reposition state. // Currently we are figuring out how should the UI trigger this state. @@ -523,8 +563,14 @@ export class MapComponent implements AfterViewInit, OnChanges, OnDestroy { ImmutableMap() ); } - - private panAndZoom(position: google.maps.LatLng | null | undefined) { + private panAndZoom( + position: + | google.maps.LatLng + | google.maps.LatLngLiteral + | google.maps.LatLngAltitudeLiteral + | null + | undefined + ) { if (!position) { return; } @@ -538,11 +584,11 @@ export class MapComponent implements AfterViewInit, OnChanges, OnDestroy { if (editMode !== EditMode.None) { this.navigationService.clearLocationOfInterestId(); for (const marker of this.markers) { - marker[1].setClickable(false); + marker[1].gmpClickable = false; } } else { for (const marker of this.markers) { - marker[1].setClickable(true); + marker[1].gmpClickable = true; } } this.mapOptions = @@ -567,16 +613,16 @@ export class MapComponent implements AfterViewInit, OnChanges, OnDestroy { this.selectedLocationOfInterestId = locationOfInterestId; } - private setIconSize(marker: google.maps.Marker, size: number) { - const icon = marker.getIcon() as google.maps.Icon; - const newIcon = { - url: icon.url, - scaledSize: { - width: size, - height: size, - }, - } as google.maps.Icon; - marker.setIcon(newIcon); + private selectSubmissionTask(taskId: string | null) { + if (taskId === this.selectedSubmissionTaskId) { + return; + } + this.selectedSubmissionTaskId = taskId; + + // Pan to submission marker on the map if selected + const marker = this.markers.get(taskId!); + if (!marker) return; + this.panAndZoom(marker.position); } private selectMarker(locationOfInterestId: string | null) { @@ -586,11 +632,9 @@ export class MapComponent implements AfterViewInit, OnChanges, OnDestroy { if (!marker) return; - this.setIconSize(marker, enlargedIconScale); + marker.gmpDraggable = this.shouldEnableDrawingTools; - marker.setDraggable(this.shouldEnableDrawingTools); - - this.panAndZoom(marker.getPosition()); + this.panAndZoom(marker.position); } private unselectMarker() { @@ -600,9 +644,7 @@ export class MapComponent implements AfterViewInit, OnChanges, OnDestroy { if (!selectedMarker) return; - this.setIconSize(selectedMarker, normalIconScale); - - selectedMarker.setDraggable(false); + selectedMarker.gmpDraggable = false; } private selectPolygons(locationOfInterestId: string | null) { @@ -726,13 +768,13 @@ export class MapComponent implements AfterViewInit, OnChanges, OnDestroy { } onSaveRepositionClick() { - this.markerToReposition?.setPosition(this.newLatLng!); + this.markerToReposition!.position = this.newLatLng!; this.loiService.updatePoint(this.newLocationOfInterestToReposition!); this.resetReposition(); } onCancelRepositionClick() { - this.markerToReposition?.setPosition(this.oldLatLng!); + this.markerToReposition!.position = this.oldLatLng!; this.resetReposition(); } 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 d2e026497..847746f9b 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 @@ -60,9 +60,9 @@
-
+
- {{i}} + {{i + 1}}
{{task.id}} 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 b694d2ede..c394e7fc2 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 @@ -146,6 +146,10 @@ export class SubmissionPanelComponent implements OnInit, OnDestroy { ).toLocaleTimeString([], {hour: 'numeric', minute: 'numeric'}); } + selectGeometry(task: Task): void { + this.navigationService.showSubmissionDetailWithHighlightedTask(task.id); + } + ngOnDestroy(): void { this.subscription.unsubscribe(); }