├── .github └── workflows │ └── release.yaml ├── .gitignore ├── .tool-versions ├── .vscode └── launch.json ├── LICENSE.txt ├── Makefile ├── README.md ├── app ├── components │ ├── location.jsx │ └── modal.jsx ├── main.css └── main.jsx ├── data ├── README.md ├── csv2json.jq ├── make_routes.sh └── routes.json ├── fly.toml ├── go.mod ├── go.sum ├── img ├── arrow.png ├── arrow.svg ├── icon_arrow_offset.png ├── icon_bus_fill.png ├── icon_bus_fill_black.png ├── icon_bus_fill_circle.png ├── icon_mock_ferry.png ├── icon_streetcar_fill.png ├── icon_streetcar_fill_black.png ├── icon_streetcar_fill_circle.png └── icon_vehicle_error.png ├── main.go ├── main_test.go ├── mock_bustime_server ├── go.mod ├── main.go ├── mock_vehicles.json └── mock_vehicles_jp.protobuf ├── package-lock.json ├── package.json └── public ├── app.css ├── app.js └── index.html /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Fly Deploy 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | deploy: 8 | name: Deploy app 9 | runs-on: ubuntu-latest 10 | concurrency: deploy-group # optional: ensure only one action runs at a time 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: superfly/flyctl-actions/setup-flyctl@master 14 | - run: flyctl deploy --remote-only 15 | env: 16 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | # Go workspace file 18 | go.work 19 | 20 | node_modules 21 | 22 | # Binaries 23 | nola-transit-map 24 | server 25 | __debug_* 26 | 27 | .vscode/* 28 | # Track vscode debugger config 29 | !.vscode/launch.json 30 | 31 | .DS_Store 32 | 33 | #generated temp route files 34 | data/route_* 35 | data/*.txt 36 | data/*.zip 37 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 19.0.1 2 | golang 1.19.3 3 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | 5 | { 6 | "name": "Dev Mode", 7 | "type": "go", 8 | "request": "launch", 9 | "mode": "auto", 10 | "program": "./", 11 | "env": { 12 | "DEV": "1", 13 | "CLEVER_DEVICES_KEY": "DEV", 14 | "CLEVER_DEVICES_IP": "DEV" 15 | } 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Benjamin Eckel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | npm install 3 | npm run build 4 | go build 5 | 6 | run: 7 | ./nola-transit-map 8 | 9 | show: 10 | open http://localhost:8080 11 | 12 | ### DEV ### 13 | 14 | mock: 15 | cd mock_bustime_server && go build && ./server 16 | 17 | dev: 18 | npm install 19 | go build 20 | sh -c 'DEV=1 CLEVER_DEVICES_KEY=1 CLEVER_DEVICES_IP=1 ./nola-transit-map' || echo "Couldn't run the binary." 21 | 22 | watch: 23 | npm run watch -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## NOLA Transit Map 2 | 3 | Realtime map of all New Orleans public transit vehicles (streetcars and busses). You can view the map here for the time being: [https://nolatransit.fly.dev/](https://nolatransit.fly.dev/) 4 | 5 | For some reason, going from the old RTA app to the new Le Pass app has resulted in the loss of realtime functionality. Not having realtime makes 6 | getting around the city extremely frustrating. This map is just a stop gap to get the data out to people again. 7 | 8 | ### Needs to be Done 9 | 10 | * somehow communicate the staleness of the data to the user (we have a status indicator for the connection but could use the vehicle timestamps) 11 | * nice icons and popups for the vehicles 12 | * show the user's location on the map 13 | 14 | ### Contributing 15 | 16 | Join #civic-hacking in the Nola Devs Slack channel, where this project is discussed: https://nola.slack.com/join/shared_invite/zt-4882ja82-iGm2yO6KCxsi2aGJ9vnsUQ. 17 | 18 | The API key is a protected secret. Only a few have access, hence the included mock server that is used in DEV mode. 19 | 20 | You need a few things on your machine to build the project. If you are an `asdf` user there is a .tool-versions file with acceptable versions of node, npm, and go, but not make to keep from conflicting with system build tools. 21 | 22 | * node and npm 23 | * go 24 | * make 25 | 26 | ### To Run in Development: 27 | 28 | 1. Run the mock bustime server in a terminal. The mock server serves fake vehicle data. Vehicles will appear stationary. 29 | ``` 30 | # terminal tab 1 - Mock bustime server 31 | make mock 32 | ``` 33 | 34 | 2. Run the main server _in another terminal_. 35 | ``` 36 | # terminal tab 2 - Go backend 37 | make dev 38 | ``` 39 | 40 | 3. (Optional) If working on the frontend, you probably want changes to trigger a JS build automatically. You'll still have to refresh the page to see changes. 41 | _In a 3rd terminal_: 42 | ``` 43 | # terminal tab 3 - React frontend 44 | make watch 45 | ``` 46 | 47 | 4. Open the frontend [http://localhost:8080](http://localhost:8080) 48 | 49 | You may need to refresh the page after the browser window is automatically opened by the `make` command. 50 | 51 | ### To Run in Production: 52 | 53 | Add the API and IP env vars to `make run`: 54 | ``` 55 | make build && make run CLEVER_DEVICES_KEY=thekey CLEVER_DEVICES_IP=ipaddr 56 | ``` 57 | 58 | ### To Refresh Route Data: 59 | When transit agencies modify their routes, you may need to refresh the route geometry data from the agency's [GTFS](https://en.wikipedia.org/wiki/GTFS) feed. 60 | Run the make_routes.sh script to download this data and convert it into the geojson format used by the frontend. 61 | ``` 62 | cd data 63 | ./make_routes.sh 64 | ``` 65 | -------------------------------------------------------------------------------- /app/components/location.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import L from 'leaflet'; 3 | import { useMap } from 'react-leaflet'; 4 | import { CircleMarker, Popup } from 'react-leaflet'; 5 | 6 | export default function LocationMarker() { 7 | const [position, setPosition] = useState(null); 8 | const [bbox, setBbox] = useState([]); 9 | 10 | const map = useMap(); 11 | 12 | useEffect(() => { 13 | map.locate().on("locationfound", function (e) { 14 | setPosition(e.latlng); 15 | //map.flyTo(e.latlng, map.getZoom()); 16 | const radius = e.accuracy; 17 | const circle = L.circle(e.latlng, radius); 18 | circle.addTo(map); 19 | setBbox(e.bounds.toBBoxString().split(",")); 20 | }); 21 | }, [map]); 22 | 23 | return position === null ? null : ( 24 | 25 | 26 | You are here. 27 | 28 | 29 | ); 30 | } -------------------------------------------------------------------------------- /app/components/modal.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Button from 'react-bootstrap/Button'; 3 | 4 | 5 | function CustomModal(props) { 6 | const [show, setShow] = useState(false); 7 | 8 | const handleClose = () => setShow(false); 9 | const handleShow = () => setShow(true); 10 | 11 | return( 12 |
13 | 14 | {show ? ( 15 |
16 | 30 |
31 | ) : ( 32 | null 33 | )} 34 |
35 | ) 36 | } 37 | 38 | export default CustomModal; -------------------------------------------------------------------------------- /app/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 0; 3 | margin: 0; 4 | } 5 | 6 | html, 7 | body, 8 | #main, 9 | main, 10 | .App, 11 | .leaflet-container { 12 | height: 100%; 13 | width: 100vw; 14 | } 15 | 16 | :root { 17 | --primary: #7A278D; 18 | --primary_darker: #610C75; 19 | --secondary_lighter: #02205C91; 20 | --secondary: #02205c; 21 | --secondary_darker: #01153c; 22 | --secondary_darkest: #00040b; 23 | --black: #111; 24 | --black_lighter: #00000094; 25 | --success: #72B01D; 26 | --error: #EA3546; 27 | --alert: #FFC100; 28 | } 29 | 30 | .control-bar { 31 | position: absolute; 32 | min-width: 35vw; 33 | border-radius: 0 0 10px 0px; 34 | 35 | display: flex; 36 | justify-content: space-between; 37 | align-items: center; 38 | 39 | background: var(--primary); 40 | color: white; 41 | z-index: 1000; 42 | 43 | filter: drop-shadow(0.2rem 0.2rem 0.5rem #00000077) 44 | } 45 | 46 | 47 | .control-bar svg { 48 | margin-right: 0.5rem; 49 | } 50 | 51 | /* .control-bar__filter-label { */ 52 | /* align-items: center; */ 53 | /* display: flex; */ 54 | /* padding: 0.75rem 1rem; */ 55 | /* } */ 56 | 57 | .control-bar__connection-container { 58 | border-left: solid 1px var(--primary_darker); 59 | padding: 0.75rem 1rem; 60 | font-weight: 600; 61 | } 62 | 63 | .control-bar__connection-container.connected svg { 64 | color: var(--success); 65 | } 66 | 67 | .control-bar__connection-container.not-connected svg { 68 | color: var(--error); 69 | } 70 | 71 | .control-bar__connection-container.trouble-connecting svg { 72 | color: var(--alert); 73 | } 74 | 75 | .route-filter { 76 | /* margin-left: 1rem; */ 77 | color: black; 78 | 79 | min-width: 300px; 80 | 81 | margin: 0.5rem; 82 | } 83 | 84 | .about-button { 85 | bottom: 0; 86 | color: white; 87 | position: absolute; 88 | left: 0; 89 | z-index: 1000; 90 | background: var(--secondary); 91 | border-radius: 0 10px 0 0; 92 | } 93 | 94 | .about-button:hover { 95 | background: var(--secondary_darker); 96 | } 97 | 98 | .about-button:active { 99 | transform: scale(99%); 100 | } 101 | 102 | .about-button svg { 103 | margin-right: 0.5rem; 104 | } 105 | 106 | button { 107 | display: flex; 108 | align-items: center; 109 | border: none; 110 | padding: 0.5rem 1rem; 111 | margin: 0; 112 | text-decoration: none; 113 | background: transparent; 114 | font-family: sans-serif; 115 | font-size: 1rem; 116 | cursor: pointer; 117 | text-align: center; 118 | transition: background 250ms ease-in-out, transform 150ms ease; 119 | } 120 | 121 | .Modal { 122 | position: fixed; 123 | z-index: 2000; 124 | inset: 0; 125 | background: var(--black_lighter); 126 | } 127 | 128 | .Modal__content { 129 | background: var(--secondary_darkest); 130 | color: white; 131 | min-height: 20%; 132 | padding: 2rem; 133 | border-radius: 10px; 134 | max-width: 60%; 135 | box-shadow: 2px 2px 8px #0009; 136 | margin-right: auto; 137 | margin-left: auto; 138 | margin-top: 10%; 139 | } 140 | 141 | .Modal__content--header { 142 | text-align: center; 143 | } 144 | 145 | .Modal__content--footer { 146 | margin-top: 2rem; 147 | } 148 | 149 | @media (min-width: 1400px) { 150 | .control-bar { 151 | min-width: 30vw; 152 | } 153 | } 154 | 155 | @media (max-width: 1100px) { 156 | .control-bar { 157 | min-width: 45vw; 158 | } 159 | 160 | .Modal__content { 161 | max-width: 75%; 162 | } 163 | } 164 | 165 | @media (max-width: 500px) { 166 | body { 167 | font-size: 0.9rem; 168 | } 169 | 170 | /* .control-bar__filter-label { */ 171 | /* width: 100%; */ 172 | /* padding: 0.5rem 0; */ 173 | /* } */ 174 | 175 | .route-filter { 176 | width: 100%; 177 | } 178 | 179 | .route-select-option__wrapper .route-and-icon span { 180 | font-size: 1.5rem; 181 | } 182 | 183 | .control-bar__label-text { 184 | display: none; 185 | } 186 | 187 | .control-bar { 188 | min-width: 100vw; 189 | font-size: 0.9rem; 190 | } 191 | 192 | .Modal__content { 193 | max-width: 90%; 194 | margin-top: 15%; 195 | font-size: 0.9rem; 196 | } 197 | 198 | .about-button { 199 | font-size: 0.7rem; 200 | padding: 0.5rem 0.75rem; 201 | } 202 | } 203 | 204 | .route-select-option__wrapper { 205 | display: grid; 206 | grid-template-columns: min-content auto; 207 | align-items: center; 208 | gap: 1rem; 209 | } 210 | 211 | .route-select-option__wrapper .route-and-icon { 212 | display: grid; 213 | grid-template-columns: 1fr 1fr; 214 | align-items: center; 215 | gap: 0.5rem; 216 | font-family: 'Trebuchet MS', 'Lucida Sans Unicode', 'Lucida Grande', 'Lucida Sans', Arial, sans-serif; 217 | font-size: 1.8rem; 218 | } 219 | 220 | .route-select-option__wrapper img { 221 | width: 20px; 222 | height: 20px; 223 | } -------------------------------------------------------------------------------- /app/main.jsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect, forwardRef } from 'react' 2 | import { createRoot } from 'react-dom/client'; 3 | import { MapContainer, TileLayer, Marker, Popup, GeoJSON } from 'react-leaflet' 4 | import { BsInfoLg, BsFillCircleFill, BsFillCloudSlashFill, BsFillExclamationTriangleFill } from 'react-icons/bs' 5 | import L from 'leaflet'; 6 | import "leaflet-rotatedmarker"; 7 | import 'bootstrap/dist/css/bootstrap.min.css'; 8 | import NortaGeoJson from '../data/routes.json'; 9 | import Row from 'react-bootstrap/Row'; 10 | import Col from 'react-bootstrap/Col'; 11 | import Select, { components as SelectComponents } from 'react-select' 12 | import makeAnimated from 'react-select/animated'; 13 | import CustomModal from './components/modal'; 14 | import LocationMarker from './components/location'; 15 | import './main.css'; 16 | 17 | import busIconMap from '../img/icon_bus_fill_circle.png' 18 | import busIconSelect from '../img/icon_bus_fill_black.png' 19 | import streetcarIconMap from '../img/icon_streetcar_fill_circle.png' 20 | import streetcarIconSelect from '../img/icon_streetcar_fill_black.png' 21 | // TODO: awaiting real ferry icon 22 | import ferryIcon from '../img/icon_mock_ferry.png' 23 | import errorIcon from '../img/icon_vehicle_error.png' 24 | import arrowIcon from '../img/icon_arrow_offset.png' 25 | 26 | import basicArrow from '../img/arrow.png' 27 | 28 | const VALID_ROUTES = NortaGeoJson 29 | .features 30 | .filter(f => f.geometry.type === "GeometryCollection" && f.properties.route_id) 31 | 32 | const ROUTE_ELEMS = VALID_ROUTES 33 | .reduce((acc, f) => { 34 | return { 35 | ...acc, 36 | [f.properties.route_id]: 37 | } 38 | }, {}) 39 | 40 | // Used in creation of options for dropdown & map markers 41 | const ROUTE_INFO = VALID_ROUTES 42 | .reduce((acc, f) => { 43 | const { route_long_name, route_type, route_color: color } = f.properties 44 | let name = route_long_name ?? '' 45 | let type = 'error' 46 | if (route_type == 3) { 47 | type = 'bus' 48 | if (!name.toLowerCase().endsWith('bus')) name += ' Bus' 49 | } 50 | if (route_type == 0) { 51 | type = 'streetcar' 52 | if (!name.toLowerCase().endsWith('streetcar')) name += ' Streetcar' 53 | } 54 | if (route_type == 4) { 55 | type = 'ferry' 56 | if (!name.toLowerCase().endsWith('ferry')) name += ' Ferry' 57 | } 58 | return { 59 | ...acc, 60 | [f.properties.route_id]: { name, type, color } 61 | } 62 | }, {}) 63 | 64 | const iconVehicle = new L.Icon({ 65 | iconUrl: basicArrow, 66 | iconRetinaUrl: basicArrow, 67 | iconAnchor: null, 68 | shadowUrl: null, 69 | shadowSize: null, 70 | shadowAnchor: null, 71 | iconSize: new L.Point(24, 24), 72 | className: 'leaflet-marker-icon' 73 | }); 74 | 75 | const RotatedMarker = forwardRef(({ children, ...props }, forwardRef) => { 76 | const markerRef = useRef(); 77 | 78 | const { rotationAngle, rotationOrigin } = props; 79 | useEffect(() => { 80 | const marker = markerRef.current; 81 | if (marker) { 82 | marker.setRotationAngle(rotationAngle); 83 | marker.setRotationOrigin(rotationOrigin); 84 | } 85 | }, [rotationAngle, rotationOrigin]); 86 | 87 | return ( 88 | { 90 | markerRef.current = ref; 91 | if (forwardRef) { 92 | forwardRef.current = ref; 93 | } 94 | }} 95 | {...props} 96 | > 97 | {children} 98 | 99 | ); 100 | }); 101 | 102 | /* 103 | These routes don't exist at NORTA.com 104 | When a vehicle enters its garage, its route becomes 'U' 105 | The definition of PO and PI routes is unknown -> filter out for now 106 | Note: The U route designation applies to both vehicles in the garage 107 | (not in service) and a selection of 24-hour routes that are not in 108 | the garage and running their normal route between 12:30am-ish and 109 | 4:00am-sh. 110 | */ 111 | const NOT_IN_SERVICE_ROUTES = ['PO', 'PI'] 112 | 113 | const MARKER_ICON_SIZE = 24 // ? pt or px 114 | 115 | const DROPDOWN_ICON_IMG = Object.freeze({ 116 | ferry: ferryIcon, 117 | streetcar: streetcarIconSelect, 118 | bus: busIconSelect, 119 | error: errorIcon, 120 | }) 121 | 122 | const ICON_ARROW = new L.Icon({ 123 | iconUrl: arrowIcon, 124 | iconRetinaUrl: arrowIcon, 125 | // Tall so arrow doesn't intersect vehicle (&& match aspect ratio of graphic) 126 | iconSize: [MARKER_ICON_SIZE, MARKER_ICON_SIZE * 2], 127 | className: 'leaflet-marker-icon' 128 | }); 129 | 130 | function ArrowMarker(props) { 131 | const { rotationAngle } = props; 132 | const markerRef = useRef(); 133 | 134 | useEffect(() => { 135 | markerRef.current?.setRotationAngle(rotationAngle); 136 | }, [rotationAngle]); 137 | return ; 138 | } 139 | 140 | function newVehicleMapIcon(image) { 141 | return new L.Icon({ 142 | iconUrl: image, 143 | iconRetinaUrl: image, 144 | iconSize: [MARKER_ICON_SIZE, MARKER_ICON_SIZE], 145 | className: 'leaflet-marker-icon' 146 | }); 147 | } 148 | 149 | const VEHICLE_MARKER_ICONS = Object.freeze({ 150 | ferry: newVehicleMapIcon(ferryIcon), 151 | streetcar: newVehicleMapIcon(streetcarIconMap), 152 | bus: newVehicleMapIcon(busIconMap), 153 | error: newVehicleMapIcon(errorIcon), 154 | }) 155 | 156 | function VehicleMarker({ children, ...props }) { 157 | const { type } = props 158 | return ( 159 | 160 | {children} 161 | 162 | ) 163 | } 164 | 165 | // React Select animations 166 | const selectAnimatedComponents = makeAnimated() 167 | 168 | const { Option: SelectOption } = SelectComponents 169 | 170 | // custom React Select option - add icons and route colors to route options 171 | function RouteSelectOption(props) { 172 | const { data: { value, name, icon, color } } = props 173 | return ( 174 | 175 |
176 |
177 | {value} 178 | {name}/ 179 |
180 | {name} 181 |
182 |
183 | ) 184 | } 185 | 186 | function timestampDisplay(timestamp) { 187 | const relativeTimestamp = new Date() - new Date(timestamp); 188 | if (relativeTimestamp < 60000) { return 'less than a minute ago'; } 189 | const minutes = Math.round(relativeTimestamp / 60000); 190 | if (minutes === 1) { return '1 minute ago'} 191 | return minutes + ' minutes ago'; 192 | } 193 | 194 | 195 | class App extends React.Component { 196 | constructor(props) { 197 | super(props) 198 | const routes = localStorage.getItem("routes") || "[]" 199 | this.state = { 200 | vehicles: [], 201 | routes: JSON.parse(routes), 202 | connected: false, 203 | lastUpdate: new Date(), 204 | now: new Date(), 205 | sse: null, 206 | } 207 | this.handleRouteChange = this.handleRouteChange.bind(this) 208 | } 209 | 210 | componentWillMount() { 211 | this.connectSSE(); 212 | this.interval = setInterval(() => this.setState({ now: Date.now() }), 1000); 213 | } 214 | 215 | componentWillUnmount() { 216 | this.closeSSE(); 217 | clearInterval(this.interval) 218 | } 219 | 220 | connectSSE = () => { 221 | if (!this.state.sse || (this.state.sse && this.state.sse.readyState == EventSource.CLOSED)) { 222 | this.setState({ connected: false }); 223 | 224 | const url = `${window.location.protocol}//${window.location.hostname}:${window.location.port}/sse` 225 | const sse = new EventSource(url); 226 | 227 | sse.onmessage = (evt) => { 228 | console.log('SSE message'); 229 | if (!this.state.connected) this.setState({ connected: true }) 230 | const vehicles = JSON.parse(evt.data) 231 | const lastUpdate = new Date() 232 | this.setState({ 233 | vehicles, 234 | lastUpdate, 235 | }) 236 | console.dir(vehicles) 237 | }; 238 | 239 | sse.onclose = () => { 240 | console.log('SSE closed'); 241 | this.setState({ connected: false }); 242 | }; 243 | 244 | sse.onerror = (error) => { 245 | console.error('SSE error:', error); 246 | this.setState({ connected: false }); 247 | }; 248 | 249 | this.setState({ sse }); 250 | } 251 | 252 | //check connection for reconnect every 5s 253 | setTimeout(this.connectSSE, 5000); 254 | }; 255 | 256 | closeSSE = () => { 257 | if (this.state.sse) { 258 | this.state.sse.close(); 259 | } 260 | }; 261 | 262 | routeComponents() { 263 | if (this.state.routes.length === 0) return Object.values(ROUTE_ELEMS) 264 | 265 | return this.state.routes 266 | .map(r => r.value) 267 | .map(rid => ROUTE_ELEMS[rid]) 268 | .filter(r => r !== null) 269 | } 270 | 271 | markerComponents() { 272 | let query = (_v) => true 273 | if (this.state.routes.length > 0) { 274 | const values = this.state.routes.map(r => r.value) 275 | query = (v) => values.includes(v.rt) 276 | console.log("query filter on routes: " + values) 277 | } 278 | 279 | return this.state.vehicles 280 | .filter(query) 281 | .map(v => { 282 | const coords = [v.lat, v.lon].map(parseFloat) 283 | const rotAng = parseInt(v.hdg, 10) 284 | const relTime = timestampDisplay(v.tmstmp) 285 | /* 286 | const type = ROUTE_INFO[v.rt]?.type ?? 'error' 287 | return ( 288 |
289 | 290 | 291 | 292 | {v.rt}{v.des ? ' - ' + v.des.replace('>>', 'to') : ''} 293 |
294 | {relTime} 295 |
296 |
297 |
298 | ) 299 | */ 300 | return 301 | 302 | {v.rt}{v.des ? ' - ' + v.des : ''} 303 |
{relTime} 304 |
305 |
306 | }) 307 | } 308 | 309 | mapContainer() { 310 | return 311 | 315 | {this.markerComponents()} 316 | {this.routeComponents()} 317 | 318 | 319 | } 320 | 321 | notConnectedScreen() { 322 | return 323 | 324 |

Connection broken. Attempting to reconnect...

325 | 326 |
327 | } 328 | 329 | buildControlBar() { 330 | let connectionStatus = this.state.connected 331 | ? 332 | Connected 333 | 334 | : 335 | Not Connected 336 | 337 | 338 | if (this.state.connected && this.lagging()) connectionStatus = 339 | 340 | Trouble Connecting... 341 | 342 | 343 | if (!this.state.connected) return this.notConnectedScreen() 344 | 345 | if (this.state.vehicles.length === 0) { 346 | return 347 | 348 |

No Vehicles found yet. Are you connected?

349 | 350 |
351 | } 352 | 353 | function compare(a, b) { 354 | return ('' + a.label).localeCompare(b.label, 'en', { numeric: true }); 355 | } 356 | const routes = [...new Set(this.state.vehicles.map(v => v.rt))] 357 | const routeOptions = routes.map(rt => { 358 | // route name is route id if routes.json is unaware of the route 359 | let { name, type, color } = ROUTE_INFO[rt] 360 | ?? { name: rt, type: 'error', color: '#000000'} 361 | if (color == "#ffffff") color = "#000000" 362 | if (rt == "PI" || rt == "PO" || rt == "U") name = "Unknown" 363 | const icon = DROPDOWN_ICON_IMG[type] ?? DROPDOWN_ICON_IMG['bus'] 364 | 365 | return { value: rt, label: rt, name, icon, color } 366 | }).sort(compare) 367 | 368 | return
369 | {/*