├── src ├── components │ ├── Saved │ │ ├── styles.scss │ │ └── index.js │ ├── RouteList │ │ ├── styles.scss │ │ ├── RouteItem │ │ │ ├── StopsButton.scss │ │ │ ├── SaveButton.scss │ │ │ ├── StopsButton.js │ │ │ ├── index.js │ │ │ ├── styles.scss │ │ │ └── SaveButton.js │ │ └── index.js │ ├── Map │ │ ├── styles.scss │ │ ├── StopMarker │ │ │ ├── index.js │ │ │ └── styles.scss │ │ ├── UserMarker │ │ │ ├── index.js │ │ │ └── styles.scss │ │ ├── StopPopup │ │ │ ├── index.js │ │ │ └── styles.scss │ │ ├── index.js │ │ ├── CanvasOverlay.js │ │ └── MapboxWrapper.js │ ├── Spinner │ │ ├── index.js │ │ └── styles.scss │ ├── StopList │ │ ├── styles.scss │ │ ├── StopGroupSwitcher │ │ │ ├── StopGroupSwitch.js │ │ │ ├── styles.scss │ │ │ └── index.js │ │ └── index.js │ ├── NavBar │ │ ├── index.js │ │ ├── RoutesButton │ │ │ ├── styles.scss │ │ │ └── index.js │ │ ├── SavedButton │ │ │ ├── styles.scss │ │ │ └── index.js │ │ └── styles.scss │ ├── VehiclesLoading │ │ ├── index.js │ │ └── styles.scss │ ├── ContextMenu │ │ ├── styles.scss │ │ └── index.js │ └── App.js ├── constants │ ├── Paths.js │ ├── InitialState.js │ └── ActionTypes.js ├── reducers │ ├── index.js │ ├── data │ │ ├── saved.js │ │ └── index.js │ └── ui │ │ ├── modal.js │ │ ├── loading.js │ │ └── index.js ├── libs │ ├── mobile.js │ ├── routing.js │ └── oba.js ├── actions │ ├── routing.js │ ├── location.js │ ├── ui.js │ ├── saved.js │ └── oba.js ├── redux │ ├── DevTools.js │ └── configureStore.js ├── styles │ ├── _globals.scss │ ├── _reset.scss │ └── base.scss ├── index.js ├── index.tmpl.html └── selectors │ └── oba.js ├── .gitignore ├── .editorconfig ├── .babelrc ├── deploy.js ├── .eslintrc ├── .travis.yml ├── LICENSE.md ├── README.md ├── webpack.config.js ├── webpack.production.config.js └── package.json /src/components/Saved/styles.scss: -------------------------------------------------------------------------------- 1 | .loading { 2 | height: 100%; 3 | width: 100%; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/RouteList/styles.scss: -------------------------------------------------------------------------------- 1 | .loading { 2 | height: 100%; 3 | width: 100%; 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | **/*.bundle.js* 4 | npm-debug.log 5 | dist 6 | .publish 7 | npm-debug.log* 8 | -------------------------------------------------------------------------------- /src/components/Map/styles.scss: -------------------------------------------------------------------------------- 1 | .map { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | right: 0; 6 | bottom: 0; 7 | background: #fff; 8 | } 9 | -------------------------------------------------------------------------------- /src/components/Spinner/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styles from './styles.scss'; 4 | 5 | export default () => ( 6 |
7 | ); 8 | -------------------------------------------------------------------------------- /src/constants/Paths.js: -------------------------------------------------------------------------------- 1 | export const ALL_ROUTES_PATH = 'routes'; 2 | export const ROUTE_PATH = 'route'; 3 | export const DIRECTION_PATH = 'direction'; 4 | export const SAVED_PATH = 'saved'; 5 | -------------------------------------------------------------------------------- /src/components/Map/StopMarker/index.js: -------------------------------------------------------------------------------- 1 | import styles from './styles.scss'; 2 | 3 | const StopMarker = { 4 | className: styles.stop, 5 | iconSize: [12, 12], 6 | }; 7 | export default StopMarker; 8 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import ui from 'reducers/ui'; 4 | import data from 'reducers/data'; 5 | 6 | export default combineReducers({ 7 | ui, 8 | data, 9 | }); 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*.{js,eslintrc,scss}] 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /src/components/Map/UserMarker/index.js: -------------------------------------------------------------------------------- 1 | import styles from './styles.scss'; 2 | 3 | const html = `
`; 4 | 5 | const UserMarker = { 6 | className: styles.user, 7 | iconSize: [24, 24], 8 | html, 9 | }; 10 | export default UserMarker; 11 | -------------------------------------------------------------------------------- /src/components/Map/StopPopup/index.js: -------------------------------------------------------------------------------- 1 | import styles from './styles.scss'; 2 | 3 | export default function stopPopup(name) { 4 | return `
${name}
`; 5 | } 6 | -------------------------------------------------------------------------------- /src/libs/mobile.js: -------------------------------------------------------------------------------- 1 | export let iOS = (navigator.userAgent.indexOf('iPhone OS') > -1) || (navigator.userAgent.indexOf('iPad') > -1) 2 | export let android = navigator.userAgent.indexOf('Android') > -1 3 | export let windowsPhone = navigator.userAgent.indexOf('Windows Phone') > -1 4 | export let mobile = iOS || android || windowsPhone 5 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react", "stage-0"], 3 | "env": { 4 | "development": { 5 | "plugins": [["react-transform", { 6 | "transforms": [{ 7 | "transform": "react-transform-catch-errors", 8 | "imports": [ 9 | "react", 10 | "redbox-react" 11 | ] 12 | }] 13 | }]] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/libs/routing.js: -------------------------------------------------------------------------------- 1 | import { createHashHistory } from 'history'; 2 | import uniloc from 'uniloc'; 3 | 4 | export const GlobalHistory = createHashHistory({ 5 | queryKey: false, 6 | }); 7 | 8 | export const Router = uniloc( 9 | { 10 | routes: 'GET /', 11 | saved: 'GET /saved', 12 | route: 'GET /route/:routeId', 13 | direction: 'GET /route/:routeId/:routeDirection', 14 | }, 15 | ); 16 | -------------------------------------------------------------------------------- /src/components/Spinner/styles.scss: -------------------------------------------------------------------------------- 1 | @import 'globals'; 2 | 3 | @keyframes spinner { 4 | 0% { transform: rotate(0deg); } 5 | 100% { transform: rotate(360deg); } 6 | } 7 | 8 | .spinner { 9 | margin: 18px auto; 10 | width: 18px; 11 | height: 18px; 12 | border: solid 2px $blue; 13 | border-bottom-color: transparent; 14 | border-radius: 50%; 15 | animation: spinner 400ms linear infinite; 16 | } 17 | -------------------------------------------------------------------------------- /src/actions/routing.js: -------------------------------------------------------------------------------- 1 | import { GlobalHistory } from 'libs/routing'; 2 | import { SET_PATHNAME } from 'constants/ActionTypes'; 3 | 4 | export function setPathname(payload) { 5 | return { 6 | type: SET_PATHNAME, 7 | payload, 8 | }; 9 | } 10 | 11 | export function setupRouter(store) { 12 | GlobalHistory.listen((location) => { 13 | store.dispatch(setPathname(location.pathname)); 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /src/components/StopList/styles.scss: -------------------------------------------------------------------------------- 1 | .info { 2 | text-align: center; 3 | padding: 25px 0 25px; 4 | } 5 | 6 | .routeNumber { 7 | font-size: 24px; 8 | } 9 | 10 | .routeName { 11 | margin-top: 15px; 12 | font-size: 16px; 13 | } 14 | 15 | .stopRow { 16 | height: 60px; 17 | width: 100%; 18 | border-top: 1px #e0e0e0 solid; 19 | cursor: default; 20 | line-height: 59px; 21 | padding-left: 20px; 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Map/StopMarker/styles.scss: -------------------------------------------------------------------------------- 1 | @import 'globals'; 2 | 3 | .stop { 4 | height: 12px; 5 | width: 12px; 6 | border-radius: 50%; 7 | background: $blue; 8 | } 9 | 10 | .popup { 11 | top: -60px; 12 | left: -90px; 13 | position: absolute; 14 | height: 70px; 15 | width: 180px; 16 | background: #fff; 17 | display: none; 18 | z-index: 9999; 19 | } 20 | 21 | .stop:hover .popup { 22 | display: block; 23 | } 24 | -------------------------------------------------------------------------------- /src/components/NavBar/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import RoutesButton from './RoutesButton'; 4 | import SavedButton from './SavedButton'; 5 | 6 | import styles from './styles.scss'; 7 | 8 | export default class NavBar extends Component { 9 | render() { 10 | return ( 11 |
12 | 13 | 14 |
15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/redux/DevTools.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 | export default createDevTools( 7 | 13 | 14 | 15 | ); 16 | -------------------------------------------------------------------------------- /src/reducers/data/saved.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import InitialState from 'constants/InitialState'; 4 | 5 | import { 6 | SET_SAVED_ROUTES, 7 | } from 'constants/ActionTypes'; 8 | 9 | 10 | function savedRoutes(state = InitialState.data.saved.savedRoutes, action = {}) { 11 | if (action.type === SET_SAVED_ROUTES) { 12 | return action.payload; 13 | } 14 | return state; 15 | } 16 | 17 | export default combineReducers({ 18 | savedRoutes, 19 | }); 20 | -------------------------------------------------------------------------------- /src/components/NavBar/RoutesButton/styles.scss: -------------------------------------------------------------------------------- 1 | @import 'globals'; 2 | 3 | .btn { 4 | width: 50%; 5 | height: 100%; 6 | color: $grayText; 7 | font-size: 16px; 8 | line-height: 50px; 9 | text-align: center; 10 | cursor: pointer; 11 | display: inline-block; 12 | } 13 | 14 | .active { 15 | color: $blue; 16 | @media (min-width: $desktop) { 17 | pointer-events: none; 18 | } 19 | } 20 | 21 | .minimized { 22 | @media (max-width: $desktop - 1px) { 23 | color: $grayText; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/NavBar/SavedButton/styles.scss: -------------------------------------------------------------------------------- 1 | @import 'globals'; 2 | 3 | .btn { 4 | width: 50%; 5 | height: 100%; 6 | color: $grayText; 7 | font-size: 16px; 8 | line-height: 50px; 9 | text-align: center; 10 | cursor: pointer; 11 | display: inline-block; 12 | } 13 | 14 | .active { 15 | color: $blue; 16 | @media (min-width: $desktop) { 17 | pointer-events: none; 18 | } 19 | } 20 | 21 | .minimized { 22 | @media (max-width: $desktop - 1px) { 23 | color: $grayText; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/styles/_globals.scss: -------------------------------------------------------------------------------- 1 | $iphone: 375px; 2 | $desktop: 960px; 3 | 4 | $blue: #157AFC; 5 | $grayText: #606060; 6 | $routeBtnBorder: #DEE9EF; 7 | $routeBtnText: #577B8E; 8 | $routeBtnIconStroke: #267EAB; 9 | 10 | .whiteBox { 11 | box-shadow: 0 0.5px 2px rgba(0,0,0,0.5); 12 | background: #fff; 13 | } 14 | 15 | .routeBtn { 16 | vertical-align: top; 17 | height: 44px; 18 | border-radius: 4px; 19 | border: 1px solid; 20 | display: inline-block; 21 | padding: 0 15px; 22 | color: $routeBtnText; 23 | } 24 | -------------------------------------------------------------------------------- /deploy.js: -------------------------------------------------------------------------------- 1 | var ghpages = require('gh-pages'); 2 | var path = require('path'); 3 | 4 | const options = !process.env.GH_TOKEN ? null : { 5 | repo: 'https://' + process.env.GH_TOKEN + '@github.com/open-austin/instabus.git', 6 | user: { 7 | name: 'Travis CI', 8 | email: 'hack@open-austin.org', 9 | }, 10 | }; 11 | 12 | ghpages.publish(path.join(__dirname, 'dist'), options, function (err) { 13 | if (err) { 14 | console.error(err); 15 | } 16 | else { 17 | console.log('available on gh page'); 18 | } 19 | }); 20 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "parser": "babel-eslint", 4 | "plugins": [ 5 | "flow-vars", 6 | "babel" 7 | ], 8 | "env": { 9 | "mocha": true 10 | }, 11 | "ecmaFeatures": { 12 | "experimentalObjectRestSpread": true, 13 | "classes": true 14 | }, 15 | "rules": { 16 | "strict": 0, 17 | "brace-style": [2, "stroustrup", {"allowSingleLine": true}], 18 | "max-len": 0, 19 | "no-unused-vars": [1, { "vars": "all", "args": "after-used" }], 20 | "flow-vars/define-flow-type": 1, 21 | "flow-vars/use-flow-type": 1, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/components/NavBar/styles.scss: -------------------------------------------------------------------------------- 1 | @import 'globals'; 2 | 3 | .nav { 4 | @extend .whiteBox; 5 | position: fixed; 6 | height: 50px; 7 | border-radius: 4px; 8 | overflow: hidden; 9 | width: calc(100% - 20px); 10 | max-width: 354px; 11 | line-height: 0; 12 | font-size: 0; 13 | right: 10px; 14 | @media (min-width: $iphone) and (max-width: $desktop - 1px) { 15 | right: calc(50% - 177px); 16 | } 17 | @media (max-width: $desktop - 1px) { 18 | bottom: 10px; 19 | } 20 | @media (min-width: $desktop) { 21 | top: 10px; 22 | width: 300px; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/components/RouteList/RouteItem/StopsButton.scss: -------------------------------------------------------------------------------- 1 | @import 'globals'; 2 | 3 | .stops { 4 | @extend .routeBtn; 5 | margin-left: 10px; 6 | border-color: $routeBtnBorder; 7 | @media (min-width: $desktop) { 8 | display: none; 9 | } 10 | } 11 | 12 | .stops.hover:hover, .stops.pressed { 13 | border-color: $routeBtnText; 14 | } 15 | 16 | .icon { 17 | margin-top: 12px; 18 | height: 18px; 19 | width: 18px; 20 | border-radius: 3px; 21 | display: inline-block; 22 | border: 1px solid $routeBtnIconStroke; 23 | fill: none; 24 | } 25 | 26 | .label { 27 | margin-left: 13px; 28 | font-size: 14px; 29 | line-height: 42px; 30 | display: inline-block; 31 | vertical-align: top; 32 | } 33 | -------------------------------------------------------------------------------- /src/components/Map/StopPopup/styles.scss: -------------------------------------------------------------------------------- 1 | @import 'globals'; 2 | 3 | .wrap { 4 | 5 | } 6 | 7 | .popup { 8 | transform: translateX(50%); 9 | display: flex; 10 | align-items: center; 11 | } 12 | 13 | .arrowWrap { 14 | position: relative; 15 | height: 14px; 16 | padding: 1px 0; 17 | width: 6px; 18 | overflow: hidden; 19 | z-index: 2; 20 | } 21 | 22 | .arrow { 23 | width: 0; 24 | height: 0; 25 | border-top: 6px solid transparent; 26 | border-bottom: 6px solid transparent; 27 | border-right: 6px solid #fff; 28 | } 29 | 30 | .text { 31 | @extend .whiteBox; 32 | position: relative; 33 | height: 28px; 34 | padding: 0 8px; 35 | border-radius: 4px; 36 | line-height: 28px; 37 | color: #000; 38 | z-index: 1; 39 | } 40 | -------------------------------------------------------------------------------- /src/styles/_reset.scss: -------------------------------------------------------------------------------- 1 | html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{border:0;font-size:100%;font:inherit;vertical-align:baseline;margin:0;padding:0}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:none}table{border-collapse:collapse;border-spacing:0} -------------------------------------------------------------------------------- /src/constants/InitialState.js: -------------------------------------------------------------------------------- 1 | export default { 2 | ui: { 3 | globalError: null, 4 | currentAgency: 1, 5 | route: { 6 | name: 'routes', 7 | options: {}, 8 | }, 9 | userLocation: null, 10 | initialVehiclesLoaded: false, 11 | loading: { 12 | routes: false, 13 | vehicles: false, 14 | stops: false, 15 | }, 16 | modal: { 17 | routes: false, 18 | saved: false, 19 | stops: false, 20 | }, 21 | }, 22 | data: { 23 | routes: { 24 | orderedRoutes: [], 25 | routesById: {}, 26 | }, 27 | stopGroups: {}, 28 | vehicles: { 29 | allVehicles: [], 30 | vehiclesByRoute: {}, 31 | }, 32 | saved: { 33 | savedRoutes: [], 34 | }, 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /src/actions/location.js: -------------------------------------------------------------------------------- 1 | import { 2 | SET_USER_LOCATION, 3 | } from 'constants/ActionTypes'; 4 | 5 | export function setUserLocation(payload) { 6 | return { 7 | type: SET_USER_LOCATION, 8 | payload, 9 | }; 10 | } 11 | 12 | export function watchUserLocation() { 13 | return (dispatch) => { 14 | navigator.geolocation.watchPosition( 15 | (position) => { 16 | const location = { 17 | lat: position.coords.latitude, 18 | lon: position.coords.longitude, 19 | }; 20 | dispatch(setUserLocation(location)); 21 | }, 22 | () => { 23 | dispatch(setUserLocation(null)); 24 | }, 25 | { 26 | enableHighAccuracy: true, 27 | timeout: 60000, 28 | maximumAge: 0, 29 | } 30 | ); 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/components/VehiclesLoading/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import styles from './styles.scss'; 5 | 6 | class VehiclesLoading extends Component { 7 | static propTypes = { 8 | initialVehiclesLoaded: PropTypes.bool, 9 | } 10 | 11 | render() { 12 | if (!this.props.initialVehiclesLoaded) { 13 | return ( 14 |
15 |
16 |
Locating Buses
17 |
18 | ); 19 | } 20 | 21 | return null; 22 | } 23 | } 24 | 25 | const mapStateToProps = (state) => ({ 26 | initialVehiclesLoaded: state.ui.initialVehiclesLoaded, 27 | }); 28 | 29 | export default connect(mapStateToProps)(VehiclesLoading); 30 | -------------------------------------------------------------------------------- /src/components/StopList/StopGroupSwitcher/StopGroupSwitch.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import _ from 'lodash'; 3 | 4 | import classNames from 'classnames'; 5 | 6 | import styles from './styles.scss'; 7 | 8 | export default class StopGroupSwitch extends Component { 9 | static propTypes = { 10 | direction: PropTypes.string, 11 | checked: PropTypes.bool, 12 | }; 13 | 14 | render() { 15 | const { direction, checked } = this.props; 16 | const stopDirection = _.startCase(direction); 17 | const labelStyle = classNames(styles.label, { 18 | [`${styles.checked}`]: checked, 19 | }); 20 | return ( 21 |
22 |
23 | {stopDirection} 24 |
25 |
26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/components/VehiclesLoading/styles.scss: -------------------------------------------------------------------------------- 1 | .wrap { 2 | position: absolute; 3 | height: 26px; 4 | width: 128px; 5 | background: rgba(0,0,0,0.8); 6 | color: #fff; 7 | top: calc(50% - 15px); 8 | left: calc(50% - 64px); 9 | border-radius: 4px; 10 | font-size: 0; 11 | line-height: 0; 12 | } 13 | 14 | @keyframes spinner { 15 | 0% { transform: rotate(0deg); } 16 | 100% { transform: rotate(360deg); } 17 | } 18 | 19 | .spinner { 20 | display: inline-block; 21 | margin: 6px; 22 | width: 14px; 23 | height: 14px; 24 | border: solid 2px #fff; 25 | border-bottom-color: transparent; 26 | border-radius: 50%; 27 | animation: spinner 400ms linear infinite; 28 | } 29 | 30 | .text { 31 | vertical-align: top; 32 | font-size: 14px; 33 | display: inline-block; 34 | height: 26px; 35 | line-height: 26px; 36 | text-align: center; 37 | } 38 | -------------------------------------------------------------------------------- /src/actions/ui.js: -------------------------------------------------------------------------------- 1 | import { 2 | SET_GLOBAL_ERROR, 3 | SET_REACT_LOADED, 4 | SET_ROUTES_MODAL, 5 | SET_SAVED_MODAL, 6 | SET_STOPS_MODAL, 7 | } from 'constants/ActionTypes'; 8 | 9 | export function setGlobalError(errorMessage) { 10 | return { 11 | type: SET_GLOBAL_ERROR, 12 | payload: errorMessage, 13 | }; 14 | } 15 | 16 | export function setReactLoaded() { 17 | return { 18 | type: SET_REACT_LOADED, 19 | }; 20 | } 21 | 22 | export function setRoutesModal(visible) { 23 | return { 24 | type: SET_ROUTES_MODAL, 25 | payload: visible, 26 | }; 27 | } 28 | 29 | export function setSavedModal(visible) { 30 | return { 31 | type: SET_SAVED_MODAL, 32 | payload: visible, 33 | }; 34 | } 35 | 36 | export function setStopsModal(visible) { 37 | return { 38 | type: SET_STOPS_MODAL, 39 | payload: visible, 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src/constants/ActionTypes.js: -------------------------------------------------------------------------------- 1 | export const SET_GLOBAL_ERROR = 'SET_GLOBAL_ERROR'; 2 | 3 | export const SET_PATHNAME = 'SET_PATHNAME'; 4 | 5 | export const SET_USER_LOCATION = 'SET_USER_LOCATION'; 6 | 7 | export const SET_SELECTED_ROUTE = 'SET_SELECTED_ROUTE'; 8 | export const SET_ROUTES = 'SET_ROUTES'; 9 | export const SET_ROUTES_LOADING = 'SET_ROUTES_LOADING'; 10 | export const SET_VEHICLES = 'SET_VEHICLES'; 11 | export const SET_VEHICLES_LOADING = 'SET_VEHICLES_LOADING'; 12 | export const INITIAL_VEHICLES_LOADED = 'INITIAL_VEHICLES_LOADED'; 13 | export const SET_STOPS = 'SET_STOPS'; 14 | export const SET_STOPS_LOADING = 'SET_STOPS_LOADING'; 15 | 16 | export const SET_SAVED_ROUTES = 'SET_SAVED_ROUTES'; 17 | 18 | export const SET_ROUTES_MODAL = 'SET_ROUTES_MODAL'; 19 | export const SET_SAVED_MODAL = 'SET_SAVED_MODAL'; 20 | export const SET_STOPS_MODAL = 'SET_STOPS_MODAL'; 21 | -------------------------------------------------------------------------------- /src/components/ContextMenu/styles.scss: -------------------------------------------------------------------------------- 1 | @import 'globals'; 2 | 3 | .context { 4 | @extend .whiteBox; 5 | position: fixed; 6 | bottom: 70px; 7 | right: 10px; 8 | width: calc(100% - 20px); 9 | max-width: 354px; 10 | height: auto; 11 | max-height: calc(100% - 80px); 12 | overflow-y: scroll; 13 | overflow-x: hidden; 14 | border-radius: 4px; 15 | -webkit-overflow-scrolling: touch; 16 | @media (min-width: $iphone) { 17 | right: calc(50% - 177px); 18 | } 19 | @media (max-width: $desktop - 1px) and (min-height: 500px) { 20 | max-height: 400px; 21 | } 22 | @media (min-width: $desktop) { 23 | right: 10px; 24 | top: 70px; 25 | width: 300px; 26 | bottom: auto; 27 | max-height: calc(100% - 80px); 28 | } 29 | } 30 | 31 | .iOS { 32 | padding: 1px 0; 33 | } 34 | 35 | .minimized { 36 | @media (max-width: $desktop - 1px) { 37 | display: none; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import FastClick from 'fastclick'; 5 | 6 | import { mobile } from 'libs/mobile'; 7 | 8 | import DevTools from 'redux/DevTools'; 9 | import configureStore from 'redux/configureStore'; 10 | 11 | import App from 'components/App'; 12 | 13 | import { setupRouter } from 'actions/routing'; 14 | 15 | FastClick.attach(document.body); 16 | 17 | const store = window.store = configureStore(); 18 | 19 | setupRouter(store); 20 | 21 | const renderDevTools = () => { 22 | if (!mobile && process.env.NODE_ENV !== 'production') { 23 | return ; 24 | } 25 | 26 | return null; 27 | }; 28 | 29 | render( 30 | 31 |
32 | 33 | { renderDevTools() } 34 |
35 |
, 36 | document.getElementById('root') 37 | ); 38 | -------------------------------------------------------------------------------- /src/components/Map/UserMarker/styles.scss: -------------------------------------------------------------------------------- 1 | @import 'globals'; 2 | 3 | .user { 4 | height: 24px; 5 | width: 24px; 6 | border-radius: 50%; 7 | position: relative; 8 | transition: all 200ms linear; 9 | } 10 | 11 | @keyframes userPulse { 12 | 0% { 13 | transform: scale(0.49); 14 | } 15 | 10% { 16 | opacity: 1; 17 | } 18 | 100% { 19 | transform: scale(1); 20 | opacity: 0; 21 | } 22 | } 23 | 24 | .pulse { 25 | height: 24px; 26 | width: 24px; 27 | border-radius: 50%; 28 | background: rgba(21, 122, 252, 0.6); 29 | border-width: 1px; 30 | border-type: solid; 31 | border-color: $blue; 32 | animation: userPulse 1.5s infinite; 33 | } 34 | 35 | .dot { 36 | position: absolute; 37 | top: 6px; 38 | left: 6px; 39 | border: 2px solid #fff; 40 | height: 12px; 41 | width: 12px; 42 | border-radius: 50%; 43 | background: $blue; 44 | box-shadow: 0 1px 4px rgba(0,0,0,0.4); 45 | } 46 | -------------------------------------------------------------------------------- /src/reducers/ui/modal.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import InitialState from 'constants/InitialState'; 3 | 4 | import { 5 | SET_ROUTES_MODAL, 6 | SET_SAVED_MODAL, 7 | SET_STOPS_MODAL, 8 | } from 'constants/ActionTypes'; 9 | 10 | function routesModal(state = InitialState.ui.modal.routes, action = {}) { 11 | if (action.type === SET_ROUTES_MODAL) { 12 | return action.payload; 13 | } 14 | return state; 15 | } 16 | 17 | function savedModal(state = InitialState.ui.modal.saved, action = {}) { 18 | if (action.type === SET_SAVED_MODAL) { 19 | return action.payload; 20 | } 21 | return state; 22 | } 23 | 24 | function stopsModal(state = InitialState.ui.modal.stops, action = {}) { 25 | if (action.type === SET_STOPS_MODAL) { 26 | return action.payload; 27 | } 28 | return state; 29 | } 30 | 31 | export default combineReducers({ 32 | routes: routesModal, 33 | saved: savedModal, 34 | stops: stopsModal, 35 | }); 36 | -------------------------------------------------------------------------------- /src/reducers/data/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import InitialState from 'constants/InitialState'; 4 | 5 | import { 6 | SET_ROUTES, 7 | SET_VEHICLES, 8 | SET_STOPS, 9 | } from 'constants/ActionTypes'; 10 | 11 | import saved from './saved'; 12 | 13 | function routes(state = InitialState.data.routes, action = {}) { 14 | if (action.type === SET_ROUTES) { 15 | return action.payload; 16 | } 17 | return state; 18 | } 19 | 20 | function vehicles(state = InitialState.data.vehicles, action = {}) { 21 | if (action.type === SET_VEHICLES) { 22 | return action.payload; 23 | } 24 | return state; 25 | } 26 | 27 | function stopGroups(state = InitialState.data.stopGroups, action = {}) { 28 | if (action.type === SET_STOPS) { 29 | return { 30 | ...state, 31 | ...action.payload, 32 | }; 33 | } 34 | return state; 35 | } 36 | 37 | export default combineReducers({ 38 | routes, 39 | vehicles, 40 | stopGroups, 41 | saved, 42 | }); 43 | -------------------------------------------------------------------------------- /src/components/StopList/StopGroupSwitcher/styles.scss: -------------------------------------------------------------------------------- 1 | @import 'globals'; 2 | 3 | .sliderWrap { 4 | width: calc(100% - 20px); 5 | height: 30px; 6 | margin: 10px 10px 0; 7 | position: relative; 8 | } 9 | 10 | .toggles { 11 | height: 30px; 12 | width: 100%; 13 | border-radius: 15px; 14 | border: 1px $routeBtnBorder solid; 15 | } 16 | 17 | .slider { 18 | position: absolute; 19 | top: 0; 20 | left: 0; 21 | bottom: 0; 22 | border: 1px $routeBtnIconStroke solid; 23 | width: 50%; 24 | border-radius: 15px; 25 | transition: transform ease-in 0.2s; 26 | pointer-events: none; 27 | } 28 | 29 | .label { 30 | display: inline-block; 31 | width: 50%; 32 | height: 100%; 33 | line-height: 28px; 34 | text-align: center; 35 | cursor: pointer; 36 | } 37 | 38 | .labelText { 39 | pointer-events: none; 40 | color: $routeBtnText; 41 | } 42 | 43 | .hover:hover .labelText, .pressed .labelText { 44 | color: $routeBtnBorder; 45 | } 46 | 47 | .input { 48 | display: none; 49 | } 50 | -------------------------------------------------------------------------------- /src/reducers/ui/loading.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import InitialState from 'constants/InitialState'; 3 | 4 | import { 5 | SET_ROUTES_LOADING, 6 | SET_VEHICLES_LOADING, 7 | SET_STOPS_LOADING, 8 | } from 'constants/ActionTypes'; 9 | 10 | function routesLoading(state = InitialState.ui.loading.routes, action = {}) { 11 | if (action.type === SET_ROUTES_LOADING) { 12 | return action.payload; 13 | } 14 | return state; 15 | } 16 | 17 | function vehiclesLoading(state = InitialState.ui.loading.vehicles, action = {}) { 18 | if (action.type === SET_VEHICLES_LOADING) { 19 | return action.payload; 20 | } 21 | return state; 22 | } 23 | 24 | function stopsLoading(state = InitialState.ui.loading.stops, action = {}) { 25 | if (action.type === SET_STOPS_LOADING) { 26 | return action.payload; 27 | } 28 | return state; 29 | } 30 | 31 | export default combineReducers({ 32 | routes: routesLoading, 33 | vehicles: vehiclesLoading, 34 | stops: stopsLoading, 35 | }); 36 | -------------------------------------------------------------------------------- /src/components/RouteList/RouteItem/SaveButton.scss: -------------------------------------------------------------------------------- 1 | @import 'globals'; 2 | 3 | .save { 4 | @extend .routeBtn; 5 | border-color: $routeBtnBorder; 6 | } 7 | 8 | .save.hover:hover, .save.pressed { 9 | border-color: $routeBtnText; 10 | } 11 | 12 | .saved { 13 | @extend .routeBtn; 14 | color: #ABABAB; 15 | border-color: #e0e0e0; 16 | } 17 | 18 | .saved.hover:hover, .saved.pressed { 19 | border-color: #ABABAB; 20 | } 21 | 22 | .icon { 23 | margin-top: 11px; 24 | height: 21px; 25 | display: inline-block; 26 | stroke: $routeBtnIconStroke; 27 | fill: none; 28 | } 29 | 30 | .saved .icon { 31 | @extend .icon; 32 | stroke: #ccc; 33 | } 34 | 35 | .label { 36 | min-width: 40px; 37 | margin-left: 9px; 38 | font-size: 14px; 39 | line-height: 42px; 40 | display: inline-block; 41 | vertical-align: top; 42 | @media (min-width: $desktop) { 43 | text-align: center; 44 | min-width: 81px; 45 | } 46 | } 47 | 48 | .extra { 49 | @media (max-width: $desktop - 1px) { 50 | display: none; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/styles/base.scss: -------------------------------------------------------------------------------- 1 | @import 'reset'; 2 | @import 'globals'; 3 | 4 | html { 5 | font-family: Arial; 6 | font-size: 14px; 7 | box-sizing: border-box; 8 | } 9 | 10 | *:focus { 11 | outline: 0; 12 | } 13 | 14 | *, *:before, *:after { 15 | box-sizing: inherit; 16 | } 17 | 18 | :root { 19 | font-family: Arial; 20 | font-size: 14px; 21 | user-select: none; 22 | -webkit-font-smoothing: subpixel-antialiased; 23 | -webkit-text-size-adjust: 100%; 24 | -webkit-tap-highlight-color: rgba(0,0,0,0); 25 | } 26 | 27 | body { 28 | width: 100vw; 29 | background: #f2f2f2; 30 | overflow-x: hidden; 31 | } 32 | 33 | input { 34 | font-family: Arial; 35 | font-size: 14px; 36 | -webkit-appearance: none; 37 | -webkit-font-smoothing: subpixel-antialiased; 38 | padding: 0; 39 | -webkit-text-size-adjust: 100%; 40 | } 41 | 42 | a { 43 | text-decoration: none; 44 | -webkit-tap-highlight-color: rgba(0,0,0,0); 45 | } 46 | 47 | .container { 48 | position: fixed; 49 | top: 0; 50 | left: 0; 51 | right: 0; 52 | bottom: 0; 53 | } 54 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "5.0" 5 | 6 | env: 7 | matrix: 8 | - NPM_2=true 9 | 10 | env: 11 | global: 12 | - secure: "j4y6Ch308DXw+8/nZoiWdCUqyKg+L+uOpV0pUjqJQbbsC7ktZFJEk7VhH/yYYq1rDlRsBg6nPzVKeJ3Bah6F2EO7GhfEkASWmsaL6d/NGVN481PGlpTOi59JbWK8NjcEriSMl35YcdfM+yL+sb4CVjGVJoN+wHbUXDtZGKZMwzgDbqXWCXULzpa+P7FIR8Ooq0y0QHLvv1sVtcmU3R1vA9rL/MEhDQHEGWBXjahOPY826NheHy9N1GjYi6KUo1iVwF/0GVsku75myn63BQ3RqmYxIry6nO5UH7Bmizzex04WrQrMXHbIhP2deo1ykMoq2G9MRYKOK1geKYVdj2sX7m5TOHgYRo77oSjJqbJ816L/doJm9yGyIRC96zF3+o+Ac4C3YZzWpmrl1JQDVs+R+I6mb0H0/2fcW9RiYph1mBKVmLoxKdch9DPstMKT/t2IL9Di57eD3xmF8Z/7tIfFPD4s7oEJsZ+HfAU5u2M0UrOhNF4EAUATPxkBWHQsUw9OQivC0QdoWdxOvTaQJNBw0NKpD41jYHLjEIVfGhqF4AhbVBEJiV2tDzA6KhHpJUwfGY4+2odWktpJsclQrM/zoogSWL+KEkN1RVKRznImNzXLWUQ6xTyOvlSOr1HzLQ8BgAB3okvb+mzTOE0rTge3fNJY/kPXWMHJeJXHsfANqIg=" 13 | 14 | script: 15 | - npm --version 16 | 17 | - npm install 18 | - npm run test 19 | 20 | cache: 21 | directories: 22 | - node_modules 23 | 24 | deploy: 25 | skip_cleanup: true 26 | provider: script 27 | script: npm run deploy 28 | on: 29 | branch: master 30 | -------------------------------------------------------------------------------- /src/redux/configureStore.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | import createLogger from 'redux-logger'; 4 | 5 | import { mobile } from 'libs/mobile'; 6 | 7 | import rootReducer from 'reducers'; 8 | import DevTools from 'redux/DevTools'; 9 | 10 | let finalCreateStore; 11 | if (!mobile && process.env.NODE_ENV !== 'production') { 12 | finalCreateStore = compose( 13 | applyMiddleware(thunk), 14 | applyMiddleware(createLogger()), 15 | DevTools.instrument({ maxAge: 30 }), 16 | )(createStore); 17 | } 18 | else { 19 | finalCreateStore = compose( 20 | applyMiddleware(thunk), 21 | )(createStore); 22 | } 23 | 24 | export default function configureStore() { 25 | const InitialState = {}; 26 | const store = finalCreateStore(rootReducer, InitialState); 27 | 28 | if (module.hot) { 29 | // Enable Webpack hot module replacement for reducers 30 | module.hot.accept('../reducers', () => { 31 | const nextRootReducer = require('../reducers'); 32 | store.replaceReducer(nextRootReducer); 33 | }); 34 | } 35 | 36 | return store; 37 | } 38 | -------------------------------------------------------------------------------- /src/components/RouteList/RouteItem/StopsButton.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import classNames from 'classnames'; 3 | import { mobile } from 'libs/mobile'; 4 | 5 | import styles from './StopsButton.scss'; 6 | 7 | export default class StopsButton extends Component { 8 | static propTypes = {}; 9 | 10 | state = { 11 | pressed: false, 12 | } 13 | 14 | onPress = () => { 15 | this.setState({ 16 | pressed: true, 17 | }); 18 | } 19 | 20 | onClick = (e) => { 21 | e.stopPropagation(); 22 | } 23 | 24 | offPress = () => { 25 | this.setState({ 26 | pressed: false, 27 | }); 28 | } 29 | 30 | render() { 31 | const btnStyle = classNames(styles.stops, { 32 | [`${styles.hover}`]: !mobile, 33 | [`${styles.pressed}`]: (mobile && this.state.pressed), 34 | }); 35 | return ( 36 |
42 |
43 |
View Stops
44 |
45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/index.tmpl.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Instabus 6 | 7 | 30 | 31 | 32 |
33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to -------------------------------------------------------------------------------- /src/libs/oba.js: -------------------------------------------------------------------------------- 1 | import fetch from 'fetch-jsonp'; 2 | import queryString from 'query-string'; 3 | 4 | const validStatusCode = new RegExp('^[4-5][0-9][0-9]$'); 5 | 6 | export default function oba(endpoint, query = {}) { 7 | // const url = `http://localhost:8080/api/where/${endpoint}.json`; 8 | const url = `http://52.88.82.199:8080/onebusaway-api-webapp/api/where/${endpoint}.json`; 9 | // const url = `http://atlanta.onebusaway.org/api/api/where/${endpoint}.json`; 10 | // const url = `http://api.tampa.onebusaway.org/api/where/${endpoint}.json`; 11 | 12 | const qs = queryString.stringify({ 13 | key: 'TEST', 14 | ...query, 15 | }); 16 | 17 | const options = { 18 | timeout: 10 * 1000, 19 | }; 20 | 21 | return fetch(`${url}?${qs}`, options) 22 | .then((res) => res.json()) 23 | .then((json) => { 24 | if (!!json.code && validStatusCode.test(json.code)) { 25 | console.error('OneBusAwayAPIError', json); 26 | throw new Error(`OneBusAwayAPIError: ${json.code} ${json.text}`); 27 | } 28 | return json; 29 | }) 30 | .catch(err => { 31 | // FIXME: Throw these as OBAError so we can filter in Sentry and elsewhere 32 | console.error(err); 33 | throw err; 34 | }); 35 | } 36 | 37 | export function keyForLocation({ lat, lon, latSpan, lonSpan }) { 38 | return `${lat}-${lon}-${latSpan}-${lonSpan}`; 39 | } 40 | -------------------------------------------------------------------------------- /src/actions/saved.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | import { 4 | SET_SAVED_ROUTES, 5 | } from 'constants/ActionTypes'; 6 | 7 | const VERSION = 'v1'; 8 | 9 | export function setSavedRoutes(savedRoutes) { 10 | return { 11 | type: SET_SAVED_ROUTES, 12 | payload: savedRoutes, 13 | }; 14 | } 15 | 16 | export function restoreSavedRoutes() { 17 | return (dispatch) => { 18 | const data = localStorage.getItem(`${VERSION}:savedRoutes`); 19 | if (data) { 20 | const savedRoutes = JSON.parse(data); 21 | dispatch(setSavedRoutes(savedRoutes)); 22 | } 23 | }; 24 | } 25 | 26 | function storeSavedRoutes(savedRoutes) { 27 | localStorage.setItem(`${VERSION}:savedRoutes`, JSON.stringify(savedRoutes)); 28 | } 29 | 30 | export function saveRoute(routeId) { 31 | return (dispatch, getState) => { 32 | const savedRoutes = _.uniq([ 33 | ...getState().data.saved.savedRoutes, 34 | routeId, 35 | ]); 36 | 37 | dispatch(setSavedRoutes(savedRoutes)); 38 | storeSavedRoutes(savedRoutes); 39 | }; 40 | } 41 | 42 | export function unsaveRoute(routeId) { 43 | return (dispatch, getState) => { 44 | const prevSavedRoutes = getState().data.saved.savedRoutes; 45 | 46 | const savedRoutes = _.remove([...prevSavedRoutes], (id) => id !== routeId); 47 | 48 | dispatch(setSavedRoutes(savedRoutes)); 49 | storeSavedRoutes(savedRoutes); 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /src/reducers/ui/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { Router } from 'libs/routing'; 3 | 4 | import loading from './loading'; 5 | import modal from './modal'; 6 | 7 | import InitialState from 'constants/InitialState'; 8 | 9 | import { 10 | SET_GLOBAL_ERROR, 11 | SET_PATHNAME, 12 | SET_USER_LOCATION, 13 | INITIAL_VEHICLES_LOADED, 14 | } from 'constants/ActionTypes'; 15 | 16 | function currentAgency(state = InitialState.ui.currentAgency) { 17 | return state; 18 | } 19 | 20 | function route(state = InitialState.ui.route, action = {}) { 21 | if (action.type === SET_PATHNAME) { 22 | return Router.lookup(action.payload); 23 | } 24 | return state; 25 | } 26 | 27 | function initialVehiclesLoaded(state = InitialState.ui.initialVehiclesLoaded, action = {}) { 28 | if (action.type === INITIAL_VEHICLES_LOADED) { 29 | return true; 30 | } 31 | return state; 32 | } 33 | 34 | function globalError(state = InitialState.ui.globalError, action = {}) { 35 | if (action.type === SET_GLOBAL_ERROR) { 36 | return action.payload; 37 | } 38 | return state; 39 | } 40 | 41 | function userLocation(state = InitialState.ui.userLocation, action = {}) { 42 | if (action.type === SET_USER_LOCATION) { 43 | return action.payload; 44 | } 45 | return state; 46 | } 47 | 48 | export default combineReducers({ 49 | globalError, 50 | currentAgency, 51 | route, 52 | userLocation, 53 | loading, 54 | modal, 55 | initialVehiclesLoaded, 56 | }); 57 | -------------------------------------------------------------------------------- /src/components/NavBar/SavedButton/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import classNames from 'classnames'; 4 | 5 | import { 6 | SAVED_PATH, 7 | } from 'constants/Paths'; 8 | 9 | import { setSavedModal } from 'actions/ui'; 10 | import { GlobalHistory, Router } from 'libs/routing'; 11 | 12 | import styles from './styles.scss'; 13 | 14 | class SavedButton extends Component { 15 | static propTypes = { 16 | route: PropTypes.object, 17 | modal: PropTypes.bool, 18 | setSavedModal: PropTypes.func, 19 | } 20 | 21 | _toggle = () => { 22 | if (this.props.route.name === SAVED_PATH) { 23 | this.props.setSavedModal(!this.props.modal); 24 | } 25 | else { 26 | this.props.setSavedModal(true); 27 | GlobalHistory.push(Router.generate(SAVED_PATH)); 28 | } 29 | } 30 | 31 | render() { 32 | const { name } = this.props.route; 33 | const btn = classNames(styles.btn, { 34 | [`${styles.active}`]: name === SAVED_PATH, 35 | [`${styles.minimized}`]: !this.props.modal, 36 | }); 37 | return ( 38 |
42 | Saved 43 |
44 | ); 45 | } 46 | } 47 | 48 | const mapDispatchToProps = { 49 | setSavedModal, 50 | }; 51 | 52 | const mapStateToProps = (state) => ({ 53 | route: state.ui.route, 54 | modal: state.ui.modal.savedRoutes, 55 | }); 56 | 57 | export default connect(mapStateToProps, mapDispatchToProps)(SavedButton); 58 | -------------------------------------------------------------------------------- /src/components/NavBar/RoutesButton/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import classNames from 'classnames'; 4 | 5 | import { 6 | ALL_ROUTES_PATH, 7 | } from 'constants/Paths'; 8 | 9 | import { GlobalHistory, Router } from 'libs/routing'; 10 | import { setRoutesModal } from 'actions/ui'; 11 | 12 | import styles from './styles.scss'; 13 | 14 | class RoutesButton extends Component { 15 | static propTypes = { 16 | route: PropTypes.object, 17 | modal: PropTypes.bool, 18 | setRoutesModal: PropTypes.func, 19 | } 20 | 21 | _toggle = () => { 22 | if (this.props.route.name === ALL_ROUTES_PATH) { 23 | this.props.setRoutesModal(!this.props.modal); 24 | } 25 | else { 26 | this.props.setRoutesModal(true); 27 | GlobalHistory.push(Router.generate(ALL_ROUTES_PATH, {})); 28 | } 29 | } 30 | 31 | render() { 32 | const { name } = this.props.route; 33 | const btn = classNames(styles.btn, { 34 | [`${styles.active}`]: name === ALL_ROUTES_PATH, 35 | [`${styles.minimized}`]: !this.props.modal, 36 | }); 37 | return ( 38 |
42 | All Routes 43 |
44 | ); 45 | } 46 | } 47 | 48 | const mapDispatchToProps = { 49 | setRoutesModal, 50 | }; 51 | 52 | const mapStateToProps = (state) => ({ 53 | route: state.ui.route, 54 | modal: state.ui.modal.routes, 55 | }); 56 | 57 | export default connect(mapStateToProps, mapDispatchToProps)(RoutesButton); 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Instabus 6.7 2 | 3 | This is a rewrite of https://github.com/luqmaan/instabus. 4 | 5 | Why rewrite Instabus? 6 | 7 | 1. Fetch data from the OneBusAway so that Instabus works for multiple cities (Austin, Tampa, Atlanta, etc.) 8 | 2. Add missing features like viewing the schedule for a stop or viewing nearby arrivals 9 | 3. Modernize the code, make it easier to read and easier to reason about 10 | 4. Be available as mobile apps first through Phonegap and then through React-Native 11 | 12 | Check out the issues labeled with `components`, to see what we're thinking for a new layout. https://github.com/open-austin/instabus/issues?q=is%3Aissue+is%3Aopen+label%3Acomponents 13 | 14 | :warning: The code is still in the very early stages and is not ready for contributions. If you'd like to start contributing code, please let me know so I can have a sense of urgency and get the code ready. 15 | 16 | ## Contributing 17 | 18 | Want to help? Have ideas for what the "new" Instabus should look like? 19 | 20 | - Open an Issue on this repo 21 | - Join the #instabus channel on the Open Austin slack: http://slack.open-austin.org 22 | - Tweet @luqmonster 23 | 24 | ## Installing 25 | 26 | ``` 27 | npm install 28 | npm start 29 | ``` 30 | 31 | Use an editor with plugins for `editorconfig` and `eslint`. 32 | 33 | Tests 34 | 35 | ``` 36 | npm run test 37 | npm run test -- --watch --full-trace 38 | ``` 39 | 40 | ## Prior Art 41 | 42 | - https://github.com/luqmaan/instabus 43 | - https://github.com/luqmaan/instabus-react 44 | - https://github.com/luqmaan/MetroRappid-iOS 45 | - https://github.com/sethgho/MetroRappidAndroid 46 | -------------------------------------------------------------------------------- /src/components/RouteList/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import ContextMenu from 'components/ContextMenu'; 5 | import Spinner from 'components/Spinner'; 6 | import RouteItem from './RouteItem'; 7 | import { getRoutes } from 'actions/oba'; 8 | 9 | import styles from './styles.scss'; 10 | 11 | class RouteList extends Component { 12 | static propTypes = { 13 | routes: PropTypes.arrayOf(PropTypes.object), 14 | routesLoading: PropTypes.bool, 15 | modal: PropTypes.bool, 16 | getRoutes: PropTypes.func, 17 | } 18 | 19 | componentDidMount() { 20 | if (!this.props.routes.length) this.props.getRoutes(); 21 | } 22 | 23 | componentWillUnmount() { 24 | 25 | } 26 | 27 | _renderRoutes = () => { 28 | const { routesLoading, routes } = this.props; 29 | 30 | if (routesLoading) { 31 | return ( 32 |
33 | 34 |
35 | ); 36 | } 37 | 38 | return routes.map(route => ); 39 | } 40 | 41 | render() { 42 | return ( 43 | 44 | { this._renderRoutes() } 45 | 46 | ); 47 | } 48 | } 49 | 50 | const mapDispatchToProps = { 51 | getRoutes, 52 | }; 53 | 54 | const mapStateToProps = (state) => ({ 55 | routes: state.data.routes.orderedRoutes, 56 | routesLoading: state.ui.loading.routes, 57 | modal: state.ui.modal.routes, 58 | }); 59 | 60 | export default connect(mapStateToProps, mapDispatchToProps)(RouteList); 61 | -------------------------------------------------------------------------------- /src/components/Saved/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import ContextMenu from 'components/ContextMenu'; 5 | import Spinner from 'components/Spinner'; 6 | import RouteItem from 'components/RouteList/RouteItem'; 7 | 8 | import { savedRoutesSelector } from 'selectors/oba'; 9 | import { getRoutes } from 'actions/oba'; 10 | 11 | import styles from './styles.scss'; 12 | 13 | class Saved extends Component { 14 | static propTypes = { 15 | getRoutes: PropTypes.func.isRequired, 16 | savedRoutes: PropTypes.arrayOf(PropTypes.object), 17 | routesLoading: PropTypes.bool, 18 | modal: PropTypes.bool, 19 | } 20 | 21 | componentDidMount() { 22 | if (!this.props.savedRoutes.length) { 23 | this.props.getRoutes(); 24 | } 25 | } 26 | 27 | renderSpinner() { 28 | return ( 29 |
30 | 31 |
32 | ); 33 | } 34 | 35 | render() { 36 | return ( 37 | 38 | { this.props.routesLoading && this.renderSpinner() } 39 | { this.props.savedRoutes.map((route) => ( 40 | 41 | )) } 42 | 43 | ); 44 | } 45 | } 46 | 47 | const mapStateToProps = (state) => ({ 48 | savedRoutes: savedRoutesSelector(state), 49 | routesLoading: state.ui.loading.routes, 50 | modal: state.ui.modal.saved, 51 | }); 52 | 53 | const mapDispatchToProps = { 54 | getRoutes, 55 | }; 56 | 57 | export default connect(mapStateToProps, mapDispatchToProps)(Saved); 58 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | var cssnano = require('cssnano'); 4 | var path = require('path'); 5 | 6 | module.exports = { 7 | devtool: 'cheap-eval-source-map', 8 | entry: __dirname + '/src/index.js', 9 | output: { 10 | path: __dirname + '/dist', 11 | filename: 'bundle.js', 12 | publicPath: '/', 13 | }, 14 | resolve: { 15 | root: path.resolve(__dirname, 'src'), 16 | extensions: ['', '.js'], 17 | }, 18 | 19 | module: { 20 | loaders: [ 21 | { test: /\.json$/, loader: 'json' }, 22 | { test: /\.js$/, exclude: /node_modules/, loader: 'babel' }, 23 | { 24 | test: /\.scss$/, 25 | loaders: [ 26 | 'style?sourceMap', 27 | 'css?modules&importLoaders=1&localIdentName=[path]___[name]__[local]___[hash:base64:5]', 28 | 'postcss-loader', 29 | 'sass', 30 | ], 31 | }, 32 | ], 33 | }, 34 | postcss: [ 35 | cssnano({ 36 | sourcemap: true, 37 | autoprefixer: { 38 | add: true, 39 | remove: true, 40 | browsers: ['last 2 versions'], 41 | }, 42 | discardComments: { 43 | removeAll: true, 44 | }, 45 | }), 46 | ], 47 | sassLoader: { 48 | includePaths: [path.resolve(__dirname, 'src/styles')], 49 | }, 50 | 51 | plugins: [ 52 | new HtmlWebpackPlugin({ 53 | template: __dirname + '/src/index.tmpl.html', 54 | }), 55 | new webpack.HotModuleReplacementPlugin(), 56 | ], 57 | 58 | devServer: { 59 | colors: true, 60 | historyApiFallback: true, 61 | inline: true, 62 | hot: true, 63 | }, 64 | }; 65 | -------------------------------------------------------------------------------- /src/components/RouteList/RouteItem/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | 3 | import { GlobalHistory, Router } from 'libs/routing'; 4 | 5 | import SaveButton from './SaveButton'; 6 | import StopsButton from './StopsButton'; 7 | 8 | import { 9 | ROUTE_PATH, 10 | } from 'constants/Paths'; 11 | 12 | import styles from './styles.scss'; 13 | 14 | export default class RouteItem extends Component { 15 | static propTypes = { 16 | route: PropTypes.object, 17 | } 18 | 19 | componentDidUpdate() { 20 | 21 | } 22 | 23 | componentWillUnmount() { 24 | 25 | } 26 | 27 | _selectRoute = (e) => { 28 | e.preventDefault(); 29 | GlobalHistory.push(Router.generate(ROUTE_PATH, { routeId: this.props.route.id })); 30 | return false; 31 | } 32 | 33 | render() { 34 | const { route } = this.props; 35 | return ( 36 |
40 | 44 |
{route.shortName}
45 |
46 |
{route.longName}
47 |
0 buses running
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | 56 | 57 |
58 |
59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/components/Map/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { watchUserLocation } from 'actions/location'; 5 | import { vehiclesSelector, polylineSelector, stopsSelector } from 'selectors/oba'; 6 | import MapboxWrapper from './MapboxWrapper'; 7 | 8 | import styles from './styles.scss'; 9 | 10 | class MapLayer extends Component { 11 | static propTypes = { 12 | route: PropTypes.object, 13 | vehicles: PropTypes.arrayOf(PropTypes.object), 14 | stops: PropTypes.arrayOf(PropTypes.object), 15 | userLocation: PropTypes.object, 16 | watchUserLocation: PropTypes.func, 17 | getVehicles: PropTypes.func, 18 | } 19 | 20 | componentDidMount() { 21 | this.props.watchUserLocation(); 22 | this.map = new MapboxWrapper('map'); 23 | } 24 | 25 | componentWillReceiveProps(props) { 26 | const { userLocation, vehicles, polyline, stops } = props; 27 | this.map.setUserLocation(userLocation); 28 | this.map.setStopsAndPolyline(stops, polyline); 29 | this.map.setVehicles(vehicles); 30 | } 31 | 32 | shouldComponentUpdate() { 33 | return false; 34 | } 35 | 36 | componentWillUnmount() { 37 | 38 | } 39 | 40 | render() { 41 | return ( 42 |
43 | ); 44 | } 45 | } 46 | 47 | const mapStateToProps = (state) => ({ 48 | vehicles: vehiclesSelector(state), 49 | polyline: polylineSelector(state), 50 | stops: stopsSelector(state), 51 | route: state.ui.route, 52 | userLocation: state.ui.userLocation, 53 | }); 54 | 55 | const mapDispatchToProps = { 56 | watchUserLocation, 57 | }; 58 | 59 | export default connect(mapStateToProps, mapDispatchToProps)(MapLayer); 60 | -------------------------------------------------------------------------------- /src/components/StopList/StopGroupSwitcher/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import _ from 'lodash'; 3 | 4 | import { GlobalHistory, Router } from 'libs/routing'; 5 | import { 6 | DIRECTION_PATH, 7 | } from 'constants/Paths'; 8 | 9 | import styles from './styles.scss'; 10 | 11 | import StopGroupSwitch from './StopGroupSwitch'; 12 | 13 | export default class StopGroupSwitcher extends Component { 14 | static propTypes = { 15 | directions: PropTypes.arrayOf(PropTypes.string), 16 | route: PropTypes.object, 17 | }; 18 | 19 | _switchGroups = () => { 20 | const { route, directions } = this.props; 21 | const { routeId, routeDirection } = route.options; 22 | const direction = _.find(directions, d => d !== routeDirection); 23 | GlobalHistory.push(Router.generate(DIRECTION_PATH, { routeId, routeDirection: direction })); 24 | } 25 | 26 | render() { 27 | const { directions, route } = this.props; 28 | 29 | if (directions.length <= 1) { 30 | return null; 31 | } 32 | 33 | const directionToggles = directions.map((direction, i) => ( 34 | 39 | )); 40 | 41 | const sliderTranslate = { 42 | transform: `translateX(${(route.options.routeDirection === directions[0]) ? '0' : '100%'})`, 43 | }; 44 | 45 | return ( 46 |
47 |
48 | {directionToggles} 49 |
50 |
51 |
52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /webpack.production.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | var ExtractTextPlugin = require('extract-text-webpack-plugin'); 4 | var path = require('path'); 5 | var cssnano = require('cssnano'); 6 | 7 | module.exports = { 8 | entry: __dirname + '/src/index.js', 9 | output: { 10 | path: __dirname + '/dist', 11 | filename: '[name]-[hash].js', 12 | }, 13 | resolve: { 14 | root: path.resolve(__dirname, 'src'), 15 | extensions: ['', '.js'], 16 | }, 17 | 18 | module: { 19 | loaders: [ 20 | { test: /\.json$/, loader: 'json' }, 21 | { test: /\.js$/, exclude: /node_modules/, loader: 'babel' }, 22 | { 23 | test: /\.scss$/, 24 | loader: ExtractTextPlugin.extract('style', 'css?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]!postcss-loader!sass'), 25 | }, 26 | ], 27 | }, 28 | sassLoader: { 29 | includePaths: [path.resolve(__dirname, 'src/styles')], 30 | }, 31 | postcss: [ 32 | cssnano({ 33 | sourcemap: true, 34 | autoprefixer: { 35 | add: true, 36 | remove: true, 37 | browsers: ['last 2 versions'], 38 | }, 39 | discardComments: { 40 | removeAll: true, 41 | }, 42 | }), 43 | ], 44 | plugins: [ 45 | new webpack.DefinePlugin({ 46 | 'process.env': { 47 | NODE_ENV: '"production"', 48 | }, 49 | }), 50 | new HtmlWebpackPlugin({ 51 | template: __dirname + '/src/index.tmpl.html', 52 | }), 53 | new webpack.optimize.OccurenceOrderPlugin(), 54 | new webpack.optimize.UglifyJsPlugin({ 55 | compress: { 56 | unused: true, 57 | dead_code: true, 58 | }, 59 | }), 60 | new ExtractTextPlugin('[name]-[hash].css'), 61 | ], 62 | }; 63 | -------------------------------------------------------------------------------- /src/components/RouteList/RouteItem/styles.scss: -------------------------------------------------------------------------------- 1 | .wrap { 2 | width: 100%; 3 | height: 115px; 4 | position: relative; 5 | border-top: 1px #e0e0e0 solid; 6 | cursor: pointer; 7 | } 8 | 9 | .wrap:first-of-type { 10 | height: 114px; 11 | border-top: none; 12 | padding: 0; 13 | } 14 | 15 | .route { 16 | width: 100%; 17 | height: 114px; 18 | line-height: 0; 19 | font-size: 0; 20 | position: relative; 21 | display: block; 22 | color: #000; 23 | } 24 | 25 | .id { 26 | height: 60px; 27 | width: 70px; 28 | font-size: 24px; 29 | line-height: 60px; 30 | display: inline-block; 31 | text-align: center; 32 | } 33 | 34 | .info { 35 | vertical-align: top; 36 | display: inline-block; 37 | height: 60px; 38 | width: calc(100% - 70px); 39 | padding: 8px 0; 40 | } 41 | 42 | .name { 43 | font-size: 14px; 44 | height: 22px; 45 | line-height: 22px; 46 | width: 100%; 47 | } 48 | 49 | .trips { 50 | font-size: 14px; 51 | height: 22px; 52 | line-height: 22px; 53 | width: 100%; 54 | color: #7A7A7A; 55 | } 56 | 57 | .btns { 58 | position: absolute; 59 | height: 44px; 60 | font-size: 0; 61 | line-height: 0; 62 | bottom: 10px; 63 | left: 15px; 64 | } 65 | 66 | .caret { 67 | position: absolute; 68 | right: 10px; 69 | height: 16px; 70 | width: 16px; 71 | top: 50%; 72 | transform: translateY(-50%); 73 | } 74 | 75 | .caretLine { 76 | position: absolute; 77 | background: #e0e0e0; 78 | top: 7px; 79 | right: 8px; 80 | height: 2px; 81 | width: 8px; 82 | border-top-left-radius: 1px; 83 | border-bottom-left-radius: 1px; 84 | } 85 | 86 | .item.hover:hover .caretLine, .item.pressed .caretLine { 87 | background: #ABABAB; 88 | } 89 | 90 | .caretTop { 91 | @extend .caretLine; 92 | transform: rotate(45deg); 93 | transform-origin: right bottom; 94 | } 95 | 96 | .caretBottom { 97 | @extend .caretLine; 98 | transform: rotate(315deg); 99 | transform-origin: right top; 100 | } 101 | -------------------------------------------------------------------------------- /src/selectors/oba.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { createSelector } from 'reselect'; 3 | 4 | import { 5 | ROUTE_PATH, 6 | DIRECTION_PATH, 7 | } from 'constants/Paths'; 8 | 9 | export const stopGroupSelector = createSelector( 10 | (state) => state.ui.route.options.routeId, 11 | (state) => state.data.stopGroups, 12 | (routeId, stopGroups) => stopGroups[routeId], 13 | ); 14 | 15 | export const vehiclesSelector = createSelector( 16 | (state) => state.data.vehicles, 17 | (state) => state.ui.route, 18 | (vehicles, route) => { 19 | const showStops = (route.name === ROUTE_PATH) || (route.name === DIRECTION_PATH); 20 | if (showStops) { 21 | return vehicles.vehiclesByRoute[route.options.routeId] || []; 22 | } 23 | 24 | return vehicles.allVehicles; 25 | } 26 | ); 27 | 28 | export const polylineSelector = createSelector( 29 | (state) => state.data.stopGroups, 30 | (state) => state.ui.route, 31 | (stopGroups, route) => { 32 | if (route.name === ROUTE_PATH && stopGroups[route.options.routeId]) { 33 | const direction = stopGroups[route.options.routeId].directions[0]; 34 | const polyline = stopGroups[route.options.routeId].groups[direction].polyline; 35 | return polyline; 36 | } 37 | else if (route.name === DIRECTION_PATH && stopGroups[route.options.routeId]) { 38 | const polyline = stopGroups[route.options.routeId].groups[route.options.routeDirection].polyline; 39 | return polyline; 40 | } 41 | 42 | return null; 43 | } 44 | ); 45 | 46 | export const stopsSelector = createSelector( 47 | (state) => state.data.stopGroups, 48 | (state) => state.ui.route, 49 | (stopGroups, route) => { 50 | if (route.name === ROUTE_PATH && stopGroups[route.options.routeId]) { 51 | const direction = stopGroups[route.options.routeId].directions[0]; 52 | const polyline = stopGroups[route.options.routeId].groups[direction].stops; 53 | return polyline; 54 | } 55 | else if (route.name === DIRECTION_PATH && stopGroups[route.options.routeId]) { 56 | const polyline = stopGroups[route.options.routeId].groups[route.options.routeDirection].stops; 57 | return polyline; 58 | } 59 | 60 | return null; 61 | } 62 | ); 63 | 64 | export const savedRoutesSelector = createSelector( 65 | (state) => state.data.saved.savedRoutes, 66 | (state) => state.data.routes.routesById, 67 | (savedRoutes, routesById) => _.remove(savedRoutes.map((routeId) => routesById[routeId])) 68 | ); 69 | -------------------------------------------------------------------------------- /src/components/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import styles from 'styles/base.scss'; 5 | 6 | import { 7 | ALL_ROUTES_PATH, 8 | ROUTE_PATH, 9 | DIRECTION_PATH, 10 | SAVED_PATH, 11 | } from 'constants/Paths'; 12 | 13 | import MapLayer from 'components/Map'; 14 | import RouteList from 'components/RouteList'; 15 | import StopList from 'components/StopList'; 16 | import NavBar from 'components/NavBar'; 17 | import Saved from 'components/Saved'; 18 | import VehiclesLoading from 'components/VehiclesLoading'; 19 | 20 | import { getRoutes, getVehicles, initialVehiclesLoaded } from 'actions/oba'; 21 | import { restoreSavedRoutes } from 'actions/saved'; 22 | 23 | 24 | class App extends Component { 25 | static propTypes = { 26 | globalError: PropTypes.string, 27 | route: PropTypes.object, 28 | getRoutes: PropTypes.func, 29 | getVehicles: PropTypes.func, 30 | initialVehiclesLoaded: PropTypes.func, 31 | restoreSavedRoutes: PropTypes.func.isRequired, 32 | } 33 | 34 | componentDidMount() { 35 | this.props.restoreSavedRoutes(); 36 | 37 | this.props.getVehicles().then(() => { 38 | this.props.initialVehiclesLoaded(); 39 | }); 40 | this.watchVehicles = setInterval(this.props.getVehicles, 30000); 41 | } 42 | 43 | componentWillUnmount() { 44 | clearInterval(this.watchVehicles); 45 | } 46 | 47 | _renderGlobalError = () =>
{this.props.globalError}
; 48 | 49 | _renderGlobalError() { 50 | return
{this.props.globalError}
; 51 | } 52 | 53 | _renderContext = () => { 54 | const name = this.props.route.name; 55 | switch (name) { 56 | case ALL_ROUTES_PATH: 57 | return ; 58 | case ROUTE_PATH: 59 | return ; 60 | case DIRECTION_PATH: 61 | return ; 62 | case SAVED_PATH: 63 | return ; 64 | default: 65 | return null; 66 | } 67 | } 68 | 69 | render() { 70 | return ( 71 |
72 | 73 | 74 | 75 | { this._renderContext() } 76 |
77 | ); 78 | } 79 | } 80 | 81 | const mapDispatchToProps = { 82 | getRoutes, 83 | getVehicles, 84 | initialVehiclesLoaded, 85 | restoreSavedRoutes, 86 | }; 87 | 88 | const mapStateToProps = (state) => ({ 89 | globalError: state.ui.globalError, 90 | route: state.ui.route, 91 | }); 92 | 93 | export default connect(mapStateToProps, mapDispatchToProps)(App); 94 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Instabus", 3 | "version": "0.0.2", 4 | "description": "bus schedules", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "npm run dev", 8 | "dev": "webpack-dev-server --progress --port 3333 --host 0.0.0.0 --history-api-fallback", 9 | "test": "NODE_PATH=./src mocha src/test/setup.js \"src/**/test.*.js\" --compilers js:babel-register", 10 | "build": "rm -rf dist/* && ls -Falth && NODE_ENV=production ./node_modules/.bin/webpack --config ./webpack.production.config.js --progress", 11 | "deploy": "npm run build && node deploy.js" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/open-austin/instabus.git" 16 | }, 17 | "author": "Open Austin", 18 | "license": "Unlicense", 19 | "bugs": { 20 | "url": "https://github.com/open-austin/instabus/issues" 21 | }, 22 | "homepage": "https://github.com/open-austin/instabus", 23 | "devDependencies": { 24 | "autoprefixer": "^6.3.3", 25 | "babel-core": "^6.6.0", 26 | "babel-eslint": "^6.0.0-beta.1", 27 | "babel-loader": "^6.2.4", 28 | "babel-plugin-react-transform": "^2.0.2", 29 | "babel-plugin-transform-react-jsx": "^6.6.0", 30 | "babel-preset-es2015": "^6.6.0", 31 | "babel-preset-react": "^6.5.0", 32 | "babel-preset-stage-0": "^6.5.0", 33 | "css-loader": "^0.23.1", 34 | "cssnano": "^3.5.2", 35 | "eslint": "^2.3.0", 36 | "eslint-config-airbnb": "^6.1.0", 37 | "eslint-plugin-babel": "^3.1.0", 38 | "eslint-plugin-flow-vars": "^0.2.1", 39 | "eslint-plugin-react": "^4.2.0", 40 | "estraverse": "^4.1.1", 41 | "estraverse-fb": "^1.3.1", 42 | "extract-text-webpack-plugin": "^1.0.1", 43 | "html-webpack-plugin": "^2.9.0", 44 | "jsdom": "^8.3.0", 45 | "node-sass": "^3.4.2", 46 | "postcss-loader": "^0.8.1", 47 | "react-transform-catch-errors": "^1.0.2", 48 | "react-transform-hmr": "^1.0.2", 49 | "redbox-react": "^1.2.2", 50 | "sass-loader": "^3.1.2", 51 | "style-loader": "^0.13.0", 52 | "webpack": "^1.12.14", 53 | "webpack-dev-server": "^1.14.1" 54 | }, 55 | "dependencies": { 56 | "babel-register": "^6.6.5", 57 | "classnames": "^2.2.3", 58 | "expect": "^1.14.0", 59 | "fastclick": "^1.0.6", 60 | "fetch-jsonp": "^1.0.0", 61 | "gh-pages": "^0.11.0", 62 | "history": "^2.0.1", 63 | "lodash": "^4.11.1", 64 | "mocha": "^2.4.5", 65 | "polyline": "^0.2.0", 66 | "query-string": "^4.1.0", 67 | "rbush": "^1.4.2", 68 | "react": "^15.0.1", 69 | "react-addons-css-transition-group": "^15.0.1", 70 | "react-dom": "^15.0.1", 71 | "react-redux": "^4.4.0", 72 | "redux": "^3.5.2", 73 | "redux-devtools": "^3.1.1", 74 | "redux-devtools-dock-monitor": "^1.1.0", 75 | "redux-devtools-log-monitor": "^1.0.5", 76 | "redux-logger": "^2.6.1", 77 | "redux-thunk": "^2.0.1", 78 | "reselect": "^2.5.1", 79 | "uniloc": "^0.3.0" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/components/ContextMenu/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | 3 | import classNames from 'classnames'; 4 | import { iOS } from 'libs/mobile'; 5 | 6 | import styles from './styles.scss'; 7 | 8 | export default class ContextMenu extends Component { 9 | static propTypes = { 10 | children: PropTypes.any, 11 | minimized: PropTypes.bool, 12 | }; 13 | 14 | state = { 15 | pressed: false, 16 | } 17 | 18 | componentDidMount() { 19 | if (iOS) { 20 | this.resizeTimeout = null; 21 | this.scrollTimeout = null; 22 | this.refs.context.addEventListener('scroll', this.onScroll); 23 | this.refs.context.addEventListener('resize', this.onResize); 24 | this.clientHeight = this.refs.context.clientHeight; 25 | this.scrollHeight = this.refs.context.scrollHeight; 26 | if (this.refs.context.scrollTop <= 0) { 27 | this.refs.context.scrollTop = 1; 28 | } 29 | } 30 | } 31 | 32 | componentDidUpdate() { 33 | if (iOS && this.refs.context.scrollTop <= 0) { 34 | this.clientHeight = this.refs.context.clientHeight; 35 | this.scrollHeight = this.refs.context.scrollHeight; 36 | this.refs.context.scrollTop = 1; 37 | } 38 | } 39 | 40 | componentWillUnmount() { 41 | if (iOS) { 42 | this.refs.context.removeEventListener('scroll', this.onScroll); 43 | this.refs.context.removeEventListener('resize', this.onResize); 44 | } 45 | } 46 | 47 | onPress = () => { 48 | this.setState({ 49 | pressed: true, 50 | }); 51 | } 52 | 53 | onScroll = () => { 54 | clearTimeout(this.scrollTimeout); 55 | this.scrollTimeout = setTimeout(this.adjustScrollTop, 100); 56 | } 57 | 58 | onResize = () => { 59 | clearTimeout(this.resizeTimeout); 60 | this.resizeTimeout = setTimeout(this.updateDimensions, 100); 61 | } 62 | 63 | offPress = () => { 64 | this.setState({ 65 | pressed: false, 66 | }); 67 | } 68 | 69 | adjustScrollTop = () => { 70 | if (this.state.pressed) return; 71 | const scrollTop = this.refs.context.scrollTop; 72 | if (scrollTop <= 0) { 73 | this.refs.context.scrollTop = 1; 74 | } 75 | else if ((this.scrollHeight - scrollTop) <= this.clientHeight) { 76 | this.refs.context.scrollTop = scrollTop - 1; 77 | } 78 | } 79 | 80 | updateDimensions = () => { 81 | this.clientHeight = this.refs.context.clientHeight; 82 | this.scrollHeight = this.refs.context.scrollHeight; 83 | } 84 | 85 | render() { 86 | const contextStyle = classNames(styles.context, { 87 | [`${styles.iOS}`]: iOS, 88 | [`${styles.minimized}`]: this.props.minimized, 89 | }); 90 | return ( 91 |
97 | {this.props.children} 98 |
99 | ); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/components/RouteList/RouteItem/SaveButton.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import classNames from 'classnames'; 4 | import { createSelector } from 'reselect'; 5 | 6 | import { mobile } from 'libs/mobile'; 7 | import { saveRoute, unsaveRoute } from 'actions/saved'; 8 | 9 | import styles from './SaveButton.scss'; 10 | 11 | const SAVE = 'Save'; 12 | const SAVED = 'Saved'; 13 | 14 | const Icon = ({ style }) => ( 15 | 16 | 17 | 18 | ); 19 | 20 | Icon.propTypes = { 21 | style: PropTypes.string, 22 | }; 23 | 24 | class SaveButton extends Component { 25 | static propTypes = { 26 | saved: PropTypes.bool, 27 | routeId: PropTypes.string.isRequired, 28 | unsaveRoute: PropTypes.func.isRequired, 29 | saveRoute: PropTypes.func.isRequired, 30 | }; 31 | 32 | state = { 33 | pressed: false, 34 | } 35 | 36 | onClick = (e) => { 37 | e.stopPropagation(); 38 | if (this.props.saved) { 39 | this.props.unsaveRoute(this.props.routeId); 40 | } 41 | else { 42 | this.props.saveRoute(this.props.routeId); 43 | } 44 | } 45 | 46 | onPress = () => { 47 | this.setState({ 48 | pressed: true, 49 | }); 50 | } 51 | 52 | offPress = () => { 53 | this.setState({ 54 | pressed: false, 55 | }); 56 | } 57 | 58 | render() { 59 | const { saved } = this.props; 60 | const btnText = (saved) ? SAVED : SAVE; 61 | const saveStyle = (saved) ? styles.saved : styles.save; 62 | const btnStyle = classNames(saveStyle, { 63 | [`${styles.hover}`]: !mobile, 64 | [`${styles.pressed}`]: (mobile && this.state.pressed), 65 | }); 66 | return ( 67 |
73 | 74 |
{btnText} Route
75 |
76 | ); 77 | } 78 | } 79 | 80 | const makeIsRouteSavedSelector = () => createSelector( 81 | (state) => state.data.saved.savedRoutes, 82 | (state, props) => props.routeId, 83 | (savedRoutes, routeId) => !!savedRoutes.find((id) => id === routeId) 84 | ); 85 | 86 | function mapStateToProps() { 87 | const isRouteSavedSelector = makeIsRouteSavedSelector(); 88 | return (state, props) => ({ 89 | saved: isRouteSavedSelector(state, props), 90 | }); 91 | } 92 | 93 | const mapDispatchToProps = { 94 | unsaveRoute, 95 | saveRoute, 96 | }; 97 | 98 | export default connect(mapStateToProps, mapDispatchToProps)(SaveButton); 99 | -------------------------------------------------------------------------------- /src/components/StopList/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { GlobalHistory, Router } from 'libs/routing'; 5 | import { 6 | ROUTE_PATH, 7 | DIRECTION_PATH, 8 | } from 'constants/Paths'; 9 | 10 | import ContextMenu from 'components/ContextMenu'; 11 | import Spinner from 'components/Spinner'; 12 | import StopGroupSwitcher from './StopGroupSwitcher'; 13 | import { stopGroupSelector } from 'selectors/oba'; 14 | 15 | import { getStops } from 'actions/oba'; 16 | 17 | import styles from './styles.scss'; 18 | 19 | class RouteList extends Component { 20 | static propTypes = { 21 | route: PropTypes.object, 22 | stopGroups: PropTypes.object, 23 | stopsLoading: PropTypes.bool, 24 | modal: PropTypes.bool, 25 | getStops: PropTypes.func, 26 | } 27 | 28 | componentDidMount() { 29 | this._setUpRoute(); 30 | } 31 | 32 | componentDidUpdate(prevProps) { 33 | const route = this.props.route; 34 | const routeId = route.options.routeId; 35 | const prevRoute = prevProps.route; 36 | const prevRouteId = prevRoute.options.routeId; 37 | if (prevRoute.name !== route.name || prevRouteId !== routeId) { 38 | this._setUpRoute(); 39 | } 40 | } 41 | 42 | componentWillUnmount() { 43 | 44 | } 45 | 46 | _setDirection = () => { 47 | const id = this.props.route.options.routeId; 48 | const direction = this.props.stopGroups.directions[0]; 49 | GlobalHistory.replace(Router.generate(DIRECTION_PATH, { routeId: id, routeDirection: direction })); 50 | } 51 | 52 | _setUpRoute = () => { 53 | if (!this.props.stopGroups) { 54 | this.props.getStops(this.props.route.options.routeId).then(() => { 55 | if (this.props.stopGroups.directions.length > 1 && !this.props.route.options.routeDirection) { 56 | this._setDirection(); 57 | } 58 | }); 59 | } 60 | else if (this.props.stopGroups.directions.length > 1 && !this.props.route.options.routeDirection) { 61 | this._setDirection(); 62 | } 63 | } 64 | 65 | _renderStop = (stop) =>
{stop.name}
; 66 | 67 | _renderStops = () => { 68 | const { stopGroups, route } = this.props; 69 | let stopGroup; 70 | if (stopGroups.directions.length > 1 && route.name === DIRECTION_PATH) { 71 | stopGroup = stopGroups.groups[route.options.routeDirection].stops; 72 | } 73 | else if (stopGroups.directions.length === 1 && route.name === ROUTE_PATH) { 74 | stopGroup = stopGroups.groups[stopGroups.directions[0]].stops; 75 | } 76 | if (!stopGroup) return null; 77 | return stopGroup.map(this._renderStop); 78 | } 79 | 80 | _renderStopGroup = () => { 81 | const { stopsLoading, stopGroups, route } = this.props; 82 | 83 | if (stopsLoading || !stopGroups) { 84 | return ( 85 |
86 | 87 |
88 | ); 89 | } 90 | 91 | return ( 92 |
93 | { this._renderStops() } 94 |
95 | ); 96 | } 97 | 98 | _renderSwitcher = () => { 99 | const { stopsLoading, stopGroups, route } = this.props; 100 | 101 | if (stopsLoading || !stopGroups || !route.options.routeDirection) return null; 102 | 103 | return ( 104 | 108 | ); 109 | } 110 | 111 | _renderRouteInfo = () => { 112 | const { stopsLoading, stopGroups } = this.props; 113 | 114 | if (stopsLoading || !stopGroups) return null; 115 | 116 | return ( 117 |
118 |
{stopGroups.route.shortName}
119 |
{stopGroups.route.longName}
120 |
121 | ); 122 | } 123 | 124 | render() { 125 | return ( 126 | 127 | { this._renderSwitcher() } 128 | { this._renderRouteInfo() } 129 | { this._renderStopGroup() } 130 | 131 | ); 132 | } 133 | } 134 | 135 | const mapDispatchToProps = { 136 | getStops, 137 | }; 138 | 139 | const mapStateToProps = (state) => ({ 140 | stopGroups: stopGroupSelector(state), 141 | route: state.ui.route, 142 | stopsLoading: state.ui.loading.stops, 143 | modal: state.ui.modal.stops, 144 | }); 145 | 146 | export default connect(mapStateToProps, mapDispatchToProps)(RouteList); 147 | -------------------------------------------------------------------------------- /src/components/Map/CanvasOverlay.js: -------------------------------------------------------------------------------- 1 | /* 2 | Generic Canvas Overlay for leaflet, 3 | Stanislav Sumbera, April , 2014 4 | 5 | - added userDrawFunc that is called when Canvas need to be redrawn 6 | - added few useful params fro userDrawFunc callback 7 | - fixed resize map bug 8 | inspired & portions taken from : https://github.com/Leaflet/Leaflet.heat 9 | 10 | License: MIT 11 | 12 | */ 13 | 14 | var pixelRatio = window.devicePixelRatio || 1; 15 | 16 | 17 | L.CanvasOverlay = L.Class.extend({ 18 | 19 | initialize: function (userDrawFunc, options) { 20 | this._userDrawFunc = userDrawFunc; 21 | L.setOptions(this, options); 22 | }, 23 | 24 | drawing: function (userDrawFunc) { 25 | this._userDrawFunc = userDrawFunc; 26 | return this; 27 | }, 28 | 29 | params:function(options){ 30 | L.setOptions(this, options); 31 | return this; 32 | }, 33 | 34 | canvas: function () { 35 | return this._canvas; 36 | }, 37 | 38 | redraw: function () { 39 | if (!this._frame) { 40 | this._frame = L.Util.requestAnimFrame(this._redraw, this); 41 | } 42 | return this; 43 | }, 44 | 45 | 46 | 47 | onAdd: function (map) { 48 | this._map = map; 49 | this._canvas = L.DomUtil.create('canvas', 'leaflet-heatmap-layer'); 50 | 51 | var size = this._map.getSize(); 52 | this._canvas.width = size.x * pixelRatio; 53 | this._canvas.height = size.y * pixelRatio; 54 | this._canvas.style.width = size.x + 'px'; 55 | this._canvas.style.height = size.y + 'px'; 56 | 57 | var animated = this._map.options.zoomAnimation && L.Browser.any3d; 58 | L.DomUtil.addClass(this._canvas, 'leaflet-zoom-' + (animated ? 'animated' : 'hide')); 59 | 60 | 61 | map._panes.markerPane.appendChild(this._canvas); 62 | this._canvas.style.pointerEvents = 'none'; 63 | this._canvas.style.zIndex = 10000; 64 | this._canvas.style.position = 'fixed'; 65 | this._canvas.style.top = 0; 66 | this._canvas.style.bottom = 0; 67 | this._canvas.style.left = 0; 68 | this._canvas.style.right = 0; 69 | 70 | map.on('moveend', this._reset, this); 71 | map.on('resize', this._resize, this); 72 | map.on('move', this._reset, this); 73 | 74 | if (map.options.zoomAnimation && L.Browser.any3d) { 75 | map.on('zoomanim', this._animateZoom, this); 76 | } 77 | 78 | map.on('zoomend', this._reset, this); 79 | 80 | this._reset(); 81 | }, 82 | 83 | onRemove: function (map) { 84 | map.getPanes().markerPane.removeChild(this._canvas); 85 | 86 | map.off('moveend', this._reset, this); 87 | map.off('resize', this._resize, this); 88 | map.off('move', this._reset, this); 89 | map.off('zoomend', this._reset, this); 90 | 91 | if (map.options.zoomAnimation) { 92 | map.off('zoomanim', this._animateZoom, this); 93 | } 94 | this_canvas = null; 95 | 96 | }, 97 | 98 | addTo: function (map) { 99 | map.addLayer(this); 100 | return this; 101 | }, 102 | 103 | _resize: function (resizeEvent) { 104 | this._canvas.width = resizeEvent.newSize.x; 105 | this._canvas.height = resizeEvent.newSize.y; 106 | }, 107 | _reset: function () { 108 | var topLeft = this._map.containerPointToLayerPoint([0, 0]); 109 | L.DomUtil.setPosition(this._canvas, topLeft); 110 | this._redraw(); 111 | }, 112 | 113 | _redraw: function () { 114 | var size = this._map.getSize(); 115 | var bounds = this._map.getBounds(); 116 | var zoomScale = (size.x * 180) / (20037508.34 * (bounds.getEast() - bounds.getWest())); // resolution = 1/zoomScale 117 | var zoom = this._map.getZoom(); 118 | this._canvas.width = size.x * pixelRatio; 119 | this._canvas.height = size.y * pixelRatio; 120 | this._canvas.style.width = size.x + 'px'; 121 | this._canvas.style.height = size.y + 'px'; 122 | 123 | // console.time('process'); 124 | 125 | if (this._userDrawFunc) { 126 | this._userDrawFunc(this, 127 | { 128 | canvas :this._canvas, 129 | bounds : bounds, 130 | size : size, 131 | zoomScale: zoomScale, 132 | zoom : zoom, 133 | options: this.options 134 | }); 135 | } 136 | 137 | 138 | // console.timeEnd('process'); 139 | 140 | this._frame = null; 141 | }, 142 | 143 | _animateZoom: function (e) { 144 | var scale = this._map.getZoomScale(e.zoom), 145 | offset = this._map._getCenterOffset(e.center)._multiplyBy(-scale).subtract(this._map._getMapPanePos()); 146 | 147 | this._canvas.style[L.DomUtil.TRANSFORM] = L.DomUtil.getTranslateString(offset) + ' scale(' + scale + ')'; 148 | 149 | } 150 | }); 151 | 152 | export default function (userDrawFunc, options) { 153 | return new L.CanvasOverlay(userDrawFunc, options); 154 | }; 155 | -------------------------------------------------------------------------------- /src/actions/oba.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import polyline from 'polyline'; 3 | 4 | import oba from 'libs/oba'; 5 | 6 | import { 7 | SET_ROUTES, 8 | SET_ROUTES_LOADING, 9 | SET_VEHICLES_LOADING, 10 | SET_VEHICLES, 11 | SET_STOPS, 12 | SET_STOPS_LOADING, 13 | INITIAL_VEHICLES_LOADED, 14 | } from 'constants/ActionTypes'; 15 | 16 | export function setRoutes(payload) { 17 | return { 18 | type: SET_ROUTES, 19 | payload, 20 | }; 21 | } 22 | 23 | export function setRoutesLoading(payload) { 24 | return { 25 | type: SET_ROUTES_LOADING, 26 | payload, 27 | }; 28 | } 29 | 30 | export function initialVehiclesLoaded() { 31 | return { 32 | type: INITIAL_VEHICLES_LOADED, 33 | }; 34 | } 35 | 36 | export function setVehicles(payload) { 37 | return { 38 | type: SET_VEHICLES, 39 | payload, 40 | }; 41 | } 42 | 43 | export function setVehiclesLoading(payload) { 44 | return { 45 | type: SET_VEHICLES_LOADING, 46 | payload, 47 | }; 48 | } 49 | 50 | export function setStops(payload) { 51 | return { 52 | type: SET_STOPS, 53 | payload, 54 | }; 55 | } 56 | 57 | export function setStopsLoading(payload) { 58 | return { 59 | type: SET_STOPS_LOADING, 60 | payload, 61 | }; 62 | } 63 | 64 | function routeDirection(name) { 65 | let stopDirection; 66 | if (name.indexOf('NB') > -1) { 67 | stopDirection = 'northbound'; 68 | } 69 | else if (name.indexOf('SB') > -1) { 70 | stopDirection = 'southbound'; 71 | } 72 | else if (name.indexOf('EB') > -1) { 73 | stopDirection = 'eastbound'; 74 | } 75 | else if (name.indexOf('WB') > -1) { 76 | stopDirection = 'westbound'; 77 | } 78 | else if (name.indexOf('IB') > -1) { 79 | stopDirection = 'inbound'; 80 | } 81 | else if (name.indexOf('OB') > -1) { 82 | stopDirection = 'outbound'; 83 | } 84 | else { 85 | stopDirection = _(name).toLower().replace(/[0-9]/g, '').trim().replace(' ', '-'); 86 | } 87 | return stopDirection; 88 | } 89 | 90 | function stopName(name) { 91 | const lowerName = _.toLower(name); 92 | const spaced = _.replace(lowerName, '/', ' / '); 93 | const words = _.words(spaced, /[^, ]+/g); 94 | const upperFirst = _.map(words, _.upperFirst); 95 | const joined = _.join(upperFirst, ' '); 96 | const unspaced = _.replace(joined, ' / ', '/'); 97 | return unspaced; 98 | } 99 | 100 | export function getStops(routeId) { 101 | return (dispatch) => { 102 | dispatch(setStopsLoading(true)); 103 | return oba(`stops-for-route/${routeId}`) 104 | .then(json => { 105 | const route = _.find(json.data.references.routes, { id: json.data.entry.routeId }); 106 | const stops = _.keyBy((json.data.references.stops), 'id'); 107 | const directions = []; 108 | const groups = _(json.data.entry.stopGroupings[0].stopGroups) 109 | .map((group) => { 110 | const direction = routeDirection(group.name.name); 111 | directions.push(direction); 112 | const longestPolyline = _.maxBy(group.polylines, 'length').points; 113 | return { 114 | name: group.name.name, 115 | direction, 116 | polyline: { 117 | encoded: longestPolyline, 118 | points: polyline.decode(longestPolyline), 119 | }, 120 | stops: group.stopIds.map((stopId) => { 121 | const name = stopName(stops[stopId].name); 122 | return { 123 | id: stopId, 124 | name, 125 | coords: { 126 | lat: stops[stopId].lat, 127 | lon: stops[stopId].lon, 128 | }, 129 | }; 130 | }), 131 | }; 132 | }) 133 | .keyBy('direction') 134 | .value(); 135 | dispatch(setStops({ 136 | [`${routeId}`]: { 137 | route, 138 | directions, 139 | groups, 140 | }, 141 | })); 142 | }) 143 | // .catch((err) => handleError(dispatch, err)) 144 | .then(() => { 145 | dispatch(setStopsLoading(false)); 146 | }); 147 | }; 148 | } 149 | 150 | function vehicleDirection(name) { 151 | let stopDirection; 152 | if (name.indexOf('NB') > -1) { 153 | stopDirection = 'northbound'; 154 | } 155 | else if (name.indexOf('SB') > -1) { 156 | stopDirection = 'southbound'; 157 | } 158 | else if (name.indexOf('EB') > -1) { 159 | stopDirection = 'eastbound'; 160 | } 161 | else if (name.indexOf('WB') > -1) { 162 | stopDirection = 'westbound'; 163 | } 164 | else if (name.indexOf('IB') > -1) { 165 | stopDirection = 'inbound'; 166 | } 167 | else if (name.indexOf('OB') > -1) { 168 | stopDirection = 'outbound'; 169 | } 170 | else { 171 | stopDirection = null; 172 | } 173 | return stopDirection; 174 | } 175 | 176 | export function getVehicles() { 177 | return (dispatch, getState) => { 178 | dispatch(setVehiclesLoading(true)); 179 | const currentAgency = getState().ui.currentAgency; 180 | return oba(`vehicles-for-agency/${currentAgency}`) 181 | .then(json => { 182 | const routes = _(json.data.references.routes).keyBy('id').value(); 183 | const trips = _(json.data.references.trips).keyBy('id').value(); 184 | const allVehicles = _(json.data.list) 185 | .filter(vehicle => vehicle.tripStatus) 186 | .map(vehicle => ({ 187 | ...vehicle, 188 | route: { 189 | id: trips[vehicle.tripId].routeId, 190 | shortName: routes[trips[vehicle.tripId].routeId].shortName, 191 | direction: vehicleDirection(trips[vehicle.tripId].tripHeadsign), 192 | }, 193 | })) 194 | .value(); 195 | const vehiclesByRoute = _(allVehicles) 196 | .groupBy('route.id') 197 | .value(); 198 | const vehicles = { 199 | allVehicles, 200 | vehiclesByRoute, 201 | }; 202 | dispatch(setVehicles(vehicles)); 203 | dispatch(setVehiclesLoading(false)); 204 | }); 205 | }; 206 | } 207 | 208 | export function getRoutes() { 209 | return (dispatch, getState) => { 210 | dispatch(setRoutesLoading(true)); 211 | const currentAgency = getState().ui.currentAgency; 212 | return oba(`routes-for-agency/${currentAgency}`) 213 | .then(json => { 214 | const orderedRoutes = _.sortBy(json.data.list, route => parseInt(route.shortName, 10)); 215 | const routesById = _.keyBy(orderedRoutes, 'id'); 216 | const routes = { 217 | orderedRoutes, 218 | routesById, 219 | }; 220 | dispatch(setRoutes(routes)); 221 | }) 222 | // .catch((err) => handleError(dispatch, err)) 223 | .then(() => { 224 | dispatch(setRoutesLoading(false)); 225 | }); 226 | }; 227 | } 228 | -------------------------------------------------------------------------------- /src/components/Map/MapboxWrapper.js: -------------------------------------------------------------------------------- 1 | /* global L */ 2 | 3 | import _ from 'lodash'; 4 | import canvasOverlay from './CanvasOverlay'; 5 | import UserMarker from './UserMarker'; 6 | import StopMarker from './StopMarker'; 7 | import stopPopup from './StopPopup'; 8 | import { mobile } from 'libs/mobile'; 9 | import rbush from 'rbush'; 10 | 11 | class MapboxWrapper { 12 | 13 | map = undefined; 14 | 15 | tree = rbush(); 16 | 17 | userLocation = undefined; 18 | userMarker = undefined; 19 | 20 | vehicles = undefined; 21 | vehiclesOverlay = undefined; 22 | canvasLayer = undefined; 23 | 24 | transitionStartTime = undefined; 25 | transitionTime = 400; 26 | 27 | polyline = undefined; 28 | polylineLayer = undefined; 29 | 30 | stops = undefined; 31 | stopMarkers = []; 32 | stopsLayer = undefined; 33 | 34 | boundsLayer = undefined; 35 | 36 | constructor(mapDiv) { 37 | L.mapbox.accessToken = 'pk.eyJ1IjoiaGFtZWVkbyIsImEiOiJHMnhTMDFvIn0.tFZs7sYMghY-xovxRPNNnw'; 38 | const mapInit = { 39 | center: [30.291708, -97.746557], 40 | zoom: 13, 41 | attributionControl: false, 42 | zoomControl: false, 43 | scrollWheelZoom: false, 44 | }; 45 | this.map = L.mapbox.map(mapDiv, 'mapbox.streets', mapInit); 46 | this.map.on('contextmenu', () => { 47 | this.map.zoomOut(); 48 | }); 49 | this.map.on('mousemove', (e) => { 50 | const { containerPoint } = e; 51 | const { x, y } = containerPoint; 52 | const result = this.tree.search([ x, y, x, y]); 53 | console.log(result); 54 | }); 55 | const panes = this.map.getPanes(); 56 | panes.overlayPane.style.pointerEvents = 'none'; 57 | this.boundsLayer = L.featureGroup().addTo(this.map); 58 | this.polylineLayer = L.featureGroup().addTo(this.boundsLayer); 59 | this.stopsLayer = L.featureGroup().addTo(this.boundsLayer); 60 | this.canvasLayer = L.featureGroup().addTo(this.map); 61 | this.pixelRatio = window.devicePixelRatio || 1; 62 | const busInitSize = 28; 63 | this.busInitRadius = busInitSize / 2; 64 | this.canvasInitSize = busInitSize + 20; 65 | this.canvasInitRadius = this.canvasInitSize / 2; 66 | const canvasSize = this.canvasInitSize * this.pixelRatio; 67 | const busSize = busInitSize * this.pixelRatio; 68 | const radius = busSize / 2; 69 | const offset = canvasSize / 2; 70 | this.oCanvas = document.createElement('canvas'); 71 | this.oCanvas.width = canvasSize; 72 | this.oCanvas.height = canvasSize; 73 | const oCtx = this.oCanvas.getContext('2d'); 74 | oCtx.fillStyle = '#fff'; 75 | oCtx.beginPath(); 76 | oCtx.arc(offset, offset, radius, 0, Math.PI * 2); 77 | oCtx.moveTo(offset - 14, offset - radius + 8); 78 | oCtx.lineTo(offset, offset - radius - 8); 79 | oCtx.lineTo(offset + 14, offset - radius + 8); 80 | oCtx.fill(); 81 | oCtx.closePath(); 82 | // south facing canvas 83 | this.southCanvas = document.createElement('canvas'); 84 | this.southCanvas.width = canvasSize; 85 | this.southCanvas.height = canvasSize; 86 | const southCtx = this.southCanvas.getContext('2d'); 87 | southCtx.shadowColor = 'rgba(0,0,0,0.4)'; 88 | southCtx.shadowBlur = 4; 89 | southCtx.shadowOffsetX = 0; 90 | southCtx.shadowOffsetY = 1; 91 | southCtx.save(); 92 | southCtx.translate(offset, offset); 93 | southCtx.rotate(Math.PI); 94 | southCtx.translate(-offset, -offset); 95 | southCtx.drawImage(this.oCanvas, 0, 0, canvasSize, canvasSize); 96 | southCtx.restore(); 97 | southCtx.fill(); 98 | // west facing canvas without shadow in chrome 99 | this.westMarkerCanvas = document.createElement('canvas'); 100 | this.westMarkerCanvas.width = canvasSize; 101 | this.westMarkerCanvas.height = canvasSize; 102 | const westMarkerCtx = this.westMarkerCanvas.getContext('2d'); 103 | westMarkerCtx.translate(offset, offset); 104 | westMarkerCtx.rotate(-Math.PI / 2); 105 | westMarkerCtx.translate(-offset, -offset); 106 | westMarkerCtx.drawImage(this.oCanvas, 0, 0, canvasSize, canvasSize); 107 | // west facing canvas 108 | this.westCanvas = document.createElement('canvas'); 109 | this.westCanvas.width = canvasSize; 110 | this.westCanvas.height = canvasSize; 111 | const westCtx = this.westCanvas.getContext('2d'); 112 | westCtx.shadowColor = 'rgba(0,0,0,0.4)'; 113 | westCtx.shadowBlur = 4; 114 | westCtx.shadowOffsetX = 0; 115 | westCtx.shadowOffsetY = 1; 116 | westCtx.drawImage(this.westMarkerCanvas, 0, 0, canvasSize, canvasSize); 117 | westCtx.fill(); 118 | // east facing canvas without shadow in chrome 119 | this.eastMarkerCanvas = document.createElement('canvas'); 120 | this.eastMarkerCanvas.width = canvasSize; 121 | this.eastMarkerCanvas.height = canvasSize; 122 | const eastMarkerCtx = this.eastMarkerCanvas.getContext('2d'); 123 | eastMarkerCtx.translate(offset, offset); 124 | eastMarkerCtx.rotate(Math.PI / 2); 125 | eastMarkerCtx.translate(-offset, -offset); 126 | eastMarkerCtx.drawImage(this.oCanvas, 0, 0, canvasSize, canvasSize); 127 | // east facing canvas 128 | this.eastCanvas = document.createElement('canvas'); 129 | this.eastCanvas.width = canvasSize; 130 | this.eastCanvas.height = canvasSize; 131 | const eastCtx = this.eastCanvas.getContext('2d'); 132 | eastCtx.shadowColor = 'rgba(0,0,0,0.4)'; 133 | eastCtx.shadowBlur = 4; 134 | eastCtx.shadowOffsetX = 0; 135 | eastCtx.shadowOffsetY = 1; 136 | eastCtx.drawImage(this.eastMarkerCanvas, 0, 0, canvasSize, canvasSize); 137 | eastCtx.fill(); 138 | // north facing canvas 139 | this.northCanvas = document.createElement('canvas'); 140 | this.northCanvas.width = canvasSize; 141 | this.northCanvas.height = canvasSize; 142 | const northCtx = this.northCanvas.getContext('2d'); 143 | northCtx.shadowColor = 'rgba(0,0,0,0.4)'; 144 | northCtx.shadowBlur = 4; 145 | northCtx.shadowOffsetX = 0; 146 | northCtx.shadowOffsetY = 1; 147 | northCtx.drawImage(this.oCanvas, 0, 0, canvasSize, canvasSize); 148 | northCtx.fill(); 149 | // non facing canvas 150 | this.regularCanvas = document.createElement('canvas'); 151 | this.regularCanvas.width = canvasSize; 152 | this.regularCanvas.height = canvasSize; 153 | const rCtx = this.regularCanvas.getContext('2d'); 154 | rCtx.fillStyle = '#fff'; 155 | rCtx.beginPath(); 156 | rCtx.arc(offset, offset, radius, 0, Math.PI * 2); 157 | rCtx.shadowColor = 'rgba(0,0,0,0.4)'; 158 | rCtx.shadowBlur = 4; 159 | rCtx.shadowOffsetX = 0; 160 | rCtx.shadowOffsetY = 1; 161 | rCtx.fill(); 162 | rCtx.closePath(); 163 | } 164 | 165 | setUserLocation = (location) => { 166 | if (!this.userMarker && location) { 167 | const locationArray = [location.lat, location.lon]; 168 | this.userMarker = L.marker(locationArray).addTo(this.boundsLayer); 169 | this.userMarker.setZIndexOffset(9999); 170 | this.userMarker.setIcon(L.divIcon(UserMarker)); 171 | } 172 | else if (location && location !== this.userLocation) { 173 | const locationArray = [location.lat, location.lon]; 174 | this.userMarker.setLatLng(L.latLng(locationArray)); 175 | } 176 | this.userLocation = location; 177 | } 178 | 179 | setStopsAndPolyline = (stops, poly) => { 180 | this.setPolyline(poly); 181 | this.setStops(stops); 182 | } 183 | 184 | setStops = (stops) => { 185 | if (stops && stops !== this.stops) { 186 | if (!mobile) { 187 | this.stopMarkers.forEach((marker) => { 188 | marker.off('click'); 189 | marker.off('mouseover'); 190 | marker.off('mouseout'); 191 | }); 192 | } 193 | this.stopsLayer.clearLayers(); 194 | this.stops = stops; 195 | this.stopMarkers = this.stops.map((stop) => { 196 | const locationArray = [stop.coords.lat, stop.coords.lon]; 197 | const stopMarker = L.marker(locationArray).addTo(this.stopsLayer); 198 | stopMarker.setIcon(L.divIcon(StopMarker)); 199 | stopMarker.bindPopup(stopPopup(stop.name), { 200 | offset: L.point(2, 15), 201 | closeButton: false, 202 | }); 203 | if (!mobile) { 204 | stopMarker.on('click', (e) => { 205 | e.preventDefault(); 206 | }); 207 | stopMarker.on('mouseover', () => { 208 | stopMarker.openPopup(); 209 | }); 210 | stopMarker.on('mouseout', () => { 211 | stopMarker.closePopup(); 212 | }); 213 | } 214 | return stopMarker; 215 | }); 216 | setTimeout(() => { 217 | this.map.fitBounds(this.boundsLayer.getBounds(), { 218 | animate: !mobile, 219 | paddingTopLeft: [0, 0], 220 | paddingBottomRight: [0, 0], 221 | }); 222 | }, 250); 223 | } 224 | else if (!stops && this.stops) { 225 | this.stops = undefined; 226 | if (!mobile) { 227 | this.stopMarkers.forEach((marker) => { 228 | marker.off('click'); 229 | marker.off('mouseover'); 230 | marker.off('mouseout'); 231 | }); 232 | } 233 | this.stopsLayer.clearLayers(); 234 | this.stopMarkers = []; 235 | } 236 | } 237 | 238 | setPolyline = (polyline) => { 239 | if (polyline && (!this.polyline || polyline.encoded !== this.polyline.encoded)) { 240 | this.polylineLayer.clearLayers(); 241 | this.polyline = polyline; 242 | const options = { 243 | color: '#157AFC', 244 | opacity: 0.5, 245 | className: 'polyline', 246 | }; 247 | L.polyline(this.polyline.points, options).addTo(this.polylineLayer); 248 | } 249 | else if (!polyline && this.polyline) { 250 | this.polyline = undefined; 251 | this.polylineLayer.clearLayers(); 252 | } 253 | } 254 | 255 | setVehicles = (vehicles) => { 256 | let v = []; 257 | if (this.vehicles) { 258 | const oldPositions = _.keyBy(this.vehicles, 'id'); 259 | v = vehicles.map((vehicle) => ({ 260 | id: vehicle.vehicleId, 261 | route: vehicle.route.shortName, 262 | direction: vehicle.route.direction, 263 | lastPosition: oldPositions[vehicle.vehicleId] ? oldPositions[vehicle.vehicleId].currentPosition : vehicle.tripStatus.position, 264 | currentPosition: oldPositions[vehicle.vehicleId] ? oldPositions[vehicle.vehicleId].currentPosition : vehicle.tripStatus.position, 265 | nextPosition: vehicle.tripStatus.position, 266 | })); 267 | this.vehicles = v; 268 | this.transitionStartTime = Date.now(); 269 | requestAnimationFrame(this.translateVehicles); 270 | } 271 | else { 272 | v = vehicles.map((vehicle) => ({ 273 | id: vehicle.vehicleId, 274 | route: vehicle.route.shortName, 275 | direction: vehicle.route.direction, 276 | lastPosition: vehicle.tripStatus.position, 277 | currentPosition: vehicle.tripStatus.position, 278 | nextPosition: vehicle.tripStatus.position, 279 | })); 280 | this.vehicles = v; 281 | this.vehiclesOverlay = canvasOverlay() 282 | .drawing(this.drawOnCanvas) 283 | .addTo(this.canvasLayer); 284 | } 285 | } 286 | 287 | translateVehicles = () => { 288 | if (!this.transitionStartTime) return; 289 | 290 | const time = Date.now(); 291 | const difference = time - this.transitionStartTime; 292 | if (difference >= this.transitionTime) { 293 | this.vehicles = this.vehicles.map((vehicle) => { 294 | const v = { 295 | id: vehicle.id, 296 | route: vehicle.route, 297 | direction: vehicle.direction, 298 | lastPosition: vehicle.nextPosition, 299 | currentPosition: vehicle.nextPosition, 300 | nextPosition: null, 301 | }; 302 | return v; 303 | }); 304 | this.transitionStartTime = null; 305 | this.vehiclesOverlay.redraw(); 306 | return; 307 | } 308 | this.vehicles = this.vehicles.map((vehicle) => { 309 | const percentTranslation = difference / this.transitionTime; 310 | if (!vehicle.lastPosition || !vehicle.nextPosition) return vehicle; 311 | const lat = vehicle.lastPosition.lat + ((vehicle.nextPosition.lat - vehicle.lastPosition.lat) * percentTranslation); 312 | const lon = vehicle.lastPosition.lon + ((vehicle.nextPosition.lon - vehicle.lastPosition.lon) * percentTranslation); 313 | const v = { 314 | id: vehicle.id, 315 | route: vehicle.route, 316 | direction: vehicle.direction, 317 | lastPosition: vehicle.lastPosition, 318 | currentPosition: { 319 | lat, 320 | lon, 321 | }, 322 | nextPosition: vehicle.nextPosition, 323 | }; 324 | return v; 325 | }); 326 | this.vehiclesOverlay.redraw(); 327 | requestAnimationFrame(this.translateVehicles); 328 | } 329 | 330 | drawOnCanvas = (overlay, params) => { 331 | this.tree.clear(); 332 | const ctx = params.canvas.getContext('2d'); 333 | ctx.scale(this.pixelRatio, this.pixelRatio); 334 | ctx.clearRect(0, 0, params.canvas.width, params.canvas.height); 335 | ctx.font = '12px Arial'; 336 | ctx.textAlign = 'center'; 337 | for (let i = 0; i < this.vehicles.length; i++) { 338 | const boundings = []; 339 | const v = this.vehicles[i]; 340 | if (v.currentPosition && params.bounds.contains([v.currentPosition.lat, v.currentPosition.lon])) { 341 | const dot = overlay._map.latLngToContainerPoint([v.currentPosition.lat, v.currentPosition.lon]); 342 | const x = dot.x - this.canvasInitRadius; 343 | const y = dot.y - this.canvasInitRadius; 344 | switch (v.direction) { 345 | case 'eastbound': 346 | ctx.drawImage(this.eastCanvas, x, y, this.canvasInitSize, this.canvasInitSize); 347 | break; 348 | case 'westbound': 349 | ctx.drawImage(this.westCanvas, x, y, this.canvasInitSize, this.canvasInitSize); 350 | break; 351 | case 'southbound': 352 | ctx.drawImage(this.southCanvas, x, y, this.canvasInitSize, this.canvasInitSize); 353 | break; 354 | case 'northbound': 355 | ctx.drawImage(this.northCanvas, x, y, this.canvasInitSize, this.canvasInitSize); 356 | break; 357 | default: 358 | ctx.drawImage(this.regularCanvas, x, y, this.canvasInitSize, this.canvasInitSize); 359 | } 360 | ctx.fillStyle = '#000'; 361 | const textX = dot.x; 362 | const textY = dot.y + 5; 363 | ctx.fillText(v.route, textX, textY); 364 | boundings.push([dot.x - this.busInitRadius, dot.y - this.busInitRadius, dot.x + this.busInitRadius, dot.y + this.busInitRadius, { id: v.id }]); 365 | } 366 | this.tree.load(boundings); 367 | } 368 | }; 369 | 370 | } 371 | 372 | export default MapboxWrapper; 373 | --------------------------------------------------------------------------------