Skip to main content

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');