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);
}