graphics/webimage.js

import Thing from './thing.js';

const UNDEFINED = -1;
const NOT_LOADED = 0;

const NUM_CHANNELS = 4;
const RED = 0;
const GREEN = 1;
const BLUE = 2;
const ALPHA = 3;

/**
 * A WebImage is used to display an image from a URL or underlying image data.
 * @class
 * @extends Thing
 */
class WebImage extends Thing {
    type = 'WebImage';
    /**
     * @constructor
     * @param {string} filename - Filepath to the image
     * @example
     * const image = new WebImage('https://en.wikipedia.org/static/images/project-logos/enwiki.png');
     */
    constructor(filename) {
        super();
        if (typeof filename !== 'string') {
            throw new TypeError(
                `You must pass a string to \`new WebImage(filename)\` that has the image\'s URL. Received type ${typeof filename}`
            );
        }

        this.setImage(filename);
        /**
         * used to indicate that the internal .data is out of sync with
         * the _hiddenCanvas. when out of sync, the _hiddenCanvas must be
         * updated before drawing
         * @type {boolean}
         * @private
         */
        this._hiddenCanvasOutOfSync = false;
        /**
         * Indicates whether the image has already perfomed initial load
         * @type {boolean}
         */
        this.imageLoaded = false;
    }

    /**
     * Set a function to be called when the WebImage is loaded.
     *
     * @param {function} callback - A function
     */
    loaded(callback) {
        if (this.imageLoaded) {
            callback();
        }
        this.loadfn = callback;
    }

    /**
     * Set the image of the WebImage.
     *
     * @param {string} filename - Filepath to the image
     */
    setImage(filename) {
        if (typeof filename !== 'string') {
            throw new TypeError(
                `You must pass a string to \`setImage(filename)\` that has the image\'s URL. Received type ${typeof filename}`
            );
        }

        this._hiddenCanvas = document.createElement('canvas');
        this._hiddenCanvas.width = 1;
        this._hiddenCanvas.height = 1;
        if (this.image) {
            // if this WebImage had an existing image, it may have an unresolved onload callback.
            // dont allow original callback to resolve, since it might attempt to load pixel data
            // from a potentially empty canvas.
            this.image.onload = null;
        }
        this.image = new Image();
        this.image.crossOrigin = 'anonymous';
        this.image.src = filename;
        this.filename = filename;
        this.width = null;
        this.height = null;
        this.data = NOT_LOADED;
        this.image.onload = () => {
            this.imageLoaded = true;
            this.checkDimensions();
            this.loadPixelData();
            if (this.loadfn) {
                this.loadfn();
            }
        };
    }

    /**
     * Reinforce the dimensions of the WebImage based on the image it displays.
     */
    checkDimensions() {
        if (this.width === null || this.height === null) {
            this.width = this.image.width;
            this.height = this.image.height;
        }
    }

    /**
     * Draws the WebImage in the canvas.
     *
     * @param {CanvasRenderingContext2D} context - Context to be drawn on.
     */
    draw(context) {
        if (this.data === NOT_LOADED) {
            return;
        }
        if (this._hiddenCanvasOutOfSync) {
            this.updateHiddenCanvas();
        }
        super.draw(context, () => {
            context.beginPath();
            // the __hiddenCanvas contains the ImageData, sized as it originally was.
            // in order to perform scaling, the destination width and height are
            // currentWidth * (currentWidth / originalWidth),
            // meaning the current size times the amount the size has changed
            context.drawImage(
                this._hiddenCanvas,
                0,
                0,
                (this.width * this.width) / this.data.width,
                (this.height * this.height) / this.data.height
            );
            context.closePath();
        });
    }

    /**
     * Return the underlying ImageData for this image.
     * Read more at https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getImageData
     */
    loadPixelData() {
        if (this.data === NOT_LOADED) {
            this._hiddenCanvas.width = this.width;
            this._hiddenCanvas.height = this.height;
            const context = this._hiddenCanvas.getContext('2d');
            context.drawImage(this.image, 0, 0, this.width, this.height);
            this.data = context.getImageData(0, 0, this.width, this.height);
            this._hiddenCanvasOutOfSync = false;
        }
        return this.data;
    }

    /**
     * Checks if the passed point is contained in the WebImage.
     *
     * @alias WebImage#containsPoint
     * @param {number} x - The x coordinate of the point being tested.
     * @param {number} y - The y coordinate of the point being tested.
     * @returns {boolean} Whether the passed point is contained in the WebImage.
     */
    _containsPoint(x, y) {
        x += this.width * this.anchor.horizontal;
        y += this.height * this.anchor.vertical;
        return x >= this.x && x <= this.x + this.width && y >= this.y && y <= this.y + this.height;
    }

    /**
     * Gets the width of the WebImage.
     *
     * @returns {number} Width of the WebImage.
     */
    getWidth() {
        return this.width;
    }

    /**
     * Gets the height of the WebImage.
     *
     * @returns {number} Height of the WebImage.
     */
    getHeight() {
        return this.height;
    }

    /**
     * Sets the size of the WebImage.
     *
     * @param {number} width - The desired width of the resulting WebImage.
     * @param {number} height - The desired height of the resulting WebImage.
     */
    setSize(width, height) {
        if (arguments.length !== 2) {
            throw new Error('You should pass exactly 2 arguments to `setSize(width, height)`.');
        }
        if (typeof width !== 'number' || !isFinite(width)) {
            throw new TypeError(`Invalid value for \`width\`. Received type ${typeof width}`);
        }
        if (typeof height !== 'number' || !isFinite(height)) {
            throw new TypeError(`Invalid value for \`height\`. Received type ${typeof height}`);
        }
        this.width = Math.max(0, width);
        this.height = Math.max(0, height);
        this._hiddenCanvasOutOfSync = true;
    }

