Team Plaatjes

JS Apps with Vector Graphics

Ross Tuck & Friends

Web version of this talk...

Thanks for reading my slides but since I only intended them for me, I never really finished the custom JS transitions. Therefore, animations may not sync finish properly if you click through them too quickly and the back behavior probably won't work as you expect since I'm inserting extra invisible nodes willy nilly.

Sorry for the inconvenience.

These slides are really quite dangerous, actually.

What to expect

Ross Tuck

@rosstuck

Specialist at Ibuildings

Expat

Team Plaatjes

Team Pictures

Team

Daan van Renterghem
@DRvanR

Robbert vd Bogerd
@nickedname

Milan Verzijlbergh
@milanigor

So...

...it happened like this.

Versioning

Searching

Validation

Business Rules

Draws Diagrams In the Browser

...Wait, it does what?

Draws Diagrams In the Browser

Huh.

Not a flowchart.

Step 1
What's out there?

Spoiler:

We used SVG.

  • Flash

  • WebGL

  • CSS

  • Canvas

  • SVG

Canvas

  • + It's hot
  • + Fast
  • ~ Raster
  • ~ Browser Support
  • - Complex

Canvas


// Code from Matt King: http://stackoverflow.com/questions/5014851
// get canvas element.
var elem = document.getElementById('myCanvas');

function collides(rects, x, y) {
    var isCollision = false;
    for (var i = 0, len = rects.length; i < len; i++) {
        var left = rects[i].x, right = rects[i].x+rects[i].w;
        var top = rects[i].y, bottom = rects[i].y+rects[i].h;
        if (right >= x
            && left <= x
            && bottom >= y
            && top <= y) {
            isCollision = rects[i];
        }
    }
    return isCollision;
}

// check if context exist
if (elem && elem.getContext) {
    // list of rectangles to render
    var rects = [{x: 0, y: 0, w: 50, h: 50},
                 {x: 75, y: 0, w: 50, h: 50}];
  // get context
  var context = elem.getContext('2d');
  if (context) {

      for (var i = 0, len = rects.length; i < len; i++) {
        context.fillRect(rects[i].x, rects[i].y, rects[i].w, rects[i].h);
      }

  }
    
    // listener, using W3C style for example    
    elem.addEventListener('click', function(e) {
        console.log('click: ' + e.offsetX + '/' + e.offsetY);
        var rect = collides(rects, e.offsetX, e.offsetY);
        if (rect) {
            alert('clicked: ' + rect.x + '/' + rect.y);
        }
    }, false);
}
					

SVG



    
    

        


var rectangles = document.querySelectorAll("rect");

for (var i = 0, length = rectangles.length; i < length; i++) {
    rectangles[i].addEventListener('click', function (event) {
        alert('clicked: ' + event.x + '/' + event.y);
    });
}

					

SVG

  • + 2D Data
  • + Simple
  • + Familiar model
  • ~ Vector
  • - Speed
  • - Forgotten Technology

And that's where we ended up.

Step 2
Research

What is SVG?

XML for pictures

Standard format

Embedded in HTML

DOM elements

Drawing


<svg>
   <circle cx="150" cy="150" 
           r="40"   fill="blue">
</svg>
					

Drawing



<svg>
  <rect x="50" y="20" rx="20" ry="20"
    width="150" height="150" fill="green" 
    stroke="#000" stroke-width="5"
    opacity="0.5" />
    
  <rect x="90" y="60" rx="20" ry="20"
    width="150" height="150" fill="green" 
    stroke="#000" stroke-width="5"
    opacity="0.5" />
</svg>
                

Mix and Match

Yes, that one.

Support

  • Modern Browsers
  • IE 9
  • Android 2.3
  • iOS 3.2
  • ...which is pretty good.

But not good enough.

We needed IE 8.

...And maybe 7.

Polyfills

  • svgweb

  • SVG Boilerplate

Enter VML.

Vector Markup Language

Microsoft Standard

Predecessor to SVG

Deprecated

SVG:



					


VML:



					

If only we had a compatibility layer...

Step 3
Drawing Stuff

Dmitry Baranovskiy

2008 - Present

SVG Drawing Library

...with a VML fallback.

Alternatives

  • Snap

  • d3.js

  • dojox.gfx

Examples

Setup


paper = Raphael(100, 200, 640, 480);

// or...

paper = Raphael('myDiv', 640, 480);
					

Drawing


var foo = paper.rect(25, 25, 60, 80);

foo.attr('fill', '#c0392b');

