block by syntagmatic 2420080

Canvas Parallel Coordinates - Shuffled Rendering

Full Screen

An example of an SVG interaction layer over a canvas rendering layer. Data from the USDA Nutrition Database.

This example has a few design differences from most parallel coordinates:

Find simpler examples of using canvas here and here

Interactions done with the brush component

Based on d3.js Parallel Coordinates

index.html

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
    <title>Canvas Parallel Coordinates</title>
    <style type="text/css">
body { 
  font-family: sans-serif;
  font-size: 12px;
  background: #f9f9f9;
  color: #777;
  margin-top: 40px;
}

body.dark {
  background: #090909;
  color: #ccc;
}

#wrap {
  width: 960px;
  margin: 0 auto;
  position: relative;
}

svg {
  font: 10px sans-serif;
}

canvas, svg {
  position: absolute;
  top: 0;
  left: 0;
}

#chart {
  position: relative;
}

.brush .extent {
  fill: rgba(0,0,0,0.12);
  stroke: rgba(255,255,255,0.6);
  shape-rendering: crisp-edges;
}

.dark .brush .extent {
  fill: rgba(255,255,255,0.12);
  stroke: rgba(0,0,0,0.5);
}

.axis line, .axis path {
  fill: none;
  stroke: #222;
  shape-rendering: crispEdges;
}

.axis text {
  fill: #222;
  text-shadow: 1px 1px 1px #fff, -1px -1px 1px #fff;
}

.axis text.label {
  fill: #444;
  font-size: 14px;
}

.dark .axis text {
  fill: #f2f2f2;
  text-shadow: 0 1px 0 #000, 1px 0 0 #000;
}

.dark .axis text.label {
  fill: #ddd;
}

.axis g,
.axis path {
  display: none;
}

#food-list {
  position: absolute;
  left: 220px;
  width: 740px;
  overflow-x: hidden;
}
#food-list span {
  display: inline-block;
  height: 6px;
  width: 6px;
  margin: 2px 4px;
}
    </style>
  </head>
  <body>
  <div id="wrap">
    <div id="chart">
      <canvas id="foreground"></canvas>
      <svg></svg>
    </div>
    <pre id="food-list"></pre>
    <p>
    Rendered: <strong id="rendered-count"></strong><br/>
    Selected: <strong id="selected-count"></strong><br/>
    Opacity: <strong id="opacity"></strong><br/>
    <button id="hide-ticks">Hide Ticks</button>
    <button id="show-ticks">Show Ticks</button><br/>
    <button id="dark-theme">Dark</button>
    <button id="light-theme">Light</button>
    </p>
    <p>
      Drag along a vertical axis to brush<br/>
      Tap the axis to remove its brush                                          
    </p>
  </div>
    <script src="//mbostock.github.com/d3/d3.v2.js"></script>
    <script src="parallel.js"></script>
  </body>
</html>

parallel.js

// shim layer with setTimeout fallback
window.requestAnimFrame = window.requestAnimationFrame       ||
                          window.webkitRequestAnimationFrame ||
                          window.mozRequestAnimationFrame    ||
                          window.oRequestAnimationFrame      ||
                          window.msRequestAnimationFrame     ||
                          function( callback ){
                            window.setTimeout(callback, 1000 / 60);
                          };

var m = [60, 10, 10, 0],
    w = 960 - m[1] - m[3],
    h = 290 - m[0] - m[2];

var xscale = d3.scale.ordinal().rangePoints([0, w], 1),
    yscale = {};

var line = d3.svg.line(),
    axis = d3.svg.axis().orient("left"),
    foreground,
    dimensions,
    brush_count = 0;

var colors = {
  "Dairy and Egg Products": [28,100,52],
  "Spices and Herbs": [214,55,79],
  "Baby Foods": [185,56,73],
  "Fats and Oils": [30,100,73],
  "Poultry Products": [359,69,49],
  "Soups, Sauces, and Gravies": [110,57,70],
  "Vegetables and Vegetable Products": [120,56,40],
  "Sausages and Luncheon Meats": [1,100,79],
  "Breakfast Cereals": [271,39,57],
  "Fruits and Fruit Juices": [274,30,76],
  "Nut and Seed Products": [10,30,42],
  "Beverages": [10,28,67],
  "Finfish and Shellfish Products": [318,65,67],
  "Legumes and Legume Products": [334,80,84],
  "Baked Products": [37,50,75],
  "Sweets": [339,60,75],
  "Cereal Grains and Pasta": [56,58,73],
  "Pork Products": [339,60,49],
  "Beef Products": [325,50,39],
  "Lamb, Veal, and Game Products": [20,49,49],
  "Fast Foods": [60,86,61],
  "Meals, Entrees, and Sidedishes": [185,80,45],
  "Snacks": [189,57,75],
  "Ethnic Foods": [41,75,61],
  "Restaurant Foods": [204,70,41]
};

d3.select("#chart")
    .style("width", (w + m[1] + m[3]) + "px")
    .style("height", (h + m[0] + m[2]) + "px")

d3.selectAll("canvas")
    .attr("width", w)
    .attr("height", h)
    .style("padding", m.join("px ") + "px");

d3.select("#hide-ticks")
    .on("click", function() {
      d3.selectAll(".axis g").style("display", "none");
      d3.selectAll(".axis path").style("display", "none");
    });

