Quantcast
Channel: Hacker News
Viewing all articles
Browse latest Browse all 10943

Ben McMahen : Using d3 and Meteor to generate scalable vector graphics (SVG)

$
0
0

Comments:"Ben McMahen : Using d3 and Meteor to generate scalable vector graphics (SVG) "

URL:http://blog.benmcmahen.com/post/41124327100/using-d3-and-meteor-to-generate-scalable-vector


One of the more useful features of my Subtitles web application is the timeline that appears towards the top of the project viewing area. You can think of it basically as a bar-chart, with the x-axis representing the length of the video, and the y-axis representing the words per minute of each caption. When you add a new caption to your project, it appears on your timeline. Its width represents its duration, its height represents the words per minute of that individual caption, and its color provides a warning — if it’s red, then the caption probably contains too many words per minute. You can move the current-time indicator to alter the playback position of your video, and you can click on an individual caption in the timeline to select the associated text-node in your main work area. In short, the timeline provides a map of your entire project—an interactive map that remains in sync with your project. It’s functionality that isn’t new to desktop applications—think iMovie or Final Cut Pro—but is fairly novel for web applications.

To see it in action, try Subtitles, or watch my demonstration video.

Meteor, d3, and SVG

The timeline was made possible largely through the use of new technologies, including the Meteor development framework, d3, and Scalable Vector Graphics (SVG).

For those that don’t know, Meteor is a simple yet powerful way to create advanced web applications entirely in Javascript. You can read all about it at their website. Scalable Vector Graphics (SVG) is a text-based graphic format that allows the developer to create interactive, animated vectors in the (modern) web browser. And d3 is a javascript library (available as a Meteor smart package) that helps manipulate SVG (and other graphics) based on data. You can begin to see how these technologies fit together: Meteor provides the (reactive) data which d3 uses to create the vector graphics that make up the Subtitles timeline.

Building the Timeline

To get started, you’ll need to add the d3 smart package to your Meteor project, which exposes the d3 global variable (and its functionality) to your application.

 meteor add d3

Next, you’ll want to create a template that contains your SVG element wrapped in a constant region.

<div class="timeline-wrapper">
 {{#constant}}
 {{/constant}}</div>

A constant region is invoked using the {{#constant}} block helper. It’s purpose is to prevent Meteor from re-rendering the DOM elements when its reactive data sources change. This is useful when using third-party widgets or libraries that manage their own DOM elements — think Google Maps, or in our case, d3. Meteor won’t handle the rendering of the timeline. Instead, Meteor will pass the data along to d3 which will do the work for us (after we supply it with some logic). You can also see that I’ve included some basic attributes and elements that will remain constant throughout the application, including the current time indicator line.

For d3 to interact with our timeline, we will need to use it within a template rendered callback.

 Template.map.rendered = function () {
 // our d3 code goes here
 }

The rendered callback runs when the given template has finished rendering, allowing us to access the DOM elements within that template. In other words, it indicates to us that the map template has rendered. And because it has rendered, we can use d3 (or jQuery, or regular javascript) to access or work with its rendered contents, including the “ element. We can then cache these DOM elements within our template object.

 Template.map.rendered = function () {
 this.node = this.find('#video-map');
 }

Now we need to set the yScale and xScale of our timeline (which, as I explained, is basically a bar chart). We want to set our y-axis to words per minute, so we need to translate a words per minute value to a pixel value, as represented on our chart. D3 provides a handy linear-scale function that makes this super easy.

 var yScale = d3.scale.linear()
 .domain([0, 4])
 .range([10, 60]);

The domain is words / duration. In other words, 15 words divided by 5 seconds will give us 3. 3 will then be mapped to the range, which will be a pixel value as represented on our timeline. We can do something similar for our xScale:

 var xScale = d3.scale.linear()
 .domain([0, self.duration])
 .range([0, $(self.timelineWrapper).width()])

The domain is the duration of the video that we’re using. The range is the width of the timeline DOM element. This allows us to map the duration of a caption to a specific width on the timeline.

With these in place, we are ready to start drawing our timeline. To do this, we need to setup a reactive data source within our rendered callback. This will inform d3 whenever our Subtitles collection changes. If we add, remove, or alter a caption, this data source will inform d3, which can update the timeline accordingly.

 if (! self.drawTimeline) {
 self.drawTimeline = Meteor.autorun(function() {
 var subtitles = Subtitles.find().fetch(); 
 self.captions = d3.select(self.node)
 .select('.caption-spans')
 .selectAll('rect')
 .data(subtitles, function (sub) {
 return sub._id; 
 });
 drawTimeline(); 
 });
 }

Using Meteor.autorun we are creating a function that will automatically run whenever a supplied reactive data source changes. Our Subtitles.find().fetch() query is one of those reactive data sources. We are then telling d3 to select all of the rectangles within the class .caption-spans within our SVG node, and using the data function to bind each subtitle to a rectangle element. We then call our drawTimeline function.

 var drawTimeline = function(){
 // Append new captions
 drawSubs(self.captions.enter().append('rect'));
 // Update changed captions
 drawSubs(self.captions.transition().duration(400));
 // Remove captions
 self.captions
 .exit()
 .transition()
 .duration(400)
 .style('opacity', 0)
 .remove(); 
 }

This function controls what to do when captions are added, changed, or removed. If a caption is removed, we apply a transition of 400ms which sets the opacity to 0, and then we remove the element from the timeline. For new captions, we want to append a new rectangle and then call the drawSubs() function in which we pass the new rectangle. For changed captions, we apply a 400ms transition and pass these captions to our drawSubs() function. The drawSubs() function handles the more detailed attributes of each caption, as seen below.

 // Sets the attributes of each new or changed caption
 var drawSubs = function (caption) {
 caption
 .attr('data-id', function (cap) { return cap._id; })
 .attr('class', 'timelineEvent')
 .attr('fill', function (cap) { 
 // Provide colour warnings if too fast rate / second
 var rate = getRatio(cap);
 if (rate &lt;= 2.3)
 return '#50ddfb'; // it's good
 else if (rate &gt; 2.3 &amp;&amp; rate &lt; 3.1)
 return '#fbb450'; // warning
 else
 return '#ea8787'; // danger
 })
 .attr('x', function (cap) { return xScale(cap.startTime); })
 .attr('y', function (cap) { return '-' + yScale(getRatio(cap)); })
 .attr('width', function (cap) { 
 return xScale(cap.endTime) - xScale(cap.startTime)
 })
 .attr('height', function (cap) {
 return yScale(getRatio(cap)); 
 });
 };

The code here is fairly self-explanatory. We have passed each caption to the drawSubs() function, and we are applying attributes to this caption which are determined by the caption data. We set the data-id attribute to the caption _id in the Meteor collection. This is useful for providing interaction between the timeline and the rest of the Meteor application. The fill attribute is determined based on the words per minute. If the rate is below 2.3 (words / seconds) then it will fill the caption with the colour #50ddfb. It’s quite simple, but you could get more complex colour gradients using one of d3’s scale functions. We then use our xScale and yScale functions to set the x, y, width, and height attributes of the caption.

This gives us our basic timeline. Pretty neat, eh?

Lastly, we need to cleanup our timeline when our template is destroyed, and this means that we need to stop the Meteor.autorun function from running if our data changes. To do this, call the .stop() function in the template destroyed callback.

 Template.map.destroyed = function () {
 this.drawTimeline.stop(); 
 };

And that’s it. For the full example, including event binding, check out the Subtitles Github repository.

Tweet

Viewing all articles
Browse latest Browse all 10943

Trending Articles