Source: ARCore/marker.js

define(["lib/jquery",                  "lib/underscore",        "common/network/dispatcher",
        "common/util/log",             "./snapshot",            "common/util/id_generator",
        "common/util/transformations", "common/util/cap_utils", "common/util/binding_list",
        "common/util/check",           "ARCore/coordinate"],
function($,                             _,                       Dispatcher,
         Log,                           Snapshot,                IDGenerator,
         Transformations,               CapUtils,                BindingList,
         check,                         Coordinate) {

    "use strict";

    var logger = Log("Marker");

    // map of UUID -> Marker
    var markers = {};

    // given a loaded image, returns the base64-encoded PNG data for the image.
    var imageToBase64 = function(image) {
        var canvas = document.createElement("canvas");
        var ctx = canvas.getContext("2d");
        canvas.width = image.width;
        canvas.height = image.height;
        ctx.drawImage(image, 0, 0);

        return canvas.toDataURL("image/png").split(",")[1];
    };

    // given image data, mask data, and an id, registers a Marker.
    var registerMarker = function(id, imageData, maskData) {
        var data = {id: id, image: imageData};

        // parse arguments
        if(maskData) {
            data.mask = maskData;
        }

        // send registration message after ensuring that markers are on
        CapUtils.ensure("MarkersModule", logger, function() {
            Dispatcher._bridge.fireEvent(
                 "marker_register",
                 data
            );
        });
    };

    // given image data, renders it to an image
    var renderImage = function(imageData, callback) {
        var img = new Image();
        img.onload = function() {
            callback(img);
        };
        img.src = "data:image;base64," + imageData;
    };

    // returns the given property of the given object, or null if the object
    // is null.
    var getPropOrNull = function(obj, prop) {
        if(obj === null) {
            return null;
        }
        else {
            return obj[prop];
        }
    };

    // array of marker ids seen in the last update
    var lastMarkers = [];

    // subscribe to incoming updates
    Dispatcher._bridge.subscribe("marker_updates", function(updates) {
        var currentMarkers = _.keys(updates);
        var appeared = _.difference(currentMarkers, lastMarkers);
        var disappeared = _.difference(lastMarkers, currentMarkers);
        lastMarkers = currentMarkers;

        if(appeared.length > 0) {
            logger.info("Markers appeared: ", appeared);
        }

        if(disappeared.length > 0) {
            logger.info("Markers disappeared: ", disappeared);
        }


        // inform appearance subscribers
        _.each(appeared, function(id) {
            markers[id].appearCallback();
        });

        // inform disappearance subscribers
        _.each(disappeared, function(id) {
            markers[id].disappearCallback();
        });

        // iterate over each update. apply this app's offset to the translation
        // terms of the transforms
        Coordinate.getWindowOffset(function(top, left) {
            _.each(updates, function(value, key) {
                value[2] = value[2] - left;
                value[6] = value[6] - top;

                // update the marker's transform and mark it dirty
                markers[key].moveCallback(value);
            });
        });
    });

    // insert a container for pinned markers or return an existing container
    var markerContainer = function() {
        if($("body #lens-marker-container").length === 0) {
            $("<div />").attr("id",                 "lens-marker-container")
                        .css( "position",           "fixed")
                        .css( "top",                "0")
                        .css( "left",               "0")
                        .css( "width",              $(window).width())
                        .css( "height",             $(window).height())
                        .css( "pointer-events",     "none")
                        .css( "transform-style",    "preserve-3d")
                        .css( "perspective",        "Infinity")
                        .css( "perspective-origin", "top left")
                        .appendTo("body");
        }

        return $("body #lens-marker-container");
    };


    /**
     * Creates a new Marker, given a marker id.
     *
     * @param {String} id         Marker id.
     * @param {Number} origWidth  Original width of the tracked image.
     * @param {Number} origHeight Original height of the tracked image.
     *
     * @privconstructor
     * @class
     * Markers are images that LuminAR watches for and tracks. They can be used
     * to track physical objects within LuminAR's field of view. They can track
     * objects even if they are rotated or scaled.
     *
     * @property {String} id Unique ID for the Marker.
     *
     * @property {Number} origWidth
     * Original width of the tracked image. May be null for a short time after
     * marker creation.
     *
     * @property {Number} origHeight
     * Original height of the tracked image. May be null for a short time after
     * marker creation.
     *
     * @memberOf Lens
     */
    var Marker = function(id, origWidth, origHeight) {
        /** @alias Lens.Marker.prototype */
        var self = {};

        // The CSS transform representing the marker's position.
        var transform = null;

        // The decomposed CSS transform representing the marker's position.
        var decomposed = null;

        // Whether the decomposed CSS trasform is out of date and needs to
        // be regenerated
        var transformDirty = false;

        // List of movement subscribers
        var movementSubscribers = BindingList();

        /// List of appearance subscribers
        var appearanceSubscribers = BindingList();

        // List of disappearance subscribers
        var disappearanceSubscribers = BindingList();

        // updates the decomposed trasform of this Marker if needed, and returns
        // the updated decomposition.
        var decompose = function() {
            if(transformDirty) {
                if(transform) {
                    decomposed = Transformations.decompose(self.getTransform());
                }
                else {
                    decomposed = null;
                }
                transformDirty = false;
            }
            return decomposed;
        };

        // gets the given properties from the decomposed transform of the marker,
        // or null if the marker is not visible.
        var getDecomposedValue = function(prop) {
            return getPropOrNull(decompose(), prop);
        };

        var appearCallback = function() {
            appearanceSubscribers.callAll(self);
        };

        var disappearCallback = function() {
            disappearanceSubscribers.callAll(self);
        };

        var moveCallback = function(newTransform) {
            transform = newTransform;
            transformDirty = true;
            movementSubscribers.callAll(self);
        };

        /**
         * Sets the original width and height of this marker. Used so factories
         * can calculate these dimensions asynchronously.
         * @param  {Number} x
         * @param  {Number} y
         * @private
         */
        self._setOrigDims = function(width, height) {
            origWidth = width;
            origHeight = height;
        };

        /**
         * Subscribes a function to be called any time this marker is moved.
         * @param  {Function} fn The function to subscribe. Gets this marker
         *                       as an argument when called.
         * @return {Binding}     A binding that allows this handler to be
         *                       cleared.
         */
        self.moved = function(fn) {
            check(fn, Function);

            return movementSubscribers.add(fn);
        };

        /**
         * Subscribes a function to be called any time this marker appears.
         * @param  {Function} fn The function to subscribe. Gets this marker
         *                       as an argument when called.
         * @return {Binding}     A binding that allows this handler to be
         *                       cleared.
         */
        self.appeared = function(fn) {
            check(fn, Function);

            return appearanceSubscribers.add(fn);
        };

        /**
         * Subscribes a function to be called any time this marker disappears.
         * @param  {Function} fn The function to subscribe. Gets this marker
         *                       as an argument when called.
         * @return {Binding}     A binding that allows this handler to be
         *                       cleared.
         */
        self.disappeared = function(fn) {
            check(fn, Function);

            return disappearanceSubscribers.add(fn);
        };

        /**
         * Stops tracking of this marker.
         */
        self.deregister = function() {
            Dispatcher._bridge.fireEvent("marker_deregister", { id: id });
        };

        /**
         * Pins an element to this marker. The element's CSS will be modified to
         * move/resize/rotate/transform it to match the tracked object.
         *
         * @param  {string | Element | jQuery} el
         * The element to track with. This can be a CSS selector string, DOM
         * element, or jQuery object.
         *
         * @param {Boolean} [clone]
         * If true, a clone of the element will be used to track the object.
         * The clone will be destroyed when the object is no longer visible.
         * Defaults to false.
         *
         * @param {Function} [onPinStart]
         * Optional. If given, this function will be invoked when we start
         * pinning the element. It will get the pinned element as an argument.
         */
        self.pin = function(el, clone, onPinStart) {
            check(el, check.Match.Elementish);
            check(clone, check.Match.Optional(Boolean));
            check(onPinStart, check.Match.Optional(Function));

            var pinned;
            if(clone) {
                pinned = $(el).clone();
            }
            else {
                pinned = $(el);
            }

            if((pinned.length !== 1) || !(pinned[0] instanceof Element)) {
                throw "Argument error: invalid element passed to pin()";
            }

            self.appeared(function(marker) {
                pinned.remove().appendTo(markerContainer());

                pinned.css("position",           "absolute");
                pinned.css("top",                0);
                pinned.css("left",               0);
                pinned.css("transform-origin",   "top left");
                pinned.css("perspective-origin", "top left");
                pinned.css("width",              marker.origWidth);
                pinned.css("height",             marker.origHeight);
            });

            self.moved(function(marker) {
                pinned.css("transform", marker.getCSSTransform());
            });
        };

        /**
         * Return true if this marker is on screen, false otherwise.
         * @return {Boolean}
         */
        self.visible = function() {
            return !!transform;
        };

        /**
         * Returns a 4x4 transformation matrix that represents the
         * transformation of a rectangle the side of the tracked image anchored
         * at the top-left corner of the screen to the current positon of the
         * marker. Returns null if the marker is not visible.
         *
         * @return {Array} 16-element array of Numbers representing the 3-D
         *                 transformation matrix in row-major order.
         */
        self.getTransform = function() {
            var t;
            if(transform === null) {
                t = [1, 0, 0, 0,
                     0, 1, 0, 0,
                     0, 0, 1, 0,
                     0, 0, 0, 1];
            }
            else {
                t = transform;
            }

            return [
                t[0], t[4], 0,   t[8],
                t[1], t[5], 0,   t[9],
                0,    0,    1,   0,
                t[2], t[6], 0,   t[10]
            ];
        };

        /**
         * Returns a CSS transform that represents the transformation of a
         * rectangle the side of the tracked image anchored at the top-left
         * corner of the screen to the current positon of the marker. Returns
         * a transform with the identity matrix if the marker is not on screen.
         *
         * @return {String} The CSS transform, suitable for use as the value
         *                  of the "transform" CSS property.
         */
        self.getCSSTransform = function() {
            var t = self.getTransform();

            return "matrix3d(" +
                t[0] +  "," + t[1]  + "," + t[2]  + "," + t[3]  + "," +
                t[4] +  "," + t[5]  + "," + t[6]  + "," + t[7]  + "," +
                t[8] +  "," + t[9]  + "," + t[10] + "," + t[11] + "," +
                t[12] + "," + t[13] + "," + t[14] + "," + t[15] + ") ";
        };

        /**
         * Gets the X coordinate of the left side of the marker. Return null if
         * the marker is not visible.
         * @return{Number} X coordinate, in pixels.
         */
        self.getX = function() {
            return getPropOrNull(getDecomposedValue("translate"), "x");
        };

        /**
         * Gets the Y coordinate of the top side of the marker. Return null if
         * the marker is not visible.
         * @return {Number} Y coordinate, in pixels.
         */
        self.getY = function() {
            return getPropOrNull(getDecomposedValue("translate"), "y");
        };

        /**
         * Gets the angle of rotation of this marker. Return null if the
         * marker is not visible.
         * @return {Number} Rotation, in radians, clockwise.
         */
        self.getRotation = function() {
            return getPropOrNull(getDecomposedValue("rotate"), "z");
        };

        /**
         * Gets the scale of the object, e.g. how much larger or smaller it is
         * than the tracked image. Return null if the marker is not visible.
         * @return {Number} The scale, as a multiple of the original size (e.g.
         *                  a value of 1 indicates no scale).
         */
        self.getScale = function() {
            var scale = getDecomposedValue("scale");

            if(scale === null) {
                return null;
            }

            return (scale.x + scale.y) / 2;
        };

        /**
         * Gets the skew of the object, e.g. how much is is deformed into a
         * trapezoid, or null if the marker is not visible.
         * @return {Number} The skew of the object.
         */
        self.getSkew = function() {
            return getPropOrNull(getDecomposedValue("skew"), "x");
        };

        self.id = id;
        Object.defineProperty(self, "origWidth",  { get: function() { return origWidth;  } });
        Object.defineProperty(self, "origHeight", { get: function() { return origHeight; } });

        markers[id] = {
            marker: self,
            appearCallback: appearCallback,
            disappearCallback: disappearCallback,
            moveCallback: moveCallback
        };

        Object.freeze(self);
        return self;
    };

    _.extend(Marker, /** @lends Lens.Marker */ {
        /**
         * Creates a marker from base64-encoded PNG data.
         *
         * @param  {String}   image
         *   Base64-encoded data.
         * @param  {String}   [mask]
         *   Optional base64-encoded mask. The mask should be an image of the
         *   same size as the image with only black and white pixels. Any areas
         *   of black will be ignored when processing the image.
         *
         * @return {Lens.Marker} The newly-created Marker.
         */
        fromImageData: function(image, mask) {
            check(image, String);
            check(image, check.Match.Optional(String));

            var id = IDGenerator.uuid();
            registerMarker(id, image, mask);

            var marker = Marker(id, null, null);

            // asynchronously render image, update original width and height
            // when done.
            renderImage(image, function(rendered) {
                marker._setOrigDims(rendered.width, rendered.height);
            });

            // return a marker with unkown original width and height
            return marker;
        },

        /**
         * Creates a marker from the URL of a PNG image.
         *
         * @param  {String}   imageURL
         *   URL of an image. The image must be on the same host as the Lens
         *   app to comply with the same-origin policy.
         * @param  {String}   [maskURL]
         *   Optional URL of a mask image. The image must be on the same host
         *   as the Lens app to comply with the same-origin policy. The mask
         *   should be an image of the same size as the image with only black
         *   and white pixels. Any areas of black will be ignored when
         *   processing the image.
         * @return {Lens.Marker} The newly-created Marker.
         */
        fromImageURL: function(imageURL, maskURL) {
            check(imageURL, String);
            check(maskURL, check.Match.Optional(String));

            var image, mask;

            var id = IDGenerator.uuid();
            var marker = Marker(id, null, null);

            // callback for when image and mask load
            var onLoad = function() {
                if(image.complete && (!mask || mask.complete)) {
                    // both loaded, convert to base64 and register the marker
                    var imageData = imageToBase64(image);
                    var maskData = null;
                    if(mask) {
                        maskData = imageToBase64(mask);
                    }

                    registerMarker(id, imageData, maskData);

                    // set the original width and height
                    marker._setOrigDims(image.width, image.height);
                }
            };

            // start loading mask and image
            if(maskURL) {
                mask = new Image();
                mask.onload = onLoad;
                mask.src = maskURL;
            }

            image = new Image();
            image.onload = onLoad;
            image.src = imageURL;

            // return the marker with unknown orig width and height
            return marker;
        },

        /**
         * Creates a marker by taking a snapshot of a page element with
         * the LuminAR camera and tracking that image.
         *
         * @param  {string | Element | jQuery} el
         * The snapshot taken by the LuminAR HD camera will be cropped to this
         * element. This can be a CSS selector string, DOM element,
         * or jQuery object. This element must be rendered to the screen; it
         * can't have been hidden with `display: none` in CSS or with jQuery's
         * `.hide()`.
         *
         * @return {Lens.Marker} The newly-created Marker.
         */
        fromSnapshot: function(el) {
            check(el, check.Match.Elementish);

            var id = IDGenerator.uuid();
            var marker = Marker(id, null, null);

            Snapshot.capture(el, function(snapshot) {
                // register the marker
                registerMarker(id, snapshot.base64);

                // get the width and height of the snapshot
                var img = snapshot.toImage();
                marker._setOrigDims(img.width, img.height);
            });

            return marker;
        },

        /**
         * Stops tracking of all markers.
         */
        clear: function() {
            Dispatcher._bridge.fireEvent("marker_clear");
        }
    });

    $(window).unload(function() {
        Marker.clear();
    });

    Lens._addMember(Marker, "Marker");
    return Marker;
});