Source: ProximityEffect.js

/*
 * ProximityEffect class by Adasha
 * Licensed under MPL-2.0
 * Repository: https://github.com/Adasha/proximity-effect
 * Demos: http://lab.adasha.com/proximity-effect
 */



let VALID_DIRECTIONS  = new Set(['both', 'horizontal', 'vertical']),
    DEFAULT_DIRECTION = 'both',
    DEFAULT_MODE      = 'redraw',
    DEFAULT_ACCURACY  =   5,
    DEFAULT_FPS       =  15,
    DEFAULT_RUNOFF    = 100,
    VALID_EFFECTS     = {
        opacity:         {min: 0, max:   1, default:       1, rule: 'opacity'},
        translateX:      {                  default:       0, rule: 'transform',       func: 'translateX',  unit: 'px'},
        translateY:      {                  default:       0, rule: 'transform',       func: 'translateY',  unit: 'px'},
        translateZ:      {                  default:       0, rule: 'transform',       func: 'translateZ',  unit: 'px'},
        rotate:          {                  default:       0, rule: 'transform',       func: 'rotate',      unit: 'deg'},
        rotateX:         {                  default:       0, rule: 'transform',       func: 'rotateX',     unit: 'deg'},
        rotateY:         {                  default:       0, rule: 'transform',       func: 'rotateY',     unit: 'deg'},
        rotateZ:         {                  default:       0, rule: 'transform',       func: 'rotateZ',     unit: 'deg'},
        scale:           {                  default:       1, rule: 'transform',       func: 'scale'},
        scaleX:          {                  default:       1, rule: 'transform',       func: 'scaleX'},
        scaleY:          {                  default:       1, rule: 'transform',       func: 'scaleY'},
        scaleZ:          {                  default:       1, rule: 'transform',       func: 'scaleZ'},
        skewX:           {                  default:       0, rule: 'transform',       func: 'skewX',       unit: 'deg'},
        skewY:           {                  default:       0, rule: 'transform',       func: 'skewY',       unit: 'deg'},
        //perspective:     {                  default:       0, rule: 'transform',       func: 'perspective', unit: 'px'},
        blur:            {min: 0,           default:       0, rule: 'filter',          func: 'blur',        unit: 'px'},
        brightness:      {min: 0,           default:     100, rule: 'filter',          func: 'brightness',  unit: '%'},
        contrast:        {min: 0,           default:     100, rule: 'filter',          func: 'contrast',    unit: '%'},
        grayscale:       {min: 0, max: 100, default:       0, rule: 'filter',          func: 'grayscale',   unit: '%'},
        hueRotate:       {                  default:       0, rule: 'filter',          func: 'hue-rotate',  unit: 'deg'},
        invert:          {min: 0, max: 100, default:       0, rule: 'filter',          func: 'invert',      unit: '%'},
        //opacity:         {min: 0, max: 100, default:     100, rule: 'filter',          func: 'opacity',     unit: '%'},
        saturate:        {min: 0, max: 100, default:     100, rule: 'filter',          func: 'saturate',    unit: '%'},
        sepia:           {min: 0, max: 100, default:       0, rule: 'filter',          func: 'sepia',       unit: '%'},

        backgroundColor: {min: 0, max: 255, default: [0,0,0], rule: 'backgroundColor', func: 'rgb',                      args: 3},
        scale3D:         {                  default: [1,1,1], rule: 'transform',       func: 'scale3D',                  args: 3}
    };







const constrain = (num, min, max) => {
    if(typeof num!=='number') return NaN;
    if(min!==undefined && min!==null && typeof min==='number') num = Math.max(num, min);
    if(max!==undefined && max!==null && typeof max==='number') num = Math.min(num, max);
    return num;
};

const roundTo = (num, dp=0) => {
    let mult = Math.pow(dp+1,10);
    return Math.round(num*mult)/mult;
};

const delta = (num, a, b) => (b-a)*constrain(num, 0, 1)+a;

const map = (num, inMin, inMax, outMin, outMax) => (num - inMin) * (outMax - outMin) / (inMax - inMin) + outMin;

