Greasy Fork is available in English.

WME PL Jump

Opens a PL in an existing WME window/tab.

// ==UserScript==
// @name            WME PL Jump
// @description     Opens a PL in an existing WME window/tab.
// @version         0.0.11
// @author          SAR85
// @copyright       SAR85
// @license         CC BY-NC-ND
// @grant           none
// @include         https://www.waze.com/editor/*
// @include         https://www.waze.com/*/editor/*
// @include         https://editor-beta.waze.com/*
// @namespace       https://greasyfork.org/users/9321
// @require			https://greasyfork.org/scripts/9794-wlib/code/wLib.js?version=106098
// ==/UserScript==

/* global OL */
/* global W */
/* global wLib */

(function () {
    'use strict';
    var app,
        AppView,
        jumpInput,
        JumpView,
        LinkView,
        Link,
        links,
        LinkCollection,
        tab;

    /**
     * Checks for necessary page elements or objects before initializing 
     * script.
     * @param tries {Number} The number of tries bootstrapping has been 
     * attempted.
     */
    function bootstrap(tries) {
        tries = tries || 0;
        if ('undefined' !== typeof wLib && window.$ &&
            window.Backbone && $('#edit-buttons').size()) {
            init();
        } else if (tries < 20) {
            window.setTimeout(function () {
                bootstrap(tries + 1);
            }, 1000);
        }
    }
    
    /**
     * Initializes global variables and sets up HTML and event listeners.
     */
    function init() {
        var tabContent = '<div id="pljumptabcontent"></div>';

        if (!$('#pljumpinput').size()) {
            tab = new wLib.Interface.Tab('PLJump', tabContent);
            initializeLink();

            initializeCollection();
            links = new LinkCollection;

            initializeViews();

            jumpInput = new JumpView({ collection: links });
            app = new AppView({ collection: links });
            updateAlert();
        }
    }

    function updateAlert() {
        var version = '0.0.11';
        var versionChanges = '';
        var previousVersion;

        versionChanges += 'WME PL Jump v' + version + ' changes:\n';
        versionChanges += '-Fixed disabled buttons issue.\n';

        if (localStorage === void 0) {
            return;
        }

        previousVersion = localStorage.pljumpVersion;

        if (version !== previousVersion) {
            alert(versionChanges);
            localStorage.pljumpVersion = version;
        }
    };

    function initializeLink() {
        /**
         * Class representing PLs.
         */
        Link = Backbone.Model.extend({
            defaults: {
                link: '',
                lonLat: null,
                segments: null,
                mapProblem: null,
                nodes: null,
                venues: null,
                selectionInBounds: false,
                selectionOnScreen: false,
                updateRequest: null,
                zoom: null
            },
            itemsToSelect: [],
            modelObject: null,
            /**
             * Checks whether the PLs items to select are in the data bounds
             * and in view.
             */
            isSelectionOnScreen: function () {
                return this.attributes.selectionInBounds &&
                    this.attributes.selectionOnScreen;
            },
            /**
             * Checks whether the PL has items to select or not.
             */
            hasItems: function () {
                return this.attributes.segments || this.attributes.nodes ||
                    this.attributes.venues || this.attributes.updateRequest ||
                    this.attributes.mapProblem;
            },
            /**
             * Parses PL to extract the location and selected item info.
             */
            initialize: function () {
                var extractedData = {},
                    link = this.get('link'),
                    lat,
                    lon,
                    lonLat,
                    mapProblem,
                    nodes,
                    segments,
                    updateRequest,
                    venues,
                    zoom;

                link.replace(/%3D/g, '=');
                lat = link.match(/lat=(-?\d+\.\d+)/);
                lon = link.match(/lon=(-?\d+\.\d+)/);
                nodes = link.match(/nodes=((\d+,?)+)/);
                segments = link.match(/segments=((\d+,?)+)/);
                updateRequest = link.match(/mapUpdateRequest=(\d+)/);
                mapProblem = link.match(/mapProblem=(\d%2F\d+)/);
                venues = link.match(/venues=((\d+\.?)+)/);
                zoom = link.match(/zoom=(\d+)/);

                extractedData.lat = lat && lat.length === 2 ?
                    parseFloat(lat[1]) : null;
                extractedData.lon = lon && lon.length === 2 ?
                    parseFloat(lon[1]) : null;
                extractedData.segments = segments && segments.length > 1 ?
                    segments[1].split(',') : null;
                extractedData.venues = venues ? venues[1].split(',') : null;
                extractedData.nodes = nodes && nodes.length > 1 ?
                    nodes[1].split(',') : null;
                extractedData.updateRequest = updateRequest &&
                    updateRequest.length === 2 ? updateRequest[1] : null;
                extractedData.mapProblem = mapProblem &&
                    mapProblem.length === 2 ?
                    mapProblem[1].replace('%2F', '/') : null;
                extractedData.zoom = zoom && zoom.length === 2 ?
                    parseInt(zoom[1]) : null;

                this.set({
                    'segments': extractedData.segments,
                    'mapProblem': extractedData.mapProblem,
                    'nodes': extractedData.nodes,
                    'venues': extractedData.venues,
                    'updateRequest': extractedData.updateRequest,
                    'zoom': extractedData.zoom
                });

                if (extractedData.lon && extractedData.lat) {
                    lonLat = new OL.LonLat(extractedData.lon,
                        extractedData.lat);
                    lonLat.transform(W.map.displayProjection,
                        W.map.getProjectionObject());
                    if (W.map.isValidLonLat(lonLat)) {
                        this.set('lonLat', lonLat);
                    }
                } else {
                    this.set('lonLat', { lon: 'None', lat: 'None' });
                }

                this.on('change:link', this.onLinkChanged);
            },
            /**
             * Private method to compile WME objects for selection.
             * @private 
             */
            createSelection: function () {
                var i,
                    itemsToSelect = [],
                    itemType,
                    itemTypes = ['segments', 'nodes', 'venues'],
                    mapBounds = W.map.getExtent(),
                    modelObject,
                    n,
                    selectionInBounds = true,
                    selectionOnScreen = true;

                var getObject = function (element) {
                    var object = W.model[itemType].get(element);
                    if (object) {
                        itemsToSelect.push(object);
                        if (!mapBounds.intersectsBounds(
                            object.geometry.getBounds())) {
                            selectionOnScreen = false;
                        }
                    } else {
                        selectionInBounds = false;
                    }
                };

                for (i = 0, n = itemTypes.length; i < n; i++) {
                    itemType = itemTypes[i];
                    _.each(this.attributes[itemType], getObject, this);
                }

                if (this.attributes.updateRequest ||
                    this.attributes.mapProblem) {
                    modelObject = W.model.mapUpdateRequests.get(
                        this.attributes.updateRequest) || W.model.problems.get(
                            this.attributes.mapProblem) || null;
                    if (!modelObject) {
                        selectionInBounds = false;
                    } else if (!mapBounds.intersectsBounds(
                        modelObject.attributes.geometry.getBounds())) {
                        selectionOnScreen = false;
                    }
                }

                this.set({
                    'selectionInBounds': selectionInBounds,
                    'selectionOnScreen': selectionOnScreen
                });
                this.modelObject = modelObject;
                this.itemsToSelect = itemsToSelect;
                return this;
            },
            /**
             * Pans the map to the lat & lon location specified in the PL.
             * @private 
             */
            moveTo: function () {
                var zoom = this.get('zoom') || W.map.getZoom();
                if (this.attributes.lonLat) {
                    W.map.moveTo(this.attributes.lonLat, zoom);
                }
                return this;
            },
            /**
             * Public method to go to a Link and select its objects.
             */
            open: function (forceMove) {
                this.createSelection();

                if (this.hasItems()) {
                    this.select();
                    if (forceMove || !this.isSelectionOnScreen()) {
                        this.moveTo();
                    }
                } else {
                    this.moveTo();
                }

                return this;
            },
            /**
             * Re-parse the link if it changes.
             */
            onLinkChanged: function () {
                this.initialize();
            },
            /**
             * Selects objects extracted from the PL.
             * @private 
             */
            select: function () {
                var selectItems = function () {

                    this.createSelection();

                    if (this.modelObject) {
                        W.commands.execute('problems:show', this.modelObject);
                    }

                    if (this.itemsToSelect.length > 0) {
                        W.selectionManager.select(this.itemsToSelect);
                    }
                };

                wLib.Model.onModelReady(selectItems,
                    this.isSelectionOnScreen(), this);

                return this;
            }
        });
    }

    function initializeCollection() {
        /**
         * Class representing collection of PLs.
         */
        LinkCollection = Backbone.Collection.extend({
            model: Link,
            getLinkID: function () {
                var id = 0;
                return function () { return id++; };
            } ()
        });
    }

    function initializeViews() {
        /**
         * Class containing the main app interface and logic.
         */
        AppView = Backbone.View.extend({
            collection: null,
            /**
             * Gets the stored options from localStorage and returns
             * them as an object.
             */
            options: function () {
                var defaultOptions = {
                    'trackHistory': true,
                    'trackSelections': false
                };
                var options = localStorage && localStorage.pljOptions;

                options = options && JSON.parse(options) || defaultOptions;
                return options;
            } (),
            clearButtonCss: {
                'margin-bottom': '10px'
            },
            divCss: {
                'overflow-y': 'scroll',
                'max-height': '400px'
            },
            tableDivCss: {
                'overflow-y': 'scroll',
                'max-height': '400px',
                'width': '292px'
            },
            el: $('#pljumptabcontent'),
            template: function () {
                var $div = $('<div/>'),
                    $table = $('<table/>').attr('id', 'plj-history-table'),
                    $clearButton = $('<button/>').attr('id', 'plj-clear-table').
                        css(this.clearButtonCss).text('Clear all entries'),
                    $trackHistory = $('<div/>').append($('<input type="checkbox" id="plj-track-history"><label>Track map move history</label>')),
                    $trackSelections = $('<div/>').append($('<input type="checkbox" id="plj-track-selections"><label>Track selections</label>')),
                    $infoText = $('<p>Click a table entry below to select it. To force the map to move to the PL location, Ctrl+Click.</p>');

                $div.append($trackHistory);
                $div.append($trackSelections);
                $div.append($clearButton.wrap('<div>').parent());
                $div.append($infoText.wrap('<div>').parent());
                $div.append($table.wrap('<div>').parent().
                    css(this.tableDivCss));

                return $div;
            },
            events: {
                'click #plj-clear-table': 'onClearTableClicked',
                'change #plj-track-history': 'onTrackHistoryChange',
                'change #plj-track-selections': 'onTrackSelectionsChange'
            },
            initialize: function () {
                this.listenTo(this.collection, 'add', this.addLink);
                this.render();
                this.onTrackHistoryChange();
                this.onTrackSelectionsChange();
            },
            render: function () {
                this.$el.append(this.template());

                this.$trackHistory = this.$el.find('#plj-track-history');
                this.$trackHistory.prop('checked',
                    this.options['trackHistory']);

                this.$trackSelections = this.$el.find('#plj-track-selections');
                this.$trackSelections.prop('checked',
                    this.options['trackSelections']);

                this.$table = this.$el.find('#plj-history-table');

                this.$clearButton = this.$el.find('#plj-clear-table');

                return this;
            },
            /**
             * Adds a new link to the table.
             */
            addLink: function (link) {
                var view = new LinkView({ model: link });
                this.$table.prepend(view.render().$el);
            },
            /**
             * Tracks map moves and determines if the location changed
             * after a move. This filters out zoom-only "moves".
             */
            mapLocationChanged: function () {
                var lastLocation,
                    locationChanged,
                    newLocation;
                var getMapLocation = function () {
                    var lonLat = W.map.getCenter(),
                        zoom = W.map.getZoom();
                    return {
                        lon: lonLat.lon,
                        lat: lonLat.lat,
                        zoom: zoom
                    };
                };
                var compareLocations = function () {
                    newLocation = getMapLocation();
                    if (newLocation.lon !== lastLocation.lon ||
                        newLocation.lat !== lastLocation.lat) {
                        lastLocation = newLocation;
                        locationChanged = true;
                    } else {
                        locationChanged = false;
                    }
                };
                lastLocation = getMapLocation();
                W.map.events.register('moveend', this, compareLocations);
                return function () {
                    return locationChanged;
                };
            } (),
            /**
             * Callback for clearing the link history.
             */
            onClearTableClicked: function (e) {
                this.$table.find('tr').remove();
                this.collection.reset();
            },
            /**
             * Callback for map move.
             */
            onMapMove: function () {
                if (this.mapLocationChanged()) {
                    this.collection.add({
                        link: $('.WazeControlPermalink a').prop('href')
                    });
                }
            },
            /**
             * Callback for selection change.
             */
            onSelectionChanged: function () {
                if (this.selectionChanged()) {
                    this.collection.add({
                        link: $('.WazeControlPermalink a').prop('href')
                    });
                }
            },
            /**
             * Callback for track history checkbox change.
             */
            onTrackHistoryChange: function (e) {
                var track = this.$trackHistory.prop('checked');

                W.map.events.unregister('moveend', this, this.onMapMove);

                if (track) {
                    W.map.events.register('moveend', this, this.onMapMove);
                }

                this.saveOption('trackHistory', track);
            },
            /**
             * Callback for track selections checkbox change.
             */
            onTrackSelectionsChange: function (e) {
                var track = this.$trackSelections.prop('checked');

                W.selectionManager.events.unregister('selectionchanged', this,
                    this.onSelectionChanged);

                if (track) {
                    W.selectionManager.events.register('selectionchanged', this,
                        this.onSelectionChanged);
                }

                this.saveOption('trackSelections', track);
            },
            /**
             * Saves app options to localStorage.
             */
            saveOption: function (key, value) {
                if (localStorage === void 0) { return; }
                this.options[key] = value;
                localStorage.pljOptions = JSON.stringify(this.options);
            },
            /**
             * Tracks selection changes to determine if the same object is 
             * selected consecutively.
             */
            selectionChanged: function () {
                var lastSelection,
                    newSelection,
                    selectionChanged;

                var getSelectedItem = function () {
                    return W.selectionManager.hasSelectedItems() &&
                        W.selectionManager.selectedItems[0];
                };

                var compareSelection = function () {
                    newSelection = getSelectedItem();
                    if (W.selectionManager.hasSelectedItems() &&
                        lastSelection !== newSelection) {
                        lastSelection = newSelection;
                        selectionChanged = true;
                    } else {
                        selectionChanged = false;
                    }
                };

                lastSelection = getSelectedItem();
                W.selectionManager.events.register('selectionchanged', this,
                    compareSelection);

                return function () {
                    return selectionChanged;
                };
            } ()
        });

        /**
         * Class for displaying link data in a table.
         */
        LinkView = Backbone.View.extend({
            tagName: 'tr',
            tdCss: {
                'padding': '5px',
                'border': '1px solid gray',
                'border-right': 'none',
                'cursor': 'pointer'
            },
            linkInfoCss: {
                'margin': 0
            },
            linkRemoveCss: {
                'padding': '5px 5px 5px 10px',
                'border': '1px solid gray',
                'border-left': 'none',
                'text-align': 'center',
                'font-weight': 'bold'
            },
            template: function () {
                var $nameCell = $('<td/>').
                    css(this.tdCss).addClass('plj-link'),

                    $deleteCell = $('<td/>').
                        css(this.linkRemoveCss).
                        addClass('plj-remove-link').
                        append($('<a/>').text('X')),

                    attributes = this.model.attributes,
                    lonLat,
                    objectsText = [];

                if (attributes.lonLat) {
                    lonLat = attributes.lonLat.clone();
                    lonLat.transform(W.map.getProjectionObject(),
                        W.model.segments.projection);
                }

                objectsText.push('<b>Lon:</b> ' + (lonLat ?
                    lonLat.lon.toFixed(3) + '\xB0' : 'None') +
                    '  <b>Lat:</b> ' + (lonLat ? lonLat.lat.toFixed(3) + '\xB0' : 'None') +
                    '  <b>Zoom:</b> ' + (attributes.zoom ? attributes.zoom : 'None'));

                if (this.model.hasItems()) {
                    if (attributes.updateRequest) {
                        objectsText.push('<b>Update Request:</b> ' +
                            attributes.updateRequest);
                    }
                    if (attributes.mapProblem) {
                        objectsText.push('<b>Map Problem:</b> ' +
                            attributes.mapProblem);
                    }
                    if (attributes.segments) {
                        objectsText.push('<b>Segments:</b> ' +
                            attributes.segments.join(', '));
                    }
                    if (attributes.nodes) {
                        objectsText.push('<b>Nodes:</b> ' +
                            attributes.nodes.join(', '));
                    }
                    if (attributes.venues) {
                        objectsText.push('<b>Places:</b> ' +
                            attributes.venues.join(', '));
                    }
                }

                _.each(objectsText, function (text) {
                    $nameCell.append(
                        $('<p/>').css(this.linkInfoCss).html(text));
                }, this);

                return [$nameCell, $deleteCell];
            },
            events: {
                'click .plj-link': 'onLinkClicked',
                'click .plj-remove-link': 'onRemoveClicked'
            },
            initialize: function () {
                this.listenTo(this.model, 'change', this.render);
                this.listenTo(this.model, 'destroy', this.remove);
            },
            render: function () {
                var cells = this.template();

                this.$nameCell = cells[0];
                this.$deleteCell = cells[1];

                this.$el.empty();
                this.$el.append(this.$nameCell).append(this.$deleteCell);
                return this;
            },
            /**
             * Callback for clicking a link.
             */
            onLinkClicked: function (e) {
                var forceMove = e && e.ctrlKey;

                W.map.events.unregister('moveend', app, app.onMapMove);

                this.model.open(forceMove);
                app.onTrackHistoryChange();
            },
            /**
             * Callback for removing a link.
             */
            onRemoveClicked: function (e) {
                this.model.destroy();
            }
        });
        
        /**
         * View for text input box and buttons for manual PL input.
         */
        JumpView = Backbone.View.extend({
            collection: null,
            tagName: 'div',
            template: function () {
                return '<input type="text" id="pljumpinput" placeholder="WME PL Jump"></input><button id="pljumpbutton" style="margin-left: 3px">Select</button><button id="pljumpbutton-move" style="margin-left: 3px">Select & Jump</button>';
            },
            events: {
                'click #pljumpbutton': 'onJumpClick',
                'click #pljumpbutton-move': 'onJumpMoveClick',
                'keyup #pljumpinput': 'onInputChanged'
            },
            initialize: function () {
                this.render();
                this.onInputChanged();
            },
            render: function () {
                this.$el.html(this.template());
                this.$el.css({
                    'float': 'right',
                    'margin': function () {
                        return wLib.isBetaEditor ? '4px 0 0 0' : '15px';
                    }()
                });

                this.input = this.$el.find('#pljumpinput');
                this.jumpButton = this.$el.find('#pljumpbutton');
                this.jumpMoveButton = this.$el.find('#pljumpbutton-move');
                this.$el.insertAfter($('#edit-buttons'));
                return this;
            },
            /**
             * Callback for clicking the jump button.
             */
            onJumpClick: function (e, forceMove) {
                var linkText = this.input.val(),
                    newLink;

                if (linkText) {
                    newLink = this.collection.add({ link: linkText });
                    newLink.open(forceMove);
                }
                this.input.val('');
                this.onInputChanged();
            },
            /**
             * Callback for clicking the jump and move button.
             */
            onJumpMoveClick: function (e) {
                this.onJumpClick(e, true);
            },
            /**
             * Callback for keyup in the input box.
             * Disables the buttons if the textbox is empty.
             * If the key is 'enter', triggers the jump button click.
             */
            onInputChanged: function (e) {
                /*
                this.jumpButton.prop('disabled',
                    this.input.val() === '' ? true : false);
                this.jumpMoveButton.prop('disabled',
                    this.input.val() === '' ? true : false);
                */
                if (e && e.keyCode === 13) {
                    this.onJumpClick();
                }
            }
        });
    }

    bootstrap();
} ());