To celebrate an important event, I hastily cobbled together an HTML canvas-based animation.

A few concerns I have include:

  • Performance: Does it run reasonably smoothly on most modern machines? How can I make it more efficient?
  • Portability / Compatibility: Does it work correctly on all modern browsers (excluding old versions of Internet Explorer)?
  • Modelling: Is this a good way to simulate fireworks? Is there anything I could do to enhance the realism?

While we normally don't say "thanks" on Stack Exchange questions, I'd like to break that rule right now and say a big Thank you! to all members of the Code Review community.

function animate(selector) {
    var $canvas = $(selector);
    var width = $canvas.innerWidth();
    var height = $canvas.innerHeight();

    /* Based on https://en.wikipedia.org/wiki/HSL_and_HSV#From_HSL */
    /* hue ∈ [0, 2π), saturation ∈ [0, 1], lightness ∈ [0, 1] */
    var fromHSL = function fromHSL(hue, saturation, lightness) {
        var c = (1 - Math.abs(2 * lightness - 1)) * saturation;
        var h = 3 * hue / Math.PI;
        var x = c * (1 - (h % 2 - 1));
        var r1 = (h < 1 || 5 <= h) ? c
               : (h < 2 || 4 <= h) ? x
               : 0;
        var g1 = (1 <= h && h < 3) ? c
               : (h < 4) ? x
               : 0;
        var b1 = (3 <= h && h < 5) ? c
               : (2 <= h) ? x
               : 0;
        var m = lightness - c / 2;
        var r = Math.floor(256 * (r1 + m));
        var g = Math.floor(256 * (g1 + m));
        var b = Math.floor(256 * (b1 + m));
        /*
        console.log('hsl(' + hue + ', ' + saturation + ', ' + lightness +
                    ') = rgb(' + r + ', ' + g + ', ' + b + ')');
        */
        return 'rgb(' + r + ', ' + g + ', ' + b + ')';
    };

    var fireworksFactory = function fireworksFactory() {
        var centerX = (0.2 + 0.6 * Math.random()) * width;
        var centerY = (0.1 + 0.4 * Math.random()) * height;
        var color = fromHSL(2 * Math.PI * Math.random(), Math.random(), 0.9);
        return new Firework(centerX, centerY, color);
    };

    var fireworks = [fireworksFactory()];
    var animation = new Animation($canvas, fireworks, fireworksFactory);
    animation.start();
    return animation;
}

function fillBanner(selector) {
    $(selector).text(atob('SGFwcHkgZ3JhZHVhdGlvbiwgQ29kZSBSZXZpZXchIENvbmdyYXR1bGF0aW9ucyE='));
}

//////////////////////////////////////////////////////////////////////

function Animation($canvas, objects, factory) {
    this.canvas = $canvas.get(0);
    this.canvasContext = this.canvas.getContext('2d');
    this.objects = objects;
    this.factory = factory;
}

Animation.prototype.start = function start() {
    var canvas = this.canvas;
    var context = this.canvasContext;
    var objects = this.objects;
    var factory = this.factory;

    var redraw = function redraw() {
        context.clearRect(0, 0, canvas.width, canvas.height);
        for (var f = objects.length - 1; f >= 0; f--) {
            var particles = objects[f].particles;
            for (var p = particles.length - 1; p >= 0; p--) {
                var particle = particles[p];
                context.beginPath();
                context.arc(particle.x, particle.y, particle.size, 0, 2 * Math.PI, false);
                context.fillStyle = particle.color;
                context.fill();
            }
            objects[f].update();
        }
    };

    var launch = function launch() {
        objects.push(factory());
        while (objects.length > 4) {
            objects.shift();
        }
    };

    this.redrawInterval = setInterval(redraw, 25 /* ms */);
    this.factoryInterval = setInterval(launch, 1500 /* ms */);
}

Animation.prototype.stop = function stop() {
    clearInterval(this.redrawInterval);
    clearInterval(this.factoryInterval);
}

//////////////////////////////////////////////////////////////////////

function Firework(centerX, centerY, color) {
    this.centerX = centerX;
    this.centerY = centerY;
    this.color = color;
    this.particles = new Array(500);
    this.Δr = 20;
    this.age = 0;

    var τ = 2 * Math.PI;
    for (var i = 0; i < this.particles.length; i++) {
        this.particles[i] = new Particle(
            this.centerX, this.centerY,
            /* r= */ 0, /* θ= */ τ * Math.random(), /* φ= */ τ * Math.random(),
            /* size= */ 2, color
        );
    }
}

