From 68b5a33b9fe8bed597a03331d95c21af3dc99455 Mon Sep 17 00:00:00 2001 From: Jake Mensch Date: Wed, 29 Apr 2020 12:36:52 -0700 Subject: [PATCH] integration of /pin-clusters --- src/components/PinMap/ClusterMarker.jsx | 41 +++++ src/components/PinMap/PinMap.jsx | 166 +++++++++++------- .../chartExtras/NumberOfRequests.jsx | 2 +- src/components/common/Loader.jsx | 14 +- src/redux/reducers/data.js | 125 +++++++++---- src/redux/reducers/ui.js | 14 +- src/redux/sagas/data.js | 154 ++++++++++++---- 7 files changed, 377 insertions(+), 139 deletions(-) create mode 100644 src/components/PinMap/ClusterMarker.jsx diff --git a/src/components/PinMap/ClusterMarker.jsx b/src/components/PinMap/ClusterMarker.jsx new file mode 100644 index 000000000..b9fbb9f68 --- /dev/null +++ b/src/components/PinMap/ClusterMarker.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import PropTypes from 'proptypes'; +import { Marker } from 'react-leaflet'; +import { divIcon, point } from 'leaflet'; + +function markerClass(count) { + if (count < 100) return 'small'; + if (count < 1000) return 'medium'; + return 'large'; +} + +function abbreviatedCount(count) { + if (count < 1000) return count; + if (count < 10000) return `${(count / 1000).toFixed(1)}K`; + if (count < 1000000) return `${(count / 1000).toFixed(0)}K`; + return `${(count / 1000000).toFixed(1)}M`; +} + +const ClusterMarker = ({ + position, + count, + onClick, +}) => { + const markerIcon = divIcon({ + html: `
${abbreviatedCount(count)}
`, + className: `marker-cluster marker-cluster-${markerClass(count)}`, + iconSize: point(40, 40), + }); + + return ( + + ); +}; + +export default ClusterMarker; + +ClusterMarker.propTypes = { + position: PropTypes.node.isRequired, + count: PropTypes.number.isRequired, + onClick: PropTypes.func.isRequired, +}; diff --git a/src/components/PinMap/PinMap.jsx b/src/components/PinMap/PinMap.jsx index 68ba20934..94b024e67 100644 --- a/src/components/PinMap/PinMap.jsx +++ b/src/components/PinMap/PinMap.jsx @@ -1,19 +1,21 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { getPinInfoRequest } from '@reducers/data'; +import { updateMapPosition } from '@reducers/ui'; import PinPopup from '@components/PinMap/PinPopup'; import CustomMarker from '@components/PinMap/CustomMarker'; +import ClusterMarker from '@components/PinMap/ClusterMarker'; import { Map, TileLayer, Rectangle, Tooltip, LayersControl, + LayerGroup, ZoomControl, withLeaflet, } from 'react-leaflet'; import Choropleth from 'react-leaflet-choropleth'; -import MarkerClusterGroup from 'react-leaflet-markercluster'; import HeatmapLayer from 'react-leaflet-heatmap-layer'; import PropTypes from 'proptypes'; import COLORS from '@styles/COLORS'; @@ -46,6 +48,7 @@ class PinMap extends Component { ready: false, width: null, height: null, + heatmapVisible: false, }; this.container = React.createRef(); } @@ -93,6 +96,14 @@ class PinMap extends Component { this.setState({ bounds }); } + updatePosition = ({ target: map }) => { + const { updatePosition } = this.props; + updatePosition({ + zoom: map.getZoom(), + bounds: map.getBounds(), + }); + } + onEachRegionFeature = (feature, layer) => { // Popup text when clicking on a region const popupText = ` @@ -114,65 +125,77 @@ class PinMap extends Component { renderMarkers = () => { const { - data, + pinClusters, getPinInfo, pinsInfo, } = this.props; - if (data) { - return data.map(d => { - if (d.latitude && d.longitude) { - const { - latitude, - longitude, - srnumber, - requesttype, - } = d; - const position = [latitude, longitude]; - const { - status, - createddate, - updateddate, - closeddate, - address, - ncname, - } = pinsInfo[srnumber] || {}; - const { displayName, color, abbrev } = REQUEST_TYPES[requesttype]; - - const popup = ( - - ); - + if (pinClusters) { + return pinClusters.map(({ + id, + count, + latitude, + longitude, + expansion_zoom: expansionZoom, + srnumber, + requesttype, + }) => { + const position = [latitude, longitude]; + + if (count > 1) { return ( - { - if (!pinsInfo[srnumber]) { - getPinInfo(srnumber); - } + count={count} + onClick={({ latlng }) => { + this.map.flyTo(latlng, expansionZoom); }} - color={color} - icon="map-marker-alt" - size="3x" - style={{ textShadow: '1px 0px 3px rgba(0,0,0,1.0), -1px 0px 3px rgba(0,0,0,1.0)' }} - > - {popup} - + /> ); } - return null; + const { + status, + createddate, + updateddate, + closeddate, + address, + ncname, + } = pinsInfo[srnumber] || {}; + const { displayName, color, abbrev } = REQUEST_TYPES[requesttype]; + + const popup = ( + + ); + + return ( + { + if (!pinsInfo[srnumber]) { + getPinInfo(srnumber); + } + }} + color={color} + icon="map-marker-alt" + size="3x" + style={{ textShadow: '1px 0px 3px rgba(0,0,0,1.0), -1px 0px 3px rgba(0,0,0,1.0)' }} + > + {popup} + + ); }); } @@ -202,9 +225,10 @@ class PinMap extends Component { geoJSON, width, height, + heatmapVisible, } = this.state; - const { data } = this.props; + const { heatmap } = this.props; return ( <> @@ -215,6 +239,21 @@ class PinMap extends Component { bounds={bounds} style={{ width, height }} zoomControl={false} + whenReady={e => { + this.map = e.target; + this.updatePosition(e); + }} + onMoveend={this.updatePosition} + onOverlayadd={({ name }) => { + if (name === 'Heatmap') { + this.setState({ heatmapVisible: true }); + } + }} + onOverlayremove={({ name }) => { + if (name === 'Heatmap') { + this.setState({ heatmapVisible: false }); + } + }} > - + {this.renderMarkers()} - + {/* intensityExtractor is required and requires a callback as the value. * The heatmap is working with an empty callback but we'll probably * improve functionality post-MVP by generating a heatmap list * on the backend. */} + {/* The heatmapVisible test prevents the component from doing + * unnecessary calculations when the heatmap isn't visible */} m.longitude} - latitudeExtractor={m => m.latitude} + longitudeExtractor={m => m[1]} + latitudeExtractor={m => m[0]} intensityExtractor={() => 1} /> @@ -324,22 +363,27 @@ class PinMap extends Component { const mapDispatchToProps = dispatch => ({ getPinInfo: srnumber => dispatch(getPinInfoRequest(srnumber)), + updatePosition: position => dispatch(updateMapPosition(position)), }); const mapStateToProps = state => ({ - data: state.data.pins, pinsInfo: state.data.pinsInfo, + pinClusters: state.data.pinClusters, + heatmap: state.data.heatmap, }); PinMap.propTypes = { - data: PropTypes.arrayOf(PropTypes.shape({})), pinsInfo: PropTypes.shape({}), + pinClusters: PropTypes.arrayOf(PropTypes.shape({})), + heatmap: PropTypes.arrayOf(PropTypes.array), getPinInfo: PropTypes.func.isRequired, + updatePosition: PropTypes.func.isRequired, }; PinMap.defaultProps = { - data: undefined, pinsInfo: {}, + pinClusters: [], + heatmap: [], }; export default connect(mapStateToProps, mapDispatchToProps)(PinMap); diff --git a/src/components/chartExtras/NumberOfRequests.jsx b/src/components/chartExtras/NumberOfRequests.jsx index 60f508988..00c9435d3 100644 --- a/src/components/chartExtras/NumberOfRequests.jsx +++ b/src/components/chartExtras/NumberOfRequests.jsx @@ -20,7 +20,7 @@ const NumberOfRequests = ({ ); const mapStateToProps = state => ({ - numRequests: state.data.pins.length, + numRequests: Object.values(state.data.counts.type).reduce((p, c) => p + c, 0), }); export default connect(mapStateToProps)(NumberOfRequests); diff --git a/src/components/common/Loader.jsx b/src/components/common/Loader.jsx index e169be085..84a2211d1 100644 --- a/src/components/common/Loader.jsx +++ b/src/components/common/Loader.jsx @@ -1,6 +1,7 @@ import React from 'react'; import PropTypes from 'proptypes'; import { connect } from 'react-redux'; +import { MENU_TABS } from '@components/common/CONSTANTS'; const Loader = ({ isLoading, @@ -17,9 +18,16 @@ const Loader = ({ ); }; -const mapStateToProps = state => ({ - isLoading: state.data.isLoading || state.comparisonData.isLoading, -}); +const mapStateToProps = state => { + const { activeTab } = state.ui.menu; + return { + isLoading: ( + state.comparisonData.isLoading + || (state.data.isMapLoading && activeTab === MENU_TABS.MAP) + || (state.data.isVisLoading && activeTab === MENU_TABS.VISUALIZATIONS) + ), + }; +}; export default connect(mapStateToProps)(Loader); diff --git a/src/redux/reducers/data.js b/src/redux/reducers/data.js index 79466a8f1..2a2e9e917 100644 --- a/src/redux/reducers/data.js +++ b/src/redux/reducers/data.js @@ -1,10 +1,14 @@ export const types = { GET_DATA_REQUEST: 'GET_DATA_REQUEST', - GET_DATA_SUCCESS: 'GET_DATA_SUCCESS', - GET_DATA_FAILURE: 'GET_DATA_FAILURE', GET_PIN_INFO_REQUEST: 'GET_PIN_INFO_REQUEST', GET_PIN_INFO_SUCCESS: 'GET_PIN_INFO_SUCCESS', GET_PIN_INFO_FAILURE: 'GET_PIN_INFO_FAILURE', + GET_PIN_CLUSTERS_SUCCESS: 'GET_PIN_CLUSTERS_SUCCESS', + GET_PIN_CLUSTERS_FAILURE: 'GET_PIN_CLUSTERS_FAILURE', + GET_HEATMAP_SUCCESS: 'GET_HEATMAP_SUCCESS', + GET_HEATMAP_FAILURE: 'GET_HEATMAP_FAILURE', + GET_VIS_DATA_SUCCESS: 'GET_VIS_DATA_SUCCESS', + GET_VIS_DATA_FAILURE: 'GET_VIS_DATA_FAILURE', SEND_GIT_REQUEST: 'SEND_GIT_REQUEST', GIT_RESPONSE_SUCCESS: 'GIT_RESPONSE_SUCCESS', GIT_RESPONSE_FAILURE: 'GIT_RESPONSE_FAILURE', @@ -14,16 +18,6 @@ export const getDataRequest = () => ({ type: types.GET_DATA_REQUEST, }); -export const getDataSuccess = response => ({ - type: types.GET_DATA_SUCCESS, - payload: response, -}); - -export const getDataFailure = error => ({ - type: types.GET_DATA_FAILURE, - payload: error, -}); - export const getPinInfoRequest = srnumber => ({ type: types.GET_PIN_INFO_REQUEST, payload: srnumber, @@ -39,6 +33,36 @@ export const getPinInfoFailure = error => ({ payload: error, }); +export const getPinClustersSuccess = response => ({ + type: types.GET_PIN_CLUSTERS_SUCCESS, + payload: response, +}); + +export const getPinClustersFailure = error => ({ + type: types.GET_PIN_CLUSTERS_FAILURE, + payload: error, +}); + +export const getHeatmapSuccess = response => ({ + type: types.GET_HEATMAP_SUCCESS, + payload: response, +}); + +export const getHeatmapFailure = error => ({ + type: types.GET_HEATMAP_FAILURE, + payload: error, +}); + +export const getVisDataSuccess = response => ({ + type: types.GET_VIS_DATA_SUCCESS, + payload: response, +}); + +export const getVisDataFailure = error => ({ + type: types.GET_VIS_DATA_FAILURE, + payload: error, +}); + export const sendGitRequest = fields => ({ type: types.SEND_GIT_REQUEST, payload: fields, @@ -55,9 +79,11 @@ export const gitResponseFailure = error => ({ }); const initialState = { - isLoading: false, + isMapLoading: false, + isVisLoading: false, error: null, - pins: [], + pinClusters: [], + heatmap: [], pinsInfo: {}, counts: {}, frequency: { @@ -72,21 +98,23 @@ export default (state = initialState, action) => { case types.GET_DATA_REQUEST: return { ...state, - isLoading: true, + isMapLoading: true, + isVisLoading: true, }; - case types.GET_DATA_SUCCESS: + case types.GET_PIN_INFO_SUCCESS: return { ...state, error: null, - isLoading: false, - ...action.payload, + pinsInfo: { + ...state.pinsInfo, + [action.payload.srnumber]: action.payload, + }, }; - case types.GET_DATA_FAILURE: { + case types.GET_PIN_INFO_FAILURE: { const { response: { status }, message, } = action.payload; - return { ...state, error: { @@ -94,22 +122,37 @@ export default (state = initialState, action) => { message, error: action.payload, }, - isLoading: false, }; } - case types.GET_PIN_INFO_REQUEST: - return state; - case types.GET_PIN_INFO_SUCCESS: + case types.GET_PIN_CLUSTERS_SUCCESS: return { ...state, error: null, - pinsInfo: { - ...state.pinsInfo, - [action.payload.srnumber]: action.payload, + pinClusters: action.payload, + isMapLoading: false, + }; + case types.GET_PIN_CLUSTERS_FAILURE: { + const { + response: { status }, + message, + } = action.payload; + return { + ...state, + error: { + code: status, + message, + error: action.payload, }, - isLoading: false, + isMapLoading: false, }; - case types.GET_PIN_INFO_FAILURE: { + } + case types.GET_HEATMAP_SUCCESS: + return { + ...state, + error: null, + heatmap: action.payload, + }; + case types.GET_HEATMAP_FAILURE: { const { response: { status }, message, @@ -121,19 +164,34 @@ export default (state = initialState, action) => { message, error: action.payload, }, - isLoading: false, }; } - case types.SEND_GIT_REQUEST: + case types.GET_VIS_DATA_SUCCESS: return { ...state, - isLoading: true, + error: null, + ...action.payload, + isVisLoading: false, }; + case types.GET_VIS_DATA_FAILURE: { + const { + response: { status }, + message, + } = action.payload; + return { + ...state, + error: { + code: status, + message, + error: action.payload, + }, + isVisLoading: false, + }; + } case types.GIT_RESPONSE_SUCCESS: return { ...state, error: null, - isLoading: false, }; case types.GIT_RESPONSE_FAILURE: { const { @@ -148,7 +206,6 @@ export default (state = initialState, action) => { message, error: action.payload, }, - isLoading: false, }; } default: diff --git a/src/redux/reducers/ui.js b/src/redux/reducers/ui.js index 31d8169a5..6e688af42 100644 --- a/src/redux/reducers/ui.js +++ b/src/redux/reducers/ui.js @@ -1,12 +1,13 @@ import { MENU_TABS } from '@components/common/CONSTANTS'; -const types = { +export const types = { TOGGLE_MENU: 'TOGGLE_MENU', SET_MENU_TAB: 'SET_MENU_TAB', SET_ERROR_MODAL: 'SET_ERROR_MODAL', SHOW_DATA_CHARTS: 'SHOW_DATA_CHARTS', SHOW_COMPARISON_CHARTS: 'SHOW_COMPARISON_CHARTS', SHOW_FEEDBACK_SUCCESS: 'SHOW_FEEDBACK_SUCCESS', + UPDATE_MAP_POSITION: 'UPDATE_MAP_POSITION', }; export const toggleMenu = () => ({ @@ -38,11 +39,17 @@ export const showFeedbackSuccess = isShown => ({ payload: isShown, }); +export const updateMapPosition = position => ({ + type: types.UPDATE_MAP_POSITION, + payload: position, +}); + const initialState = { menu: { isOpen: true, activeTab: MENU_TABS.MAP, }, + map: {}, error: { isOpen: false, }, @@ -92,6 +99,11 @@ export default (state = initialState, action) => { ...state, displayFeedbackSuccess: action.payload, }; + case types.UPDATE_MAP_POSITION: + return { + ...state, + map: action.payload, + }; default: return state; } diff --git a/src/redux/sagas/data.js b/src/redux/sagas/data.js index e151a1982..da42b9e55 100644 --- a/src/redux/sagas/data.js +++ b/src/redux/sagas/data.js @@ -11,34 +11,72 @@ import { COUNCILS } from '@components/common/CONSTANTS'; import { types, - getDataSuccess, - getDataFailure, + getPinClustersSuccess, + getPinClustersFailure, + getHeatmapSuccess, + getHeatmapFailure, getPinInfoSuccess, getPinInfoFailure, + getVisDataSuccess, + getVisDataFailure, gitResponseSuccess, gitResponseFailure, } from '../reducers/data'; import { + types as uiTypes, setErrorModal, showDataCharts, showFeedbackSuccess, } from '../reducers/ui'; - -/* /////////// INDIVIDUAL API CALLS /////////// */ +/* ////////////////// API CALLS //////////////// */ const BASE_URL = process.env.DB_URL; -function* getPins(filters) { - const pinUrl = `${BASE_URL}/pins`; +/* //// MAP //// */ + +function* fetchPinClusters(filters, { zoom, bounds }) { + const clustersUrl = `${BASE_URL}/pin-clusters`; + + const { + _northEast: { lat: north, lng: east }, + _southWest: { lat: south, lng: west }, + } = bounds; + + const { data } = yield call(axios.post, clustersUrl, { + ...filters, + zoom, + bounds: { + north, + east, + south, + west, + }, + }); + + return data; +} + +function* fetchHeatmap(filters) { + const heatmapUrl = `${BASE_URL}/heatmap`; + + const { data } = yield call(axios.post, heatmapUrl, filters); + + return data; +} + +function* fetchPinInfo(srnumber) { + const pinInfoUrl = `${BASE_URL}/servicerequest/${srnumber}`; - const { data } = yield call(axios.post, pinUrl, filters); + const { data } = yield call(axios.get, pinInfoUrl); return data; } -function* getCounts(filters) { +/* //// VISUALIZATIONS //// */ + +function* fetchCounts(filters) { const countsUrl = `${BASE_URL}/requestcounts`; const { data } = yield call(axios.post, countsUrl, { @@ -52,7 +90,7 @@ function* getCounts(filters) { }; } -function* getFrequency(filters) { +function* fetchFrequency(filters) { const frequencyUrl = `${BASE_URL}/requestfrequency`; const { data } = yield call(axios.post, frequencyUrl, filters); @@ -60,7 +98,7 @@ function* getFrequency(filters) { return data; } -function* getTimeToClose(filters) { +function* fetchTimeToClose(filters) { const ttcUrl = `${BASE_URL}/timetoclose`; const { data } = yield call(axios.post, ttcUrl, filters); @@ -68,44 +106,33 @@ function* getTimeToClose(filters) { return data; } -function* fetchPinInfo(srnumber) { - const pinInfoUrl = `${BASE_URL}/servicerequest/${srnumber}`; - - const { data } = yield call(axios.get, pinInfoUrl); - - return data; -} - -function* postFeedback(message) { - const contactURL = `${BASE_URL}/feedback`; - - const response = yield call(axios.post, contactURL, message); - return response; -} - -/* //////////// COMBINED API CALL //////////// */ - -function* getAll(filters) { +function* fetchVisData(filters) { const [ - pins, counts, frequency, timeToClose, ] = yield all([ - call(getPins, filters), - call(getCounts, filters), - call(getFrequency, filters), - call(getTimeToClose, filters), + call(fetchCounts, filters), + call(fetchFrequency, filters), + call(fetchTimeToClose, filters), ]); return { - pins, counts, frequency, timeToClose, }; } +/* //// OTHER //// */ + +function* postFeedback(message) { + const contactURL = `${BASE_URL}/feedback`; + + const response = yield call(axios.post, contactURL, message); + return response; +} + /* ////////////////// FILTERS //////////////// */ const getState = (state, slice) => state[slice]; @@ -130,16 +157,63 @@ function* getFilters() { }; } +function* getMapPosition() { + const { map } = yield select(getState, 'ui'); + return map; +} + /* /////////////////// SAGAS ///////////////// */ -function* getData() { +function* getMapData() { + const filters = yield getFilters(); + const mapPosition = yield getMapPosition(); + + try { + const clustersData = yield call(fetchPinClusters, filters, mapPosition); + yield put(getPinClustersSuccess(clustersData)); + } catch (e) { + yield put(getPinClustersFailure(e)); + yield put(setErrorModal(true)); + return; + } + + try { + const heatmapData = yield call(fetchHeatmap, filters); + yield put(getHeatmapSuccess(heatmapData)); + } catch (e) { + yield put(getHeatmapFailure(e)); + yield put(setErrorModal(true)); + } +} + +function* getVisData() { const filters = yield getFilters(); try { - const data = yield call(getAll, filters); - yield put(getDataSuccess(data)); + const data = yield call(fetchVisData, filters); + yield put(getVisDataSuccess(data)); yield put(showDataCharts(true)); } catch (e) { - yield put(getDataFailure(e)); + yield put(getVisDataFailure(e)); + yield put(setErrorModal(true)); + } +} + +function* updatePinClusters() { + const filters = yield getFilters(); + + if ( + !filters.startDate + || !filters.endDate + || !filters.ncList.length + || !filters.requestTypes.length + ) return; + + const mapPosition = yield getMapPosition(); + try { + const data = yield call(fetchPinClusters, filters, mapPosition); + yield put(getPinClustersSuccess(data)); + } catch (e) { + yield put(getPinClustersFailure(e)); yield put(setErrorModal(true)); } } @@ -168,7 +242,9 @@ function* sendContactData(action) { } export default function* rootSaga() { - yield takeLatest(types.GET_DATA_REQUEST, getData); + yield takeLatest(types.GET_DATA_REQUEST, getMapData); + yield takeLatest(types.GET_DATA_REQUEST, getVisData); + yield takeLatest(uiTypes.UPDATE_MAP_POSITION, updatePinClusters); yield takeEvery(types.GET_PIN_INFO_REQUEST, getPinData); yield takeLatest(types.SEND_GIT_REQUEST, sendContactData); }