This post is a longer examination of the new scatter plot visualization I added to my upcoming data visualization presentation for jQuery Conference. As with the other Angular examples I’ve posted recently, this one is based on the data I keep about the world’s most valuable comics. This particular visualization lives on the page I keep detailing record comic book sales. Take a look. It’s pretty cool, especially if you’re fascinated by comic books that have sold for more than $100,000.
For reference, it looks like the following. It’s 20 years of comics sales plotted horizontally with their sale prices (ranging from $100,000 to $2,500,000) plotted vertically. You can see a couple of clusters of activity based on different events in the marketplace.
So, how did I do it? And why did I do it this way? D3 actually has a few scatter plot examples out there, so I could have used one of those, but as I thought about it I decided I wanted to do something hand-rolled. A scatter plot is actually pretty easy to map out and, as an added bonus, I could lay out the basics of the chart in Illustrator, which allowed me to demo the fact that SVG can be authored like any image by a graphic designer and then manipulated with JavaScript and CSS.
So let’s take a look at the particulars.
First we’ll start with the markup. This is Angular, after all. I’ve simplified the markup a bit, taking out some of the elements representing grid lines. Check the source on Github for all the details.
The interesting bits, from the Angular perspective, happen around line 30. The core of the visualization is in the circle
element. It’s an ng-repeat
that goes through the items
collection in the controller’s scope. It’s filtered by the venues
property.
Take note of the prefixes on the next set of attributes. For starters, as I normally do, I prepend data-*
to Angular attributes as that’s the way custom attributes are defined in the spec. I like to play by the rules. You’ve probably seen that before. What’s probably not so familiar is the use of the additional ng-attr-*
prefix. If you’re familiar with Angular you know you should just be able to do fill="{{colorPicker(it.venue)}}"
and Angular should evaluate the results of the colorPicker
method and insert it in as the value of the fill
attribute. This doesn’t work with SVG elements. Some browsers will parse the entire SVG element before Angular has a chance to evaluate the SVG attributes. So if you put n Angular expression inside an SVG attribute it will throw an error. ng-attr solves this by binding the attribute solely to Angular’s discretion. Here, I’m using this pattern three times. The fill
attribute, which provides the color coding for the different venues s these comic books have sold (with the handy colorPicker
method that accepts the it.venue
as an argument) , and the cx
and cy
attributes which indicate the center of the circle.
The cx
and cy
attributes are passed through custom filters which transform the data into usable x and y coordinates. I’ll talk about those in the section on the Angular filters. Following the filters there are two mouse events used to show and hide a simple (svg) tooltip.
The tooltip is in the next section. Doing boxes in SVG isn’t quite the same thing it is with HTML. You can’t just throw a box in there and have text flow inside the box like you can with a DIV or something, so this would have been easier with an HTML tooltip. That said, I wanted to do this in SVG so it’s all in SVG. In this case I’m doing it with a solid box with several text elements layered on top. All of these tooltip elements show based on the tooltip
model on the scope. If the price is truthy, the tooltip shows. Otherwise, it’s hidden. For each text
element and the rect
element, I’m using x
and y
coordinates generated with the same Angular filters in use on the circles and using the transform
attribute to move the element around based on that common x/y. Each of the text elements also expose the data from the model.
Following the tooltip code, in the ‘legend’
g
element there are several rect
elements which allow for quick filtering by sales venue. ng-mouseover
and ng-mouseout
are used to update the venues
filter.
That’s the markup. SVG isn’t that scary, is it?
<div data-ng-app="comicsApp">
<div data-ng-controller="chartCtrl">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
width="980" height="600" viewBox="0 0 980 600" xml:space="preserve">
<g id="lines">
<line fill="none" stroke="#000000" stroke-miterlimit="10"
x1="90.828" y1="-8" x2="90.828" y2="579.297"/>
<line fill="none" stroke="#B5B5B6" stroke-miterlimit="10"
x1="0" y1="579.297" x2="981.996" y2="579.297"/>
<line fill="none" stroke="#595858" stroke-miterlimit="10"
x1="734.275" y1="49.246" x2="734.275" y2="579.161"/>
<!--about 50 lines cut-->
</g>
<g id="text">
<text x="10" y="16">price in USD</text>
<text x="71" y="574">$0</text>
<text x="480" y="595">2003</text>
<text x="78" y="595">1993</text>
<text x="881" y="595">2013</text>
<text x="25" y="553">$100,000</text>
<text x="25" y="468">$500,000</text>
<text x="16" y="363">$1,000,000</text>
<text x="16" y="256">$1,500,000</text>
<text x="16" y="151">$2,000,000</text>
<text x="16" y="45" >$2,500,000</text>
</g>
<g id="dynamic">
<circle data-ng-repeat="it in items | filter : venues"
opacity="0.8"
data-ng-attr-fill="{{colorPicker(it.venue)}}"
data-ng-attr-cx="{{it.date | xDate}}"
data-ng-attr-cy="{{it.price | yPrice}}"
data-ng-mouseover="updateTooltip(it)"
data-ng-mouseout="updateTooltip(0)" r="7" />
<rect data-ng-show="tooltip.price" data-ng-model="tooltip"
transform="translate(-55,-100)"
data-ng-attr-x="{{tooltip.date | xDate}}"
data-ng-attr-y="{{tooltip.price | yPrice}}"
width="150" height="80"
fill="white" style="filter:url(#drop-shadow)" />
<text data-ng-show="tooltip.price" data-ng-model="tooltip"
transform="translate(-45,-90)"
data-ng-attr-x="{{tooltip.date | xDate}}"
data-ng-attr-y="{{tooltip.price | yPrice}}">
{{tooltip.title}} {{tooltip.issue}}</text>
<text data-ng-show="tooltip.price" data-ng-model="tooltip"
transform="translate(-45,-75)"
data-ng-attr-x="{{tooltip.date | xDate}}"
data-ng-attr-y="{{tooltip.price | yPrice}}" >
{{tooltip.pedigree}}{{tooltip.collection}}{{tooltip.provenance}} {{tooltip.grade_src | srcFilter}} {{tooltip.grade}}</text>
<text data-ng-show="tooltip.price" data-ng-model="tooltip"
transform="translate(-45,-60)"
data-ng-attr-x="{{tooltip.date | xDate}}"
data-ng-attr-y="{{tooltip.price | yPrice}}" >
{{tooltip.price|currency}}</text>
<text data-ng-show="tooltip.price" data-ng-model="tooltip"
transform="translate(-45,-45)"
data-ng-attr-x="{{tooltip.date | xDate}}"
data-ng-attr-y="{{tooltip.price | yPrice}}" >
{{tooltip.date}}</text>
<text data-ng-show="tooltip.price" data-ng-model="tooltip"
transform="translate(-45,-30)"
data-ng-attr-x="{{tooltip.date | xDate}}"
data-ng-attr-y="{{tooltip.price | yPrice}}" >
{{tooltip.venue}}</text>
</g>
<g id="legend">
<rect x="125" y="5" width="15" height="15" fill="#D95B43"
data-ng-mouseover="venues='comic connect'"
data-ng-mouseout="venues=''"></rect>
<text x="145" y="15" class="legend">Comic Connect</text>
<rect x="245" y="5" width="15" height="15" fill="#ECD078"
data-ng-mouseover="venues='heritage'"
data-ng-mouseout="venues=''"></rect>
<text x="265" y="15" class="legend">Heritage</text>
<rect x="325" y="5" width="15" height="15"
fill="#C02942" data-ng-mouseover="venues='comiclink'"
data-ng-mouseout="venues=''"></rect>
<text x="345" y="15" class="legend">ComicLink</text>
<rect x="405" y="5" width="15" height="15" fill="#542437"
data-ng-mouseover="venues='pedigree'"
data-ng-mouseout="venues=''"></rect>
<text x="425" y="15" class="legend">Pedigree</text>
<rect x="485" y="5" width="15" height="15" fill="#53777A"
data-ng-mouseover="venues='metropolis'"
data-ng-mouseout="venues=''"></rect>
<text x="505" y="15" class="legend">Metropolis</text>
<rect x="565" y="5" width="15" height="15" fill="#69D2E7"
data-ng-mouseover="venues='jp'"
data-ng-mouseout="venues=''"></rect>
<text x="585" y="15" class="legend">JP/Mint</text>
<rect x="645" y="5" width="15" height="15" fill="#FA6900"
data-ng-mouseover="venues='mastronet'"
data-ng-mouseout="venues=''"></rect>
<text x="665" y="15" class="legend">Mastronet</text>
<rect x="725" y="5" width="15" height="15" fill="#FE4365"
data-ng-mouseover="venues='pgc'"
data-ng-mouseout="venues=''"></rect>
<text x="745" y="15" class="legend">PGCMint</text>
<rect x="805" y="5" width="15" height="15" fill="#666666"
data-ng-mouseover="venues='unknown'"
data-ng-mouseout="venues=''"></rect>
<text x="825" y="15" class="legend">Other/Unkown</text>
</g>
</svg>
</div>
</div>
Now let’s take a look at the JavaScript. First up is the controller. This is pretty simple as far as these things go. $scope.items
is first populated with data provided by an Angular factory. The factory is a simple $http.get
so I’m not showing it here. I just organized the data request into a factory so two controllers could share the same code for getting comics data. Following that, the $scope.tooltip
model is set with a single property, price
set to zero. This falsy value ensures that the tooltip we previously defined in the HTML isn’t shown by default. Following that is a small method designed to update the tooltip model with data representing the currently selected comic book. Finally, there’s a small method and related array used to choose colors for the different venues. The method accepts a string argument and matches against a list of known venues, returning the proper color.
That’s the whole controller.
angular.module('comicsApp.controllers', ['comicFilters','comicsFactories']).
controller('chartCtrl', ["$scope",'dataService',function( $scope, dataService ) {
$scope.items = dataService;
$scope.tooltip = {
price:0
}
$scope.updateTooltip = function(it) {
$scope.tooltip = {
price:it.price || 0,
venue:it.venue,
date:it.date,
title:it.title,
issue:it.issue,
pedigree:it.pedigree,
collection:it.collection,
provenance:it.provenance,
grade_src: it.grade_src,
grade : it.grade
}
}
$scope.colorPicker= function( venue ){
switch (venue) {
case "Heritage":
return $scope.colors[0];
case "Comic Connect":
return $scope.colors[1];
case "Comiclink":
return $scope.colors[2];
case "Pedigree":
return $scope.colors[3];
case "Metropolis":
return $scope.colors[4];
case "JP The Mint":
return $scope.colors[5];
case "Mastronet":
return $scope.colors[6];
case "PGCMint":
return $scope.colors[7];
default:
return $scope.colors[8];
}
}
$scope.colors = ["#ECD078","#D95B43","#C02942","#542437","#53777A","#69D2E7","#FA6900", "#FE4365","#666666"];
}
]);
Now let’s take a look at the filters. As I mentioned earlier, this particular visualization isn’t generic, so these are only useful in the context of this particular implementation. Which is fine. I’m not doing a million of these.
The first, xDate
, defines the x axis. It takes the date (in the form yyyy-mm-dd) and splits it into and array containing the years, months and days. I calculate the total number of months from 1993 by subtracting 1993 from the year and then multiplying that number by 12. I then adding that number to the number of months from the original date array. After that the numbers of months is fudged into a pixel measurement as the offset of the left of the chart, 90, is added to the number of months multiplied by 3.3333, which is a rough “month” unit on this particular chart.
The y axis filter is easier. It calculates a pixel representation of the book’s value (the value divided by 4700, the number of dollars per pixel) and that number is subtracted from 579, which is the offset from the top of the SVG element to the baseline of the chart.
If you were doing a generic chart that could take any similar data and produce a scatter plot, you would have to do these calculations on the fly. Getting the minimum and maximum values out of the data and creating a range and scales depending on the specific data. Since I defined the data and parameters for this before I wrote a line of code.
Yay for lazy.
angular.module('comicFilters', []).filter('xDate', function () {
"use strict";
return function (input) {
if (input !== undefined) {
var date = input.split("-");
var years = date[0] - 1993;
var months = (years * 12) + parseInt(date[1]);
return 90 + months * 3.3333;
}
};
}).filter('yPrice', function () {
"use strict";
return function (input) {
if (input !== undefined){
return 579 - (input/4700);
}
};
})
And there you have it. Check out the $100,000 club repo for all the source code from this visualization and keep your eyes peeled for my jQuery Conference presentation where I’ll be talking about this as well as D3, the canvas element (with the web audio API) and Google Maps.