├── 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 |
`;
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 |
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 |
--------------------------------------------------------------------------------