5
\$\begingroup\$

Directive that shows text as particles on a canvas. Looking for a general review. Plunker here.

image.service.js:

(function () {
    'use strict';

    angular
        .module('app')
        .factory('ImageUtil', ImageUtil);

    function ImageUtil () {
        function preloadImages(srcs) {
            function loadImage(src) {
                return new Promise(function(resolve, reject) {
                    let img = new Image();
                    img.onload = function() {
                        resolve(img);
                    };
                    img.onerror = img.onabort = function() {
                        reject(src);
                    };
                    img.src = src;
                });
            }
            return Promise.all(srcs.map(loadImage));
        }

        function* forEachPixel(context,  spacing) {
            let width  = context.canvas.width;
            let height = context.canvas.height;
            let pixels = context.getImageData(0, 0, width, height).data;

            for(let y = 0; y < height; y += spacing) {
                for (let x = 0; x < width; x += spacing) {
                    let i = (y * width + x) * 4;
                    let RGBA = {
                        R: pixels[i], G: pixels[i + 1],
                        B: pixels[i + 2], A: pixels[i + 3]
                    };
                    yield [x, y, RGBA];
                }
            }
        }

        function* forEachAlphaPixel(context,  spacing) {
            for(let a  of forEachPixel(context,  spacing)) {
                if(a[2].A > 0) {
                    yield a;
                }
            }
        }

        return {
            forEachPixel: forEachPixel,
            forEachAlphaPixel: forEachAlphaPixel,
            preloadImages: preloadImages
        };
    }
})();

shuffle.filter.js

(function () {
    'use strict';
    angular.module('app')
        .filter('shuffle', shuffle);

    function shuffle() {
        return function (a) {
            for (let i = a.length; i; i--) {
                let j = Math.floor(Math.random() * i);
                [ a[i - 1], a[j]] = [a[j], a[i - 1]];
            }
            return a;
        };
    }

})();

particle.service.js:

(function() {
    'use strict';
    angular
        .module('app')
        .factory('Particle', ParticleFactory);


    function ParticleFactory () {


        class Particle {
            constructor(x, y, originX, originY, color, atributes) {
                this.originalColor = this.color = color;
                this.originX       = originX;
                this.originY       = originY;
                this.atributes     = atributes;

                this.x  = x;
                this.y  = y;
                this.vx = 0;
                this.vy = 0;
            }

            inOrigin() {
                return Math.abs(this.originY - this.y) < 1
                    && Math.abs(this.originX - this.x) < 1;
            }

            update({x: mx, y: my}, r) {
                let rx = mx - this.x;
                let ry = my - this.y;
                let distance = rx * rx + ry * ry;

                if (distance < r) {
                    let force = -r / distance;
                    let angle = Math.atan2(ry, rx);
                    this.vx += force * Math.cos(angle);
                    this.vy += force * Math.sin(angle);
                }
                this.x += (this.vx *= this.atributes.friction)
                       + (this.originX - this.x)
                       * this.atributes.ease;

                this.y += (this.vy *= this.atributes.friction)
                       + (this.originY - this.y)
                       * this.atributes.ease;

                this.color = this.inOrigin()
                    ? this.originalColor
                    : this.atributes.motionColor;

                this.color = this.color
                    || this.originalColor;
            }

            reset(x, y, color) {
                this.originalColor = color;
                this.originX = x;
                this.originY = y;
            }

            draw(context) {
                context.fillStyle = this.color;
                context.fillRect(this.x, this.y,
                    this.atributes.size,
                    this.atributes.size);
            }
        }
        class ParticleBuilder {

            constructor() {
                this.x = this.originX = 0;
                this.y = this.originY = 0 ;
                this.color = '#000000';

                this.commonAttr = {
                    friction:  0.95,
                    ease: 0.1,
                    size:  3,
                    motionColor: undefined,
                };
            }

            build() {
                return new Particle(
                    this.x, this.y,
                    this.originX,
                    this.originY,
                    this.color,
                    this.commonAttr);
            }

            setColor(color) {
                this.color = color;
                return this;
            }

            setOriginX(x) {
                this.originX = x;
                return this;
            }
            setOriginY(y) {
                this.originY = y;
                return this;
            }

            setX(x) {
                this.x = x;
                return this;
            }

            setY(y) {
                this.y = y;
                return this;
            }

            setLocation (x, y) {
                return this
                    .setX(x)
                    .setY(y)
                    .setOriginX(x)
                    .setOriginY(y);
            }


            setMotionColor (color) {
                this.commonAttr.motionColor = color
                    ||  this.commonAttr.motionColor;
                return this;
            }

            setEase(ease) {
                this.commonAttr.ease = ease
                    ||  this.commonAttr.ease;
                return this;
            }

            setFriction(friction) {
                this.commonAttr.friction = friction
                    || this.commonAttr.friction;
                return this;
            }

            setSize(size) {
                this.commonAttr.size = size
                    || this.commonAttr.size;
                return this;
            }
        }


        class ParticleObserver {

            constructor(builder) {
                this.particles = [];
                this.builder = builder;
            }

            update(mouse, r) {
                for(let p of  this.particles) {
                    p.update(mouse, r);
                }
            }

            render(context) {
                let canvas = context.canvas;
                context.clearRect(0, 0, canvas.width,  canvas.height);
                for(let p of  this.particles) {
                    p.draw(context);
                }
            }

            reset(index, x, y, color) {
                if(index < this.particles.length) {
                    this.particles[index].reset(x, y, color);
                }
                else {
                    let p = this.builder
                        .setOriginX(x)
                        .setOriginY(y)
                        .setColor(color)
                        .build();
                    this.particles.push(p);
                }
            }

            resize(len) {
                this.particles.length = len;
            }

        }

        return {
            builder : () => new ParticleBuilder(),
            observer : builder => new ParticleObserver(builder)
        };
    }
})();