foo.attr({
    stroke: '#e67e22', 
    'stroke-width': 12
});
					

Interaction


var circle = paper.circle(120, 120, 90)
    .attr('fill', 'green');

circle.click(function () {
    alert('Hello!');
});
					

Events

Ain't jQuery

Browser differences


circle.click(function (event) {
    event = $.event.fix(event);

    alert(event.target);
});
					

Don't cross the streams.

Sets


paper.setStart();
rect = paper.rect(60, 20, 200, 80),
rect2 = paper.rect(60, 110, 200, 80);
mySet = paper.setFinish();

mySet.attr('fill', '#c0392b');

mySet.push(paper.rect(60, 200, 200, 80));

mySet.attr('fill', 'green');

mySet.remove();
					

Paths


paper.path('M60,60 L210,60 L135,210 Z');
					

Animation



mustache.animate(
    {fill: '#f00'},
    600
);

mustache.animate(
    {transform: 'R180 T0,100'}, 
    600
);
					

Path Animation



var definition = 'M20,100 L200,100',

arrow = paper.path(definition).attr({
    'stroke-width': 5,
    'arrow-end': 'block'
});
    
definition += ' L200,200';
arrow.animate({path: definition}, 700);

					

Step 4
Let's build

What should we build?

DNA

Business Rules

Made of Pairs

Two Bases

G-C || A-T

C-G || T-A

GCAT

DNA

DNA

GACCG

Our Mission

  1. Enter shorthand

  2. Get diagram

  3. ???

  4. Profit

It's just like any other app.


var Pair = Backbone.Model.extend({
    oppositeBase: {
        G: 'C',
        C: 'G',
        T: 'A',
        A: 'T'
    },
    
    getTopBase: function () {
        return this.oppositeBase[this.get('bottom')];
    },
    
    getBottomBase: function () {
        return this.get('bottom');
    }
});
					

var PairCollection = Backbone.Collection.extend({
    model: Pair
});
					


var ControlsView = Backbone.View.extend({

    events: {
        'click button.redraw': 'resetCollection'
    },
    
    resetCollection: function() {
        // split text, convert to model blah blah

        this.options.collection.reset(models);
    }
});
new ControlsView({el: '#controls', collection: collection});

					

So far...


function PairRenderer (paper) {
    this.paper = paper;
};
PairRenderer.prototype = {
    render: function (collection) {
        // ...
    }
};
					

var pr = new PairRenderer(paper);
collection.on('reset', pr.render, pr);
					

render: function (collection) {
    if (this.set) {
        this.set.remove();
    }

    this.paper.setStart();
    this.draw(collection);
    this.set = this.paper.setFinish();
}
					

draw: function (collection) {
    collection.each(function (index) {

        this.renderBase(pair.getTopBase(), index, true);
        this.renderBase(pair.getBottomBase(), index, false);

    }, this);
}
					

renderBase: function(base, index, topOrBottom) {
    var colors = {
        G: 'MediumSeaGreen',
        C: 'skyblue',
        A: 'gold',
        T: 'tomato',
    };

    this.paper.rect(...)
        .attr({ fill: colors[base] });
    this.paper.text(...);
}
					

renderBase: function(base, index, topOrBottom) {
    var colors = {
        G: 'MediumSeaGreen',
        C: 'skyblue',
        A: 'gold',
        T: 'tomato',
    };

    this.paper.rect((index * 80) + 10, (topOrBottom ? 90 : 170), 40, 40)
        .attr({ fill: colors[base] });
    this.paper.text(index * 80) + 30, (topOrBottom ? 45 : 115), base);
}
					

Yeah, that's readable.

Separation of Concerns

Renderer should render.

Not position everything.


var Grid = function() {};
Grid.prototype = {

    getTile: function(x, y) {
        var startX = (x * 80) + 10,
            startY = (y * 80) + 10;
    
        return {
            x: startX,
            y: startY,
            centerX: startX + 20,
            centerY: startY + 20
        };
    }
    
}
					

Let's try that again.


draw: function (collection) {
    var grid = this.grid;

    collection.each(function (pair, index) {
        this.renderBase(pair.getTopBase(), grid.getTile(index, 0));
        this.renderBase(pair.getBottomBase(), grid.getTile(index, 1));
    }, this);
}

					

