diff options
author | Marcus Lundblad <ml@update.uu.se> | 2019-10-03 23:01:18 +0200 |
---|---|---|
committer | Marcus Lundblad <ml@update.uu.se> | 2019-10-19 12:45:02 +0200 |
commit | b6abaf3e8f11846436f159e665a7d02993f1d91a (patch) | |
tree | 5666a20c51a4c3eabccde906ed9aa2d12772d649 | |
parent | 66b0fecc556093ff94c9620f4055d865f5c2027d (diff) | |
download | gnome-maps-wip/mlundblad/transit-plugin-resrobot.tar.gz |
Add transit plugin for Resrobotwip/mlundblad/transit-plugin-resrobot
Adds a plugin for the Rerobot
(national Swedish) public transit
journey planning API.
-rw-r--r-- | src/org.gnome.Maps.src.gresource.xml | 1 | ||||
-rw-r--r-- | src/transitplugins/resrobot.js | 636 |
2 files changed, 637 insertions, 0 deletions
diff --git a/src/org.gnome.Maps.src.gresource.xml b/src/org.gnome.Maps.src.gresource.xml index 5ec3d065..322722bf 100644 --- a/src/org.gnome.Maps.src.gresource.xml +++ b/src/org.gnome.Maps.src.gresource.xml @@ -113,5 +113,6 @@ <file alias="geojsonvt/transform.js">transform.js</file> <file alias="geojsonvt/wrap.js">wrap.js</file> <file>transitplugins/openTripPlanner.js</file> + <file>transitplugins/resrobot.js</file> </gresource> </gresources> diff --git a/src/transitplugins/resrobot.js b/src/transitplugins/resrobot.js new file mode 100644 index 00000000..f1aedb6d --- /dev/null +++ b/src/transitplugins/resrobot.js @@ -0,0 +1,636 @@ +/* -*- Mode: JS2; indent-tabs-mode: nil; js2-basic-offset: 4 -*- */ +/* vim: set et ts=4 sw=4: */ +/* + * Copyright (c) 2019 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, see <http://www.gnu.org/licenses/>. + * + * Author: Marcus Lundblad <ml@update.uu.se> + */ + +/** + * This module implements a transit routing plugin for the Swedish national + * Resrobot transit journey planning API. + * + * API docs for Resrobot can be found at: + * https://www.trafiklab.se/api/resrobot-reseplanerare/dokumentation/sokresa + */ + +const Champlain = imports.gi.Champlain; +const GLib = imports.gi.GLib; +const Soup = imports.gi.Soup; + +const Application = imports.application; +const GraphHopperTransit = imports.graphHopperTransit; +const HTTP = imports.http; +const HVT = imports.hvt; +const TransitPlan = imports.transitPlan; +const Utils = imports.utils; + +const BASE_URL = 'https://api.resrobot.se'; +const API_VERSION = 'v2'; + +// Timezone for timestamps returned by this provider +const NATIVE_TIMEZONE = 'Europe/Stockholm'; + +const ISO_8601_DURATION_REGEXP = new RegExp(/PT((\d+)H)?((\d+)M)?/); + +const Products = { + EXPRESS_TRAIN: 2, + REGIONAL_TRAIN: 4, + EXPRESS_BUS: 8, + LOCAL_TRAIN: 16, + SUBWAY: 32, + TRAM: 64, + BUS: 128, + FERRY: 256, + // This is not currently specified in the API documentation + TAXI: 512 +}; + +const LegType = { + WALK: 'WALK', + TRANSIT: 'JNY', + TRANSFER: 'TRSF' +}; + +const CatCode = { + EXPRESS_TRAIN: 1, + REGIONAL_TRAIN: 2, + EXPRESS_BUS: 3, + LOCAL_TRAIN: 4, + SUBWAY: 5, + TRAM: 6, + BUS: 7, + FERRY: 8, + // This is not currently specified in the API documentation + TAXI: 9 +}; + +const MAX_NUM_NEARBY_STOPS = 5; +const NEARBY_STOPS_SEARCH_RADIUS = 500; + +// ignore walking legs at the beginning/end when below this distance +const DISTANCE_THREASHOLD_TO_IGNORE = 50; + +// search radius to search for walk-only journeys +const WALK_SEARCH_RADIUS = 2000; + +// maximum distance for walk-only journey +const MAX_WALK_ONLY_DISTANCE = 2500; + +var Resrobot = class Resrobot { + constructor(params) { + this._session = new Soup.Session({ user_agent : 'gnome-maps/' + pkg.version }); + this._plan = Application.routingDelegator.transitRouter.plan; + this._query = Application.routeQuery; + this._key = params.key; + this._tz = GLib.TimeZone.new(NATIVE_TIMEZONE); + + if (!this._key) + throw new Error('missing key'); + } + + fetchFirstResults() { + let filledPoints = this._query.filledPoints; + + this._extendPrevious = false; + this._viaId = null; + + if (filledPoints.length > 3) { + Utils.debug('This plugin supports at most one via location'); + this._plan.reset(); + this._plan.requestFailed(); + this._query.reset(); + } else if (filledPoints.length === 2) { + this._fetchResults(); + } else { + let lat = filledPoints[1].place.location.latitude; + let lon = filledPoints[1].place.location.longitude; + + this._fetchNearbyStops(lat, lon, MAX_NUM_NEARBY_STOPS, + NEARBY_STOPS_SEARCH_RADIUS, + () => this._fetchResults()); + } + } + + fetchMoreResults() { + this._extendPrevious = true; + + if (!this._scrF && !this._scrB) + this._noRouteFound(); + else + this._fetchResults(); + } + + _fetchNearbyStops(lat, lon, num, radius, callback) { + let query = new HTTP.Query(this._getNearbyStopsQueryParams(lat, lon, + num, radius)); + let uri = new Soup.URI(BASE_URL + '/' + API_VERSION + + '/location.nearbystops?' + query.toString()); + let request = new Soup.Message({ method: 'GET', uri: uri }); + + this._session.queue_message(request, (obj, message) => { + if (message.status_code !== Soup.Status.OK) { + Utils.debug('Failed to get nearby stops: ' + message.status_code); + this._noRouteFound(); + } else { + try { + let result = JSON.parse(message.response_body.data); + let stopLocations = result.StopLocation; + + Utils.debug('nearby stops: ' + JSON.stringify(result, null, 2)); + + if (stopLocations && stopLocations.length > 0) { + let stopLocation = stopLocations[0]; + + this._viaId = stopLocation.id; + callback(); + } else { + Utils.debug('No nearby stops found'); + this._noRouteFound(); + } + } catch (e) { + Utils.debug('Error parsing result: ' + e); + this._plan.reset(); + this._plan.requestFailed(); + } + } + }); + } + + _fetchResults() { + let query = new HTTP.Query(this._getQueryParams()); + let uri = new Soup.URI(BASE_URL + '/' + API_VERSION + '/trip?' + + query.toString()); + let request = new Soup.Message({ method: 'GET', uri: uri }); + + this._session.queue_message(request, (obj, message) => { + if (message.status_code !== Soup.Status.OK) { + Utils.debug('Failed to get trip: ' + message.status_code); + /* No routes found. If this is the first search + * (not "load more") and the distance is short + * enough, generate a walk-only itinerary + */ + let [start, end, distance] = + this._getAsTheCrowFliesPointsAndDistanceForQuery(); + + if (!this._extendPrevious && + distance <= WALK_SEARCH_RADIUS) { + GraphHopperTransit.fetchWalkingRoute([start, end], (route) => { + if (route && route.distance <= MAX_WALK_ONLY_DISTANCE) { + let walkingItinerary = + this._createWalkingOnlyItinerary(start, + end, + route); + this._plan.updateWithNewItineraries([walkingItinerary]); + } else { + this._noRouteFound(); + } + }); + } else { + this._noRouteFound(); + } + } else { + try { + let result = JSON.parse(message.response_body.data); + + Utils.debug('result: ' + JSON.stringify(result, null, 2)); + if (result.Trip) { + let itineraries = this._createItineraries(result.Trip); + + // store the back and forward references from the result + this._scrB = result.scrB; + this._scrF = result.scrF; + this._processItineraries(itineraries); + } else { + this._noRouteFound(); + } + } catch (e) { + Utils.debug('Error parsing result: ' + e); + this._plan.reset(); + this._plan.requestFailed(); + } + } + }); + } + + /* get total "as the crow flies" start, and end points, and distance for + * the query + */ + _getAsTheCrowFliesPointsAndDistanceForQuery() { + let start = this._query.filledPoints[0]; + let end = this._query.filledPoints.last(); + let startLoc = start.place.location; + let endLoc = end.place.location; + + return [start, end, endLoc.get_distance_from(startLoc) * 1000]; + } + + _processItineraries(itineraries) { + /* if this is the first request, and the distance is short enough, + * add an additional walking-only itinerary at the beginning + */ + let [start, end, distance] = + this._getAsTheCrowFliesPointsAndDistanceForQuery(); + + if (!this._extendPrevious && distance <= WALK_SEARCH_RADIUS) { + GraphHopperTransit.fetchWalkingRoute([start, end], (route) => { + if (route && route.distance <= MAX_WALK_ONLY_DISTANCE) { + let walkingItinerary = + this._createWalkingOnlyItinerary(start, end, route); + + itineraries.unshift(walkingItinerary); + } + GraphHopperTransit.addWalkingToItineraries(itineraries, + () => this._plan.updateWithNewItineraries(itineraries, + this._query.arriveBy, + this._extendPrevious)); + }); + } else { + GraphHopperTransit.addWalkingToItineraries(itineraries, + () => this._plan.updateWithNewItineraries(itineraries, + this._query.arriveBy, + this._extendPrevious)); + } + } + + _createWalkingOnlyItinerary(start, end, route) { + let walkingLeg = GraphHopperTransit.createWalkingLeg(start, end, + start.place.name, + end.place.name, + route); + let duration = route.duration; + /* if the query has no date, just use a fake, since only the time + * is relevant for displaying in this case + */ + let date = this._query.date || '2019-01-01'; + let time = this._query.time + ':00'; + + let [timestamp, tzOffset] = + this._query.time ? this._parseTime(time, date) : + this._getTimestampAndTzOffsetNow(); + + if (this._query.arriveBy) { + walkingLeg.arrival = timestamp; + walkingLeg.departure = timestamp - route.time; + } else { + walkingLeg.departure = timestamp; + walkingLeg.arrival = timestamp + route.time; + } + + walkingLeg.agencyTimezoneOffset = tzOffset; + + let walkingItinerary = + new TransitPlan.Itinerary({ legs: [walkingLeg]} ); + + walkingItinerary.adjustTimings(); + + return walkingItinerary; + } + + _reset() { + if (this._query.latest) + this._query.latest.place = null; + else + this._plan.reset(); + } + + /* Indicate that no routes where found, either shows the "No route found" + * message, or in case of loading additional (later/earlier) results, + * indicate no such where found, so that the sidebar can disable the + * "load more" functionallity as appropriate. + */ + _noRouteFound() { + if (this._extendPrevious) { + this._plan.noMoreResults(); + } else { + this._reset(); + this._plan.noRouteFound(); + } + } + + _createItineraries(trips) { + return trips.map((trip) => this._createItinerary(trip)); + } + + _createItinerary(trip) { + let legs = this._createLegs(trip.LegList.Leg); + let duration = this._parseDuration(trip.duration); + let origin = trip.LegList.Leg[0].Origin; + let destination = trip.LegList.Leg.last().Destination; + let [startTime,] = this._parseTime(origin.time, origin.date); + let [endTime,] = this._parseTime(destination.time, destination.date); + + return new TransitPlan.Itinerary({ duration: duration, + departure: startTime, + arrival: endTime, + legs: legs, + duration: duration }); + } + + /** + * Parse a time and date string into a timestamp into an array with + * an absolute timestamp in ms since Unix epoch and a timezone offset + * for the provider's native timezone at the given time and date + */ + _parseTime(time, date) { + let timeText = '%sT%s'.format(date, time); + let dateTime = GLib.DateTime.new_from_iso8601(timeText, this._tz); + + return [dateTime.to_unix() * 1000, dateTime.get_utc_offset() / 1000]; + } + + /** + * Get absolute timestamp for "now" in ms and timezone offset in the + * native timezone of the provider's native timezone @ "now" + */ + _getTimestampAndTzOffsetNow() { + let dateTime = GLib.DateTime.new_now(this._tz); + + return [dateTime.to_unix() * 1000, dateTime.get_utc_offset() / 1000]; + } + + /** + * Parse a subset of ISO 8601 duration expressions. + * Handle hour and minute parts + */ + _parseDuration(duration) { + let match = duration.match(ISO_8601_DURATION_REGEXP); + + if (match) { + let [,,h,,min] = match; + + return (h || 0) * 3600 + (min || 0) * 60; + } else { + Utils.debug('Unknown duration: ' + duration); + + return -1; + } + } + + _createLegs(legs) { + let result = legs.map((leg, index, legs) => this._createLeg(leg, index, legs)); + + if (this._canLegBeIgnored(result[0])) + result.shift(); + + if (this._canLegBeIgnored(result.last())) + result.splice(-1); + + return result; + } + + /* determines if a leg can ignored at the start or end, to catch the + * case when the user probably meant to search for a trip from a transit + * stop anyway + */ + _canLegBeIgnored(leg) { + if (!leg.isTransit) { + /* check that the distance is below the threashold and also that + * the duration is below 1 min, since the API in some occasions + * apparently gives distance 0, even though a walking leg has + * longer duration, and spans a distance in coordinates. + */ + return leg.distance <= DISTANCE_THREASHOLD_TO_IGNORE && + leg.duration <= 60; + } else { + return false; + } + } + + _createLeg(leg, index, legs) { + let isTransit; + + if (leg.type === LegType.TRANSIT) + isTransit = true; + else if (leg.type === LegType.WALK || leg.type === LegType.TRANSFER) + isTransit = false; + else + throw new Error('Unknown leg type: ' + leg.type); + + let origin = leg.Origin; + let destination = leg.Destination; + let product = leg.Product; + + if (!origin) + throw new Error('Missing Origin element'); + if (!destination) + throw new Error('Missing Destination element'); + if (!product && isTransit) + throw new Error('Missing Product element for transit leg'); + + let first = index === 0; + let last = index === legs.length - 1; + /* for walking legs in the beginning or end, use the name from the + * query, so we get the names of the place the user searched for in + * the results, when starting/ending at a transitstop, use the stop + * name + */ + let from = + first && !isTransit ? this._query.filledPoints[0].place.name : + origin.name; + let to = + last && !isTransit ? this._query.filledPoints.last().place.name : + destination.name; + let [departure, tzOffset] = this._parseTime(origin.time, origin.date); + let [arrival,] = this._parseTime(destination.time, destination.date); + let route = isTransit ? product.num : null; + let routeType = + isTransit ? this._getHVTCodeFromCatCode(product.catCode) : null; + let agencyName = isTransit ? product.operator : null; + let agencyUrl = isTransit ? product.operatorUrl : null; + let polyline = this._createPolylineForLeg(leg); + let duration = leg.duration ? this._parseDuration(leg.duration) : null; + + let result = new TransitPlan.Leg({ departure: departure, + arrival: arrival, + from: from, + to: to, + headsign: leg.direction, + fromCoordinate: [origin.lat, + origin.lon], + toCoordinate: [destination.lat, + destination.lon], + route: route, + routeType: routeType, + polyline: polyline, + isTransit: isTransit, + distance: leg.dist, + duration: duration, + agencyName: agencyName, + agencyUrl: agencyUrl, + agencyTimezoneOffset: tzOffset, + tripShortName: route }); + + if (isTransit) + result.intermediateStops = this._createIntermediateStops(leg); + + return result; + } + + _createPolylineForLeg(leg) { + let polyline; + + if (leg.Stops && leg.Stops.Stop) { + polyline = []; + + leg.Stops.Stop.forEach((stop) => { + polyline.push(new Champlain.Coordinate({ latitude: stop.lat, + longitude: stop.lon })); + }); + } else { + polyline = + [new Champlain.Coordinate({ latitude: leg.Origin.lat, + longitude: leg.Origin.lon }), + new Champlain.Coordinate({ latitude: leg.Destination.lat, + longitude: leg.Destination.lon })]; + } + + return polyline; + } + + _createIntermediateStops(leg) { + let result = []; + + if (!leg.Stops && !leg.Stops.Stop) + throw new Error('Missing Stops element'); + + leg.Stops.Stop.forEach((stop, index) => { + if (index !== 0) + result.push(this._createIntermediateStop(stop)); + }); + + return result; + } + + _createIntermediateStop(stop) { + let [departure, departureTzOffset] = [,]; + let [arrival, arrivalTzOffset] = [,]; + + if (stop.depTime && stop.depDate) + [departure, departureTzOffset] = this._parseTime(stop.depTime, stop.depDate) + if (stop.arrTime && stop.arrDate) + [arrival, arrivalTzOffset] = this._parseTime(stop.arrTime, stop.arrDate); + + if (!arrival) + arrival = departure; + if (!departure) + departure = arrival; + + return new TransitPlan.Stop({ name: stop.name, + arrival: arrival, + departure: departure, + agencyTimezoneOffset: departureTzOffset || arrivalTzOffset, + coordinate: [stop.lat, stop.lon] }); + } + + _getHVTCodeFromCatCode(code) { + switch (parseInt(code)) { + case CatCode.EXPRESS_TRAIN: + return HVT.HIGH_SPEED_RAIL_SERVICE; + case CatCode.REGIONAL_TRAIN: + return HVT.REGIONAL_RAIL_SERVICE; + case CatCode.EXPRESS_BUS: + return HVT.EXPRESS_BUS_SERVICE; + case CatCode.LOCAL_TRAIN: + return HVT.SUBURBAN_RAILWAY_SERVICE; + case CatCode.SUBWAY: + return HVT.METRO_SERVICE; + case CatCode.TRAM: + return HVT.TRAM_SERVICE; + case CatCode.BUS: + return HVT.BUS_SERVICE; + case CatCode.FERRY: + return HVT.WATER_TRANSPORT_SERVICE; + case CatCode.TAXI: + return HVT.COMMUNAL_TAXI_SERVICE; + default: + Utils.debug('Unknown catCode: ' + code); + return HVT.MISCELLANEOUS_SERVICE; + } + } + + _getQueryParams() { + let points = this._query.filledPoints; + let originLocation = points[0].place.location; + let destLocation = points.last().place.location; + let transitOptions = this._query.transitOptions; + let params = { key: this._key, + originCoordLat: originLocation.latitude, + originCoordLong: originLocation.longitude, + destCoordLat: destLocation.latitude, + destCoordLong: destLocation.longitude, + format: 'json' }; + + if (!transitOptions.showAllTransitTypes) + params.products = this._getAllowedProductsForQuery(); + + if (this._viaId) + params.viaId = this._viaId; + + if (this._extendPrevious) { + params.context = this._query.arriveBy ? this._scrB : this._scrF; + } else { + if (this._query.arriveBy) + params.searchForArrival = 1; + + if (this._query.time) + params.time = this._query.time; + + if (this._query.date) + params.date = this._query.date; + } + + return params; + } + + _getNearbyStopsQueryParams(lat, lon, num, radius) { + let params = { key: this._key, + originCoordLat: lat, + originCoordLong: lon, + maxNo: num, + r: radius, + format: 'json' }; + + return params; + } + + _getAllowedProductsForQuery() { + let products = 0; + + this._query.transitOptions.transitTypes.forEach((type) => { + products += this._productCodeForTransitType(type); + }); + + return products; + } + + _productCodeForTransitType(type) { + switch (type) { + case TransitPlan.RouteType.BUS: + return Products.BUS + Products.EXPRESS_BUS + Products.TAXI; + case TransitPlan.RouteType.TRAM: + return Products.TRAM; + case TransitPlan.RouteType.TRAIN: + return Products.EXPRESS_TRAIN + Products.LOCAL_TRAIN; + case TransitPlan.RouteType.SUBWAY: + return Products.SUBWAY; + case TransitPlan.RouteType.FERRY: + return Products.FERRY; + default: + return 0; + } + } +} |