Using D3, I want to take the data visualization type of a classical heatmap...
.. onto a compartmentalized version of several heatmap groups drawing data from a single data source.
Technically this should be one heatmap element drawing its data from a single source - separation and thus clustering/grouping is supposed to happen through sorting the data in the *.csv file (group one, group two, group three..) and the D3 *.JS file handling the styling.
While generating a single map:
// Build X scales and axis:
const x = d3.scaleBand()
.range([0, width])
.domain(myGroups)
.padding(0.00);
svg.append('g')
.attr('transform', `translate(0,${height})`)
.call(d3.axisBottom(x));
// Build Y scales and axis:
const y = d3.scaleBand()
.range([height, 0])
.domain(myVars)
.padding(0.00);
svg.append('g')
.call(d3.axisLeft(y));
assigning a color:
// Assign color scale
const myColor = d3.scaleLinear()
.range(['red', '#750606'])
.domain([1, 100]);
and fetching (sample) data:
// Read the data
d3.csv('https://raw.githubusercontent.com/holtzy/D3-graph-gallery/master/DATA/heatmap_data.csv', (data) => {
data.sort(function(a, b) {
return myVars.indexOf(b.variable) - myVars.indexOf(a.variable) || myGroups.indexOf(a.group) - myGroups.indexOf(b.group)
});
Has been working like a charm:
CodePen
I'm struggling to expand this basic structure onto the generation of multiple groups as described above. Expanding the color scheme, trying to build several additional X and Y axis that cover different ranges result in a complete break of the D3 element rendering the map unable to be displayed at all.
Can someone point me in the right direction on how to generate multiple heatmap groups without breaking the heatmap?
I was able to solve the compartmentalization using a row and column based procedure to construct the compartments:
// Dimensions
const numCategoryCols = 4;
const numCategoryRows = Math.ceil(grouped.length / numCategoryCols);
const numEntryCols = 3;
const numEntryRows = Math.ceil(grouped[0].values.length / numEntryCols);
const gridSize = 20;
const width = gridSize * numCategoryCols * numEntryCols;
const height = gridSize * numCategoryRows * numEntryRows;
const tooltipArrowSize = 8;
// Containers
const container = d3
.select("#" + containerId)
.classed("heatmap-grid", true)
.style("position", "relative");
const svg = container
.append("svg")
.style("display", "block")
.style("width", "100%")
.attr("viewBox", [0, 0, width, height])
.style("opacity", 0);
svg.transition()
.duration(3000)
.delay((d,i) => i*200)
.style("opacity", 1)
// Heatmap
const gCategory = svg
.selectAll(".category-g")
.data(grouped, (d) => d.key)
.join("g")
.attr("class", "category-g")
.attr("fill", (d) => color(d.key))
.attr("transform", (_, i) => {
const y = Math.floor(i / numCategoryCols);
const x = i % numCategoryCols;
return `translate(${gridSize * numEntryCols * x},${
gridSize * numEntryRows * y
})`;
});
const gEntry = gCategory
.selectAll(".entry-g")
.data((d) => d.values)
.join("g")
.attr("class", "entry-g")
.attr("transform", (_, i) => {
const y = Math.floor(i / numEntryCols);
const x = i % numEntryCols;
return `translate(${gridSize * x},${gridSize * y})`;
});
const entry = gEntry
.append("rect")
.attr("width", gridSize)
.attr("height", gridSize)
.attr("fill-opacity", (d) => d.Severity / 100)
.on("mouseenter", showTooltip)
.on("mouseleave", hideTooltip);
Related
I'm using d3js to draw a chart which plots two data series as two lines.
However, parts of the bottom line (the blue line) are obscured:
Hiding either line by adding display: none in the browser's devel tools shows both lines fully rendered.
The rendered SVG looks like this (sorry for the picture, hard to copy the text):
Each path is created by its own D3 function because the vertical scales are different:
var theLineFcnA = d3.line()
.x(function (d) { return xScaleT(d.t); })
.y(function (d) { return yScaleA(d.v); });
var theLineFcnB = d3.line()
.x(function (d) { return xScaleT(d.t); })
.y(function (d) { return yScaleB(d.v); });
And called like this:
function plotLineA(plotData)
{
if (theSVG === null)
return;
// plot it
var theALine = theSVG.select('.lineChart').select("path.lineA");
theALine.data([plotData]);
theSVG.select("g.x.axis").call(xAxis);
theSVG.select("g.y.axisA").call(yAxisA);
theSVG.select("path.lineA").attr("d", theLineFcnA);
}
(there is a similar function for line B)
Any idea on how to fix this? I've fiddled around with various CSS properties on the line but not sure what else to do.
Many thanks
I suppose you set the width of the bottom curve (which should be the first path laid down) to be thicker than the that of the top curve. Here's an example:
let N = 12;
let n = 5;
let cur = 0;
let pts1 = d3.range(N).map(function (x) {
let step = 2 * d3.randomInt(0, 2)() - 1;
cur = cur + step;
return [x, cur];
});
cur = pts1[n - 1][1];
let pts2 = pts1.slice(0, n);
pts2 = pts2.concat(
d3.range(n, N).map(function (x) {
let step = 2 * d3.randomInt(0, 2)() - 1;
cur = cur + step;
return [x, cur];
})
);
let [ymin, ymax] = d3.extent(pts1.concat(pts2).map((d) => d[1]));
let width = 500;
let w = d3.min([800, width]);
let h = 0.625 * w;
let pad = 20;
let x_scale = d3
.scaleLinear()
.domain([0, N])
.range([pad, w - pad]);
let y_scale = d3
.scaleLinear()
.domain([ymin, ymax])
.range([h - pad, pad]);
let pts_to_path = d3
.line()
.x((d) => x_scale(d[0]))
.y((d) => y_scale(d[1]));
let svg = d3.select('#container')
.append("svg")
.attr("width", w)
.attr("height", h);
svg
.selectAll("path")
.data([pts1, pts2])
.join("path")
.attr("d", pts_to_path)
.attr("stroke", (_, i) => d3.schemeCategory10[i])
.attr("stroke-width", (_, i) => (i == 0 ? 6 : 2))
.attr("fill", "none")
svg
.append("g")
.attr("transform", `translate(0, ${h / 2})`)
.call(d3.axisBottom(x_scale));
svg
.append("g")
.attr("transform", `translate(${pad})`)
.call(d3.axisLeft(y_scale).ticks(4));
<script src="https://cdn.jsdelivr.net/npm/d3#7"></script>
<script src="https://cdn.jsdelivr.net/npm/#observablehq/plot#0.6"></script>
<div id="container"></div>
I'm currently working on trying to create a "facet plot" within d3.js, which I realize in D3 language isn't really a thing. However, I found this thread which outlined how to accomplish the task. From this post, I've converted the line chart from vertical to horizontal, and added a few extra padding elements where needed.
Now I would like to add a tooltip to each plot. From working with other pieces of code, this seems quite challenging. For some reason I can't figure out how to attach the tooltip to each individual plot. Any thoughts on how I might be able to accomplish this?
What it currently looks like:
// Data and manipluation:
const data = d3.range(25).map(i => ({
bib: Math.floor(i / 5) + 1,
ratio: -1 + Math.random() * 5,
run: [1, 2, 3, 4, 5][i % 5],
run: [1, 2, 3, 4, 5][i % 5],
name: ['metric1', 'metric2', 'metric3', 'metric4', 'metric5'][Math.floor(i / 5)]
}));
const grouped = d3.group(data,d=>d.bib);
// Dimensions:
const height = 800;
const width = 700;
const margin = {
top: 10,
left: 50,
right: 50,
bottom: 50
}
const padding = 30;
const doublePadding = padding * 2;
const plotHeight = (height-doublePadding)/grouped.size - padding;
const plotWidth = width-padding*2;
// const plotWidth = (width-padding)/grouped.size - padding;
// const plotHeight = height-padding*2;
const svg = d3.select("#chart1")
.append("svg")
.attr("width", margin.left+width+margin.right)
.attr("height", margin.top+height+margin.bottom+(padding*grouped.size));
const g = svg.append("g")
.attr("transform","translate("+[margin.left,margin.top]+")");
//Scales:
const xScale = d3.scaleLinear()
.domain(d3.extent(data, d => d.run))
.range([0, plotWidth]);
const yScale = d3.scaleLinear()
.domain(d3.extent(data, d => d.ratio))
.range([plotHeight,0]);
// Place plots:
const plots = g.selectAll(null)
.data(grouped)
.enter()
.append("g")
.attr("transform", function(d,i) {
return "translate("+[padding,i*(doublePadding+plotHeight)+padding]+")";
})
//Optional plot background:
plots.append("rect")
.attr("width",plotWidth)
.attr("height",plotHeight)
.attr("fill","#ddd");
// Plot actual data
plots.selectAll(null)
.data(d=>d[1])
.enter()
.append("circle")
.attr("r", 4)
.attr("cy", d=>yScale(d.ratio))
.attr("cx", d=>xScale(d.run))
// Plot line if needed:
plots.append("path")
.attr("d", function(d) {
return d3.line()
.x(d=>xScale(d.run))
.y(d=>yScale(d.ratio))
(d[1])
})
.attr("stroke", "#333")
.attr("stroke-width", 1)
.attr("fill","none")
// Plot names if needed:
plots.append("text")
.attr("x", plotWidth/2)
.attr("y", -10)
.text(function(d) {
return d[1][0].name;
})
.attr("text-anchor","middle");
// Plot axes
plots.append("g")
.attr("transform","translate("+[0,plotHeight]+")")
.call(d3.axisBottom(xScale).ticks(4));
plots.append("g")
.attr("transform","translate("+[-padding,0]+")")
.call(d3.axisLeft(yScale))
<head>
<!-- Load d3.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.6.1/d3.min.js"></script>
</head>
<body>
<div class="graph-container">
<div id="chart1"></div>
</div>
</body>
See a solution in a fiddle:
Add a g element with background and text(s):
const tooltip = d3.select('svg')
.append('g')
.style('visibility', 'hidden');
tooltip.append('rect')
.attr('width', 100)
.attr('height', 50)
.style('fill', '#fff')
.style('stroke', '#000')
tooltip.append('text')
.attr('x', 20)
.attr('y', 20)
Update it on mouseenter, mousevove, mouseleave:
plots.append("rect")
.attr("width",plotWidth)
.attr("height",plotHeight)
.attr("fill","#ddd")
.on("mouseenter", (e, d) => {
tooltip.style('visibility', 'visible');
tooltip.select('text').text(d[0]);
})
.on("mouseleave", () => tooltip.style('visibility', 'hidden'))
.on("mousemove", e => tooltip.attr('transform', `translate(${e.clientX},${e.clientY})`))
I am working on this slider effect using React Hooks and Redux. My codes are the following:
const Barchart = ({chartData}) => {
let newArray = []
let len = chartData.length
const [XArray,setXArray]=useState([chartData])
const [Yarray,setYArray]=useState(chartData[len-1].anArray) //so the initial state here should be an empty array
// const d3Container = useRef(null);
useEffect(()=>{
let len = chartData.length
console.log(chartData.length)
newArray = chartData[len-1].anArray
setYArray(newArray)
if(newArray.length!==0){
const height = 70 //height of the actual chart, different than the svg element
const width = 26.5*newArray.length //width of the actual chart, different than the svg element
const svg = d3.select('.svg-canvas')
svg.selectAll("*").remove()
var x = d3.scaleLinear().domain([0,7]).range([0,width])
var y = d3.scaleLinear().domain([0,d3.max(Yarray)]).range([height,0])
var xAxis = d3.axisBottom(x).ticks(8)
var yAxis = d3.axisLeft(y).ticks(5)
//locate the chart in the middle of the svg frame: 800/2 - width/2
var chartGroup = svg.append('g').attr('transform','translate('+(400 - width/2)+',300)')
chartGroup.selectAll("rect").data(Yarray).enter().append("rect")
.attr("height",(d,i)=>d*3)
.attr("width","15")
.attr("fill","blue")
.attr('x',(d,i)=>26.5*i)
.attr('y',(d,i)=>height-d*3)
chartGroup.selectAll('text').data(Yarray).enter().append("text")
.attr('font-size',15)
.attr('x',(d,i)=>26.5*i)
.attr('y',(d,i)=>height-5-d*3+2)
.text((d,i)=>d)
chartGroup.append('g').attr('class','axis y')
// .attr('transform','translate(500,76)')
.call(yAxis)
chartGroup.append('g').attr('class','axis x')
.attr('transform','translate(0,'+height+')')
.call(xAxis)
}
},[chartData])
const newArrayFunc = (a) =>{
setYArray(a)
}
return(
<div id='chart-container'>
<h3>Bar Chart</h3>
<svg className="svg-canvas" width="800px" height="400px"></svg>
</div>
)
}
const mapStateToProps = state => ({
chartData:state.chartChange
});
export default connect(mapStateToProps)(Barchart)
So as you see, even though I have setYArray in the useEffect, its asynchronous features prevent Yarray from being immediately updated. Whenever I have a new array coming from chartData, the d3 bar chart uses the previous array.
The objective I am trying to achieve here is whenever the array from chartData gets updated, the updated array will then be used in the d3 bar chart right after.
What should I do here?
Option 1
Continue using the same newArray value you updated state with
const Barchart = ({ chartData }) => {
let newArray = [];
let len = chartData.length;
const [XArray, setXArray] = useState([chartData]);
const [Yarray, setYArray] = useState(chartData[len - 1].anArray); //so the initial state here should be an empty array
// const d3Container = useRef(null);
useEffect(() => {
let len = chartData.length;
console.log(chartData.length);
newArray = chartData[len - 1].anArray;
setYArray(newArray);
if (newArray.length) { // <-- use in-scope newArray value
const height = 70; //height of the actual chart, different than the svg element
const width = 26.5 * newArray.length; //width of the actual chart, different than the svg element
const svg = d3.select(".svg-canvas");
svg.selectAll("*").remove();
var x = d3.scaleLinear().domain([0, 7]).range([0, width]);
var y = d3
.scaleLinear()
.domain([0, d3.max(newArray)]) // <-- use in-scope newArray value
.range([height, 0]);
var xAxis = d3.axisBottom(x).ticks(8);
var yAxis = d3.axisLeft(y).ticks(5);
//locate the chart in the middle of the svg frame: 800/2 - width/2
var chartGroup = svg
.append("g")
.attr("transform", "translate(" + (400 - width / 2) + ",300)");
chartGroup
.selectAll("rect")
.data(newArray) // <-- use in-scope newArray value
.enter()
.append("rect")
.attr("height", (d, i) => d * 3)
.attr("width", "15")
.attr("fill", "blue")
.attr("x", (d, i) => 26.5 * i)
.attr("y", (d, i) => height - d * 3);
chartGroup
.selectAll("text")
.data(newArray) // <-- use in-scope newArray value
.enter()
.append("text")
.attr("font-size", 15)
.attr("x", (d, i) => 26.5 * i)
.attr("y", (d, i) => height - 5 - d * 3 + 2)
.text((d, i) => d);
chartGroup
.append("g")
.attr("class", "axis y")
// .attr('transform','translate(500,76)')
.call(yAxis);
chartGroup
.append("g")
.attr("class", "axis x")
.attr("transform", "translate(0," + height + ")")
.call(xAxis);
}
}, [chartData]);
const newArrayFunc = (a) => {
setYArray(a);
};
return (
<div id="chart-container">
<h3>Bar Chart</h3>
<svg className="svg-canvas" width="800px" height="400px"></svg>
</div>
);
};
Option 2
Update the state and use a second effect to update d3
const Barchart = ({ chartData }) => {
let newArray = [];
let len = chartData.length;
const [XArray, setXArray] = useState([chartData]);
const [Yarray, setYArray] = useState(chartData[len - 1].anArray); //so the initial state here should be an empty array
// const d3Container = useRef(null);
useEffect(() => {
let len = chartData.length;
console.log(chartData.length);
newArray = chartData[len - 1].anArray;
setYArray(newArray);
}, [chartData]);
useEffect(() => {
if (Yarray.length) {
const height = 70; //height of the actual chart, different than the svg element
const width = 26.5 * Yarray.length; //width of the actual chart, different than the svg element
const svg = d3.select(".svg-canvas");
svg.selectAll("*").remove();
var x = d3.scaleLinear().domain([0, 7]).range([0, width]);
var y = d3
.scaleLinear()
.domain([0, d3.max(Yarray)])
.range([height, 0]);
var xAxis = d3.axisBottom(x).ticks(8);
var yAxis = d3.axisLeft(y).ticks(5);
//locate the chart in the middle of the svg frame: 800/2 - width/2
var chartGroup = svg
.append("g")
.attr("transform", "translate(" + (400 - width / 2) + ",300)");
chartGroup
.selectAll("rect")
.data(Yarray)
.enter()
.append("rect")
.attr("height", (d, i) => d * 3)
.attr("width", "15")
.attr("fill", "blue")
.attr("x", (d, i) => 26.5 * i)
.attr("y", (d, i) => height - d * 3);
chartGroup
.selectAll("text")
.data(Yarray)
.enter()
.append("text")
.attr("font-size", 15)
.attr("x", (d, i) => 26.5 * i)
.attr("y", (d, i) => height - 5 - d * 3 + 2)
.text((d, i) => d);
chartGroup
.append("g")
.attr("class", "axis y")
// .attr('transform','translate(500,76)')
.call(yAxis);
chartGroup
.append("g")
.attr("class", "axis x")
.attr("transform", "translate(0," + height + ")")
.call(xAxis);
}
}, [Yarray]);
const newArrayFunc = (a) => {
setYArray(a);
};
return (
<div id="chart-container">
<h3>Bar Chart</h3>
<svg className="svg-canvas" width="800px" height="400px"></svg>
</div>
);
};
I want a ‘mirrored’ bar chart (i.e. one that looks like a sound wave) and have come up with the following using the d3 stack generator and a linear y scale:
import * as d3 from "d3";
const WIDTH = 300;
const HEIGHT = 300;
const LIMIT = 4;
const container = d3.select("svg").append("g");
var data = [];
for (var i = 0; i < 100; i++) {
data.push({
index: i,
value: Math.random() * LIMIT
});
}
var stack = d3
.stack()
.keys(["value"])
.order(d3.stackOrderNone)
.offset(d3.stackOffsetSilhouette);
var series = stack(data);
var xScale = d3
.scaleLinear()
.range([0, WIDTH])
.domain([0, data.length]);
var yScale = d3
.scaleLinear()
.range([HEIGHT, 0])
.domain([0, LIMIT / 2]);
var xAxis = d3.axisBottom().scale(xScale);
var yAxis = d3.axisLeft().scale(yScale);
container
.selectAll(".bar")
.data(series[0])
.enter()
.append("rect")
.attr("class", "bar")
.attr("x", d => {
return xScale(d.data.index);
})
.attr("y", d => {
return yScale(d[0]) / 2 - HEIGHT / 2;
})
.attr("width", WIDTH / series[0].length)
.attr("height", d => yScale(d[1]));
However, I feel like I’ve hacked the calculations for both the y scale domain and for positioning the blocks.
For the domain I currently use 0 to the data's upper limit / 2.
For my y position I use yScale(d[0]) / 2 - HEIGHT / 2; despite the height being directly based off the scale i.e. d => yScale(d[1]).
Is there a better, more idiomatic way to achieve what I want?
It seems the way the stack function calculates values has changed since D3 v2, and therefore I had to do two things to achieve this in a nicer way.
I switched my y scale domain to be the extents of the data and then translated by -0.5 * HEIGHT
I modified my calculation for the y position and height:
.attr('y', d => yScale(d[1]))
.attr('height', d => yScale(d[0]) - yScale(d[1]));
I have a graph I need to make but having a hard time figuring out the best approach. Essentially what I need is two different data sets on the y-axis that are separate values but still related. At zero on the y-axis the data set changes to a different value that goes in positive increments.This is an example of the type of graph I am talking about
What would be the best way to go about creating this? While I can certainly find examples of multiple y-axis graphs, they don't seem to account for this use case.
You can indeed create two different scales, which is probably the standard solution, or... you can create only one scale! So, just for the sake of curiosity, here is how to do it:
Create a scale going from -10 to 10...
var yScale = d3.scaleLinear()
.domain([-10, 10])
... changing the negative values to positive ones in the axis...
var yAxis = d3.axisLeft(yScale)
.tickFormat(d => d < 0 ? Math.abs(d) : d);
... and, of course, changing the y values to negative ones in the data for the lines below the x axis (here named dataInspiration):
dataInspiration.forEach(d => d.y = -d.y)
Here is a demo using random numbers:
var width = 600,
height = 200,
padding = 20;
svg = d3.select('body').append('svg')
.attr('width', width)
.attr('height', height);
var dataExpiration = d3.range(10).map(d => ({
x: d,
y: Math.random() * 10
}));
var dataInspiration = d3.range(10).map(d => ({
x: d,
y: Math.random() * 10
}));
dataInspiration.forEach(d => d.y = -d.y)
var xScale = d3.scalePoint()
.domain(d3.range(10))
.range([padding, width - padding]);
var yScale = d3.scaleLinear()
.domain([-10, 10])
.range([height - padding, padding])
var line = d3.line()
.x(d => xScale(d.x))
.y(d => yScale(d.y))
.curve(d3.curveMonotoneX);
var lineExpiration = svg.append("path")
.attr("fill", "none")
.attr("stroke", "blue")
.attr("d", line(dataExpiration));
var lineInspiration = svg.append("path")
.attr("fill", "none")
.attr("stroke", "red")
.attr("d", line(dataInspiration));
var xAxis = d3.axisBottom(xScale)
.tickFormat(d => d != 0 ? d : null);
var yAxis = d3.axisLeft(yScale)
.tickFormat(d => d < 0 ? Math.abs(d) : d);
var gX = svg.append("g")
.attr("transform", "translate(0," + yScale(0) + ")")
.call(xAxis);
var gY = svg.append("g")
.attr("transform", "translate(" + padding + ",0)")
.call(yAxis);
<script src="https://d3js.org/d3.v4.min.js"></script>