[gnome-maps/wip/mlundblad/transit-routing: 10/32] Add module to query an OpenTripPlanner instance



commit e91eee9a246c9267aed84df46fbeae012ab33132
Author: Marcus Lundblad <ml update uu se>
Date:   Mon Feb 15 23:18:14 2016 +0100

    Add module to query an OpenTripPlanner instance
    
    Adds an openTripPlanner module, containing functionallity for interfacing against
    an OpenTripPlanner service. Also has functionallity to augment itineraries obtained
    with walk routing from GraphHopper (as used by the other routing modes).
    
    Furthermore, the transitPlan module contains interfacing classes modelling transit data
    
    Plan:
    Populated by a list of itineraries when performing a transit query.
    
    Itinerary:
    Represents one particular trip option in a search result. Contains one or more transit legs.
    
    Leg:
    Represents one distinct part of a trip, such as
    "take tram #5 departuring at 10:00 from Foo Street, get off at Bar Street, arrival at 10:12".
    
    Stop:
    Represents an intermediate stop along a transit leg (a place where the vehicle stops, but
    the itinerary doesn't board or alight the vehicle.
    
    https://bugzilla.gnome.org/show_bug.cgi?id=755808

 po/POTFILES.in                       |    2 +
 src/openTripPlanner.js               | 1090 ++++++++++++++++++++++++++++++++++
 src/org.gnome.Maps.src.gresource.xml |    2 +
 src/transitPlan.js                   |  648 ++++++++++++++++++++
 4 files changed, 1742 insertions(+), 0 deletions(-)
---
diff --git a/po/POTFILES.in b/po/POTFILES.in
index 082bc0f..4da0060 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -38,6 +38,7 @@ src/geoJSONSource.js
 src/layersPopover.js
 src/mainWindow.js
 src/mapView.js
+src/openTripPlanner.js
 src/osmConnection.js
 src/osmEditDialog.js
 src/placeBubble.js
@@ -49,5 +50,6 @@ src/routeService.js
 src/sendToDialog.js
 src/shapeLayer.js
 src/sidebar.js
+src/transitPlan.js
 src/translations.js
 src/utils.js
diff --git a/src/openTripPlanner.js b/src/openTripPlanner.js
new file mode 100644
index 0000000..3036c1a
--- /dev/null
+++ b/src/openTripPlanner.js
@@ -0,0 +1,1090 @@
+/* -*- Mode: JS2; indent-tabs-mode: nil; js2-basic-offset: 4 -*- */
+/* vim: set et ts=4 sw=4: */
+/*
+ * Copyright (c) 2016 Marcus Lundblad
+ *
+ * GNOME Maps is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by the
+ * Free Software Foundation; either version 2 of the License, or (at your
+ * option) any later version.
+ *
+ * GNOME Maps is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+ * for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with GNOME Maps; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * Author: Marcus Lundblad <ml update uu se>
+ */
+
+const Lang = imports.lang;
+
+const GLib = imports.gi.GLib;
+const Soup = imports.gi.Soup;
+
+const Application = imports.application;
+const EPAF = imports.epaf;
+const HTTP = imports.http;
+const Location = imports.location;
+const Place = imports.place;
+const RouteQuery = imports.routeQuery;
+const Service = imports.service;
+const TransitPlan = imports.transitPlan;
+const Utils = imports.utils;
+
+/* timeout after which the routers data is considered stale and we will force
+ * a reload (24 hours) */
+const ROUTERS_TIMEOUT = 24 * 60 * 60 * 1000;
+
+/* minimum distance when an explicit walk route will be requested to suppliment
+ * the transit route */
+const MIN_WALK_ROUTING_DISTANCE = 100;
+
+/* minimum distance of a transit leg, below which we would replace the leg with
+ * walking if the leg is the first or last */
+const MIN_TRANSIT_LEG_DISTANCE = 300;
+
+/* maximum walking distance for a potential replacement of a beginning or ending
+ * transit leg */
+const MAX_WALK_OPTIMIZATION_DISTANCE = 1000;
+
+/* maximum distance difference for performing a replacement of a beginning or
+ * ending transit leg with a walking leg */
+const MAX_WALK_OPTIMIZATION_DISTANCE_DIFFERENCE = 500;
+
+/* minimum acceptable time margin when recalculating walking legs in the middle
+ * of an itinerary */
+const MIN_INTERMEDIATE_WALKING_SLACK = 60;
+
+/* maximum walking distance, filter out itineraries containing walking legs
+ * whith longer walking after refined by GraphHopper */
+const MAX_WALKING_DISTANCE = 2000;
+
+/* maximum radius to search for stops */
+const STOP_SEARCH_RADIUS = 2000;
+
+/* maximum number of transit stops to consider as candidates for start/end
+ * points */
+const NUM_STOPS_TO_TRY = 5;
+
+/* gap to use when fetching additional routes */
+const GAP_BEFORE_MORE_RESULTS = 120;
+
+const OpenTripPlanner = new Lang.Class({
+    Name: 'OpenTripPlanner',
+
+    _init: function() {
+        this._session = new Soup.Session();
+        /* initially set routers as updated far back in the past to force
+         * a download when first request */
+        this._routersUpdatedTimestamp = 0;
+        this._query = Application.routeQuery;
+        this._plan = new TransitPlan.Plan();
+        this._baseUrl = this._getBaseUrl();
+        this._walkingRoutes = [];
+        this._extendPrevious = false;
+    },
+
+    get plan() {
+        return this._plan;
+    },
+
+    get enabled() {
+        return this._baseUrl !== null;
+    },
+
+    connect: function() {
+        this._signalHandler = this._query.connect('notify::points', (function() {
+            if (this._query.isValid())
+                this._fetchRoute();
+        }).bind(this));
+    },
+
+    disconnect: function() {
+        if (this._signalHandler !== 0) {
+            this._query.disconnect(this._signalHandler);
+            this._signalHandler = 0;
+        }
+    },
+
+    fetchMoreResults: function() {
+        this._extendPrevious = true;
+        this._fetchRoute();
+    },
+
+    _getBaseUrl: function() {
+        let debugUrl = GLib.getenv('OTP_BASE_URL');
+
+        if (debugUrl) {
+            return debugUrl;
+        } else {
+            let otp = Service.getService().openTripPlanner
+
+            if (otp && otp.baseUrl) {
+                return otp.baseUrl;
+            } else {
+                Utils.debug('No OpenTripPlanner URL defined in service file');
+                return null;
+            }
+        }
+    },
+
+    _getRouterUrl: function(router) {
+        if (!router || router.length === 0)
+            router = 'default';
+
+        return this._baseUrl + '/routers/' + router;
+    },
+
+    _fetchRouters: function(callback) {
+        let currentTime = (new Date()).getTime();
+
+        if (currentTime - this._routersUpdatedTimestamp < ROUTERS_TIMEOUT) {
+            callback(true);
+        } else {
+            let uri = new Soup.URI(this._baseUrl + '/routers');
+            let request = new Soup.Message({ method: 'GET', uri: uri });
+
+            request.request_headers.append('Accept', 'application/json');
+            this._session.queue_message(request, (function(obj, message) {
+                if (message.status_code !== Soup.Status.OK) {
+                    callback(false);
+                    return;
+                }
+
+                try {
+                    this._routers = JSON.parse(message.response_body.data);
+                    this._routersUpdatedTimestamp = (new Date()).getTime();
+                    callback(true);
+                } catch (e) {
+                    Utils.debug('Failed to parse router information');
+                    callback(false);
+                }
+            }).bind(this));
+        }
+    },
+
+    _getRoutersForPlace: function(place) {
+        let routers = [];
+
+        this._routers.routerInfo.forEach((function(routerInfo) {
+            /* TODO: only check bounding rectangle for now
+             * should we try to do a finer-grained check using the bounding
+             * polygon (if OTP gives one for the routers).
+             * And should we add some margins to allow routing from just outside
+             * a network (walking distance)? */
+            if (place.location.latitude >= routerInfo.lowerLeftLatitude &&
+                place.location.latitude <= routerInfo.upperRightLatitude &&
+                place.location.longitude >= routerInfo.lowerLeftLongitude &&
+                place.location.longitude <= routerInfo.upperRightLongitude)
+                routers.push(routerInfo.routerId);
+        }));
+
+        return routers;
+    },
+
+    /* Note: this is theoretically slow (O(n*m)), but we will have filtered
+     * possible routers for the starting and ending query point, so they should
+     * be short (in many cases just one element) */
+    _routerIntersection: function(routers1, routers2) {
+        return routers1.filter(function(n) {
+            return routers2.indexOf(n) != -1;
+        });
+    },
+
+    _getMode: function(routeType) {
+        switch (routeType) {
+        case TransitPlan.RouteType.TRAM:
+            return 'TRAM';
+        case TransitPlan.RouteType.TRAIN:
+            return 'RAIL';
+        case TransitPlan.RouteType.SUBWAY:
+            return 'SUBWAY';
+        case TransitPlan.RouteType.BUS:
+            return 'BUS';
+        case TransitPlan.RouteType.FERRY:
+            return 'FERRY';
+        default:
+            throw new Error('unhandled route type');
+        }
+    },
+
+    _getModes: function(options) {
+        let modes = options.showRouteTypes.map((function(routeType) {
+            return this._getMode(routeType);
+        }).bind(this));
+
+        return modes.join(',');
+    },
+
+    _selectBestStopRecursive: function(stops, index, stopIndex, callback) {
+        if (index < stops.length) {
+            let points = this._query.filledPoints;
+            let stop = stops[index];
+            let stopPoint =
+                this._createQueryPointForCoord([stop.lat, stop.lon]);
+
+            if (stops[0].dist < 100) {
+                /* if the stop is close enough to the intended point, just
+                 * return the top most from the the original query */
+                this._selectBestStopRecursive(stops, index + 1, stopIndex,
+                                              callback);
+            } else if (stopIndex === 0) {
+                this._fetchWalkingRoute([points[0], stopPoint],
+                                        (function(route) {
+                    stop.dist = route.distance;
+                    this._selectBestStopRecursive(stops, index + 1, stopIndex,
+                                              callback);
+                }).bind(this));
+            } else if (stopIndex === points.length - 1) {
+                this._fetchWalkingRoute([stopPoint, points[points.length - 1]],
+                                        (function(route) {
+                    stop.dist = route.distance;
+                    this._selectBestStopRecursive(stops, index + 1, stopIndex,
+                                              callback);
+                }).bind(this));
+            } else {
+                /* for intermediate stops just return the one geographically
+                 * closest */
+                this._selectBestStopRecursive(stops, index + 1, stopIndex,
+                                              callback);
+            }
+        } else {
+            /* re-sort stops by distance and select the closest after refining
+             * distances */
+            stops.sort(this._sortTransitStops);
+            Utils.debug('refined stops: ');
+            stops.forEach(function(stop) {
+                Utils.debug(JSON.stringify(stop, '', 2));
+            });
+            callback(stops[0]);
+        }
+    },
+
+    /* stopIndex here is the index of stop (i.e. starting point, intermediate
+     * stop, final stop */
+    _selectBestStop: function(stops, stopIndex, callback) {
+        this._selectBestStopRecursive(stops, 0, stopIndex, callback);
+    },
+
+    _sortTransitStops: function(s1, s2) {
+        return s1.dist > s2.dist;
+    },
+
+    _fetchRoutesForStop: function(router, stop, callback) {
+        let query = new HTTP.Query();
+        let uri = new Soup.URI(this._getRouterUrl(router) +
+                               '/index/stops/' + stop.id + '/routes');
+        let request = new Soup.Message({ method: 'GET', uri: uri });
+
+        request.request_headers.append('Accept', 'application/json');
+        this._session.queue_message(request, (function(obj, message) {
+            if (message.status_code !== Soup.Status.OK) {
+                Utils.debug('Failed to get routes for stop');
+                this._reset();
+            } else {
+                let routes = JSON.parse(message.response_body.data);
+
+                Utils.debug('Routes for stop: ' + stop + ': ' + JSON.stringify(routes));
+                callback(routes);
+            }
+        }).bind(this));
+    },
+
+    _routeMatchesSelectedModes: function(route) {
+        let desiredRouteTypes = this._query.transitOptions.showRouteTypes;
+
+        for (let i = 0; i < desiredRouteTypes.length; i++) {
+            let type = desiredRouteTypes[i];
+
+            if (type === TransitPlan.RouteType.TRAM && route.mode === 'TRAM')
+                return true;
+            else if (type === TransitPlan.RouteType.SUBWAY && route.mode === 'SUBWAY')
+                return true;
+            else if (type === TransitPlan.RouteType.TRAIN && route.mode === 'RAIL')
+                return true;
+            else if (type === TransitPlan.RouteType.BUS &&
+                     (route.mode === 'BUS' || route.mode === 'TAXI'))
+                return true;
+            else if (type === TransitPlan.RouteType.FERRY && route.mode === 'FERRY')
+                return true;
+        }
+
+        return false;
+    },
+
+    _filterStopsRecursive: function(router, stops, index, filteredStops, callback) {
+        if (index < stops.length) {
+            let stop = stops[index];
+
+            this._fetchRoutesForStop(router, stop, (function(routes) {
+                for (let i = 0; i < routes.length; i++) {
+                    let route = routes[i];
+
+                    if (this._routeMatchesSelectedModes(route)) {
+                        filteredStops.push(stop);
+                        break;
+                    }
+                }
+                this._filterStopsRecursive(router, stops, index + 1,
+                                           filteredStops, callback);
+            }).bind(this));
+        } else {
+            callback(filteredStops);
+        }
+    },
+
+    _filterStops: function(router, stops, callback) {
+        this._filterStopsRecursive(router, stops, 0, [], callback);
+    },
+
+    _fetchTransitStopsRecursive: function(router, index, result, callback) {
+        let points = this._query.filledPoints;
+
+        if (index < points.length) {
+            let point = points[index];
+            let params = { lat: point.place.location.latitude,
+                           lon: point.place.location.longitude,
+                           radius: STOP_SEARCH_RADIUS };
+            let query = new HTTP.Query(params);
+            let uri = new Soup.URI(this._getRouterUrl(router) +
+                                   '/index/stops?' + query.toString());
+            let request = new Soup.Message({ method: 'GET', uri: uri });
+
+            request.request_headers.append('Accept', 'application/json');
+            this._session.queue_message(request, (function(obj, message) {
+                if (message.status_code !== Soup.Status.OK) {
+                    Utils.debug('Failed to get stop for search point ' + point);
+                    this._reset();
+                } else {
+                    let stops = JSON.parse(message.response_body.data);
+
+                    if (stops.length === 0) {
+                        Utils.debug('No suitable stop found from router');
+                        callback(null);
+                        return;
+                    }
+
+                    if (this._query.transitOptions.showAllRouteTypes) {
+                        stops.sort(this._sortTransitStops);
+                        stops = stops.splice(0, NUM_STOPS_TO_TRY);
+
+                        Utils.debug('stops: ' + JSON.stringify(stops, '', 2));
+                        this._selectBestStop(stops, index, (function(stop) {
+                            result.push(stop);
+                            this._fetchTransitStopsRecursive(router, index + 1,
+                                                             result, callback);
+                        }).bind(this));
+                    } else {
+                        this._filterStops(router, stops, (function(filteredStops) {
+                            filteredStops.sort(this._sortTransitStops);
+                            filteredStops = filteredStops.splice(0, NUM_STOPS_TO_TRY);
+
+                            if (filteredStops.length === 0) {
+                                Utils.debug('No suitable stop found using selected transit modes');
+                                callback(null);
+                                return;
+                            }
+
+                            this._selectBestStop(filteredStops, index, (function(stop) {
+                                result.push(stop);
+                                this._fetchTransitStopsRecursive(router, index + 1,
+                                                                 result, callback);
+                            }).bind(this));
+                        }).bind(this));
+                    }
+                }
+            }).bind(this));
+        } else {
+            callback(result);
+        }
+    },
+
+    _fetchTransitStops: function(router, callback) {
+        this._fetchTransitStopsRecursive(router, 0, [], callback);
+    },
+
+    /* get a time suitably formatted for the OpenTripPlanner query param */
+    _formatTime: function(time, offset) {
+        let utcTimeWithOffset = (time + offset) / 1000;
+        let date = GLib.DateTime.new_from_unix_utc(utcTimeWithOffset);
+
+        return date.format('%R');
+    },
+
+    /* get a date suitably formatted for the OpenTripPlanner query param */
+    _formatDate: function(time, offset) {
+        let utcTimeWithOffset = (time + offset) / 1000;
+        let date = GLib.DateTime.new_from_unix_utc(utcTimeWithOffset);
+
+        return date.format('%F');
+    },
+
+    _fetchRoutesForRouter: function(router, callback) {
+        this._fetchTransitStops(router, (function(stops) {
+            let points = this._query.filledPoints;
+
+            if (!stops) {
+                callback(null);
+                return;
+            }
+
+            /* if there's only a start and end stop (no intermediate stops)
+             * and those stops are identical, reject the routing, since this
+             * means there would be no point in transit, and OTP would give
+             * some bizarre option like boarding transit, go one stop and then
+             * transfer to go back the same route */
+            if (stops.length === 2 && stops[0].id === stops[1].id) {
+                callback(null);
+                return;
+            }
+
+            let params = { fromPlace: stops[0].id,
+                           toPlace: stops[stops.length - 1].id };
+            let intermediatePlaces = [];
+
+            for (let i = 1; i < stops.length - 1; i++) {
+                intermediatePlaces.push(stops[i].id);
+            }
+            if (intermediatePlaces.length > 0)
+                params.intermediatePlaces = intermediatePlaces;
+
+            params.numItineraries = 5;
+            params.showIntermediateStops = true;
+
+            let time = this._query.time;
+            let date = this._query.date;
+
+            if (this._extendPrevious) {
+                let itineraries = this.plan.itineraries;
+                let lastItinerary = itineraries[itineraries.length - 1];
+                let time;
+                let offset;
+
+                if (this._query.arriveBy) {
+                    time = lastItinerary.transitArrivalTime -
+                           GAP_BEFORE_MORE_RESULTS * 1000;
+                    offset = lastItinerary.transitArrivalTimezoneOffset;
+                } else {
+                    time = lastItinerary.transitDepartureTime +
+                           GAP_BEFORE_MORE_RESULTS * 1000;
+                    offset = lastItinerary.transitDepartureTimezoneOffset;
+                }
+
+                params.time = this._formatTime(time, offset);
+                params.date = this._formatDate(time, offset);
+            } else {
+                if (time) {
+                    params.time = time;
+                    /* it seems OTP doesn't like just setting a time, so if the query
+                     * doesn't specify a date, go with today's date */
+                    if (!date) {
+                        let dateTime = GLib.DateTime.new_now_local();
+
+                        params.date = dateTime.format('%F');
+                    }
+                }
+
+                if (date)
+                    params.date = date;
+            }
+
+            if (this._query.arriveBy)
+                params.arriveBy = true;
+
+            let options = this._query.transitOptions;
+            if (options && !options.showAllRouteTypes)
+                params.mode = this._getModes(options);
+
+            let query = new HTTP.Query(params);
+            let uri = new Soup.URI(this._getRouterUrl(router) + '/plan?' +
+                                   query.toString());
+            let request = new Soup.Message({ method: 'GET', uri: uri });
+
+            request.request_headers.append('Accept', 'application/json');
+            this._session.queue_message(request, (function(obj, message) {
+                if (message.status_code !== Soup.Status.OK) {
+                    Utils.debug('Failed to get route plan from router ' +
+                                routers[index] + ' ' + message);
+                    callback(null);
+                } else {
+                    callback(JSON.parse(message.response_body.data));
+                }
+            }).bind(this));
+        }).bind(this));
+    },
+
+    _fetchRoutesRecursive: function(routers, index, result, callback) {
+        if (index < routers.length) {
+            let router = routers[index];
+
+            this._fetchRoutesForRouter(router, (function(response) {
+                if (response) {
+                    Utils.debug('plan: ' + JSON.stringify(response, '', 2));
+                    result.push(response);
+                }
+
+                this._fetchRoutesRecursive(routers, index + 1, result, callback);
+            }).bind(this));
+        } else {
+            callback(result);
+        }
+    },
+
+    _fetchRoutes: function(routers, callback) {
+        this._fetchRoutesRecursive(routers, 0, [], callback);
+    },
+
+    _reset: function() {
+        this._extendPrevious = false;
+        if (this._query.latest)
+            this._query.latest.place = null;
+        else
+            this.plan.reset();
+    },
+
+    _fetchRoute: function() {
+        this._fetchRouters((function(success) {
+            if (success) {
+                let points = this._query.filledPoints;
+                let routers = this._getRoutersForPoints(points);
+
+                if (routers.length > 0) {
+                    this._fetchRoutes(routers, (function(routes) {
+                        let itineraries = [];
+                        routes.forEach((function(plan) {
+                            if (plan.plan && plan.plan.itineraries) {
+                                itineraries =
+                                    itineraries.concat(
+                                        this._createItineraries(plan.plan.itineraries));
+                            }
+                        }).bind(this));
+
+                        if (itineraries.length === 0) {
+                            Application.notificationManager.showMessage(_("No route found."));
+                            /* don't reset query points, unlike for turn-based
+                             * routing, since options and timeing might influence
+                             * results */
+                            this._extendPrevious = false;
+                            this.plan.reset();
+                        } else {
+                            this._recalculateItineraries(itineraries);
+                        }
+                    }).bind(this));
+
+                } else {
+                    Application.notificationManager.showMessage(_("No timetable data found for this 
route."));
+                    this._reset();
+                }
+            } else {
+                Application.notificationManager.showMessage(_("Route request failed."));
+                this._reset();
+            }
+        }).bind(this));
+    },
+
+    _isOnlyWalkingItinerary: function(itinerary) {
+        return itinerary.legs.length === 1 && !itinerary.legs[0].transit;
+    },
+
+    _recalculateItineraries: function(itineraries) {
+        /* filter out itineraries with only walking */
+        let newItineraries = [];
+
+        itineraries.forEach((function(itinerary) {
+            if (!this._isOnlyWalkingItinerary(itinerary))
+                newItineraries.push(itinerary);
+        }).bind(this));
+
+        /* TODO: should we always calculate a walking itinerary to put at the
+         * top if the total distance is below some threashhold? */
+        this._recalculateItinerariesRecursive(newItineraries, 0);
+    },
+
+    _isItineraryRealistic: function(itinerary) {
+        for (let i = 0; i < itinerary.legs.length; i++) {
+            let leg = itinerary.legs[i];
+
+            if (!leg.transit) {
+                /* if a walking leg exceeds the maximum desired walking
+                 * distance, or for a leg "in-between" two transit legs, if
+                 * there's insufficent switch time */
+                if (leg.distance > MAX_WALKING_DISTANCE) {
+                    return false;
+                } else if (i >= 1 && i < itinerary.legs.length - 1) {
+                    let previousLeg = itinerary.legs[i - 1];
+                    let nextLeg = itinerary.legs[i + 1];
+
+                    let availableTime =
+                        (nextLeg.departure - previousLeg.arrival) / 1000;
+
+                    if (availableTime <
+                        leg.duration + MIN_INTERMEDIATE_WALKING_SLACK)
+                        return false;
+                }
+            }
+        }
+
+        return true;
+    },
+
+    _recalculateItinerariesRecursive: function(itineraries, index) {
+        if (index < itineraries.length) {
+            this._recalculateItinerary(itineraries[index], (function(itinerary) {
+                itineraries[index] = itinerary;
+                this._recalculateItinerariesRecursive(itineraries, index + 1);
+            }).bind(this));
+        } else {
+            /* filter out itineraries where there are intermediate walking legs
+             * that are too narrow time-wise, this is nessesary since running
+             * OTP with only transit data can result in some over-optimistic
+             * walking itinerary legs, since it will use "line-of-sight"
+             * distances.
+             * also filter out itineraries where recalculation process ended
+             * up with just walking */
+            let filteredItineraries = [];
+
+            itineraries.forEach((function(itinerary) {
+                if (this._isItineraryRealistic(itinerary) &&
+                    !this._isOnlyWalkingItinerary(itinerary))
+                    filteredItineraries.push(itinerary);
+            }).bind(this));
+
+            if (filteredItineraries.length > 0) {
+                filteredItineraries.forEach((function(itinerary) {
+                    itinerary.adjustTimings();
+                }).bind(this));
+
+                /* sort itineraries, by departure time ascending if querying
+                 * by leaving time, by arrival time descending when querying
+                 * by arriving time */
+                if (this._query.arriveBy)
+                    filteredItineraries.sort(TransitPlan.sortItinerariesByArrivalDesc);
+                else
+                    filteredItineraries.sort(TransitPlan.sortItinerariesByDepartureAsc);
+
+                let newItineraries = this._extendPrevious ?
+                                     this.plan.itineraries.concat(filteredItineraries) :
+                                     filteredItineraries;
+
+                /* reset the "load more results" flag */
+                this._extendPrevious = false;
+                this.plan.update(newItineraries);
+            } else {
+                Application.notificationManager.showMessage(_("No route found."));
+                this._reset();
+            }
+        }
+    },
+
+    _createWalkingLeg: function(from, to, fromName, toName, route) {
+        return new TransitPlan.Leg({fromCoordinate:      [from.place.location.latitude,
+                                                          from.place.location.longitude],
+                                    toCoordinate:        [to.place.location.latitude,
+                                                          to.place.location.longitude],
+                                    from:                fromName,
+                                    to:                  toName,
+                                    isTransit:           false,
+                                    polyline:            route.path,
+                                    duration:            route.time / 1000,
+                                    distance:            route.distance,
+                                    walkingInstructions: route.turnPoints });
+    },
+
+    /* fetches walking route and stores the route for the given coordinate
+     * pair to avoid requesting the same route over and over from GraphHopper */
+    _fetchWalkingRoute: function(points, callback) {
+        let index = points[0].place.location.latitude + ',' +
+                    points[0].place.location.longitude + ';' +
+                    points[1].place.location.latitude + ',' +
+                    points[1].place.location.longitude;
+        let route = this._walkingRoutes[index];
+
+        if (!route) {
+            Application.routeService.fetchRouteAsync(points,
+                                                     RouteQuery.Transportation.PEDESTRIAN,
+                                                     (function(newRoute) {
+                this._walkingRoutes[index] = newRoute;
+                callback(newRoute);
+            }).bind(this));
+        } else {
+            callback(route);
+        }
+    },
+
+    _recalculateItinerary: function(itinerary, callback) {
+        let from = this._query.filledPoints[0];
+        let to = this._query.filledPoints[this._query.filledPoints.length - 1];
+
+        if (itinerary.legs.length === 1 && !itinerary.legs[0].transit) {
+            /* special case, if there's just one leg of an itinerary, and that leg
+             * leg is a non-transit (walking), recalculate the route in its entire
+             * using walking */
+            this._fetchWalkingRoute(this._query.filledPoints, (function(route) {
+                let leg = this._createWalkingLeg(from, to, from.place.name,
+                                                 to.place.name, route);
+                let newItinerary =
+                    new TransitPlan.Itinerary({departure: itinerary.departure,
+                                               duration: route.time / 1000,
+                                               legs: [leg]});
+                callback(newItinerary);
+            }).bind(this));
+        } else if (itinerary.legs.length === 1 && itinerary.legs[0].transit) {
+            /* special case if there is extactly one transit leg */
+            let leg = itinerary.legs[0];
+            let startLeg = this._createQueryPointForCoord(leg.fromCoordinate);
+            let endLeg = this._createQueryPointForCoord(leg.toCoordinate);
+            let fromLoc = from.place.location;
+            let startLoc = startLeg.place.location;
+            let endLoc = endLeg.place.location;
+            let toLoc = to.place.location;
+            let startWalkDistance = fromLoc.get_distance_from(startLoc) * 1000;
+            let endWalkDistance = endLoc.get_distance_from(toLoc) * 1000;
+
+            if (startWalkDistance >= MIN_WALK_ROUTING_DISTANCE &&
+                endWalkDistance >= MIN_WALK_ROUTING_DISTANCE) {
+                /* add an extra walking leg to both the beginning and end of the
+                 * itinerary */
+                this._fetchWalkingRoute([from, startLeg], (function(firstRoute) {
+                    let firstLeg =
+                        this._createWalkingLeg(from, startLeg, from.place.name,
+                                               leg.from, firstRoute);
+                    this._fetchWalkingRoute([endLeg, to], (function(lastRoute) {
+                        let lastLeg = this._createWalkingLeg(endLeg, to, leg.to,
+                                                             to.place.name,
+                                                             lastRoute);
+                        itinerary.legs.unshift(firstLeg);
+                        itinerary.legs.push(lastLeg);
+                        callback(itinerary);
+                    }).bind(this));
+                }).bind(this));
+            } else if (endWalkDistance >= MIN_WALK_ROUTING_DISTANCE) {
+                /* add an extra walking leg to the end of the itinerary */
+                this._fetchWalkingRoute([endLeg, to], (function(lastRoute) {
+                    let lastLeg =
+                        this._createWalkingLeg(endLeg, to, leg.to,
+                                               to.place.name, lastRoute);
+                    itinerary.legs.push(lastLeg);
+                    callback(itinerary);
+                }).bind(this));
+            } else {
+                /* if only there's only a walking leg to be added to the start
+                 * let the recursive routine dealing with multi-leg itineraries
+                 * handle it */
+                this._recalculateItineraryRecursive(itinerary, 0, callback);
+            }
+        } else {
+            /* replace walk legs with GraphHopper-generated paths (hence the
+             * callback nature of this. Filter out unrealistic itineraries (having
+             * walking segments not possible in reasonable time, due to our running
+             * of OTP with only transit data). */
+            this._recalculateItineraryRecursive(itinerary, 0, callback);
+        }
+    },
+
+    _createQueryPointForCoord: function(coord) {
+        let location = new Location.Location({ latitude: coord[0],
+                                               longitude: coord[1],
+                                               accuracy: 0 });
+        let place = new Place.Place({ location: location });
+        let point = new RouteQuery.QueryPoint();
+
+        point.place = place;
+        return point;
+    },
+
+    _recalculateItineraryRecursive: function(itinerary, index, callback) {
+        if (index < itinerary.legs.length) {
+            let leg = itinerary.legs[index];
+            if (index === 0) {
+                let from = this._query.filledPoints[0];
+                let startLeg =
+                    this._createQueryPointForCoord(leg.fromCoordinate);
+                let endLeg =
+                    this._createQueryPointForCoord(leg.toCoordinate);
+                let fromLoc = from.place.location;
+                let startLegLoc = startLeg.place.location;
+                let endLegLoc = endLeg.place.location;
+                let distanceToEndLeg =
+                    fromLoc.get_distance_from(endLegLoc) * 1000;
+                let distanceToStartLeg =
+                    fromLoc.get_distance_from(startLegLoc) * 1000;
+                let nextLeg = itinerary.legs[index + 1];
+
+                if (!leg.transit ||
+                    ((leg.distance <= MIN_TRANSIT_LEG_DISTANCE ||
+                     (distanceToEndLeg <= MAX_WALK_OPTIMIZATION_DISTANCE &&
+                      distanceToEndLeg - distanceToStartLeg <=
+                      MAX_WALK_OPTIMIZATION_DISTANCE_DIFFERENCE)) &&
+                     itinerary.legs.length > 1)) {
+                    /* if the first leg of the intinerary returned by OTP is a
+                     * walking one, recalculate it with GH using the actual
+                     * starting coordinate from the input query,
+                     * also replace a transit leg at the start with walking if
+                     * its distance is below a threashhold, to avoid suboptimal
+                     * routes due to only running OTP with transit data,
+                     * also optimize away cases where the routing would make one
+                     * "pass by" a stop at the next step in the itinerary due to
+                     * similar reasons */
+                    let to = this._createQueryPointForCoord(leg.toCoordinate);
+                    let toName = leg.to;
+
+                    /* if the next leg is a walking one, "fold" it into the one
+                     * we create here */
+                    if (nextLeg && !nextLeg.transit) {
+                        to = this._createQueryPointForCoord(nextLeg.toCoordinate);
+                        toName = nextLeg.to;
+                        itinerary.legs.splice(index + 1, index + 1);
+                    }
+
+                    this._fetchWalkingRoute([from, to], (function(route) {
+                        let newLeg =
+                            this._createWalkingLeg(from, to, from.place.name,
+                                                   toName, route);
+                        itinerary.legs[index] = newLeg;
+                        this._recalculateItineraryRecursive(itinerary, index + 1,
+                                                            callback);
+                    }).bind(this));
+                } else {
+                    /* introduce an additional walking leg calculated
+                     * by GH in case the OTP starting point as far enough from
+                     * the original starting point */
+                    let to = this._createQueryPointForCoord(leg.fromCoordinate);
+                    let fromLoc = from.place.location;
+                    let toLoc = to.place.location;
+                    let distance = fromLoc.get_distance_from(toLoc) * 1000;
+
+                    if (distance >= MIN_WALK_ROUTING_DISTANCE) {
+                        this._fetchWalkingRoute([from, to], (function(route) {
+                            let newLeg =
+                                this._createWalkingLeg(from, to, from.place.name,
+                                                       leg.from, route);
+                            itinerary.legs.unshift(newLeg);
+                            /* now, next index will be two steps up, since we
+                             * inserted a new leg */
+                            this._recalculateItineraryRecursive(itinerary,
+                                                                index + 2,
+                                                                callback);
+                        }).bind(this));
+                    } else {
+                        this._recalculateItineraryRecursive(itinerary, index + 1,
+                                                            callback);
+                    }
+                }
+            } else if (index === itinerary.legs.length - 1) {
+                let to = this._query.filledPoints[this._query.filledPoints.length - 1];
+                let startLeg =
+                    this._createQueryPointForCoord(leg.fromCoordinate);
+                let endLeg = this._createQueryPointForCoord(leg.toCoordinate);
+                let toLoc = to.place.location;
+                let startLegLoc = startLeg.place.location;
+                let endLegLoc = endLeg.place.location;
+                let distanceFromEndLeg =
+                    toLoc.get_distance_from(endLegLoc) * 1000;
+                let distanceFromStartLeg =
+                    toLoc.get_distance_from(startLegLoc) * 1000;
+                let previousLeg = itinerary.legs[itinerary.legs.length - 2];
+
+                if (!leg.transit ||
+                    ((leg.distance <= MIN_TRANSIT_LEG_DISTANCE ||
+                      (distanceFromStartLeg <= MAX_WALK_OPTIMIZATION_DISTANCE &&
+                       distanceFromStartLeg - distanceFromEndLeg <=
+                       MAX_WALK_OPTIMIZATION_DISTANCE_DIFFERENCE)) &&
+                      itinerary.legs.length > 1)) {
+                    /* if the final leg of the itinerary returned by OTP is a
+                     * walking one, recalculate it with GH using the actual
+                     * ending coordinate from the input query
+                     * also replace a transit leg at the end with walking if
+                     * its distance is below a threashhold, to avoid suboptimal
+                     * routes due to only running OTP with transit data,
+                     * also optimize away cases where the routing would make one
+                     * "pass by" a stop at the previous step in the itinerary
+                     * due to similar reasons */
+                    let finalTransitLeg;
+                    let insertIndex;
+                    if (leg.transit && previousLeg && !previousLeg.transit) {
+                        /* if we optimize away the final transit leg, and the
+                         * previous leg is a walking one, "fold" both into a
+                         * single walking leg */
+                        finalTransitLeg = previousLeg;
+                        insertIndex = index -1;
+                        itinerary.legs.pop();
+                    } else {
+                        finalTransitLeg = leg;
+                        insertIndex = index;
+                    }
+                    let from = this._createQueryPointForCoord(finalTransitLeg.fromCoordinate);
+                    this._fetchWalkingRoute([from, to], (function(route) {
+                        let newLeg =
+                            this._createWalkingLeg(from, to,
+                                                   finalTransitLeg.from,
+                                                   to.place.name, route);
+                        itinerary.legs[insertIndex] = newLeg;
+                        this._recalculateItineraryRecursive(itinerary,
+                                                            insertIndex + 1,
+                                                            callback);
+                    }).bind(this));
+                } else {
+                    /* introduce an additional walking leg calculated by GH in
+                     * case the OTP end point as far enough from the original
+                     * end point */
+                    let from = this._createQueryPointForCoord(leg.toCoordinate);
+                    let fromLoc = from.place.location;
+                    let toLoc = to.place.location;
+                    let distance = fromLoc.get_distance_from(toLoc) * 1000;
+
+                    if (distance >= MIN_WALK_ROUTING_DISTANCE) {
+                        this._fetchWalkingRoute([from, to], (function(route) {
+                            let newLeg =
+                                this._createWalkingLeg(from, to, leg.to,
+                                                       to.place.name, route);
+                            itinerary.legs.push(newLeg);
+                            /* now, next index will be two steps up, since we
+                             * inserted a new leg */
+                            this._recalculateItineraryRecursive(itinerary,
+                                                                 index + 2,
+                                                                 callback);
+                        }).bind(this));
+                    } else {
+                        this._recalculateItineraryRecursive(itinerary, index + 1,
+                                                            callback);
+                    }
+                }
+            } else {
+                /* if an intermediate leg is a walking one, and it's distance is
+                 * above the threashhold distance, calculate an exact route */
+                if (!leg.transit && leg.distance >= MIN_WALK_ROUTING_DISTANCE) {
+                    let from = this._createQueryPointForCoord(leg.fromCoordinate);
+                    let to = this._createQueryPointForCoord(leg.toCoordinate);
+
+                    /* if the next leg is the final one of the itinerary,
+                     * and it's shorter than the "optimize away" distance,
+                     * create a walking leg all the way to the final destination
+                     */
+                    let nextLeg = itinerary.legs[index + 1];
+                    if (index === itinerary.legs.length - 2 &&
+                        nextLeg.distance <= MIN_TRANSIT_LEG_DISTANCE) {
+                        to = this._query.filledPoints[this._query.filledPoints.length - 1];
+                        itinerary.legs.splice(index + 1, index + 1);
+                    }
+
+                    this._fetchWalkingRoute([from, to], (function(route) {
+                        let newLeg = this._createWalkingLeg(from, to, leg.from,
+                                                            leg.to, route);
+                        itinerary.legs[index] = newLeg;
+                        this._recalculateItineraryRecursive(itinerary,
+                                                            index + 1,
+                                                            callback);
+                    }).bind(this));
+                } else {
+                    this._recalculateItineraryRecursive(itinerary, index + 1,
+                                                        callback);
+                }
+            }
+        } else {
+            callback(itinerary);
+        }
+    },
+
+    _getRoutersForPoints: function(points) {
+        let startRouters = this._getRoutersForPlace(points[0].place);
+        let endRouters =
+            this._getRoutersForPlace(points[points.length - 1].place);
+
+        let intersectingRouters =
+            this._routerIntersection(startRouters, endRouters);
+
+        return intersectingRouters;
+    },
+
+    _createItineraries: function(itineraries) {
+        return itineraries.map((function(itinerary) {
+                                    return this._createItinerary(itinerary);
+                                }).bind(this));
+    },
+
+    _createItinerary: function(itinerary) {
+        let legs = this._createLegs(itinerary.legs);
+        return new TransitPlan.Itinerary({ duration:  itinerary.duration,
+                                           transfers: itinerary.transfers,
+                                           departure: itinerary.startTime,
+                                           arrival:   itinerary.endTime,
+                                           legs:      legs});
+    },
+
+    _createLegs: function(legs) {
+        return legs.map((function(leg) {
+            return this._createLeg(leg);
+        }).bind(this));
+    },
+
+    /* check if a string is a valid hex RGB string */
+    _isValidHexColor: function(string) {
+        if (string && string.length === 6) {
+            let regex = /^[A-Fa-f0-9]/;
+
+            return string.match(regex);
+        }
+
+        return false;
+    },
+
+    _createLeg: function(leg) {
+        let polyline = EPAF.decode(leg.legGeometry.points);
+        let intermediateStops =
+            this._createIntermediateStops(leg);
+        let color = this._isValidHexColor(leg.routeColor) ?
+                    leg.routeColor : null;
+        let textColor = this._isValidHexColor(leg.routeTextColor) ?
+                        leg.routeTextColor : null;
+
+        /* instroduce an extra stop at the end (in additional to the
+         * intermediate stops we get from OTP */
+        intermediateStops.push(new TransitPlan.Stop({ name: leg.to.name,
+                                                      arrival: leg.to.arrival,
+                                                      agencyTimezoneOffset: leg.agencyTimeZoneOffset,
+                                                      coordinate: [leg.to.lat,
+                                                                   leg.to.lon] }));
+
+        return new TransitPlan.Leg({ departure:            leg.from.departure,
+                                     arrival:              leg.to.arrival,
+                                     from:                 leg.from.name,
+                                     to:                   leg.to.name,
+                                     headsign:             leg.headsign,
+                                     intermediateStops:    intermediateStops,
+                                     fromCoordinate:       [leg.from.lat,
+                                                            leg.from.lon],
+                                     toCoordinate:         [leg.to.lat,
+                                                            leg.to.lon],
+                                     route:                leg.route,
+                                     routeType:            leg.routeType,
+                                     polyline:             polyline,
+                                     isTransit:            leg.transitLeg,
+                                     distance:             leg.distance,
+                                     duration:             leg.duration,
+                                     agencyName:           leg.agencyName,
+                                     agencyUrl:            leg.agencyUrl,
+                                     agencyTimezoneOffset: leg.agencyTimeZoneOffset,
+                                     color:                color,
+                                     textColor:            textColor,
+                                     tripShortName:        leg.tripShortName });
+    },
+
+    _createIntermediateStops: function(leg) {
+        let stops = leg.intermediateStops;
+        return stops.map((function(stop) {
+            return this._createIntermediateStop(stop, leg);
+        }).bind(this));
+    },
+
+    _createIntermediateStop: function(stop, leg) {
+        return new TransitPlan.Stop({ name:       stop.name,
+                                      arrival:    stop.arrival,
+                                      departure:  stop.departure,
+                                      agencyTimezoneOffset: leg.agencyTimeZoneOffset,
+                                      coordinate: [stop.lat, stop.lon] });
+    }
+});
diff --git a/src/org.gnome.Maps.src.gresource.xml b/src/org.gnome.Maps.src.gresource.xml
index 0e16722..9353b7d 100644
--- a/src/org.gnome.Maps.src.gresource.xml
+++ b/src/org.gnome.Maps.src.gresource.xml
@@ -36,6 +36,7 @@
     <file>mapView.js</file>
     <file>mapWalker.js</file>
     <file>notification.js</file>
+    <file>openTripPlanner.js</file>
     <file>notificationManager.js</file>
     <file>osmAccountDialog.js</file>
     <file>osmConnection.js</file>
@@ -75,6 +76,7 @@
     <file>storedRoute.js</file>
     <file>togeojson/togeojson.js</file>
     <file>transitOptions.js</file>
+    <file>transitPlan.js</file>
     <file>translations.js</file>
     <file>turnPointMarker.js</file>
     <file>userLocationBubble.js</file>
diff --git a/src/transitPlan.js b/src/transitPlan.js
new file mode 100644
index 0000000..84e4119
--- /dev/null
+++ b/src/transitPlan.js
@@ -0,0 +1,648 @@
+/* -*- Mode: JS2; indent-tabs-mode: nil; js2-basic-offset: 4 -*- */
+/* vim: set et ts=4 sw=4: */
+/*
+ * Copyright (c) 2016 Marcus Lundblad
+ *
+ * GNOME Maps is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by the
+ * Free Software Foundation; either version 2 of the License, or (at your
+ * option) any later version.
+ *
+ * GNOME Maps is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+ * for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with GNOME Maps; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * Author: Marcus Lundblad <ml update uu se>
+ */
+
+const Lang = imports.lang;
+
+const _ = imports.gettext.gettext;
+const N_ = imports.gettext.dgettext;
+
+const Champlain = imports.gi.Champlain;
+const Gio = imports.gi.Gio;
+const GLib = imports.gi.GLib;
+const GObject = imports.gi.GObject;
+
+const HVT = imports.hvt;
+
+// in org.gnome.desktop.interface
+const CLOCK_FORMAT_KEY = 'clock-format';
+
+let _desktopSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' });
+let clockFormat = _desktopSettings.get_string(CLOCK_FORMAT_KEY);
+
+/*
+ * These constants corresponds to the routeType attribute of transit legs
+ * in original GTFS specification.
+ */
+const RouteType = {
+    NON_TRANSIT: -1,
+    TRAM:        0,
+    SUBWAY:      1,
+    TRAIN:       2,
+    BUS:         3,
+    FERRY:       4,
+    /* Cable car referres to street-level cabel cars, where the propulsive
+     * cable runs in a slot between the tracks beneeth the car
+     * https://en.wikipedia.org/wiki/Cable_car_%28railway%29
+     * For example the cable cars in San Fransisco
+     * https://en.wikipedia.org/wiki/San_Francisco_cable_car_system
+     */
+    CABLE_CAR:   5,
+    /* Gondola referres to a suspended cable car, typically aerial cable cars
+     * where the car is suspended from the cable
+     * https://en.wikipedia.org/wiki/Gondola_lift
+     * For example the "Emirates Air Line" in London
+     * https://en.wikipedia.org/wiki/Emirates_Air_Line_%28cable_car%29
+     */
+    GONDOLA:     6,
+    /* Funicular referres to a railway system designed for steep inclines,
+     * https://en.wikipedia.org/wiki/Funicular
+     */
+    FUNICULAR:   7
+};
+
+/* extra time to add to the first itinerary leg when it's a walking leg */
+const WALK_SLACK = 120;
+
+function _printTimeWithTZOffset(time, offset) {
+    let utcTimeWithOffset = (time + offset) / 1000;
+    let date = GLib.DateTime.new_from_unix_utc(utcTimeWithOffset);
+
+    if (clockFormat === '24h')
+        return date.format('%R');
+    else
+        return date.format('%r');
+}
+
+const DEFAULT_ROUTE_COLOR = '000000';
+const DEFAULT_ROUTE_TEXT_COLOR = 'ffffff';
+
+const Plan = new Lang.Class({
+    Name: 'Plan',
+    Extends: GObject.Object,
+    Signals: {
+        'update': {},
+        'reset': {},
+        'itinerary-selected': { param_types: [GObject.TYPE_OBJECT] },
+        'itinerary-deselected': {}
+    },
+
+    _init: function(params) {
+        this.parent(params);
+        this.reset();
+    },
+
+    get itineraries() {
+        return this._itineraries;
+    },
+
+    get selectedItinerary() {
+        return this._selectedItinerary;
+    },
+
+    update: function(itineraries) {
+        this._itineraries = itineraries;
+        this.bbox = this._createBBox();
+        this.emit('update');
+    },
+
+    reset: function() {
+        this._itineraries = [];
+        this.bbox = null;
+        this._selectedItinerary = null;
+        this.emit('reset');
+    },
+
+    selectItinerary: function(itinerary) {
+        this._selectedItinerary = itinerary;
+        this.emit('itinerary-selected', itinerary);
+    },
+
+    deselectItinerary: function() {
+        this._selectedItinerary = null;
+        this.emit('itinerary-deselected');
+    },
+
+    _createBBox: function() {
+        let bbox = new Champlain.BoundingBox();
+        this._itineraries.forEach(function(itinerary) {
+            bbox.compose(itinerary.bbox);
+        });
+        return bbox;
+    }
+});
+
+const Itinerary = new Lang.Class({
+    Name: 'Itinerary',
+    Extends: GObject.Object,
+
+    _init: function(params) {
+        this._duration = params.duration;
+        delete params.duration;
+
+        this._departure = params.departure;
+        delete params.departure;
+
+        this._arrival = params.arrival;
+        delete params.arrival;
+
+        this._transfers = params.transfers;
+        delete params.transfers;
+
+        this._legs = params.legs;
+        delete params.legs;
+
+        this.parent();
+
+        this.bbox = this._createBBox();
+    },
+
+    get duration() {
+        return this._duration;
+    },
+
+    get departure() {
+        return this._departure;
+    },
+
+    get arrival() {
+        return this._arrival;
+    },
+
+    get transfers() {
+        return this._transfers;
+    },
+
+    get legs() {
+        return this._legs;
+    },
+
+    /* adjust timings of the legs of the itinerary, using the real duration of
+     * walking legs, also sets the timezone offsets according to adjacent
+     * transit legs */
+    _adjustLegTimings: function() {
+        if (this.legs.length === 1 && !this.legs[0].transit) {
+            /* if there is only one leg, and it's a walking one, just need to
+             * adjust the arrival time */
+            let leg = this.legs[0];
+            leg.arrival = leg.departure + leg.duration * 1000;
+
+            return;
+        }
+
+        for (let i = 0; i < this.legs.length; i++) {
+            let leg = this.legs[i];
+
+            if (!leg.transit) {
+                if (i === 0) {
+                    /* for the first leg subtract the walking time plus a
+                     * safty slack from the departure time of the following
+                     * leg */
+                    let nextLeg = this.legs[i + 1];
+                    leg.departure =
+                        nextLeg.departure - leg.duration * 1000 - WALK_SLACK;
+                    leg.arrival = leg.departure + leg.duration * 1000;
+                    /* use the timezone offset from the first transit leg */
+                    leg.agencyTimezoneOffset = nextLeg.agencyTimezoneOffset;
+                } else {
+                    /* for walking legs in the middle or at the end, just add
+                     * the actual walking walk duration to the arrival time of
+                     * the previous leg */
+                    let previousLeg = this.legs[i - 1];
+                    leg.departure = previousLeg.arrival;
+                    leg.arrival = previousLeg.arrival + leg.duration * 1000;
+                    /* use the timezone offset of the previous (transit) leg */
+                    leg.agencyTimezoneOffset = previousLeg.agencyTimezoneOffset;
+                }
+            }
+        }
+    },
+
+    _createBBox: function() {
+        let bbox = new Champlain.BoundingBox();
+
+        this._legs.forEach(function(leg) {
+            bbox.compose(leg.bbox);
+        });
+
+        return bbox;
+    },
+
+    prettyPrintTimeInterval: function() {
+        /* Translators: this is a format string for showing a departure and
+         * arrival time, like:
+         * "12:00 – 13:03" where the placeholder %s are the actual times,
+         * these could be rearranged if needed */
+        return _("%s \u2013 %s").format(this._getDepartureTime(),
+                                        this._getArrivalTime());
+    },
+
+    _getDepartureTime: function() {
+        /* take the itinerary departure time and offset using the timezone
+         * offset of the first leg */
+        return _printTimeWithTZOffset(this.departure,
+                                      this.legs[0].agencyTimezoneOffset);
+    },
+
+    _getArrivalTime: function() {
+        /* take the itinerary departure time and offset using the timezone
+         * offset of the last leg */
+        let lastLeg = this.legs[this.legs.length - 1];
+        return _printTimeWithTZOffset(this.arrival,
+                                      lastLeg.agencyTimezoneOffset);
+    },
+
+    prettyPrintDuration: function() {
+        let mins = this.duration / 60;
+
+        if (mins < 60) {
+            return N_("%d minute", "%d minutes", mins).format(mins);
+        } else {
+            let hours = Math.floor(mins / 60);
+
+            mins = mins % 60;
+
+            if (mins === 0)
+                return N_("%d hour", "%d hours", hours).format(hours);
+            else
+                return N_("%d:%0d hour", "%d:%02d hours", hours).format(hours, mins);
+        }
+    },
+
+    adjustTimings: function() {
+        this._adjustLegTimings();
+        this._departure = this._legs[0].departure;
+        this._arrival = this._legs[this._legs.length - 1].arrival;
+        this._duration = (this._arrival - this._departure) / 1000;
+    },
+
+    _getTransitDepartureLeg: function() {
+        for (let i = 0; i < this._legs.length; i++) {
+            let leg = this._legs[i];
+
+            if (leg.transit)
+                return leg;
+        }
+
+        throw new Error('no transit leg found');
+    },
+
+    _getTransitArrivalLeg: function() {
+        for (let i = this._legs.length - 1; i >= 0; i--) {
+            let leg = this._legs[i];
+
+            if (leg.transit)
+                return leg;
+        }
+
+        throw new Error('no transit leg found');
+    },
+
+    /* gets the departure time of the first transit leg */
+    get transitDepartureTime() {
+        return this._getTransitDepartureLeg().departure;
+    },
+
+    /* gets the timezone offset of the first transit leg */
+    get transitDepartureTimezoneOffset() {
+        return this._getTransitDepartureLeg().timezoneOffset;
+    },
+
+    /* gets the arrival time of the final transit leg */
+    get transitArrivalTime() {
+        return this._getTransitArrivalLeg().arrival;
+    },
+
+    /* gets the timezone offset of the final transit leg */
+    get transitArrivalTimezoneOffset() {
+        return this._getTransitArrivalLeg().timezoneOffset;
+    }
+});
+
+const Leg = new Lang.Class({
+    Name: 'Leg',
+
+    _init: function(params) {
+        this._route = params.route;
+        delete params.route;
+
+        this._routeType = params.routeType;
+        delete params.routeType;
+
+        this._departure = params.departure;
+        delete params.departure;
+
+        this._arrival = params.arrival;
+        delete params.arrival;
+
+        this._polyline = params.polyline;
+        delete params.polyline;
+
+        this._fromCoordinate = params.fromCoordinate;
+        delete params.fromCoordinate;
+
+        this._toCoordinate = params.toCoordinate;
+        delete params.toCoordinate;
+
+        this._from = params.from;
+        delete params.from;
+
+        this._to = params.to;
+        delete params.to;
+
+        this._intermediateStops = params.intermediateStops;
+        delete params.intermediateStops;
+
+        this._headsign = params.headsign;
+        delete params.headsign;
+
+        this._isTransit = params.isTransit;
+        delete params.isTransit;
+
+        this._walkingInstructions = params.walkingInstructions;
+        delete params.walkingInstructions;
+
+        this._distance = params.distance;
+        delete params.distance;
+
+        this._duration = params.duration;
+        delete params.duration;
+
+        this._agencyName = params.agencyName;
+        delete params.agencyName;
+
+        this._agencyUrl = params.agencyUrl;
+        delete params.agencyUrl;
+
+        this._agencyTimezoneOffset = params.agencyTimezoneOffset;
+        delete params.agencyTimezoneOffset;
+
+        this._color = params.color;
+        delete params.color;
+
+        this._textColor = params.textColor;
+        delete params.textColor;
+
+        this._tripShortName = params.tripShortName;
+        delete params.tripShortName;
+
+        this.parent();
+
+        this.bbox = this._createBBox();
+    },
+
+    get route() {
+        return this._route;
+    },
+
+    get routeType() {
+        return this._routeType;
+    },
+
+    get departure() {
+        return this._departure;
+    },
+
+    set departure(departure) {
+        this._departure = departure;
+    },
+
+    get arrival() {
+        return this._arrival;
+    },
+
+    get timezoneOffset() {
+        return this._agencyTimezoneOffset;
+    },
+
+    set arrival(arrival) {
+        this._arrival = arrival;
+    },
+
+    get polyline() {
+        return this._polyline;
+    },
+
+    get fromCoordinate() {
+        return this._fromCoordinate;
+    },
+
+    get toCoordinate() {
+        return this._toCoordinate;
+    },
+
+    get from() {
+        return this._from;
+    },
+
+    get to() {
+        return this._to;
+    },
+
+    get intermediateStops() {
+        return this._intermediateStops;
+    },
+
+    get headsign() {
+        return this._headsign;
+    },
+
+    get transit() {
+        return this._isTransit;
+    },
+
+    get distance() {
+        return this._distance;
+    },
+
+    get duration() {
+        return this._duration;
+    },
+
+    get agencyName() {
+        return this._agencyName;
+    },
+
+    get agencyUrl() {
+        return this._agencyUrl;
+    },
+
+    get agencyTimezoneOffset() {
+        return this._agencyTimezoneOffset;
+    },
+
+    set agencyTimezoneOffset(tzOffset) {
+        this._agencyTimezoneOffset = tzOffset;
+    },
+
+    get color() {
+        return this._color || DEFAULT_ROUTE_COLOR;
+    },
+
+    get textColor() {
+        return this._textColor || DEFAULT_ROUTE_TEXT_COLOR;
+    },
+
+    get tripShortName() {
+        return this._tripShortName;
+    },
+
+    _createBBox: function() {
+        let bbox = new Champlain.BoundingBox();
+
+        this.polyline.forEach(function({ latitude, longitude }) {
+            bbox.extend(latitude, longitude);
+        });
+
+        return bbox;
+    },
+
+    get iconName() {
+        if (this._isTransit) {
+            let type = this._routeType;
+            switch (type) {
+                /* special case HVT codes */
+                case HVT.CABLE_CAR:
+                    return 'route-transit-cablecar-symbolic';
+                default:
+                    let hvtSupertype = HVT.supertypeOf(type);
+
+                    if (hvtSupertype !== -1)
+                        type = hvtSupertype;
+
+                    switch (type) {
+                        case RouteType.TRAM:
+                        case HVT.TRAM_SERVICE:
+                            return 'route-transit-tram-symbolic';
+
+                        case RouteType.SUBWAY:
+                        case HVT.METRO_SERVICE:
+                        case HVT.URBAN_RAILWAY_SERVICE:
+                        case HVT.UNDERGROUND_SERVICE:
+                            return 'route-transit-subway-symbolic';
+
+                        case RouteType.TRAIN:
+                        case HVT.RAILWAY_SERVICE:
+                        case HVT.SUBURBAN_RAILWAY_SERVICE:
+                            return 'route-transit-train-symbolic';
+
+                        case RouteType.BUS:
+                        case HVT.BUS_SERVICE:
+                        case HVT.COACH_SERVICE:
+                        case HVT.TROLLEYBUS_SERVICE:
+                            /* TODO: handle a special case icon for trolleybus */
+                            return 'route-transit-bus-symbolic';
+
+                        case RouteType.FERRY:
+                        case HVT.WATER_TRANSPORT_SERVICE:
+                        case HVT.FERRY_SERVICE:
+                            return 'route-transit-ferry-symbolic';
+
+                        case RouteType.CABLE_CAR:
+                            return 'route-transit-cablecar-symbolic';
+
+                        case RouteType.GONDOLA:
+                        case HVT.TELECABIN_SERVICE:
+                            return 'route-transit-gondolalift-symbolic';
+
+                        case RouteType.FUNICULAR:
+                        case HVT.FUNICULAR_SERVICE:
+                            return 'route-transit-funicular-symbolic';
+
+                        case HVT.TAXI_SERVICE:
+                            /* TODO: should we have a dedicated taxi icon? */
+                            return 'route-car-symbolic';
+                        default:
+                            /* use a fallback question mark icon in case of some future,
+                             * for now unknown mode appears */
+                            return 'dialog-question-symbolic';
+                    }
+            }
+        } else {
+            return 'route-pedestrian-symbolic';
+        }
+    },
+
+    get walkingInstructions() {
+        return this._walkingInstructions;
+    },
+
+    prettyPrintTimeInterval: function() {
+        /* Translators: this is a format string for showing a departure and
+         * arrival time in a more compact manner to show in the instruction
+         * list for an itinerary, like:
+         * "12:00–13:03" where the placeholder %s are the actual times,
+         * these could be rearranged if needed */
+        return _("%s\u2013%s").format(this.prettyPrintDepartureTime(),
+                                      this.prettyPrintArrivalTime());
+    },
+
+    prettyPrintDepartureTime: function() {
+        /* take the itinerary departure time and offset using the timezone
+         * offset of the first leg */
+        return _printTimeWithTZOffset(this.departure, this.agencyTimezoneOffset);
+    },
+
+    prettyPrintArrivalTime: function() {
+        /* take the itinerary departure time and offset using the timezone
+         * offset of the last leg */
+        return _printTimeWithTZOffset(this.arrival, this.agencyTimezoneOffset);
+    }
+});
+
+const Stop = new Lang.Class({
+    Name: 'Stop',
+
+    _init: function(params) {
+        this._name = params.name;
+        delete params.name;
+
+        this._arrival = params.arrival;
+        delete params.arrival;
+
+        this._departure = params.departure;
+        delete params.departure;
+
+        this._agencyTimezoneOffset = params.agencyTimezoneOffset;
+        delete params.agencyTimezoneOffset;
+
+        this._coordinate = params.coordinate;
+        delete params.coordinate;
+    },
+
+    get name() {
+        return this._name;
+    },
+
+    get coordinate() {
+        return this._coordinate;
+    },
+
+    prettyPrintDepartureTime: function() {
+        /* take the itinerary departure time and offset using the timezone
+         * offset of the first leg */
+        return _printTimeWithTZOffset(this._departure, this._agencyTimezoneOffset);
+    },
+
+    prettyPrintArrivalTime: function() {
+        /* take the itinerary departure time and offset using the timezone
+         * offset of the last leg */
+        return _printTimeWithTZOffset(this._arrival, this._agencyTimezoneOffset);
+    }
+});
+
+function sortItinerariesByDepartureAsc(first, second) {
+    return first.departure > second.departure;
+}
+
+function sortItinerariesByArrivalDesc(first, second) {
+    return first.arrival < second.arrival;
+}


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]