From 5612e0ff1fa8c9b00140837d3542991fa6048d85 Mon Sep 17 00:00:00 2001 From: Michael Maurizi Date: Thu, 21 Feb 2019 15:40:28 -0500 Subject: [PATCH 1/7] Allow creating projects for locations that don't have an ingested City Rather than using the city endpoint for the autocomplete in the new project form and the lab header, we now use a Google geocoder, and make a separate AJAX call to the API to determine if that lat/lon has data. This will allow us to support a broader number of locations than we do currently. Closes #333 Closes #266 Closes #251 --- package.json | 2 + src/app/app.module.ts | 13 ++ src/app/charts/chart.component.ts | 2 +- .../lab/components/city-dropdown.component.ts | 171 +++++++++++++++--- src/app/lab/lab.component.html | 2 +- src/app/models/map-cell.model.ts | 19 ++ .../project/add-edit-project.component.html | 5 +- src/app/services/map-cell.service.ts | 22 +++ src/assets/sass/components/_popover.scss | 9 + src/assets/sass/components/_project-form.scss | 10 +- src/environments/environment.prod.ts | 1 + src/environments/environment.ts | 1 + src/tsconfig.app.json | 2 +- src/tsconfig.spec.json | 1 + tsconfig.json | 1 + yarn.lock | 20 +- 16 files changed, 230 insertions(+), 51 deletions(-) create mode 100644 src/app/models/map-cell.model.ts create mode 100644 src/app/services/map-cell.service.ts diff --git a/package.json b/package.json index a6ac19bb..df9cb86e 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ }, "private": true, "dependencies": { + "@agm/core": "1.0.0-beta.2", "@angular/animations": "^4.4.7", "@angular/common": "^4.4.7", "@angular/compiler": "^4.4.7", @@ -55,6 +56,7 @@ "@angular/cli": "1.3.2", "@angular/compiler-cli": "^4.4.7", "@angular/language-service": "^4.4.7", + "@types/googlemaps": "^3.30.16", "@types/jasmine": "2.8.9", "@types/jquery": "^3.3.29", "@types/node": "~11.9.3", diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 4de26512..be9f3671 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -6,9 +6,11 @@ import { FormsModule } from '@angular/forms'; import { HttpModule, XHRBackend, RequestOptions } from '@angular/http'; // 3rd party modules +import { AgmCoreModule } from '@agm/core'; import { BsDropdownModule, CollapseModule, + PopoverModule, TooltipModule } from 'ngx-bootstrap'; import { ClipboardModule } from 'ngx-clipboard'; import { NouisliderModule } from 'ng2-nouislider'; @@ -44,9 +46,11 @@ import { AuthGuard } from './auth/auth.guard'; import { DataExportService } from './services/data-export.service'; import { ImageExportService } from './services/image-export.service'; +import { MapCellService } from './services/map-cell.service'; import { ProjectService } from './services/project.service'; import { apiHost } from './constants'; +import { environment } from '../environments/environment'; // Custom app providers const locationStrategyProvider = { @@ -54,6 +58,12 @@ const locationStrategyProvider = { useClass: HashLocationStrategy }; +// Google maps config +const AGM_CONFIG = { + apiKey: environment.googleMapsApiKey, + libraries: ['places'] +}; + @NgModule({ bootstrap: [ AppComponent ], declarations: [ @@ -75,9 +85,11 @@ const locationStrategyProvider = { FormsModule, HttpModule, routing, + AgmCoreModule.forRoot(AGM_CONFIG), BsDropdownModule.forRoot(), CollapseModule.forRoot(), TooltipModule.forRoot(), + PopoverModule.forRoot(), Ng2AutoCompleteModule, ClipboardModule, NouisliderModule, @@ -95,6 +107,7 @@ const locationStrategyProvider = { AuthGuard, DataExportService, ImageExportService, + MapCellService, ProjectService ] }) diff --git a/src/app/charts/chart.component.ts b/src/app/charts/chart.component.ts index 832d53d7..79f2c1e8 100644 --- a/src/app/charts/chart.component.ts +++ b/src/app/charts/chart.component.ts @@ -121,7 +121,7 @@ export class ChartComponent implements OnChanges, OnDestroy, AfterViewInit { ngOnChanges($event) { // happens if different chart selected - if (!this.scenario || !this.city || !this.models || !this.dataset) { return; } + if (!this.scenario || !this.city || !this.city.properties || !this.models || !this.dataset) { return; } this.updateChart($event); } diff --git a/src/app/lab/components/city-dropdown.component.ts b/src/app/lab/components/city-dropdown.component.ts index 0ca9f663..ea8e7715 100644 --- a/src/app/lab/components/city-dropdown.component.ts +++ b/src/app/lab/components/city-dropdown.component.ts @@ -1,8 +1,15 @@ -import { Component, Input } from '@angular/core'; +import { Component, ElementRef, EventEmitter, Input, NgZone, OnInit, ViewChild, forwardRef } from '@angular/core'; +import { ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR, NG_VALIDATORS, Validator, ValidationErrors } from '@angular/forms'; -import { ProjectData } from '../../models/project-data.model'; +import { MapsAPILoader } from '@agm/core'; +import { City } from 'climate-change-components'; +import { Point } from 'geojson'; +import { Observable } from 'rxjs/Observable'; +import { MapCell } from '../../models/map-cell.model'; +import { MapCellService } from '../../services/map-cell.service'; import { apiHost } from '../../constants'; +import { environment } from '../../../environments/environment'; /* City Dropdown Component @@ -12,47 +19,151 @@ import { apiHost } from '../../constants'; */ - @Component({ selector: 'ccl-city-dropdown', template: `` + `, + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CityDropdownComponent), + multi: true, + }, { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CityDropdownComponent), + multi: true, + }] }) -export class CityDropdownComponent { +export class CityDropdownComponent implements OnInit, ControlValueAccessor, Validator { + + @ViewChild('input') input: ElementRef; - public apiCities: string = apiHost + '/api/city/?search=:keyword'; + public errors: ValidationErrors = null; - @Input() projectData: ProjectData; - @Input() showIcon = true; + private autocomplete: google.maps.places.Autocomplete; + private onChange = (_: any) => { }; - constructor() {} + constructor(private el: ElementRef, private mapsApiLoader: MapsAPILoader, + private zone: NgZone, private mapCellService: MapCellService) {} - // custom formatter to display list of options as City, State - public cityListFormatter(data: any): string { - let html = ''; - html += data.properties.name ? - `${data.properties.name}, ${data.properties.admin}` : data; - return html; + ngOnInit() { + try { + this.setupAutocomplete(); + } catch (error) { + this.mapsApiLoader.load().then(() => this.setupAutocomplete()); + } } - // custom formatter to display string for selected city as City, State - public cityValueFormatter(data: any): string { - let displayValue = ''; - if (data && data.properties) { - displayValue += data.properties.name + ', ' + data.properties.admin; + writeValue(city: any) { + if (city && city.properties) { + this.input.nativeElement.value = `${city.properties.name}, ${city.properties.admin}`; } - return displayValue; + } + + registerOnChange(fn: any) { + this.onChange = fn; + } + + // Required by interface, not used + registerOnTouched(fn: any) {} + + validate(c: FormControl): ValidationErrors { + return this.errors; + } + + private setupAutocomplete() { + const options = { + types: ['(cities)'] + }; + this.autocomplete = new google.maps.places.Autocomplete(this.input.nativeElement, options); + this.autocomplete.addListener('place_changed', () => this.onPlaceChanged()); + + const forceAutocompleteOff = () => { + // Chrome ignores 'autocomplete="off"' but will turn off autocomplete for + // invalid options. Google Places sets 'autocomplete="off"' regardless of + // what was set on the before, so we need to override that in JS + this.input.nativeElement.autocomplete = 'forced-false'; + this.input.nativeElement.removeEventListener('focus', forceAutocompleteOff); + }; + this.input.nativeElement.addEventListener('focus', forceAutocompleteOff); + } + + private onPlaceChanged() { + const place = this.autocomplete.getPlace(); + const point = { + type: 'Point', + coordinates: [ + place.geometry.location.lng(), + place.geometry.location.lat(), + ] + } as Point; + + // This event is handled outside of the Angular change detection cycle so we + // need to emit any changes inside a zone.run handler to trigger a detection cycle + this.zone.run(() => { + // Unset city while API call is in progress to let parent component + // disable creation button + this.onChange({} as City); + }); + + this.mapCellService.nearest(point, environment.distance) + .catch((err: Response) => { + return Observable.of([]); + }) + .subscribe((cells: MapCell[]) => { + let city; + if (cells.length > 0) { + this.errors = null; + const datasets = new Set(); + for (const cell of cells) { + for (const dataset of cell.properties.datasets) { + datasets.add(dataset); + } + } + city = { + type: 'Feature', + geometry: point, + properties: { + name: place.name, + admin: this.getAdminFromAddress(place), + datasets: [...datasets], + region: undefined + } + } as City; + } else { + this.errors = { missing: true }; + city = {} as City; + } + + this.zone.run(() => { + this.onChange(city); + }); + }); + } + + private getAdminFromAddress(address: google.maps.places.PlaceResult): string { + let admin = ''; + address.address_components.forEach(component => { + if (component.types.includes('administrative_area_level_1')) { + admin = component.short_name; + } + }); + return admin; } } diff --git a/src/app/lab/lab.component.html b/src/app/lab/lab.component.html index 3d73b340..89999122 100755 --- a/src/app/lab/lab.component.html +++ b/src/app/lab/lab.component.html @@ -15,7 +15,7 @@
- +
diff --git a/src/app/models/map-cell.model.ts b/src/app/models/map-cell.model.ts new file mode 100644 index 00000000..6a84cffd --- /dev/null +++ b/src/app/models/map-cell.model.ts @@ -0,0 +1,19 @@ +import { Point } from 'geojson'; + +export interface ProximityProperties { + ocean: boolean; +} + +/* tslint:disable:variable-name */ +export interface MapCellProperties { + datasets: string[]; + distance_meters: number; + proximity: ProximityProperties; +} +/* tslint:enable:variable-name */ + +export interface MapCell { + type: string; + geometry: Point; + properties: MapCellProperties; +} diff --git a/src/app/project/add-edit-project.component.html b/src/app/project/add-edit-project.component.html index 494d598c..6f2e5b39 100644 --- a/src/app/project/add-edit-project.component.html +++ b/src/app/project/add-edit-project.component.html @@ -14,6 +14,7 @@

Edit project

class="form-control" id="project.name" name="project.name" + required [(ngModel)]="model.project.project_data.name">
- +
@@ -53,7 +54,7 @@

Edit project