I have a bar chart in d3 that uses focus/context. When the user brushes to select the dates, I want to snap to the dates available, rather than them being able to brush to any date between the beginning and end. This brush snapping example uses a twelve hour interval. In my case, there is no regular interval. Is there a way to snap to the dates with data?
const margin = {
top: 20,
right: 20,
bottom: 90,
left: 50
},
margin2 = {
top: 230,
right: 20,
bottom: 30,
left: 50
},
width = 960 - margin.left - margin.right,
height = 300 - margin.top - margin.bottom,
height2 = 300 - margin2.top - margin2.bottom;
const parseTime = d3.timeParse("%Y-%m-%d %H:%M");
const x = d3.scaleTime().range([0, width]),
x2 = d3.scaleTime().range([0, width]),
y = d3.scaleLinear().range([height, 0]),
y2 = d3.scaleLinear().range([height2, 0]),
dur = d3.scaleLinear().range([0, 12]);
const xAxis = d3.axisBottom(x).tickSize(0),
xAxis2 = d3.axisBottom(x2).tickSize(0),
yAxis = d3.axisLeft(y).tickSize(0);
const brush = d3.brushX()
.extent([
[0, 0],
[width, height2]
])
.on("start brush end", brushed);
const svg = d3.select("body").append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom);
svg.append("defs").append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("width", width)
.attr("height", height);
const focus = svg.append("g")
.attr("class", "focus")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
const context = svg.append("g")
.attr("class", "context")
.attr("transform", "translate(" + margin2.left + "," + margin2.top + ")");
d3.csv("https://raw.githubusercontent.com/sprucegoose1/sample-data/main/data.csv").then((data) => {
const parseTime = d3.timeParse("%Y-%m-%d %H:%M");
const mouseoverTime = d3.timeFormat("%a %e %b %Y %H:%M");
const minTime = d3.timeFormat("%b%e, %Y");
const parseDate = d3.timeParse("%b %Y");
data.forEach((d) => {
d.date = parseTime(d.date);
d.end = parseTime(d.end);
d.distance = +d.distance;
return d;
},
(error, data) => {
if (error) throw error;
})
let total = 0;
data.forEach((d) => total = d.distance + total);
const minDate = d3.min(data, d => d.date)
const xMin = d3.min(data, d => d.date)
const yMax = Math.max(20, d3.max(data, d => d.distance))
x.domain([xMin, d3.max(data, d => d.date)])
y.domain([0, yMax]);
x2.domain(x.domain());
y2.domain(y.domain());
var rects = focus.append("g");
rects.attr("clip-path", "url(#clip)");
rects.selectAll("rects")
.data(data)
.enter().append("rect")
.style("fill","royalblue")
.attr("class", "rects")
.attr("x", d => x(d.date))
.attr("y", d => y(d.distance))
.attr("width", 10)
.attr("height", d => height - y(d.distance))
focus.append("g")
.attr("class", "axis x-axis")
.attr("transform", "translate(0," + height + ")")
.call(xAxis);
focus.append("g")
.attr("class", "axis axis--y")
.call(yAxis);
focus.append("text")
.attr("transform", "rotate(-90)")
.attr("y", 0 - margin.left)
.attr("x", 0 - (height / 2))
.attr("dy", "1em")
.style("text-anchor", "middle")
.text("Distance in meters");
svg.append("text")
.attr("transform",
"translate(" + ((width + margin.right + margin.left) / 2) + " ," +
(height + margin.top + margin.bottom) + ")")
.style("text-anchor", "middle")
.text("Date");
var rects = context.append("g");
rects.attr("clip-path", "url(#clip)");
rects.selectAll("rects")
.data(data)
.enter().append("rect")
.style("fill", "royalblue")
.attr("class", "rects")
.attr("x", d => x2(d.date))
.attr("y", d => y2(d.distance))
.attr("width", 10)
.attr("height", d => height2 - y2(d.distance));
context.append("g")
.attr("class", "axis x-axis")
.attr("transform", "translate(0," + height2 + ")")
.call(xAxis2);
context.append("g")
.attr("class", "brush")
.call(brush)
.call(brush.move, x.range());
});
function brushed(event) {
var s = event.selection || x2.range();
x.domain(s.map(x2.invert, x2));
focus.selectAll(".rects")
.attr("x", d => x(d.date))
.attr("y", d => y(d.distance))
.attr("width", 10)
.attr("height", d => height - y(d.distance))
focus.select(".x-axis").call(xAxis);
var e = event.selection;
var selectedrects = focus.selectAll('.rects').filter(() => {
var xValue = this.getAttribute('x');
return e[0] <= xValue && xValue <= e[1];
});
}
body {
font-family: avenir next, sans-serif;
font-size: 12px;
}
.axis {
stroke-width: 0.5px;
stroke: #888;
font: 10px avenir next, sans-serif;
}
.axis>path {
stroke: #888;
}
.handle {
width: 6px !important;
fill: #000 !important;
margin-left: 0px !important;
display: block;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
<div id="totalDistance">
</div>
Similar to my last answer but in this version, during the drag event, it finds the dates in your dataset closest to both drag handles and snaps to them. Note, I didn't completely polish this up (it doesn't re-draw the "focus" bar):
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: avenir next, sans-serif;
font-size: 12px;
}
.axis {
stroke-width: 0.5px;
stroke: #888;
font: 10px avenir next, sans-serif;
}
.axis > path {
stroke: #888;
}
.handle {
width: 6px !important;
fill: #000 !important;
margin-left: 0px !important;
display: block;
}
</style>
</head>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
<div id="totalDistance"></div>
<script>
const margin = {
top: 20,
right: 20,
bottom: 90,
left: 50,
},
margin2 = {
top: 230,
right: 20,
bottom: 30,
left: 50,
},
width = 960 - margin.left - margin.right,
height = 300 - margin.top - margin.bottom,
height2 = 300 - margin2.top - margin2.bottom;
const parseTime = d3.timeParse('%Y-%m-%d %H:%M');
const x = d3.scaleTime().range([0, width]),
x2 = d3.scaleTime().range([0, width]),
y = d3.scaleLinear().range([height, 0]),
y2 = d3.scaleLinear().range([height2, 0]),
dur = d3.scaleLinear().range([0, 12]);
const xAxis = d3.axisBottom(x).tickSize(0),
xAxis2 = d3.axisBottom(x2).tickSize(0),
yAxis = d3.axisLeft(y).tickSize(0);
const brush = d3
.brushX()
.extent([
[0, 0],
[width, height2],
])
.on('end', brushed)
.on('brush', brushing);
const svg = d3
.select('body')
.append('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom);
svg
.append('defs')
.append('clipPath')
.attr('id', 'clip')
.append('rect')
.attr('width', width)
.attr('height', height);
const focus = svg
.append('g')
.attr('class', 'focus')
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
const context = svg
.append('g')
.attr('class', 'context')
.attr(
'transform',
'translate(' + margin2.left + ',' + margin2.top + ')'
);
let data;
d3.csv(
'https://raw.githubusercontent.com/sprucegoose1/sample-data/main/data.csv'
).then((d) => {
data = d;
const parseTime = d3.timeParse('%Y-%m-%d %H:%M');
const mouseoverTime = d3.timeFormat('%a %e %b %Y %H:%M');
const minTime = d3.timeFormat('%b%e, %Y');
const parseDate = d3.timeParse('%b %Y');
data.forEach(
(d) => {
d.date = parseTime(d.date);
d.end = parseTime(d.end);
d.distance = +d.distance;
return d;
},
(error, data) => {
if (error) throw error;
}
);
let total = 0;
data.forEach((d) => (total = d.distance + total));
const minDate = d3.min(data, (d) => d.date);
const xMin = d3.min(data, (d) => d.date);
const yMax = Math.max(
20,
d3.max(data, (d) => d.distance)
);
x.domain([xMin, d3.max(data, (d) => d.date)]);
y.domain([0, yMax]);
x2.domain(x.domain());
y2.domain(y.domain());
var rects = focus.append('g');
rects.attr('clip-path', 'url(#clip)');
rects
.selectAll('rects')
.data(data)
.enter()
.append('rect')
.style('fill', 'royalblue')
.attr('class', 'rects')
.attr('x', (d) => x(d.date))
.attr('y', (d) => y(d.distance))
.attr('width', 10)
.attr('height', (d) => height - y(d.distance));
focus
.append('g')
.attr('class', 'axis x-axis')
.attr('transform', 'translate(0,' + height + ')')
.call(xAxis);
focus.append('g').attr('class', 'axis axis--y').call(yAxis);
focus
.append('text')
.attr('transform', 'rotate(-90)')
.attr('y', 0 - margin.left)
.attr('x', 0 - height / 2)
.attr('dy', '1em')
.style('text-anchor', 'middle')
.text('Distance in meters');
svg
.append('text')
.attr(
'transform',
'translate(' +
(width + margin.right + margin.left) / 2 +
' ,' +
(height + margin.top + margin.bottom) +
')'
)
.style('text-anchor', 'middle')
.text('Date');
var rects = context.append('g');
rects.attr('clip-path', 'url(#clip)');
rects
.selectAll('rects')
.data(data)
.enter()
.append('rect')
.style('fill', 'royalblue')
.attr('class', 'rects')
.attr('x', (d) => x2(d.date))
.attr('y', (d) => y2(d.distance))
.attr('width', 10)
.attr('height', (d) => height2 - y2(d.distance));
context
.append('g')
.attr('class', 'axis x-axis')
.attr('transform', 'translate(0,' + height2 + ')')
.call(xAxis2);
context
.append('g')
.attr('class', 'brush')
.call(brush)
.call(brush.move, x.range());
});
function brushing(event) {
if (!event.sourceEvent) return;
const clDt = event.selection.map(x2.invert),
snapXs = x.domain();
// find the dates in the dataset closest to both handles
for (let i = 1; i < data.length; i++){
const currDate = data[i].date,
prevDate = data[i-1].date;
if (Math.abs(currDate - clDt[0]) < Math.abs(prevDate - clDt[0]))
snapXs[0] = currDate;
if (Math.abs(currDate - clDt[1]) < Math.abs(prevDate - clDt[1]))
snapXs[1] = currDate;
}
x.domain(snapXs);
d3.select(this).call(brush.move, [x2(snapXs[0]), x2(snapXs[1])]);
}
function brushed(event){
d3.select(".x-axis").call(d3.axisBottom(x))
};
</script>
</body>
</html>