diff --git a/application/frontend/package.json b/application/frontend/package.json index 729e9109..873f078b 100644 --- a/application/frontend/package.json +++ b/application/frontend/package.json @@ -60,6 +60,8 @@ "protobufjs": "^7.4.0", "rxjs": "~7.8.2", "split.js": "^1.6.2", + "terra-draw": "^1.31.1", + "terra-draw-google-maps-adapter": "^1.6.0", "tslib": "^2.8.1", "zone.js": "~0.14.10" }, diff --git a/application/frontend/src/app/core/containers/map-wrapper/map-wrapper.component.ts b/application/frontend/src/app/core/containers/map-wrapper/map-wrapper.component.ts index b1cbaec1..79f00720 100644 --- a/application/frontend/src/app/core/containers/map-wrapper/map-wrapper.component.ts +++ b/application/frontend/src/app/core/containers/map-wrapper/map-wrapper.component.ts @@ -19,6 +19,7 @@ import { Component, ElementRef, EventEmitter, + HostBinding, Input, OnDestroy, OnInit, @@ -28,6 +29,11 @@ import { Observable, Subscription } from 'rxjs'; import { tap } from 'rxjs/operators'; import { MapService } from '../../services'; +export interface MapInitializeEvent { + map: google.maps.Map; + element: HTMLElement; +} + @Component({ selector: 'app-map-wrapper', templateUrl: './map-wrapper.component.html', @@ -36,7 +42,8 @@ import { MapService } from '../../services'; }) export class MapWrapperComponent implements OnInit, OnDestroy { @Input() options: google.maps.MapOptions; - @Output() mapInitialize = new EventEmitter(); + @Output() mapInitialize = new EventEmitter(); + @HostBinding('attr.id') readonly mapId = 'google-map-container'; private readonly subscriptions: Subscription[] = []; private previousMapWidth: number; @@ -54,7 +61,7 @@ export class MapWrapperComponent implements OnInit, OnDestroy { private initializeMap(options: google.maps.MapOptions): Observable { return this.mapService.initMap(this.el.nativeElement, options).pipe( tap((map) => { - this.mapInitialize.emit(map); + this.mapInitialize.emit({ map, element: this.el.nativeElement }); // Reset the map bounds when it is toggled on. // bounds_changed fires off whenever the viewable bounds of the map change, even from user interaction, diff --git a/application/frontend/src/app/core/containers/map/map.component.ts b/application/frontend/src/app/core/containers/map/map.component.ts index bf04e1ef..2a98be6c 100644 --- a/application/frontend/src/app/core/containers/map/map.component.ts +++ b/application/frontend/src/app/core/containers/map/map.component.ts @@ -26,7 +26,6 @@ import { combineLatest, Observable, Subscription } from 'rxjs'; import { distinctUntilChanged, take } from 'rxjs/operators'; import { selectMapSelectionToolsVisible, State } from 'src/app/reducers'; import { ActiveFilter, FilterOption } from 'src/app/shared/models'; -import { boundsToTurfPolygon, mapsPolygonToTurfPolygon } from 'src/app/util'; import { MapActions, PreSolveShipmentActions, @@ -47,10 +46,10 @@ import * as fromUI from '../../selectors/ui.selectors'; import { selectPage } from '../../selectors/ui.selectors'; import TravelSimulatorSelectors from '../../selectors/travel-simulator.selectors'; import { - MATERIAL_COLORS, VehicleInfoWindowService, VisitRequestInfoWindowService, MultiselectInfoWindowService, + TerraDrawService, } from '../../services'; import { DepotLayer } from '../../services/depot-layer.service'; import { MapService } from '../../services/map.service'; @@ -60,6 +59,7 @@ import { PreSolveVehicleLayer } from '../../services/pre-solve-vehicle-layer.ser import { PreSolveVisitRequestLayer } from '../../services/pre-solve-visit-request-layer.service'; import { RouteLayer } from '../../services/route-layer.service'; import { MapLayer, MapLayerId } from '../../models/map'; +import { MapInitializeEvent } from '../map-wrapper/map-wrapper.component'; @Component({ selector: 'app-map', @@ -80,8 +80,8 @@ export class MapComponent implements OnInit, OnDestroy { } private readonly subscriptions: Subscription[] = []; - private drawingManager: google.maps.drawing.DrawingManager; private map: google.maps.Map; + private mapElement: HTMLElement; private page: Page; constructor( @@ -96,7 +96,8 @@ export class MapComponent implements OnInit, OnDestroy { private preSolveVisitRequestLayer: PreSolveVisitRequestLayer, public vehicleInfoWindowService: VehicleInfoWindowService, public visitRequestInfoWindowService: VisitRequestInfoWindowService, - public multiselectInfoWindowService: MultiselectInfoWindowService + public multiselectInfoWindowService: MultiselectInfoWindowService, + private terraDrawService: TerraDrawService ) {} ngOnInit(): void { @@ -160,6 +161,7 @@ export class MapComponent implements OnInit, OnDestroy { ngOnDestroy(): void { this.subscriptions.splice(0).forEach((subscription) => subscription.unsubscribe()); + this.terraDrawService.destroy(); } private createFilter(option: FilterOption): ActiveFilter { @@ -195,16 +197,7 @@ export class MapComponent implements OnInit, OnDestroy { } onToggleSelectMapItems(mode: SelectionMode): void { - if (mode === SelectionMode.Off) { - this.drawingManager.setMap(null); - } else { - this.drawingManager.setMap(this.map); - this.drawingManager.setDrawingMode( - mode === SelectionMode.Bbox - ? google.maps.drawing.OverlayType.RECTANGLE - : google.maps.drawing.OverlayType.POLYGON - ); - } + this.terraDrawService.setMode(mode); } onTypeChange(satellite: boolean): void { @@ -215,44 +208,31 @@ export class MapComponent implements OnInit, OnDestroy { this.mapService.zoomToHome(); } - onMapInitialize(map: google.maps.Map): void { - this.map = map; - this.drawingManager = this.createDrawingTools(); + onMapInitialize(event: MapInitializeEvent): void { + this.map = event.map; + this.mapElement = event.element; + this.initializeTerraDraw(); } - createDrawingTools(): google.maps.drawing.DrawingManager { - const drawingManager = new google.maps.drawing.DrawingManager({ - drawingControl: false, - drawingMode: google.maps.drawing.OverlayType.RECTANGLE, - rectangleOptions: { - fillOpacity: 0.0, - strokeColor: MATERIAL_COLORS.Red.hex, - }, - polygonOptions: { - fillOpacity: 0.0, - strokeColor: MATERIAL_COLORS.Red.hex, - }, - }); + private initializeTerraDraw(): void { + if (!this.mapElement) { + console.error('Map element not found for TerraDraw initialization'); + return; + } - google.maps.event.addListener(drawingManager, 'overlaycomplete', (event) => { - let polygon; - if (event.type === 'rectangle') { - const bounds = event.overlay.getBounds(); - polygon = boundsToTurfPolygon(bounds); - } else { - polygon = mapsPolygonToTurfPolygon(event.overlay); - } - event.overlay.setMap(null); + this.terraDrawService.initialize(this.map, this.mapElement); - if (this.page === Page.Shipments) { - this.store.dispatch(MapActions.selectPreSolveShipmentMapItems({ polygon })); - } else if (this.page === Page.Vehicles) { - this.store.dispatch(MapActions.selectPreSolveVehicleMapItems({ polygon })); - } else if (this.page === Page.RoutesChart) { - this.store.dispatch(MapActions.selectPostSolveMapItems({ polygon })); - } - }); - return drawingManager; + this.subscriptions.push( + this.terraDrawService.onFeatureComplete$.subscribe((polygon) => { + if (this.page === Page.Shipments) { + this.store.dispatch(MapActions.selectPreSolveShipmentMapItems({ polygon })); + } else if (this.page === Page.Vehicles) { + this.store.dispatch(MapActions.selectPreSolveVehicleMapItems({ polygon })); + } else if (this.page === Page.RoutesChart) { + this.store.dispatch(MapActions.selectPostSolveMapItems({ polygon })); + } + }) + ); } isPreSolve(): boolean { diff --git a/application/frontend/src/app/core/services/index.ts b/application/frontend/src/app/core/services/index.ts index bc6d4dee..9f2cfe92 100644 --- a/application/frontend/src/app/core/services/index.ts +++ b/application/frontend/src/app/core/services/index.ts @@ -36,6 +36,7 @@ export * from './pre-solve-visit-request-layer.service'; export * from './routes-chart.service'; export * from './route-layer.service'; export * from './storage-api.service'; +export * from './terra-draw.service'; export * from './upload.service'; export * from './validation.service'; export * from './vehicle-info-window.service'; diff --git a/application/frontend/src/app/core/services/terra-draw.service.spec.ts b/application/frontend/src/app/core/services/terra-draw.service.spec.ts new file mode 100644 index 00000000..282a8f85 --- /dev/null +++ b/application/frontend/src/app/core/services/terra-draw.service.spec.ts @@ -0,0 +1,46 @@ +/* +Copyright 2024 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { TestBed } from '@angular/core/testing'; +import { TerraDrawService } from './terra-draw.service'; + +describe('TerraDrawService', () => { + let service: TerraDrawService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(TerraDrawService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should not be ready initially', () => { + expect(service.isReady).toBeFalse(); + }); + + it('should handle setMode gracefully when not initialized', () => { + // Should not throw when called before initialization + expect(() => service.setMode(1)).not.toThrow(); // SelectionMode.Bbox + expect(() => service.setMode(2)).not.toThrow(); // SelectionMode.Polygon + expect(() => service.setMode(0)).not.toThrow(); // SelectionMode.Off + }); + + it('should handle destroy gracefully when not initialized', () => { + expect(() => service.destroy()).not.toThrow(); + }); +}); diff --git a/application/frontend/src/app/core/services/terra-draw.service.ts b/application/frontend/src/app/core/services/terra-draw.service.ts new file mode 100644 index 00000000..cff997c6 --- /dev/null +++ b/application/frontend/src/app/core/services/terra-draw.service.ts @@ -0,0 +1,159 @@ +/* +Copyright 2024 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Injectable, NgZone } from '@angular/core'; +import { BehaviorSubject, Observable, Subject } from 'rxjs'; +import { filter, take } from 'rxjs/operators'; +import { TerraDraw, TerraDrawRectangleMode, TerraDrawPolygonMode, HexColor } from 'terra-draw'; +import { TerraDrawGoogleMapsAdapter } from 'terra-draw-google-maps-adapter'; +import { Feature, Polygon } from '@turf/helpers'; +import { SelectionMode } from '../models'; +import { MATERIAL_COLORS } from './map-theme.service'; + +/** + * Manages TerraDraw drawing tools for map selection. + */ +@Injectable({ providedIn: 'root' }) +export class TerraDrawService { + private draw: TerraDraw | null = null; + private projectionListener: google.maps.MapsEventListener | null = null; + private readonly ready$ = new BehaviorSubject(false); + private readonly featureComplete$ = new Subject>(); + + readonly onFeatureComplete$: Observable> = this.featureComplete$.asObservable(); + readonly onReady$: Observable = this.ready$.pipe( + filter((ready) => ready), + take(1) + ); + + get isReady(): boolean { + return this.ready$.getValue(); + } + + constructor(private zone: NgZone) {} + + initialize(map: google.maps.Map, mapElement: HTMLElement): void { + if (this.draw) { + this.destroy(); + } + + // Required by TerraDraw Google Maps adapter + const mapContainer = map.getDiv(); + if (!mapContainer.id) { + mapContainer.id = 'terra-draw-map-container'; + } + + // Wait for projection before creating TerraDraw (Google Maps adapter requirement) + if (map.getProjection()) { + this.initializeTerraDraw(map); + } else { + this.projectionListener = map.addListener('projection_changed', () => { + this.projectionListener?.remove(); + this.projectionListener = null; + this.initializeTerraDraw(map); + }); + } + } + + private initializeTerraDraw(map: google.maps.Map): void { + const strokeColor = MATERIAL_COLORS.Red.hex as HexColor; + + const rectangleMode = new TerraDrawRectangleMode({ + styles: { + fillColor: '#ffffff' as HexColor, + fillOpacity: 0, + outlineColor: strokeColor, + outlineWidth: 2, + }, + }); + + const polygonMode = new TerraDrawPolygonMode({ + styles: { + fillColor: '#ffffff' as HexColor, + fillOpacity: 0, + outlineColor: strokeColor, + outlineWidth: 2, + closingPointColor: strokeColor, + closingPointWidth: 6, + closingPointOutlineColor: '#ffffff' as HexColor, + closingPointOutlineWidth: 2, + }, + }); + + // Create TerraDraw instance with Google Maps adapter + this.draw = new TerraDraw({ + adapter: new TerraDrawGoogleMapsAdapter({ + lib: google.maps, + map, + coordinatePrecision: 9, + }), + modes: [rectangleMode, polygonMode], + }); + + this.draw.start(); + + this.draw.on('ready', () => { + this.zone.run(() => { + this.ready$.next(true); + }); + }); + + this.draw.on('finish', (id: string, context: { action: string; mode: string }) => { + if (context.action === 'draw') { + const snapshot = this.draw.getSnapshot(); + const feature = snapshot.find((f) => f.id === id); + + if (feature && feature.geometry.type === 'Polygon') { + this.zone.run(() => { + this.featureComplete$.next(feature as Feature); + this.draw.removeFeatures([id]); + }); + } + } + }); + } + + setMode(mode: SelectionMode): void { + if (!this.draw || !this.isReady) { + return; + } + + switch (mode) { + case SelectionMode.Bbox: + this.draw.setMode('rectangle'); + break; + case SelectionMode.Polygon: + this.draw.setMode('polygon'); + break; + case SelectionMode.Off: + default: + this.draw.setMode('static'); + break; + } + } + + destroy(): void { + if (this.projectionListener) { + this.projectionListener.remove(); + this.projectionListener = null; + } + if (this.draw) { + this.draw.stop(); + this.draw = null; + this.ready$.next(false); + } + } +} diff --git a/application/package-lock.json b/application/package-lock.json index 27dedd68..a17dacb2 100644 --- a/application/package-lock.json +++ b/application/package-lock.json @@ -31,7 +31,7 @@ "compression": "^1.8.1", "cors": "^2.8.5", "express": "^4.22.1", - "multer": "^2.1.1", + "multer": "2.1.1", "pako": "^2.1.0", "pino-http": "^10.4.0" }, @@ -108,6 +108,8 @@ "protobufjs": "^7.4.0", "rxjs": "~7.8.2", "split.js": "^1.6.2", + "terra-draw": "^1.31.1", + "terra-draw-google-maps-adapter": "^1.6.0", "tslib": "^2.8.1", "zone.js": "~0.14.10" }, @@ -6908,6 +6910,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@googlemaps/js-api-loader": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-2.1.1.tgz", + "integrity": "sha512-yUpAwksbHrlZIWD49JmveNSfBG4oAK0AwMknfSaPMnP5N7UT8oFRVCqwjGb1XQovi//7KLbPQKZpbofiLGzpDw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@types/google.maps": "^3.53.1" + } + }, "node_modules/@googlemaps/routeoptimization": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/@googlemaps/routeoptimization/-/routeoptimization-0.6.1.tgz", @@ -12190,7 +12202,6 @@ "version": "3.64.1", "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.64.1.tgz", "integrity": "sha512-nEBoa6iDNipICtxJ5VlrOgPNZQ6ixIg5nuv8iryFj0Z/1NLgxyg3pQCVegPuCzGCyTQwQI/N3uZvLUysqAzaaw==", - "dev": true, "license": "MIT" }, "node_modules/@types/graceful-fs": { @@ -29253,6 +29264,22 @@ "streamx": "^2.12.5" } }, + "node_modules/terra-draw": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/terra-draw/-/terra-draw-1.31.1.tgz", + "integrity": "sha512-cZYpFW8G0dHhkZ1yxOoOuCQ7GZJHdN7Za4yZwd6UgQuj3H7qqFxalH2bBPsGLUAtF5gQLslxhp/7r5bpscQ0IA==", + "license": "MIT" + }, + "node_modules/terra-draw-google-maps-adapter": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/terra-draw-google-maps-adapter/-/terra-draw-google-maps-adapter-1.6.0.tgz", + "integrity": "sha512-YZDXShIsDkbxuIcZUo81jdJnnEeYSJRfy/RiqYVtprhVGOiycN58K2f3ugzYE5GlrlnBjxkvpo+JlaZmtjrRWQ==", + "license": "MIT", + "peerDependencies": { + "@googlemaps/js-api-loader": "^1.14.3 || ^2.0.0", + "terra-draw": "^1.0.0" + } + }, "node_modules/terser": { "version": "5.29.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.29.1.tgz",