Skip to content

Commit cad6636

Browse files
Merge pull request #401 from opentripplanner/custom-layer-config
Custom GTFS-rt overlays
2 parents 92b0de6 + 59763fd commit cad6636

File tree

6 files changed

+503
-6
lines changed

6 files changed

+503
-6
lines changed

Diff for: example.js

+24-3
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
DefaultItinerary,
2323
DefaultMainPanel,
2424
FieldTripWindows,
25+
// GtfsRtVehicleOverlay,
2526
MailablesWindow,
2627
MobileResultsScreen,
2728
MobileSearchScreen,
@@ -70,6 +71,26 @@ const TermsOfStorage = () => (
7071
</>
7172
)
7273

74+
// Define custom map overlays.
75+
// customMapOverlays can be a single overlay element or an array of such elements.
76+
// Each overlay must include a name prop (and a key prop if wrapping in an array).
77+
// (Wrapping the overlays inside a React Fragment <> or other component will not work.)
78+
const customMapOverlays = [
79+
// Uncomment the code below and change props to add GTFS-rt overlays.
80+
// <GtfsRtVehicleOverlay
81+
// key='custom1'
82+
// liveFeedUrl='https://gtfs-rt.example.com/feed1.pb'
83+
// name='GTFS-rt Example Vehicles 1'
84+
// />,
85+
// <GtfsRtVehicleOverlay
86+
// key='custom2'
87+
// liveFeedUrl='https://gtfs-rt.example.com/feed2.pb'
88+
// name='GTFS-rt Example Vehicles 2'
89+
// routeDefinitionUrl='https://gtfs-rt.example.com/routes.json'
90+
// visible
91+
// />
92+
]
93+
7394
// define some application-wide components that should be used in
7495
// various places. The following components can be provided here:
7596
// - defaultMobileTitle (required)
@@ -86,8 +107,8 @@ const TermsOfStorage = () => (
86107
// - TermsOfService (required if otpConfig.persistence.strategy === 'otp_middleware')
87108
// - TermsOfStorage (required if otpConfig.persistence.strategy === 'otp_middleware')
88109
const components = {
89-
90110
defaultMobileTitle: () => <div className='navbar-title'>OpenTripPlanner</div>,
111+
getCustomMapOverlays: () => customMapOverlays,
91112
/**
92113
* Example of a custom route label provider to pass to @opentripplanner/core-utils/map#itineraryToTransitive.
93114
* @param {*} itineraryLeg The OTP itinerary leg for which to obtain a custom route label.
@@ -140,8 +161,8 @@ const store = createStore(
140161
combineReducers({
141162
callTaker: createCallTakerReducer(otpConfig),
142163
otp: createOtpReducer(otpConfig),
143-
user: createUserReducer(),
144-
router: connectRouter(history)
164+
router: connectRouter(history),
165+
user: createUserReducer()
145166
}),
146167
compose(applyMiddleware(...middleware))
147168
)

Diff for: lib/components/map/default-map.js

+4-3
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ class DefaultMap extends Component {
128128
vehicleRentalQuery,
129129
vehicleRentalStations
130130
} = this.props
131+
const { getCustomMapOverlays, getTransitiveRouteLabel } = this.context
131132

132133
const center = mapConfig && mapConfig.initLat && mapConfig.initLon
133134
? [mapConfig.initLat, mapConfig.initLon]
@@ -159,9 +160,7 @@ class DefaultMap extends Component {
159160
<EndpointsOverlay />
160161
<RouteViewerOverlay />
161162
<StopViewerOverlay />
162-
<TransitiveOverlay
163-
getTransitiveRouteLabel={this.context.getTransitiveRouteLabel}
164-
/>
163+
<TransitiveOverlay getTransitiveRouteLabel={getTransitiveRouteLabel} />
165164
<TripViewerOverlay />
166165
<ElevationPointMarker />
167166

@@ -200,6 +199,8 @@ class DefaultMap extends Component {
200199
default: return null
201200
}
202201
})}
202+
{/* Render custom overlays, if set. */}
203+
{typeof getCustomMapOverlays === 'function' && getCustomMapOverlays()}
203204
</BaseMap>
204205
</MapContainer>
205206
)

Diff for: lib/components/map/gtfs-rt-vehicle-overlay.js

