diff --git a/Jenkinsfile b/Jenkinsfile index 8db28937..c38667f2 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -29,10 +29,7 @@ node { stage('test') { wrap([$class: 'AnsiColorBuildWrapper']) { - writeFile file: 'src/app/constants.ts', text: """ - export const defaultCity = {\'id\': 7, \'properties\': {\'name\': \'Philadelphia\', \'admin\': \'PA\'}}; - export const apiHost = \'${env.API_HOST}\'; - export const defaultScenario = \'RCP85\';\n""" + sh "aws s3 cp 's3://${env.CC_SETTINGS_BUCKET}/angular/constants.ts' 'src/app/constants.ts'" sh './scripts/test --jenkins' diff --git a/README.md b/README.md index 94a4ad9c..4768816f 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,18 @@ Alternatively, you can bring up the vagrant VM that has the dependencies install If your development host machine meets the requirements above, simply: - Clone this repo and run `yarn install` - - `cp example/constants.ts.example src/app/constants.ts` - - Edit `constants.ts` to set the API server name + - Copy the "Climate lab dev config" from LastPass to `src/app/constants.ts` (See below if you do not have access to this resource) - `yarn run serve` The site will then be available at [http://localhost:4200](http://localhost:4200) on your host machine. +#### Setting up constants.ts + +For users who don't have access to our internal configuration file, you can create your own with the following steps: + - Enable the "Maps JavaScript API" and "Places API" from the [Google API library](https://console.cloud.google.com/apis/library), then create a new [Google API key](https://console.cloud.google.com/apis/credentials/). You can restrict it to the "Maps JavaScript API" and "Places API" with referrers set to `http://localhost:4200/*` + - `cp example/constants.ts.example src/app/constants.ts` + - Edit `constants.ts` to set the API server name and Google API key + ### Setup via Vagrant VM _Recommended only if you don't have the requirements above installed on your host system and are unable to install. If you do, strongly consider instructions in #Setup instead._ diff --git a/example/constants.ts.example b/example/constants.ts.example index 2433645d..909d9c4e 100644 --- a/example/constants.ts.example +++ b/example/constants.ts.example @@ -4,3 +4,4 @@ */ export const apiHost = 'https://app.climate.azavea.com'; +export const googleApiKey = 'Add API key here'; 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..6f64ad83 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,10 @@ 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 { apiHost, googleApiKey } from './constants'; // Custom app providers const locationStrategyProvider = { @@ -54,6 +57,12 @@ const locationStrategyProvider = { useClass: HashLocationStrategy }; +// Google maps config +const AGM_CONFIG = { + apiKey: googleApiKey, + libraries: ['places'] +}; + @NgModule({ bootstrap: [ AppComponent ], declarations: [ @@ -75,9 +84,11 @@ const locationStrategyProvider = { FormsModule, HttpModule, routing, + AgmCoreModule.forRoot(AGM_CONFIG), BsDropdownModule.forRoot(), CollapseModule.forRoot(), TooltipModule.forRoot(), + PopoverModule.forRoot(), Ng2AutoCompleteModule, ClipboardModule, NouisliderModule, @@ -95,6 +106,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..2dd24a2e 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,152 @@ 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.errors = null; + 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..33a80289 100755 --- a/src/app/lab/lab.component.html +++ b/src/app/lab/lab.component.html @@ -15,7 +15,7 @@
- +
diff --git a/src/app/lab/lab.component.ts b/src/app/lab/lab.component.ts index 42610311..b0c7f6a6 100755 --- a/src/app/lab/lab.component.ts +++ b/src/app/lab/lab.component.ts @@ -12,6 +12,7 @@ import { Subscription } from 'rxjs/Subscription'; import { Chart, + City, ClimateModel, Dataset, Indicator, @@ -35,6 +36,7 @@ export class LabComponent implements OnInit, OnDestroy { public chart: Chart; public indicator: Indicator; private routeParamsSubscription: Subscription; + private lastCity: City; constructor(private projectService: ProjectService, private route: ActivatedRoute, @@ -58,6 +60,7 @@ export class LabComponent implements OnInit, OnDestroy { if (this.project.project_data.charts[0]) { this.indicator = this.project.project_data.charts[0].indicator; } + this.saveLastCity(this.project.project_data.city); }, error => this.router.navigate(['/']) // Reroute if error ); @@ -106,16 +109,24 @@ export class LabComponent implements OnInit, OnDestroy { this.removeChart(); /* Trigger lifecycle to truly destroy the chart component & its children Reset defaults in fresh child components + Restore to last valid city in case the current one is invalid Cleanly evaluate which children to have (e.g. extra params) */ setTimeout(() => { this.indicator = indicator; this.saveExtraParams({}); const chart = new Chart({indicator: indicator, unit: indicator.default_units}); + this.project.project_data.city = this.lastCity; this.project.project_data.charts = [chart]; }) } + public saveLastCity(city: City) { + if (city && city.properties) { + this.lastCity = city; + } + } + public modelsChanged(models: ClimateModel[]) { this.project.project_data.models = models; } 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