const random = (v=2, m='uniform') => {
    switch(m)
    {
        case 'gaussian' :
        case 'normal' :
            let t = 0,
                c = 6;
            for(let i=0; i<c; i++)
            {
                t += (Math.random()-0.5)*v;
            }
            return t/c;
            break;
        case 'uniform' :
        default :
            return (Math.random()-0.5)*v;
    }
}

const XOR = (a, b) => (a || b) && !(a && b);

const isVisibleInViewport = (el) => {
    let bounds = el.getBoundingClientRect(),
        view   = document.documentElement;
    return bounds.right>=0 && bounds.left<=view.clientWidth && bounds.bottom>=0 && bounds.top<=view.clientHeight;
};

//const startTimer = (delay) =>

const valToObj = (val, key='value') => {
    let obj = {};
    obj[key] = val;
    return obj;
};

const isObject = obj => obj==Object(obj);






/**
 * Class representing a ProximityEffect.
 * @extends EventTarget
 */
class ProximityEffect extends EventTarget
{
    // TODO: Private variables once possible.

    /**
     * Create a ProximityEffect instance.
     * @param {NodeList} nodes - A list of nodes to control.
     * @param {Object} [params={}] - An object containing effect parameters.
     * @param {number} [params.threshold=0] - The effect threshold, in pixels.
     * @param {number} [params.runoff] - The effect runoff, in pixels.
     * @param {number} [params.attack=1] - The effect attack.
     * @param {number} [params.decay=1] - The effect decay.
     * @param {number} [params.accuracy] - The effect accuracy.
     * @param {boolean} [params.invert=false] - Invert distances.
     * @param {number} [params.offsetX=0] - The global horizontal offset, in pixels.
     * @param {number} [params.offsetY=0] - The global vertical offset, in pixels.
     * @param {number} [params.jitter=0] - The effect jitter, in pixels.
     * @param {string} [params.direction="both"] - The effect direction, one of 'both', 'horizontal' or 'vertical'.
     * @param {Element} [params.target] - The effect tracker target.
     * @param {boolean} [params.doPresetDistances=false] - Prime the initial distances to create an initial transition. Only available through params argument in constructor.
     * @fires ProximityEffect#ready
     * @fires ProximityEffect#redraw
     * @fires ProximityEffect#reflow
     */
    constructor(nodes, params = {})
    {
        super();

        if(!nodes) throw new Error('ProximityEffect: nodes argument is required');

        this.preventCenterCalculations = true;


        this._params = params;
        this.nodes = nodes;
        this._pointer = {};


        this.threshold = this._params.hasOwnProperty('threshold') ? this._params.threshold : 0;
        this.runoff    = this._params.hasOwnProperty('runoff')    ? this._params.runoff    : DEFAULT_RUNOFF;
        this.attack    = this._params.hasOwnProperty('attack')    ? this._params.attack    : 1;
        this.decay     = this._params.hasOwnProperty('decay')     ? this._params.decay     : 1;
        this.accuracy  = this._params.hasOwnProperty('accuracy')  ? this._params.accuracy  : DEFAULT_ACCURACY;
        //this.reverse   = this._params.reverse   || false;
        this.invert    = this._params.invert    || false;
        this.offsetX   = this._params.offsetX   || 0;
        this.offsetY   = this._params.offsetY   || 0;
        this.jitter    = this._params.jitter    || 0;
        this.direction = this._params.direction || DEFAULT_DIRECTION;
        this.FPS       = this._params.FPS       || DEFAULT_FPS;
        this.mode      = this._params.mode      || DEFAULT_MODE;
        this.target    = this._params.target;


       	if(document.readyState==='completed') this.init();
       	else window.addEventListener('load', () => this.init());
    }


    init()
    {
        this.preventCenterCalculations = false;
        this.setCenterPoints();

        this.update = this.update.bind(this);

        window.addEventListener('scroll', this.reflowEvent.bind(this));
        window.addEventListener('resize', this.reflowEvent.bind(this));

        document.addEventListener('mousemove', this.updatePointer.bind(this));

        // TODO: add alternative trigger modes

        document.dispatchEvent(new MouseEvent('mousemove'));
        this.dispatchEvent(new Event('ready'));
        window.requestAnimationFrame(this.update);
    }





