Skip to content

Instantly share code, notes, and snippets.

@cmchap
Forked from GerHobbelt/.gitignore
Last active August 29, 2015 13:56
Show Gist options
  • Save cmchap/9081705 to your computer and use it in GitHub Desktop.
Save cmchap/9081705 to your computer and use it in GitHub Desktop.
D3.js streamgraph

A little blurb to settle the nerves …… ? 8

This demo expands on the standard streamgraph demo in several ways:

  • generates a (bogus) set of tags, one for each series shown in the streamgraph.
  • these 'tags' are printed at the top of the graph as a legenda: the layout logic exploys SVG node.getBBox() to determine the exact pixel cost (width/height) of each tag and distributes them evenly across the horizontal expanse.
  • mixes the above with [Google] WebFonts. That got me a nasty surprise, which I fixed with a timer-delayed action and since Safari's behaviour raised a few hairs I arrived at the FOUT pages ('flash/flicker of unstyled content') and good cause to further enhance my timer-based hack; I added the timeout after looking at Paul Irish's blog page. Unless someone can prove me wrong, I think I got the bugger nailed. (Nasty polling way to check for font loaded and rendered, though...)
  • hover and click event in legenda animate = fade the streams, and vice versa.

Positioning tags in the streams

And then there's the important bit of course: one way to find the 'proper spot' in each stream to print the tag, as big as possible. As can be seen in the code (bottom section of index.hmtl) this was a quest; I didn't commit all the iterations into git, but the 'improvement #x' comments are indicative of what came after I finally got the basic approach nailed - which only worked for a large enough data set as every 'tag' had to span multiple [x] data points to allow this simplistic algorithm to deliver something near usable.

The improvements now allow for small datasets as they are sliced/interpolated at a 1px step to produce (interpolated) data points to make the tag placement algorithm work as desired.

How I got here

The general idea was to scan each stream polygon from left to right and see where we might find a horizontal-enough section that could fit the tag, in its smallest form or maybe bigger. Then continue with the scan in order to find the 'best' location by picking the top 1 from the candidate set.

This started out as code which located the first ('left-most') [x] datapoint which was 'high enough' (.y value!) to make a chance of containing the tag text bbox. Then the code would interpolate the left-most x position for which the .y was exactly the minimum required, and from there the code went to scan to the right to find when we'd hit a right edge, either a downwards upper edge (.y0 + .y) or a upwards going lower edge (.y0). This still lacked the 'growing' code which is needed to find out how big we can scale the text vertically, but somewhere in there I screwed up and got eaten by bugs.

So I ditched that part of the code and went for the 'lazy method' as you see now, which was intended as a speed optimization for that original approach: if you'ld have enough data points, the interpolation tacics wouldn't be required as with sufficient data points, every pixel would be covered by an explicit datapoint anyhow. After a bit of testing and headscratching due to yet another off-by-one mistake of mine I got where I am today (seek out the if(0) in the code to see the visual debugging of red point indicating text x,y and green box indicating the detected largest rectangle fitting the given stream).

Then a bit of pondering led to the decision to keep the 'lazy method' as the only method (so that TODO comment at the bottom is now practically bogus) and just 'tweak' small datasets into becomes virtually large datasets by altering the stepsize in the scan loop to a non-integer, minimal step size. And the rest is history...

