[gnome-maps] Add MapMarker and MapBubble classes



commit f446cd87ecc65a81966f329d3d63087fb2f0b9c1
Author: Damián Nohales <damiannohales gmail com>
Date:   Sun Jun 22 13:59:02 2014 -0300

    Add MapMarker and MapBubble classes
    
    Add base classes intended to create markers associated with
    content rich GtkPopover based bubbles.
    
    https://bugzilla.gnome.org/show_bug.cgi?id=722871

 src/gnome-maps.js.gresource.xml |    3 +
 src/mapBubble.js                |   48 +++++++
 src/mapMarker.js                |  262 +++++++++++++++++++++++++++++++++++++++
 src/mapView.js                  |   26 +---
 src/mapWalker.js                |  199 +++++++++++++++++++++++++++++
 src/utils.js                    |   21 +++
 6 files changed, 539 insertions(+), 20 deletions(-)
---
diff --git a/src/gnome-maps.js.gresource.xml b/src/gnome-maps.js.gresource.xml
index ca842c7..b37a5e8 100644
--- a/src/gnome-maps.js.gresource.xml
+++ b/src/gnome-maps.js.gresource.xml
@@ -11,8 +11,11 @@
     <file>layersPopover.js</file>
     <file>main.js</file>
     <file>mainWindow.js</file>
+    <file>mapBubble.js</file>
     <file>mapLocation.js</file>
+    <file>mapMarker.js</file>
     <file>mapView.js</file>
+    <file>mapWalker.js</file>
     <file>notification.js</file>
     <file>notificationManager.js</file>
     <file>path.js</file>