    /////////////////////////////////
    //                             //
    //     GETTER/SETTER PROPS     //
    //                             //
    /////////////////////////////////



    /**
     * Get the current target
     * @return {Element|Falsy} The current target.
     */
    get target()
    {
        return this._params.target;
    }

    /**
     * Set the current target
     * @param {Element|Falsy} target - A reference to a DOM Element, or falsy to target mouse.
     */
    set target(target)
    {
        if(!target || target.getBoundingClientRect()) this._params.target = target;
        else return void console.log(`${target} is not a valid target`);
    }





    /**
     * Get the list of nodes.
     * @return {Array<Element>} The node array.
     */
    get nodes()
    {
        return this._nodes;
    }

    /**
     * Set the list of nodes.
     * @param {NodeList<Element>} list - The list of nodes.
     */
    set nodes(list)
    {
        if(!(list instanceof NodeList)) throw new Error(`${list} is not a node list`);
        if(list.length<1) throw new Error(`No nodes found in ${list}`);

        this._nodes = [].slice.call(list);
        this._nodeData = this._nodes.map(i => ({
            node:      i,
            style:     i.style.cssText,
            lastDelta: this._params.doPresetDistances ? 1 : null
        }));

		if(this._params && !this.preventCenterCalculations) this.setCenterPoints();
    }





    /**
     * Get the list of effects.
     * @return {Array<Object>} The effects array.
     */
    get effects()
    {
        return this._effects;
    }





    /**
     * Get the effect threshold.
     * @return {number} The threshold radius, in pixels.
     */
    get threshold()
    {
    	return this._params.threshold;
    }

    /**
     * Set the effect threshold.
     * @param {number} value - The new threshold radius, in pixels.
     */
    set threshold(value)
    {
    	this._params.threshold = constrain(value, 0);
    }





    /**
     * Get the effect runoff.
     * @return {number} The runoff radius, in pixels.
     */
    get runoff()
    {
    	return this._params.runoff;
    }

    /**
     * Set the effect runoff.
     * @param {number} value - The new runoff radius, in pixels.
     */
    set runoff(value)
    {
    	this._params.runoff = constrain(value, 0);
        this._invRunoff = 1/this._params.runoff;
    }





    /**
     * Get the effect boundary.
     * @return {number} The boundary radius, in pixels.
     */
    get boundary()
    {
    	return this.threshold + this.runoff;
    }





    /**
     * Get the invert state.
     * @return {boolean} The invert value.
     */
    get invert()
    {
        return this._params.invert;
    }

    /**
     * Set the invert state.
     * @param {boolean} flag - The new invert value.
     */
    set invert(flag)
    {
        this._params.invert = !!flag;
    }





    /**
     * Get the effect attack.
     * @return {number} The attack value.
     */
    get attack()
    {
    	return this._params.attack;
    }

    /**
     * Set the effect attack.
     * @param {number} value - The new attack value.
     */
    set attack(value)
    {
    	this._params.attack = constrain(value, 0, 1);
    }




    /**
     * Get the effect decay.
     * @return {number} The decay value.
     */
    get decay()
    {
    	return this._params.decay;
    }

    /**
     * Set the effect decay.
     * @param {number} value - The new decay value.
     */
    set decay(value)
    {
    	this._params.decay = constrain(value, 0, 1);
    }





    /**
     * Get the global horizontal offset.
     * @return {number} The offset value, in pixels.
     */
  	get offsetX()
  	{
        return this._params.offsetX;
  	}

    /**
     * Get the global vertical offset.
     * @return {number} The offset value, in pixels.
     */
 	get offsetY()
  	{
        return this._params.offsetY;
  	}



    /**
     * Set the global horizontal offset.
     * @param {number} value - The new offset value, in pixels.
     */
  	set offsetX(value)
  	{
        this._params.offsetX = value;
        if(!this.preventCenterCalculations) this.setCenterPoints();
  	}

