block by renecnielsen 5752d98ff56b31079d0d6cedadfca9be

Overlapping Bump Chart

Full Screen

Overlapping Bump Chart

Playing around with the idea of a bump chart comparison with two overlapping areas. Based on Farmers Markets data from data.gov.

Another iteration with step interpolation instead of cardinal.

Alt text

forked from susielu‘s block: Overlapping Bump Chart

index.js

'use strict';

var svg = d3.select('svg');
svg.append('text').attr('class', 'title').attr('x', 960 / 2).attr('y', 35).text("Farmers' Markets Goods Comparison");

d3.json('https://gist.githubusercontent.com/susielu/3d194b8660ec6ab214a3/raw/38a2cdc96efaaaeb4849c86b600de5dfecea2dec/farmers-markets-lat-long.json', function (error, data) {
  var h = 480;
  var w = 800;
  var padding = 20;
  var xScale = d3.scaleLinear().range([padding, w - padding]).domain([-130, -65]);
  var xBarScale = d3.scaleLinear().range([padding, w - padding]);
  var yScale = d3.scaleLinear().range([h - padding, padding]).domain([20, 50]);
  var comp1 = "maple";
  var comp2 = "seafood";
  var selected = 'comp1';
  var offset = 'translate(60, 40)';

  //Filtering out states outside of the contiguous US for simplicity
  data = data.filter(function (d) {
    return d.x >= -130 && d.x <= -65 && d.y >= 20 && d.y <= 50;
  });

  //Making a legend w00t http://d3-legend.susielu.com/
  var colors = d3.scaleOrdinal().domain(['' + comp1, '' + comp2, 'both']).range(["rgba(0, 200, 200, .5)", "rgba(200, 0, 200, .5)", "#ac8cdc"]);

  var colorLegend = d3.legendColor().shapeHeight(8).shapePadding(5).scale(colors);

  svg.append('g').attr('class', 'legend').attr('transform', 'translate(200, 390)').call(colorLegend);

  var map = svg.append('g').attr('class', 'map').attr('transform', offset);

  map.selectAll('circle').data(data).enter().append('circle').attr('r', 1).attr('cx', function (d) {
    return xScale(d.x);
  }).attr('cy', function (d) {
    return yScale(d.y);
  });

  var rollup = function rollup(leaves) {
    var first = 0;
    var second = 0;
    var both = 0;

    leaves.forEach(function (l) {
      if (l[comp1] === "Y") {
        first++;
      }
      if (l[comp2] === "Y") {
        second++;
      }
      if (l[comp1] === "Y" && l[comp2] === "Y") {
        both++;
      }
    });

    return {
      length: leaves.length,
      comp1: first,
      comp2: second,
      both: both
    };
  };

  var lat = svg.append('g').attr('class', 'lat').attr('transform', offset);

  var latArea = d3.area().x(function (d) {
    return xScale(parseInt(d.key));
  }).y1(function (d) {
    return yLatScale(d.value.length);
  }).y0(function (d) {
    return yLatScale(0);
  }).curve(d3.curveCardinal),
      latNested = d3.nest().key(function (d) {
    return Math.round(d.x);
  }).rollup(rollup).entries(data).sort(function (a, b) {
    return parseInt(a.key) - parseInt(b.key);
  });

  var yLatMax = d3.max(latNested, function (d) {
    return d.value.length;
  });
  var yLatScale = d3.scaleLinear().range([h - 40, h - 140]).domain([0, yLatMax]);

  //Makes a horizontal bar chart then rotates it for the longitudinal graph
  var long = svg.append('g').attr('class', 'long').attr('transform', 'rotate(90, ' + (w + 60) + ', 40) ' + offset);

  var xLongScale = d3.scaleLinear().range([w + padding, w + h - padding]).domain([50, 20]);

  var longArea = d3.area().x(function (d) {
    return xLongScale(parseInt(d.key));
  }).y1(function (d) {
    return yLongScale(d.value.length);
  }).y0(function (d) {
    return yLongScale(0);
  }).curve(d3.curveCardinal),
      longNested = d3.nest().key(function (d) {
    return Math.round(d.y);
  }).rollup(rollup).entries(data).sort(function (a, b) {
    return parseInt(a.key) - parseInt(b.key);
  });

  var yLongMax = d3.max(longNested, function (d) {
    return d.value.length;
  });
  var yLongScale = d3.scaleLinear().range([padding, padding - 100]).domain([0, yLongMax]);

  var transition = d3.transition().ease(d3.easePolyInOut);

  var createHistogram = function createHistogram(group, area, nest) {
    group.append('path').attr('fill', 'none').attr('stroke', 'grey').attr('d', area(nest));

    group.append('path').attr('class', 'comp1');

    group.append('path').attr('class', 'comp2');
  };

  var updateMap = function updateMap() {
    map.selectAll('circle').attr('class', function (d) {
      return d[comp1] === "Y" && d[comp2] === "Y" ? 'compBoth' : d[comp1] === "Y" ? 'comp1' : d[comp2] === "Y" ? 'comp2' : '';
    });
  };

  var updateHistogram = function updateHistogram(type, group, area, nest, scale) {
    var nestKey = type === "lat" ? 'x' : 'y';
    nest = d3.nest().key(function (d) {
      return Math.round(d[nestKey]);
    }).rollup(rollup).entries(data).sort(function (a, b) {
      return parseInt(a.key) - parseInt(b.key);
    });

    //Overlapping bump area logic
    area.y1(function (d) {
      if (d.value.comp1 > d.value.comp2) {
        return scale(d.value.comp1);
      } else {
        return scale(d.value.comp1 + d.value.comp2 - d.value.both);
      }
    });

    area.y0(function (d) {
      if (d.value.comp1 > d.value.comp2) {
        return scale(0);
      } else {
        return scale(d.value.comp2 - d.value.both);
      }
    });

    group.select('path.comp1').transition(transition).attr('d', area(nest));

    //Overlapping bump area logic
    area.y1(function (d) {
      if (d.value.comp2 > d.value.comp1) {
        return scale(d.value.comp2);
      } else {
        return scale(d.value.comp1 + d.value.comp2 - d.value.both);
      }
    });

    area.y0(function (d) {
      if (d.value.comp2 > d.value.comp1) {
        return scale(0);
      } else {
        return scale(d.value.comp1 - d.value.both);
      }
    });

    group.select('path.comp2').transition(transition).attr('d', area(nest));
  };

  var update = function update() {
    updateMap();
    updateHistogram('lat', lat, latArea, latNested, yLatScale);
    updateHistogram('long', long, longArea, longNested, yLongScale);

    //Update text colors in Goods selector
    svg.selectAll('.types text').attr('class', function (d) {
      return d.key === comp1 ? 'comp1' : d.key === comp2 ? 'comp2' : '';
    });

    //Update legend key
    colors.domain(['' + comp1, '' + comp2, 'both']);
    colorLegend.scale(colors);
    svg.select('g.legend').call(colorLegend);
  };

  //Initial render of graphs and map
  createHistogram(lat, latArea, latNested);
  createHistogram(long, longArea, longNested);
  update();

  var variables = [{ "key": "vegetables", "label": "Vegetables 96%", "percent": .96 }, { "key": "bakedgoods", "label": "Baked Goods 88%", "percent": .88 }, { "key": "honey", "label": "Honey 81%", "percent": .81 }, { "key": "jams", "label": "Jams 80%", "percent": .80 }, { "key": "fruits", "label": "Fruits 80%", "percent": .80 }, { "key": "herbs", "label": "Herbs 79%", "percent": .79 }, { "key": "eggs", "label": "Eggs 74%", "percent": .74 }, { "key": "flower", "label": "Flowers 69%", "percent": .69 }, { "key": "soap", "label": "Soap 67%", "percent": .67 }, { "key": "plants", "label": "Plants 66%", "percent": .66 }, { "key": "crafts", "label": "Crafts 61%", "percent": .61 }, { "key": "prepared", "label": "Prepared Food 61%", "percent": .61 }, { "key": "meat", "label": "Meat 55%", "percent": .55 }, { "key": "cheese", "label": "Cheese 50%", "percent": .50 }, { "key": "poultry", "label": "Poultry 45%", "percent": .45 }, { "key": "coffee", "label": "Coffee 33%", "percent": .33 }, { "key": "maple", "label": "Maple 32%", "percent": .32 }, { "key": "nuts", "label": "Nuts 29%", "percent": .29 }, { "key": "trees", "label": "Trees 29%", "percent": .29 }, { "key": "seafood", "label": "Seafood 24%", "percent": .24 }, { "key": "juices", "label": "Juices 22%", "percent": .22 }, { "key": "mushrooms", "label": "Mushrooms 22%", "percent": .22 }, { "key": "petfood", "label": "Pet Food 18%", "percent": .18 }, { "key": "wine", "label": "Wine 17%", "percent": .17 }, { "key": "beans", "label": "Beans 14%", "percent": .14 }, { "key": "grains", "label": "Grains 14%", "percent": .14 }, { "key": "wildharvest", "label": "Wild Harvest 13%", "percent": .13 }, { "key": "nursery", "label": "Nursery 6%", "percent": .06 }, { "key": "tofu", "label": "Tofu 4%", "percent": .04 }];

  svg.append('text').attr('class', '.controlTitle').attr('x', 20).attr('y', 40).text('Goods selector');

  svg.selectAll('rect.control').data(['comp1', 'comp2']).enter().append('rect').attr('x', function (d, i) {
    return 20 + i * 20;
  }).attr('y', 50).attr('width', 15).attr('height', 15).attr('class', function (d) {
    return 'control ' + d + ' ' + (selected === d ? 'selected' : '');
  }).on('click', function (d) {
    if (selected === "comp1") {
      selected = "comp2";
    } else {
      selected = "comp1";
    }

    svg.selectAll('rect.control').attr('class', function (d) {
      return 'control ' + d + ' ' + (selected === d ? 'selected' : '');
    });
  });

  var types = svg.append('g').attr('class', 'types');

  var changeComp = function changeComp(d) {
    if (selected === "comp1") {
      comp1 = d.key;
    } else {
      comp2 = d.key;
    }
    update();
  };

  types.selectAll('text').data(variables).enter().append('text').attr('x', 20).attr('y', function (d, i) {
    return i * 14 + 80;
  }).text(function (d) {
    return d.label;
  }).attr('class', function (d) {
    return d.key === comp1 ? 'comp1' : d.key === comp2 ? 'comp2' : '';
  }).on('click', changeComp);
});

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <link href='https://fonts.googleapis.com/css?family=Lato:300,900' rel='stylesheet' type='text/css'>

    <style>
      body{
        background-color: whitesmoke;
      }

      svg {
         background-color: white;
         font-family: 'Lato';
      }

      text.title {
        text-anchor: middle;
        font-size: 20px;
      }

      .legend text {
        font-size: 12px;
      }

       path {
         fill-opacity: .8;
       }

       circle {
         fill: grey;
         opacity: .7;
       }

       .comp1 {
         fill: rgb(0, 200, 200);
       }

       .comp2 {
         fill: rgb(200, 0, 200);
       }

       .compBoth {
         fill: #ac8cdc;
       }

       path.comp1, path.comp2 {
         opacity: .5;
       }

       rect {
         opacity: .8;
         cursor: pointer;
       }

       rect.comp1 {
         stroke: rgb(0, 200, 200);
       }

       rect.comp2 {
         stroke: rgb(200, 0, 200);
       }

       rect:not(.selected) {
         fill: white;
       }

       .types {
         font-size: 8px;
         text-transform: uppercase;
         font-weight: bold;
         cursor: pointer;
       }

    </style>