<!DOCTYPE html>
<html>
<head>
<title>Streamgraph</title>
<link href='http://fonts.googleapis.com/css?family=PT+Sans+Narrow:700,400|Rock+Salt' rel='stylesheet' type='text/css'>
<script type="text/javascript" src="http://d3js.org/d3.v2.js"></script>
<script type="text/javascript" src="stream_layers.js"></script>
<style>
#chart {
font: 12px/18px 'Rock Salt',sans-serif;
/*
font-family: 'Pt Sans Narrow' !important;
font-style: normal;
font-weight: 400;
*/
}
button {
font: 14px 'Rock Salt',sans-serif;
background-color: #222;
background-image: -moz-linear-gradient(top, rgba(255,255,255,.25), rgba(255,255,255,.11));
background-image: -webkit-gradient(linear,left top,left bottom,color-stop(0, rgba(255,255,255,.25)),color-stop(1, rgba(255,255,255,.11)));
background-image: -webkit-linear-gradient(rgba(255,255,255,.25), rgba(255,255,255,.11));
color: #fff;
text-rendering: optimizeLegibility;
text-shadow: 0 -1px 1px #222;
padding: 3px 10px 3px 10px;
border: 0;
border-radius: 0;
border-bottom: 1px solid #222;
margin: 0;
-moz-box-shadow: 0 1px 3px #999;
-webkit-box-shadow: 0 1px 3px #999;
display: inline-block;
}
button.first {
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
}
button.last {
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
}
button.active {
background-color: rgb(65,102,133);
}
button:hover {
background-color: steelblue;
}
/* SVG styles */
rect.wrapper
{
fill: none;
}
.hover rect.wrapper
{
fill: rgba(255, 102, 0, 0.3);
}
text
{
/* font: 12px 'PT Sans Narrow',sans-serif; */
fill: black;
}
text.bbox_hack
{
font: 24px 'Rock Salt',sans-serif;
}
.tags_in_stream text
{
fill: white;
/* fill: red; -- use this while debugging the graphic placement of the in-stream tags */
}
svg
{
font: 14px 'PT Sans Narrow',sans-serif;
}
</style>
</head>
<body>
<div id="chart">
<h2>Known issues:</h2>
<ul>
<li>WebKit, FF and the rest suffer from <a href="http://paulirish.com/2009/fighting-the-font-face-fout/">FOUT</a>; we cope with it by hiding and monitoring the SVG <code>.getBBox()</code> call return values as those are what's important to us: see the WARNING comments in the code.
<li>Mouseover any stream strip when you just updated and the update transition will halt, so that you're still stuck with the 'old' data. Click 'update' twice more, not moving the mouse outside the button, to recover. (Yes, all streamgraph examples in github / gist suffer from this AFAICT. Haven't investigated a fix, yet.)
</ul>
<h2>Use / Play</h2>
<p>Hover over the stream strips, the legenda and see the fades; click on either of 'em to highlight one or more streams. Hit the
<button class="first last" onclick="transition(); return false;">
Update
</button>
button to switch between the two datasets.
</div>
<script type="text/javascript" src="http://gerhobbelt.github.com/bl.ocks.org-hack/fixit.js" ></script>
<script>
var n = 15, // number of layers
m = 10, // number of samples per layer
datagen = stream_layers,
raw_data0 = datagen(n, m),
raw_data1 = datagen(n, m),
data0 = d3.layout.stack().offset("wiggle")(raw_data0),
data1 = d3.layout.stack().offset("wiggle")(raw_data1),
tags,
color = d3.interpolateRgb("#aad", "#556"),
strip_toggled = [];
var w = 960,
h = 200,
top_height = 40, /* WARNING: can't use the name 'top' here as that will break in Chrome20, which has that naem reserved for an internal, non-overridable function! So we name this one top_height */
mx = m - 1,
my = d3.max(data0.concat(data1), function(d) {
return d3.max(d, function(d) {
return d.y0 + d.y;
});
}),
// convert values to pixels:
y = function(v) {
return v * (h - top_height) / my;
},
x = function(v) {
return v * w / mx;
};
var area = d3.svg.area()
.x(function(d) { return x(d.x); })
.y0(function(d) { return h - y(d.y0); })
.y1(function(d) { return h - y(d.y + d.y0); })
.interpolate('linear');
var graph = d3.select("#chart")
.append("svg")
.attr("width", w)
.attr("height", h);
var header = graph
.append("g")
.attr("class", "topside")
.style("visibility", "hidden"); // flicker fix for the d3.timer+getBBox further below.
// print the tags at the top:
var topside_sel = header.selectAll("g")
.data(tags = names_gen(n)) // w and h are used by names_gen() but are not defined at the time tags var is declared above, so we init tags here!
.enter()
.append("g")
.attr("class", "tag_top")
.on('click', function(d, i){
var c;
// undefined is treated as if it was 'false'
strip_toggled[i] = !strip_toggled[i];
// set/reset the color:
c = (strip_toggled[i] ? '#ff6600' : color(i / n));
// tweak the top_tag rect:
//d3.select(this).select("rect") -- needs class or id to pick the right one; less coding work to copy&paste:
top_marker_rects
.each(function(d, idx) {
if (idx == i)
d3.select(this).style("fill", c);
});
// also tweak the stream strip:
stream_strips
.each(function(d, idx) {
if (idx == i)
d3.select(this).style("fill", c);
});
})
// set opacity up right now so on transition it'll start at 1.0:
.attr("opacity", 1)
.on("mouseover", function(d, i) {
// you'll get a slightly different animation when hovering here, rather than over the strips themselves.
// Here we only 'fade' the strips, but highlight the tag, instead of fade out the others.
stream_strips
.transition()
.duration(1000)
.attr("opacity", function(d, j) {
return j != i ? 0.2 : 1;
});
d3.select(this)
.classed("hover", true);
})
.on("mouseout", function(d, i) {
stream_strips
.transition()
.duration(1000)
.attr("opacity", 1);
d3.select(this)
.classed("hover", false);
});
var top_wrappers = topside_sel
.append("rect")
.attr("x", 0)
.attr("y", 0)
.classed("wrapper", true);
var top_marker_rects = topside_sel
.append("rect")
.style("fill", function(d, i) {
return color(i / n);
})
.attr("width", 10)
.attr("height", 10)
.attr("x", 1)
.attr("y", 0);
var top_marker_texts = topside_sel
.append("text")
.text(function(d, i) {
return d.name;
})
.attr("x", 15)
.attr("y", 10);
/*
WARNING: this is nasty hack to make sure the top texts get rendered, before we call
.getBBox() in correct_tags_topside_positioning() for those nodes: as per
spec getBBox only takes the actual CSS-styled font metrics into account
once the node has been rendered.
Not only do we have (minimal) risk of observing screen flicker now, but it
also means that we can only search for suitably large areas in the stream strips
and place the tags there ONLY ONCE this timer has fired, or that code would
fail by using the wrong (guestimated) textbox width/height values!
Anti-flicker through style="visibility: hidden;"
WARNING: using a very short delay, e.g. 1 or 10, will FAIL TO DELIVER on Safari/Win at least:
for some insane reason you must delay longer, I guess they want one VBL at least,
but this sort of 'guess my timeout, buster!' behaviour gives me the Willies.
Tested to FAIL on Safari 5/Win for delays up to 50 msec!
As 100 msecs isn't always going to cut it, we do the HACK thing and place a text
outside the view, then check its BBox results for a change to detect when bloody
Safari has finally updated its internal SVG state.
Turns out it's all due to the WebFonts in use here.
https://developers.google.com/webfonts/faq#While_Loading
http://paulirish.com/2009/fighting-the-font-face-fout/
Keep in mind to DISABLE THE TIMER-BASED HACK when you change the styles to use local
fonts!!
*/
var bbox_hack = graph
.append("text")
.attr("class", "bbox_hack")
.text("blurbyblurboblurbixbloodySafari!")
.attr("x", 15)
.attr("y", 100)
.style("visibility", "hidden");
bbox_hack.__bbox__ = bbox_hack.node().getBBox();
var getBBox_will_be_correct = false;
d3.timer(function(elapsed) {
var hack = bbox_hack.node().getBBox();
if (hack.width == bbox_hack.__bbox__.width && elapsed < 2000)
return false;
console.log("Safari fires BBox @ ", elapsed);
getBBox_will_be_correct = true;
header
.style("visibility", "visible");
correct_tags_topside_positioning(n);
update_instream_tags();
return true; // only invoke this one ONCE, i.e. be done with it.
}, 100);
// --- end of hacked invocation of correct_tags_topside_positioning(n) ---
// the actual graphing work: draw the stream graph:
var vis = graph.append("g");
var stream_strips = vis.selectAll("path")
.data(data0)
.enter().append("path")
.style("fill", function(d, i) {
return color(i / n);
})
.attr("d", area)
.on('click', function(d, i){
d3.select(this).style("fill", function() {
var c;
// undefined is treated as if it was 'false'
strip_toggled[i] = !strip_toggled[i];
// set/reset the color:
c = (strip_toggled[i] ? '#ff6600' : color(i / n));
// also tweak the top_tag rect:
top_marker_rects
.each(function(d, idx) {
if (idx == i)
d3.select(this).style("fill", c);
});
return c;
});
})
// set opacity up right now so on transition it'll start at 1.0:
.attr("opacity", 1)
.on("mouseover", function(d, i) {
stream_strips
.transition()
.duration(1000)
.attr("opacity", function(d, j) {
return j != i ? 0.2 : 1;
});
topside_sel
.transition()
.duration(1000)
.attr("opacity", function(d, j) {
return j != i ? 0.2 : 1;
});
})
.on("mouseout", function(d, i) {
stream_strips
.transition()
.duration(1000)
.attr("opacity", 1);
topside_sel
.transition()
.duration(1000)
.attr("opacity", 1);
});
// and set up the SVG nodes for the tags-in-stream:
var tags_instream = graph.append("g")
.attr("class", "tags_in_stream");
var tags_instream_sel = tags_instream.selectAll("text")
.data(tags)
.enter()
.append("text")
.style("pointer-events", "none")
.text(function(d, i) {
return d.name;
})
.attr("x", w / 2)
.attr("y", h / 2)
//.style("visibility", "hidden")
.attr("opacity", 0);
function transition() {
d3.selectAll("path")
.data(function() {
var d = data1;
data1 = data0;
return data0 = d;
})
.transition()
.duration(2500)
.attr("d", area)
// hide at start of animation, then show new ones at end of transition:
.each("start", function() {
tags_instream_sel
//.style("visibility", "hidden")
.transition()
.attr("opacity", 0);
})
.each("end", function() {
// and update the instream tags; only do so iff the getBBox fix already fired
if (getBBox_will_be_correct) {
update_instream_tags();
}
});
}
// generate semi-random tags to go with the points dataset
function names_gen(n) {
var a = [];
var o = {};
var snip = ["aero", "blu", "foo", "bar", "nal", "skip", "sky", "jam", "poon", "tang", "ploo", "ker", "hum", "di", "com", "tan", "te", "pu", "ter"];
var i, l, c, s;
for (i = 0; i < n; i++) {
s = "";
for (l = Math.max(0, Math.log(Math.random() * 11)); l >= 0; l--) {
c = Math.floor(Math.random() * snip.length);
s += snip[c];
}
if (s.length < 3 || o[s]) {
// too short for our taste or just a dupe
i--;
continue;
}
o[s] = 1;
a.push({
name: s,
// just a random starting point for the in-stream tags:
x: Math.random() * w,
y: Math.random() * h
});
}
// Now roughly estimate position for 'topside' plotting of the texts, based on the length of the strings.
// This is blatantly negigent of proportional fonts! but we use it anyway as a starting heuristic to get 'good'
// placing; as good placement is dependent on the actual BBoxes, we 'adjust' the positioning once we got
// set plotted and have access to their BBoxes.
l = 0;
for (i = 0; i < n; i++) {
l += a[i].name.length;
}
c = (w - n * 20) / l;
l = 0;
for (i = 0; i < n; i++) {
a[i].top_x = Math.round(i * 20 + l * c);
l += a[i].name.length;
}
return a;
}
function correct_tags_topside_positioning(n) {
var i, l, v, c, top_sel;
top_marker_texts
.each(function(d, i) {
// and get the actual pixel width of the text node, we'll need it for placing it in the streamgraph proper:
var bbox = this.getBBox();
d.text_bbox = bbox;
});
l = 0;
v = 0;
for (i = 0; i < n; i++) {
l += tags[i].text_bbox.width;
v = Math.max(v, tags[i].text_bbox.height);
}
c = (w - n * 20) / l;
l = 0;
for (i = 0; i < n; i++) {
tags[i].top_x = Math.round(i * 20 + l * c);
l += tags[i].text_bbox.width;
}
// adjust the tags printed at the top:
//top_sel = header.selectAll("g.tag_top")... but reusing the vars is faster:
topside_sel
// .data(tags) -- no need, we're only updating and the changes are accessible already as the tags elems are refenced by the svg nodes!
.attr("transform", function(d, i) {
return "translate(" + d.top_x + ",20)";
});
top_wrappers
.attr("width", function(d, i) {
return 20 + d.text_bbox.width;
})
.attr("height", v);
top_marker_rects
.attr("y", /* center vertically */ (v - 10) / 2);
top_marker_texts
.attr("y", function(d, i) {
return 10 - d.text_bbox.y;
});
}
function update_instream_tags() {
// now that we got all that, it's time to see if and where to put the tags inside the stream strips:
tags_instream_sel
.each(function(d, i) {
d.tag_opt_place = find_instream_tag_print_position_optimum(i);
if (!d.tag_opt_place) {
d3.select(this)
//.style("visibility", "hidden")
.transition()
.attr("opacity", 0);
} else {
d3.select(this)
//.style("visibility", "visible")
.style("font-size", Math.floor(d.tag_opt_place.scale * 100) + "%")
// scaling fonts isn't what I'ld expect, so center the text horizontally:
.style("text-align", "center")
.attr("x", function() {
return x(d.tag_opt_place.x);
})
.attr("y", function() {
return h - y(d.tag_opt_place.y) +
// lift baseline accordingly:
Math.min(0, d.tag_opt_place.bbox.y * d.tag_opt_place.scale);
})
.transition()
.attr("opacity", 1);
// visual debug aids:
if (0) {
tags_instream
.append("rect")
.attr("fill", "none")
.attr("stroke", "green")
.attr("width", function() {
return x(Math.max(0.02, d.tag_opt_place.x1 - d.tag_opt_place.x0));
})
.attr("height", function() {
return y(d.tag_opt_place.y_max - d.tag_opt_place.y_min);
})
.attr("x", function() {
return x(d.tag_opt_place.x0);
})
.attr("y", function() {
return h - y(d.tag_opt_place.y_max);
});
tags_instream
.append("circle")
.attr("fill", "red")
.attr("r", 5)
.attr("cx", function() {
return x(d.tag_opt_place.x);
})
.attr("cy", function() {
return h - y(d.tag_opt_place.y);
});
}
}
});
}
/*
Naive approach to find the largest inscribed rectangle in a polygon.
I didn't research the algorithms available, but tried here what I came up with myself,
which can surely be improved upon.
The approach is simply to scan from left to right through the (x,y0,y) set for a
stream strip (~ polygon) and seek the longest horizontal 'scanline' which allows
for a minimum rectangle height equal to the height of the tag text at the top:
this is chosen as a 'sane minimum size' as we assume that any smaller text inside
the stream will be ridiculous from a viewability POV.
Bigger of course is better, but we can only do that when the horizontal scanline
length is larger than the required mimum for displaying the text at minimum scale.
To speed up the worst-case O(n^2) scan process we skip and 'stop' the scan at any
x where the height (y) is less than the required minimum height.
*/
function find_instream_tag_print_position_optimum(i) {
// we assume that we already have the BBox w+h for the tag:
var poly = data0[i];
var tinfo = tags[i];
var reqd_y = tinfo.text_bbox.height / y(1); // convert to *data* units: inverse of screen px transforms
var reqd_x = tinfo.text_bbox.width / x(1);
var optimum;
/*
improvement #1: slower but more accurate for low point counts: interpolate between [x] points to produce more opportunities to
find a suitable space using the 'lazy' scan, which assumes there's points enough to not bother with polygon & scanline algos.
*/
var step_x = Math.min(1, 1 / x(1));
/*
improvement #2: for better visual palatability, stay away from the left and right edge by 'padding' pixels, so that the tag texts
don't end up smack against the side, left or right.
*/
var x_padding = (0.02 * w) / x(1); // padding: 2% of graph width
var x_end = m - 1 - x_padding;
function v(i) {
var i0 = Math.floor(i);
var i_delta = i - i0;
var v0 = poly[i0];
if (i0 + 1 < m && i_delta >= step_x) {
var v1 = poly[i0 + 1];
return {
x: i,
y0: v0.y0 + (v1.y0 - v0.y0) * i_delta,
y: v0.y + (v1.y - v0.y) * i_delta
};
}
return v0;
}
for (var i = x_padding; i <= x_end; i += step_x) {
var v1 = v(i);
if (v1.y < reqd_y)
continue;
// speed up: scan forward to see if we have any chance at getting the minimum reqd width:
if (i + reqd_x > x_end)
continue;
var y_min = v1.y0, y_max = y_min + v1.y;
var scale = 0;
for (var j = step_x; i + j <= x_end; j += step_x) {
var v2 = v(i + j);
if (v2.y < reqd_y)
break;
// cut off by rise from below?
if (v2.y0 > y_max - reqd_y)
break;
// cut off by drop from above?
if (v2.y0 + v2.y < y_min + reqd_y)
break;
var y_min1, y_max1;
y_min1 = Math.max(v2.y0, y_min);
y_max1 = Math.min(v2.y0 + v2.y, y_max);
var scale_new = Math.min(j / reqd_x, (y_max1 - y_min1) / reqd_y);
if (scale_new < scale)
break;
scale = scale_new;
y_min = y_min1;
y_max = y_max1;
}
// allow edge case where reqd_x = 1 would fit in a rhombus --> j = 1 here (and so on for higher reqd_x)
if (j < reqd_x)
continue;
/*
Here we get VERY lazy: instead of a proper double scanline algorithm, which can find
inscribed rectangle at non-integer x positions, we try the lazy route by looking at
whether we can drop a rectangle in the space delineated by the x points themselves
*/
// simplified search: we have a minimum width at least, how high can we go?
var scale = Math.min(j / reqd_x, (y_max - y_min) / reqd_y);
if (scale >= 1) {
if (!optimum || optimum.scale < scale) {
var x_offset = scale * reqd_x;
x_offset = (j - x_offset) / 2;
var y_offset = scale * reqd_y;
y_offset = (y_max + y_min - y_offset) / 2;
optimum = {
scale: scale,
x: i /* + x_offset */,
y: y_offset,
reqd_x: reqd_x,
reqd_y: reqd_y,
y_min: y_min,
y_max: y_max,
x0: i,
x1: i + j - step_x,
bbox: tinfo.text_bbox
};
continue;
}
}
/*
TODO:
Use a double scanline approach where we determine the interpolated X start
position where we can deliver a left edge of the minimum required height,
then scan forward to find the right-most interpolated X where the right edge
of the inscribed rect will be.
After that, or at the same time, the task is to also see if anything larger
is possible, i.e. find the biggest inscribed rect, where the text scale
determines the optimum (which is not strictly speaking identical to finding
the biggest inscribed rect in terms of surface area!)
*/
}
return optimum; // WARNING: MAY be falsey!
}
</script>
</body>
</html>
/* Inspired by Lee Byron's test data generator. */
function stream_layers(n, m, o) {
if (arguments.length < 3) o = 0;
function bump(a) {
var x = 1 / (.1 + Math.random()),
y = 2 * Math.random() - .5,
z = 10 / (.1 + Math.random());
for (var i = 0; i < m; i++) {
var w = (i / m - y) * z;
a[i] += x * Math.exp(-w * w);
}
}
return d3.range(n).map(function() {
var a = [], i;
for (i = 0; i < m; i++) a[i] = o + o * Math.random();
for (i = 0; i < 5; i++) bump(a);
return a.map(stream_index);
});
}
/* Another layer generator using gamma distributions. */
function stream_waves(n, m) {
return d3.range(n).map(function(i) {
return d3.range(m).map(function(j) {
var x = 20 * j / m - i / 3;
return 2 * x * Math.exp(-.5 * x);
}).map(stream_index);
});
}
function stream_index(d, i) {
return {x: i, y: Math.max(0, d)};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment