I want to create a heatmap with d3 showing days (January 1, January 2, etc.) on one axis, and hours (12am, 1am, 2am, etc.) on the other. I have managed to get the data to display, but I'm not sure if it's right. I also can't figure out how to get the ticks showing the days/hours. I know tickFormat should probably be used, but I'm not sure how. Any help would be supremely appreciated.
const margin = { top: 130, right: 30, bottom: 30, left: 130 },
width = 650 - margin.left - margin.right,
height = 650 - margin.top - margin.bottom;
const svg = d3.select("#viz")
.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform",
"translate(" + margin.left + "," + margin.top + ")");
const parseTime = d3.utcParse("%m/%d/%Y %I:%M:%S %p");
const formatHours = d3.timeFormat("%H%M")
const formatYears = d3.timeFormat("%m/%Y")
let minDate, maxDate;
const x = d3.scaleTime()
.range([0, width])
svg.append("g")
.attr("transform", "translate(0,0)")
.call(d3.axisTop(x))
const y = d3.scaleTime()
.range([0, height])
.domain([0, 2359])
svg.append("g")
.call(d3.axisLeft(y));
const color = d3.scaleOrdinal()
.range(["#fff000", "purple", "blue", "green"])
d3.csv("https://raw.githubusercontent.com/sprucegoose1/sample-data/main/subway_delays.csv").then((data) => {
data.forEach((d) => {
d.day = parseTime(d.Date)
d.hour = +formatHours(d.day)
})
let allDates = d3.extent(data, d => d.day)
x.domain(allDates)
svg.selectAll()
.data(data)
.enter()
.append("rect")
.attr("x", d => x(d.day))
.attr("y", d => y(d.hour))
.attr("width", 2)
.attr("height", 2)
.style("fill", d => color(d.Affected))
})
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
<div id="viz"></div>
I think you have two options.
The first is to have the y-scale represent the number of seconds each data point is into its respective day. The y-scale then becomes a simple linear scale like:
const y = d3.scaleLinear()
.range([0, height])
.domain([0, 86400]); //<-- number of seconds in a day
For this to work, you'll need to add a property into the data for each point that calculates its "seconds into the day":
data.forEach((d) => {
d.day = parseTime(d.Date);
d.numberOfSecondsIntoDay =
d.day.getHours() * 60 * 60 +
d.day.getMinutes() * 60 +
d.day.getSeconds();
});
With this approach, by default, you'd end up with a y-axis showing an integer "number of seconds" value. So, you'd then need a custom formatter to convert that to HH:MM:SS
(or something else). Code borrowed from here:
svg.append('g').call(
d3.axisLeft(y).tickFormat((v) => {
return new Date(1000 * v).toISOString().substr(11, 8);
})
);
Putting everything together:
<!DOCTYPE html>
<html>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
<div id="viz"></div>
<script>
const margin = { top: 60, right: 30, bottom: 30, left: 60 },
width = 650 - margin.left - margin.right,
height = 650 - margin.top - margin.bottom;
const svg = d3
.select('#viz')
.append('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.append('g')
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
const parseTime = d3.utcParse('%m/%d/%Y %I:%M:%S %p');
let minDate, maxDate;
const x = d3.scaleTime().range([0, width]);
svg.append('g').attr('transform', 'translate(0,0)').call(d3.axisTop(x));
const y = d3.scaleLinear().range([0, height]).domain([0, 86400]);
svg.append('g').call(
d3.axisLeft(y).tickFormat((v) => {
return new Date(1000 * v).toISOString().substr(11, 8);
})
);
const color = d3
.scaleOrdinal()
.range(['#fff000', 'purple', 'blue', 'green']);
d3.csv(
'https://raw.githubusercontent.com/sprucegoose1/sample-data/main/subway_delays.csv'
).then((data) => {
data.forEach((d) => {
d.day = parseTime(d.Date);
d.numberOfSecondsIntoDay =
d.day.getHours() * 60 * 60 +
d.day.getMinutes() * 60 +
d.day.getSeconds();
});
let allDates = d3.extent(data, (d) => d.day);
x.domain(allDates);
svg
.selectAll()
.data(data)
.enter()
.append('rect')
.attr('x', (d) => x(d.day))
.attr('y', (d) => y(d.numberOfSecondsIntoDay))
.attr('width', 2)
.attr('height', 2)
.style('fill', (d) => color(d.Affected));
});
</script>
</body>
</html>
I think this approach is programmatically cleaner but you lose the nice logical "time" ticks on the y-axis.
The second is to continue to use a time scale on the y-axis but coerce all the date times to just times on a single day. In this example I lop off the time and make all the data points occur on the epoch day of Jan 1st, 1970. So the scale becomes:
const y = d3.scaleTime()
.range([0, height])
.domain([new Date(0), new Date(86400 * 1000)]); //<-- first day of epoch
The data manipulation is then:
data.forEach((d) => {
d.day = parseTime(d.Date);
d.justTimePart = new Date(
(d.day.getHours() * 60 * 60 +
d.day.getMinutes() * 60 +
d.day.getSeconds()) * 1000)
});
At this point, you'll see some strangeness with the default tick formatter. It'll show you the year and day on those boundaries. So again let's use a custom format:
svg.append('g').call(
d3.axisLeft(y).
tickFormat(d3.utcFormat("%I %p")) //<-- American style AM/PM
);
Putting this one all together:
<!DOCTYPE html>
<html>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
<div id="viz"></div>
<script>
const margin = { top: 60, right: 30, bottom: 30, left: 60 },
width = 650 - margin.left - margin.right,
height = 650 - margin.top - margin.bottom;
const svg = d3
.select('#viz')
.append('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.append('g')
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
const parseTime = d3.utcParse('%m/%d/%Y %I:%M:%S %p');
let minDate, maxDate;
const x = d3.scaleTime().range([0, width]);
svg.append('g').attr('transform', 'translate(0,0)').call(d3.axisTop(x));
const y = d3.scaleTime().range([0, height]).domain([new Date(0), new Date(86400 * 1000)]);
svg.append('g').call(
d3.axisLeft(y).
tickFormat(d3.utcFormat("%I %p"))
);
const color = d3
.scaleOrdinal()
.range(['#fff000', 'purple', 'blue', 'green']);
d3.csv(
'https://raw.githubusercontent.com/sprucegoose1/sample-data/main/subway_delays.csv'
).then((data) => {
data.forEach((d) => {
d.day = parseTime(d.Date);
d.justTimePart = new Date(
(d.day.getHours() * 60 * 60 +
d.day.getMinutes() * 60 +
d.day.getSeconds()) * 1000)
});
let allDates = d3.extent(data, (d) => d.day);
x.domain(allDates);
svg
.selectAll()
.data(data)
.enter()
.append('rect')
.attr('x', (d) => x(d.day))
.attr('y', (d) => y(d.justTimePart))
.attr('width', 2)
.attr('height', 2)
.style('fill', (d) => color(d.Affected));
});
</script>
</body>
</html>
I prefer this approach as it retains the help d3 gives you on making a nicely formatted y-axis.