10. Guided Project 1: Interactive Dashboard with Real-time Data
This project will guide you through building a simple yet powerful interactive dashboard using D3.js. The dashboard will feature multiple synchronized charts (a Line Chart and a Bar Chart) that update with simulated real-time data. This project will reinforce your understanding of data binding, scales, axes, interactivity, and transitions, while introducing concepts like data aggregation and multi-chart synchronization.
Project Objective
Create an interactive dashboard that displays two connected visualizations:
- Line Chart: Shows the trend of “total sales” over time.
- Bar Chart: Displays the “average sales per region” for the current data window.
The dashboard should:
- Simulate real-time data streaming.
- Allow the user to select a time range on the line chart (brushing) to filter the data shown in the bar chart.
- Update both charts smoothly with transitions when new data arrives or the time range changes.
- Include tooltips for both charts.
Project Structure
We’ll use a single HTML file and a main JavaScript file. For simplicity, we’ll keep the D3.js code within the app.js and manage state there.
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>D3.js Real-time Interactive Dashboard</title>
<style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 20px; background-color: #f4f7f6; color: #333; }
h1, h2 { text-align: center; color: #2c3e50; }
.dashboard-container {
display: flex;
flex-direction: column;
align-items: center;
max-width: 1200px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
padding: 20px;
}
.chart-wrapper {
width: 100%;
margin-bottom: 30px;
}
svg {
display: block;
margin: 0 auto;
border: 1px solid #eee;
border-radius: 5px;
background-color: #fff;
}
.line-chart-path {
fill: none;
stroke: steelblue;
stroke-width: 2px;
}
.area-chart-path {
fill: lightsteelblue;
opacity: 0.7;
}
.bar {
fill: teal;
transition: fill 0.2s ease-out;
}
.bar:hover {
fill: darkcyan;
}
.axis path, .axis line {
fill: none;
stroke: #666;
shape-rendering: crispEdges;
}
.axis text {
font-size: 11px;
fill: #666;
}
.brush .selection {
fill-opacity: .3;
stroke: #555;
stroke-dasharray: 2,2;
}
.tooltip {
position: absolute;
text-align: center;
padding: 8px;
font: 12px sans-serif;
background: rgba(255, 255, 255, 0.9);
border: 1px solid #999;
border-radius: 4px;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}
button {
padding: 10px 20px;
margin-bottom: 20px;
background-color: #007bff;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s ease;
}
button:hover {
background-color: #0056b3;
}
</style>
</head>
<body>
<h1>Real-time Interactive Sales Dashboard</h1>
<div class="dashboard-container">
<button id="reset-brush">Reset Brush</button>
<div class="chart-wrapper">
<h2>Total Sales Over Time</h2>
<svg id="line-chart" width="960" height="300"></svg>
</div>
<div class="chart-wrapper">
<h2>Average Sales Per Region (Filtered by Time)</h2>
<svg id="bar-chart" width="960" height="300"></svg>
</div>
<div id="line-tooltip" class="tooltip"></div>
<div id="bar-tooltip" class="tooltip"></div>
</div>
<script type="module" src="./app.js"></script>
</body>
</html>
Step-by-Step Guide
Step 1: Data Simulation
We’ll create a function to generate simulated sales data. Each data point will represent sales at a specific time, broken down by region.
app.js
import * as d3 from 'd3';
// --- Global Variables and Setup ---
const LINE_CHART_ID = '#line-chart';
const BAR_CHART_ID = '#bar-chart';
const LINE_TOOLTIP_ID = '#line-tooltip';
const BAR_TOOLTIP_ID = '#bar-tooltip';
const lineSvg = d3.select(LINE_CHART_ID);
const barSvg = d3.select(BAR_CHART_ID);
const lineTooltip = d3.select(LINE_TOOLTIP_ID);
const barTooltip = d3.select(BAR_TOOLTIP_ID);
const margin = { top: 30, right: 30, bottom: 50, left: 60 };
const lineChartWidth = +lineSvg.attr('width') - margin.left - margin.right;
const lineChartHeight = +lineSvg.attr('height') - margin.top - margin.bottom;
const barChartWidth = +barSvg.attr('width') - margin.left - margin.right;
const barChartHeight = +barSvg.attr('height') - margin.top - margin.bottom;
const parseTime = d3.timeParse('%Y-%m-%d %H:%M:%S');
const formatTime = d3.timeFormat('%Y-%m-%d %H:%M:%S');
let allData = []; // Stores all historical data
const regions = ['East', 'West', 'North', 'South'];
let dataInterval; // To hold the interval timer
let currentTime = new Date(2025, 0, 1, 0, 0, 0); // Start date: Jan 1, 2025
const MAX_DATA_POINTS = 50; // Keep roughly 50 points in the line chart
// Group elements for charts
const lineChartG = lineSvg.append('g').attr('transform', `translate(${margin.left},${margin.top})`);
const barChartG = barSvg.append('g').attr('transform', `translate(${margin.left},${margin.top})`);
// --- Scales ---
const xScaleLine = d3.scaleTime().range([0, lineChartWidth]);
const yScaleLine = d3.scaleLinear().range([lineChartHeight, 0]);
const xScaleBar = d3.scaleBand().range([0, barChartWidth]).padding(0.1);
const yScaleBar = d3.scaleLinear().range([barChartHeight, 0]);
const colorScaleBar = d3.scaleOrdinal(d3.schemeCategory10).domain(regions);
// --- Axes ---
const xAxisLine = d3.axisBottom(xScaleLine).tickFormat(d3.timeFormat('%H:%M'));
const yAxisLine = d3.axisLeft(yScaleLine).ticks(5);
const xAxisBar = d3.axisBottom(xScaleBar);
const yAxisBar = d3.axisLeft(yScaleBar).ticks(5);
// Axis groups
const xAxisLineGroup = lineChartG.append('g').attr('class', 'x-axis axis').attr('transform', `translate(0,${lineChartHeight})`);
const yAxisLineGroup = lineChartG.append('g').attr('class', 'y-axis axis');
const xAxisBarGroup = barChartG.append('g').attr('class', 'x-axis axis').attr('transform', `translate(0,${barChartHeight})`);
const yAxisBarGroup = barChartG.append('g').attr('class', 'y-axis axis');
// Line and Area generators for line chart
const lineGenerator = d3.line()
.x(d => xScaleLine(d.time))
.y(d => yScaleLine(d.totalSales));
const areaGenerator = d3.area()
.x(d => xScaleLine(d.time))
.y0(lineChartHeight)
.y1(d => yScaleLine(d.totalSales));
// --- Data Simulation Function ---
function generateNewDataPoint() {
currentTime.setSeconds(currentTime.getSeconds() + 1); // Advance time by 1 second
let totalSales = 0;
const regionalSales = {};
regions.forEach(region => {
const sales = Math.floor(Math.random() * 50) + 10; // Sales between 10 and 60
regionalSales[region] = sales;
totalSales += sales;
});
return {
time: new Date(currentTime), // Deep copy of currentTime
totalSales: totalSales,
regionalSales: regionalSales
};
}
// --- Filtering State ---
let brushExtent = null; // Stores the currently selected time range from the brush
// --- Functions to Draw Charts ---
function drawLineChart(dataToRender) {
// Update scales' domains
xScaleLine.domain(d3.extent(dataToRender, d => d.time));
yScaleLine.domain([0, d3.max(dataToRender, d => d.totalSales) + 20]).nice();
// Update axes
xAxisLineGroup.transition().duration(500).call(xAxisLine);
yAxisLineGroup.transition().duration(500).call(yAxisLine);
// Draw area path
lineChartG.selectAll('.area-chart-path')
.data([dataToRender]) // Bind all data to a single path
.join(
enter => enter.append('path')
.attr('class', 'area-chart-path')
.attr('d', areaGenerator),
update => update.transition().duration(500).attr('d', areaGenerator),
exit => exit.remove()
);
// Draw line path
lineChartG.selectAll('.line-chart-path')
.data([dataToRender]) // Bind all data to a single path
.join(
enter => enter.append('path')
.attr('class', 'line-chart-path')
.attr('d', lineGenerator),
update => update.transition().duration(500).attr('d', lineGenerator),
exit => exit.remove()
);
// Add interactivity to line chart (points)
lineChartG.selectAll('circle')
.data(dataToRender)
.join(
enter => enter.append('circle')
.attr('r', 4)
.attr('fill', 'steelblue')
.attr('stroke', 'white')
.attr('stroke-width', 1.5)
.attr('cx', d => xScaleLine(d.time))
.attr('cy', d => yScaleLine(d.totalSales))
.on('mouseover', function(event, d) {
d3.select(this).attr('r', 6).attr('fill', 'darkblue');
lineTooltip.html(`Time: ${formatTime(d.time)}<br/>Total Sales: ${d.totalSales}`)
.style('left', (event.pageX + 10) + 'px')
.style('top', (event.pageY - 28) + 'px')
.style('opacity', 1);
})
.on('mouseout', function() {
d3.select(this).attr('r', 4).attr('fill', 'steelblue');
lineTooltip.style('opacity', 0);
}),
update => update
.transition().duration(500)
.attr('cx', d => xScaleLine(d.time))
.attr('cy', d => yScaleLine(d.totalSales)),
exit => exit.remove()
);
}
function drawBarChart(dataToRender) {
// Aggregate data for bar chart: average sales per region
const regionalAverages = {};
regions.forEach(region => {
let sum = 0;
dataToRender.forEach(d => {
sum += d.regionalSales[region];
});
regionalAverages[region] = sum / dataToRender.length;
});
// Convert to array of objects for D3
const barChartData = regions.map(region => ({
region: region,
averageSales: isNaN(regionalAverages[region]) ? 0 : regionalAverages[region] // Handle empty data
}));
// Update scales' domains
xScaleBar.domain(barChartData.map(d => d.region));
yScaleBar.domain([0, d3.max(barChartData, d => d.averageSales) + 10 || 50]).nice(); // Default max if empty
// Update axes
xAxisBarGroup.transition().duration(500).call(xAxisBar);
yAxisBarGroup.transition().duration(500).call(yAxisBar);
// Draw bars
barChartG.selectAll('.bar')
.data(barChartData, d => d.region)
.join(
enter => enter.append('rect')
.attr('class', 'bar')
.attr('x', d => xScaleBar(d.region)!)
.attr('y', barChartHeight)
.attr('width', xScaleBar.bandwidth())
.attr('height', 0)
.attr('fill', d => colorScaleBar(d.region))
.on('mouseover', function(event, d) {
d3.select(this).attr('fill', 'darkcyan');
barTooltip.html(`Region: <strong>${d.region}</strong><br/>Avg Sales: ${d.averageSales.toFixed(2)}`)
.style('left', (event.pageX + 10) + 'px')
.style('top', (event.pageY - 28) + 'px')
.style('opacity', 1);
})
.on('mouseout', function(event, d) {
d3.select(this).attr('fill', colorScaleBar(d.region));
barTooltip.style('opacity', 0);
})
.call(enter => enter.transition().duration(750)
.attr('y', d => yScaleBar(d.averageSales))
.attr('height', d => barChartHeight - yScaleBar(d.averageSales))),
update => update.transition().duration(750)
.attr('x', d => xScaleBar(d.region)!)
.attr('y', d => yScaleBar(d.averageSales))
.attr('width', xScaleBar.bandwidth())
.attr('height', d => barChartHeight - yScaleBar(d.averageSales))
.attr('fill', d => colorScaleBar(d.region)),
exit => exit.transition().duration(500)
.attr('y', barChartHeight)
.attr('height', 0)
.style('opacity', 0)
.remove()
);
}
// --- Brush for Interaction ---
const brush = d3.brushX()
.extent([[0, 0], [lineChartWidth, lineChartHeight]])
.on('end', brushed);
const brushGroup = lineChartG.append('g')
.attr('class', 'brush')
.call(brush);
function brushed(event) {
brushExtent = event.selection; // Save the brush selection
if (!brushExtent) {
// No selection, or selection cleared
updateDashboard(allData); // Show all data in bar chart
} else {
const [x0, x1] = brushExtent.map(xScaleLine.invert); // Convert pixel coordinates back to time
const filteredData = allData.filter(d => d.time >= x0 && d.time <= x1);
updateDashboard(allData, filteredData); // Update with filtered data
}
}
// Function to reset the brush
d3.select('#reset-brush').on('click', () => {
brushGroup.call(brush.move, null); // Clear the brush
brushExtent = null;
updateDashboard(allData);
});
// --- Main Dashboard Update Logic ---
function updateDashboard(currentLineData, currentBarData = currentLineData) {
drawLineChart(currentLineData);
drawBarChart(currentBarData);
}
// --- Start Data Simulation ---
function startSimulation() {
// Fill initial data
for (let i = 0; i < MAX_DATA_POINTS; i++) {
allData.push(generateNewDataPoint());
}
updateDashboard(allData);
dataInterval = setInterval(() => {
allData.push(generateNewDataPoint());
if (allData.length > MAX_DATA_POINTS) {
allData.shift(); // Remove oldest data point to maintain window
}
// If brush is active, update bar chart with filtered data
if (brushExtent) {
const [x0, x1] = brushExtent.map(xScaleLine.invert);
const filteredData = allData.filter(d => d.time >= x0 && d.time <= x1);
updateDashboard(allData, filteredData);
} else {
updateDashboard(allData); // Otherwise, update with all data
}
}, 1000); // Add a new data point every second
}
// Start everything!
startSimulation();
Explanation of Key Parts:
- Data Generation (
generateNewDataPoint): This function creates a new data point every second, simulating real-time data. Each point includestime,totalSales, andregionalSales. - Global Data (
allData): We maintainallDataas a continuously updating array. It acts like a circular buffer, maintaining a fixedMAX_DATA_POINTSby removing the oldest point when a new one arrives. - Line Chart (
drawLineChart):- Uses
xScaleLine(time scale) andyScaleLine(linear scale) to map time and sales. d3.line()andd3.area()generators create the path data.- Uses D3’s
join()pattern for data binding, ensuring smooth transitions for new, updated, and removed data points. - Includes
mouseoverandmouseoutevents for individual data points to show a tooltip.
- Uses
- Bar Chart (
drawBarChart):- Data Aggregation: Before drawing, it aggregates the
regionalSalesfrom thedataToRender(which could be the full data or brushed data) to calculateaverageSalesper region. This is a crucial step for synchronization. - Uses
xScaleBar(band scale) andyScaleBar(linear scale). d3.schemeCategory10provides distinct colors for regions.- Also uses D3’s
join()pattern with transitions. - Includes
mouseoverandmouseoutfor bar tooltips.
- Data Aggregation: Before drawing, it aggregates the
- Brushing (
d3.brushX):d3.brushX()creates a brush behavior that allows users to select a horizontal range on the line chart.- The
brushedfunction is called when the brush selection changes. - It
inverts thexScaleLineto convert pixel coordinates back to time values. - It filters
allDatabased on the brush extent to determinefilteredDatafor the bar chart.
- Dashboard Synchronization (
updateDashboard): This central function takes the data for the line chart and (optionally) for the bar chart. It calls the respective drawing functions. - Real-time Simulation (
startSimulation): AnsetIntervalrepeatedly callsgenerateNewDataPoint, updatesallData, and then triggersupdateDashboard. If a brush is active, it ensures the bar chart updates with the filtered data.
Exercises/Mini-Challenges (Building upon the project)
- Improve Line Chart Tooltip: Instead of just hovering individual points, make the line chart tooltip appear when hovering anywhere on the chart, showing the data for the closest point on the line. This requires more complex
mousemoveevent handling andxScaleLine.invert()to find the nearest data point. - Dynamic Line Colors for Regions: Modify the line chart to show separate lines for each region’s sales, instead of just the total. This would require restructuring the data passed to the line generator and creating multiple paths. The bar chart could then still show regional averages, or perhaps total sales for that region within the brushed period.
- Cross-Highlighting: When hovering over a bar in the bar chart, highlight the corresponding region’s data points (if you implemented separate lines for regions) or even draw a temporary line on the line chart representing that region’s historical data for the hovered region.
- Legend: Add interactive legends for both charts. For the bar chart, clicking a legend item could hide/show that region’s bar. For the line chart, a legend for multiple lines (if implemented) could do the same.
- Pause/Play/Speed Control: Add buttons or sliders to control the data simulation: pause/play, and increase/decrease the update speed.
- Persistent Brush: Allow the brush selection to “persist” even when new data arrives. When data shifts (due to
allData.shift()), the brush coordinates should dynamically adjust to stay with the original time window it was meant to represent. This is a more advanced brush interaction.
This project demonstrates a real-world application of D3.js, combining multiple techniques to create an interactive and dynamic data visualization. Mastering these patterns is key to building sophisticated dashboards. In the next project, we’ll tackle the challenge of visualizing extremely large datasets.