Skip to content

Instantly share code, notes, and snippets.

@susielu
Last active October 10, 2023 14:22
Show Gist options
  • Save susielu/1b10cd2de64b027a91e960c43c9bc23f to your computer and use it in GitHub Desktop.
Save susielu/1b10cd2de64b027a91e960c43c9bc23f to your computer and use it in GitHub Desktop.
Overlapping Bump Chart

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

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)
});
<!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>
'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);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment