Skip to content
This repository has been archived by the owner on Aug 21, 2023. It is now read-only.

Commit

Permalink
Allow creating projects for locations that don't have an ingested City
Browse files Browse the repository at this point in the history
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
  • Loading branch information
maurizi committed Mar 1, 2019
1 parent af4ba2a commit 5612e0f
Show file tree
Hide file tree
Showing 16 changed files with 230 additions and 51 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -44,16 +46,24 @@ 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 = {
provide: LocationStrategy,
useClass: HashLocationStrategy
};

// Google maps config
const AGM_CONFIG = {
apiKey: environment.googleMapsApiKey,
libraries: ['places']
};

@NgModule({
bootstrap: [ AppComponent ],
declarations: [
Expand All @@ -75,9 +85,11 @@ const locationStrategyProvider = {
FormsModule,
HttpModule,
routing,
AgmCoreModule.forRoot(AGM_CONFIG),
BsDropdownModule.forRoot(),
CollapseModule.forRoot(),
TooltipModule.forRoot(),
PopoverModule.forRoot(),
Ng2AutoCompleteModule,
ClipboardModule,
NouisliderModule,
Expand All @@ -95,6 +107,7 @@ const locationStrategyProvider = {
AuthGuard,
DataExportService,
ImageExportService,
MapCellService,
ProjectService
]
})
Expand Down
2 changes: 1 addition & 1 deletion src/app/charts/chart.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
171 changes: 141 additions & 30 deletions src/app/lab/components/city-dropdown.component.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -12,47 +19,151 @@ import { apiHost } from '../../constants';
<ccl-city-dropdown
[projectData]="your_project.project_data">
*/

@Component({
selector: 'ccl-city-dropdown',
template: `<div class="dropdown dropdown-location">
<div class="input">
<input auto-complete
[(ngModel)]="projectData.city"
[source]="apiCities"
[list-formatter]="cityListFormatter"
[value-formatter]="cityValueFormatter"
display-property-name="name"
path-to-data="features"
<ng-template #cityError>
<i class="icon-attention"></i>
No climate data for this location
</ng-template>
<input #input
id="project.city"
class="autocomplete"
[ngClass]="errors === null ? '' : 'error'"
type="text"
placeholder="Enter your city"
min-chars="2" />
[popover]="cityError"
triggers=""
container="body"
[isOpen]="errors !== null"
containerClass="error-popover"
placeholder="Enter your city" />
</div>
</div>`
</div>`,
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 ?
`<span>${data.properties.name}, ${data.properties.admin}</span>` : 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 <input> 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<string>();
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;
}
}
2 changes: 1 addition & 1 deletion src/app/lab/lab.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<!-- city selector -->
<div class="control-group">
<label>Location</label>
<ccl-city-dropdown [projectData]="project.project_data"></ccl-city-dropdown>
<ccl-city-dropdown [(ngModel)]="project.project_data.city"></ccl-city-dropdown>
</div>
<!-- scenario selector -->
<div class="control-group">
Expand Down
19 changes: 19 additions & 0 deletions src/app/models/map-cell.model.ts
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 3 additions & 2 deletions src/app/project/add-edit-project.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ <h3 *ngIf="edit">Edit project</h3>
class="form-control"
id="project.name"
name="project.name"
required
[(ngModel)]="model.project.project_data.name">
<label for="project.description">Description</label>
<textarea type="text"
Expand All @@ -23,7 +24,7 @@ <h3 *ngIf="edit">Edit project</h3>
[(ngModel)]="model.project.project_data.description"></textarea>
<div *ngIf="!edit" class="project-options">
<label for="project.city">Choose a city*</label>
<ccl-city-dropdown [projectData]="model.project.project_data" [showIcon]="false"></ccl-city-dropdown>
<ccl-city-dropdown [(ngModel)]="model.project.project_data.city" name="project.city" #city="ngModel"></ccl-city-dropdown>
<div class="row">
<div class="column-5 flex-column">
<label for="project.scenario">Scenario*</label>
Expand Down Expand Up @@ -53,7 +54,7 @@ <h3 *ngIf="edit">Edit project</h3>
<div class="row align-center">
<button type="submit"
class="button button-primary"
[disabled]="!projectForm.form.valid || !model.project.project_data.city.id || !model.project.project_data.scenario.name ||
[disabled]="!projectForm.form.valid || !model.project.project_data.city.geometry || !model.project.project_data.scenario.name ||
!model.project.project_data.dataset.name"
*ngIf="!edit">Create Project</button>
<button type="submit"
Expand Down
22 changes: 22 additions & 0 deletions src/app/services/map-cell.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Injectable } from '@angular/core';
import { Response } from '@angular/http';
import { Point } from 'geojson';
import { Observable } from 'rxjs/Observable';

import { MapCell } from '../models/map-cell.model';
import { LabApiHttp } from '../auth/api-http.service';
import { apiHost } from '../constants';

@Injectable()
export class MapCellService {

constructor(private apiHttp: LabApiHttp) {}

nearest(point: Point, distance: number): Observable<MapCell[]> {
const url = `${apiHost}/api/map-cell/${point.coordinates[1]}/${point.coordinates[0]}?distance=${distance}`;
return this.apiHttp.get(url)
.map((resp) => {
return resp.json() || [] as MapCell[];
});
}
}
9 changes: 9 additions & 0 deletions src/assets/sass/components/_popover.scss
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,12 @@
border-left-color: #ffffff;
bottom: -$popover-arrow;
}

.popover.error-popover {
background: #FA2D3A;
color: #ffffff;

> .arrow:after {
border-top-color: #FA2D3A;
}
}
10 changes: 5 additions & 5 deletions src/assets/sass/components/_project-form.scss
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,8 @@ ccl-add-edit-project {

}

.ng2-auto-complete {
.autocomplete {
width: 100%;

input {
width: 100%;
}
}
}

Expand All @@ -58,3 +54,7 @@ input, textarea {
border: 1px solid #ccc;
padding: 6px;
}

input.error {
border-color: #FA2D3A !important;
}
1 change: 1 addition & 0 deletions src/environments/environment.prod.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const environment = {
production: true,
distance: 32000,
googleMapsApiKey: 'AIzaSyDbVOdIZAq4rBo94p947kCBo_KKPyqFf9I',
};
Loading

0 comments on commit 5612e0f

Please sign in to comment.