Guided Project 1: Interactive Dashboard with Real-time Data

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:

  1. Line Chart: Shows the trend of “total sales” over time.
  2. 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:

  1. Data Generation (generateNewDataPoint): This function creates a new data point every second, simulating real-time data. Each point includes time, totalSales, and regionalSales.
  2. Global Data (allData): We maintain allData as a continuously updating array. It acts like a circular buffer, maintaining a fixed MAX_DATA_POINTS by removing the oldest point when a new one arrives.
  3. Line Chart (drawLineChart):
    • Uses xScaleLine (time scale) and yScaleLine (linear scale) to map time and sales.
    • d3.line() and d3.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 mouseover and mouseout events for individual data points to show a tooltip.
  4. Bar Chart (drawBarChart):
    • Data Aggregation: Before drawing, it aggregates the regionalSales from the dataToRender (which could be the full data or brushed data) to calculate averageSales per region. This is a crucial step for synchronization.
    • Uses xScaleBar (band scale) and yScaleBar (linear scale).
    • d3.schemeCategory10 provides distinct colors for regions.
    • Also uses D3’s join() pattern with transitions.
    • Includes mouseover and mouseout for bar tooltips.
  5. Brushing (d3.brushX):
    • d3.brushX() creates a brush behavior that allows users to select a horizontal range on the line chart.
    • The brushed function is called when the brush selection changes.
    • It inverts the xScaleLine to convert pixel coordinates back to time values.
    • It filters allData based on the brush extent to determine filteredData for the bar chart.
  6. 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.
  7. Real-time Simulation (startSimulation): An setInterval repeatedly calls generateNewDataPoint, updates allData, and then triggers updateDashboard. If a brush is active, it ensures the bar chart updates with the filtered data.

Exercises/Mini-Challenges (Building upon the project)

  1. 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 mousemove event handling and xScaleLine.invert() to find the nearest data point.
  2. 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.
  3. 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.
  4. 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.
  5. Pause/Play/Speed Control: Add buttons or sliders to control the data simulation: pause/play, and increase/decrease the update speed.
  6. 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.