Interactions & Events
Comprehensive guide to adding interactivity and event handling to D3.js visualizations.
Basic Event Handling
Mouse Events
svg
.selectAll('circle')
.on('click', function (event, d) {
console.log('Clicked:', d);
console.log('Element:', this);
console.log('Event:', event);
})
.on('mouseover', function (event, d) {
d3.select(this).attr('fill', 'red').attr('r', 10);
})
.on('mouseout', function (event, d) {
d3.select(this).attr('fill', 'steelblue').attr('r', 5);
})
.on('dblclick', function (event, d) {
console.log('Double clicked:', d);
});
Event Object Properties
.on("click", function(event, d) {
console.log("Mouse position:", event.clientX, event.clientY);
console.log("Relative position:", d3.pointer(event, this));
console.log("Modifier keys:", {
shiftKey: event.shiftKey,
ctrlKey: event.ctrlKey,
altKey: event.altKey
});
});
Hover Effects
Simple Hover
svg
.selectAll('rect')
.on('mouseenter', function (event, d) {
d3.select(this).transition().duration(200).attr('fill', 'orange');
})
.on('mouseleave', function (event, d) {
d3.select(this).transition().duration(200).attr('fill', 'steelblue');
});
Hover with Data Highlighting
function highlightConnected(hoveredId) {
svg
.selectAll('.node')
.classed('highlighted', d => d.id === hoveredId)
.classed('dimmed', d => d.id !== hoveredId);
svg
.selectAll('.link')
.classed(
'highlighted',
d => d.source.id === hoveredId || d.target.id === hoveredId
)
.classed(
'dimmed',
d => d.source.id !== hoveredId && d.target.id !== hoveredId
);
}
svg
.selectAll('.node')
.on('mouseenter', (event, d) => highlightConnected(d.id))
.on('mouseleave', () => {
svg.selectAll('.node, .link').classed('highlighted dimmed', false);
});
Tooltips
Basic Tooltip
// Create tooltip div
const tooltip = d3
.select('body')
.append('div')
.attr('class', 'tooltip')
.style('position', 'absolute')
.style('visibility', 'hidden')
.style('background', 'rgba(0,0,0,0.8)')
.style('color', 'white')
.style('padding', '8px')
.style('border-radius', '4px')
.style('font-size', '12px');
svg
.selectAll('circle')
.on('mouseover', function (event, d) {
tooltip.style('visibility', 'visible').text(`Value: ${d.value}`);
})
.on('mousemove', function (event) {
tooltip
.style('top', event.pageY - 10 + 'px')
.style('left', event.pageX + 10 + 'px');
})
.on('mouseout', function () {
tooltip.style('visibility', 'hidden');
});
Rich HTML Tooltip
const tooltip = d3
.select('body')
.append('div')
.attr('class', 'tooltip')
.style('position', 'absolute')
.style('visibility', 'hidden');
svg
.selectAll('circle')
.on('mouseover', function (event, d) {
tooltip.style('visibility', 'visible').html(`
<strong>${d.name}</strong><br/>
Value: ${d.value}<br/>
Category: ${d.category}
`);
})
.on('mousemove', function (event) {
const [x, y] = d3.pointer(event, document.body);
tooltip.style('top', y + 10 + 'px').style('left', x + 10 + 'px');
})
.on('mouseout', function () {
tooltip.style('visibility', 'hidden');
});
Tooltip with Bounds Checking
function showTooltip(event, d) {
const tooltipWidth = 150;
const tooltipHeight = 60;
const margin = 10;
let x = event.pageX + margin;
let y = event.pageY - tooltipHeight - margin;
// Check right boundary
if (x + tooltipWidth > window.innerWidth) {
x = event.pageX - tooltipWidth - margin;
}
// Check top boundary
if (y < 0) {
y = event.pageY + margin;
}
tooltip
.style('visibility', 'visible')
.style('left', x + 'px')
.style('top', y + 'px')
.html(`<strong>${d.name}</strong><br/>Value: ${d.value}`);
}
Drag Behavior
Basic Drag
const drag = d3
.drag()
.on('start', function (event, d) {
d3.select(this)
.raise() // Bring to front
.classed('active', true);
})
.on('drag', function (event, d) {
d3.select(this).attr('cx', event.x).attr('cy', event.y);
})
.on('end', function (event, d) {
d3.select(this).classed('active', false);
});
svg.selectAll('circle').call(drag);
Constrained Drag
const constrainedDrag = d3.drag().on('drag', function (event, d) {
// Constrain to bounds
const x = Math.max(0, Math.min(width, event.x));
const y = Math.max(0, Math.min(height, event.y));
d3.select(this).attr('cx', x).attr('cy', y);
// Update data
d.x = x;
d.y = y;
});
Drag with Snap
const snapDrag = d3.drag().on('drag', function (event, d) {
const snapSize = 20;
const snappedX = Math.round(event.x / snapSize) * snapSize;
const snappedY = Math.round(event.y / snapSize) * snapSize;
d3.select(this).attr('cx', snappedX).attr('cy', snappedY);
});
Zoom and Pan
Basic Zoom
const zoom = d3
.zoom()
.scaleExtent([0.5, 10]) // Min and max zoom levels
.on('zoom', function (event) {
const transform = event.transform;
svg.selectAll('g').attr('transform', transform);
});
svg.call(zoom);
Zoom with Smooth Transitions
const zoom = d3.zoom().on('zoom', function (event) {
svg.select('.chart-area').attr('transform', event.transform);
});
// Programmatic zoom
function zoomToFit(bounds) {
const [[x0, y0], [x1, y1]] = bounds;
const dx = x1 - x0;
const dy = y1 - y0;
const x = (x0 + x1) / 2;
const y = (y0 + y1) / 2;
const scale = Math.min(8, 0.9 / Math.max(dx / width, dy / height));
const translate = [width / 2 - scale * x, height / 2 - scale * y];
svg
.transition()
.duration(750)
.call(
zoom.transform,
d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale)
);
}
Zoom with Constraints
const constrainedZoom = d3
.zoom()
.scaleExtent([1, 8])
.translateExtent([
[0, 0],
[width, height],
]) // Pan boundaries
.on('zoom', function (event) {
svg.select('.chart-area').attr('transform', event.transform);
});
Brush Selection
Basic Brush
const brush = d3
.brush()
.extent([
[0, 0],
[width, height],
])
.on('start brush end', function (event) {
const selection = event.selection;
if (selection) {
const [[x0, y0], [x1, y1]] = selection;
// Highlight points within selection
svg.selectAll('circle').classed('selected', d => {
const x = xScale(d.x);
const y = yScale(d.y);
return x >= x0 && x <= x1 && y >= y0 && y <= y1;
});
} else {
// Clear selection
svg.selectAll('circle').classed('selected', false);
}
});
svg.append('g').attr('class', 'brush').call(brush);
Brush with Data Filtering
const brush = d3
.brushX() // X-only brush
.extent([
[0, 0],
[width, height],
])
.on('brush end', function (event) {
const selection = event.selection;
if (selection) {
const [x0, x1] = selection;
const filteredData = data.filter(d => {
const x = xScale(d.date);
return x >= x0 && x <= x1;
});
updateChart(filteredData);
}
});
Keyboard Events
Global Keyboard Events
d3.select('body').on('keydown', function (event) {
switch (event.key) {
case 'Escape':
clearSelection();
break;
case 'Delete':
deleteSelected();
break;
case 'ArrowLeft':
panLeft();
break;
case 'ArrowRight':
panRight();
break;
}
});
Element-specific Keyboard Events
// Make elements focusable
svg
.selectAll('circle')
.attr('tabindex', 0) // Make focusable
.on('keydown', function (event, d) {
if (event.key === 'Enter') {
selectNode(d);
}
event.stopPropagation(); // Prevent bubbling
});
Custom Interactions
Selection Rectangle
let isSelecting = false;
let startPoint = null;
let selectionRect = null;
svg.on('mousedown', function (event) {
if (event.target === this) {
// Only on background
isSelecting = true;
startPoint = d3.pointer(event, this);
selectionRect = svg
.append('rect')
.attr('class', 'selection')
.attr('x', startPoint[0])
.attr('y', startPoint[1])
.attr('width', 0)
.attr('height', 0)
.style('fill', 'rgba(0,0,255,0.1)')
.style('stroke', 'blue');
}
});
svg.on('mousemove', function (event) {
if (isSelecting && selectionRect) {
const currentPoint = d3.pointer(event, this);
const x = Math.min(startPoint[0], currentPoint[0]);
const y = Math.min(startPoint[1], currentPoint[1]);
const width = Math.abs(currentPoint[0] - startPoint[0]);
const height = Math.abs(currentPoint[1] - startPoint[1]);
selectionRect
.attr('x', x)
.attr('y', y)
.attr('width', width)
.attr('height', height);
}
});
svg.on('mouseup', function (event) {
if (isSelecting) {
isSelecting = false;
if (selectionRect) {
selectionRect.remove();
selectionRect = null;
}
}
});
Context Menu
const contextMenu = d3
.select('body')
.append('div')
.attr('class', 'context-menu')
.style('position', 'absolute')
.style('visibility', 'hidden')
.style('background', 'white')
.style('border', '1px solid #ccc')
.style('padding', '5px');
svg.selectAll('circle').on('contextmenu', function (event, d) {
event.preventDefault();
contextMenu
.style('visibility', 'visible')
.style('left', event.pageX + 5 + 'px')
.style('top', event.pageY + 5 + 'px').html(`
<div onclick="editNode('${d.id}')">Edit</div>
<div onclick="deleteNode('${d.id}')">Delete</div>
<div onclick="duplicateNode('${d.id}')">Duplicate</div>
`);
});
// Hide context menu on click elsewhere
d3.select('body').on('click', function () {
contextMenu.style('visibility', 'hidden');
});
Performance Optimization
Event Delegation
// Instead of attaching events to many elements
svg.on('click', function (event) {
const target = event.target;
if (target.tagName === 'circle') {
const d = d3.select(target).datum();
handleCircleClick(d);
}
});
Throttled Events
function throttle(func, delay) {
let timeoutId;
let lastExecTime = 0;
return function (...args) {
const currentTime = Date.now();
if (currentTime - lastExecTime > delay) {
func.apply(this, args);
lastExecTime = currentTime;
} else {
clearTimeout(timeoutId);
timeoutId = setTimeout(
() => {
func.apply(this, args);
lastExecTime = Date.now();
},
delay - (currentTime - lastExecTime)
);
}
};
}
const throttledMouseMove = throttle(function (event) {
// Handle mousemove
}, 16); // ~60fps
svg.on('mousemove', throttledMouseMove);
Accessibility
Keyboard Navigation
svg
.selectAll('circle')
.attr('tabindex', 0)
.attr('role', 'button')
.attr('aria-label', d => `Data point: ${d.value}`)
.on('keydown', function (event, d) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleSelection(d);
}
});
Screen Reader Support
// Add descriptions
svg.append('desc').text('Bar chart showing sales data over time');
svg
.selectAll('rect')
.attr('aria-label', d => `${d.category}: ${d.value}`)
.attr('role', 'img');