diff --git a/.eslintrc.js b/.eslintrc.js
index 2f93580a2..e053723a1 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -21,6 +21,15 @@ module.exports = {
ecmaVersion: 2018,
sourceType: 'module',
},
+ settings: {
+ "import/extensions": [
+ ".js",
+ ".jsx"
+ ],
+ 'import/resolver': {
+ webpack: "webpack.config.js",
+ },
+ },
plugins: [
'react',
'react-hooks'
@@ -28,6 +37,7 @@ module.exports = {
rules: {
'linebreak-style': 'off',
"react-hooks/rules-of-hooks": "error",
- "react-hooks/exhaustive-deps": "warn"
+ "react-hooks/exhaustive-deps": "warn",
+ "arrow-parens": ["error", "as-needed"]
},
};
diff --git a/.example.env b/.example.env
index 05872ca35..39d38b052 100644
--- a/.example.env
+++ b/.example.env
@@ -1 +1,2 @@
REACT_APP_MAPBOX_TOKEN=REDACTED
+DB_URL=REDACTED
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/blank-issue.md b/.github/ISSUE_TEMPLATE/blank-issue.md
new file mode 100644
index 000000000..a3c7c04f0
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/blank-issue.md
@@ -0,0 +1,14 @@
+---
+name: Blank Issue
+about: Describe this issue's purpose here
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+#Description
+
+## Action Items
+
+## Resources
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 000000000..f3d5c415e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,38 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: bug
+assignees: ''
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Desktop (please complete the following information):**
+ - OS: [e.g. iOS]
+ - Browser [e.g. chrome, safari]
+ - Version [e.g. 22]
+
+**Smartphone (please complete the following information):**
+ - Device: [e.g. iPhone6]
+ - OS: [e.g. iOS8.1]
+ - Browser [e.g. stock browser, safari]
+ - Version [e.g. 22]
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 000000000..60b710323
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,17 @@
+---
+name: Feature request
+about: Describe a new feature for the project
+title: "[FEAT]"
+labels: ''
+assignees: ''
+
+---
+
+### Overview
+REPLACE THIS TEXT -General overview of the feature
+
+### Action Items
+REPLACE THIS TEXT -If the issue has already been researched, and the course of action is clear, this will describe the steps. However, if the steps can be divided into tasks for more than one person, we recommend dividing it up into separate issues, or assigning it as a pair programming task.
+
+### Resources/Instructions
+REPLACE THIS TEXT -If there is a website which has documentation that helps with this issue provide the link(s) here.
diff --git a/.github/ISSUE_TEMPLATE/new-backend-service-request.md b/.github/ISSUE_TEMPLATE/new-backend-service-request.md
new file mode 100644
index 000000000..df041954b
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/new-backend-service-request.md
@@ -0,0 +1,62 @@
+---
+name: New Backend Service request
+about: Describe the inputs and outputs of a frontend request to the backend
+title: "[Service] Enter service name here"
+labels: backend
+assignees: ''
+
+---
+
+# Description
+Describe the new service purpose here
+
+## Endpoint
+Check or uncheck the supported methods
+Accepted methods
+ - [ ] GET
+ - [ ] POST
+
+Path: ```server:port/{ENTER PATH HERE}```
+
+## Inputs
+Describe a quick overview of the inputs here
+**Use JSON code flags to describe the json payload**
+```json
+{
+ "startDate":"2015-01-01",
+ "endDate":"2015-12-31",
+ "ncList": ["SUNLAND-TUJUNGA NC"],
+ "requestTypes":["Homeless Encampment"]
+}
+```
+## Outputs
+Describe a quick overview of the expected outputs here
+**Use JSON code flags to describe the json payload**
+```json
+{
+ "lastPulled": "NOW",
+ "data": [
+ {
+ "ncname": "SUNLAND-TUJUNGA NC",
+ "requesttype": "Homeless Encampment",
+ "srnumber": "1-79371671",
+ "latitude": 34.2500573562,
+ "longitude": -118.285967224,
+ "address": "TUJUNGA CANYON BLVD AT PINEWOOD AVE, 91042",
+ "createddate": 1449835131
+ },
+ {
+ "ncname": "SUNLAND-TUJUNGA NC",
+ "requesttype": "Homeless Encampment",
+ "srnumber": "1-75982851",
+ "latitude": 34.2480072639,
+ "longitude": -118.285966934,
+ "address": "PINEWOOD AVE AT FOOTHILL BLVD, 91042",
+ "createddate": 1449245408
+ },
+ ]
+}
+```
+
+**Additional context**
+Add any other context about the feature request here.
diff --git a/bin/checkEnv.js b/bin/checkEnv.js
new file mode 100644
index 000000000..f659a6ca1
--- /dev/null
+++ b/bin/checkEnv.js
@@ -0,0 +1,23 @@
+
+// verifies that the .env file contains all the keys in .example.env
+
+const fs = require('fs');
+const dotenv = require('dotenv');
+
+function getKeys(fileName) {
+ try {
+ return Object.keys(dotenv.parse(fs.readFileSync(fileName)));
+ } catch {
+ console.log(`Your ${fileName} file does not exist or is incorrectly formatted.\n`);
+ process.exit(1);
+ }
+}
+
+const envKeys = getKeys('.env'),
+ exampleKeys = getKeys('.example.env'),
+ missingKeys = exampleKeys.filter(key => !envKeys.includes(key));
+
+if (missingKeys.length > 0) {
+ console.error('You are missing these keys in your .env file:', missingKeys, '\n');
+ process.exit(1);
+}
diff --git a/package.json b/package.json
index 72795cc08..8232ca517 100644
--- a/package.json
+++ b/package.json
@@ -8,9 +8,13 @@
"bulma": "^0.8.0",
"bulma-checkradio": "^1.1.1",
"bulma-switch": "^2.0.0",
+ "chart.js": "^2.9.3",
+ "chartjs-chart-box-and-violin-plot": "^2.2.0",
"classnames": "^2.2.6",
"dataframe-js": "^1.4.3",
+ "dotenv": "^8.2.0",
"dotenv-webpack": "^1.7.0",
+ "eslint-import-resolver-webpack": "^0.12.1",
"gh-pages": "^2.1.1",
"html-webpack-plugin": "^3.2.0",
"jest": "^24.9.0",
@@ -30,16 +34,19 @@
"redux": "^4.0.4",
"redux-devtools-extension": "^2.13.8",
"redux-logger": "^3.0.6",
+ "redux-saga": "^1.1.3",
+ "regenerator-runtime": "^0.13.3",
"webpack-merge": "^4.2.2"
},
"scripts": {
- "start": "npm run dev",
+ "start": "npm run check-env && npm run dev",
"dev": "webpack-dev-server --config webpack.dev.js --host 0.0.0.0",
"build": "webpack --config webpack.prod.js",
- "lint": "eslint src/**/*.js*",
+ "lint": "eslint 'src/**/*.js*'",
"test": "jest --passWithNoTests",
"predeploy": "npm run build",
- "deploy": "gh-pages -d dist"
+ "deploy": "gh-pages -d dist",
+ "check-env": "node ./bin/checkEnv"
},
"devDependencies": {
"@babel/core": "^7.8.4",
diff --git a/public/index.html b/public/index.html
index 3997002d4..39a8ee63f 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,5 +1,5 @@
-
+
diff --git a/server.js b/server.js
index c12bbd50c..b56eb49ad 100644
--- a/server.js
+++ b/server.js
@@ -1,15 +1,16 @@
const express = require('express');
const path = require('path');
+
const port = process.env.PORT || 3000;
const app = express();
// the __dirname is the current directory from where the script is running
-app.use(express.static(__dirname + '/dist'));
+app.use(express.static(path.join(__dirname, '/dist')));
// send the user to index html page inspite of the url
app.get('*', (req, res) => {
res.sendFile(path.resolve(__dirname, 'public/index.html'));
});
-console.log(`Listening on port ${port}`)
+console.log(`Listening on port ${port}`);
app.listen(port);
diff --git a/server/requirements.txt b/server/requirements.txt
index b0ea661a1..55f85d080 100644
--- a/server/requirements.txt
+++ b/server/requirements.txt
@@ -32,6 +32,7 @@ requests-async==0.5.0
rfc3986==1.3.2
sanic==19.9.0
Sanic-Cors==0.10.0.post3
+sanic-gzip==0.3.0
Sanic-Plugins-Framework==0.9.2
six==1.14.0
sodapy==2.0.0
diff --git a/server/src/app.py b/server/src/app.py
index 4d99c34f0..087b91794 100644
--- a/server/src/app.py
+++ b/server/src/app.py
@@ -2,6 +2,7 @@
from sanic import Sanic
from sanic.response import json
from sanic_cors import CORS
+from sanic_gzip import Compress
from configparser import ConfigParser
from threading import Timer
from datetime import datetime
@@ -10,11 +11,13 @@
from services.time_to_close import time_to_close
from services.frequency import frequency
from services.pinService import PinService
+from services.requestDetailService import RequestDetailService
from services.ingress_service import ingress_service
from services.sqlIngest import DataHandler
app = Sanic(__name__)
CORS(app)
+compress = Compress()
def configure_app():
@@ -31,11 +34,13 @@ def configure_app():
@app.route('/')
+@compress.compress()
async def index(request):
return json('You hit the index')
@app.route('/timetoclose')
+@compress.compress()
async def timetoclose(request):
ttc_worker = time_to_close(app.config['Settings'])
@@ -50,6 +55,7 @@ async def timetoclose(request):
@app.route('/requestfrequency')
+@compress.compress()
async def requestfrequency(request):
freq_worker = frequency(app.config['Settings'])
@@ -61,6 +67,7 @@ async def requestfrequency(request):
@app.route('/sample-data')
+@compress.compress()
async def sample_route(request):
sample_dataset = {'cool_key': ['value1', 'value2'],
app.config['REDACTED']: app.config['REDACTED']}
@@ -68,6 +75,7 @@ async def sample_route(request):
@app.route('/ingest', methods=["POST"])
+@compress.compress()
async def ingest(request):
"""Accept POST requests with a list of years to import.
Query parameter name is 'years', and parameter value is
@@ -89,6 +97,7 @@ async def ingest(request):
@app.route('/update')
+@compress.compress()
async def update(request):
ingress_worker = ingress_service()
return_data = ingress_worker.update()
@@ -96,6 +105,7 @@ async def update(request):
@app.route('/delete')
+@compress.compress()
async def delete(request):
ingress_worker = ingress_service()
return_data = ingress_worker.delete()
@@ -103,6 +113,7 @@ async def delete(request):
@app.route('/pins', methods=["POST"])
+@compress.compress()
async def pinMap(request):
pin_worker = PinService(app.config['Settings'])
postArgs = request.json
@@ -118,7 +129,16 @@ async def pinMap(request):
return json(return_data)
+@app.route('/servicerequest/', methods=["GET"])
+async def requestDetails(request, srnumber):
+ detail_worker = RequestDetailService(app.config['Settings'])
+
+ return_data = await detail_worker.get_request_detail(srnumber)
+ return json(return_data)
+
+
@app.route('/test_multiple_workers')
+@compress.compress()
async def test_multiple_workers(request):
Timer(10.0, print, ["Timer Test."]).start()
return json("Done")
diff --git a/server/src/services/dataService.py b/server/src/services/dataService.py
index 11b22d998..ddf364e4b 100644
--- a/server/src/services/dataService.py
+++ b/server/src/services/dataService.py
@@ -1,18 +1,22 @@
-import sqlalchemy as db
+import datetime
import pandas as pd
+import sqlalchemy as db
class DataService(object):
def includeMeta(func):
- def inner1(*args, **kwargs):
+ def innerFunc(*args, **kwargs):
dataResponse = func(*args, **kwargs)
if 'Error' in dataResponse:
return dataResponse
- withMeta = {'lastPulled': 'NOW', 'data': dataResponse}
+ # Will represent last time the ingest pipeline ran
+ lastPulledTimestamp = datetime.datetime.utcnow()
+ withMeta = {'lastPulled': lastPulledTimestamp,
+ 'data': dataResponse}
return withMeta
- return inner1
+ return innerFunc
def __init__(self, config=None, tableName="ingest_staging_table"):
self.config = config
diff --git a/server/src/services/pinService.py b/server/src/services/pinService.py
index 8938ba488..9754e50b5 100644
--- a/server/src/services/pinService.py
+++ b/server/src/services/pinService.py
@@ -16,25 +16,17 @@ async def get_base_pins(self,
'LastPulled': 'Timestamp',
'data': [
{
- 'ncname':'String',
- 'requesttype':'String',
'srnumber':'String',
'latitude': 'String',
'longitude': 'String',
- 'address': 'String',
- 'createddate': 'Timestamp'
}
]
}
"""
- items = ['ncname',
- 'requesttype',
- 'srnumber',
+ items = ['srnumber',
'latitude',
- 'longitude',
- 'address',
- 'createddate']
+ 'longitude']
ncs = '\'' + '\', \''.join(ncList) + '\''
requests = '\'' + '\', \''.join(requestTypes) + '\''
diff --git a/server/src/services/requestDetailService.py b/server/src/services/requestDetailService.py
new file mode 100644
index 000000000..a01a20d8b
--- /dev/null
+++ b/server/src/services/requestDetailService.py
@@ -0,0 +1,32 @@
+from .dataService import DataService
+
+
+class RequestDetailService(object):
+ def __init__(self, config=None, tableName="ingest_staging_table"):
+ self.dataAccess = DataService(config, tableName)
+
+ async def get_request_detail(self, requestNumber=None):
+ """
+ Returns all properties tied to a service request given the srNumber
+ {
+ 'LastPulled': 'Timestamp',
+ 'data': {
+ 'ncname':'String',
+ 'requesttype':'String',
+ 'srnumber':'String',
+ 'latitude': 'String',
+ 'longitude': 'String',
+ 'address': 'String',
+ 'createddate': 'Timestamp'
+ .
+ .
+ .
+ }
+ }
+ """
+
+ items = ['*']
+ filters = ['srnumber = \'{}\''.format(requestNumber)]
+ result = self.dataAccess.query(items, filters)
+
+ return result
diff --git a/src/App.jsx b/src/App.jsx
index 06b378b8c..a56d1914d 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -12,12 +12,12 @@ const App = () => {
}, []);
return (
-
+ <>
-
+ >
);
};
diff --git a/src/Util/DataService.js b/src/Util/DataService.js
index 5179c250e..bf95a1d84 100644
--- a/src/Util/DataService.js
+++ b/src/Util/DataService.js
@@ -51,12 +51,12 @@ export function getBroadCallVolume(year, startMonth = 0, endMonth = 13, onBroadD
const end = Math.max(startMonth, endMonth);
DataFrame.fromJSON(`https://data.lacity.org/resource/${dataResources[year]}.json?$select=count(*)+AS+CallVolume,NCName,RequestType&$where=date_extract_m(CreatedDate)+between+${start}+and+${end}&$group=NCName,RequestType&$order=CallVolume DESC`)
- .then((df) => {
+ .then(df => {
df.show();
- const totalCounts = df.groupBy('ncname').aggregate((group) => group.stat.sum('callvolume')).rename('aggregation', 'callvolume');
+ const totalCounts = df.groupBy('ncname').aggregate(group => group.stat.sum('callvolume')).rename('aggregation', 'callvolume');
const biggestProblems = {};
- df.toCollection().forEach((row) => {
+ df.toCollection().forEach(row => {
const rhs = parseInt(row.callvolume, 10);
const lhs = parseInt(biggestProblems[row.ncname], 10);
if (!lhs) {
@@ -68,7 +68,7 @@ export function getBroadCallVolume(year, startMonth = 0, endMonth = 13, onBroadD
}
});
const colorMap = getColorMap(false);
- totalCounts.toCollection().forEach((row) => {
+ totalCounts.toCollection().forEach(row => {
const biggestProblem = biggestProblems[`${row.ncname}_biggestproblem`];
const dataPoint = {
title: row.ncname,
@@ -92,9 +92,9 @@ export function getZoomedCallVolume(
const start = Math.min(startMonth, endMonth);
const end = Math.max(startMonth, endMonth);
- DataFrame.fromJSON(`https://data.lacity.org/resource/${dataResources[year]}.json?$select=count(*)+AS+CallVolume,NCName,RequestType&$where=NCName+=+'${ncName}'+and+date_extract_m(CreatedDate)+between+${start}+and+${end}&$group=NCName,RequestType&$order=CallVolume DESC`).then((df) => {
+ DataFrame.fromJSON(`https://data.lacity.org/resource/${dataResources[year]}.json?$select=count(*)+AS+CallVolume,NCName,RequestType&$where=NCName+=+'${ncName}'+and+date_extract_m(CreatedDate)+between+${start}+and+${end}&$group=NCName,RequestType&$order=CallVolume DESC`).then(df => {
const colorMap = getColorMap(false);
- df.toCollection().forEach((row) => {
+ df.toCollection().forEach(row => {
const dataPoint = {
title: row.requesttype,
color: colorMap[row.requesttype],
diff --git a/src/components/PinMap/PinMap.jsx b/src/components/PinMap/PinMap.jsx
index 7f1c1de8f..3b1f40607 100644
--- a/src/components/PinMap/PinMap.jsx
+++ b/src/components/PinMap/PinMap.jsx
@@ -1,6 +1,12 @@
import React, { Component } from 'react';
+import { connect } from 'react-redux';
import {
- Map, Marker, Popup, TileLayer, Rectangle, Tooltip,
+ Map,
+ Marker,
+ Popup,
+ TileLayer,
+ Rectangle,
+ Tooltip,
} from 'react-leaflet';
import Choropleth from 'react-leaflet-choropleth';
import PropTypes from 'proptypes';
@@ -10,27 +16,20 @@ import PropTypes from 'proptypes';
// import councilDistrictsOverlay from '../../data/la-city-council-districts-2012.json';
import ncOverlay from '../../data/nc-boundary-2019.json';
-const pinMapProps = {
- data: PropTypes.string,
- showMarkers: PropTypes.boolean,
-};
-
-
class PinMap extends Component {
constructor(props) {
super(props);
this.state = {
- position: [34.0173157, -118.2497254],
+ position: [34.0094213, -118.6008506],
zoom: 10,
mapUrl: `https://api.tiles.mapbox.com/v4/mapbox.streets/{z}/{x}/{y}.png?access_token=${process.env.REACT_APP_MAPBOX_TOKEN}`,
- // dataUrl: 'https://data.lacity.org/resource/h65r-yf5i.json?$select=location,zipcode,address,requesttype,status,ncname,streetname,housenumber&$where=date_extract_m(CreatedDate)+between+2+and+3',
geoJSON: ncOverlay,
bounds: null,
};
}
- highlightRegion = (e) => {
+ highlightRegion = e => {
const layer = e.target;
layer.setStyle({
@@ -43,7 +42,7 @@ class PinMap extends Component {
layer.bringToFront();
}
- resetRegionHighlight = (e) => {
+ resetRegionHighlight = e => {
const layer = e.target;
layer.setStyle({
@@ -56,7 +55,7 @@ class PinMap extends Component {
});
}
- zoomToRegion = (e) => {
+ zoomToRegion = e => {
const bounds = e.target.getBounds();
this.setState({ bounds });
}
@@ -96,7 +95,7 @@ class PinMap extends Component {
fillOpacity: 0.7,
}}
onEachFeature={this.onEachFeature}
- ref={(el) => {
+ ref={el => {
if (el) {
this.choropleth = el.leafletElement;
return this.choropleth;
@@ -115,13 +114,12 @@ class PinMap extends Component {
const { data, showMarkers } = this.props;
if (showMarkers && data) {
- return data.map((d) => {
- if (d.location) {
- const { location } = d;
- const position = [location.latitude, location.longitude];
+ return data.map(d => {
+ if (d.latitude && d.longitude) {
+ const position = [d.latitude, d.longitude];
return (
-
+
Type:
{d.requesttype}
@@ -141,7 +139,12 @@ class PinMap extends Component {
return (
-
+
No Data To Display
@@ -162,7 +165,7 @@ class PinMap extends Component {
center={position}
zoom={zoom}
bounds={bounds}
- style={{ height: '85vh' }}
+ style={{ height: '88.4vh' }}
>
({
+ data: state.data.data,
+});
+
+PinMap.propTypes = {
+ data: PropTypes.arrayOf(PropTypes.shape({})),
+ showMarkers: PropTypes.bool,
+};
+
PinMap.defaultProps = {
- data: '',
+ data: undefined,
showMarkers: true,
};
-PinMap.propTypes = pinMapProps;
-
-export default PinMap;
+export default connect(mapStateToProps, null)(PinMap);
diff --git a/src/components/Visualizations/Chart.jsx b/src/components/Visualizations/Chart.jsx
new file mode 100644
index 000000000..0773025de
--- /dev/null
+++ b/src/components/Visualizations/Chart.jsx
@@ -0,0 +1,97 @@
+import React from 'react';
+import PropTypes from 'proptypes';
+import Chart from 'chart.js';
+import 'chartjs-chart-box-and-violin-plot';
+import COLORS from '@styles/COLORS';
+
+// ///////// CHARTJS DEFAULTS ///////////
+
+Object.assign(Chart.defaults.global, {
+ defaultFontColor: COLORS.FONTS,
+ defaultFontFamily: 'Roboto',
+ animation: false,
+ responsive: true,
+ maintainAspectRatio: false,
+ legend: false,
+});
+
+Object.assign(Chart.defaults.global.title, {
+ display: true,
+ fontFamily: 'Open Sans',
+ fontSize: 20,
+});
+
+Object.assign(Chart.defaults.scale.scaleLabel, {
+ display: true,
+ fontFamily: 'Open Sans',
+ fontWeight: 'bold',
+ fontSize: 15,
+});
+
+Object.assign(Chart.defaults.global.tooltips, {
+ xPadding: 10,
+ yPadding: 10,
+ bodyFontFamily: 'Roboto',
+ bodyFontSize: 14,
+ bodyFontColor: COLORS.FONTS,
+ backgroundColor: 'rgb(200, 200, 200)',
+ cornerRadius: 4,
+});
+
+// //////////// COMPONENT //////////////
+
+class ReactChart extends React.Component {
+ canvasRef = React.createRef();
+
+ componentDidMount() {
+ const { type, data, options } = this.props;
+ const ctx = this.canvasRef.current.getContext('2d');
+ this.chart = new Chart(ctx, {
+ type,
+ data,
+ options,
+ });
+ this.setHeight();
+ }
+
+ componentDidUpdate(prevProps) {
+ const { data } = this.props;
+
+ if (prevProps.data !== data) {
+ this.chart.data = data;
+ this.chart.update();
+ this.setHeight();
+ }
+ }
+
+ setHeight = () => {
+ const { height } = this.props;
+
+ if (height) {
+ const numLabels = this.chart.data.labels.length;
+ const heightPx = height(numLabels);
+ this.canvasRef.current.parentNode.style.height = `${heightPx}px`;
+ }
+ }
+
+ render() {
+ return (
+
+
+
+ );
+ }
+}
+
+export default ReactChart;
+
+ReactChart.propTypes = {
+ type: PropTypes.string.isRequired,
+ data: PropTypes.shape.isRequired,
+ options: PropTypes.shape.isRequired,
+ height: PropTypes.func,
+};
+
+ReactChart.defaultProps = {
+ height: undefined,
+};
diff --git a/src/components/Visualizations/Criteria.jsx b/src/components/Visualizations/Criteria.jsx
new file mode 100644
index 000000000..acd7e442d
--- /dev/null
+++ b/src/components/Visualizations/Criteria.jsx
@@ -0,0 +1,56 @@
+import React from 'react';
+import PropTypes from 'proptypes';
+import { connect } from 'react-redux';
+
+const Criteria = ({
+ startDate,
+ endDate,
+ councils,
+}) => {
+ const dateText = startDate && endDate
+ ? `From ${startDate} to ${endDate}`
+ : 'No date range selected.';
+
+ const councilsText = councils.length > 0
+ ? councils.join('; ')
+ : 'No councils selected.';
+
+ return (
+
+
Criteria
+
+
+
+ Date Range
+
+ { dateText }
+
+
+
+ Neighborhood Council District
+
+ { councilsText }
+
+
+
+ );
+};
+
+const mapStateToProps = state => ({
+ startDate: state.data.startDate,
+ endDate: state.data.endDate,
+ councils: state.data.councils,
+});
+
+export default connect(mapStateToProps)(Criteria);
+
+Criteria.propTypes = {
+ startDate: PropTypes.string,
+ endDate: PropTypes.string,
+ councils: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
+};
+
+Criteria.defaultProps = {
+ startDate: undefined,
+ endDate: undefined,
+};
diff --git a/src/components/Visualizations/Frequency.jsx b/src/components/Visualizations/Frequency.jsx
new file mode 100644
index 000000000..ab9f2cec3
--- /dev/null
+++ b/src/components/Visualizations/Frequency.jsx
@@ -0,0 +1,98 @@
+import React from 'react';
+import PropTypes from 'proptypes';
+import { connect } from 'react-redux';
+import { REQUEST_TYPES } from '@components/common/CONSTANTS';
+import moment from 'moment';
+import Chart from './Chart';
+
+const Frequency = ({
+ requestTypes,
+}) => {
+ // // DATA ////
+
+ const randomPoints = (count, min, max) => Array.from({ length: count })
+ .map((el, idx) => ({
+ x: moment().add(idx, 'd').toDate(),
+ y: Math.round(Math.random() * (max - min) + min),
+ }));
+
+ const dummyData = REQUEST_TYPES.reduce((p, c) => {
+ const acc = p;
+ acc[c.type] = randomPoints(10, 20, 200);
+ return acc;
+ }, {});
+
+ const selectedTypes = REQUEST_TYPES.filter(el => requestTypes[el.type]);
+
+ const chartData = {
+ datasets: selectedTypes.map(t => ({
+ label: `${t.abbrev} requests`,
+ backgroundColor: t.color,
+ borderColor: t.color,
+ fill: false,
+ lineTension: 0,
+ data: dummyData[t.type],
+ })),
+ };
+
+ // // OPTIONS ////
+
+ const chartOptions = {
+ aspectRatio: 0.7,
+ title: {
+ text: 'Frequency',
+ fontSize: 20,
+ },
+ scales: {
+ xAxes: [{
+ type: 'time',
+ time: {
+ unit: 'day',
+ round: 'day',
+ },
+ scaleLabel: {
+ labelString: 'Timeline',
+ },
+ ticks: {
+ minRotation: 45,
+ maxRotation: 45,
+ },
+ }],
+ yAxes: [{
+ scaleLabel: {
+ labelString: '# of Requests',
+ },
+ ticks: {
+ beginAtZero: true,
+ },
+ }],
+ },
+ tooltips: {
+ callbacks: {
+ title: () => null,
+ },
+ },
+ };
+
+ if (chartData.datasets.length === 0) return null;
+
+ return (
+
+
+
+ );
+};
+
+const mapStateToProps = state => ({
+ requestTypes: state.data.requestTypes,
+});
+
+export default connect(mapStateToProps)(Frequency);
+
+Frequency.propTypes = {
+ requestTypes: PropTypes.shape({}).isRequired,
+};
diff --git a/src/components/Visualizations/Legend.jsx b/src/components/Visualizations/Legend.jsx
new file mode 100644
index 000000000..451f4a613
--- /dev/null
+++ b/src/components/Visualizations/Legend.jsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import PropTypes from 'proptypes';
+import { connect } from 'react-redux';
+import { REQUEST_TYPES } from '@components/common/CONSTANTS';
+import Icon from '@components/common/Icon';
+
+const Legend = ({
+ requestTypes,
+}) => {
+ const selectedTypes = REQUEST_TYPES.filter(el => requestTypes[el.type]);
+
+ return (
+
+
Legend
+
+ {
+ selectedTypes.length > 0
+ ? selectedTypes.map(({ type, color, abbrev }) => (
+
+
+ { type }
+ {' '}
+ [
+ {abbrev}
+ ]
+
+ ))
+ : (
+
+ No request types selected.
+
+ )
+ }
+
+
+ );
+};
+
+const mapStateToProps = state => ({
+ requestTypes: state.data.requestTypes,
+});
+
+export default connect(mapStateToProps)(Legend);
+
+Legend.propTypes = {
+ requestTypes: PropTypes.shape({}).isRequired,
+};
diff --git a/src/components/Visualizations/NumberOfRequests.jsx b/src/components/Visualizations/NumberOfRequests.jsx
new file mode 100644
index 000000000..dd84e7c40
--- /dev/null
+++ b/src/components/Visualizations/NumberOfRequests.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import PropTypes from 'proptypes';
+
+function addCommas(num) {
+ return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
+}
+
+const NumberOfRequests = ({
+ numRequests,
+}) => (
+
+
Number of Requests
+
+
+ { addCommas(numRequests) }
+
+
+
+);
+
+export default NumberOfRequests;
+
+NumberOfRequests.propTypes = {
+ numRequests: PropTypes.number,
+};
+
+NumberOfRequests.defaultProps = {
+ numRequests: 1285203, // until we get data
+};
diff --git a/src/components/Visualizations/TimeToClose.jsx b/src/components/Visualizations/TimeToClose.jsx
new file mode 100644
index 000000000..bd8f17b8f
--- /dev/null
+++ b/src/components/Visualizations/TimeToClose.jsx
@@ -0,0 +1,94 @@
+import React from 'react';
+import PropTypes from 'proptypes';
+import { connect } from 'react-redux';
+import { REQUEST_TYPES } from '@components/common/CONSTANTS';
+import Chart from './Chart';
+
+
+const TimeToClose = ({
+ requestTypes,
+}) => {
+ // // DATA ////
+
+ const randomSeries = (count, min, max) => Array.from({ length: count })
+ .map(() => Math.random() * (max - min) + min);
+
+ const dummyData = REQUEST_TYPES.reduce((p, c) => {
+ const acc = p;
+ acc[c.type] = randomSeries(8, 0, 11);
+ return acc;
+ }, {});
+
+ const selectedTypes = REQUEST_TYPES.filter(el => requestTypes[el.type]);
+
+ const chartData = {
+ labels: selectedTypes.map(t => t.abbrev),
+ datasets: [{
+ data: selectedTypes.map(t => dummyData[t.type]),
+ backgroundColor: selectedTypes.map(t => t.color),
+ borderColor: '#000',
+ borderWidth: 1,
+ }],
+ };
+
+ // // OPTIONS ////
+
+ const chartOptions = {
+ title: {
+ text: 'Time to Close',
+ },
+ scales: {
+ xAxes: [{
+ scaleLabel: {
+ display: true,
+ labelString: 'Days',
+ },
+ ticks: {
+ beginAtZero: true,
+ stepSize: 1,
+ coef: 0,
+ },
+ }],
+ yAxes: [{
+ scaleLabel: {
+ labelString: 'Type of Request',
+ },
+ }],
+ },
+ tooltips: {
+ callbacks: {
+ title: () => null,
+ },
+ },
+ tooltipDecimals: 1,
+ };
+
+ // // HEIGHT ////
+
+ const chartHeight = numLabels => (
+ numLabels > 0
+ ? 100 + (numLabels * 40)
+ : 0
+ );
+
+ return (
+
+
+
+ );
+};
+
+const mapStateToProps = state => ({
+ requestTypes: state.data.requestTypes,
+});
+
+export default connect(mapStateToProps)(TimeToClose);
+
+TimeToClose.propTypes = {
+ requestTypes: PropTypes.shape({}).isRequired,
+};
diff --git a/src/components/Visualizations/index.jsx b/src/components/Visualizations/index.jsx
new file mode 100644
index 000000000..029221dca
--- /dev/null
+++ b/src/components/Visualizations/index.jsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'proptypes';
+import { connect } from 'react-redux';
+import clx from 'classnames';
+import { MENU_TABS } from '@components/common/CONSTANTS';
+import Criteria from './Criteria';
+import Legend from './Legend';
+import NumberOfRequests from './NumberOfRequests';
+import TimeToClose from './TimeToClose';
+import Frequency from './Frequency';
+
+const Visualizations = ({
+ menuIsOpen,
+ menuActiveTab,
+}) => {
+ if (menuActiveTab !== MENU_TABS.VISUALIZATIONS) return null;
+
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+const mapStateToProps = state => ({
+ menuIsOpen: state.ui.menu.isOpen,
+ menuActiveTab: state.ui.menu.activeTab,
+});
+
+export default connect(mapStateToProps)(Visualizations);
+
+Visualizations.propTypes = {
+ menuIsOpen: PropTypes.bool.isRequired,
+ menuActiveTab: PropTypes.string.isRequired,
+};
diff --git a/src/components/common/CONSTANTS.js b/src/components/common/CONSTANTS.js
index 16e0e631b..2c323531a 100644
--- a/src/components/common/CONSTANTS.js
+++ b/src/components/common/CONSTANTS.js
@@ -205,3 +205,8 @@ export const COUNCILS = [
'WOODLAND HILLS-WARNER CENTER NC',
'ZAPATA KING NC',
];
+
+export const MENU_TABS = {
+ MAP: 'Map',
+ VISUALIZATIONS: 'Data Visualization',
+};
diff --git a/src/components/common/Dropdown.jsx b/src/components/common/Dropdown.jsx
index 6ff448565..f174f2307 100644
--- a/src/components/common/Dropdown.jsx
+++ b/src/components/common/Dropdown.jsx
@@ -29,7 +29,7 @@ const Dropdown = ({
}, className);
useEffect(() => {
- const handleClickOutside = (e) => {
+ const handleClickOutside = e => {
// Clicked inside dropdown
if (dropdownNode.current.contains(e.target) || !isOpen) {
return;
@@ -38,7 +38,7 @@ const Dropdown = ({
updateIsOpen(false);
};
- const handleEscapeKeydown = (e) => {
+ const handleEscapeKeydown = e => {
// Non-esc key pressed
if (e.keyCode !== 27 || !isOpen) {
return;
@@ -60,16 +60,16 @@ const Dropdown = ({
};
}, [isOpen, currentSelection]);
- const toggleOpen = () => updateIsOpen((prevIsOpen) => !prevIsOpen);
+ const toggleOpen = () => updateIsOpen(prevIsOpen => !prevIsOpen);
- const handleItemClick = (e) => {
+ const handleItemClick = e => {
e.preventDefault();
updateSelection(e.currentTarget.textContent);
updateIsOpen(false);
onClick(e.currentTarget.getAttribute('value'));
};
- const renderDropdownItems = (items) => items.map((item) => (
+ const renderDropdownItems = items => items.map(item => (
(
+ data-place={position}
+ >
{ children }
);
@@ -21,8 +22,12 @@ HoverOverInfo.propTypes = {
title: PropTypes.string,
text: PropTypes.string,
position: PropTypes.oneOf(['top', 'bottom', 'left', 'right']),
+ children: PropTypes.element,
};
HoverOverInfo.defaultProps = {
- position: 'right'
+ title: undefined,
+ text: undefined,
+ position: 'right',
+ children: (),
};
diff --git a/src/components/common/Icon.jsx b/src/components/common/Icon.jsx
index 9ee4ea8a9..52f2280c7 100644
--- a/src/components/common/Icon.jsx
+++ b/src/components/common/Icon.jsx
@@ -1,3 +1,5 @@
+/* eslint-disable jsx-a11y/no-static-element-interactions */
+/* eslint-disable jsx-a11y/click-events-have-key-events */
import React from 'react';
import PropTypes from 'proptypes';
import classNames from 'classnames';
@@ -12,6 +14,7 @@ const Icon = ({
*/
size,
iconSize,
+ iconStyle,
className,
fixedWidth,
spin,
@@ -44,7 +47,7 @@ const Icon = ({
className={containerClassName}
style={style}
>
-
+
{label}
);
@@ -86,6 +89,7 @@ Icon.propTypes = {
pulse: PropTypes.bool,
bordered: PropTypes.bool,
style: PropTypes.shape({}),
+ iconStyle: PropTypes.shape({}),
};
Icon.defaultProps = {
@@ -101,4 +105,5 @@ Icon.defaultProps = {
pulse: false,
bordered: false,
style: undefined,
+ iconStyle: undefined,
};
diff --git a/src/components/main/body/Body.jsx b/src/components/main/body/Body.jsx
index 8b8499990..4893a9bff 100644
--- a/src/components/main/body/Body.jsx
+++ b/src/components/main/body/Body.jsx
@@ -1,12 +1,16 @@
import React from 'react';
+import Visualizations from '@components/Visualizations';
import Menu from '../menu/Menu';
import PinMap from '../../PinMap/PinMap';
const Body = () => (
-
+
);
diff --git a/src/components/main/footer/Footer.jsx b/src/components/main/footer/Footer.jsx
index 6726e0e14..5ff72142b 100644
--- a/src/components/main/footer/Footer.jsx
+++ b/src/components/main/footer/Footer.jsx
@@ -1,4 +1,8 @@
import React from 'react';
+import { connect } from 'react-redux';
+import propTypes from 'proptypes';
+import moment from 'moment';
+
import COLORS from '../../../styles/COLORS';
const footerTextStyle = {
@@ -7,7 +11,9 @@ const footerTextStyle = {
width: '100vw',
};
-const Footer = () => (
+const Footer = ({
+ lastUpdated,
+}) => (
);
-export default Footer;
+const mapStateToProps = state => ({
+ lastUpdated: state.data.lastUpdated,
+});
+
+Footer.propTypes = {
+ lastUpdated: propTypes.string,
+};
+
+Footer.defaultProps = {
+ lastUpdated: undefined,
+};
+
+export default connect(mapStateToProps, null)(Footer);
diff --git a/src/components/main/header/Header.jsx b/src/components/main/header/Header.jsx
index d709ab035..6b70222a1 100644
--- a/src/components/main/header/Header.jsx
+++ b/src/components/main/header/Header.jsx
@@ -1,7 +1,11 @@
import React from 'react';
+import propTypes from 'proptypes';
+import { connect } from 'react-redux';
import COLORS from '../../../styles/COLORS';
-const Header = () => {
+const Header = ({
+ data,
+}) => {
const cta2Style = {
color: COLORS.BRAND.CTA2,
fontWeight: 'bold',
@@ -18,46 +22,86 @@ const Header = () => {
};
return (
-