particle-animator.service.js:

(function() {
    'use strict';
    angular
        .module('app')
        .factory('ParticleAnimator', ParticleImageAnimatorService);

    ParticleImageAnimatorService.$inject = ['Particle', '$window', 'ImageUtil', 'shuffleFilter'];

    function ParticleImageAnimatorService (Particle, $window, ImageUtil, shuffleFilter) {

        class ParticleImage {
            constructor(builder, context) {
                this.observer  = Particle.observer(builder);
                this.context   = context;
                this.spacing   = 3;
                this.animating = false;
                this.color = null;
            }
            setSpacing(spacing){
                this.spacing = spacing
                    || this.spacing;
                return this;
            }

            setColor(color){
                this.color = color;
                return this;
            }

            reset(callback) {
                let context = this.context;
                callback(context);

                let height = context.canvas.height;
                let width  = context.canvas.width;
                shuffleFilter(this.observer.particles);
                this.observer.builder
                    .setX(Math.random() * width)
                    .setY(Math.random() * height);

                let gen = ImageUtil
                    .forEachAlphaPixel(context, this.spacing);
                let i = 0;
                for (let [x, y, {R, G, B, A}] of gen) {
                    let color = this.color || `rgba(${R},${G},${B},${A})`;
                    this.observer.reset(i++, x, y, color);
                }
                this.observer.resize(i);
                return this;
            }


            start(mouse, radius) {
                this.animating = true;
                animate(this, mouse, radius);
                return this;
            }

            stop() {
                this.animating = false;
                return this;
            }
        }

        function animate(self, mouse, radius) {
            if(!self.animating) {
                return;
            }
            self.observer.update(mouse, radius);
            self.observer.render(self.context);
            $window.requestAnimationFrame(
                ()=> animate(self, mouse, radius));
        }

        return {
            create : (builder, context) => new ParticleImage(builder, context),
        };
    }
})();

particle-text.directive.js:

angular.module('app')
    .directive("particleText", ParticleText);

ParticleText.$inject = ['Particle', 'ParticleAnimator', '$document'];

function ParticleText(Particle, ParticleAnimator, $document) {
    return {
        restrict: 'E',
        template: '<canvas/>',
        link: function(scope, element, attrs) {

            let canvas       = element.find('canvas')[0];
            let context      = canvas.getContext('2d');
            let r            = parseInt(attrs.radius, 10) || 20;
            let radius       =  r * r;
            let mouse        =  {x: canvas.width, y: canvas.height};
            let fontFamilly  =  attrs.fontFamilly || 'Arial';
            let fontSize     = parseInt(attrs.fontSize, 10) || 30;
            let padding      = parseInt(attrs.heightPadding, 10) || 3;
            let color        = attrs.color || 'black';
            let text         = attrs.text;
            canvas.height    = fontSize + padding;
            context.globalAlpha = 0.7;
            addEvents();


            let builder = Particle.builder()
                .setFriction(parseFloat(attrs.friction))
                .setEase(parseFloat(attrs.ease))
                .setSize(parseInt(attrs.size, 10))
                .setMotionColor(attrs.motionColor);

            let pia = ParticleAnimator.create(builder, context)
                .setSpacing(parseInt(attrs.spacing, 10))
                .setColor(color);


            function paintText(context) {
                let font     = `${fontSize}pt ${fontFamilly}`;
                context.font = font;
                let textSize = context.measureText(text);
                let height   = context.canvas.height;
                let width    = context.canvas.width = textSize.width;

                context.clearRect(0, 0, width, height);
                context.font = font;
                context.fillText(text,  0, (height / 2) + (fontSize/2));
            }

            function addEvents() {
                $document.bind("mousemove", onMouseMove);
                $document.bind("touchstart", onTouchStart, false);
                $document.bind("touchmove", onTouchMove, false);
                $document.bind("touchend", onTouchend, false);
            }

            function onDestroy() {
                $document.unbind("mousemove", onMouseMove);
                $document.unbind("touchstart", onTouchStart);
                $document.unbind("touchmove", onTouchMove);
                $document.unbind("touchend", onTouchend);
            }

            function setMouse(x, y) {
                var rect = canvas.getBoundingClientRect();
                mouse.x = x - rect.left;
                mouse.y = y - rect.top;
            }

            function onMouseMove(event) {
                setMouse(event.clientX, event.clientY);
            }

            function onTouchStart(event) {
                setMouse(event.changedTouches[0].clientX,
                    event.changedTouches[0].clientY);
            }

            function onTouchMove(event) {
                event.preventDefault();
                setMouse(event.targetTouches[0].clientX,
                    event.targetTouches[0].clientY);
            }
            function onTouchend(event){
                event.preventDefault();
                setMouse(0, 0);
            }


            attrs.$observe('text', function (interpolatedText) {
                text = interpolatedText;
                pia.reset(paintText);
            });
            pia.reset(paintText);
            pia.start(mouse, radius);
            scope.$on('$destroy', onDestroy);
        }
    };
}
\$\endgroup\$
  • \$\begingroup\$ Could you tell us what you want us to review? anything in particular? The result look great btw, good job! \$\endgroup\$ – Julien Rousé Jun 6 '17 at 7:04
  • 1
    \$\begingroup\$ Just a general review. Could be on angular best practices or any kind of improvement. \$\endgroup\$ – MAG Jun 6 '17 at 7:15
  • \$\begingroup\$ I can't help you much, I'm not very well versed in those language yet, but if you write it in your post maybe you'll find help quicker. Good luck! And I'll say it again, the result looks very nice! \$\endgroup\$ – Julien Rousé Jun 6 '17 at 7:18