    /**
     * Set the global vertical offset, in pixels.
     * @param {number} value - The new offset value.
     */
  	set offsetY(value)
  	{
        this._params.offsetY = value;
        if(!this.preventCenterCalculations) this.setCenterPoints();
  	}





    /**
     * Get the jitter value.
     * @return {number} The jitter value, in pixels.
     */
  	get jitter()
  	{
        return this._params.jitter;
  	}

    /**
     * Set the jitter value.
     * @param {number} num - The new jitter value, in pixels.
     */
  	set jitter(num)
  	{
        this._params.jitter = constrain(num, 0);
        for(let i=0; i<this.nodes.length; i++)
        {
            this._setNodeIndexData(i, 'jitter', {
                x: random(this.jitter),
                y: random(this.jitter)
            });
        }
        if(!this.preventCenterCalculations) this.setCenterPoints();
  	}





    /**
     * Get the effect direction.
     * @return {string} The direction value.
     */
    get direction()
    {
        return this._params.direction;
    }

    /**
     * Set the effect direction.
     * @param {string} str - The new direction value, both|horizontal|vertical.
     */
    set direction(str)
    {
        if(VALID_DIRECTIONS.has(str))
        {
        	this._params.direction = str;
        }
        else return void console.log(`${str} not a valid direction.`);
    }





    // FPS [Number>0]

    get FPS()
    {
    	return this._params.FPS;
    }

    set FPS(num)
    {
        if(num>0)
        {
            this._params.FPS = constrain(num, 0);
        }
        else return void console.log('Invalid FPS requested.');
    }





    // MODE [String]

    get mode()
    {
        return this._params.mode;
    }

    set mode(mode)
    {
        if(mode)
        {
            //alert(this._params.mode);
            if(mode===this._params.mode) return void console.log(`Already in ${mode} mode. Mode not changed.`);

            let b = document.body;
        	// b.removeEventListener('mousemove',  this.update());
        	// b.removeEventListener('enterframe', this.update());
        	// window.clearInterval(this._frameLoop);

            switch(mode)
        	{
        		case 'mousemove' :
        			b.addEventListener('mousemove', this.update());
        			break;
        		case 'enterframe' :
        			b.addEventListener('enterframe', this.update());
        			this._frameLoop = window.setInterval(() =>
        				b.dispatchEvent(new Event('enterframe'))
        			, 1000/this.FPS);
        			break;
        		case 'redraw' :
        			break;
                default :
                    return void console.log(`${mode} is not a recognised mode.`);
        	}

            this._params.mode = mode;
        }
    }





    /**
     * Get the effect accuracy.
     * @return {number} The accuracy value.
     */
    get accuracy()
    {
        return this._params.accuracy;
    }

    /**
     * Set the effect accuracy.
     * @param {number} num - The new accuracy value.
     */
    set accuracy(num)
    {
        this._params.accuracy = Math.floor(constrain(num, 0));
    }





    /**
     * Get the last known mouse pointer coordinates, relative to the viewport, in pixels.
     * @return {Object} An object containing x and y properties.
     */
    get pointer()
    {
        return {
            x: this._pointer.x,
            y: this._pointer.y
        }
    }







    ////////////////////////////
    //                        //
    //     PUBLIC METHODS     //
    //                        //
    ////////////////////////////