diff --git a/src/mapBubble.js b/src/mapBubble.js
new file mode 100644
index 0000000..f747327
--- /dev/null
+++ b/src/mapBubble.js
@@ -0,0 +1,48 @@
+/* -*- Mode: JS2; indent-tabs-mode: nil; js2-basic-offset: 4 -*- */
+/* vim: set et ts=4 sw=4: */
+/*
+ * Copyright (c) 2014 Damián Nohales
+ *
+ * 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: Damián Nohales <damiannohales gmail com>
+ */
+
+const Gtk = imports.gi.Gtk;
+
+const Lang = imports.lang;
+
+const MapBubble = new Lang.Class({
+    Name: "MapBubble",
+    Extends: Gtk.Popover,
+    Abstract: true,
+
+    _init: function(params) {
+        this._place = params.place;
+        delete params.place;
+
+        this._mapView = params.mapView;
+        params.relative_to = params.mapView;
+        delete params.mapView;
+
+        params.modal = false;
+
+        this.parent(params);
+    },
+
+    get place() {
+        return this._place;
+    }
+});
\ No newline at end of file
diff --git a/src/mapMarker.js b/src/mapMarker.js
new file mode 100644
index 0000000..ea47ff3
--- /dev/null
+++ b/src/mapMarker.js
@@ -0,0 +1,262 @@
+/* -*- Mode: JS2; indent-tabs-mode: nil; js2-basic-offset: 4 -*- */
+/* vim: set et ts=4 sw=4: */
+/*
+ * Copyright (c) 2014 Damián Nohales
+ *
+ * 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: Damián Nohales <damiannohales gmail com>
+ */
+
+const Cairo = imports.gi.cairo;
+const Champlain = imports.gi.Champlain;
+const Geocode = imports.gi.GeocodeGlib;
+const GObject = imports.gi.GObject;
+const Gtk = imports.gi.Gtk;
+
+const Lang = imports.lang;
+const Mainloop = imports.mainloop;
+
+const MapWalker = imports.mapWalker;
+const Utils = imports.utils;
+
+const MapMarker = new Lang.Class({
+    Name: 'MapMarker',
+    Extends: Champlain.Marker,
+    Abstract: true,
+    Signals: {
+        'gone-to': { }
+    },
+
+    _init: function(params) {
+        this._place = params.place;
+        delete params.place;
+
+        this._mapView = params.mapView;
+        delete params.mapView;
+
+        this._view = this._mapView.view;
+
+        params.latitude = this.place.location.latitude;
+        params.longitude = this.place.location.longitude;
+        params.selectable = true;
+
+        this.parent(params);
+
+        this.connect('notify::size', this._translateMarkerPosition.bind(this));
+        this.connect('notify::selected', this._onMarkerSelected.bind(this));
+
+        // Some markers are draggable, we want to sync the marker location and
+        // the location saved in the GeocodePlace
+        this.bind_property('latitude',
+                           this.place.location, 'latitude',
+                           GObject.BindingFlags.DEFAULT);
+
+        this.bind_property('longitude',
+                           this.place.location, 'longitude',
+                           GObject.BindingFlags.DEFAULT);
+    },
+
+    _translateMarkerPosition: function() {
+        this.set_translation(-this.anchor.x, -this.anchor.y, 0);
+    },
+
+    /**
+     * Returns: The anchor point for the marker icon, relative to the
+     * top left corner.
+     */
+    get anchor() {
+        return { x: 0, y: 0 };
+    },
+
+    get bubbleSpacing() {
+        return 0;
+    },
+
+    get place() {
+        return this._place;
+    },
+
+    get bubble() {
+        if (this._bubble === undefined)
+            this._bubble = this._createBubble();
+
+        return this._bubble;
+    },
+
+    _createBubble: function() {
+        // Markers has no associated bubble by default
+        return null;
+    },
+
+    _positionBubble: function(bubble) {
+        let [tx, ty, tz] = this.get_translation();
+        let x = this._view.longitude_to_x(this.longitude);
+        let y = this._view.latitude_to_y(this.latitude);
+        let mapSize = this._mapView.get_allocation();
+
+        let pos = new Cairo.RectangleInt({ x: x + tx - this.bubbleSpacing,
+                                           y: y + ty - this.bubbleSpacing,
+                                           width: this.width + this.bubbleSpacing * 2,
+                                           height: this.height + this.bubbleSpacing * 2 });
+        bubble.pointing_to = pos;
+        bubble.position = Gtk.PositionType.TOP;
+
+        // Gtk+ doesn't provide a widget allocation by calling get_allocation
+        // if it's not visible, the bubble positioning occurs when bubble
+        // is not visible yet
+        let bubbleSize = bubble.get_preferred_size()[1];
+
+        // Set bubble position left/right if it's close to a vertical map edge
+        if (pos.x + pos.width / 2 + bubbleSize.width / 2 >= mapSize.width)
+            bubble.position = Gtk.PositionType.LEFT;
+        else if (pos.x + pos.width / 2 - bubbleSize.width / 2 <= 0)
+            bubble.position = Gtk.PositionType.RIGHT;
+        // Avoid bubble to cover header bar if the marker is close to the top map edge
+        else if (pos.y - bubbleSize.height <= 0)
+            bubble.position = Gtk.PositionType.BOTTOM;
+    },
+
+    _hideBubbleOn: function(signal, duration) {
+        let sourceId = null;
+        let signalId = this._view.connect(signal, (function() {
+            if (sourceId)
+                Mainloop.source_remove(sourceId);
+            else
+                this.hideBubble();
+
+            let callback = (function() {
+                sourceId = null;
+                this.showBubble();
+            }).bind(this);
+
+            if (duration)
+                sourceId = Mainloop.timeout_add(duration, callback);
+            else
+                sourceId = Mainloop.idle_add(callback);
+        }).bind(this));
+
+        Utils.once(this.bubble, 'closed', (function() {
+            // We still listening for the signal to refresh
+            // the existent timeout
+            if (!sourceId)
+                this._view.disconnect(signalId);
+        }).bind(this));
+
+        Utils.once(this, 'notify::selected', (function() {
+            // When the marker gets deselected, we need to ensure
+            // that the timeout callback is not called anymore.
+            if (sourceId) {
+                Mainloop.source_remove(sourceId);
+                this._view.disconnect(signalId);
+            }
+        }).bind(this));
+    },
+
+    _initBubbleSignals: function() {
+        this._hideBubbleOn('notify::zoom-level', 500);
+        this._hideBubbleOn('notify::size');
+
+        // This is done to get just one marker selected at any time regardless
+        // of the layer to which it belongs so we can get only one visible bubble
+        // at any time. We do this for markers in different layers because for
+        // markers in the same layer, ChamplainMarkerLayer single selection mode
+        // does the job.
+        this._mapView.onSetMarkerSelected(this);
+
+        let markerSelectedSignalId = this._mapView.connect('marker-selected', (function(mapView, 
selectedMarker) {
+            if (this.get_parent() != selectedMarker.get_parent())
+                this.selected = false;
+        }).bind(this));
+
+        let goingToSignalId = this._mapView.connect('going-to',
+                                                    this.set_selected.bind(this, false));
+        let buttonPressSignalId = this._view.connect('button-press-event',
+                                                     this.set_selected.bind(this, false));
+        // Destroy the bubble when the marker is destroyed o removed from a layer
+        let parentSetSignalId = this.connect('parent-set',
+                                             this.set_selected.bind(this, false));
+        let dragMotionSignalId = this.connect('drag-motion',
+                                              this.set_selected.bind(this, false));
+
+        Utils.once(this.bubble, 'closed', (function() {
+            this._mapView.disconnect(markerSelectedSignalId);
+            this._mapView.disconnect(goingToSignalId);
+            this._view.disconnect(buttonPressSignalId);
+            this.disconnect(parentSetSignalId);
+            this.disconnect(dragMotionSignalId);
+
+            this._bubble.destroy();
+            delete this._bubble;
+        }).bind(this));
+    },
+
+    _isInsideView: function() {
+        let [tx, ty, tz] = this.get_translation();
+        let x = this._view.longitude_to_x(this.longitude);
+        let y = this._view.latitude_to_y(this.latitude);
+        let mapSize = this._mapView.get_allocation();
+
+        return x + tx + this.width > 0 && x + tx < mapSize.width &&
+               y + ty + this.height > 0 && y + ty < mapSize.height;
+    },
+
+    showBubble: function() {
+        if (this.bubble && !this.bubble.visible && this._isInsideView()) {
+            this._initBubbleSignals();
+            this.bubble.show();
+            this._positionBubble(this.bubble);
+        }
+    },
+
+    hideBubble: function() {
+        if (this._bubble)
+            this._bubble.hide();
+    },
+
+    get walker() {
+        if (this._walker === undefined)
+            this._walker = new MapWalker.MapWalker(this.place, this._mapView);
+
+        return this._walker;
+    },
+
+    zoomToFit: function() {
+        this.walker.zoomToFit();
+    },
+
+    goTo: function(animate) {
+        Utils.once(this.walker, 'gone-to', (function() {
+            this.emit('gone-to');
+        }).bind(this));
+
+        this.walker.goTo(animate);
+    },
+
+    goToAndSelect: function(animate) {
+        Utils.once(this, 'gone-to', (function() {
+            this.selected = true;
+        }).bind(this));
+
+        this.goTo(animate);
+    },
+
+    _onMarkerSelected: function() {
+        if (this.selected)
+            this.showBubble();
+        else
+            this.hideBubble();
+    }
+});
diff --git a/src/mapView.js b/src/mapView.js
index 3ac6624..95153d8 100644
--- a/src/mapView.js
+++ b/src/mapView.js
@@ -37,6 +37,7 @@ const Application = imports.application;
 const Utils = imports.utils;
 const Path = imports.path;
 const MapLocation = imports.mapLocation;
