├── 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 | ![](img/screenshot.png) 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 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | 17 | 18 | 19 | 20 | 21 | 22 | 31 | SpaceTime Reviews 32 | 33 | 34 | 35 | 38 | 39 |
40 | 50 | 51 | 52 | 53 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /src/components/ReviewSummary.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { Bar } from "nivo"; 3 | import { AutoSizer } from "react-virtualized"; 4 | 5 | class ReviewSummary extends Component { 6 | render() { 7 | return ( 8 | 9 | 10 | {({ height, width }) => ( 11 | 108 | )} 109 | 110 | ); 111 | } 112 | } 113 | 114 | export default ReviewSummary; 115 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | animation: App-logo-spin infinite 20s linear; 7 | height: 80px; 8 | } 9 | 10 | .App-header { 11 | background-color: #222; 12 | height: 150px; 13 | padding: 20px; 14 | color: white; 15 | } 16 | 17 | .App-title { 18 | font-size: 1.5em; 19 | } 20 | 21 | .App-intro { 22 | font-size: large; 23 | } 24 | 25 | @keyframes App-logo-spin { 26 | from { transform: rotate(0deg); } 27 | to { transform: rotate(360deg); } 28 | } 29 | 30 | /* /Dashboard */ 31 | 32 | .keen-dashboard { 33 | background: #f2f2f2; 34 | font-family: 'Gotham Rounded SSm A', 'Gotham Rounded SSm B', 'Helvetica Neue', Helvetica, Arial, sans-serif; 35 | } 36 | 37 | .keen-dataviz { 38 | background: #fff; 39 | border: 1px solid #e7e7e7; 40 | border-radius: 2px; 41 | box-sizing: border-box; 42 | } 43 | .keen-dataviz-title { 44 | border-bottom: 1px solid #e7e7e7; 45 | border-radius: 2px 2px 0 0; 46 | font-size: 13px; 47 | padding: 2px 10px 0; 48 | text-transform: uppercase; 49 | } 50 | .keen-dataviz-stage { 51 | padding: 10px; 52 | } 53 | 54 | .keen-dataviz-notes { 55 | background: #fbfbfb; 56 | border-radius: 0 0 2px 2px; 57 | border-top: 1px solid #e7e7e7; 58 | font-family: 'Helvetica Neue', Helvetica, sans-serif; 59 | font-size: 11px; 60 | padding: 0 10px; 61 | } 62 | 63 | .keen-dataviz .keen-dataviz-metric { 64 | border-radius: 2px; 65 | } 66 | 67 | .keen-dataviz .keen-spinner-indicator { 68 | border-top-color: rgba(0, 187, 222, .4); 69 | } 70 | 71 | .keen-dashboard .chart-wrapper { 72 | background: #fff; 73 | border: 1px solid #e2e2e2; 74 | border-radius: 3px; 75 | margin-bottom: 10px; 76 | } 77 | .keen-dashboard .chart-wrapper .chart-title { 78 | border-bottom: 1px solid #d7d7d7; 79 | color: #666; 80 | font-size: 14px; 81 | font-weight: 200; 82 | padding: 7px 10px 4px; 83 | } 84 | 85 | .keen-dashboard .chart-wrapper .chart-stage { 86 | overflow: hidden; 87 | padding: 5px 10px; 88 | position: relative; 89 | } 90 | 91 | .keen-dashboard .chart-wrapper .chart-notes { 92 | background: #fbfbfb; 93 | border-top: 1px solid #e2e2e2; 94 | color: #808080; 95 | font-size: 12px; 96 | padding: 8px 10px 5px; 97 | } 98 | 99 | .keen-dashboard .chart-wrapper .keen-dataviz, 100 | .keen-dashboard .chart-wrapper .keen-dataviz-title, 101 | .keen-dashboard .chart-stage .chart-title { 102 | border: medium none; 103 | } 104 | 105 | /* geo-explorer */ 106 | body.application { 107 | padding: 0; 108 | margin: 0; 109 | } 110 | 111 | .row { 112 | margin: 0; 113 | } 114 | 115 | #app-wrapper { 116 | height: 100%; 117 | position: absolute; 118 | width: 100%; 119 | } 120 | 121 | #app-toolbar { 122 | background: rgba(0,0,0,.75); 123 | box-shadow: 0 1px 5px rgba(0,0,0,.1); 124 | color: #fff; 125 | position: fixed; 126 | top: 0px; 127 | width: 100%; 128 | z-index: 1; 129 | } 130 | 131 | #app-toolbar .btn-primary { 132 | background-color: #00afd7; 133 | border: medium none; 134 | } 135 | #app-toolbar .btn-primary:focus, 136 | #app-toolbar .btn-primary:hover { 137 | background-color: #0098BB; 138 | } 139 | 140 | #app-maparea { 141 | border-right: 1px solid #d7d7d7; 142 | position: fixed; 143 | top: 0px; 144 | width: 67%; 145 | z-index: 0; 146 | height: 100%; 147 | } 148 | 149 | #app-sidebar { 150 | padding: 10px; 151 | position: absolute; 152 | right: 0; 153 | top: 75px; 154 | width: 33%; 155 | } 156 | 157 | .chart-wrapper { 158 | min-height: 400px; 159 | } 160 | .chart-stage { 161 | min-height: 400px; 162 | } 163 | 164 | #app-sidebar .chart-wrapper { 165 | border: medium none; 166 | box-shadow: 0 1px 3px rgba(0,0,0,.1); 167 | } 168 | 169 | .tools { 170 | min-height: 75px; 171 | padding: 0 5px 0 0; 172 | } 173 | 174 | .tool input, .tool select { 175 | background: rgba(0,0,0,.5); 176 | border: medium none; 177 | box-shadow: none; 178 | color: #e7e7e7; 179 | float: left; 180 | margin: 0 1%; 181 | } 182 | .radius input, .radius select { 183 | width: 48%; 184 | } 185 | .tool input, 186 | .tool label, 187 | .tool h5 { 188 | text-shadow: 0 0 1px #000; 189 | } 190 | -------------------------------------------------------------------------------- /src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === "localhost" || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === "[::1]" || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 18 | ) 19 | ); 20 | 21 | export default function register() { 22 | if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener("load", () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (isLocalhost) { 36 | // This is running on localhost. Lets check if a service worker still exists or not. 37 | checkValidServiceWorker(swUrl); 38 | 39 | // Add some additional logging to localhost, pointing developers to the 40 | // service worker/PWA documentation. 41 | navigator.serviceWorker.ready.then(() => { 42 | console.log( 43 | "This web app is being served cache-first by a service " + 44 | "worker. To learn more, visit https://goo.gl/SC7cgQ" 45 | ); 46 | }); 47 | } else { 48 | // Is not local host. Just register service worker 49 | registerValidSW(swUrl); 50 | } 51 | }); 52 | } 53 | } 54 | 55 | function registerValidSW(swUrl) { 56 | navigator.serviceWorker 57 | .register(swUrl) 58 | .then(registration => { 59 | registration.onupdatefound = () => { 60 | const installingWorker = registration.installing; 61 | installingWorker.onstatechange = () => { 62 | if (installingWorker.state === "installed") { 63 | if (navigator.serviceWorker.controller) { 64 | // At this point, the old content will have been purged and 65 | // the fresh content will have been added to the cache. 66 | // It's the perfect time to display a "New content is 67 | // available; please refresh." message in your web app. 68 | console.log("New content is available; please refresh."); 69 | } else { 70 | // At this point, everything has been precached. 71 | // It's the perfect time to display a 72 | // "Content is cached for offline use." message. 73 | console.log("Content is cached for offline use."); 74 | } 75 | } 76 | }; 77 | }; 78 | }) 79 | .catch(error => { 80 | console.error("Error during service worker registration:", error); 81 | }); 82 | } 83 | 84 | function checkValidServiceWorker(swUrl) { 85 | // Check if the service worker can be found. If it can't reload the page. 86 | fetch(swUrl) 87 | .then(response => { 88 | // Ensure service worker exists, and that we really are getting a JS file. 89 | if ( 90 | response.status === 404 || 91 | response.headers.get("content-type").indexOf("javascript") === -1 92 | ) { 93 | // No service worker found. Probably a different app. Reload the page. 94 | navigator.serviceWorker.ready.then(registration => { 95 | registration.unregister().then(() => { 96 | window.location.reload(); 97 | }); 98 | }); 99 | } else { 100 | // Service worker found. Proceed as normal. 101 | registerValidSW(swUrl); 102 | } 103 | }) 104 | .catch(() => { 105 | console.log( 106 | "No internet connection found. App is running in offline mode." 107 | ); 108 | }); 109 | } 110 | 111 | export function unregister() { 112 | if ("serviceWorker" in navigator) { 113 | navigator.serviceWorker.ready.then(registration => { 114 | registration.unregister(); 115 | }); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/components/Map.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import mapboxgl from "mapbox-gl"; 3 | 4 | mapboxgl.accessToken = process.env.REACT_APP_MAPBOX_TOKEN; 5 | 6 | class Map extends Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = { 10 | lng: props.mapCenter.longitude, 11 | lat: props.mapCenter.latitude, 12 | zoom: props.mapCenter.zoom 13 | }; 14 | 15 | this.businessMarkers = []; 16 | } 17 | 18 | // https://stackoverflow.com/questions/37599561/drawing-a-circle-with-the-radius-in-miles-meters-with-mapbox-gl-js 19 | createGeoJSONCircle = (center, radiusInKm, points) => { 20 | if (!points) points = 64; 21 | 22 | var coords = { 23 | latitude: center[1], 24 | longitude: center[0] 25 | }; 26 | 27 | var km = radiusInKm; 28 | 29 | var ret = []; 30 | var distanceX = km / (111.32 * Math.cos(coords.latitude * Math.PI / 180)); 31 | var distanceY = km / 110.574; 32 | 33 | var theta, x, y; 34 | for (var i = 0; i < points; i++) { 35 | theta = i / points * (2 * Math.PI); 36 | x = distanceX * Math.cos(theta); 37 | y = distanceY * Math.sin(theta); 38 | 39 | ret.push([coords.longitude + x, coords.latitude + y]); 40 | } 41 | ret.push(ret[0]); 42 | 43 | return { 44 | type: "geojson", 45 | data: { 46 | type: "FeatureCollection", 47 | features: [ 48 | { 49 | type: "Feature", 50 | geometry: { 51 | type: "Polygon", 52 | coordinates: [ret] 53 | } 54 | } 55 | ] 56 | } 57 | }; 58 | }; 59 | 60 | businessPopupHTML = business => { 61 | return ``; 75 | }; 76 | 77 | setBusinessMarkers() { 78 | const { businesses } = this.props; 79 | this.businessMarkers.map(m => { 80 | m.remove(); 81 | return true; 82 | }); 83 | 84 | this.businessMarkers = businesses.map(b => { 85 | return new mapboxgl.Marker() 86 | .setLngLat([b.location.x, b.location.y]) 87 | .setPopup( 88 | new mapboxgl.Popup({ offset: 25 }).setHTML(this.businessPopupHTML(b)) 89 | ) 90 | .addTo(this.map); 91 | }); 92 | } 93 | 94 | componentDidUpdate() { 95 | this.setBusinessMarkers(); 96 | if (this.mapLoaded) { 97 | this.map 98 | .getSource("polygon") 99 | .setData( 100 | this.createGeoJSONCircle( 101 | [this.props.mapCenter.longitude, this.props.mapCenter.latitude], 102 | this.props.mapCenter.radius 103 | ).data 104 | ); 105 | } 106 | } 107 | 108 | componentDidMount() { 109 | const { lng, lat, zoom } = this.state; 110 | 111 | this.map = new mapboxgl.Map({ 112 | container: this.mapContainer, 113 | style: "mapbox://styles/mapbox/streets-v9", 114 | center: [lng, lat], 115 | zoom 116 | }); 117 | 118 | this.map.on("load", () => { 119 | this.mapLoaded = true; 120 | this.map.addSource( 121 | "polygon", 122 | this.createGeoJSONCircle([lng, lat], this.props.mapCenter.radius) 123 | ); 124 | this.map.addLayer({ 125 | id: "polygon", 126 | type: "fill", 127 | source: "polygon", 128 | layout: {}, 129 | paint: { 130 | "fill-color": "blue", 131 | "fill-opacity": 0.6 132 | } 133 | }); 134 | }); 135 | 136 | const onDragEnd = e => { 137 | var lngLat = e.target.getLngLat(); 138 | 139 | const viewport = { 140 | latitude: lngLat.lat, 141 | longitude: lngLat.lng, 142 | zoom: this.map.getZoom() 143 | }; 144 | this.props.mapSearchPointChange(viewport); 145 | 146 | this.map 147 | .getSource("polygon") 148 | .setData( 149 | this.createGeoJSONCircle( 150 | [lngLat.lng, lngLat.lat], 151 | this.props.mapCenter.radius 152 | ).data 153 | ); 154 | }; 155 | 156 | new mapboxgl.Marker({ color: "red", zIndexOffset: 9999 }) 157 | .setLngLat([lng, lat]) 158 | .addTo(this.map) 159 | .setPopup( 160 | new mapboxgl.Popup().setText( 161 | "Drag me to search for businessees with reviews! Also, try changing the query radius and date range." 162 | ) 163 | ) 164 | .setDraggable(true) 165 | .on("dragend", onDragEnd) 166 | .addTo(this.map) 167 | .togglePopup(); 168 | 169 | this.map.on("move", () => { 170 | const { lng, lat } = this.map.getCenter(); 171 | 172 | this.setState({ 173 | lng: lng, 174 | lat: lat, 175 | zoom: this.map.getZoom().toFixed(2) 176 | }); 177 | }); 178 | 179 | this.setBusinessMarkers(); 180 | } 181 | 182 | render() { 183 | return ( 184 |
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 |
231 |
232 |
233 |
234 |
235 |
Query Radius
236 | 246 | 249 |
250 |
251 | 252 |
253 |
254 |
Latitude
255 | (true)} 263 | /> 264 |
265 |
266 | 267 |
268 |
269 |
Longitude
270 | true} 278 | /> 279 |
280 |
281 | 282 |
283 |
284 |
Start Date
285 | 293 |
294 |
295 | 296 |
297 |
298 |
End Date
299 | 307 |
308 |
309 | 310 |
311 |
312 |
SpaceTime Reviews
313 | Data from Yelp Open Dataset 314 | 317 |
318 |
319 |
320 |
321 |
322 |
323 |
324 | 331 |
332 |
333 | 334 |
335 |
336 |
337 |
338 |
Review Star Summary
339 |
340 | 344 |
345 |
346 | Review stars for businesses in the selected radius and date 347 | range. 348 |
349 |
350 |
351 |
352 |
353 |
354 |
Category Summary
355 |
356 | 357 |
358 |
359 | Business category breakdown for businesses in the selected 360 | radius with reviews in the date range. 361 |
362 |
363 |
364 |
365 |
366 | ); 367 | } 368 | } 369 | 370 | export default App; 371 | --------------------------------------------------------------------------------