d3.select("#show-ticks")
    .on("click", function() {
      d3.selectAll(".axis g").style("display", "block");
      d3.selectAll(".axis path").style("display", "block");
    });

d3.select("#dark-theme")
    .on("click", function() {
      d3.select("body").attr("class", "dark");
    });

d3.select("#light-theme")
    .on("click", function() {
      d3.select("body").attr("class", null);
    });

foreground = document.getElementById('foreground').getContext('2d');

foreground.strokeStyle = "rgba(0,100,160,0.1)";
foreground.lineWidth = 1.3;    // avoid weird subpixel effects

foreground.fillText("Loading...",w/2,h/2);

var svg = d3.select("svg")
    .attr("width", w + m[1] + m[3])
    .attr("height", h + m[0] + m[2])
  .append("svg:g")
    .attr("transform", "translate(" + m[3] + "," + m[0] + ")");

d3.csv("nutrients.csv", function(data) {

  // Convert quantitative scales to floats
  data = data.map(function(d) {
    for (var k in d) {
      if (k != "name" && k != "group" && k != "id")
        d[k] = parseFloat(d[k]) || 0;
    };
    return d;
  });

  // Extract the list of dimensions and create a scale for each.
  xscale.domain(dimensions = d3.keys(data[0]).filter(function(d) {
    return d != "name" && d != "group" && d != "id" &&(yscale[d] = d3.scale.linear()
        .domain(d3.extent(data, function(p) { return +p[d]; }))
        .range([h, 0]));
  }));

  // Render full foreground
  paths(data, foreground, brush_count);

  // Add a group element for each dimension.
  var g = svg.selectAll(".dimension")
      .data(dimensions)
    .enter().append("svg:g")
      .attr("class", "dimension")
      .attr("transform", function(d) { return "translate(" + xscale(d) + ")"; });

  // Add an axis and title.
  g.append("svg:g")
      .attr("class", "axis")
      .each(function(d) { d3.select(this).call(axis.scale(yscale[d])); })
    .append("svg:text")
      .attr("text-anchor", "left")
      .attr("y", -8)
      .attr("x", -4)
      .attr("transform", "rotate(-19)")
      .attr("class", "label")
      .text(String);

  // Add and store a brush for each axis.
  g.append("svg:g")
      .attr("class", "brush")
      .each(function(d) { d3.select(this).call(yscale[d].brush = d3.svg.brush().y(yscale[d]).on("brush", brush)); })
    .selectAll("rect")
      .attr("x", -16)
      .attr("width", 32)
      .attr("rx", 3)
      .attr("ry", 3);

  // Handles a brush event, toggling the display of foreground lines.
  function brush() {
    brush_count++;
    var actives = dimensions.filter(function(p) { return !yscale[p].brush.empty(); }),
        extents = actives.map(function(p) { return yscale[p].brush.extent(); });

    // Get lines within extents
    var selected = [];
    data.map(function(d) {
      return actives.every(function(p, i) {
        return extents[i][0] <= d[p] && d[p] <= extents[i][1];
      }) ? selected.push(d) : null;
    });

    // Render selected lines
    paths(selected, foreground, brush_count);
  }

  function paths(data, ctx, count) {
    var n = data.length,
        i = 0,
        opacity = d3.min([2/Math.pow(n,0.37),1]);
    d3.select("#selected-count").text(n);
    d3.select("#opacity").text((""+opacity).slice(0,6));

    data = shuffle(data);

    // data table
    var foodText = "";
    data.slice(0,10).forEach(function(d) {
      foodText += "<span style='background:" + color(d.group,0.85) + "'></span>" + d.name + "<br/>";
    });
    d3.select("#food-list").html(foodText);

    ctx.clearRect(0,0,w+1,h+1);
    function render() {
      var max = d3.min([i+12, n]);
      data.slice(i,max).forEach(function(d) {
        path(d, foreground, color(d.group,opacity));
      });
      i = max;
      d3.select("#rendered-count").text(i);
    };

    // render all lines until finished or a new brush event
    (function animloop(){
      if (i >= n || count < brush_count) return;
      requestAnimFrame(animloop);
      render();
    })();
  };
});


function path(d, ctx, color) {
  if (color) ctx.strokeStyle = color;
  ctx.beginPath();
  var x0 = 0,
      y0 = 0;
  dimensions.map(function(p,i) {
    var x = xscale(p),
        y = yscale[p](d[p]);
    if (i == 0) {
      ctx.moveTo(x,y);
    } else { 
      var cp1x = x - 0.85*(x-x0);
      var cp1y = y0;
      var cp2x = x - 0.15*(x-x0);
      var cp2y = y;
      ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
    }
    x0 = x;
    y0 = y;
  });
  ctx.stroke();
};

function color(d,a) {
  var c = colors[d];
  return ["hsla(",c[0],",",c[1],"%,",c[2],"%,",a,")"].join("");
};

// Fisher-Yates shuffle
function shuffle(array) {
  var m = array.length, t, i;

  // While there remain elements to shuffle…
  while (m) {

    // Pick a remaining element…
    i = Math.floor(Math.random() * m--);

    // And swap it with the current element.
    t = array[m];
    array[m] = array[i];
    array[i] = t;
  }

  return array;
}