    /**
     * Add a new effect to the effect stack.
     * @param {string} name - The effect name.
     * @param {number|Object} near - The effect value at the closest distance.
     * @param {number} near.value - The effect value at closest distance, as an object property.
     * @param {number} [near.scatter] - The random distribution of the value at the closest distance.
     * @param {string} [near.scatterMethod] - The random scatter method.
     * @param {number|Object} far - The effect value at the furthest distance.
     * @param {number} far.value - The effect value at furthest distance, as an object property.
     * @param {number} [far.scatter] - The random distribution of the value at the furthest distance.
     * @param {string} [far.scatterMethod] - The random scatter method.
     * @param {Object} [params] - An object containing additional effect parameters.
     * @param {string} [params.rule] - The CSS style rule to use.
     * @param {string} [params.func] - The CSS function of the given style rule.
     * @param {number} [params.min] - The minimum effect value.
     * @param {number} [params.max] - The maximum effect value.
     * @param {number} [params.default] - The default effect value.
     * @param {string} [params.unit] - The effect CSS unit.
     */
    addEffect(name, near, far, params)
    {
        if(VALID_EFFECTS.hasOwnProperty(name)) // TODO: how necessary is this really?
        {
            /** Effect already exists **/
            params = VALID_EFFECTS[name];
        }
        else if(params && isObject(params) && typeof params.rule==='string') // TODO: do we need any deeper validation checks?
        {
            /** Register custom effect **/
            VALID_EFFECTS[name] = params;
        }
        else return void console.log(`${name} is not a valid effect type`);

        if(typeof near==='number') near = valToObj(constrain(near, params.min, params.max));
        if(typeof far==='number') far = valToObj(constrain(far, params.min, params.max));


        this._effects = this._effects || [];
        this._effects.push({
            type: name,
            near: near,
            far:  far,
            params: params
        });


        let scatMeth = params.scatterMethod || 'uniform';
        for(let i=0; i<this._nodeData.length; i++)
        {
            let effects = this.getNodeIndexData(i, 'effects') || this._setNodeIndexData(i, 'effects', [])['effects'];
            effects.push({
                near: near.scatter ? near.value+random(near.scatter, scatMeth) : near.value,
                far:   far.scatter ?  far.value+random( far.scatter, scatMeth) :  far.value
            });
        }
    }



    /**
     * Check if a named effect is already on the stack.
     * @param {string} name - The name of the effect to check for.
     * @return {boolean} True if the effect exists at least once.
     */
    hasEffect(name)
    {
        return this.effects.find(eff => eff['type']===name)!==undefined;
    }



    /**
     * Remove all instances of an effect from the stack.
     * @param {string} name - The name of the effect to remove.
     */
    removeEffect(name)
    {
        if(this.hasEffect(name))
        {
            for(let i=0; i<this.effects.length; i++)
            {
                let eff = this.effects[i];
                if(eff['type']===name)
                {
                    this.effects.splice(i, 1);
                }
            }
        }
    }





    /**
     * Get the distance to the current target from the given node, in pixels.
     * @param {Element} n - The node to check.
     */
    distanceFrom(n)
    {
        return this.getNodeData(n, 'distance');
    }



    /**
     * Get the distance to the current target from the given node index, in pixels.
     * @param {number} i - The node index to check.
     */
    distanceFromIndex(i)
    {
        return this.getNodeIndexData(i, 'distance');
    }





    /**
     * Recalculate each node's centre point, including global offset and jitter.
     */
    setCenterPoints()
    {
    	for(let n=0; n<this.nodes.length; n++)
    	{
    		let node   = this.nodes[n],
                cssTxt = node.style.cssText;

            node.style.cssText = this.getNodeIndexData(n, 'style');

    		let bounds = node.getBoundingClientRect(),
                x      = (bounds.left+bounds.right )*0.5 - this.offsetX,
                y      = (bounds.top +bounds.bottom)*0.5 - this.offsetY,
                jitter = this.getNodeIndexData(n, 'jitter');

            if(jitter && this.jitter>0)
            {
                x += jitter.x;
                y += jitter.y;
            }

            node.style.cssText = cssTxt;
    		this._setNodeIndexData(n, 'center', {x: x, y: y});
        }
    }





    /**
     * @typedef {Object} NodeData
     * @property {Element} node - A reference to the node.
     * @property {Array<Object>} effects - An array of applied effects containing near and far values for each.
     * @property {number} effects[].near - Did this work?.
     */
    /**
     * Return an object containing the given node's effect data or a specific property of that data.
     * @param {Element} n - The node to return data for.
     * @param {string} [prop] - The data property to return, leave out to return the entire object.
     * @return {mixed|NodeData} The chosen property value, or an object containing the node's data.
     */
    getNodeData(n, prop)
    {
        let data = this._nodeData[this.nodes.findIndex(n => n===node)];
        return prop ? data[prop] : data;
    }