    /* Get and set pixel functions */

    /**
     * Gets a pixel at the given x and y coordinates.
     * Read more here:
     * https://developer.mozilla.org/en-US/docs/Web/API/ImageData/data
     *
     * @param {number} x - The x coordinate of the point being tested.
     * @param {number} y - The y coordinate of the point being tested.
     * @returns {array} An array of 4 numbers representing the (r,g,b,a) values
     *                     of the pixel at that coordinate.
     */
    getPixel(x, y) {
        if (this.data === NOT_LOADED || x > this.width || x < 0 || y > this.height || y < 0) {
            return [UNDEFINED, UNDEFINED, UNDEFINED, UNDEFINED];
        } else {
            const index = NUM_CHANNELS * (y * this.width + x);
            const pixel = [
                this.data.data[index + RED],
                this.data.data[index + GREEN],
                this.data.data[index + BLUE],
                this.data.data[index + ALPHA],
            ];
            return pixel;
        }
    }

    /**
     * Get the red value at a given location in the image.
     *
     * @param {number} x - The x coordinate of the point being tested.
     * @param {number} y - The y coordinate of the point being tested.
     * @returns {integer} An integer between 0 and 255.
     */
    getRed(x, y) {
        return this.getPixel(x, y)[RED];
    }

    /**
     * Get the green value at a given location in the image.
     *
     * @param {number} x - The x coordinate of the point being tested.
     * @param {number} y - The y coordinate of the point being tested.
     * @returns {integer} An integer between 0 and 255.
     */
    getGreen(x, y) {
        return this.getPixel(x, y)[GREEN];
    }

    /**
     * Get the blue value at a given location in the image.
     *
     * @param {number} x - The x coordinate of the point being tested.
     * @param {number} y - The y coordinate of the point being tested.
     * @returns {integer} An integer between 0 and 255.
     */
    getBlue(x, y) {
        return this.getPixel(x, y)[BLUE];
    }

    /**
     * Get the alpha value at a given location in the image.
     *
     * @param {number} x - The x coordinate of the point being tested.
     * @param {number} y - The y coordinate of the point being tested.
     * @returns {integer} An integer between 0 and 255.
     */
    getAlpha(x, y) {
        return this.getPixel(x, y)[ALPHA];
    }

    /**
     * Set the `component` value at a given location in the image to `val`.
     *
     * @param {number} x - The x coordinate of the point being tested.
     * @param {number} y - The y coordinate of the point being tested.
     * @param {integer} component - Integer representing the color value to
     * be set. R, G, B = 0, 1, 2, respectively.
     * @param {integer} val - The desired value of the `component` at the pixel.
     * Must be between 0 and 255.
     */
    setPixel(x, y, component, val) {
        if (this.data !== NOT_LOADED && !(x < 0 || y < 0 || x > this.width || y > this.height)) {
            // Update the pixel value
            const index = NUM_CHANNELS * (y * this.width + x);
            this.data.data[index + component] = val;
            this._hiddenCanvasOutOfSync = true;
        }
    }

    /**
     * Set the red value at a given location in the image to `val`.
     *
     * @param {number} x - The x coordinate of the point being tested.
     * @param {number} y - The y coordinate of the point being tested.
     * @param {integer} val - The desired value of the red component at the pixel.
     * Must be between 0 and 255.
     */
    setRed(x, y, val) {
        this.setPixel(x, y, RED, val);
    }

    /**
     * Set the green value at a given location in the image to `val`.
     *
     * @param {number} x - The x coordinate of the point being tested.
     * @param {number} y - The y coordinate of the point being tested.
     * @param {integer} val - The desired value of the green component at the pixel.
     * Must be between 0 and 255.
     */
    setGreen(x, y, val) {
        this.setPixel(x, y, GREEN, val);
    }

    /**
     * Set the blue value at a given location in the image to `val`.
     *
     * @param {number} x - The x coordinate of the point being tested.
     * @param {number} y - The y coordinate of the point being tested.
     * @param {integer} val - The desired value of the blue component at the pixel.
     * Must be between 0 and 255.
     */
    setBlue(x, y, val) {
        this.setPixel(x, y, BLUE, val);
    }

    /**
     * Set the alpha value at a given location in the image to `val`.
     *
     * @param {number} x - The x coordinate of the point being tested.
     * @param {number} y - The y coordinate of the point being tested.
     * @param {integer} val - The desired value of the alpha component at the
     * pixel.
     * Must be between 0 and 255.
     */
    setAlpha(x, y, val) {
        this.setPixel(x, y, ALPHA, val);
    }

    /**
     * Replace the underlying ImageData of the WebImage with an instance of the ImageData class.
     * @example
     * const imageData = new ImageData(
     *   new UInt8ClampedArray([255, 0, 0, 255]),
     *   1,
     *   1
     * );
     * const img = new Webimage('www.whatever.com');
     * img.setImageData(imageData);
     * add(img);
     *
     * @param {ImageData} imageData
     */
    setImageData(imageData) {
        this.image = null;
        this.data = imageData;
        this.width = imageData.width;
        this.height = imageData.height;
        this._hiddenCanvasOutOfSync = true;
    }

    /**
     * Update the hidden canvas with the instance's current data.
     * This is automatically called after operations that modify ImageData.
     */
    updateHiddenCanvas() {
        this._hiddenCanvas.width = Math.max(this._hiddenCanvas.width, this.width);
        this._hiddenCanvas.height = Math.max(this._hiddenCanvas.height, this.height);
        const context = this._hiddenCanvas.getContext('2d');
        context.putImageData(this.data, 0, 0);
        this._hiddenCanvasOutOfSync = false;
    }
}

export default WebImage;