I have a multiline chart created with d3.js using focus and context, where I can use the context to brush to a different extent of dates. My data only includes dates that are the first of each month, but when brushing, one can brush between the dates. I want the brush to snap to the discrete dates in my data because eventually I want to output the selected data in a table.
I found this example, based on this stackoverflow question, that uses d3v3, but I'm having a hard time translating it to my needs. Any help would be very welcome.
let timeW = 960,
timeH = 500
let timeMargin = { top: 20, right: 250, bottom: 130, left: 60 },
timeMargin2 = { top: 410, right: 250, bottom: 30, left: 60 },
timeWidth = timeW - timeMargin.left - timeMargin.right,
timeHeight = timeH - timeMargin.top - timeMargin.bottom,
timeHeight2 = timeH - timeMargin2.top - timeMargin2.bottom;
var parseDate = d3.timeParse("%Y-%m-%d");
let timeseries = d3.select("#timeseries-container").append('svg')
.attr('id', 'timeseries')
.attr("width", timeWidth + timeMargin.left + timeMargin.right)
.attr("height", timeHeight + timeMargin.top + timeMargin.bottom)
var graph = timeseries.append('g').attr('transform', 'translate(' + timeMargin.left + ',' + timeMargin.top + ')');
var x2 = d3.scaleTime().range([0, timeWidth]),
x3 = d3.scaleTime().range([0, timeWidth]),
y2 = d3.scaleLinear().range([timeHeight, 0]),
y3 = d3.scaleLinear().range([timeHeight2, 0]);
var xAxis = d3.axisBottom(x2),
xAxis2 = d3.axisBottom(x3),
yAxis = d3.axisLeft(y2);
var brush = d3.brushX()
.extent([[0, 0], [timeWidth, timeHeight2]])
.on("brush end", brushed);
var zoom = d3.zoom()
.scaleExtent([1, Infinity])
.translateExtent([[0, 0], [timeWidth, timeHeight]])
.extent([[0, 0], [timeWidth, timeHeight]])
.on("zoom", zoomed);
var line = d3.line()
.x(function (d) { return x2(d.date); })
.y(function (d) { return y2(d.value); });
var line2 = d3.line()
.x(function (d) { return x3(d.date); })
.y(function (d) { return y3(d.value); });
timeseries.append("defs").append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("width", timeWidth)
.attr("height", timeHeight);
var focus = timeseries.append("g")
.attr("class", "focus")
.attr("transform", "translate(" + timeMargin.left + "," + timeMargin.top + ")");
var context = timeseries.append("g")
.attr("class", "context")
.attr("transform", "translate(" + timeMargin2.left + "," + timeMargin2.top + ")");
d3.csv("https://raw.githubusercontent.com/sprucegoose1/sample-data/main/sample.csv").then(function (data) {
data.forEach((d) => {
d.date = parseDate(d.date);
d.value = +d.value;
})
x2.domain(d3.extent(data, function (d) { return d.date; }));
y2.domain([0, d3.max(data, function (d) { return d.value; })]);
x3.domain(x2.domain());
y3.domain(y2.domain());
const dataNest = d3.group(data, d => d.state)
const seriesColors = ["#003F5C", "#38CFE8"]
var color = d3.scaleOrdinal()
.range(seriesColors);
focus
.selectAll("path")
.data(dataNest)
.enter().append("path")
.attr('class', 'groups')
.attr("d", d => {
d.line = this;
return line(d[1]);
})
.style("stroke", d => color(d[0]))
.style("stroke-width", 1)
.style('fill', 'none')
.attr("clip-path", "url(#clip)")
focus.append("g")
.attr("class", "axis axis--x")
.attr("transform", "translate(0," + timeHeight + ")")
.call(xAxis);
focus.append("g")
.attr("class", "axis axis--y")
.call(yAxis);
context
.selectAll("path")
.data(dataNest)
.enter().append("path")
.attr('class', 'groups')
.attr("d", d => {
d.line = this;
return line2(d[1]);
})
.style("stroke", d => color(d[0]))
.style("stroke-width", 1)
.style('fill', 'none')
.attr("clip-path", "url(#clip)")
context.append("g")
.attr("class", "axis axis--x")
.attr("transform", "translate(0," + timeHeight2 + ")")
.call(xAxis2);
context.append("g")
.attr("class", "brush")
.call(brush)
.call(brush.move, x2.range());
});
let zoombrush;
function brushed(event) {
var s = event.selection || x2.range();
if (s[1] === s[0]) s[1] += .5;
if (event.mode === "move") {
var extentlength = Math.round(s[1] - s[0])
event.target.extent([Math.round(s[0] + 0.5) - 0.5,
Math.round(s[0] + 0.5) - 0.5 + extentlength])
} else {
event.target.extent([Math.round(s[0] + 0.5) - 0.5,
Math.round(s[1] + 0.5) - 0.5])
}
event.target(d3.select(this))
x2.domain(s.map(x3.invert, x3));
focus.selectAll(".groups")
.attr("d", d => {
d.line = this;
return line(d[1]);
})
focus.select(".axis--x").call(xAxis);
timeseries.select(".zoom").call(zoom.transform, d3.zoomIdentity
.scale(timeWidth / (s[1] - s[0]))
.translate(-s[0], 0));
zoombrush = 0;
}
brush.on('end', (event, d) => {
})
function zoomed(event) {
if (zoombrush) return; // ignore zoom-by-brush
zoombrush = 1;
var t = event.transform;
x.domain(t.rescaleX(x2).domain());
focus.select(".groups").attr("d", line);
focus.select(".axis--x").call(xAxis);
context.select(".brush").call(brush.move, x.range().map(t.invertX, t));
zoombrush = 0;
}
.line {
stroke: #333;
fill: none;
clip-path: url(#clip);
}
.zoom {
cursor: move;
fill: none;
pointer-events: all;
}
.handle {
fill: #4D4E56;
stroke: #fff;
stroke-opacity: 0.5;
stroke-width: 1.25px;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
<div id="timeseries-container"></div>
Here is an example using d3 v7. I removed the zoom behavior completely. It was an over-complicated way just to re-draw the lines.
<!DOCTYPE html>
<html>
<head>
<style>
.line {
stroke: #333;
fill: none;
clip-path: url(#clip);
}
.handle {
fill: #4d4e56;
stroke: #fff;
stroke-opacity: 0.5;
stroke-width: 1.25px;
}
</style>
</head>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
<div id="timeseries-container"></div>
<script>
let timeW = 960,
timeH = 500;
let timeMargin = { top: 20, right: 250, bottom: 130, left: 60 },
timeMargin2 = { top: 410, right: 250, bottom: 30, left: 60 },
timeWidth = timeW - timeMargin.left - timeMargin.right,
timeHeight = timeH - timeMargin.top - timeMargin.bottom,
timeHeight2 = timeH - timeMargin2.top - timeMargin2.bottom;
var parseDate = d3.timeParse('%Y-%m-%d');
let timeseries = d3
.select('#timeseries-container')
.append('svg')
.attr('id', 'timeseries')
.attr('width', timeWidth + timeMargin.left + timeMargin.right)
.attr('height', timeHeight + timeMargin.top + timeMargin.bottom);
var graph = timeseries
.append('g')
.attr(
'transform',
'translate(' + timeMargin.left + ',' + timeMargin.top + ')'
);
var x2 = d3.scaleTime().range([0, timeWidth]),
x3 = d3.scaleTime().range([0, timeWidth]),
y2 = d3.scaleLinear().range([timeHeight, 0]),
y3 = d3.scaleLinear().range([timeHeight2, 0]);
var xAxis = d3.axisBottom(x2),
xAxis2 = d3.axisBottom(x3),
yAxis = d3.axisLeft(y2);
var brush = d3
.brushX()
.extent([
[0, 0],
[timeWidth, timeHeight2],
])
.on('brush', brush)
.on('end', brushed);
var line = d3
.line()
.x(function (d) {
return x2(d.date);
})
.y(function (d) {
return y2(d.value);
});
var line2 = d3
.line()
.x(function (d) {
return x3(d.date);
})
.y(function (d) {
return y3(d.value);
});
timeseries.append("defs").append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("width", timeWidth)
.attr("height", timeHeight);
var focus = timeseries
.append('g')
.attr('class', 'focus')
.attr(
'transform',
'translate(' + timeMargin.left + ',' + timeMargin.top + ')'
);
var context = timeseries
.append('g')
.attr('class', 'context')
.attr(
'transform',
'translate(' + timeMargin2.left + ',' + timeMargin2.top + ')'
);
d3.csv(
'https://raw.githubusercontent.com/sprucegoose1/sample-data/main/sample.csv'
).then(function (data) {
data.forEach((d) => {
d.date = parseDate(d.date);
d.value = +d.value;
});
x2.domain(
d3.extent(data, function (d) {
return d.date;
})
);
y2.domain([
0,
d3.max(data, function (d) {
return d.value;
}),
]);
x3.domain(x2.domain());
y3.domain(y2.domain());
const dataNest = d3.group(data, (d) => d.state);
const seriesColors = ['#003F5C', '#38CFE8'];
var color = d3.scaleOrdinal().range(seriesColors);
focus
.selectAll('path')
.data(dataNest)
.enter()
.append('path')
.attr('class', 'focus_lines')
.attr('d', (d) => {
d.line = this;
return line(d[1]);
})
.style('stroke', (d) => color(d[0]))
.style('stroke-width', 1)
.style('fill', 'none')
.attr('clip-path', 'url(#clip)');
focus
.append('g')
.attr('class', 'axis axis--x')
.attr('transform', 'translate(0,' + timeHeight + ')')
.call(xAxis);
focus.append('g').attr('class', 'axis axis--y').call(yAxis);
context
.selectAll('path')
.data(dataNest)
.enter()
.append('path')
.attr('class', 'groups')
.attr('d', (d) => {
d.line = this;
return line2(d[1]);
})
.style('stroke', (d) => color(d[0]))
.style('stroke-width', 1)
.style('fill', 'none')
.attr('clip-path', 'url(#clip)');
context
.append('g')
.attr('class', 'axis axis--x')
.attr('transform', 'translate(0,' + timeHeight2 + ')')
.call(xAxis2);
context
.append('g')
.attr('class', 'brush')
.call(brush)
.call(brush.move, x2.range());
});
function brush(event) {
if (!event.sourceEvent) return;
const d0 = event.selection.map(x3.invert),
start = d3.timeMonth.floor(d0[0]),
end = d3.timeMonth.ceil(d0[1]);
x2.domain([start, end]);
d3.select(this).call(brush.move, [x3(start), x3(end)]);
}
function brushed(event){
const lines = d3.selectAll('.focus_lines')
.attr('d', (d) => {
return line(d[1]);
});
d3.select(".axis--x").call(d3.axisBottom(x2))
};
</script>
</body>
</html>