Firework.prototype.update = function update() {
    for (var i = 0; i < this.particles.length; i++) {
        this.particles[i].r += this.Δr;
        this.particles[i].recalcCartesianProjection();

        this.Δr -= 0.00005 * this.Δr * this.Δr;                     // Air resist
        this.particles[i].y += 0.00000008 * this.age * this.age;   // Gravity
        this.particles[i].size *= 0.98;                            // Fade
        this.age++;
    }
};

//////////////////////////////////////////////////////////////////////

function Particle(x, y, r, θ, φ, size, color) {
    this.origX = x;
    this.origY = y;
    this.r = r;
    this.sinθ = Math.sin(θ);
    // this.cosθ = Math.cos(θ);         // Not needed
    this.sinφ = Math.sin(φ);
    this.cosφ = Math.cos(φ);
    this.size = size;
    this.color = color;
    this.recalcCartesianProjection();
}

Particle.prototype.recalcCartesianProjection = function() {
    this.x = this.origX + this.r * this.sinθ * this.cosφ;
    this.y = this.origY + this.r * this.sinθ * this.sinφ;
};
canvas {
    background: black;
    background: linear-gradient(to bottom, black, rgba(0,0,99,0) 400%);
}
div.marquee {
    white-space: nowrap;
    position: absolute;
    top: 60px;
    -webkit-animation: flyby 15s linear infinite;
    animation: flyby 15s linear infinite;
}
@-webkit-keyframes flyby {
    from {
        left: 640px;
    }
    to {
        left: -640px;
    }
}
@keyframes flyby {
    from {
        left: 640px;
    }
    to {
        left: -640px;
    }
}
div.marquee img {
    display: inline-block;
}
div.marquee div {
    display: inline-block;
    position: relative;
    top: -0.8em;
    font: small-caps bold 18px Optima, Futura, sans-serif;
    background: orange;
    padding: 2px 10px;
}
<!DOCTYPE HTML>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Animation</title>
    <link rel="stylesheet" type="text/css" href="celebrate.css">
  </head>
  <body>
    <div id="viewport" style="width: 640px; height: 480px;">
      <canvas id="sky" width="640" height="480"></canvas>
      <!-- Based on public domain image
           https://pixabay.com/en/aeroplane-aircraft-airplane-flight-161999/ -->
      <div class="marquee">
        <img src="https://i.stack.imgur.com/bGZ1m.png" width="80" height="43">
        <div id="banner">Using an incompatible browser? No celebration for you.</div>
      </div>
    </div>
    <script type="text/javascript" src="https://code.jquery.com/jquery-1.11.1.min.js"></script>
    <script type="text/javascript" src="celebrate.js"></script>
    <script type="text/javascript">
        $(function() {
            fillBanner('#banner');
            var anim = animate('#sky');
            setTimeout(function() { anim.stop(); }, 60000);
        });
    </script>
  </body>
</html>

  • 5
    I would put a bounty on this question just to thanks everyone that worked on this wonderful site! This question is awesome! Can't wait to see a review ! – Marc-Andre Sep 29 '14 at 19:07
  • 11
    The beauty of Code Review, you can make working code about just anything and get it reviewed :) – Simon Forsberg Sep 29 '14 at 19:22
  • 2
    I think a good answer should involve an equally clever code snippet! – janos Sep 29 '14 at 19:57
  • 4
    It even animates smoothly on my iPhone (5c). I think that should cover all the desktops you would want to support. – 11684 Sep 29 '14 at 20:31
  • 6
    Bug report (minor): neither the plane nor the banner catch fire when a shell explodes directly underneath them. – AShelly Sep 29 '14 at 22:00
up vote 31 down vote accepted

Performance

It works fine for me in Opera and Chrome, but it is buggy in Firefox.

Profiling reveals that redraw is responsible (big surprise :) ), and there is not much to optimize without changing the whole concept. Two minor optimizations might be:

  • save 2 * Math.PI in a constant.
  • assign context.fillStyle = particle.color; outside the particles loop (all particles of one object have the same color).

But this isn't enough to make it run lag-free in Firefox for me. It works fine with max 3 fireworks and 300 particles (although a bit boring).

Realism