+237
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import { transit_realtime as transitRealtime } from 'gtfs-realtime-bindings'
2+
import L from 'leaflet'
3+
import TransitVehicleOverlay from '@opentripplanner/transit-vehicle-overlay'
4+
import PropTypes from 'prop-types'
5+
import React from 'react'
6+
import ReactDOMServer from 'react-dom/server'
7+
import { FeatureGroup, MapLayer, Marker, Polyline } from 'react-leaflet'
8+
import styled from 'styled-components'
9+
10+
import { getModeFromRoute } from '../../util/viewer'
11+
12+
export const VehicleShape = styled.span`
13+
background-color: #${props => props.route?.route_color || '000000'};
14+
border: 1px solid #000000;
15+
border-radius: 50%;
16+
color: #${props => props.route?.route_text_color || 'ffffff'};
17+
display: block;
18+
height: 24px;
19+
left: -6px;
20+
line-height: 24px;
21+
margin-left: auto;
22+
margin-right: auto;
23+
position: relative;
24+
text-align: center;
25+
top: -6px;
26+
width: 24px;
27+
`
28+
29+
/**
30+
* Convert GTFS-rt entities to vehicle location from OTP-UI.
31+
* @param vehicle The GTFS-rt vehicle position entity to convert.
32+
* @param routes Optional GTFS-like routes list.
33+
*/
34+
function gtfsRtToTransitVehicle (gtfsRtVehicle, routes) {
35+
const { position, stopId, timestamp, trip, vehicle } = gtfsRtVehicle.vehicle
36+
const route = routes.find(r => r.route_id === trip.routeId)
37+
let routeShortName
38+
let routeType = 'BUS'
39+
if (route) {
40+
routeShortName = route.route_short_name
41+
// Obtain the OTP route type from the GTFS route_type enum value.
42+
routeType = getModeFromRoute({ type: route.route_type })
43+
}
44+
return {
45+
heading: position.bearing,
46+
id: vehicle.id,
47+
lat: position.latitude,
48+
lon: position.longitude,
49+
reportDate: new Date(parseInt(timestamp, 10)).toLocaleString(),
50+
routeShortName,
51+
routeType,
52+
stopId,
53+
tripId: trip.tripId,
54+
vehicleId: vehicle.id
55+
}
56+
}
57+
58+
/**
59+
* Generate a vehicle shape component (a colored dot) based on the provided routes list.
60+
*/
61+
function makeVehicleShape (routes) {
62+
return props => {
63+
const { lat, lon, routeShortName } = props.vehicle
64+
const route = routes.find(r => r.route_short_name === routeShortName) || {}
65+
const iconHtml = ReactDOMServer.renderToStaticMarkup(
66+
<VehicleShape route={route}>
67+
{route.route_short_name}
68+
</VehicleShape>
69+
)
70+
return (
71+
<Marker
72+
icon={L.divIcon({ html: iconHtml })}
73+
position={[lat, lon]}
74+
/>
75+
)
76+
}
77+
}
78+
79+
/**
80+
* This component is a composite overlay that renders GTFS-rt vehicle positions
81+
* that it downloads from the liveFeedUrl prop, using the
82+
* @opentripplanner/transit-vehicle-overlay package.
83+
*
84+
* For transit routes not defined in a known GTFS feed, this component also
85+
* renders transit route shapes if available from the routeDefinitionUrl prop.
86+
*/
87+
class GtfsRtVehicleOverlay extends MapLayer {
88+
static propTypes = {
89+
/** URL to GTFS-rt feed in protocol buffer format. */
90+
liveFeedUrl: PropTypes.string,
91+
/** URL to GTFS-like route list in JSON format. */
92+
routeDefinitionUrl: PropTypes.string,
93+
/** Sets whether the layer is initially visible. */
94+
visible: PropTypes.bool
95+
}
96+
97+
constructor (props) {
98+
super(props)
99+
this.state = {
100+
routes: [],
101+
vehicleLocations: [],
102+
// Set to undefined to fall back on the default symbols,
103+
// unless a route definition is provided.
104+
vehicleSymbols: undefined,
105+
visible: props.visible
106+
}
107+
}
108+
109+
async componentDidMount () {
110+
const { liveFeedUrl, name, registerOverlay, routeDefinitionUrl } = this.props
111+
registerOverlay(this)
112+
if (!routeDefinitionUrl) {
113+
console.warn(`routeDefinitionUrl prop is missing for overlay '${name}'.`)
114+
} else {
115+
// If route definitions are provided, wait until they are
116+
// fetched so they can be used rendering vehicle shapes.
117+
await this._fetchRoutes()
118+
}
119+
if (!liveFeedUrl) {
120+
console.warn(`liveFeedUrl prop is missing for overlay '${name}'.`)
121+
} else if (this.state.visible) {
122+
// If layer is initially visible, start getting the vehicle positions.
123+
this._startRefreshing()
124+
}
125+
}
126+
127+
componentWillUnmount () {
128+
this._stopRefreshing()
129+
}
130+
131+
onOverlayAdded = () => {
132+
this.setState({ visible: true })
133+
this._startRefreshing()
134+
}
135+
136+
onOverlayRemoved = () => {
137+
this.setState({ visible: false })
138+
this._stopRefreshing()
139+
}
140+
141+
createLeafletElement () {}
142+
143+
updateLeafletElement () {}
144+
145+
/**
146+
* Fetches GTFS-rt vehicle positions (protocol buffer) and converts to
147+
* OTP-UI transitVehicleType before saving to component state.
148+
*/
149+
_fetchVehiclePositions = async () => {
150+
const { liveFeedUrl } = this.props
151+
if (liveFeedUrl) {
152+
try {
153+
const response = await fetch(liveFeedUrl)
154+
if (response.status >= 400) {
155+
const error = new Error('Received error from server')
156+
error.response = response
157+
throw error
158+
}
159+
const buffer = await response.arrayBuffer()
160+
const view = new Uint8Array(buffer)
161+
const feed = transitRealtime.FeedMessage.decode(view)
162+
163+
const vehicleLocations = feed.entity.map(vehicle => gtfsRtToTransitVehicle(vehicle, this.state.routes))
164+
this.setState({ vehicleLocations })
165+
} catch (err) {
166+
console.log(err)
167+
}
168+
}
169+
}
170+
171+
/**
172+
* Fetches GTFS-like route definitions (JSON).
173+
* (called once when component is mounted)
174+
*/
175+
_fetchRoutes = async () => {
176+
const { routeDefinitionUrl } = this.props
177+
if (routeDefinitionUrl) {
178+
try {
179+
const response = await fetch(routeDefinitionUrl)
180+
if (response.status >= 400) {
181+
const error = new Error('Received error from server')
182+
error.response = response
183+
throw error
184+
}
185+
const routes = await response.json()
186+
187+
// Generate the symbols for the overlay at this time
188+
// so it renders the route colors for each vehicle.
189+
const vehicleSymbols = [
190+
{
191+
minZoom: 0,
192+
symbol: makeVehicleShape(routes)
193+
}
194+
]
195+
196+
this.setState({ routes, vehicleSymbols })
197+
} catch (err) {
198+
console.log(err)
199+
}
200+
}
201+
}
202+
203+
_startRefreshing () {
204+
// initial vehicle retrieval
205+
this._fetchVehiclePositions()
206+
207+
// set up timer to refresh vehicle positions periodically.
208+
// defaults to every 30 sec.
209+
this._refreshTimer = setInterval(this._fetchVehiclePositions, 30000)
210+
}
211+
212+
_stopRefreshing () {
213+
if (this._refreshTimer) clearInterval(this._refreshTimer)
214+
}
215+
216+
render () {
217+
const { routes, vehicleLocations, vehicleSymbols, visible } = this.state
218+
return (
219+
<FeatureGroup>
220+
{routes.map(r => (
221+
<Polyline
222+
color={`#${r.route_color}`}
223+
key={r.route_id}
224+
opacity={0.6}
225+
positions={r.shape}
226+
/>
227+
))}
228+
<TransitVehicleOverlay
229+
symbols={vehicleSymbols}
230+
vehicleList={visible ? vehicleLocations : null}
231+
/>
232+
</FeatureGroup>
233+
)
234+
}
235+
}
236+
237+
export default GtfsRtVehicleOverlay

