├── .npmrc ├── public ├── scene-region.yaml ├── gray.zip ├── logo.png ├── favicon.ico ├── refill-style.zip ├── manifest.json └── index.html ├── .yarnrc ├── src ├── images │ └── logo.psd ├── index.css ├── components │ ├── Sidebar │ │ ├── ExportData │ │ │ ├── ExportData.css │ │ │ └── index.js │ │ ├── Legend │ │ │ ├── Legend.css │ │ │ ├── Legend.test.js │ │ │ └── index.js │ │ ├── AnalysisName │ │ │ ├── AnalysisName.css │ │ │ ├── index.js │ │ │ └── AnalysisName.test.js │ │ ├── TimeFilters │ │ │ ├── TimeFilters.css │ │ │ ├── chart.js │ │ │ └── index.js │ │ ├── ErrorMessage │ │ │ └── index.js │ │ ├── ModeSelect │ │ │ ├── ModeSelect.test.js │ │ │ └── index.js │ │ ├── Sidebar.css │ │ ├── DatePicker │ │ │ ├── DatePicker.css │ │ │ └── index.js │ │ ├── ETAView │ │ │ └── index.js │ │ └── index.js │ ├── Loader │ │ ├── Loader.css │ │ └── index.js │ ├── App.test.js │ ├── Map │ │ ├── Route │ │ │ ├── RouteMarkers │ │ │ │ ├── RouteMarkers.css │ │ │ │ └── index.js │ │ │ ├── RouteLine │ │ │ │ └── index.js │ │ │ └── index.js │ │ ├── test.js │ │ ├── RouteError │ │ │ ├── RouteError.css │ │ │ └── index.js │ │ ├── Map.css │ │ ├── index.js │ │ └── TangramLayer │ │ │ └── index.js │ ├── DevTools │ │ └── index.js │ ├── App.css │ ├── App.js │ ├── MapSearchBar │ │ ├── MapSearchBar.css │ │ └── index.js │ └── MapContainer.js ├── store │ ├── actions │ │ ├── reset.js │ │ ├── coverage.js │ │ ├── tangram.js │ │ ├── loading.js │ │ ├── errors.js │ │ ├── view.js │ │ ├── map.js │ │ ├── date.js │ │ ├── app.js │ │ ├── route.js │ │ ├── index.js │ │ └── barchart.js │ ├── reducers │ │ ├── coverage.js │ │ ├── tangram.js │ │ ├── config.js │ │ ├── index.js │ │ ├── view.js │ │ ├── loading.js │ │ ├── map.js │ │ ├── barchart.js │ │ ├── errors.js │ │ ├── app.js │ │ ├── date.js │ │ └── route.js │ └── index.js ├── test │ └── mocks.js ├── app │ ├── __mocks__ │ │ └── processing.js │ ├── doc-title.js │ ├── __tests__ │ │ ├── update-url.test.js │ │ ├── data.test.js │ │ └── processing.test.js │ ├── route-info.js │ ├── dataGeojson.js │ ├── update-url.js │ ├── route-time.js │ ├── region-bounds.js │ └── region.js ├── lib │ ├── __tests__ │ │ ├── exporter.test.js │ │ ├── geojson.test.js │ │ ├── url-state.test.js │ │ ├── route-segments.test.js │ │ ├── valhalla.test.js │ │ └── tiles.test.js │ ├── geojson.js │ ├── routing.js │ ├── fetch-utils.js │ ├── url-state.js │ ├── route-segments.js │ ├── tangram.js │ ├── color-ramps.js │ ├── exporter.js │ ├── valhalla.js │ └── tiles.js ├── index.js ├── config.js ├── registerServiceWorker.js ├── init.js └── proto │ └── speedtile.proto.json ├── .eslintrc ├── .editorconfig ├── .gitignore ├── circle.yml ├── README.md ├── CONTRIBUTING.md ├── package.json └── LICENSE /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /public/scene-region.yaml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | save-prefix false 2 | -------------------------------------------------------------------------------- /public/gray.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opentraffic/analyst-ui/HEAD/public/gray.zip -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opentraffic/analyst-ui/HEAD/public/logo.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opentraffic/analyst-ui/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/images/logo.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opentraffic/analyst-ui/HEAD/src/images/logo.psd -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /public/refill-style.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opentraffic/analyst-ui/HEAD/public/refill-style.zip -------------------------------------------------------------------------------- /src/components/Sidebar/ExportData/ExportData.css: -------------------------------------------------------------------------------- 1 | .export-message { 2 | margin-top: 1em; 3 | font-weight: bold; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/Sidebar/Legend/Legend.css: -------------------------------------------------------------------------------- 1 | .legend-color { 2 | width: 25px; 3 | height: 25px; 4 | } 5 | 6 | .legend-label { 7 | padding-left: 0.5em; 8 | } 9 | -------------------------------------------------------------------------------- /src/store/actions/reset.js: -------------------------------------------------------------------------------- 1 | import { CLEAR_ANALYSIS } from '../actions' 2 | 3 | export function resetAnalysis () { 4 | return { type: CLEAR_ANALYSIS } 5 | } 6 | -------------------------------------------------------------------------------- /src/components/Sidebar/AnalysisName/AnalysisName.css: -------------------------------------------------------------------------------- 1 | .analysis-name { 2 | font-size: 18px; 3 | font-style: italic; 4 | } 5 | 6 | .analysis-edit-form .input { 7 | width: 100%; 8 | } 9 | -------------------------------------------------------------------------------- /src/store/actions/coverage.js: -------------------------------------------------------------------------------- 1 | import { SET_DATA_GEOJSON } from '../actions' 2 | 3 | export function setDataGeoJSON (geo) { 4 | return { 5 | type: SET_DATA_GEOJSON, 6 | geo 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/store/actions/tangram.js: -------------------------------------------------------------------------------- 1 | import { UPDATE_SCENE } from '../actions' 2 | 3 | export function updateScene (newProps) { 4 | return { 5 | type: UPDATE_SCENE, 6 | scene: newProps 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/test/mocks.js: -------------------------------------------------------------------------------- 1 | 2 | // Cheap stub 3 | export const Tangram = { 4 | leafletLayer: function () { 5 | return { 6 | addTo: function () {}, 7 | _layerAdd: function () {} 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/app/__mocks__/processing.js: -------------------------------------------------------------------------------- 1 | export function getSpeedFromDataTilesForSegmentId () { 2 | return Math.ceil(Math.random() * 100) 3 | } 4 | 5 | export function getNextSegmentDelayFromDataTiles () { 6 | return Math.ceil(Math.random() * 40) 7 | } 8 | -------------------------------------------------------------------------------- /src/components/Sidebar/TimeFilters/TimeFilters.css: -------------------------------------------------------------------------------- 1 | .timefilter-hourly { 2 | margin-top: 1em; 3 | } 4 | 5 | .timefilter-controls { 6 | margin-top: 1em; 7 | } 8 | 9 | /* Overrides on dc.css */ 10 | div.dc-chart { 11 | float: none; 12 | } 13 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "parserOptions": { 4 | "sourceType": "module", 5 | }, 6 | "extends": ["react-app", "standard", "standard-jsx"], 7 | "rules": { 8 | "jsx-quotes": ["error", "prefer-double"] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /src/components/Loader/Loader.css: -------------------------------------------------------------------------------- 1 | .loading-indicator { 2 | position: absolute; 3 | background-color: white; 4 | font-family: Lato; 5 | z-index: 1000; 6 | padding: 3px 8px; 7 | right: 0; 8 | margin: 12px; 9 | border-radius: 2px; 10 | border: solid 1px rgba(0,0,0,0.2); 11 | } 12 | -------------------------------------------------------------------------------- /src/components/Sidebar/ErrorMessage/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Message } from 'semantic-ui-react' 3 | 4 | const ErrorMessage = (props) => ( 5 | 6 | {props.header} 7 |

{props.message}

8 |
9 | ) 10 | 11 | export default ErrorMessage 12 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/components/App.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import React from 'react' 3 | import ReactDOM from 'react-dom' 4 | import App from './App' 5 | import { Tangram } from '../test/mocks' 6 | import 'url-search-params-polyfill' 7 | 8 | it('renders without crashing', () => { 9 | global.Tangram = Tangram 10 | const div = document.createElement('div') 11 | ReactDOM.render(, div) 12 | }) 13 | -------------------------------------------------------------------------------- /src/components/Map/Route/RouteMarkers/RouteMarkers.css: -------------------------------------------------------------------------------- 1 | /* Fix icon positioning from Leaflet.extra-markers package */ 2 | i.icon.map-marker-start, 3 | i.icon.map-marker-end { 4 | font-size: 1.5em; 5 | margin-top: 0.25em; 6 | margin-left: 0.05em; 7 | } 8 | 9 | i.icon.map-marker-middle { 10 | font-size: 0.5em; 11 | margin-top: 2.25em; 12 | margin-left: 0.075em; 13 | opacity: 0.85; 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Sidebar/ModeSelect/ModeSelect.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import React from 'react' 3 | import ReactDOM from 'react-dom' 4 | import { ModeSelect } from './index' 5 | 6 | describe('ModeSelect', () => { 7 | it('renders without crashing', () => { 8 | const div = document.createElement('div') 9 | ReactDOM.render(, div) 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /src/store/actions/loading.js: -------------------------------------------------------------------------------- 1 | import { START_LOADING, STOP_LOADING, HIDE_LOADING } from '../actions' 2 | 3 | export function startLoading () { 4 | return { 5 | type: START_LOADING 6 | } 7 | } 8 | 9 | export function stopLoading () { 10 | return { 11 | type: STOP_LOADING 12 | } 13 | } 14 | 15 | export function hideLoading () { 16 | return { 17 | type: HIDE_LOADING 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.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 | 23 | public/sample-tiles 24 | .vscode -------------------------------------------------------------------------------- /src/components/Map/test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import React from 'react' 3 | import ReactDOM from 'react-dom' 4 | import Map from './' 5 | import { Tangram } from '../../test/mocks' 6 | 7 | it('renders without crashing', () => { 8 | global.Tangram = Tangram 9 | const div = document.createElement('div') 10 | const config = { 11 | mapzen: { apiKey: 'foo' } 12 | } 13 | 14 | ReactDOM.render(, div) 15 | }) 16 | -------------------------------------------------------------------------------- /src/lib/__tests__/exporter.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import { convertArrayOfObjectsToCsv } from '../exporter' 3 | 4 | it('converts an array of objects to CSV', () => { 5 | const objects = [ 6 | { 7 | one: 'hi', 8 | two: 'yes' 9 | }, 10 | { 11 | one: 'there' 12 | } 13 | ] 14 | const result = convertArrayOfObjectsToCsv(objects) 15 | 16 | expect(result).toEqual('one,two\nhi,yes\nthere,\n') 17 | }) 18 | -------------------------------------------------------------------------------- /src/store/reducers/coverage.js: -------------------------------------------------------------------------------- 1 | import { SET_DATA_GEOJSON } from '../actions' 2 | 3 | const initialState = { 4 | dataGeoJSON: null 5 | } 6 | 7 | const coverage = (state = initialState, action) => { 8 | switch (action.type) { 9 | case SET_DATA_GEOJSON: 10 | return { 11 | ...state, 12 | dataGeoJSON: action.geo 13 | } 14 | default: 15 | return state 16 | } 17 | } 18 | 19 | export default coverage 20 | -------------------------------------------------------------------------------- /src/store/actions/errors.js: -------------------------------------------------------------------------------- 1 | import { ADD_ERROR, REMOVE_ERROR, CLEAR_ERRORS } from '../actions' 2 | 3 | // Action creators 4 | export function addError (error) { 5 | return { 6 | type: ADD_ERROR, 7 | error 8 | } 9 | } 10 | 11 | export function removeError (error) { 12 | return { 13 | type: REMOVE_ERROR, 14 | error 15 | } 16 | } 17 | 18 | export function clearErrors () { 19 | return { 20 | type: CLEAR_ERRORS 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/store/actions/view.js: -------------------------------------------------------------------------------- 1 | import { SET_VIEW_BOUNDS, CLEAR_VIEW_BOUNDS, SET_GEOJSON } from '../actions' 2 | 3 | export function setBounds (bounds) { 4 | return { 5 | type: SET_VIEW_BOUNDS, 6 | bounds 7 | } 8 | } 9 | 10 | export function clearBounds () { 11 | return { 12 | type: CLEAR_VIEW_BOUNDS 13 | } 14 | } 15 | 16 | export function setGeoJSON (geo) { 17 | return { 18 | type: SET_GEOJSON, 19 | geoJSON: geo 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/store/reducers/tangram.js: -------------------------------------------------------------------------------- 1 | import { UPDATE_SCENE } from '../actions' 2 | 3 | const initialState = { 4 | scene: {} 5 | } 6 | 7 | const tangram = (state = initialState, action) => { 8 | switch (action.type) { 9 | case UPDATE_SCENE: 10 | return { 11 | ...state, 12 | scene: Object.assign({}, state.scene, action.scene) 13 | } 14 | default: 15 | return state 16 | } 17 | } 18 | 19 | export default tangram 20 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // Polyfills 2 | import 'string.prototype.padstart' 3 | 4 | // React 5 | import React from 'react' 6 | import ReactDOM from 'react-dom' 7 | 8 | // Application 9 | import './index.css' 10 | import App from './components/App' 11 | import registerServiceWorker from './registerServiceWorker' 12 | import { initApp } from './init' 13 | 14 | initApp() 15 | 16 | ReactDOM.render(, document.getElementById('root')) 17 | registerServiceWorker() 18 | -------------------------------------------------------------------------------- /src/components/Map/RouteError/RouteError.css: -------------------------------------------------------------------------------- 1 | .route-error { 2 | position: relative; 3 | top: 60px; 4 | max-width: 350px; 5 | margin: 0 auto; 6 | z-index: 1000; 7 | 8 | /* Use existing Semantic UI font styling; we should import these */ 9 | font-family: Lato,'Helvetica Neue',Arial,Helvetica,sans-serif; 10 | font-size: 14px; 11 | } 12 | 13 | .route-error-message { 14 | margin-right: 1em; 15 | } 16 | 17 | .route-error p { 18 | line-height: 2; 19 | } 20 | -------------------------------------------------------------------------------- /src/store/reducers/config.js: -------------------------------------------------------------------------------- 1 | import initialState from '../../config' 2 | import { SET_MAP_VIEW } from '../actions' 3 | 4 | const config = (state = initialState, action) => { 5 | switch (action.type) { 6 | case SET_MAP_VIEW: 7 | return { 8 | ...state, 9 | map: { 10 | center: action.coordinates, 11 | zoom: action.zoom 12 | } 13 | } 14 | default: 15 | return state 16 | } 17 | } 18 | 19 | export default config 20 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from 'redux' 2 | import { persistState } from 'redux-devtools' 3 | import thunk from 'redux-thunk' 4 | 5 | import DevTools from '../components/DevTools' 6 | import reducers from './reducers' 7 | 8 | const store = createStore(reducers, compose( 9 | applyMiddleware(thunk), 10 | DevTools.instrument(), 11 | persistState(window.location.href.match(/[?&]debug_session=([^&]+)\b/)) 12 | )) 13 | 14 | export default store 15 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | # Using Yarn, rather than NPM. See https://circleci.com/docs/1.0/yarn/ 2 | 3 | machine: 4 | node: 5 | version: 8.2.0 6 | environment: 7 | PATH: "${PATH}:${HOME}/${CIRCLE_PROJECT_REPONAME}/node_modules/.bin" 8 | 9 | dependencies: 10 | override: 11 | - yarn install 12 | cache_directories: 13 | - ~/.cache/yarn 14 | 15 | test: 16 | override: 17 | - yarn test # run all tests, including the linter 18 | - yarn build # make sure we can successfully build 19 | -------------------------------------------------------------------------------- /src/components/DevTools/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { createDevTools } from 'redux-devtools' 3 | import LogMonitor from 'redux-devtools-log-monitor' 4 | import DockMonitor from 'redux-devtools-dock-monitor' 5 | 6 | const DevTools = createDevTools( 7 | 13 | 14 | 15 | ) 16 | 17 | export default DevTools 18 | -------------------------------------------------------------------------------- /src/app/doc-title.js: -------------------------------------------------------------------------------- 1 | import config from '../config' 2 | import store from '../store' 3 | 4 | // Subscribe to changes in state to determine document title 5 | export function initDocTitle () { 6 | store.subscribe(() => { 7 | const state = store.getState() 8 | updateDocTitle(state.app.viewName) 9 | }) 10 | } 11 | 12 | function updateDocTitle (value) { 13 | const defaultTitle = config.name 14 | if (value) { 15 | document.title = `${value} | ${defaultTitle}` 16 | } else { 17 | document.title = defaultTitle 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/components/Sidebar/Legend/Legend.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import React from 'react' 3 | import ReactDOM from 'react-dom' 4 | import { shallow } from 'enzyme' 5 | import Legend from './index' 6 | 7 | describe('Legend', () => { 8 | it('renders without crashing', () => { 9 | const div = document.createElement('div') 10 | ReactDOM.render(, div) 11 | }) 12 | 13 | it('renders rows of legend data', () => { 14 | const wrapper = shallow() 15 | expect(wrapper.find('tr').length).toEqual(10) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/components/Sidebar/Sidebar.css: -------------------------------------------------------------------------------- 1 | .Sidebar { 2 | padding: 8px 11px 16px; 3 | background-color: whitesmoke; 4 | overflow-x: hidden; 5 | overflow-y: auto; 6 | } 7 | 8 | .Sidebar .ui.segment { 9 | margin: 8px 0; 10 | } 11 | 12 | .app-logo { 13 | height: 25px; 14 | margin-top: 6px; 15 | margin-bottom: 3px; 16 | margin-left: 1px; 17 | } 18 | 19 | .info-icon { 20 | position: absolute; 21 | top: 14px; 22 | right: 10px; 23 | color: gray; 24 | } 25 | 26 | .info-icon:hover { 27 | background-color: white; 28 | color: inherit; 29 | } 30 | -------------------------------------------------------------------------------- /src/lib/__tests__/geojson.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import { merge } from '../geojson' 3 | 4 | it('merges an array of geojson objects', () => { 5 | const features = [ 6 | { 7 | type: 'FeatureCollection', 8 | features: [{ 9 | geometry: { coords: [1, 2] }, 10 | properties: { baz: true } 11 | }] 12 | }, 13 | { 14 | type: 'FeatureCollection', 15 | features: [{ 16 | geometry: { coords: [0, 1] }, 17 | properties: { foo: 'bar' } 18 | }] 19 | } 20 | ] 21 | const result = merge(features) 22 | 23 | expect(result.features.length).toEqual(2) 24 | }) 25 | -------------------------------------------------------------------------------- /src/components/App.css: -------------------------------------------------------------------------------- 1 | /* CSS custom properties */ 2 | :root { 3 | --sidebar-width: 480px; 4 | } 5 | 6 | .map-container { 7 | position: fixed; 8 | top: 0; 9 | left: 0; 10 | width: calc(100% - var(--sidebar-width)); 11 | height: 100%; 12 | } 13 | 14 | .sidebar-container { 15 | position: fixed; 16 | top: 0; 17 | right: 0; 18 | width: var(--sidebar-width); 19 | height: 100%; 20 | border-left: 1px solid #c0c0c0; 21 | } 22 | 23 | .feature-info { 24 | position: absolute; 25 | min-width: 125px; 26 | min-height: 50px; 27 | z-index: 1000; 28 | background: rgba(0,0,0,0.5); 29 | color: white; 30 | padding: 5px; 31 | } 32 | -------------------------------------------------------------------------------- /src/store/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | import app from './app' 3 | import config from './config' 4 | import date from './date' 5 | import errors from './errors' 6 | import map from './map' 7 | import route from './route' 8 | import tangram from './tangram' 9 | import view from './view' 10 | import loading from './loading' 11 | import barchart from './barchart' 12 | import coverage from './coverage' 13 | 14 | const reducers = combineReducers({ 15 | app, 16 | config, 17 | date, 18 | errors, 19 | map, 20 | route, 21 | tangram, 22 | view, 23 | loading, 24 | barchart, 25 | coverage 26 | }) 27 | 28 | export default reducers 29 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | name: 'Open Traffic Analyst UI', 3 | mapzen: { 4 | apiKey: '8f2a0651ebc54366b59183dca4851489' 5 | }, 6 | map: { 7 | center: [0, 120], 8 | zoom: 3 9 | }, 10 | osmlrTileUrl: 'https://wbg-osmlr-tiles.s3.amazonaws.com/v1.1/geojson/', 11 | dataGeojson: 'https://stvno.github.io/wereldbank/coverage.geojson', 12 | historicSpeedTileUrl: 'https://wbg-speedtiles-prod.s3.amazonaws.com/', 13 | nextSegmentTileUrl: 'https://wbg-speedtiles-prod.s3.amazonaws.com/', 14 | refSpeedTileUrl: 'https://wbg-referencetiles-prod.s3.amazonaws.com/', 15 | valhallaHost: 'routing-v1-1.opentraffic.io' 16 | } 17 | 18 | export default config 19 | -------------------------------------------------------------------------------- /src/store/reducers/view.js: -------------------------------------------------------------------------------- 1 | import { SET_VIEW_BOUNDS, SET_GEOJSON, CLEAR_VIEW_BOUNDS, CLEAR_ANALYSIS } from '../actions' 2 | 3 | const initialState = { 4 | bounds: null, 5 | geoJSON: null 6 | } 7 | 8 | const view = (state = initialState, action) => { 9 | switch (action.type) { 10 | case SET_VIEW_BOUNDS: 11 | return { 12 | ...state, 13 | bounds: action.bounds 14 | } 15 | case SET_GEOJSON: 16 | return { 17 | ...state, 18 | geoJSON: action.geoJSON 19 | } 20 | case CLEAR_VIEW_BOUNDS: 21 | case CLEAR_ANALYSIS: 22 | return initialState 23 | default: 24 | return state 25 | } 26 | } 27 | 28 | export default view 29 | -------------------------------------------------------------------------------- /src/components/Loader/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import { Icon } from 'semantic-ui-react' 4 | import './Loader.css' 5 | 6 | class Loader extends React.Component { 7 | render () { 8 | const { isLoading } = this.props 9 | 10 | if (isLoading) { 11 | return ( 12 |
13 | 14 | Loading... 15 |
16 | ) 17 | } 18 | return null 19 | } 20 | } 21 | 22 | function mapStateToProps (state) { 23 | return { 24 | isLoading: state.loading.isLoading 25 | } 26 | } 27 | 28 | export default connect(mapStateToProps)(Loader) 29 | -------------------------------------------------------------------------------- /src/components/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Provider } from 'react-redux' 3 | 4 | import MapContainer from './MapContainer' 5 | import Sidebar from './Sidebar' 6 | import DevTools from './DevTools' 7 | import store from '../store' 8 | 9 | import 'semantic-ui-css/semantic.min.css' 10 | import './App.css' 11 | 12 | class App extends Component { 13 | render () { 14 | return ( 15 | 16 |
17 | 18 | 19 | 20 |
21 |
22 | ) 23 | } 24 | } 25 | 26 | export default App 27 | -------------------------------------------------------------------------------- /src/store/actions/map.js: -------------------------------------------------------------------------------- 1 | import { SET_MAP_LOCATION, SET_LABEL, SET_MAP_VIEW } from '../actions' 2 | 3 | // stores lat and lng of new location from MapSearchBar 4 | export function setLocation (latlng, label) { 5 | return { 6 | type: SET_MAP_LOCATION, 7 | latlng, 8 | label 9 | } 10 | } 11 | 12 | export function clearLabel () { 13 | return { 14 | type: SET_LABEL, 15 | name: null 16 | } 17 | } 18 | 19 | /** 20 | * recenters map when location chosen from MapSearchBar (and other places) 21 | * 22 | * @param {Array} coordinates - [lat, lng] 23 | * @param {Number} zoom - zoom level, can be fractional 24 | */ 25 | export function recenterMap (coordinates, zoom) { 26 | return { 27 | type: SET_MAP_VIEW, 28 | coordinates, 29 | zoom 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/store/reducers/loading.js: -------------------------------------------------------------------------------- 1 | import { START_LOADING, STOP_LOADING, HIDE_LOADING } from '../actions' 2 | 3 | const initialState = { 4 | isLoading: false, 5 | counter: 0 6 | } 7 | 8 | const loading = (state = initialState, action) => { 9 | switch (action.type) { 10 | case START_LOADING: 11 | return { 12 | ...state, 13 | isLoading: true, 14 | counter: state.counter + 1 15 | } 16 | case STOP_LOADING: 17 | return { 18 | ...state, 19 | isLoading: state.counter - 1 !== 0, 20 | counter: state.counter - 1 21 | } 22 | case HIDE_LOADING: 23 | return { 24 | ...state, 25 | isLoading: false, 26 | counter: 0 27 | } 28 | default: 29 | return state 30 | } 31 | } 32 | 33 | export default loading 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Open Traffic Analyst UI 2 | 3 | A part of the [OTv2 platform](https://github.com/opentraffic/otv2-platform). Replaces OTv1's [Traffic Engine App](https://github.com/opentraffic/traffic-engine-app). 4 | 5 | Analyst UI is a browser-based tool that can fetch and display public data extracts (all in Protocol Buffer format) produced by the [Open Traffic Datastore](https://github.com/opentraffic/datastore/). 6 | 7 | ## Using 8 | 9 | Contact World Bank for more information 10 | 11 | ## Developing 12 | 13 | Analyst UI uses the [ReactJS](https://reactjs.org/) and [Redux](http://redux.js.org/) libraries, as well as [Tangram](https://github.com/tangrams/tangram) for map display. For local development, [see this readme on using the Create React App tool](https://github.com/opentraffic/analyst-ui/blob/master/README-create-react-app.md). 14 | -------------------------------------------------------------------------------- /src/store/reducers/map.js: -------------------------------------------------------------------------------- 1 | import config from '../../config' 2 | import { SET_MAP_LOCATION, SET_LABEL, SET_MAP_VIEW } from '../actions' 3 | 4 | const initialState = { 5 | coordinates: config.map.center, 6 | zoom: config.map.zoom, 7 | label: '' 8 | } 9 | 10 | const map = (state = initialState, action) => { 11 | switch (action.type) { 12 | case SET_MAP_LOCATION: 13 | return { 14 | ...state, 15 | coordinates: action.latlng, 16 | label: action.label 17 | } 18 | case SET_LABEL: 19 | return { 20 | ...state, 21 | label: action.label 22 | } 23 | case SET_MAP_VIEW: 24 | return { 25 | ...state, 26 | coordinates: action.coordinates, 27 | zoom: action.zoom 28 | } 29 | default: 30 | return state 31 | } 32 | } 33 | 34 | export default map 35 | -------------------------------------------------------------------------------- /src/store/reducers/barchart.js: -------------------------------------------------------------------------------- 1 | import { CLEAR_BARCHART, ADD_SEGMENTS_TO_BARCHART } from '../actions' 2 | 3 | const initialState = { 4 | percentDiffsBinnedByHour: [], 5 | speedsBinnedByHour: [], 6 | refSpeedsBinnedByHour: [] 7 | } 8 | 9 | const barchart = (state = initialState, action) => { 10 | switch (action.type) { 11 | case CLEAR_BARCHART: 12 | return { 13 | ...state, 14 | speedsBinnedByHour: [], 15 | percentDiffsBinnedByHour: [], 16 | refSpeedsBinnedByHour: [] 17 | } 18 | case ADD_SEGMENTS_TO_BARCHART: 19 | return { 20 | ...state, 21 | speedsBinnedByHour: action.speedsBinnedByHour, 22 | percentDiffsBinnedByHour: action.percentDiffsBinnedByHour, 23 | refSpeedsBinnedByHour: action.refSpeedsBinnedByHour 24 | } 25 | default: 26 | return state 27 | } 28 | } 29 | 30 | export default barchart 31 | -------------------------------------------------------------------------------- /src/components/Map/RouteError/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Message, Button } from 'semantic-ui-react' 4 | import './RouteError.css' 5 | 6 | const RouteError = (props) => { 7 | if (!props.message || props.message.trim().length === 0) return null 8 | 9 | return ( 10 |
11 | 12 | Routing error 13 |

14 | {props.message} 15 | 16 |

17 |
18 |
19 | ) 20 | } 21 | 22 | RouteError.propTypes = { 23 | message: PropTypes.string, 24 | onClick: PropTypes.func.isRequired 25 | } 26 | 27 | RouteError.defaultProps = { 28 | onClick: function () {} 29 | } 30 | 31 | export default RouteError 32 | -------------------------------------------------------------------------------- /src/store/reducers/errors.js: -------------------------------------------------------------------------------- 1 | import { uniqWith, reject, isEqual } from 'lodash' 2 | import { ADD_ERROR, REMOVE_ERROR, CLEAR_ERRORS } from '../actions' 3 | 4 | const initialState = { 5 | errors: [] 6 | } 7 | 8 | const errors = (state = initialState, action) => { 9 | switch (action.type) { 10 | case ADD_ERROR: { 11 | // Append an error object the current list of errors. 12 | // Filter out identical errors. 13 | const errorsCollection = [...state.errors, action.error] 14 | 15 | return { 16 | ...state, 17 | errors: uniqWith(errorsCollection, isEqual) 18 | } 19 | } 20 | case REMOVE_ERROR: { 21 | return { 22 | ...state, 23 | errors: reject(state.errors, action.error) 24 | } 25 | } 26 | case CLEAR_ERRORS: 27 | return initialState 28 | default: 29 | return state 30 | } 31 | } 32 | 33 | export default errors 34 | -------------------------------------------------------------------------------- /src/components/Sidebar/DatePicker/DatePicker.css: -------------------------------------------------------------------------------- 1 | .info-panel { 2 | padding: 10px 21px; 3 | border-top: 1px solid #dce0e0; 4 | color: #484848; 5 | } 6 | 7 | .DateRangePicker, 8 | .DateRangePickerInput { 9 | display: block; 10 | } 11 | 12 | 13 | .DateRangePickerInput { 14 | border: 0; 15 | border-radius: .28571429rem; 16 | box-shadow: 0 0 0 1px #767676 inset!important; 17 | } 18 | 19 | .DateInput { 20 | background: transparent; 21 | } 22 | 23 | .DateRangePicker__picker { 24 | z-index: 3; 25 | top: 54px; 26 | } 27 | 28 | .DayPicker--horizontal { 29 | box-shadow: 0 0 0 1px rgba(34, 36, 38, 0.15), 0 0 2px 0 rgba(34, 36, 38, 0.15); 30 | } 31 | 32 | .DateInput--with-caret::before { 33 | top: 44px; 34 | border-bottom-color: rgba(34, 36, 38, 0.25); 35 | } 36 | 37 | .DateInput--with-caret::after { 38 | z-index: 4; 39 | top: 45px; 40 | } 41 | 42 | .DayPicker__week-header { 43 | top: 57px; 44 | } 45 | 46 | .DateRangePickerInput__clear-dates { 47 | position: absolute; 48 | right: 0; 49 | top: 6px; 50 | } 51 | -------------------------------------------------------------------------------- /src/lib/geojson.js: -------------------------------------------------------------------------------- 1 | // GeoJSON utils. 2 | import normalize from '@mapbox/geojson-normalize' 3 | 4 | /** 5 | * Merge a series of GeoJSON objects into one FeatureCollection containing all 6 | * features in all files. The objects can be any valid GeoJSON root object, 7 | * including FeatureCollection, Feature, and Geometry types. 8 | * 9 | * This has been adapted from the module `@mapbox/geojson-merge@1.0.2`. The 10 | * original module is not included because it includes a dependency on `JSONStream` 11 | * which is not actually needed here, and contains a shebang line that breaks 12 | * Webpack. (https://github.com/dominictarr/JSONStream/issues/126) 13 | * 14 | * @param {Array} - inputs a list of GeoJSON objects of any type 15 | * @return {Object} - a geojson FeatureCollection. 16 | */ 17 | export function merge (inputs) { 18 | const normalized = inputs.map(normalize) 19 | const output = { 20 | type: 'FeatureCollection', 21 | features: normalized.reduce((features, geo) => { 22 | return features.concat(geo.features) 23 | }, []) 24 | } 25 | 26 | return output 27 | } 28 | -------------------------------------------------------------------------------- /src/store/actions/date.js: -------------------------------------------------------------------------------- 1 | import { 2 | SET_DATE, 3 | SET_DATE_RANGE, 4 | CLEAR_DATE_RANGE, 5 | SET_DAY_FILTER, 6 | SET_HOUR_FILTER 7 | } from '../actions' 8 | 9 | export function setDate (startDate, endDate) { 10 | return { 11 | type: SET_DATE, 12 | startDate, 13 | endDate 14 | } 15 | } 16 | 17 | export function clearDate () { 18 | return { 19 | type: SET_DATE, 20 | startDate: null, 21 | endDate: null 22 | } 23 | } 24 | 25 | export function setDayFilter (filter) { 26 | return { 27 | type: SET_DAY_FILTER, 28 | 29 | // Cast values to number 30 | dayFilter: filter.map(i => Number(i)) 31 | } 32 | } 33 | 34 | export function setHourFilter (filter) { 35 | return { 36 | type: SET_HOUR_FILTER, 37 | 38 | // Cast values to number 39 | hourFilter: filter.map(i => Number(i)) 40 | } 41 | } 42 | 43 | export function setDateRange (start, end) { 44 | return { 45 | type: SET_DATE_RANGE, 46 | start, 47 | end 48 | } 49 | } 50 | 51 | export function clearDateRange () { 52 | return { 53 | type: CLEAR_DATE_RANGE 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/store/actions/app.js: -------------------------------------------------------------------------------- 1 | import { SET_ANALYSIS_MODE, CLEAR_ANALYSIS, SET_ANALYSIS_NAME, SET_REF_SPEED_COMPARISON_ENABLED, SET_REF_SPEED_ENABLED } from '../actions' 2 | 3 | const ROUTE_MODE = 'ROUTE' 4 | const REGION_MODE = 'REGION' 5 | 6 | function setAnalysisMode (mode) { 7 | return { 8 | type: SET_ANALYSIS_MODE, 9 | mode 10 | } 11 | } 12 | 13 | export function setRegionAnalysisMode () { 14 | return setAnalysisMode(REGION_MODE) 15 | } 16 | 17 | export function setRouteAnalysisMode () { 18 | return setAnalysisMode(ROUTE_MODE) 19 | } 20 | 21 | export function clearAnalysisMode () { 22 | return { type: CLEAR_ANALYSIS } 23 | } 24 | 25 | export function setAnalysisName (viewName) { 26 | return { 27 | type: SET_ANALYSIS_NAME, 28 | viewName: viewName.trim() 29 | } 30 | } 31 | 32 | export function setRefSpeedComparisonEnabled (newState) { 33 | return { 34 | type: SET_REF_SPEED_COMPARISON_ENABLED, 35 | refSpeedComparisonEnabled: newState 36 | } 37 | } 38 | 39 | export function setRefSpeedEnabled (newState) { 40 | return { 41 | type: SET_REF_SPEED_ENABLED, 42 | refSpeedEnabled: newState 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/lib/routing.js: -------------------------------------------------------------------------------- 1 | // expects L.Latlng object 2 | function findClosestRoutePoint (latlng, coordinates) { 3 | let minDist = Number.MAX_VALUE 4 | let minIndex, dist 5 | 6 | for (let i = coordinates.length - 1; i >= 0; i--) { 7 | dist = latlng.distanceTo(coordinates[i]) 8 | if (dist < minDist) { 9 | minIndex = i 10 | minDist = dist 11 | } 12 | } 13 | 14 | return minIndex 15 | } 16 | 17 | function getWaypointIndices (waypoints, coordinates) { 18 | const indices = [] 19 | 20 | for (let i = 0; i < waypoints.length; i++) { 21 | indices.push(findClosestRoutePoint(waypoints[i], coordinates)) 22 | } 23 | 24 | return indices 25 | } 26 | 27 | function findNearestWpBefore (i, waypoints, coordinates) { 28 | const wpIndices = getWaypointIndices(waypoints, coordinates) 29 | let j = wpIndices.length - 1 30 | while (j >= 0 && wpIndices[j] > i) { 31 | j-- 32 | } 33 | 34 | return j 35 | } 36 | 37 | export function getNewWaypointPosition (latlng, waypoints, coordinates) { 38 | const index = findClosestRoutePoint(latlng, coordinates) 39 | const afterIndex = findNearestWpBefore(index, waypoints, coordinates) 40 | return afterIndex 41 | } 42 | -------------------------------------------------------------------------------- /src/app/__tests__/update-url.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import * as updateUrl from '../update-url' 3 | 4 | describe('url updating', () => { 5 | describe('getTimeFilters()', () => { 6 | it('returns formatted params for both the day and hour filters if present in state', () => { 7 | const state = { 8 | dayFilter: [0, 4], 9 | hourFilter: [3, 8] 10 | } 11 | 12 | const result = updateUrl.getTimeFilters(state) 13 | expect(result.df).toEqual('0/4') 14 | expect(result.hf).toEqual('3/8') 15 | }) 16 | 17 | it('returns formatted params for only the day filter in state', () => { 18 | const state = { 19 | dayFilter: [0, 4] 20 | } 21 | 22 | const result = updateUrl.getTimeFilters(state) 23 | expect(result.df).toEqual('0/4') 24 | expect(result.hf).toEqual(null) 25 | }) 26 | 27 | it('returns formatted params for only the hour filter in state', () => { 28 | const state = { 29 | hourFilter: [3, 8] 30 | } 31 | 32 | const result = updateUrl.getTimeFilters(state) 33 | expect(result.hf).toEqual('3/8') 34 | expect(result.df).toEqual(null) 35 | }) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /src/components/Sidebar/Legend/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Segment, Header } from 'semantic-ui-react' 3 | import { speedRamp, percDiffRamp } from '../../../lib/color-ramps' 4 | import './Legend.css' 5 | 6 | export default class Legend extends React.PureComponent { 7 | makeTableRows () { 8 | // Make a clone and do not mutate the original array 9 | const ramp = (this.props.compareEnabled) ? percDiffRamp : speedRamp 10 | const scale = ramp.slice().reverse() 11 | return scale.map((i) => { 12 | return ( 13 | 14 | 15 | {i.label} 16 | 17 | ) 18 | }) 19 | } 20 | 21 | render () { 22 | return ( 23 | 24 |
25 | { (this.props.compareEnabled) ? 'Percent change, compared to reference speeds' : 'Speed, in kilometers per hour' } 26 |
27 | 28 | 29 | { this.makeTableRows() } 30 | 31 |
32 |
33 | ) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/store/reducers/app.js: -------------------------------------------------------------------------------- 1 | import { SET_ANALYSIS_MODE, CLEAR_ANALYSIS, SET_ANALYSIS_NAME, SET_REF_SPEED_COMPARISON_ENABLED, SET_REF_SPEED_ENABLED } from '../actions' 2 | 3 | const initialState = { 4 | analysisMode: null, 5 | viewName: '', 6 | refSpeedComparisonEnabled: false, 7 | refSpeedEnabled: false 8 | } 9 | 10 | const app = (state = initialState, action) => { 11 | switch (action.type) { 12 | case SET_ANALYSIS_MODE: 13 | return { 14 | ...state, 15 | analysisMode: action.mode 16 | } 17 | case CLEAR_ANALYSIS: 18 | return { 19 | ...state, 20 | analysisMode: null, 21 | refSpeedComparisonEnabled: false, 22 | refSpeedEnabled: false 23 | } 24 | case SET_ANALYSIS_NAME: 25 | return { 26 | ...state, 27 | viewName: action.viewName 28 | } 29 | case SET_REF_SPEED_COMPARISON_ENABLED: 30 | return { 31 | ...state, 32 | refSpeedComparisonEnabled: action.refSpeedComparisonEnabled 33 | } 34 | case SET_REF_SPEED_ENABLED: 35 | return { 36 | ...state, 37 | refSpeedEnabled: action.refSpeedEnabled 38 | } 39 | default: 40 | return state 41 | } 42 | } 43 | 44 | export default app 45 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Local installation 2 | 3 | ``` 4 | git clone 5 | cd analyst-ui 6 | npm install 7 | npm run build 8 | npm start 9 | ``` 10 | 11 | ## Testing locally 12 | 13 | ``` 14 | npm test 15 | ``` 16 | 17 | ## Linting 18 | 19 | ``` 20 | npm run lint 21 | ``` 22 | 23 | The JavaScript style used is based on [Standard](https://standardjs.com/). 24 | We make one override to prefer double-quotes in JSX, which is much 25 | more common in the React ecosystem and in HTML code in general. 26 | 27 | Under the hood, we must also use the `babel-eslint` parser so that static 28 | properties on classes (a very cutting-edge JavaScript syntax feature) is not 29 | marked as errors. 30 | 31 | Because `create-react-app` has its own linting system, this is not tied into 32 | the `npm run build` script. Our test script has been augmented to run this 33 | separately. Note that `package.json` needs to specify the same versions of `eslint` 34 | and various `eslint-config-*` packages as `create-react-app` or errors will occur. 35 | 36 | ## Testing publicly 37 | 38 | - Make all changes in a different branch, then make a pull request. 39 | - All commits are tested by CircleCI. 40 | - Pull requests will deploy an instance to Netlify. See the pull request details for the link. 41 | -------------------------------------------------------------------------------- /src/components/Sidebar/ETAView/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { connect } from 'react-redux' 4 | import { Segment, Header } from 'semantic-ui-react' 5 | import humanizeDuration from 'humanize-duration' 6 | 7 | class ETAView extends React.Component { 8 | static propTypes = { 9 | baselineTime: PropTypes.number, 10 | trafficRouteTime: PropTypes.number 11 | } 12 | 13 | render () { 14 | if (this.props.baselineTime && this.props.trafficRouteTime) { 15 | const baselineTimeToDisplay = humanizeDuration(this.props.baselineTime * 1000, { round: true }) 16 | const trafficRouteTimeToDisplay = humanizeDuration(this.props.trafficRouteTime * 1000, { round: true }) 17 | return ( 18 | 19 |
Route time
20 |

Given speed limits: {baselineTimeToDisplay}

21 |

Given measured traffic: {trafficRouteTimeToDisplay}

22 |
23 | ) 24 | } 25 | 26 | return null 27 | } 28 | } 29 | 30 | function mapStateToProps (state) { 31 | return { 32 | baselineTime: state.route.baselineTime, 33 | trafficRouteTime: state.route.trafficRouteTime 34 | } 35 | } 36 | 37 | export default connect(mapStateToProps)(ETAView) 38 | -------------------------------------------------------------------------------- /src/lib/fetch-utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Wrapper around Fetch API which can interact with a resource that will 3 | * return a JSON payload along with an unsuccessful HTTP status code. 4 | * https://github.com/github/fetch/issues/203 5 | */ 6 | 7 | /** 8 | * Parses the JSON returned by a network request 9 | * 10 | * @param {object} response - A response from a network request 11 | * 12 | * @return {object} - The parsed JSON, status from the response 13 | */ 14 | function parseJSON (response) { 15 | return new Promise(resolve => response.json() 16 | .then(json => resolve({ 17 | status: response.status, 18 | ok: response.ok, 19 | json 20 | }))) 21 | } 22 | 23 | /** 24 | * Requests a URL, returning a promise 25 | * 26 | * @param {string} url - The URL we want to request 27 | * @param {object} options - The options we want to pass to window.fetch() 28 | * 29 | * @return {Promise} - The request promise 30 | */ 31 | export function request (url, options) { 32 | return new Promise((resolve, reject) => { 33 | window.fetch(url, options) 34 | .then(parseJSON) 35 | .then(response => { 36 | if (response.ok) { 37 | return resolve(response.json) 38 | } 39 | 40 | // Send error payload from the server's json 41 | return reject(response.json) 42 | }) 43 | .catch(error => reject(new Error(error.message))) 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /src/components/Map/Map.css: -------------------------------------------------------------------------------- 1 | .leaflet-container { 2 | position: absolute !important; 3 | width: 100%; 4 | height: 100%; 5 | } 6 | 7 | .leaflet-container .leaflet-control-attribution { 8 | background: transparent; 9 | color: #444; 10 | margin: 0.5em; 11 | text-shadow: 0 0 2px rgba(255,255,255,0.85); 12 | pointer-events: none; 13 | user-select: none; 14 | } 15 | 16 | .leaflet-control-attribution a { 17 | pointer-events: auto; 18 | } 19 | 20 | .leaflet-control-attribution a, 21 | .leaflet-control-attribution a:hover, 22 | .leaflet-control-attribution a:visited { 23 | color: #444; 24 | } 25 | 26 | .leaflet-top .leaflet-control { 27 | margin-top: 55px; 28 | } 29 | 30 | .leaflet-top { 31 | z-index: 800; 32 | } 33 | 34 | /* leaflet-touch styling for zoom buttons */ 35 | .leaflet-bar { 36 | border: solid 2px rgba(0,0,0,0.2) !important; 37 | background-clip: padding-box !important; 38 | box-shadow: none !important; 39 | } 40 | 41 | .leaflet-bar a { 42 | width: 30px !important; 43 | height: 30px !important; 44 | line-height: 30px !important; 45 | } 46 | 47 | .leaflet-control-zoom-in { 48 | font-size: 22px !important; 49 | } 50 | 51 | .leaflet-control-zoom-out { 52 | font-size: 24px !important; 53 | } 54 | 55 | .map-bounding-box-disabled { 56 | cursor: inherit; 57 | } 58 | 59 | .leaflet-areaselect-shade { 60 | position: absolute; 61 | background: rgba(0,0,0,0.4); 62 | z-index: 400; 63 | } 64 | -------------------------------------------------------------------------------- /src/store/reducers/date.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment' 2 | import { 3 | SET_DATE, 4 | SET_DATE_RANGE, 5 | CLEAR_DATE_RANGE, 6 | SET_DAY_FILTER, 7 | SET_HOUR_FILTER 8 | } from '../actions' 9 | 10 | const initialState = { 11 | // For date picker 12 | startDate: null, 13 | endDate: null, 14 | dateRange: {}, 15 | 16 | // For time chart filters 17 | dayFilter: null, 18 | hourFilter: null 19 | } 20 | 21 | const date = (state = initialState, action) => { 22 | switch (action.type) { 23 | case SET_DATE: 24 | const year = moment(action.startDate).year() 25 | const week = String(moment(action.startDate).week()).padStart(2, '0') 26 | return { 27 | ...state, 28 | startDate: action.startDate, 29 | endDate: action.endDate, 30 | year: year, 31 | week: week 32 | } 33 | case SET_DATE_RANGE: 34 | const startRange = moment(action.start) 35 | const endRange = moment(action.end) 36 | return { 37 | ...state, 38 | dateRange: { 39 | rangeStart: startRange, 40 | rangeEnd: endRange 41 | } 42 | } 43 | case CLEAR_DATE_RANGE: 44 | return { 45 | ...state, 46 | dateRange: { 47 | rangeStart: null, 48 | rangeENd: null 49 | } 50 | } 51 | case SET_DAY_FILTER: 52 | return { 53 | ...state, 54 | dayFilter: action.dayFilter 55 | } 56 | case SET_HOUR_FILTER: 57 | return { 58 | ...state, 59 | hourFilter: action.hourFilter 60 | } 61 | default: 62 | return state 63 | } 64 | } 65 | 66 | export default date 67 | -------------------------------------------------------------------------------- /src/lib/url-state.js: -------------------------------------------------------------------------------- 1 | // Turn query string into an object with key/value 2 | export function getQueryStringObject (queryString = window.location.search) { 3 | const params = new window.URLSearchParams(queryString) 4 | const queryObject = {} 5 | 6 | for (const param of params.entries()) { 7 | const [key, value] = param 8 | 9 | // Do not add to object if key is blank string or value is undefined 10 | if (key !== '' && typeof value !== 'undefined') { 11 | queryObject[key] = value 12 | } 13 | } 14 | 15 | return queryObject 16 | } 17 | 18 | // Parsing query string to return certain parameter 19 | export function parseQueryString (param, queryString = window.location.search) { 20 | const params = new window.URLSearchParams(queryString) 21 | return params.get(param) 22 | } 23 | 24 | // Adding new parameter to query string 25 | // If no query string, empty URLSearchParams object is created 26 | export function addNewParam (params = {}, queryString = window.location.search) { 27 | const searchParams = new window.URLSearchParams(queryString) 28 | 29 | for (var param in params) { 30 | const [key, value] = [param, params[param]] 31 | 32 | // If no value, delete key 33 | if (value === null) { 34 | searchParams.delete(key) 35 | } else { 36 | searchParams.set(key, value) 37 | } 38 | } 39 | 40 | const newQueryString = `?${searchParams.toString()}` 41 | return newQueryString 42 | } 43 | 44 | // Replace existing history state 45 | export function updateURL (params = {}) { 46 | const locationPrefix = window.location.pathname 47 | const queryString = addNewParam(params) 48 | window.history.replaceState({}, null, locationPrefix + queryString) 49 | } 50 | -------------------------------------------------------------------------------- /src/app/__tests__/data.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import { consolidateTiles } from '../data' 3 | 4 | it('consolidates array of tiles into a keyed object', () => { 5 | const tiles = [ 6 | { 7 | level: 0, 8 | index: 1000, 9 | speeds: new Array(100), 10 | startSegmentIndex: 0, 11 | subtileSegments: 100, 12 | meta_type: 'historic', 13 | meta_year: '2017', 14 | meta_week: '01', 15 | meta_subtile: 0 16 | }, 17 | { 18 | level: 0, 19 | index: 1000, 20 | speeds: new Array(30), 21 | startSegmentIndex: 100, 22 | subtileSegments: 100, 23 | meta_type: 'historic', 24 | meta_year: '2017', 25 | meta_week: '01', 26 | meta_subtile: 1 27 | }, 28 | { 29 | level: 0, 30 | index: 1001, 31 | speeds: new Array(2), 32 | startSegmentIndex: 0, 33 | subtileSegments: 100, 34 | meta_type: 'historic', 35 | meta_year: '2017', 36 | meta_week: '01', 37 | meta_subtile: 0 38 | }, 39 | { 40 | level: 1, 41 | index: 1000, 42 | speeds: new Array(1), 43 | startSegmentIndex: 0, 44 | subtileSegments: 100, 45 | meta_type: 'historic', 46 | meta_year: '2017', 47 | meta_week: '01', 48 | meta_subtile: 0 49 | } 50 | ] 51 | const result = consolidateTiles(tiles) 52 | 53 | expect(result).toHaveProperty('historic') 54 | 55 | const tile = result.historic['2017']['01'] 56 | 57 | expect(tile).toHaveProperty('0') 58 | expect(tile).toHaveProperty('1') 59 | expect(tile['0']).toHaveProperty('1000') 60 | expect(tile['0']).toHaveProperty('1001') 61 | expect(tile['1']).toHaveProperty('1000') 62 | expect(tile['0']['1000']['0']).toHaveProperty('speeds') 63 | expect(tile['0']['1000']['1']).toHaveProperty('speeds') 64 | }) 65 | -------------------------------------------------------------------------------- /src/lib/__tests__/url-state.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import 'url-search-params-polyfill' 3 | import { 4 | getQueryStringObject, 5 | parseQueryString, 6 | addNewParam 7 | } from '../url-state' 8 | 9 | describe('returns an object of query parameters/values', () => { 10 | it('returns an empty object when page has no search params', () => { 11 | const result = getQueryStringObject('') 12 | const expected = ({}) 13 | 14 | expect(result).toEqual(expected) 15 | }) 16 | 17 | it('does not add to object if key is a blank string or value is undefined', () => { 18 | const result1 = getQueryStringObject('a=3&b=4&=2') 19 | const result2 = getQueryStringObject('a=3&b=4&c') 20 | 21 | const expected = { a: '3', b: '4' } 22 | expect(result1).toEqual(expected) 23 | expect(result2).toEqual(expected) 24 | }) 25 | }) 26 | 27 | describe('parses query string and returns the requested param', () => { 28 | it('returns a string value when a requested param is present', () => { 29 | const result = parseQueryString('a', 'a=2&b=4&c=6') 30 | expect(result).toEqual('2') 31 | }) 32 | 33 | it('returns null if empty string or param does not exist', () => { 34 | const result1 = parseQueryString('', 'a=2&b=4&c=6') 35 | const result2 = parseQueryString('d', 'a=2&b=4&c=6') 36 | 37 | expect(result1).toEqual(null) 38 | expect(result2).toEqual(null) 39 | }) 40 | }) 41 | 42 | describe('adds params to query string', () => { 43 | it('merges object to existing query string', () => { 44 | const result = addNewParam({a: 2, b: 4, c: 3}, 'a=3&b=5') 45 | const expected = '?a=2&b=4&c=3' 46 | 47 | expect(result).toEqual(expected) 48 | }) 49 | it('deletes key from query string if no value', () => { 50 | const result = addNewParam({a: 2, b: 4, c: null}, 'a=3&b=5') 51 | const expected = '?a=2&b=4' 52 | 53 | expect(result).toEqual(expected) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /src/store/actions/route.js: -------------------------------------------------------------------------------- 1 | import { 2 | ADD_ROUTE_WAYPOINT, 3 | REMOVE_ROUTE_WAYPOINT, 4 | UPDATE_ROUTE_WAYPOINT, 5 | INSERT_ROUTE_WAYPOINT, 6 | SET_ROUTE, 7 | SET_ROUTE_ERROR, 8 | SET_ROUTE_SEGMENTS, 9 | SET_BASELINE_TIME, 10 | SET_TRAFFIC_ROUTE_TIME 11 | } from '../actions' 12 | 13 | export function addWaypoint (waypoint) { 14 | return { 15 | type: ADD_ROUTE_WAYPOINT, 16 | waypoint 17 | } 18 | } 19 | 20 | export function removeWaypoint (waypoint) { 21 | return { 22 | type: REMOVE_ROUTE_WAYPOINT, 23 | waypoint 24 | } 25 | } 26 | 27 | export function updateWaypoint (oldWaypoint, newWaypoint) { 28 | return { 29 | type: UPDATE_ROUTE_WAYPOINT, 30 | oldWaypoint, 31 | newWaypoint 32 | } 33 | } 34 | 35 | export function insertWaypoint (waypoint, insertAfter) { 36 | return { 37 | type: INSERT_ROUTE_WAYPOINT, 38 | waypoint, 39 | index: insertAfter + 1 40 | } 41 | } 42 | 43 | export function setRoute (latlngs) { 44 | return { 45 | type: SET_ROUTE, 46 | lineCoordinates: latlngs 47 | } 48 | } 49 | 50 | export function clearRoute () { 51 | return { 52 | type: SET_ROUTE, 53 | lineCoordinates: [] 54 | } 55 | } 56 | 57 | export function setRouteError (message) { 58 | return { 59 | type: SET_ROUTE_ERROR, 60 | error: message 61 | } 62 | } 63 | 64 | export function clearRouteError () { 65 | return { 66 | type: SET_ROUTE_ERROR, 67 | error: null 68 | } 69 | } 70 | 71 | export function setRouteSegments (segments) { 72 | return { 73 | type: SET_ROUTE_SEGMENTS, 74 | routeSegments: segments 75 | } 76 | } 77 | 78 | export function clearRouteSegments () { 79 | return { 80 | type: SET_ROUTE_SEGMENTS, 81 | routeSegments: [] 82 | } 83 | } 84 | 85 | export function setBaselineTime (time) { 86 | return { 87 | type: SET_BASELINE_TIME, 88 | time 89 | } 90 | } 91 | 92 | export function setTrafficRouteTime (time) { 93 | return { 94 | type: SET_TRAFFIC_ROUTE_TIME, 95 | time 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/store/actions/index.js: -------------------------------------------------------------------------------- 1 | /* app */ 2 | export const SET_ANALYSIS_MODE = 'SET_ANALYSIS_MODE' 3 | export const CLEAR_ANALYSIS = 'CLEAR_ANALYSIS' 4 | export const SET_ANALYSIS_NAME = 'SET_ANALYSIS_NAME' 5 | export const SET_REF_SPEED_COMPARISON_ENABLED = 'SET_REF_SPEED_COMPARISON_ENABLED' 6 | export const SET_REF_SPEED_ENABLED = 'SET_REF_SPEED_ENABLED' 7 | 8 | export const START_LOADING = 'START_LOADING' 9 | export const STOP_LOADING = 'STOP_LOADING' 10 | export const HIDE_LOADING = 'HIDE_LOADING' 11 | 12 | /* date */ 13 | export const SET_DATE = 'SET_DATE' 14 | export const SET_DATE_RANGE = 'SET_DATE_RANGE' 15 | export const CLEAR_DATE_RANGE = 'CLEAR_DATE_RANGE' 16 | export const SET_DAY_FILTER = 'SET_DAY_FILTER' 17 | export const SET_HOUR_FILTER = 'SET_HOUR_FILTER' 18 | 19 | /* errors */ 20 | export const ADD_ERROR = 'ADD_ERROR' 21 | export const REMOVE_ERROR = 'REMOVE_ERROR' 22 | export const CLEAR_ERRORS = 'CLEAR_ERRORS' 23 | 24 | /* map */ 25 | export const SET_MAP_LOCATION = 'SET_MAP_LOCATION' 26 | export const SET_LABEL = 'SET_LABEL' 27 | export const SET_MAP_VIEW = 'SET_MAP_VIEW' 28 | 29 | /* route */ 30 | export const ADD_ROUTE_WAYPOINT = 'ADD_ROUTE_WAYPOINT' 31 | export const REMOVE_ROUTE_WAYPOINT = 'REMOVE_ROUTE_WAYPOINT' 32 | export const UPDATE_ROUTE_WAYPOINT = 'UPDATE_ROUTE_WAYPOINT' 33 | export const INSERT_ROUTE_WAYPOINT = 'INSERT_ROUTE_WAYPOINT' 34 | export const SET_ROUTE = 'SET_ROUTE' 35 | export const SET_ROUTE_SEGMENTS = 'SET_ROUTE_SEGMENTS' 36 | export const SET_ROUTE_ERROR = 'SET_ROUTE_ERROR' 37 | export const SET_BASELINE_TIME = 'SET_BASELINE_TIME' 38 | export const SET_TRAFFIC_ROUTE_TIME = 'SET_TRAFFIC_ROUTE_TIME' 39 | 40 | /* tangram */ 41 | export const UPDATE_SCENE = 'UPDATE_SCENE' 42 | 43 | /* view */ 44 | export const SET_VIEW_BOUNDS = 'SET_VIEW_BOUNDS' 45 | export const CLEAR_VIEW_BOUNDS = 'CLEAR_VIEW_BOUNDS' 46 | export const SET_GEOJSON = 'SET_GEOJSON' 47 | export const SET_DATA_GEOJSON = 'SET_DATA_GEOJSON' 48 | 49 | /* barchart */ 50 | export const CLEAR_BARCHART = 'CLEAR_BARCHART' 51 | export const ADD_SEGMENTS_TO_BARCHART = 'ADD_SEGMENTS_TO_BARCHART' 52 | -------------------------------------------------------------------------------- /src/lib/__tests__/route-segments.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import { getSegmentWidth, 3 | getLinearValue, 4 | STOPS 5 | } from '../route-segments.js' 6 | 7 | describe('if speed != 0, returns segment width value using STOPS array based on zoom level', () => { 8 | it('returns the first value of STOPS array if zoom level <= first zoom value and speed != 0', () => { 9 | const firstZoom = STOPS[0][0] 10 | const firstWidth = STOPS[0][1] 11 | const result = getSegmentWidth(firstZoom - 2, 10) 12 | const expected = parseFloat(firstWidth) 13 | 14 | expect(result).toEqual(expected) 15 | }) 16 | 17 | it('returns the segment width value matching zoom level of STOPS array if speed != 0', () => { 18 | const zoomValue = STOPS[1][0] 19 | const segmentWidth = STOPS[1][1] 20 | const result = getSegmentWidth(zoomValue, 10) 21 | const expected = parseFloat(segmentWidth) 22 | 23 | expect(result).toEqual(expected) 24 | }) 25 | 26 | it('returns the last value of STOPS array if zoom level >= last zoom value and speed != 0', () => { 27 | const lastZoom = STOPS[STOPS.length - 1][0] 28 | const lastWidth = STOPS[STOPS.length - 1][1] 29 | const result = getSegmentWidth(lastZoom + 2, 10) 30 | const expected = parseFloat(lastWidth) 31 | 32 | expect(result).toEqual(expected) 33 | }) 34 | 35 | it('returns the linear interpolation of zoom value using STOPS array if given intermediate zoom level and speed !== 0', () => { 36 | const result = getSegmentWidth(16, 10) 37 | const expected = 3.5 38 | 39 | expect(result).toEqual(expected) 40 | }) 41 | }) 42 | 43 | describe('returns y value (segment width) by calculating linear equation using two given points', () => { 44 | it('returns the linear interpolation of zoom value, given two points in (zoom, width) format', () => { 45 | const point1 = [12, 3] 46 | const point2 = [14, 6] 47 | const zoomValue = 13 48 | const result = getLinearValue(point1[0], point1[1], point2[0], point2[1], zoomValue) 49 | const expected = 4.5 50 | 51 | expect(result).toEqual(expected) 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /src/lib/route-segments.js: -------------------------------------------------------------------------------- 1 | export const STOPS = [[15, '3px'], [17, '4px'], [18, '10px'], [20, '45px']] 2 | // outline is the same line + 2px width, to give 1px on either side 3 | export const OUTLINE_STOPS = [[15, '5px'], [17, '6px'], [18, '12px'], [20, '47px']] 4 | // export const ZERO_SPEED_STOPS = [[15, '0.5px'], [17, '1px'], [18, '2.5px'], [20, '11.25px']] 5 | // export const ZERO_SPEED_OUTLINE_STOPS = [[15, '0px'], [17, '2px'], [18, '3.5px'], [20, '12.25px']] 6 | 7 | // Calculate the slope and y-intercept in order to get linear equation 8 | // Values given as params are (x, y) and (c, d) 9 | export function getLinearValue (x, y, c, d, zoom) { 10 | // Calculating slope 11 | const m = (y - d) / (x - c) 12 | // Calculating y-intercept 13 | const b = y - (m * x) 14 | // Getting linear interpolation of zoom value 15 | const value = (m * zoom) + b 16 | return value 17 | } 18 | 19 | // Replicating how tangram "stops" data structure works 20 | export function getSegmentWidth (zoom, speed) { 21 | // If speed is less than or equal to zero, or not present, use zeroSpeed stops for less weight 22 | // const array = (speed <= 0 || speed === null || typeof speed === 'undefined') ? ZERO_SPEED_STOPS : STOPS 23 | const array = STOPS 24 | const startValue = array[0] 25 | const endValue = array[array.length - 1] 26 | // If zoom values are outside the defined range, 27 | // then they are capped by highest and lowest values in range 28 | if (zoom < startValue[0]) { 29 | return parseFloat(startValue[1]) 30 | } else if (zoom > endValue[0]) { 31 | return parseFloat(endValue[1]) 32 | } 33 | // If they are found in range, use second value in pair 34 | for (let i = 0; i < array.length; i++) { 35 | if (array[i][0] === zoom) { 36 | return parseFloat(array[i][1]) 37 | } else if (zoom < array[i][0]) { 38 | // If they are intermediate zoom levels, interpolate values 39 | const x = array[i][0] 40 | const y = parseFloat(array[i][1]) 41 | const c = array[i - 1][0] 42 | const d = parseFloat(array[i - 1][1]) 43 | return getLinearValue(x, y, c, d, zoom) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | Open Traffic Analyst UI 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | 42 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/components/Sidebar/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import { Icon } from 'semantic-ui-react' 4 | import ErrorMessage from './ErrorMessage' 5 | import AnalysisName from './AnalysisName' 6 | import ModeSelect from './ModeSelect' 7 | import DatePicker from './DatePicker' 8 | import TimeFilters from './TimeFilters' 9 | import ETAView from './ETAView' 10 | import Legend from './Legend' 11 | import ExportData from './ExportData' 12 | import './Sidebar.css' 13 | 14 | class Sidebar extends React.Component { 15 | render () { 16 | let errors = null 17 | if (this.props.errors.length > 0) { 18 | errors = this.props.errors.map(error => ( 19 | 20 | )) 21 | } 22 | 23 | return ( 24 |
25 |
26 | Open Traffic Analyst 27 | 28 |
29 | {errors} 30 | 31 | 32 | {this.props.analysisMode && 33 | 34 | } 35 | {this.props.analysisMode && this.props.date && this.props.date.startDate && 36 | 37 | } 38 | 39 | {this.props.analysisMode && this.props.date && this.props.date.startDate && 40 | 41 | } 42 | {this.props.analysisMode && this.props.date && this.props.date.startDate && 43 | 44 | } 45 |
46 | ) 47 | } 48 | } 49 | 50 | function mapStateToProps (state) { 51 | return { 52 | errors: state.errors.errors, 53 | date: state.date, 54 | analysisMode: state.app.analysisMode, 55 | refSpeedComparisonEnabled: state.app.refSpeedComparisonEnabled, 56 | refSpeedEnabled: state.app.refSpeedEnabled 57 | } 58 | } 59 | 60 | export default connect(mapStateToProps)(Sidebar) 61 | -------------------------------------------------------------------------------- /src/components/Map/Route/RouteLine/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Polyline, LayerGroup } from 'react-leaflet' 4 | import { getSpeedColor } from '../../../../lib/color-ramps' 5 | import { getSegmentWidth } from '../../../../lib/route-segments' 6 | import { displayRouteInfo, removeInfo } from '../../../../app/route-info' 7 | 8 | export default class RouteLine extends React.PureComponent { 9 | static propTypes = { 10 | segments: PropTypes.arrayOf(PropTypes.shape({ 11 | coordinates: PropTypes.array, 12 | color: PropTypes.string 13 | })), 14 | compareEnabled: PropTypes.bool.isRequired, 15 | refEnabled: PropTypes.bool.isRequired, 16 | insertWaypoint: PropTypes.func, 17 | zoom: PropTypes.number.isRequired 18 | } 19 | 20 | static defaultProps = { 21 | segments: [], 22 | insertWaypoint: function () {} 23 | } 24 | 25 | createPolylineBorder () { 26 | return this.props.segments.map((segment, index) => { 27 | return ( 28 | 35 | ) 36 | }) 37 | } 38 | 39 | createPolylineSegment () { 40 | return this.props.segments.map((segment, index) => { 41 | return ( 42 | displayRouteInfo(event, segment)} 49 | onMouseOut={removeInfo} 50 | /> 51 | ) 52 | }) 53 | } 54 | 55 | render () { 56 | if (!this.props.segments || this.props.segments.length === 0) return null 57 | return ( 58 | 59 | {this.createPolylineBorder()} 60 | {this.createPolylineSegment()} 61 | 62 | ) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/app/route-info.js: -------------------------------------------------------------------------------- 1 | let featureFlag = false 2 | let featureInfo 3 | 4 | export function displayRegionInfo (selection) { 5 | // if feature is null, that means it is not a route 6 | if (!selection.feature || !selection.feature.properties.osmlr_id) { 7 | // if a feature info box exists but mouse is not over a route anymore 8 | // remove the feature info box and set featureFlag to false 9 | if (featureFlag) removeInfo() 10 | return 11 | } 12 | // if there is no feature info box yet but mouse is over a route 13 | // create the feature info box 14 | if (!featureFlag) createFeatureInfo() 15 | setPosition(selection.pixel.x, selection.pixel.y) 16 | /* eslint-disable camelcase */ 17 | const { speed, osmlr_id, id, percentDiff, refSpeed } = selection.feature.properties 18 | featureInfo.innerHTML = 19 | `

SPEED: ${speed ? speed.toFixed(2) : 0} kph
20 | REFERENCE: ${refSpeed ? refSpeed.toFixed(2) : 0} kph
21 | OSMLR_ID: ${osmlr_id}
22 | ID: ${id}
23 | PERCENT DIFF: ${percentDiff ? percentDiff.toFixed(2) : 0}%
24 |

` 25 | /* eslint-enable camelcase */ 26 | } 27 | 28 | function createFeatureInfo () { 29 | featureInfo = document.createElement('div') 30 | featureInfo.setAttribute('class', 'feature-info') 31 | window.map.getContainer().appendChild(featureInfo) 32 | featureFlag = true 33 | } 34 | 35 | function setPosition (left, top) { 36 | featureInfo.style.left = (left + 10) + 'px' 37 | featureInfo.style.top = (top + 15) + 'px' 38 | } 39 | 40 | export function removeInfo () { 41 | if (featureFlag) { 42 | featureInfo.parentNode.removeChild(featureInfo) 43 | featureFlag = false 44 | } 45 | } 46 | 47 | export function displayRouteInfo (event, selection) { 48 | if (!featureFlag) createFeatureInfo() 49 | setPosition(event.containerPoint.x, event.containerPoint.y) 50 | const { speed, id, tileIdx, segmentIdx, percentDiff, refSpeed } = selection.properties 51 | 52 | featureInfo.innerHTML = 53 | `

SPEED: ${speed ? speed.toFixed(2) : 0} kph
54 | REFERENCE: ${refSpeed ? refSpeed.toFixed(2) : 0} kph
55 | ID: ${id}
56 | SEGMENT: ${segmentIdx}
57 | TILE: ${tileIdx}
58 | PERCENT DIFF: ${speed != null && percentDiff ? percentDiff.toFixed(2) : 0}%
59 |

` 60 | } 61 | -------------------------------------------------------------------------------- /src/components/Sidebar/ExportData/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { connect } from 'react-redux' 4 | import { Segment, Header, Button, Icon } from 'semantic-ui-react' 5 | import { exportData } from '../../../lib/exporter' 6 | import './ExportData.css' 7 | 8 | class ExportData extends React.Component { 9 | static propTypes = { 10 | geoJSON: PropTypes.object, 11 | analysisName: PropTypes.string, 12 | analysisMode: PropTypes.string, 13 | date: PropTypes.object, 14 | route: PropTypes.object, 15 | refSpeedComparisonEnabled: PropTypes.bool 16 | } 17 | 18 | constructor (props) { 19 | super(props) 20 | 21 | this.state = { 22 | message: null 23 | } 24 | } 25 | 26 | onClickButton = (format) => { 27 | const result = exportData(this.props.geoJSON, this.props.analysisName, format, this.props.analysisMode, this.props.date, this.props.refSpeedComparisonEnabled, this.props.route) 28 | if (result === false) { 29 | this.setState({ 30 | message: 'There is no data to download.' 31 | }) 32 | } else { 33 | this.setState({ 34 | message: null 35 | }) 36 | } 37 | } 38 | 39 | render () { 40 | const message = (this.state.message) ?

{this.state.message}

: null 41 | 42 | return ( 43 | 44 |
45 |
Export
46 | 47 |
48 | 49 |