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,