</head>
<body>
    <svg width="960" height="500"></svg>
    <script src="https://d3js.org/d3.v4.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3-legend/2.9.0/d3-legend.min.js"></script>
    <script src="index.js"></script>
</body>
</html>

index-es6.js

const svg = d3.select('svg')
svg.append('text')
  .attr('class', 'title')
  .attr('x', 960/2)
  .attr('y', 35)
  .text("Farmers' Markets Goods Comparison")

d3.json('https://gist.githubusercontent.com/susielu/3d194b8660ec6ab214a3/raw/38a2cdc96efaaaeb4849c86b600de5dfecea2dec/farmers-markets-lat-long.json',  (error, data) => {
  const h = 480
  const w = 800
  const padding = 20
  const xScale = d3.scaleLinear().range([padding, w - padding]).domain([-130, -65])
  const xBarScale = d3.scaleLinear().range([padding, w - padding])
  const yScale = d3.scaleLinear().range([h - padding,  padding]).domain([20, 50])
  let comp1 = "maple"
  let comp2 = "seafood"
  let selected = 'comp1'
  const offset = 'translate(60, 40)'

  //Filtering out states outside of the contiguous US for simplicity
  data = data.filter(d => d.x >= -130 && d.x <= -65 && d.y >= 20 && d.y <=50)

  //Making a legend w00t http://d3-legend.susielu.com/
  const colors = d3.scaleOrdinal().domain([`${comp1}` , `${comp2}`, 'both']).range([
    "rgba(0, 200, 200, .5)",
    "rgba(200, 0, 200, .5)",
    "#ac8cdc"
  ])

  const colorLegend = d3.legendColor()
    .shapeHeight(8)
    .shapePadding(5)
    .scale(colors)

  svg.append('g')
    .attr('class', 'legend')
    .attr('transform', 'translate(200, 390)')
    .call(colorLegend)

  const map = svg.append('g')
    .attr('class', 'map')
    .attr('transform', offset)

  map.selectAll('circle')
    .data(data)
    .enter()
    .append('circle')
    .attr('r', 1)
    .attr('cx', d => xScale(d.x))
    .attr('cy', d => yScale(d.y))

  const rollup = leaves => {
    let first = 0
    let second = 0
    let both = 0

    leaves.forEach(l => {
      if (l[comp1] === "Y"){ first++ }
      if (l[comp2] === "Y"){ second++}
      if (l[comp1] === "Y" && l[comp2] === "Y"){ both++ }
    })

    return {
      length: leaves.length,
      comp1: first,
      comp2: second,
      both: both
    }
  }

  const lat = svg.append('g')
    .attr('class', 'lat')
    .attr('transform', offset)

  let latArea = d3.area()
      .x(d => xScale(parseInt(d.key)))
      .y1(d => yLatScale(d.value.length))
      .y0(d => yLatScale(0))
      .curve(d3.curveCardinal),
    latNested = d3.nest()
      .key(d => Math.round(d.x))
      .rollup(rollup)
      .entries(data)
      .sort((a,b) => parseInt(a.key) - parseInt(b.key));

  const yLatMax = d3.max(latNested, d => d.value.length)
  const yLatScale = d3.scaleLinear().range([h -40, h - 140]).domain([0, yLatMax])

  //Makes a horizontal bar chart then rotates it for the longitudinal graph
  const long = svg.append('g')
    .attr('class', 'long')
    .attr('transform', `rotate(90, ${w + 60}, 40) ${offset}`)

  const xLongScale = d3.scaleLinear().range([w + padding, w + h - padding]).domain([50, 20])

  let longArea = d3.area()
    .x(d => xLongScale(parseInt(d.key)))
    .y1(d => yLongScale(d.value.length))
    .y0(d => yLongScale(0))
    .curve(d3.curveCardinal),
   longNested = d3.nest()
    .key(d => Math.round(d.y))
    .rollup(rollup)
    .entries(data)
    .sort((a,b) => parseInt(a.key) - parseInt(b.key))

  const yLongMax = d3.max(longNested, d => d.value.length)
  const yLongScale = d3.scaleLinear().range([ padding , padding - 100]).domain([0, yLongMax])

  const transition = d3.transition()
    .ease(d3.easePolyInOut)

  const createHistogram = (group, area, nest) => {
    group.append('path')
      .attr('fill', 'none')
      .attr('stroke', 'grey')
      .attr('d', area(nest))

    group.append('path')
      .attr('class', 'comp1')

    group.append('path')
      .attr('class', 'comp2')
  }

  const updateMap = () => {
    map.selectAll('circle')
    .attr('class', d => d[comp1] === "Y" && d[comp2] === "Y" ?
      'compBoth' :
      d[comp1] === "Y" ?
        'comp1' : d[comp2] === "Y" ?
        'comp2' : '')
  }

  const updateHistogram = (type, group, area, nest, scale) => {
    const nestKey = type === "lat" ? 'x' : 'y'
    nest = d3.nest()
      .key(d => Math.round(d[nestKey]))
      .rollup(rollup)
      .entries(data)
      .sort((a,b) => parseInt(a.key) - parseInt(b.key))

    //Overlapping bump area logic
    area.y1(d => {
      if (d.value.comp1 > d.value.comp2){
        return scale(d.value.comp1)
      } else {
        return scale(d.value.comp1 + d.value.comp2 - d.value.both)
      }
    })

    area.y0(d => {
      if (d.value.comp1 > d.value.comp2){
        return scale(0)
      } else {
        return scale(d.value.comp2 - d.value.both)
      }
    })

    group.select('path.comp1')
      .transition(transition)
      .attr('d', area(nest))

    //Overlapping bump area logic
    area.y1(d => {
      if (d.value.comp2 > d.value.comp1){
        return scale(d.value.comp2)
      } else {
        return scale(d.value.comp1 + d.value.comp2 - d.value.both)
      }
    })

    area.y0(d => {
      if (d.value.comp2 > d.value.comp1){
        return scale(0)
      } else {
        return scale(d.value.comp1 - d.value.both)
      }
    })

    group.select('path.comp2')
      .transition(transition)
      .attr('d', area(nest))
  }

  const update = ()=> {
    updateMap()
    updateHistogram('lat', lat, latArea, latNested, yLatScale)
    updateHistogram('long', long, longArea, longNested, yLongScale)

    //Update text colors in Goods selector
    svg.selectAll('.types text')
    .attr('class', d => d.key === comp1 ? 'comp1' : d.key === comp2 ? 'comp2' : '')

    //Update legend key
    colors.domain([`${comp1}` , `${comp2}`, 'both'])
    colorLegend.scale(colors)
    svg.select('g.legend').call(colorLegend)

  }

  //Initial render of graphs and map
  createHistogram(lat, latArea, latNested)
  createHistogram(long, longArea, longNested)
  update()

  const variables = [
    { "key": "vegetables", "label": "Vegetables 96%", "percent": .96},
    { "key": "bakedgoods", "label": "Baked Goods 88%", "percent": .88},
    { "key": "honey", "label": "Honey 81%", "percent": .81},
    { "key": "jams", "label": "Jams 80%", "percent": .80},
    { "key": "fruits", "label": "Fruits 80%", "percent": .80},
    { "key": "herbs", "label": "Herbs 79%", "percent": .79},
    { "key": "eggs", "label": "Eggs 74%", "percent": .74},
    { "key": "flower", "label": "Flowers 69%", "percent": .69},
    { "key": "soap", "label": "Soap 67%", "percent": .67 },
    { "key": "plants", "label": "Plants 66%", "percent": .66},
    { "key": "crafts", "label": "Crafts 61%", "percent": .61},
    { "key": "prepared", "label": "Prepared Food 61%", "percent": .61},
    { "key": "meat", "label": "Meat 55%", "percent": .55},
    { "key": "cheese", "label": "Cheese 50%", "percent": .50},
    { "key": "poultry", "label": "Poultry 45%", "percent": .45},
    { "key": "coffee", "label": "Coffee 33%", "percent": .33},
    { "key": "maple", "label": "Maple 32%", "percent": .32},
    { "key": "nuts", "label": "Nuts 29%", "percent": .29},
    { "key": "trees", "label": "Trees 29%", "percent": .29},
    { "key": "seafood", "label": "Seafood 24%", "percent": .24},
    { "key": "juices", "label": "Juices 22%", "percent": .22},
    { "key": "mushrooms", "label": "Mushrooms 22%", "percent": .22},
    { "key": "petfood", "label": "Pet Food 18%", "percent": .18},
    { "key": "wine", "label": "Wine 17%", "percent": .17},
    { "key": "beans", "label": "Beans 14%", "percent": .14},
    { "key": "grains", "label": "Grains 14%", "percent": .14},
    { "key": "wildharvest", "label": "Wild Harvest 13%", "percent": .13},
    { "key": "nursery", "label": "Nursery 6%", "percent": .06},
    { "key": "tofu", "label": "Tofu 4%", "percent": .04},
  ]

  svg.append('text')
    .attr('class', '.controlTitle')
    .attr('x', 20)
    .attr('y', 40)
    .text('Goods selector')

  svg.selectAll('rect.control')
    .data(['comp1', 'comp2'])
    .enter()
    .append('rect')
    .attr('x', (d, i) => 20 + i*20)
    .attr('y', 50)
    .attr('width', 15)
    .attr('height', 15)
    .attr('class', d => `control ${d} ${selected === d ? 'selected' : ''}`)
    .on('click', d => {
      if (selected === "comp1"){
        selected = "comp2"
      } else {
        selected = "comp1"
      }

      svg.selectAll('rect.control')
        .attr('class', d => `control ${d} ${selected === d ? 'selected' : ''}`)
    })


  const types = svg.append('g')
    .attr('class', 'types')

  let changeComp = (d) => {
     if (selected === "comp1"){
       comp1 = d.key
     } else {
       comp2 = d.key
     }
     update()
  }

  types.selectAll('text')
    .data(variables)
    .enter()
    .append('text')
    .attr('x', 20)
    .attr('y', (d, i) => i*14 + 80)
    .text(d => d.label)
    .attr('class', d => d.key === comp1 ? 'comp1' : d.key === comp2 ? 'comp2' : '')
    .on('click', changeComp)

});