Diff for: lib/index.js

+2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import PlanTripButton from './components/form/plan-trip-button'
1111
import SettingsPreview from './components/form/settings-preview'
1212
import SwitchButton from './components/form/switch-button'
1313
import DefaultMap from './components/map/default-map'
14+
import GtfsRtVehicleOverlay from './components/map/gtfs-rt-vehicle-overlay'
1415
import Map from './components/map/map'
1516
import StylizedMap from './components/map/stylized-map'
1617
import OsmBaseLayer from './components/map/osm-base-layer'
@@ -73,6 +74,7 @@ export {
7374

7475
// map components
7576
DefaultMap,
77+
GtfsRtVehicleOverlay,
7678
ItineraryCarousel,
7779
Map,
7880
OsmBaseLayer,

Diff for: package.json

+2
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"@opentripplanner/route-viewer-overlay": "^1.0.4",
4848
"@opentripplanner/stop-viewer-overlay": "^1.0.4",
4949
"@opentripplanner/stops-overlay": "^3.0.2",
50+
"@opentripplanner/transit-vehicle-overlay": "^2.1.1",
5051
"@opentripplanner/transitive-overlay": "^1.0.7",
5152
"@opentripplanner/trip-details": "^1.1.4",
5253
"@opentripplanner/trip-form": "^1.0.5",
@@ -65,6 +66,7 @@
6566
"font-awesome": "^4.7.0",
6667
"formik": "^2.1.5",
6768
"formik-error-focus": "^1.1.0",
69+
"gtfs-realtime-bindings": "^0.0.6",
6870
"haversine": "^1.1.0",
6971
"history": "^4.7.2",
7072
"humanize-duration": "^3.25.2",

0 commit comments

Comments
 (0)