/* -*- Mode: JS2; indent-tabs-mode: nil; js2-basic-offset: 4 -*- */ /* vim: set et ts=4 sw=4: */ /* * Copyright (c) 2017 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 . * * Author: Marcus Lundblad */ import gettext from 'gettext'; import GLib from 'gi://GLib'; import GObject from 'gi://GObject'; import {BoundingBox} from './boundingBox.js'; import * as HVT from './hvt.js'; import * as Time from './time.js'; import * as Utils from './utils.js'; const _ = gettext.gettext; const ngettext = gettext.ngettext; /* * These constants corresponds to the routeType attribute of transit legs * in original GTFS specification. */ export const RouteType = { NON_TRANSIT: -1, TRAM: 0, SUBWAY: 1, TRAIN: 2, BUS: 3, FERRY: 4, /* Cable car refers to street-level cabel cars, where the propulsive * cable runs in a slot between the tracks beneath the car * https://en.wikipedia.org/wiki/Cable_car_%28railway%29 * For example the cable cars in San Francisco * https://en.wikipedia.org/wiki/San_Francisco_cable_car_system */ CABLE_CAR: 5, /* Gondola refers 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 refers to a railway system designed for steep inclines, * https://en.wikipedia.org/wiki/Funicular */ FUNICULAR: 7, /* Electric buses that draw power from overhead wires using poles. */ TROLLEYBUS: 11, /* Railway in which the track consists of a single rail or a beam. */ MONORAIL: 12 }; /* extra time to add to the first itinerary leg when it's a walking leg */ const WALK_SLACK = 120; export const DEFAULT_ROUTE_COLOR = '4c4c4c'; export const DEFAULT_ROUTE_TEXT_COLOR = 'ffffff'; export class Plan extends GObject.Object { constructor(params) { super(params); this.reset(); this._attribution = null; this._attributionUrl = null; } get itineraries() { return this._itineraries; } get selectedItinerary() { return this._selectedItinerary; } get attribution() { return this._attribution; } set attribution(attribution) { this._attribution = attribution; } get attributionUrl() { return this._attributionUrl; } set attributionUrl(attributionUrl) { this._attributionUrl = attributionUrl; } update(itineraries) { this._itineraries = itineraries; this.bbox = this._createBBox(); this.emit('update'); } /** * Update plan with new itineraries, setting the new itineraries if it's * the first fetch for a query, or extending the existing ones if it's * a request to load more */ updateWithNewItineraries(itineraries, arriveBy, extendPrevious) { /* sort itineraries, by departure time ascending if querying * by leaving time, by arrival time descending when querying * by arriving time */ if (arriveBy) itineraries.sort(sortItinerariesByArrivalDesc); else itineraries.sort(sortItinerariesByDepartureAsc); let newItineraries = extendPrevious ? this.itineraries.concat(itineraries) : itineraries; this.update(newItineraries); } reset() { this._itineraries = []; this.bbox = null; this._selectedItinerary = null; this._attribution = null; this._attributionUrl = null; this.emit('reset'); } noMoreResults() { this.emit('no-more-results'); } selectItinerary(itinerary) { this._selectedItinerary = itinerary; this.emit('itinerary-selected', itinerary); } deselectItinerary() { this._selectedItinerary = null; this.emit('itinerary-deselected'); } error(msg) { this.emit('error', msg); } noRouteFound() { this.emit('error', _("No route found.")); } noTimetable() { this.emit('error', _("No timetable data found for this route.")); } requestFailed() { this.emit('error', _("Route request failed.")); } noProvider() { this.emit('error', _("No provider found for this route.")); } _createBBox() { let bbox = new BoundingBox(); this._itineraries.forEach(function(itinerary) { bbox.compose(itinerary.bbox); }); return bbox; } } GObject.registerClass({ Signals: { 'update': {}, 'reset': {}, 'no-more-results': {}, 'itinerary-selected': { param_types: [GObject.TYPE_OBJECT] }, 'itinerary-deselected': {}, 'error': { param_types: [GObject.TYPE_STRING] } } }, Plan); export class Itinerary extends GObject.Object { constructor(params) { super(); this._duration = params.duration; this._departure = params.departure; this._arrival = params.arrival; this._transfers = params.transfers; this._legs = params.legs; 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() { 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 * safety 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() { let bbox = new BoundingBox(); this._legs.forEach(function(leg) { bbox.compose(leg.bbox); }); return bbox; } prettyPrintTimeInterval() { /* 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() { /* take the itinerary departure time and offset using the timezone * offset of the first leg */ return Time.formatTimeWithTZOffset(this.departure, this.legs[0].agencyTimezoneOffset); } _getArrivalTime() { /* take the itinerary departure time and offset using the timezone * offset of the last leg */ let lastLeg = this.legs[this.legs.length - 1]; return Time.formatTimeWithTZOffset(this.arrival, lastLeg.agencyTimezoneOffset); } prettyPrintDuration() { let mins = Math.ceil(this.duration / 60); if (mins < 60) { let minStr = Utils.formatLocaleInteger(mins); /* translators: this is an indication for a trip duration of * less than an hour, with only the minutes part, using plural forms * as appropriate */ return ngettext("%s minute", "%s minutes", mins).format(minStr); } else { let hours = Math.floor(mins / 60); let hourStr = Utils.formatLocaleInteger(hours); mins = mins % 60; if (mins === 0) { /* translators: this is an indication for a trip duration, * where the duration is an exact number of hours (i.e. no * minutes part), using plural forms as appropriate */ return ngettext("%s hour", "%s hours", hours).format(hourStr); } else { let minStr = Utils.formatLocaleIntegerMinimumTwoDigits(mins); /* translators: this is an indication for a trip duration * where the duration contains an hour and minute part, it's * pluralized on the hours part */ return ngettext("%s:%s hour", "%s:%s hours", hours).format(hourStr, minStr); } } } adjustTimings() { 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() { 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() { 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; } get isWalkingOnly() { return this.legs.length === 1 && !this.legs[0].isTransit; } } GObject.registerClass(Itinerary); export class Leg { constructor(params) { this._route = params.route; this._routeType = params.routeType; this._departure = params.departure; this._arrival = params.arrival; this._polyline = params.polyline; this._fromCoordinate = params.fromCoordinate; this._toCoordinate = params.toCoordinate; this._from = params.from; this._to = params.to; this._intermediateStops = params.intermediateStops; this._headsign = params.headsign; this._isTransit = params.isTransit; this._walkingInstructions = params.walkingInstructions; this._distance = params.distance; this._duration = params.duration; this._agencyName = params.agencyName; this._agencyUrl = params.agencyUrl; this._agencyTimezoneOffset = params.agencyTimezoneOffset; this._color = params.color; this._textColor = params.textColor; this._tripShortName = params.tripShortName; this.bbox = this._createBBox(); this._compactRoute = null; } get route() { return this._route; } set route(route) { this._route = route; } // try to get a shortened route name, suitable for overview rendering get compactRoute() { if (this._compactRoute) return this._compactRoute; if (this._route.startsWith(this._agencyName)) { /* if the agency name is a prefix of the route name, display the * agency name in the overview, this way we get a nice "transition" * into the expanded route showing the full route name */ this._compactRoute = this._agencyName; } else if (this._tripShortName && (this._agencyName.length < this._tripShortName.length)) { /* if the agency name is shorter than the trip short name, * which can sometimes be a more "internal" number, like a * "train number", which is less known by the general public, * prefer the agency name */ this._compactRoute = this._agencyName; } else if (this._tripShortName && this._tripShortName.length <= 6) { /* if the above conditions are unmet, use the trip short name * as a fallback if it was shorter than the original route name */ this._compactRoute = this._tripShortName; } else if (this._color) { /* as a fallback when the route has a defined color, * use an empty compact label to get a colored badge */ this._compactRoute = ''; } else { /* if none of the above is true, use the original route name, * and rely on label ellipsization */ this._compactRoute = this._route; } return this._compactRoute; } get routeType() { return this._routeType; } set routeType(routeType) { this._routeType = 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; } set polyline(polyline) { this._polyline = 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; } set intermediateStops(intermediateStops) { this._intermediateStops = intermediateStops; } get headsign() { return this._headsign; } get transit() { return this._isTransit; } get distance() { return this._distance; } set distance(distance) { this._distance = 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; } set color(color) { this._color = color; } get textColor() { return this._textColor || DEFAULT_ROUTE_TEXT_COLOR; } set textColor(textColor) { this._textColor = textColor; } get tripShortName() { return this._tripShortName; } _createBBox() { let bbox = new 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'; case HVT.AIR_SERVICE: return 'route-transit-airplane-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; } set walkingInstructions(walkingInstructions) { this._walkingInstructions = walkingInstructions; } /* Pretty print timing for a transit leg, set params.isStart: true when * printing for the starting leg of an itinerary. * For starting walking legs, only the departure time will be printed, * otherwise the departure and arrival time of the leg (i.e. transit ride * or an in-between walking section) will be printed. */ prettyPrintTime(params) { if (!this.transit && params.isStart) { return this.prettyPrintDepartureTime(); } else { /* 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() { /* take the itinerary departure time and offset using the timezone * offset of the first leg */ return Time.formatTimeWithTZOffset(this.departure, this.agencyTimezoneOffset); } prettyPrintArrivalTime() { /* take the itinerary departure time and offset using the timezone * offset of the last leg */ return Time.formatTimeWithTZOffset(this.arrival, this.agencyTimezoneOffset); } } export class Stop { constructor(params) { this._name = params.name; this._arrival = params.arrival; this._departure = params.departure; this._agencyTimezoneOffset = params.agencyTimezoneOffset; this._coordinate = params.coordinate; } get name() { return this._name; } get coordinate() { return this._coordinate; } prettyPrint(params) { if (params.isFinal) { /* take the stop arrival time and offset using the timezone * offset of the last leg */ return Time.formatTimeWithTZOffset(this._arrival, this._agencyTimezoneOffset); } else { /* take the stop departure time and offset using the timezone * offset of the first leg */ return Time.formatTimeWithTZOffset(this._departure, this._agencyTimezoneOffset); } } } function sortItinerariesByDepartureAsc(first, second) { /* always sort walk-only itineraries first, as they would always be * starting at the earliest possible departure time */ if (first.isWalkingOnly) return -1; else if (second.isWalkingOnly) return 1; else return first.departure > second.departure; } function sortItinerariesByArrivalDesc(first, second) { /* always sort walk-only itineraries first, as they would always be * ending at the latest possible arrival time */ if (first.isWalkingOnly) return -1; else if (second.isWalkingOnly) return 1; else return first.arrival < second.arrival; }