/* -*- Mode: JS2; indent-tabs-mode: nil; js2-basic-offset: 4 -*- */ /* vim: set et ts=4 sw=4: */ /* * Copyright (c) 2011, 2012, 2013 Red Hat, Inc. * * 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: Zeeshan Ali (Khattak) */ import Adw from 'gi://Adw'; import GObject from 'gi://GObject'; import Gdk from 'gi://Gdk'; import GeocodeGlib from 'gi://GeocodeGlib'; import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import Gtk from 'gi://Gtk'; import Shumate from 'gi://Shumate'; import GnomeMaps from 'gi://GnomeMaps'; import {Application} from './application.js'; import {BoundingBox} from './boundingBox.js'; import * as Color from './color.js'; import * as Geoclue from './geoclue.js'; import * as GeocodeFactory from './geocode.js'; import {GeoJSONShapeLayer} from './geoJSONShapeLayer.js'; import {KmlShapeLayer} from './kmlShapeLayer.js'; import {GpxShapeLayer} from './gpxShapeLayer.js'; import {Location} from './location.js'; import * as MapSource from './mapSource.js'; import {MapWalker} from './mapWalker.js'; import {OSMAccountDialog} from './osmAccountDialog.js'; import {OSMEdit} from './osmEdit.js'; import {OSMEditDialog} from './osmEditDialog.js'; import {Place} from './place.js'; import {PlaceMarker} from './placeMarker.js'; import {RouteQuery} from './routeQuery.js'; import * as Service from './service.js'; import {ShapeLayer} from './shapeLayer.js'; import {StoredRoute} from './storedRoute.js'; import {TransitArrivalMarker} from './transitArrivalMarker.js'; import {TransitBoardMarker} from './transitBoardMarker.js'; import {TransitWalkMarker} from './transitWalkMarker.js'; import {TurnPointMarker} from './turnPointMarker.js'; import {UserLocationMarker} from './userLocationMarker.js'; import * as Utils from './utils.js'; import {ZoomInDialog} from './zoomInDialog.js'; const _LOCATION_STORE_TIMEOUT = 500; const MapMinZoom = 2; const MapMaxZoom = 19; /* threashhold for route color luminance when we consider it more or less * as white, and draw an outline on the path */ const OUTLINE_LUMINANCE_THREASHHOLD = 0.9; // color used for turn-by-turn-based routes (non-transit) const TURN_BY_TURN_ROUTE_COLOR = '0000FF'; // line width for route lines const ROUTE_LINE_WIDTH = 5; /* length of filled parts of dashed lines used for walking legs of transit * itineraries */ const DASHED_ROUTE_LINE_FILLED_LENGTH = 5; // length of gaps of dashed lines used for walking legs of transit itineraries const DASHED_ROUTE_LINE_GAP_LENGTH = 5; // Maximum limit of file size (20 MB) that can be loaded without user confirmation const FILE_SIZE_LIMIT_MB = 20; export class MapView extends Gtk.Overlay { static MapType = { LOCAL: 'MapsLocalSource', STREET: 'MapsStreetSource', AERIAL: 'MapsAerialSource' } /* * Due to the mathematics of spherical mericator projection, * the map must be truncated at a latitude less than 90 degrees. */ static MAX_LATITUDE = 85.05112; static MIN_LATITUDE = -85.05112; static MAX_LONGITUDE = 180; static MIN_LONGITUDE = -180; get routingOpen() { return this._routingOpen || this._instructionMarkerLayer.visible; } set routingOpen(value) { let isValid = Application.routeQuery.isValid(); this._routingOpen = value && isValid; this._routeLayers.forEach((routeLayer) => routeLayer.visible = value && isValid); this._instructionMarkerLayer.visible = value && isValid; if (!value) this.routeShowing = false; this.notify('routingOpen'); } get routeShowing() { return this._routeShowing; } set routeShowing(value) { this._routeShowing = value; this.notify('routeShowing'); } get mapSource() { return this._mapSource; } constructor({mapType, mainWindow, ...params}) { super(params); this.overflow = Gtk.Overflow.HIDDEN; this._mainWindow = mainWindow; this._storeId = 0; this._storeRotationId = 0; this.map = this._initMap(); this.child = this.map; this._initLicense(); this.setMapType(mapType ?? this._getStoredMapType()); this._initScale(); this._initLayers(); if (Application.normalStartup) { this._goToStoredLocation(); this._setStoredRotation(); } this.shapeLayerStore = new Gio.ListStore(GObject.TYPE_OBJECT); Application.geoclue.connect('location-changed', this._updateUserLocation.bind(this)); Application.geoclue.connect('notify::state', this._updateUserLocation.bind(this)); this._connectRouteSignals(); let actions = { 'route-from-here': { onActivate: () => this._onRouteFromHereActivated() }, 'add-intermediate-destination': { onActivate: () => this._onAddIntermediateDestinationActivated() }, 'route-to-here': { onActivate: () => this._onRouteToHereActivated() }, 'whats-here': { onActivate: () => this._onWhatsHereActivated() }, 'copy-location': { onActivate: () => this._onCopyLocationActivated() }, 'add-osm-location': { onActivate: () => this._onAddOSMLocationActivated() } }; let actionGroup = new Gio.SimpleActionGroup(); Utils.addActions(actionGroup, actions, null); this.insert_action_group('view', actionGroup); this._routeFromHereAction = actionGroup.lookup('route-from-here'); this._routeToHereAction = actionGroup.lookup('route-to-here'); this._addIntermediateDestinationAction = actionGroup.lookup('add-intermediate-destination'); let builder = Gtk.Builder.new_from_resource('/org/gnome/Maps/ui/context-menu.ui'); let menuModel = builder.get_object('context-menu'); this._contextMenu = new Gtk.PopoverMenu({ menu_model: menuModel, has_arrow: false }); this._contextMenu.set_parent(this); this._clickGesture = new Gtk.GestureClick({ button: Gdk.BUTTON_SECONDARY }); this._clickGesture.connect('pressed', this._onClickGesturePressed.bind(this)); this.add_controller(this._clickGesture); this._longPressGesture = new Gtk.GestureLongPress({ touch_only: true });; this._longPressGesture.connect('pressed', this._onLongPressGesturePressed.bind(this)); this.add_controller(this._longPressGesture); } vfunc_size_allocate(width, height, baseline) { super.vfunc_size_allocate(width, height, baseline); this._contextMenu.present(); } zoomIn() { let zoom = this.map.viewport.zoom_level; let maxZoom = this.map.viewport.max_zoom_level; let fraction = zoom - Math.floor(zoom); /* if we're zoomed to a fraction close to the next higher even zoom level * zoom to the next higher after that to avoid just going a tiny bit */ this.map.go_to_full_with_duration(this.map.viewport.latitude, this.map.viewport.longitude, Math.min(fraction < 0.7 ? Math.floor(zoom + 1) : Math.floor(zoom + 2), maxZoom), 200); } zoomOut() { let zoom = this.map.viewport.zoom_level; let minZoom = this.map.viewport.min_zoom_level; let fraction = zoom - Math.floor(zoom); /* if we're zoomed to a fraction close to the next lower even zoom level * zoom to the next lower after that to avoid just going a tiny bit */ this.map.go_to_full_with_duration(this.map.viewport.latitude, this.map.viewport.longitude, Math.max(fraction > 0.3 ? Math.floor(zoom) : Math.floor(zoom - 1), minZoom), 200); } _initScale() { let showScale = Application.settings.get('show-scale'); this._scale = new Shumate.Scale({ visible: showScale, viewport: this.map.viewport, halign: Gtk.Align.START, valign: Gtk.Align.END }); if (Utils.getMeasurementSystem() === Utils.METRIC_SYSTEM) this._scale.unit = Shumate.Unit.METRIC; else this._scale.unit = Shumate.Unit.IMPERIAL; this.add_overlay(this._scale); } _initLicense() { this._license = new Shumate.License({ halign: Gtk.Align.END, valign: Gtk.Align.END }); this.add_overlay(this._license); } _initMap() { let map = new Shumate.Map(); map.viewport.max_zoom_level = MapMaxZoom; map.viewport.min_zoom_level = MapMinZoom; map.viewport.connect('notify::latitude', this._onViewMoved.bind(this)); map.viewport.connect('notify::rotation', this._onViewRotated.bind(this)); // switching map type will set view min-zoom-level from map source map.viewport.connect('notify::min-zoom-level', () => { if (map.viewport.min_zoom_level < MapMinZoom) { map.viewport.min_zoom_level = MapMinZoom; } }); Application.settings.connect('changed::show-scale', this._onShowScaleChanged.bind(this)); return map; } /* create and store a route layer, pass true to get a dashed line */ _createRouteLayer(dashed, lineColor, outlineColor, width) { let strokeColor = new Gdk.RGBA({ red: Color.parseColor(lineColor, 0), green: Color.parseColor(lineColor, 1), blue: Color.parseColor(lineColor, 2), alpha: 1.0 }); let routeLayer = new Shumate.PathLayer({ viewport: this.map.viewport, stroke_width: width, stroke_color: strokeColor }); if (dashed) routeLayer.set_dash([DASHED_ROUTE_LINE_FILLED_LENGTH, DASHED_ROUTE_LINE_GAP_LENGTH]); if (outlineColor) { let outlineStrokeColor = new Gdk.RGBA({ red: Color.parseColor(outlineColor, 0), green: Color.parseColor(outlineColor, 1), blue: Color.parseColor(outlineColor, 2), alpha: 1.0 }); routeLayer.outline_color = outlineStrokeColor; routeLayer.outline_width = 1.0; } this._routeLayers.push(routeLayer); this.map.insert_layer_behind(routeLayer, this._instructionMarkerLayer); return routeLayer; } _clearRouteLayers() { this._routeLayers.forEach((routeLayer) => { routeLayer.remove_all(); routeLayer.visible = false; this.map.remove_layer(routeLayer); }); this._routeLayers = []; } _initLayers() { let mode = Gtk.SelectionMode.MULTIPLE; this._userLocationLayer = new Shumate.MarkerLayer({ selection_mode: mode, viewport: this.map.viewport }); this.map.add_layer(this._userLocationLayer); this._placeLayer = new Shumate.MarkerLayer({ selection_mode: mode, viewport: this.map.viewport }); this.map.insert_layer_above(this._placeLayer, this._userLocationLayer); this._instructionMarkerLayer = new Shumate.MarkerLayer({ selection_mode: mode, viewport: this.map.viewport }); this.map.insert_layer_above(this._instructionMarkerLayer, this._placeLayer); ShapeLayer.SUPPORTED_TYPES.push(GeoJSONShapeLayer); ShapeLayer.SUPPORTED_TYPES.push(KmlShapeLayer); ShapeLayer.SUPPORTED_TYPES.push(GpxShapeLayer); this._routeLayers = []; } _connectRouteSignals() { let route = Application.routingDelegator.graphHopper.route; let transitPlan = Application.routingDelegator.transitRouter.plan; let query = Application.routeQuery; route.connect('update', () => { this.showRoute(route); this.routeShowing = true; }); route.connect('reset', () => { this._clearRouteLayers(); this._instructionMarkerLayer.remove_all(); this._turnPointMarker = null; this.routeShowing = false; }); transitPlan.connect('update', () => this._showTransitPlan(transitPlan)); transitPlan.connect('reset', () => { this._clearRouteLayers(); this._instructionMarkerLayer.remove_all(); this._turnPointMarker = null; this.routeShowing = false; }); transitPlan.connect('itinerary-selected', (obj, itinerary) => { this._showTransitItinerary(itinerary); this.routeShowing = true; }); transitPlan.connect('itinerary-deselected', () => { this._clearRouteLayers(); this._instructionMarkerLayer.remove_all(); this._turnPointMarker = null; this.routeShowing = false; }); query.connect('notify', () => { this.routingOpen = query.isValid(); this._clearRouteLayers(); this._instructionMarkerLayer.remove_all(); this.routeShowing = false; }); query.connect('notify::points', () => { let query = Application.routeQuery; let numPoints = query.points.length; this._routeFromHereAction.enabled = numPoints < RouteQuery.MAX_QUERY_POINTS; this._routeToHereAction.enabled = numPoints < RouteQuery.MAX_QUERY_POINTS; this._addIntermediateDestinationAction.enabled = query.filledPoints.length >= 2 && numPoints < RouteQuery.MAX_QUERY_POINTS; }); } _getStoredMapType() { let mapType = Application.settings.get('map-type'); // make sure it's a valid map type for (let type in MapView.MapType) { if (mapType === MapView.MapType[type]) { return mapType; } } return MapType.STREET; } getMapType() { return this._mapType; } setMapType(mapType) { if (this._mapType && this._mapType === mapType) return; //let overlay_sources = this.view.get_overlay_sources(); this._mapType = mapType; let mapSource; if (mapType !== MapView.MapType.LOCAL) { let tiles = Service.getService().tiles; if (mapType === MapView.MapType.AERIAL && tiles.aerial) mapSource = MapSource.createAerialSource(); else mapSource = MapSource.createStreetSource(); // update license if (this._mapSource) this._license.remove_map_source(this._mapSource); this._license.append_map_source(mapSource); Application.settings.set('map-type', mapType); } else { let source = new GnomeMaps.FileDataSource({ path: Utils.getBufferText(Application.application.local_tile_path) }); try { source.prepare(); mapSource = new Shumate.RasterRenderer({ id: 'local', name: 'local', min_zoom_level: source.min_zoom, max_zoom_level: source.max_zoom, tile_size: Application.application.local_tile_size ?? 512, projection: Shumate.MapProjection.MERCATOR, data_source: source }); } catch(e) { this.setMapType(MapView.MapType.STREET); Application.application.local_tile_path = false; this._mainWindow.showToast(e.message); return; } } let mapLayer = new Shumate.MapLayer({ map_source: mapSource, viewport: this.map.viewport }); this.map.add_layer(mapLayer); this._mapSource = mapSource; this.map.viewport.set_reference_map_source(mapSource); this.emit("map-type-changed", mapType); } _onShowScaleChanged() { this._scale.visible = Application.settings.get('show-scale'); } _checkIfFileSizeNeedsConfirmation(files) { let confirmLoad = false; let totalFileSizeMB = 0; let file; let i = 0; do { let file = files.get_item(i); totalFileSizeMB += file.query_info(Gio.FILE_ATTRIBUTE_STANDARD_SIZE, 0, null).get_size(); i++; } while (file); totalFileSizeMB = totalFileSizeMB / (1024 * 1024); if (totalFileSizeMB > FILE_SIZE_LIMIT_MB) { confirmLoad = true; } return {'confirmLoad': confirmLoad, 'totalFileSizeMB': totalFileSizeMB}; } _onShapeLoad(error, bbox, layer) { if (error) { this._mainWindow.showToast(_("Failed to open layer")); } else { bbox.compose(layer.bbox); } this._remainingFilesToLoad--; if (this._remainingFilesToLoad === 0) { this.gotoBBox(bbox); } } openShapeLayers(files) { let result = this._checkIfFileSizeNeedsConfirmation(files); if (result.confirmLoad) { let totalFileSizeMB = result.totalFileSizeMB; let dialog = new Adw.MessageDialog ({ transient_for: this._mainWindow, modal: true, heading: _("Do you want to continue?"), body: _("You are about to open files with a total " + "size of %s MB. This could take some time to" + " load").format(totalFileSizeMB.toLocaleString(undefined, { maximumFractionDigits: 1 })) }); dialog.add_response('cancel', _("Cancel")); dialog.add_response('continue', _("Continue")); dialog.set_response_appearance('continue', Adw.ResponseAppearance.SUGGESTED); dialog.set_default_response('cancel'); dialog.set_close_response('cancel'); dialog.connect('response', (widget, responseId) => { if (responseId === 'continue') { this._loadShapeLayers(files); } dialog.destroy(); }); dialog.present(); } else { this._loadShapeLayers(files); } return true; } _loadShapeLayers(files) { let bbox = new BoundingBox(); this._remainingFilesToLoad = files.get_n_items(); for (let i = 0; i < files.get_n_items(); i++) { let file = files.get_item(i); try { let i = this._findShapeLayerIndex(file); let layer = (i > -1) ? this.shapeLayerStore.get_item(i) : null; if (!layer) { layer = ShapeLayer.newFromFile(file, this); if (!layer) throw new Error(_("File type is not supported")); layer.load(this._onShapeLoad.bind(this), bbox); this.shapeLayerStore.append(layer); } } catch (e) { Utils.debug(e); this._mainWindow.showToast(_("Failed to open layer")); } } } removeShapeLayer(shapeLayer) { shapeLayer.unload(); let i = this._findShapeLayerIndex(shapeLayer.file); this.shapeLayerStore.remove(i); } _findShapeLayerIndex(file) { for (let i = 0; i < this.shapeLayerStore.get_n_items(); i++) if (this.shapeLayerStore.get_item(i).file.equal(file)) return i; return -1; } goToGeoURI(uri) { try { let location = new Location({ heading: -1 }); location.set_from_uri(uri); let place = new Place({ location: location, name: location.description, store: false }); let marker = new PlaceMarker({ place: place, mapView: this }); this._placeLayer.add_marker(marker); marker.goToAndSelect(true); } catch(e) { this._mainWindow.showToast(_("Failed to open GeoURI")); Utils.debug("failed to open GeoURI: %s".format(e.message)); } } goToHttpURL(url) { Place.parseHttpURL(url, (place, error) => { if (place) { let marker = new PlaceMarker({ place: place, mapView: this }); this._placeLayer.add_marker(marker); marker.goToAndSelect(true); } else { this._mainWindow.showToast(error); } }); } gotoUserLocation(animate) { if (!this._userLocation) return; this.emit('going-to-user-location'); Utils.once(this._userLocation, "gone-to", () => this.emit('gone-to-user-location')); this._userLocation.goTo(animate); } gotoAntipode() { let lat = -this.map.viewport.latitude; let lon = this.map.viewport.longitude > 0 ? this.map.viewport.longitude - 180 : this.map.viewport.longitude + 180; let place = new Place({ location: new Location({ latitude: lat, longitude: lon }), initialZoom: this.map.viewport.zoom_level }); new MapWalker(place, this).goTo(true); } _getViewBBox() { let {x, y, width, height} = this.get_allocation(); let [top, left] = this.map.viewport.widget_coords_to_location(0, 0); let [bottom, right] = this.map.viewport.widget_coords_to_location(width - 1, height - 1); return new BoundingBox({ left: left, top: top, right: right, bottom: bottom }); } userLocationVisible() { let box = this._getViewBBox(); return box.covers(this._userLocation.latitude, this._userLocation.longitude); } _updateUserLocation() { if (!Application.geoclue.place) return; if (Application.geoclue.state !== Geoclue.State.ON) { if (this._userLocation) this._userLocation.visible = false; return; } if (!this._userLocation) { let place = Application.geoclue.place; this._userLocation = new UserLocationMarker({ place: place, mapView: this }); this._userLocation.addToLayer(this._userLocationLayer); } this._userLocation.visible = true; this.emit('user-location-changed'); } _storeLocation() { let viewport = this.map.viewport; let zoom = viewport.zoom_level; let location = [viewport.latitude, viewport.longitude]; /* protect agains situations where the map view was already * disposed, in this case zoom will be set to the GObject property * getter */ if (!isNaN(zoom)) { Application.settings.set('zoom-level', zoom); Application.settings.set('last-viewed-location', location); } else { Utils.debug('Failed to extract location to store'); } } _storeRotation() { let viewport = this.map.viewport; let rotation = viewport.rotation; if (!isNaN(rotation)) Application.settings.set('rotation', rotation); } _goToStoredLocation() { let location = Application.settings.get('last-viewed-location'); if (location.length === 2) { let [lat, lon] = location; let zoom = Application.settings.get('zoom-level'); if (lat >= MapView.MIN_LATITUDE && lat <= MapView.MAX_LATITUDE && lon >= MapView.MIN_LONGITUDE && lon <= MapView.MAX_LONGITUDE) { this.map.viewport.latitude = lat; this.map.viewport.longitude = lon; if (zoom >= this.map.viewport.min_zoom_level && zoom <= this.map.viewport.max_zoom_level) { this.map.viewport.zoom_level = zoom; } else { Utils.debug('Invalid initial zoom level: ' + zoom); } } else { Utils.debug('Invalid initial coordinates: ' + lat + ', ' + lon); } } else { /* bounding box. for backwards compatibility, not used anymore */ let bbox = new BoundingBox({ top: location[0], bottom: location[1], left: location[2], right: location[3] }); this.map.connect("notify::realized", () => { if (this.map.realized) this.gotoBBox(bbox, true); }); } } _setStoredRotation() { let rotation = Application.settings.get('rotation'); if (rotation < 0.0 || rotation >= 2 * Math.PI) { // safeguard agains out-of-bounds rotation values Utils.debug('Invalid stored rotation, set no rotation'); rotation = 0; } this.map.viewport.rotation = rotation; } gotoBBox(bbox, linear) { if (!bbox.isValid()) { Utils.debug('Bounding box is invalid'); return; } let [lon, lat] = bbox.getCenter(); let place = new Place({ location: new Location({ latitude : lat, longitude : lon }), bounding_box: new GeocodeGlib.BoundingBox({ top : bbox.top, bottom : bbox.bottom, left : bbox.left, right : bbox.right }) }); new MapWalker(place, this).zoomToFit(); } getZoomLevelFittingBBox(bbox) { let mapSource = this._mapSource; let goodSize = false; let zoomLevel = this.map.viewport.max_zoom_level; do { let minX = mapSource.get_x(zoomLevel, bbox.left); let minY = mapSource.get_y(zoomLevel, bbox.bottom); let maxX = mapSource.get_x(zoomLevel, bbox.right); let maxY = mapSource.get_y(zoomLevel, bbox.top); let {x, y, width, height} = this.get_allocation(); if (minY - maxY <= height && maxX - minX <= width) goodSize = true; else zoomLevel--; if (zoomLevel <= this.map.viewport.min_zoom_level) { zoomLevel = this.map.viewport.min_zoom_level; goodSize = true; } } while (!goodSize); return zoomLevel; } showTurnPoint(turnPoint) { if (this._turnPointMarker) this._instructionMarkerLayer.remove_marker(this._turnPointMarker); this._turnPointMarker = null; if (turnPoint.isStop()) return; this._turnPointMarker = new TurnPointMarker({ turnPoint: turnPoint, mapView: this }); this._instructionMarkerLayer.add_marker(this._turnPointMarker); this._turnPointMarker.goTo(); } showTransitStop(transitStop, transitLeg) { if (this._turnPointMarker) this._instructionMarkerLayer.remove_marker(this._turnPointMarker); this._turnPointMarker = new TurnPointMarker({ transitStop: transitStop, transitLeg: transitLeg, mapView: this }); this._instructionMarkerLayer.add_marker(this._turnPointMarker); this._turnPointMarker.goTo(); } _showStoredRoute(stored) { let query = Application.routeQuery; let route = Application.routingDelegator.graphHopper.route; Application.routingDelegator.graphHopper.storedRoute = stored.route; let resetId = route.connect('reset', function() { route.disconnect(resetId); query.freeze_notify(); let storedLast = stored.places.length - 1; query.points[0].place = stored.places[0]; query.points[1].place = stored.places[storedLast]; query.transportation = stored.transportation; for (let i = 1; i < storedLast; i++) { let point = query.addPoint(i); point.place = stored.places[i]; } query.thaw_notify(); }); route.reset(); } showPlace(place, animation) { this._placeLayer.remove_all(); if (place instanceof StoredRoute) { this._showStoredRoute(place); return; } this.routingOpen = false; let placeMarker = new PlaceMarker({ place: place, mapView: this }); this._placeLayer.add_marker(placeMarker); placeMarker.goToAndSelect(animation); Application.application.selected_place = place; } showRoute(route) { let routeLayer; this._clearRouteLayers(); this._placeLayer.remove_all(); routeLayer = this._createRouteLayer(false, TURN_BY_TURN_ROUTE_COLOR, null, ROUTE_LINE_WIDTH); route.path.forEach((polyline) => routeLayer.add_node(polyline)); this.routingOpen = true; this._showDestinationTurnpoints(); this.gotoBBox(route.bbox); } _showDestinationTurnpoints() { let route = Application.routingDelegator.graphHopper.route; let query = Application.routeQuery; let pointIndex = 0; this._instructionMarkerLayer.remove_all(); this._turnPointMarker = null; route.turnPoints.forEach((turnPoint) => { if (turnPoint.isStop()) { let queryPoint = query.filledPoints[pointIndex]; let destinationMarker = new TurnPointMarker({ turnPoint: turnPoint, queryPoint: queryPoint, mapView: this }); this._instructionMarkerLayer.add_marker(destinationMarker); pointIndex++; } }, this); } _showTransitItinerary(itinerary) { this.gotoBBox(itinerary.bbox); this._clearRouteLayers(); this._placeLayer.remove_all(); this._instructionMarkerLayer.remove_all(); this._turnPointMarker = null; itinerary.legs.forEach((leg, index) => { let dashed = !leg.transit; let color = leg.color; let outlineColor = leg.textColor; let hasOutline = Color.relativeLuminance(color) > OUTLINE_LUMINANCE_THREASHHOLD; let routeLayer; let lineWidth = ROUTE_LINE_WIDTH + (hasOutline ? 2 : 0); routeLayer = this._createRouteLayer(dashed, color, hasOutline ? outlineColor : null, lineWidth); /* if this is a walking leg and not at the start, "stitch" it * together with the end point of the previous leg, as the walk * route might not reach all the way */ if (index > 0 && !leg.transit) { let previousLeg = itinerary.legs[index - 1]; let lastPoint = previousLeg.polyline.last(); routeLayer.add_node(lastPoint); } leg.polyline.forEach((function (polyline) { routeLayer.add_node(polyline); })); /* like above, "stitch" the route segment with the next one if it's * a walking leg, and not the last one */ if (index < itinerary.legs.length - 1 && !leg.transit) { let nextLeg = itinerary.legs[index + 1]; let firstPoint = nextLeg.polyline[0]; routeLayer.add_node(firstPoint); } }) itinerary.legs.forEach((leg, index) => { let previousLeg = index === 0 ? null : itinerary.legs[index - 1]; /* add start marker */ let start; if (!leg.transit) { start = new TransitWalkMarker({ leg: leg, previousLeg: previousLeg, mapView: this }); } else { start = new TransitBoardMarker({ leg: leg, mapView: this }); } this._instructionMarkerLayer.add_marker(start); }); /* add arrival marker */ let lastLeg = itinerary.legs.last(); let arrival = new TransitArrivalMarker({ leg: lastLeg, mapView: this }); this._instructionMarkerLayer.add_marker(arrival); this.routingOpen = true; } _showTransitPlan(plan) { this.gotoBBox(plan.bbox); } _onViewMoved() { this.emit('view-moved'); if (this._storeId !== 0) return; this._storeId = GLib.timeout_add(null, _LOCATION_STORE_TIMEOUT, () => { this._storeId = 0; this._storeLocation(); }); } _onViewRotated() { if (this._storeRotationId !== 0) return; this._storeRotationId = GLib.timeout_add(null, _LOCATION_STORE_TIMEOUT, () => { this._storeRotationId = 0; this._storeRotation(); }); } onSetMarkerSelected(selectedMarker) { this.emit('marker-selected', selectedMarker); } _onClickGesturePressed(gesture, n_presses, x, y) { if (n_presses > 1) { gesture.set_state(Gtk.EventSequenceState.DENIED); return; } let event = gesture.get_current_event(); if (event.triggers_context_menu()) { this._showContextMenuAt(x, y); gesture.set_state(Gtk.EventSequenceState.CLAIMED); } gesture.set_state(Gtk.EventSequenceState.DENIED); } _onLongPressGesturePressed(gesture, x, y) { this._showContextMenuAt(x, y); gesture.set_state(Gtk.EventSequenceState.CLAIMED); } _showContextMenuAt(x, y) { let viewport = this.map.viewport; let rect = new Gdk.Rectangle({ x: x, y: y, width: 0, height: 0 }); [this._latitude, this._longitude] = viewport.widget_coords_to_location(this, x, y); if (this.direction === Gtk.TextDirection.RTL) { this._contextMenu.halign = Gtk.Align.END; } else { this._contextMenu.halign = Gtk.Align.START; } this._contextMenu.pointing_to = rect; this._contextMenu.popup(); } _onRouteFromHereActivated() { let query = Application.routeQuery; let location = new Location({ latitude: this._latitude, longitude: this._longitude, accuracy: 0 }); let place = new Place({ location: location, store: false }); query.points[0].place = place; } _onAddIntermediateDestinationActivated() { let query = Application.routeQuery; let location = new Location({ latitude: this._latitude, longitude: this._longitude, accuracy: 0 }); let place = new Place({ location: location, store: false }); query.addPoint(-1).place = place; } _onRouteToHereActivated() { let query = Application.routeQuery; let location = new Location({ latitude: this._latitude, longitude: this._longitude, accuracy: 0 }); let place = new Place({ location: location, store: false }); query.points.last().place = place; } _onWhatsHereActivated() { GeocodeFactory.getGeocoder().reverse(this._latitude, this._longitude, (place) => { if (place) { this.showPlace(place, true); } else { this._mainWindow.showToast(_("Nothing found here!")); } }); } _onCopyLocationActivated() { let location = new Location({ latitude: this._latitude, longitude: this._longitude, accuracy: 0 }); let clipboard = this.get_clipboard(); let uri = location.to_uri(GeocodeGlib.LocationURIScheme.GEO); clipboard.set(uri); } _onAddOSMLocationActivated() { let osmEdit = Application.osmEdit; /* if the user is not already signed in, show the account dialog */ if (!osmEdit.isSignedIn) { let dialog = osmEdit.createAccountDialog(this._mainWindow, true); dialog.show(); dialog.connect('response', (dialog, response) => { dialog.destroy(); if (response === OSMAccountDialog.Response.SIGNED_IN) this._addOSMLocation(); }); return; } this._addOSMLocation(); } _addOSMLocation() { let osmEdit = Application.osmEdit; let viewport = this.map.viewport; if (viewport.zoom_level < OSMEdit.MIN_ADD_LOCATION_ZOOM_LEVEL) { let zoomInDialog = new ZoomInDialog({ longitude: this._longitude, latitude: this._latitude, map: this.map, transient_for: this._mainWindow, modal: true }); zoomInDialog.connect('response', () => zoomInDialog.destroy()); zoomInDialog.show(); return; } let dialog = osmEdit.createEditNewDialog(this._mainWindow, this._latitude, this._longitude); dialog.show(); dialog.connect('response', (dialog, response) => { dialog.destroy(); if (response === OSMEditDialog.Response.UPLOADED) { this._mainWindow.showToast(_("Location was added in OpenStreetMap")); } }); } } GObject.registerClass({ Properties: { // this property is true when the routing sidebar is active 'routingOpen': GObject.ParamSpec.boolean('routingOpen', 'Routing open', 'Routing sidebar open', GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE, false), /* this property is true when a route is being shown on the map */ 'routeShowing': GObject.ParamSpec.boolean('routeShowing', 'Route showing', 'Showing a route on the map', GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE, false) }, Signals: { 'user-location-changed': {}, 'going-to': {}, 'going-to-user-location': {}, 'gone-to-user-location': {}, 'view-moved': {}, 'marker-selected': { param_types: [Shumate.Marker] }, 'map-type-changed': { param_types: [GObject.TYPE_STRING] } }, }, MapView);