+const MapWalker = imports.mapWalker;
 const UserLocation = imports.userLocation;
 const _ = imports.gettext.gettext;
 
@@ -113,21 +114,6 @@ const MapView = new Lang.Class({
         this.view.map_source = source;
     },
 
-    ensureLocationsVisible: function(locations) {
-        let bbox = new Champlain.BoundingBox({ left:   180,
-                                               right: -180,
-                                               bottom:  90,
-                                               top:    -90 });
-
-        locations.forEach(function(location) {
-            bbox.left   = Math.min(bbox.left,   location.longitude);
-            bbox.right  = Math.max(bbox.right,  location.longitude);
-            bbox.bottom = Math.min(bbox.bottom, location.latitude);
-            bbox.top    = Math.max(bbox.top,    location.latitude);
-        });
-        this.view.ensure_visible(bbox, true);
-    },
-
     gotoUserLocation: function(animate) {
         this.emit('going-to-user-location');
         this._userLocation.once("gone-to", (function() {
@@ -181,9 +167,6 @@ const MapView = new Lang.Class({
 
         route.path.forEach(this._routeLayer.add_node.bind(this._routeLayer));
 
-        // Animate to the center of the route bounding box
-        // goto() is currently implemented on mapLocation, so we need to go
-        // through some hoops here.
         let [lat, lon] = route.bbox.get_center();
         let place = new Geocode.Place({
             location     : new Geocode.Location({ latitude  : lat,
@@ -193,13 +176,16 @@ const MapView = new Lang.Class({
                                                      left   : route.bbox.left,
                                                      right  : route.bbox.right })
         });
-        let mapLocation = new MapLocation.MapLocation(place, this);
 
-        mapLocation.goTo(true);
+        new MapWalker.MapWalker(place, this).goTo(true);
     },
 
     _onViewMoved: function() {
         this.emit('view-moved');
+    },
+
+    onSetMarkerSelected: function(selectedMarker) {
+        this.emit('marker-selected', selectedMarker);
     }
 });
 Utils.addSignalMethods(MapView.prototype);
diff --git a/src/mapWalker.js b/src/mapWalker.js
new file mode 100644
index 0000000..ea6a14a
--- /dev/null
+++ b/src/mapWalker.js
@@ -0,0 +1,199 @@
+/* -*- 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.
+ * Copyright (c) 2014 Damián Nohales
+ *
+ * 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: Zeeshan Ali (Khattak) <zeeshanak gnome org>
+ *         Damián Nohales <damiannohales gmail com>
+ */
+
+const Clutter = imports.gi.Clutter;
+const Champlain = imports.gi.Champlain;
+const Geocode = imports.gi.GeocodeGlib;
+
+const Lang = imports.lang;
+
+const Utils = imports.utils;
+
+const _MAX_DISTANCE = 19850; // half of Earth's circumference (km)
+const _MIN_ANIMATION_DURATION = 2000; // msec
+const _MAX_ANIMATION_DURATION = 5000; // msec
+
+const MapWalker = new Lang.Class({
+    Name: 'MapWalker',
+
+    _init: function(place, mapView) {
+        this.place = place;
+        this._mapView = mapView;
+        this._view = mapView.view;
+        this._boundingBox = this._createBoundingBox(this.place);
+    },
+
+    _createBoundingBox: function(place) {
+        if (place.bounding_box !== null) {
+            return new Champlain.BoundingBox({ top: place.bounding_box.top,
+                                               bottom: place.bounding_box.bottom,
+                                               left: place.bounding_box.left,
+                                               right: place.bounding_box.right });
+        } else
+            return null;
+    },
+
+    // Zoom to the maximal zoom-level that fits the place type
+    zoomToFit: function() {
+        let zoom;
+
+        if (this._boundingBox !== null) {
+            let max = this._view.max_zoom_level;
+            let min = this._view.min_zoom_level;
+            for (let i = max; i >= min; i--) {
+                let zoomBox = this._view.get_bounding_box_for_zoom_level(i);
+                if (this._boxCovers(zoomBox)) {
+                    zoom = i;
+                    break;
+                }
+            }
+        } else {
+            switch (this.place.place_type) {
+            case Geocode.PlaceType.STREET:
+                zoom = 16;
+                break;
+
+            case Geocode.PlaceType.CITY:
+                zoom = 11;
+                break;
+
+            case Geocode.PlaceType.REGION:
+                zoom = 10;
+                break;
+
+            case Geocode.PlaceType.COUNTRY:
+                zoom = 6;
+                break;
+
+            default:
+                zoom = 11;
+                break;
+            }
+        }
+        this._view.zoom_level = zoom;
+        this._view.center_on(this.place.location.latitude,
+                             this.place.location.longitude);
+    },
+
+    goTo: function(animate) {
+        Utils.debug('Going to ' + this.place.location.description);
+        this._mapView.emit('going-to');
+
+        if (!animate) {
+            this._view.center_on(this.place.location.latitude, this.place.location.longitude);
+            this._view.animate_zoom = false;
+            this.zoomToFit();
+            this._view.animate_zoom = true;
+            this.emit('gone-to');
+
+            return;
+        }
+
+        /* Lets first ensure that both current and destination location are visible
+         * before we start the animated journey towards destination itself. We do this
+         * to create the zoom-out-then-zoom-in effect that many map implementations
+         * do. This not only makes the go-to animation look a lot better visually but
+         * also give user a good idea of where the destination is compared to current
+         * location.
+         */
+
+        this._view.goto_animation_mode = Clutter.AnimationMode.EASE_IN_CUBIC;
+
+        let fromLocation = new Geocode.Location({ latitude: this._view.get_center_latitude(),
+                                                  longitude: this._view.get_center_longitude() });
+        this._updateGoToDuration(fromLocation);
+
+        Utils.once(this._view, 'animation-completed', (function() {
+            Utils.once(this._view, 'animation-completed::go-to', (function() {
+                this.zoomToFit();
+                this._view.goto_animation_mode = Clutter.AnimationMode.EASE_IN_OUT_CUBIC;
+                this.emit('gone-to');
+            }).bind(this));
+
+            this._view.goto_animation_mode = Clutter.AnimationMode.EASE_OUT_CUBIC;
+            this._view.go_to(this.place.location.latitude, this.place.location.longitude);
+        }).bind(this));
+
+        this._ensureVisible(fromLocation);
+    },
+
+    _ensureVisible: function(fromLocation) {
+        let visibleBox = null;
+
+        if (this._boundingBox !== null && this._boundingBox.is_valid()) {
+            visibleBox = this._boundingBox.copy();
+
+            visibleBox.extend(fromLocation.latitude, fromLocation.longitude);
+        } else {
+            visibleBox = new Champlain.BoundingBox({ left:   180,
+                                                     right: -180,
+                                                     bottom:  90,
+                                                     top:    -90 });
+
+            [fromLocation, this.place.location].forEach(function(location) {
+                visibleBox.left   = Math.min(visibleBox.left,   location.longitude);
+                visibleBox.right  = Math.max(visibleBox.right,  location.longitude);
+                visibleBox.bottom = Math.min(visibleBox.bottom, location.latitude);
+                visibleBox.top    = Math.max(visibleBox.top,    location.latitude);
+            });
+        }
+
+        this._view.ensure_visible(visibleBox, true);
+    },
+
+    _boxCovers: function(coverBox) {
+        if (this._boundingBox === null)
+            return false;
+
+        if (coverBox.left > this._boundingBox.left)
+            return false;
+
+        if (coverBox.right < this._boundingBox.right)
+            return false;
+
+        if (coverBox.top < this._boundingBox.top)
+            return false;
+
+        if (coverBox.bottom > this._boundingBox.bottom)
+            return false;
+
+        return true;
+    },
+
+    _updateGoToDuration: function(fromLocation) {
+        let toLocation = this.place.location;
+
+        let distance = fromLocation.get_distance_from(toLocation);
+        let duration = (distance / _MAX_DISTANCE) * _MAX_ANIMATION_DURATION;
+
+        // Clamp duration
+        duration = Math.max(_MIN_ANIMATION_DURATION,
+                            Math.min(duration, _MAX_ANIMATION_DURATION));
+
+        // We divide by two because Champlain treats both go_to and
+        // ensure_visible as 'goto' journeys with its own duration.
+        this._view.goto_animation_duration = duration / 2;
+    }
+});
+Utils.addSignalMethods(MapWalker.prototype);
diff --git a/src/utils.js b/src/utils.js
index 631ca12..9d4df43 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -159,6 +159,27 @@ function writeFile(filename, buffer) {
     }
 }
 
+function getAccuracyDescription(accuracy) {
+    switch(accuracy) {
+    case Geocode.LOCATION_ACCURACY_UNKNOWN:
+        /* Translators: Accuracy of user location information */
+        return _("Unknown");
+    case 0:
+        /* Translators: Accuracy of user location information */
+        return _("Exact");
+    default:
+        let area =  Math.PI * Math.pow(accuracy / 1000, 2);
+
+        debug(accuracy + ' => ' + area);
+        if (area >= 1)
+            area = Math.floor(area);
+        else
+            area = Math.floor(area * 10) / 10;
+
+        return _("%f km²").format(area);
+    }
+}
+
 function load_icon(icon, size, loadCompleteCallback) {
     if (icon instanceof Gio.FileIcon) {
         _load_file_icon(icon, loadCompleteCallback);


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