├── src
├── index.css
├── index.js
├── App.test.js
├── components
│ ├── CategorySummary.js
│ ├── ReviewSummary.js
│ └── Map.js
├── App.css
├── registerServiceWorker.js
└── App.js
├── img
└── screenshot.png
├── public
├── favicon.ico
├── manifest.json
└── index.html
├── .gitignore
├── package.json
└── README.md
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | font-family: sans-serif;
5 | }
6 |
--------------------------------------------------------------------------------
/img/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/johnymontana/spacetime-reviews/HEAD/img/screenshot.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/johnymontana/spacetime-reviews/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 | import "./index.css";
4 | import App from "./App";
5 | import registerServiceWorker from "./registerServiceWorker";
6 |
7 | ReactDOM.render(, document.getElementById("root"));
8 | registerServiceWorker();
9 |
--------------------------------------------------------------------------------
/src/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 |
5 | it('renders without crashing', () => {
6 | const div = document.createElement('div');
7 | ReactDOM.render(, div);
8 | ReactDOM.unmountComponentAtNode(div);
9 | });
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env.local
15 | .env.development.local
16 | .env.test.local
17 | .env.production.local
18 |
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "spacetime-reviews",
3 | "version": "0.1.0",
4 | "author": "William Lyon",
5 | "private": true,
6 | "dependencies": {
7 | "mapbox-gl": "git+https://git@github.com/johnymontana/mapbox-gl-js.git",
8 | "moment": "^2.22.1",
9 | "neo4j-driver": "^1.6.1",
10 | "nivo": "^0.31.0",
11 | "react": "^16.3.2",
12 | "react-dom": "^16.3.2",
13 | "react-scripts": "1.1.4",
14 | "react-virtualized": "^9.19.0"
15 | },
16 | "scripts": {
17 | "start": "react-scripts start",
18 | "build": "react-scripts build",
19 | "test": "react-scripts test --env=jsdom",
20 | "eject": "react-scripts eject"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## spacetime-reviews
2 |
3 | A React app to demonstrate how to use the spatial and temporal functionality introduced in Neo4j 3.4. It makes use of:
4 |
5 | * [create-react-app](https://github.com/facebook/create-react-app)
6 | * [neo4j-javascript-driver](https://github.com/neo4j/neo4j-javascript-driver)
7 | * [Mapbox GL JS](https://www.mapbox.com/mapbox-gl-js/api/)
8 | * [Nivo charts](http://nivo.rocks/)
9 |
10 | 
11 |
12 | ## Installation
13 |
14 | Set environment variables:
15 |
16 | ```
17 | REACT_APP_NEO4J_URI=XXX
18 | REACT_APP_NEO4J_USER=XXX
19 | REACT_APP_NEO4J_PASSWORD=XXX
20 | REACT_APP_MAPBOX_TOKEN=XXX
21 | ```
22 |
23 | these can be added to `.env`
24 |
25 | Clone this git repo, and then
26 |
27 | ```
28 | npm install
29 | npm start
30 | ```
31 |
32 |
--------------------------------------------------------------------------------
/src/components/CategorySummary.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import { Pie } from "nivo";
3 | import { AutoSizer } from "react-virtualized";
4 |
5 | class CategorySummary extends Component {
6 | render() {
7 | return (
8 |
9 | {({ height, width }) => (
10 |
11 |
53 | )}
54 |
55 | );
56 | }
57 | }
58 |
59 | export default CategorySummary;
60 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
185 |
(this.mapContainer = el)}
187 | className="absolute top right left bottom"
188 | />
189 |
190 | );
191 | }
192 | }
193 |
194 | export default Map;
195 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import "./App.css";
3 | import Map from "./components/Map";
4 | import ReviewSummary from "./components/ReviewSummary";
5 | import CategorySummary from "./components/CategorySummary";
6 | import neo4j from "neo4j-driver/lib/browser/neo4j-web";
7 | import { Date } from "neo4j-driver/lib/v1/temporal-types";
8 | import moment from "moment";
9 |
10 | class App extends Component {
11 | constructor(props) {
12 | super(props);
13 | let focusedInput = null;
14 |
15 | this.state = {
16 | focusedInput,
17 | startDate: moment("2014-01-01"),
18 | endDate: moment("2018-01-01"),
19 | businesses: [],
20 | starsData: [],
21 | reviews: [{ day: "2018-01-01", value: 10 }],
22 | categoryData: [],
23 | selectedBusiness: false,
24 | mapCenter: {
25 | latitude: 33.45891430753237,
26 | longitude: -112.06830118178001,
27 | radius: 1.5,
28 | zoom: 14
29 | }
30 | };
31 |
32 | this.driver = neo4j.driver(
33 | process.env.REACT_APP_NEO4J_URI,
34 | neo4j.auth.basic(
35 | process.env.REACT_APP_NEO4J_USER,
36 | process.env.REACT_APP_NEO4J_PASSWORD
37 | ),
38 | { encrypted: true }
39 | );
40 | this.fetchBusinesses();
41 | this.fetchCategories();
42 | }
43 |
44 | onDatesChange = ({ startDate, endDate }) => {
45 | if (startDate && endDate) {
46 | this.setState(
47 | {
48 | startDate,
49 | endDate
50 | },
51 | () => {
52 | this.fetchBusinesses();
53 | this.fetchCategories();
54 | }
55 | );
56 | } else {
57 | this.setState({
58 | startDate,
59 | endDate
60 | });
61 | }
62 | };
63 |
64 | onFocusChange = focusedInput => this.setState({ focusedInput });
65 |
66 | businessSelected = b => {
67 | this.setState({
68 | selectedBusiness: b
69 | });
70 | };
71 |
72 | mapSearchPointChange = viewport => {
73 | this.setState({
74 | mapCenter: {
75 | ...this.state.mapCenter,
76 | latitude: viewport.latitude,
77 | longitude: viewport.longitude,
78 | zoom: viewport.zoom
79 | }
80 | });
81 | };
82 |
83 | fetchCategories = () => {
84 | const { mapCenter, startDate, endDate } = this.state;
85 | const session = this.driver.session();
86 |
87 | session
88 | .run(
89 | `MATCH (b:Business)<-[:REVIEWS]-(r:Review)
90 | WHERE $start <= r.date <= $end AND distance(b.location, point({latitude: $lat, longitude: $lon})) < ($radius * 1000)
91 | WITH DISTINCT b
92 | OPTIONAL MATCH (b)-[:IN_CATEGORY]->(c:Category)
93 | WITH c.name AS cat, COUNT(b) AS num ORDER BY num DESC LIMIT 25
94 | RETURN COLLECT({id: cat, label: cat, value: toFloat(num)}) AS categoryData
95 | `,
96 | {
97 | lat: mapCenter.latitude,
98 | lon: mapCenter.longitude,
99 | radius: mapCenter.radius,
100 | start: new Date(
101 | startDate.year(),
102 | startDate.month() + 1,
103 | startDate.date()
104 | ),
105 | end: new Date(endDate.year(), endDate.month() + 1, endDate.date())
106 | }
107 | )
108 | .then(result => {
109 | console.log(result);
110 | const categoryData = result.records[0].get("categoryData");
111 | this.setState({
112 | categoryData
113 | });
114 | session.close();
115 | })
116 | .catch(e => {
117 | console.log(e);
118 | session.close();
119 | });
120 | };
121 |
122 | fetchBusinesses = () => {
123 | const { mapCenter, startDate, endDate } = this.state;
124 | const session = this.driver.session();
125 | session
126 | .run(
127 | `
128 | MATCH (b:Business)<-[:REVIEWS]-(r:Review)
129 | WHERE $start <= r.date <= $end AND distance(b.location, point({latitude: $lat, longitude: $lon})) < ( $radius * 1000)
130 | OPTIONAL MATCH (b)-[:IN_CATEGORY]->(c:Category)
131 | WITH r,b, COLLECT(c.name) AS categories
132 | WITH COLLECT(DISTINCT b {.*, categories}) AS businesses, COLLECT(DISTINCT r) AS reviews
133 | UNWIND reviews AS r
134 | WITH businesses, r.stars AS stars, COUNT(r) AS num ORDER BY stars
135 | WITH businesses, COLLECT({stars: toString(stars), count:toFloat(num)}) AS starsData
136 | RETURN businesses, starsData`,
137 | {
138 | lat: mapCenter.latitude,
139 | lon: mapCenter.longitude,
140 | radius: mapCenter.radius,
141 | start: new Date(
142 | startDate.year(),
143 | startDate.month() + 1,
144 | startDate.date()
145 | ),
146 | end: new Date(endDate.year(), endDate.month() + 1, endDate.date())
147 | }
148 | )
149 | .then(result => {
150 | console.log(result);
151 | const record = result.records[0];
152 | const businesses = record.get("businesses");
153 | const starsData = record.get("starsData");
154 |
155 | this.setState({
156 | businesses,
157 | starsData
158 | });
159 | session.close();
160 | })
161 | .catch(e => {
162 | // TODO: handle errors.
163 | console.log(e);
164 | session.close();
165 | });
166 | };
167 |
168 | componentDidUpdate = (prevProps, prevState) => {
169 | if (
170 | this.state.mapCenter.latitude !== prevState.mapCenter.latitude ||
171 | this.state.mapCenter.longitude !== prevState.mapCenter.longitude
172 | ) {
173 | this.fetchBusinesses();
174 | this.fetchCategories();
175 | }
176 | if (
177 | this.state.selectedBusiness &&
178 | (!prevState.selectedBusiness ||
179 | this.state.selectedBusiness.id !== prevState.selectedBusiness.id ||
180 | false ||
181 | false)
182 | ) {
183 | }
184 | };
185 |
186 | handleSubmit = () => {};
187 |
188 | radiusChange = e => {
189 | this.setState(
190 | {
191 | mapCenter: {
192 | ...this.state.mapCenter,
193 | radius: Number(e.target.value)
194 | }
195 | },
196 | () => {
197 | this.fetchBusinesses();
198 | this.fetchCategories();
199 | }
200 | );
201 | };
202 |
203 | dateChange = e => {
204 | if (e.target.id === "timeframe-start") {
205 | this.setState(
206 | {
207 | startDate: moment(e.target.value)
208 | },
209 | () => {
210 | this.fetchBusinesses();
211 | this.fetchCategories();
212 | }
213 | );
214 | } else if (e.target.id === "timeframe-end") {
215 | this.setState(
216 | {
217 | endDate: moment(e.target.value)
218 | },
219 | () => {
220 | this.fetchBusinesses();
221 | this.fetchCategories();
222 | }
223 | );
224 | }
225 | };
226 |
227 | render() {
228 | return (
229 |
230 |
322 |
333 |
334 |
365 |
366 | );
367 | }
368 | }
369 |
370 | export default App;
371 |
--------------------------------------------------------------------------------