Codebase list gnome-maps / upstream/3.36.4 src / mapMarker.js
upstream/3.36.4

Tree @upstream/3.36.4 (Download .tar.gz)

mapMarker.js @upstream/3.36.4raw · history · blame

/* -*- 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, see <http://www.gnu.org/licenses/>.
 *
 * Author: Damián Nohales <damiannohales@gmail.com>
 */

const Cairo = imports.cairo;
const Champlain = imports.gi.Champlain;
const Clutter = imports.gi.Clutter;
const Gdk = imports.gi.Gdk;
const GObject = imports.gi.GObject;
const Gtk = imports.gi.Gtk;
const Mainloop = imports.mainloop;

const MapWalker = imports.mapWalker;
const Utils = imports.utils;

var MapMarker = GObject.registerClass({
    Implements: [Champlain.Exportable],
    Abstract: true,
    Signals: {
        'gone-to': { }
    },
    Properties: {
        'surface': GObject.ParamSpec.override('surface',
                                              Champlain.Exportable),
        'view-latitude': GObject.ParamSpec.double('view-latitude', '', '',
                                                  GObject.ParamFlags.READABLE |
                                                  GObject.ParamFlags.WRITABLE,
                                                  -90, 90, 0),
        'view-longitude': GObject.ParamSpec.double('view-longitude', '', '',
                                                   GObject.ParamFlags.READABLE |
                                                   GObject.ParamFlags.WRITABLE,
                                                   -180, 180, 0),
        'view-zoom-level': GObject.ParamSpec.int('view-zoom-level', '', '',
                                                 GObject.ParamFlags.READABLE |
                                                 GObject.ParamFlags.WRITABLE,
                                                 0, 20, 3)
    }
}, class MapMarker extends Champlain.Marker {

    _init(params) {
        this._place = params.place;
        delete params.place;

        this._mapView = params.mapView;
        delete params.mapView;

        params.latitude = this.place.location.latitude;
        params.longitude = this.place.location.longitude;
        params.selectable = true;

        super._init(params);

        this.connect('notify::size', this._translateMarkerPosition.bind(this));
        if (this._mapView) {
            this._view = this._mapView.view;
            this.connect('notify::selected', this._onMarkerSelected.bind(this));
            this.connect('button-press', this._onButtonPress.bind(this));
            this.connect('touch-event', this._onTouchEvent.bind(this));

            // Some markers are draggable, we want to sync the marker location and
            // the location saved in the GeocodePlace
            // These are not bindings because the place may have a different
            // location later
            this.connect('notify::latitude', () => {
                this.place.location.latitude = this.latitude;
            });
            this.connect('notify::longitude', () => {
                this.place.location.longitude = this.longitude;
            });

            this.place.connect('notify::location', this._onLocationChanged.bind(this));

            this._view.bind_property('latitude', this, 'view-latitude',
                                     GObject.BindingFlags.DEFAULT);
            this._view.bind_property('longitude', this, 'view-longitude',
                                     GObject.BindingFlags.DEFAULT);
            this._view.bind_property('zoom-level', this, 'view-zoom-level',
                                     GObject.BindingFlags.DEFAULT);
            this.connect('notify::view-latitude', this._onViewUpdated.bind(this));
            this.connect('notify::view-longitude', this._onViewUpdated.bind(this));
            this.connect('notify::view-zoom-level', this._onViewUpdated.bind(this));
        }
    }

    get surface() {
        return this._surface;
    }

    set surface(v) {
        this._surface = v;
    }

    vfunc_get_surface() {
        return this._surface;
    }

    vfunc_set_surface(surface) {
        this._surface = surface;
    }

    _actorFromIconName(name, size, color) {
        try {
            let theme = Gtk.IconTheme.get_default();
            let pixbuf;

            if (color) {
                let info = theme.lookup_icon(name, size, 0);
                pixbuf = info.load_symbolic(color, null, null, null)[0];
            } else {
                pixbuf = theme.load_icon(name, size, 0);
            }

            let canvas = new Clutter.Canvas({ width: pixbuf.get_width(),
                                              height: pixbuf.get_height() });

            canvas.connect('draw', (canvas, cr) => {
                cr.setOperator(Cairo.Operator.CLEAR);
                cr.paint();
                cr.setOperator(Cairo.Operator.OVER);

                Gdk.cairo_set_source_pixbuf(cr, pixbuf, 0, 0);
                cr.paint();

                this._surface = cr.getTarget();
            });

            let actor = new Clutter.Actor();
            actor.set_content(canvas);
            actor.set_size(pixbuf.get_width(), pixbuf.get_height());
            canvas.invalidate();

            return actor;
        } catch (e) {
            Utils.debug('Failed to load image: %s'.format(e.message));
            return null;
        }
    }

    _onButtonPress(marker, event) {
        // Zoom in on marker on double-click
        if (event.get_click_count() > 1) {
            if (this._view.zoom_level < this._view.max_zoom_level) {
                this._view.zoom_level = this._view.max_zoom_level;
                this._view.center_on(this.latitude, this.longitude);
            }
        }
    }

    _onTouchEvent(marker, event) {
        if (event.type() == Clutter.EventType.TOUCH_BEGIN)
            this.selected = true;

        return Clutter.EVENT_STOP;
    }

    _translateMarkerPosition() {
        this.set_translation(-this.anchor.x, -this.anchor.y, 0);
    }

    _onLocationChanged() {
        this.set_location(this.place.location.latitude, this.place.location.longitude);

        if (this._bubble) {
            if (this._isInsideView())
                this._positionBubble(this._bubble);
            else
                this.hideBubble();
        }
    }

    /**
     * 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() {
        // Markers has no associated bubble by default
        return null;
    }

    _positionBubble(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 Gdk.Rectangle({ 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(signal, duration) {
        let sourceId = null;
        let signalId = this._view.connect(signal, () => {
            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);
        });

        Utils.once(this.bubble, 'closed', () => {
            // We still listening for the signal to refresh
            // the existent timeout
            if (!sourceId)
                this._view.disconnect(signalId);
        });

        Utils.once(this, 'notify::selected', () => {
            // 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);
            }
        });
    }

    _initBubbleSignals() {
        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', (mapView, selectedMarker) => {
            if (this.get_parent() !== selectedMarker.get_parent())
                this.selected = false;
        });

        let viewTouchEventSignalId =
            this._view.connect('touch-event', () => this.set_selected(false));

        let goingToSignalId = this._mapView.connect('going-to', () => {
            this.set_selected(false);
        });
        let buttonPressSignalId =
            this._view.connect('button-press-event', () => {
                this.set_selected(false);
            });
        // Destroy the bubble when the marker is destroyed o removed from a layer
        let parentSetSignalId = this.connect('parent-set', () => {
            this.set_selected(false);
        });
        let dragMotionSignalId = this.connect('drag-motion', () => {
            this.set_selected(false);
        });
        let markerHiddenSignalId = this.connect('notify::visible', () => {
            if (!this.visible) {
                this.set_selected(false);
            }
        });

        Utils.once(this.bubble, 'closed', () => {
            this._mapView.disconnect(markerSelectedSignalId);
            this._mapView.disconnect(goingToSignalId);
            this._view.disconnect(buttonPressSignalId);
            this._view.disconnect(viewTouchEventSignalId);
            this.disconnect(parentSetSignalId);
            this.disconnect(dragMotionSignalId);

            this._bubble.destroy();
            delete this._bubble;
        });
    }

    _isInsideView() {
        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;
    }

    _onViewUpdated() {
        if (this.bubble) {
            if (this._isInsideView())
                this._positionBubble(this.bubble);
            else
                this.bubble.hide();
        }
    }

    showBubble() {
        if (this.bubble && !this.bubble.visible && this._isInsideView()) {
            this._initBubbleSignals();
            this.bubble.show();
            this._positionBubble(this.bubble);
        }
    }

    hideBubble() {
        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() {
        this.walker.zoomToFit();
    }

    goTo(animate) {
        Utils.once(this.walker, 'gone-to', () => this.emit('gone-to'));
        this.walker.goTo(animate);
    }

    goToAndSelect(animate) {
        Utils.once(this, 'gone-to', () => this.selected = true);

        this.goTo(animate);
    }

    _onMarkerSelected() {
        if (this.selected)
            this.showBubble();
        else
            this.hideBubble();
    }
});