3
\$\begingroup\$

There's a few files so I'll focus on a more general code review.

Overall it looks good! The code is well structured, readable and your logic is easy to follow.

ES6+

You're already using newer features such as generators and array matching but there's some extra features you can take advantage of:

Object matching

You could use object matching when your variable is the same as the property you're getting. E.g.:

let width  = context.canvas.width;
let height = context.canvas.height;
  // could become
let { width, height } = context.canvas;

Array matching

You're already taking advantage of array matching in a few places but you could use it even more if you prefer!

let canvas       = element.find('canvas')[0];
  // could become
let [canvas] = element.find('canvas')

I prefer the latter but it's completely personal preference, as is all of this!

Object shorthand

Since you are using the same names the object of methods returned from ImageUtil could be simplified

return {
    forEachPixel: forEachPixel,
    forEachAlphaPixel: forEachAlphaPixel,
    preloadImages: preloadImages
};
  // could become
return {
  forEachPixel,
  forEachAlphaPixel,
  preloadImages,
}

Angular: selector name

I can't speak for best practices on this one, but I'd recommend using a template name other than 'canvas' to avoid confusion with the HTML5 element.

const

You could benefit from declaring the variables you don't reassign as const. They will then throw a TypeError if you try and reassign them accidentally.

Returning early

Returning early can help with reducing the nested complexity, for example:

reset(index, x, y, color) {
    if(index < this.particles.length) {
        this.particles[index].reset(x, y, color);
    }
    else {
        let p = this.builder
            .setOriginX(x)
            .setOriginY(y)
            .setColor(color)
            .build();
        this.particles.push(p);
    }
}
  // could become
reset(index, x, y, color) {
    if(index < this.particles.length) {
        this.particles[index].reset(x, y, color);
        return;
    }

      const p = this.builder
          .setOriginX(x)
          .setOriginY(y)
          .setColor(color)
          .build();
      this.particles.push(p);
}

Getters and setters

You could use getters and setters instead of prefixing methods with 'get' and 'set'. E.g.:

setX(x) {
    this.x = x;
    return this;
}
  // could become
set x(x) {
  this._x = x;
}
  // called by: x.foo = 5

Though this might not be desired since you lose the advantage of chaining your setters in this case.

Variable names

In the above example, a particle p is built and pushed to the particles array. I'd recommend not abbrievating it and just naming it particles to avoid mental mapping.

Particle's update method could greatly benefit from this, while a lot can be understood from context it could potentially be more clear through using radius instead of r, etc.

Robert C. Martin wrote about this among many other topics in his book Clean Code.

See clean-code-javascript for some of the advice applied to JavaScript.

Recreating the wheel

Usually I'd recommend using an existing solution instead of recreating the wheel unless it's done as a learning exercise. For instance, lodash has a shuffle method.

Final comments

Again, the code looks good and I've mainly just highlighted some extra new features/things to keep the code consistent.

I'd recommend using a linter such as eslint and extending from a good style guide rules can spot these/other potential pitfalls. I recommend Airbnb's JavaScript Style Guide as a good starting point.

Linters can identify when you could replace let with const and lifesavers such as warning you when you don't use a variable you've declared - you might have returned the wrong value by accident!

You can use an online checker such as jshint if you don't want to setup a linter for now.

If anything needs clarifaction just let me know!

\$\endgroup\$
  • 1
    \$\begingroup\$ Note to OP: setX(x) returns this while set x(x) returns x \$\endgroup\$ – le_m Jun 7 '17 at 19:18

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.