renderBase: function(base, index, topOrBottom) {
    var colors = {
        G: 'MediumSeaGreen',
        C: 'skyblue',
        A: 'gold',
        T: 'tomato',
    };

    this.paper.rect((index * 80) + 10, (topOrBottom ? 90 : 170), 40, 40)
        .attr({ fill: colors[base] });
    this.paper.text(index * 80) + 30, (topOrBottom ? 45 : 115), base);
}
					

renderBase: function(base, tile) {
    var colors = {
        G: 'MediumSeaGreen',
        C: 'skyblue',
        A: 'gold',
        T: 'tomato',
    };

    this.paper.rect(tile.x, tile.y, 40, 40)
        .attr({ fill: colors[base] });
    this.paper.text(tile.centerX, tile.centerY, base);    
}
					

renderBase: function(base, tile) {
    var colors = {
        G: 'MediumSeaGreen',
        C: 'skyblue',
        A: 'gold',
        T: 'tomato',
    };

    this.paper.rect(tile.x, tile.y, tile.pct(40), tile.pct(40))
        .attr({ fill: colors[base] });
    this.paper.text(tile.centerX, tile.centerY, base);    
}
					

Keep in mind:

  • Not framework related

  • Not a best practice

  • Not relevant for every project

Improvise

Next Step

Almost the same


LineRenderer.prototype = {
    render: function (collection) {
        // ... this.set ...
    },
    
    draw: function (collection) {
        this.drawLine(this.grid.getTile(collection.length, 0));
        this.drawLine(this.grid.getTile(collection.length, 1));
    }
};

					

drawLine: function (tile) {
    var y = tile.centerY;

    this.paper.path('M 0,'+y+' L '+tile.x+','+y);


}
					

drawLine: function (tile) {
    var y = tile.centerY;

    this.paper.path(
        Raphael.format('M 0,{0} L {1},{0}', y, tile.x)
    );
}

					

drawLine: function (tile) {
    var y = tile.centerY;

    this.paper.path(
        Raphael.format('M 0,{0} L {1},{0}', y, tile.x)
    ).attr('stroke', '#333').toBack();
}
					

Ta da

It works...

...for better and for worse.

Resizing

Boundaries


paper = Raphael('myDiv', 300, 300);

paper.rect(50, 50, 150, 150); // inside

paper.rect(250, 50, 150, 150);// outside
					

Meanwhile, in DNA-land...


collection.on('reset', function () {


});
					

collection.on('reset', function () {
    var maxCol = grid.getTile(collection.length, 2);

});
					

collection.on('reset', function () {
    var maxCol = grid.getTile(collection.length, 2);
    paper.setSize(maxCol.x, maxCol.y);
});
					

Setup

  • PairRenderer

  • LineRenderer

  • Resizer

Problems

Text wrap

div

tspan

Roll your own

DOM insert order

Use a draw event

Scaling

ViewBox

Percentages

Transforms

Interactive elements

Use a hotspot element

Step 5
Thinking Big

Events

Loose coupling


var eventDispatcher = _.extend({}, Backbone.Events);
					

// Rendering layer...
paper.rect(10, 10, 40, 40)
    .attr('fill', 'blue')
    .click(function () {
        eventDispatcher.trigger('color:chosen', 'blue')
    })

					

// Controller layer...
eventDispatcher.on('color:chosen', function(color) {
    clipboard.copy(color);
});

					

// Other widget...
eventDispatcher.on('color:chosen', function(color) {
    $('#current_color').css('background', color);
});

					

Command Pattern


warehouse.resupply(product, store);
					

commandProcessor.execute("Warehouse:Resupply", {
    warehouse: warehouse,
    product: clickedProduct,
    store: myStore
});
					

// Handlers/Warehouse/Resupply.js
{
    execute: function (warehouse, product, store) {
        warehouse.resupply(product, store);
    }
}
					

Sequence

Debugging logs

Undo operations

Multiuser editing

Mid to large

Decide based on features

Commands + Promises


var promise = commandProcessor.execute("Foo:Bar", {baz: butt});

promise.done(function (returnedData) {
    // replace view when done
});					

Testing

Models, Commands, Controllers...

But rendering?


$('rect').first().attr('x')
                    

Eyeball

PhantomJS

Epilogue

Is this the one true path?

No.

But it is surprisingly walkable.

Best advice:

Build a proof of concept.

Insert inspiring story

...that's not in the spec...

Wow.

We did that.

THE END

By Ross Tuck & Friends

Image Credits

https://joind.in/9610

Ross Tuck
@rosstuck
Daan van Renterghem
@DRvanR
Robbert vd Bogerd
@nickedname
Milan Verzijlbergh
@milanigor