There are a lot of things that could be done to make it more realistic (with a lot of work, and probably heavy performance costs), but I think that your version is quite good. It doesn't actually look that much like real firework, but it's a great representation. It looks good and everybody knows that it's supposed to be fireworks.

The main difference to real fireworks is that the particles of real fireworks don't look like dots most of the time, but like lines (or drops, especially when falling down).

Also, real fireworks tend to come in groups of explosions, not in timed intervals (because it's more exciting to see one giant scene instead of a steady stream of small once). This is not something I would aim for in an animation though.

Naming

  • objects is a bit generic, I think. drawableObjects would be a bit clearer, but as it expects fireworks anyways, I would go with fireworks.
  • launch also might be a bit generic. launchFireworks would make it clear that it adds a new firework (as opposed to say launch the animation, or launch a new particle).
  • I would use fireworks instead of f (and maybe particle instead of p) in redraw.

Misc

  • it's fine to hardcode math factors, but if you put the other magic numbers in fields (interval length, number of particles, number of fireworks, time of whole animation, etc), it would be easier to test/change configurations.

Portability / Compatibility: Does it work correctly on all modern browsers (excluding old versions of Internet Explorer)?

In Safari, there is a cute tingly-shiny (don't know how to call it) effect as the fully spread pieces fade out. In Chrome, either there is no such effect, or it's so faint it's not visible. (I'm on a Mac.) (Sorry, I don't know enough about animations / canvas to have clue as to why...)

Modelling: Is this a good way to simulate fireworks? Is there anything I could do to enhance the realism?

In the small view the snippet, it looks great. If you switch to full screen and look at it long enough, some minor warts start to show:

  • At end of the explosion, the pieces are all moving together, as if stuck on an invisible paper and floating down

  • The pieces seem to be moving downward at constant speed instead of accelerating, as if there's no gravity, or gravity is very week. It doesn't really look like they are falling

  • I suppose there is a time limit: after some time the fireworks just stop, though the airplane keeps flying through. And the fireworks don't stop gracefully, but in the middle of explosions, just frozen in the air.

Minor technical things

Semicolons are missing at the end of the definitions of Animation.prototype.start and Animation.prototype.stop. Which is valid, but not consistent with the rest of the code.

The greek variable names are cool...

var τ = 2 * Math.PI;

... it's just that, I don't know how to type them :-/. It would be simpler to just use English words.

The bottom line ...

Oh nevermind any of that! Big THANK YOU ALL for the amazing site and community! The announcement was unexpected and witty, very befitting, and pure awesomeness!

  • 4
    I'm betting the "tingly-shiny effect" in Safari is some sort of anti-aliasing or affine transform artefact (yes, I see it too). I know Safari's rendering behaves very differently from Chrome's (and IE's), but I don't know the specifics. That said, the effect is almost more feature than bug; it fits nicely with the overall effect. But nevermind that! Thank you all, and congratulations! – Flambino Sep 29 '14 at 19:58

You should always have an Alt attribute in your img tags

<img src="http://i.stack.imgur.com/bGZ1m.png" width="80" height="43">
<img src="http://i.stack.imgur.com/bGZ1m.png" width="80" height="43" 
       alt="Toy Airplane - Based on public domain image 
      http://pixabay.com/en/aeroplane-aircraft-airplane-flight-161999/" >

new lines added for less scrolling and not necessary

Alt attribute can be anything but should describe the image, it's used for many different things including screen readers for visually impaired users.

  • 3
    While you should always have an alt ATTRIBUTE, you should not always have a value in there. If the value isn't content that is of value to the site user, then it's actually a detriment. In this example, I'd say it's a detriment. For this attribute to be useful, I'd suggest a value of "airplane" – DA. Sep 30 '14 at 4:09
  • @DA. I agree, I just put something semi Relevant in there, but less is better, especially on something that is moving. but I disagree in that it should not always have a value, it should always have a value so that it is compliant to standards. – Malachi Sep 30 '14 at 4:17
  • 1
    If the image is purely decorative (ie, not part of the content of the page) then it should actually have an empty alt attribute: sitepoint.com/… – DA. Sep 30 '14 at 6:30
  • 1
    W3 standards say that when the image is "used to create bullets in a list, a horizontal line, or other similar decoration" which is not similar to a visual object that is a major part of the site you are displaying, in this case the correct attribute value would be "Airplane" because the banner is already textualized in the html/javascript. The W3 standard says to leave the attribute blank when the image is used for formatting, an airplane that flies across the screen is not formatting. – Malachi Sep 30 '14 at 12:39
  • It's subjective in this example, but yes, in this particular case 'airplane' is likely applicable. My point is merely that while you should always have an alt attribute, you shouldn't necessarily always have a value in it. – DA. Sep 30 '14 at 14:02

(I know I'm quite late to celebrate...anyway)

I wanted the firework to sparkle, so I created a Color object. When the firework is "old enough", the particles simply begin to sparkle using a random opacity :-)

I also addressed @janos remark regarding the end of the animation: the last fireworks now have a couple of seconds to disappear.

function animate(selector) {
    var $canvas = $(selector);
    var width = $canvas.innerWidth();
    var height = $canvas.innerHeight();

    var fireworksFactory = function fireworksFactory() {
        var centerX = (0.2 + 0.6 * Math.random()) * width;
        var centerY = (0.1 + 0.4 * Math.random()) * height;
        var color = new Color(2 * Math.PI * Math.random(), Math.random(), 0.9);
        return new Firework(centerX, centerY, color);
    };

    var fireworks = [fireworksFactory()];
    var animation = new Animation($canvas, fireworks, fireworksFactory);
    animation.start();
    return animation;
}

function fillBanner(selector) {
    $(selector).text(atob('SGFwcHkgZ3JhZHVhdGlvbiwgQ29kZSBSZXZpZXchIENvbmdyYXR1bGF0aW9ucyE='));
}

//////////////////////////////////////////////////////////////////////

function Animation($canvas, objects, factory) {
    this.canvas = $canvas.get(0);
    this.canvasContext = this.canvas.getContext('2d');
    this.objects = objects;
    this.factory = factory;
}

Animation.prototype.start = function start() {
    var canvas = this.canvas;
    var context = this.canvasContext;
    var objects = this.objects;
    var factory = this.factory;

    var redraw = function redraw() {
        context.clearRect(0, 0, canvas.width, canvas.height);
        for (var f = objects.length - 1; f >= 0; f--) {
            var particles = objects[f].particles;
            for (var p = particles.length - 1; p >= 0; p--) {
                var particle = particles[p];
                context.beginPath();
                context.arc(particle.x, particle.y, particle.size, 0, 2 * Math.PI, false);
                context.fillStyle = particle.color;
                context.fill();
            }
            objects[f].update();
        }
    };

    var launch = function launch() {
        objects.push(factory());
        while (objects.length > 4) {
            objects.shift();
        }
    };

    this.redrawInterval = setInterval(redraw, 25 /* ms */);
    this.factoryInterval = setInterval(launch, 1500 /* ms */);
}

Animation.prototype.stop = function stop() {
    clearInterval(this.factoryInterval);
    setTimeout(function() { clearInterval(this.redrawInterval); }, 3000);
}

//////////////////////////////////////////////////////////////////////

function Firework(centerX, centerY, color) {
    this.centerX = centerX;
    this.centerY = centerY;
    this.color = color;
    this.particles = new Array(500);
    this.Δr = 20;
    this.age = 0;
    this.color = color

    var τ = 2 * Math.PI;
    for (var i = 0; i < this.particles.length; i++) {
        this.particles[i] = new Particle(
            this.centerX, this.centerY,
            /* r= */ 0, /* θ= */ τ * Math.random(), /* φ= */ τ * Math.random(),
            /* size= */ 2, color.rgb()
        );
    }
}

Firework.prototype.update = function update() {
    for (var i = 0; i < this.particles.length; i++) {
        this.particles[i].r += this.Δr;
        this.particles[i].recalcCartesianProjection();

        this.Δr -= 0.00005 * this.Δr * this.Δr;                     // Air resist
        this.particles[i].y += 0.00000008 * this.age * this.age;   // Gravity
        this.particles[i].size *= 0.98;                            // Fade
        this.age++;
        if(this.age > 10000){
            // Let the particles sparkle after some time
            this.particles[i].color = this.color.rgba();
        }
    }
};

//////////////////////////////////////////////////////////////////////

function Color(hue, saturation, lightness) {
    /* Based on https://en.wikipedia.org/wiki/HSL_and_HSV#From_HSL */
    /* hue ∈ [0, 2π), saturation ∈ [0, 1], lightness ∈ [0, 1] */
    var c = (1 - Math.abs(2 * lightness - 1)) * saturation;
    var h = 3 * hue / Math.PI;
    var x = c * (1 - (h % 2 - 1));
    var r1 = (h < 1 || 5 <= h) ? c
           : (h < 2 || 4 <= h) ? x
           : 0;
    var g1 = (1 <= h && h < 3) ? c
           : (h < 4) ? x
           : 0;
    var b1 = (3 <= h && h < 5) ? c
           : (2 <= h) ? x
           : 0;
    var m = lightness - c / 2;
    var r = Math.floor(256 * (r1 + m));
    var g = Math.floor(256 * (g1 + m));
    var b = Math.floor(256 * (b1 + m));
    /*
    console.log('hsl(' + hue + ', ' + saturation + ', ' + lightness +
                ') = rgb(' + r + ', ' + g + ', ' + b + ')');
    */
    this.r = r;
    this.g = g;
    this.b = b;
}

Color.prototype.rgb = function() {
    return 'rgb(' + this.r + ', ' + this.g + ', ' + this.b + ')';
};

Color.prototype.rgba = function() {
    var opacity = Math.min(1, Math.random()*5);
    return 'rgba(' + this.r + ', ' + this.g + ', ' + this.b + ', ' + opacity + ')';
};

//////////////////////////////////////////////////////////////////////

function Particle(x, y, r, θ, φ, size, color) {
    this.origX = x;
    this.origY = y;
    this.r = r;
    this.sinθ = Math.sin(θ);
    // this.cosθ = Math.cos(θ);         // Not needed
    this.sinφ = Math.sin(φ);
    this.cosφ = Math.cos(φ);
    this.size = size;
    this.color = color;
    this.recalcCartesianProjection();
}

Particle.prototype.recalcCartesianProjection = function() {
    this.x = this.origX + this.r * this.sinθ * this.cosφ;
    this.y = this.origY + this.r * this.sinθ * this.sinφ;
};
canvas {
    background: black;
    background: linear-gradient(to bottom, black, rgba(0,0,99,0) 400%);
}
div.marquee {
    white-space: nowrap;
    position: absolute;
    top: 60px;
    -webkit-animation: flyby 15s linear infinite;
    animation: flyby 15s linear infinite;
}
@-webkit-keyframes flyby {
    from {
        left: 640px;
    }
    to {
        left: -640px;
    }
}
@keyframes flyby {
    from {
        left: 640px;
    }
    to {
        left: -640px;
    }
}
div.marquee img {
    display: inline-block;
}
div.marquee div {
    display: inline-block;
    position: relative;
    top: -0.8em;
    font: small-caps bold 18px Optima, Futura, sans-serif;
    background: orange;
    padding: 2px 10px;
}
<!DOCTYPE HTML>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Animation</title>
    <link rel="stylesheet" type="text/css" href="celebrate.css">
  </head>
  <body>
    <div id="viewport" style="width: 640px; height: 480px;">
      <canvas id="sky" width="640" height="480"></canvas>
      <!-- Based on public domain image
           https://pixabay.com/en/aeroplane-aircraft-airplane-flight-161999/ -->
      <div class="marquee">
        <img src="https://i.stack.imgur.com/bGZ1m.png" width="80" height="43">
        <div id="banner">Using an old version of Internet Explorer? No celebration for you.</div>
      </div>
    </div>
    <script type="text/javascript" src="https://code.jquery.com/jquery-1.11.1.min.js"></script>
    <script type="text/javascript" src="celebrate.js"></script>
    <script type="text/javascript">
        $(function() {
            fillBanner('#banner');
            var anim = animate('#sky');
            setTimeout(function() { anim.stop(); }, 60000);
        });
    </script>
  </body>
</html>

  • I am using chrome and see no fireworks...I ran in IE and was able to see the fireworks, and I didn't get the "no celebration" message – Malachi Aug 3 '17 at 15:32
  • Chrome gave me a warning that this site was trying to load unsafe scripts, I see the fireworks now. I see fading, but not sparkling though. – Malachi Aug 3 '17 at 15:41
  • 1
    @Malachi thanks for letting me now! It should work again (https everywhere) – oliverpool Aug 7 '17 at 12:47

Your Answer

By clicking "Post Your Answer", you acknowledge that you have read our updated terms of service, privacy policy and cookie policy, and that your continued use of the website is subject to these policies.

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