diff --git a/scss/components/_search-form.scss b/scss/components/_search-form.scss
new file mode 100644
index 0000000..b7e154d
--- /dev/null
+++ b/scss/components/_search-form.scss
@@ -0,0 +1,17 @@
+.SearchForm {
+ width: 100%;
+ position: relative;
+
+ input {
+ width: 100%;
+ margin: 0;
+ height: 35px;
+
+ border-radius: 0;
+ background-color: $btn-bg;
+ border-color: $marine-light;
+ color: $marine-light;
+ }
+
+ margin-bottom: 0;
+}
diff --git a/scss/components/_search-results.scss b/scss/components/_search-results.scss
new file mode 100644
index 0000000..5cbf615
--- /dev/null
+++ b/scss/components/_search-results.scss
@@ -0,0 +1,32 @@
+.SearchResults {
+ width: 100%;
+ position: relative;
+ padding-right: 10px;
+
+ border: 1px solid $marine-light;
+
+ p {
+ margin-bottom: 10px;
+ }
+
+ p.address {
+ font-size: 14px;
+ }
+
+ button.close {
+ float: right;
+
+ height: 20px;
+ padding: 0 5px;
+ margin: 5px 5px 10px 10px;
+ font-size: 11px;
+ line-height: 11px;
+ background-color: $off-white;
+ border-color: transparent;
+ color: $marine-light;
+ text-align: left;
+ text-transform: capitalize;
+ letter-spacing: 0.5px;
+ border: none;
+ }
+}
diff --git a/scss/components/_search.scss b/scss/components/_search.scss
new file mode 100644
index 0000000..b6a7f8f
--- /dev/null
+++ b/scss/components/_search.scss
@@ -0,0 +1,9 @@
+.Search {
+ width: 300px;
+ position: absolute;
+ top: $margin-5 + $app-header-height - 10;
+ left: $margin-25 + $zoom-controls-width + $padding-10 - 10;
+ z-index: 5;
+ padding-right: 10px;
+ background-color: transparent;
+}
diff --git a/scss/main.scss b/scss/main.scss
index 254a2e9..45df137 100644
--- a/scss/main.scss
+++ b/scss/main.scss
@@ -37,3 +37,6 @@
@import 'components/modal';
@import 'components/small-device-message';
@import 'components/about-copy';
+@import 'components/search';
+@import 'components/search-results';
+@import 'components/search-form';
diff --git a/src/actions/async_actions.js b/src/actions/async_actions.js
index 5a474e5..3442185 100644
--- a/src/actions/async_actions.js
+++ b/src/actions/async_actions.js
@@ -1,6 +1,6 @@
import { polyfill } from 'es6-promise';
import fetch from 'isomorphic-fetch';
-import { cartoSQLQuery } from '../constants/app_config';
+import { cartoSQLQuery, geocodingK } from '../constants/app_config';
import * as actions from '../constants/action_types';
import {
configureStatsSQL,
@@ -171,3 +171,50 @@ export const fetchGeoPolygons = (geo) => {
.catch(error => dispatch(receiveGeoPolygonsError(error)));
};
};
+
+
+// address geocode
+// we are about to make a GET request to geocode a location
+const locationGeocodeRequest = searchTerm => ({
+ type: actions.LOCATION_GEOCODE_REQUEST,
+ searchTerm
+});
+
+// we have JSON data representing the geocoded location
+const locationGeocodeSuccess = json => ({
+ type: actions.LOCATION_GEOCODE_SUCCESS,
+ json
+});
+
+// we encountered an error geocoding the location
+export const locationGeocodeError = error => ({
+ type: actions.LOCATION_GEOCODE_ERROR,
+ error
+});
+
+/*
+ * Redux Thunk action creator to fetch geocoded JSON for a given location / address
+ * @param {string} location: A URI encoded string representing an address,
+ * e.g. "1600+Amphitheatre+Parkway,+Mountain+View,+CA"
+*/
+export const fetchLocationGeocode = (searchTerm) => {
+ const searchTermEncoded = encodeURIComponent(searchTerm);
+ const viewportBias = encodeURIComponent('40.485604,-74.284058|40.935303,-73.707275');
+ const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${searchTermEncoded}&bounds=${viewportBias}&key=${geocodingK}`;
+
+ return (dispatch) => {
+ dispatch(locationGeocodeRequest(searchTerm));
+ return fetch(url)
+ .then(res => res.json())
+ .then((json) => {
+ const { results, status } = json;
+ // catch a non-successful geocode result that was returned in the response
+ if (!results || !results.length || status !== 'OK') {
+ dispatch(locationGeocodeError('Address not found, please try again.'));
+ } else {
+ dispatch(locationGeocodeSuccess(results[0]));
+ }
+ })
+ .catch(error => dispatch(locationGeocodeError(error)));
+ };
+};
diff --git a/src/actions/filter_by_location_actions.js b/src/actions/filter_by_location_actions.js
new file mode 100644
index 0000000..9bbdb6e
--- /dev/null
+++ b/src/actions/filter_by_location_actions.js
@@ -0,0 +1,15 @@
+import {
+ CLEAR_LOCATION_GEOCODE,
+ FILTER_BY_LOCATION,
+} from '../constants/action_types';
+
+export const clearLocationGeocode = () => ({
+ type: CLEAR_LOCATION_GEOCODE,
+});
+
+export const filterByLocation = latLon => ({
+ type: FILTER_BY_LOCATION,
+ latLon
+});
+
+export default clearLocationGeocode;
diff --git a/src/actions/index.js b/src/actions/index.js
index ccc4bd4..f63730e 100644
--- a/src/actions/index.js
+++ b/src/actions/index.js
@@ -4,6 +4,7 @@ export { fetchCrashStatsData,
fetchCrashesDateRange,
fetchCrashesMaxDate,
fetchGeoPolygons,
+ fetchLocationGeocode
} from './async_actions';
export { startDateChange, endDateChange } from './filter_by_date_actions';
export {
@@ -19,3 +20,4 @@ export {
} from './filter_by_area_actions';
export filterByContributingFactor from './filter_contributing_factor_actions';
export { openModal, closeModal } from './modal_actions';
+export { clearLocationGeocode, filterByLocation } from './filter_by_location_actions';
diff --git a/src/components/App.js b/src/components/App.js
index f56d9dd..cd8e566 100644
--- a/src/components/App.js
+++ b/src/components/App.js
@@ -10,6 +10,7 @@ import OptionsFiltersConnected from '../containers/OptionsFiltersConnected';
import ModalConnected from '../containers/ModalConnected';
import SmallDeviceMessage from './SmallDeviceMessage';
import LoadingIndicator from './LoadingIndicator';
+import Search from '../containers/SearchConnected';
class App extends Component {
constructor() {
@@ -96,6 +97,7 @@ class App extends Component {
:
[
,
+ ,
${addressFormatted}
`)
+ .addTo(this.map);
+ this.searchCircle = L.circle(coordinates,
+ intersectionCircleRadiusMeters,
+ intersectionCircleStyle)
+ .addTo(this.map);
+ }
+
+ // remove marker if user cleared search result or applied filter by location
+ if (!searchResult && this.props.searchResult) {
+ this.map.removeLayer(this.searchMarker);
+ this.map.removeLayer(this.searchCircle);
+ this.searchMarker = null;
+ this.searchCircle = null;
+ }
}
shouldComponentUpdate() {
@@ -351,10 +383,7 @@ class LeafletMap extends Component {
function handleMouseover(e) {
const layer = e.target;
- layer.setStyle({
- fillColor: '#105b63',
- fillOpacity: 1
- });
+ layer.setStyle(geoPolygonStyle);
self.revealFilterAreaTooltip(geo, e);
@@ -454,6 +483,7 @@ LeafletMap.defaultProps = {
identifier: '',
lngLats: [],
geojson: {},
+ searchResult: null,
};
LeafletMap.propTypes = {
@@ -494,6 +524,10 @@ LeafletMap.propTypes = {
}).isRequired,
noInjuryFatality: PropTypes.bool.isRequired
}).isRequired,
+ searchResult: PropTypes.shape({
+ addressFormatted: PropTypes.string,
+ result: PropTypes.arrayOf(PropTypes.number)
+ }),
};
export default LeafletMap;
diff --git a/src/components/Search/SearchForm.js b/src/components/Search/SearchForm.js
new file mode 100644
index 0000000..059c7c5
--- /dev/null
+++ b/src/components/Search/SearchForm.js
@@ -0,0 +1,60 @@
+import React, { Component, PropTypes } from 'react';
+
+class Search extends Component {
+ static propTypes = {
+ error: PropTypes.string,
+ isFetching: PropTypes.bool.isRequired,
+ searchTerm: PropTypes.string,
+ result: PropTypes.shape({
+ addressFormatted: PropTypes.string,
+ coordinates: PropTypes.arrayOf(PropTypes.number)
+ }),
+ fetchLocationGeocode: PropTypes.func.isRequired,
+ }
+
+ static defaultProps = {
+ error: null,
+ result: null,
+ searchTerm: null
+ }
+
+ state = {
+ inputText: '',
+ }
+
+ handleSubmit = (e) => {
+ const { inputText } = this.state;
+
+ e.preventDefault();
+
+ if (inputText && inputText.length) {
+ this.props.fetchLocationGeocode(inputText);
+ this.setState({
+ inputText: '',
+ });
+ }
+ }
+
+ handleChange = (e) => {
+ this.setState({
+ inputText: e.target.value,
+ });
+ }
+
+ render() {
+ const { inputText } = this.state;
+
+ return (
+
+ );
+ }
+}
+
+export default Search;
diff --git a/src/components/Search/SearchResults.js b/src/components/Search/SearchResults.js
new file mode 100644
index 0000000..15af2ff
--- /dev/null
+++ b/src/components/Search/SearchResults.js
@@ -0,0 +1,73 @@
+import React, { Component, PropTypes } from 'react';
+
+import { intersectionCircleRadiusFeet } from '../../constants/app_config';
+
+class SearchResults extends Component {
+ static propTypes = {
+ clearLocationGeocode: PropTypes.func.isRequired,
+ error: PropTypes.string,
+ filterByLocation: PropTypes.func.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ searchTerm: PropTypes.string,
+ result: PropTypes.shape({
+ addressFormatted: PropTypes.string,
+ coordinates: PropTypes.arrayOf(PropTypes.number)
+ }),
+ }
+
+ static defaultProps = {
+ error: null,
+ result: null,
+ searchTerm: null
+ }
+
+ handleFilterResult = (e) => {
+ e.preventDefault();
+ const { result } = this.props;
+ this.props.filterByLocation(result.coordinates);
+ }
+
+ closeThisPanel = (e) => {
+ e.preventDefault();
+ this.props.clearLocationGeocode();
+ }
+
+ showSearchResult = () => {
+ const { error, isFetching, result } = this.props;
+
+ if (isFetching) {
+ return (searching...
);
+ }
+
+ if (error) {
+ return ({error}
);
+ }
+
+ if (result) {
+ return [
+ ,
+ Filter crashes within {intersectionCircleRadiusFeet} feet of this location?
,
+ {result.addressFormatted}
,
+
+ ];
+ }
+
+ return null;
+ }
+
+ render() {
+ return (
+
+ {this.showSearchResult()}
+
+ );
+ }
+}
+
+export default SearchResults;
diff --git a/src/components/Search/index.js b/src/components/Search/index.js
new file mode 100644
index 0000000..e4f9f23
--- /dev/null
+++ b/src/components/Search/index.js
@@ -0,0 +1,35 @@
+import React, { PropTypes } from 'react';
+
+import SearchForm from './SearchForm';
+import SearchResults from './SearchResults';
+
+const Search = props => (
+
+
+ {
+ (props.error || props.result) &&
+
+ }
+
+);
+
+Search.propTypes = {
+ error: PropTypes.string,
+ isFetching: PropTypes.bool.isRequired,
+ searchTerm: PropTypes.string,
+ result: PropTypes.shape({
+ addressFormatted: PropTypes.string,
+ coordinates: PropTypes.arrayOf(PropTypes.number)
+ }),
+ clearLocationGeocode: PropTypes.func.isRequired,
+ fetchLocationGeocode: PropTypes.func.isRequired,
+ filterByLocation: PropTypes.func.isRequired,
+};
+
+Search.defaultProps = {
+ error: null,
+ result: null,
+ searchTerm: null
+};
+
+export default Search;
diff --git a/src/constants/action_types.js b/src/constants/action_types.js
index 2e513be..19514ba 100644
--- a/src/constants/action_types.js
+++ b/src/constants/action_types.js
@@ -5,6 +5,8 @@ export const FILTER_BY_AREA_TYPE = 'FILTER_BY_AREA_TYPE';
export const FILTER_BY_AREA_IDENTIFIER = 'FILTER_BY_AREA_IDENTIFIER';
export const FILTER_BY_AREA_CUSTOM = 'FILTER_BY_AREA_CUSTOM';
+export const FILTER_BY_LOCATION = 'FILTER_BY_LOCATION';
+
export const TOGGLE_CUSTOM_AREA_DRAW = 'TOGGLE_CUSTOM_AREA_DRAW';
export const FILTER_BY_TYPE_INJURY = 'FILTER_BY_TYPE_INJURY';
@@ -39,3 +41,9 @@ export const GEO_POLYGONS_ERROR = 'GEO_POLYGONS_ERROR';
export const MODAL_OPENED = 'MODAL_OPENED';
export const MODAL_CLOSED = 'MODAL_CLOSED';
+
+// Geocoding a search location
+export const LOCATION_GEOCODE_REQUEST = 'LOCATION_GEOCODE_REQUEST';
+export const LOCATION_GEOCODE_SUCCESS = 'LOCATION_GEOCODE_SUCCESS';
+export const LOCATION_GEOCODE_ERROR = 'LOCATION_GEOCODE_ERROR';
+export const CLEAR_LOCATION_GEOCODE = 'CLEAR_LOCATION_GEOCODE';
diff --git a/src/constants/app_config.js b/src/constants/app_config.js
index 8896728..50392aa 100644
--- a/src/constants/app_config.js
+++ b/src/constants/app_config.js
@@ -57,3 +57,29 @@ export const labelFormats = {
nypd_precinct: 'NYPD Precinct {}',
intersection: '{}',
};
+
+// GeoJSON polyfons for clicking specific areas
+// the style for drawing them onto the map
+export const geoPolygonStyle = {
+ fillColor: '#105b63',
+ fillOpacity: 1
+};
+
+// API key for geocoding
+// TO DO: replace this with a key on Chekpeds / GreenInfo account
+export const geocodingK = 'AIzaSyCgATLAbiGUrmZSIaJsCZTewG9Zu32jxus';
+
+// geocoder search: the radius of the "preview" circle, and its visual style
+export const intersectionCircleRadiusMeters = 27.4;
+export const intersectionCircleRadiusFeet = Math.round(intersectionCircleRadiusMeters * 3.28084);
+export const intersectionCircleStyle = {
+ /*
+ color: '#105b63',
+ opacity: 1,
+ weight: 2,
+ */
+ stroke: false,
+ fillColor: '#105b63',
+ fillOpacity: 0.5,
+ clickable: false,
+};
diff --git a/src/constants/sql_queries.js b/src/constants/sql_queries.js
index c6c205b..3acff2b 100644
--- a/src/constants/sql_queries.js
+++ b/src/constants/sql_queries.js
@@ -221,6 +221,29 @@ const filterByCustomAreaClause = (lonLatArray) => {
return '';
};
+
+const filterByLocationSQL = ({ lat, lon }) => {
+ if (lat && lon) {
+ return sls`
+ AND
+ ST_Contains(
+ ST_Buffer(
+ ST_Transform(
+ ST_SetSRID(
+ ST_MakePoint(${lon}, ${lat}),
+ 4326
+ ),
+ 3785
+ ),
+ 65
+ ),
+ c.the_geom_webmercator
+ )
+ `;
+ }
+ return '';
+};
+
/*
********************************** MAP ****************************************
*/
@@ -232,7 +255,7 @@ const filterByCustomAreaClause = (lonLatArray) => {
// @param {string} harm: crash type, one of 'ALL', 'cyclist', 'motorist', 'ped'
// @param {string} persona: crash type, of of 'ALL', 'fatality', 'injury', 'no inj/fat'
export const configureMapSQL = (params) => {
- const { startDate, endDate, filterType, geo, identifier, lngLats } = params;
+ const { startDate, endDate, filterType, geo, identifier, lngLats, lat, lon } = params;
return sls`
SELECT * FROM
@@ -259,6 +282,7 @@ export const configureMapSQL = (params) => {
${filterByCustomAreaClause(lngLats)}
${filterByTypeWhereClause(filterType)}
${filterByIdentifierWhereClause(identifier, geo)}
+ ${filterByLocationSQL(lat, lon)}
AND
c.the_geom IS NOT NULL
GROUP BY
diff --git a/src/containers/LeafletMapConnected.js b/src/containers/LeafletMapConnected.js
index aef306f..101a54e 100644
--- a/src/containers/LeafletMapConnected.js
+++ b/src/containers/LeafletMapConnected.js
@@ -3,11 +3,12 @@ import { connect } from 'react-redux';
import { filterByAreaIdentifier, filterByAreaCustom, fetchGeoPolygons } from '../actions/';
import LeafletMap from '../components/LeafletMap/';
-const mapStateToProps = ({ filterDate, filterType, filterArea }, ownProps) => {
+const mapStateToProps = ({ filterDate, filterType, filterArea, geocoding }, ownProps) => {
const { startDate, endDate } = filterDate;
const { location: { query } } = ownProps;
const { lat, lng, zoom } = query;
const { geo, geojson, identifier, lngLats, drawEnabeled } = filterArea;
+ const { result } = geocoding;
return {
zoom: zoom ? Number(zoom) : undefined,
lat: lat ? Number(lat) : undefined,
@@ -20,6 +21,7 @@ const mapStateToProps = ({ filterDate, filterType, filterArea }, ownProps) => {
geojson,
identifier,
lngLats,
+ searchResult: result,
};
};
diff --git a/src/containers/SearchConnected.js b/src/containers/SearchConnected.js
new file mode 100644
index 0000000..8b9defc
--- /dev/null
+++ b/src/containers/SearchConnected.js
@@ -0,0 +1,25 @@
+import { connect } from 'react-redux';
+
+import {
+ fetchLocationGeocode,
+ clearLocationGeocode,
+ filterByLocation,
+} from '../actions';
+
+import Search from '../components/Search';
+
+const mapStateToProps = ({ geocoding }) => {
+ const { error, isFetching, searchTerm, result } = geocoding;
+ return {
+ error,
+ isFetching,
+ result,
+ searchTerm,
+ };
+};
+
+export default connect(mapStateToProps, {
+ fetchLocationGeocode,
+ clearLocationGeocode,
+ filterByLocation,
+})(Search);
diff --git a/src/reducers/geocodeReducer.js b/src/reducers/geocodeReducer.js
new file mode 100644
index 0000000..1ffd5e1
--- /dev/null
+++ b/src/reducers/geocodeReducer.js
@@ -0,0 +1,61 @@
+import {
+ CLEAR_LOCATION_GEOCODE,
+ LOCATION_GEOCODE_REQUEST,
+ LOCATION_GEOCODE_SUCCESS,
+ LOCATION_GEOCODE_ERROR,
+} from '../constants/action_types';
+
+const defaultState = {
+ isFetching: false,
+ searchTerm: '',
+ result: null,
+ error: null
+};
+
+const parseGeocodeResult = (result) => {
+ const { formatted_address, geometry } = result;
+ const addressLabel = formatted_address.replace(/,\s+(USA|Canada|Mexico)\s*$/, '');
+
+ return {
+ addressFormatted: addressLabel,
+ coordinates: [geometry.location.lat, geometry.location.lng],
+ };
+};
+
+/*
+ * Geocoding Reducer
+ * @param {object} state: default reducer state
+ * @param {object} action: redux action creator
+*/
+export default (state = defaultState, action) => {
+ switch (action.type) {
+ case LOCATION_GEOCODE_REQUEST:
+ return {
+ ...state,
+ isFetching: true,
+ searchTerm: action.searchTerm,
+ result: null
+ };
+
+ case LOCATION_GEOCODE_SUCCESS:
+ return {
+ ...state,
+ error: null,
+ isFetching: false,
+ result: parseGeocodeResult(action.json)
+ };
+
+ case LOCATION_GEOCODE_ERROR:
+ return {
+ ...state,
+ isFetching: false,
+ error: action.error
+ };
+
+ case CLEAR_LOCATION_GEOCODE:
+ return defaultState;
+
+ default:
+ return state;
+ }
+};
diff --git a/src/reducers/index.js b/src/reducers/index.js
index 8f56360..eb28b0a 100644
--- a/src/reducers/index.js
+++ b/src/reducers/index.js
@@ -12,6 +12,7 @@ import crashesDateRange from './crashes_date_range_reducer';
import crashesMaxDate from './crashes_max_date_reducer';
import crashStats from './crash_stats_reducer';
import modal from './modal_reducer';
+import geocoding from './geocodeReducer';
// breakpoints for redux-responsive store
// taken from scss/skeleton/base/variables
@@ -39,6 +40,7 @@ const rootReducer = combineReducers({
filterArea,
filterContributingFactor,
filterType,
+ geocoding,
modal,
routing: routerReducer,
yearRange,