    /**
     * Return an object containing the given node index's effect data.
     * @param {number} i - The node index to return data for.
     * @param {string} prop - The data property to return.
     * @return {Object} True if the property exists, false otherwise.
     */
    getNodeIndexData(i, prop)
    {
        return this._nodeData[i][prop];
    }




    /**
     * Return a boolean determining if the given node has thegiven data.
     * @param {number} i - The node index to return data for.
     * @param {string} prop - The data property to return.
     * @return {boolean} An object containing the node's data.
     */
    hasNodeIndexData(i, prop)
    {
        return this._nodeData[i].hasOwnProperty(prop);
    }





	///////////////////////////////
    //                           //
	//     'PRIVATE' METHODS     //
    //                           //
	///////////////////////////////



    _setNodeIndexData(n, prop, val)
    {
        if(!this._nodeData[n]) this._nodeData[n] = {};
        this._nodeData[n][prop] = val;
        return this._nodeData[n];
    }







    ////////////////////
    //                //
    //     EVENTS     //
    //                //
    ////////////////////



    updatePointer(evt)
    {
        this._pointer.x = evt.clientX;
        this._pointer.y = evt.clientY;
    }





    reflowEvent(evt)
    {
        if(evt.currentTarget!==this) this.dispatchEvent(new Event('reflow'));

        // TODO: is this a hack? or the best way to do it?
        if(!this.preventCenterCalculations) window.setTimeout(() => this.setCenterPoints(), 1);
    }





    update(timestamp)
    {
        let view = document.documentElement;

        for(let n=0; n<this.nodes.length; n++)
        {
            let node    = this.nodes[n],
                bounds  = node.getBoundingClientRect(),
                center  = this.getNodeIndexData(n, 'center');

			let centerX = center.x - (node.dataset['offsetx']||0),
                centerY = center.y - (node.dataset['offsety']||0);

            let tx, ty,
                last = this.getNodeIndexData(n, 'lastDelta');


            if(this.target)
            {
                let b = this.target.getBoundingClientRect();
                tx = (b.left + b.right)*0.5;
                ty = (b.top + b.bottom)*0.5;
            }
            else
            {
                tx = this.pointer.x;
                ty = this.pointer.y;
            }


            let dx = tx - centerX,
                dy = ty - centerY,
                dd, td, d;

            if(this.direction==='both') dd = Math.sqrt(dx*dx+dy*dy);
            else dd = Math.abs(this.direction==='horizontal' ? dx : dy);

    		td = constrain((dd-this.threshold)*this._invRunoff, 0, 1);
            if(this.invert) td = 1 - td;

            this._setNodeIndexData(n, 'distance', td);


            d = last+(td-last)*(XOR(td>last, this.invert) ? this.decay : this.attack);

            d = roundTo(d, this.accuracy);
            this._setNodeIndexData(n, 'lastDelta', d);


			if(this.effects.length>0)
			{
    			let styles = {};

    			for(let f=0; f<this._effects.length; f++)
    			{
    				let effect   = this._effects[f],
                        nodeVals = this.getNodeIndexData(n, 'effects')[f];

                    let near     = nodeVals.near,
                        far      = nodeVals.far,
                        rule     = effect.params.rule,
                        func     = effect.params.func,
                        unit     = effect.params.unit || '',
                        val      = delta(d, near, far);


                    if(!func)
                    {
                        node.style[rule] = `${val}${unit}`;
                    }
                    else
                    {
                        if(!styles[rule]) styles[rule] = [];
                        styles[rule].push(func+'('+val+unit+')');
                    }
    			}
                for(let rule in styles)
                {
                  node.style[rule] = styles[rule].join(' ');
                }
                let ix = Math.floor(d*1000);
                node.style.zIndex = this.invert ? ix : 1000-ix;
            }
        }

        if(this.mode==='redraw') window.requestAnimationFrame(this.update);

        this.dispatchEvent(new Event('redraw'));

    } // update end

}