Academic Block

Interactive Charts with JavaScript Get Certified

Making a chart interactive means answering one question: “Which part of the chart is the mouse currently over?” This is called hit-testing. Once you know the answer, you can show tooltips, highlight bars, or select items.

Hit-Testing Bars

Hit-testing answers: is the mouse over a bar? For a bar chart it is a rectangle check. In this lesson the examples use made-up mouse positions so the results are the same every time; the last example is fully live.

What this code does: Draws a bar chart and checks whether a simulated mouse position (280, 100) is inside any bar. The text output lists the rectangle of every bar and whether the mouse hit it (only Thu does), then names the hovered bar. The picture shows the chart with the hovered bar in a lighter blue and a pink crosshair where the mouse is.

const canvas = document.createElement("canvas");
canvas.width = 480;
canvas.height = 280;
const ctx = canvas.getContext("2d");

const data = [
    { label: "Mon", value: 40 },
    { label: "Tue", value: 65 },
    { label: "Wed", value: 50 },
    { label: "Thu", value: 80 },
    { label: "Fri", value: 55 }
];

const max = Math.max(...data.map(d => d.value));
const barWidth = 50;
const gap = 25;
const startX = 40;
const baseY = 220;

// Turn every data item into a rectangle (x, y, width, height)
function getBars() {
    return data.map((d, i) => {
        const height = (d.value / max) * 170;
        const x = startX + i * (barWidth + gap);
        return { ...d, x, y: baseY - height, width: barWidth, height };
    });
}

// Hit-test: is the point (px, py) inside the rectangle of this bar?
function pointInRect(px, py, bar) {
    return px >= bar.x && px <= bar.x + bar.width &&
           py >= bar.y && py <= bar.y + bar.height;
}

function draw(highlightLabel = null, mouse = null) {
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    getBars().forEach(bar => {
        ctx.fillStyle = bar.label === highlightLabel ? "#60a5fa" : "#3b82f6";
        ctx.fillRect(bar.x, bar.y, bar.width, bar.height);

        ctx.fillStyle = "#1e293b";
        ctx.font = "13px Arial";
        ctx.textAlign = "center";
        ctx.fillText(bar.label, bar.x + bar.width / 2, baseY + 20);
    });

    if (mouse) drawMouse(ctx, mouse.x, mouse.y);
}

// Simulate mouse over "Thu"
const mouse = { x: 280, y: 100 };
const bars = getBars();
const hovered = bars.find(b => pointInRect(mouse.x, mouse.y, b));

bars.forEach(b => {
    console.log(b.label + ": x " + b.x + " to " + (b.x + b.width) + ", y " + b.y.toFixed(0) + " to " + (b.y + b.height) + " -> " + (pointInRect(mouse.x, mouse.y, b) ? "HIT" : "miss"));
});

draw(hovered ? hovered.label : null, mouse);
console.log(hovered ? `Mouse is over: ${hovered.label} (${hovered.value})` : "No bar under cursor");
showCanvas("Simulated mouse at (280, 100): the hovered bar is lighter", canvas);

// ===== Helpers below are not part of the lesson =====
function showCanvas(label, source) {
    const copy = document.createElement("canvas");
    copy.width = source.width;
    copy.height = source.height;
    copy.getContext("2d").drawImage(source, 0, 0);
    copy.style.maxWidth = "100%";
    setTimeout(() => {
        const box = document.createElement("div");
        box.className = "demo-canvas-box";
        box.innerHTML = "<p class='demo-canvas-label'>" + label + "</p>";
        box.appendChild(copy);
        document.getElementById("previewContent").appendChild(box);
    }, 0);
}

function drawMouse(ctx, x, y) {
    ctx.save();
    ctx.strokeStyle = "#d946ef";
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.arc(x, y, 7, 0, Math.PI * 2);
    ctx.moveTo(x - 12, y);
    ctx.lineTo(x + 12, y);
    ctx.moveTo(x, y - 12);
    ctx.lineTo(x, y + 12);
    ctx.stroke();
    ctx.restore();
}

Showing a Tooltip

A tooltip is a small label that follows the hovered item. On a canvas it is simply a filled rectangle with text, drawn last so it sits on top of everything else.

What this code does: Finds the bar under a simulated mouse position (280, 90) and draws the chart with that bar highlighted and a dark tooltip above it showing ‘Thu: 80’. The tooltip’s width is measured from its text with measureText(), and it is kept inside the canvas edges. The pink crosshair marks the mouse and the text output reports the tooltip text.

const canvas = document.createElement("canvas");
canvas.width = 480;
canvas.height = 300;
const ctx = canvas.getContext("2d");

const data = [
    { label: "Mon", value: 40 },
    { label: "Tue", value: 65 },
    { label: "Wed", value: 50 },
    { label: "Thu", value: 80 },
    { label: "Fri", value: 55 }
];

const max = Math.max(...data.map(d => d.value));
const barWidth = 50;
const gap = 25;
const startX = 40;
const baseY = 230;

// Turn every data item into a rectangle (x, y, width, height)
function getBars() {
    return data.map((d, i) => {
        const height = (d.value / max) * 170;
        const x = startX + i * (barWidth + gap);
        return { ...d, x, y: baseY - height, width: barWidth, height };
    });
}

// Hit-test: is the point (px, py) inside the rectangle of this bar?
function pointInRect(px, py, bar) {
    return px >= bar.x && px <= bar.x + bar.width &&
           py >= bar.y && py <= bar.y + bar.height;
}

function drawTooltip(text, x, y) {
    ctx.font = "13px Arial";
    const padding = 8;
    const width = ctx.measureText(text).width + padding * 2;
    const height = 28;
    // Keep the tooltip inside the canvas
    const left = Math.max(4, Math.min(canvas.width - width - 4, x - width / 2));

    ctx.fillStyle = "#1e293b";
    ctx.fillRect(left, y - 40, width, height);

    ctx.fillStyle = "#ffffff";
    ctx.textAlign = "center";
    ctx.fillText(text, left + width / 2, y - 21);
}

function draw(hovered, mouse) {
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    getBars().forEach(bar => {
        const isHovered = hovered && bar.label === hovered.label;
        ctx.fillStyle = isHovered ? "#60a5fa" : "#3b82f6";
        ctx.fillRect(bar.x, bar.y, bar.width, bar.height);

        ctx.fillStyle = "#1e293b";
        ctx.font = "13px Arial";
        ctx.textAlign = "center";
        ctx.fillText(bar.label, bar.x + bar.width / 2, baseY + 20);
    });

    if (hovered) {
        drawTooltip(`${hovered.label}: ${hovered.value}`, hovered.x + hovered.width / 2, hovered.y);
    }

    if (mouse) drawMouse(ctx, mouse.x, mouse.y);
}

const mouse = { x: 280, y: 90 };
const hovered = getBars().find(b => pointInRect(mouse.x, mouse.y, b));

draw(hovered, mouse);
console.log(hovered ? `Tooltip shown for ${hovered.label}` : "No tooltip");
if (hovered) console.log("Tooltip text:", hovered.label + ": " + hovered.value);
showCanvas("Tooltip above the hovered bar", canvas);

// ===== Helpers below are not part of the lesson =====
function showCanvas(label, source) {
    const copy = document.createElement("canvas");
    copy.width = source.width;
    copy.height = source.height;
    copy.getContext("2d").drawImage(source, 0, 0);
    copy.style.maxWidth = "100%";
    setTimeout(() => {
        const box = document.createElement("div");
        box.className = "demo-canvas-box";
        box.innerHTML = "<p class='demo-canvas-label'>" + label + "</p>";
        box.appendChild(copy);
        document.getElementById("previewContent").appendChild(box);
    }, 0);
}

function drawMouse(ctx, x, y) {
    ctx.save();
    ctx.strokeStyle = "#d946ef";
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.arc(x, y, 7, 0, Math.PI * 2);
    ctx.moveTo(x - 12, y);
    ctx.lineTo(x + 12, y);
    ctx.moveTo(x, y - 12);
    ctx.lineTo(x, y + 12);
    ctx.stroke();
    ctx.restore();
}

Click-to-Select

Hover is temporary, selection is remembered. To select, you store the chosen item in a variable and draw it differently until something else is clicked.

What this code does: Simulates three clicks and shows the chart after each one. A click on Thu selects it (orange outline), a click on Tue moves the selection to Tue, and a click on empty space clears it. The selection is kept in the variable selected, and handleClick() updates it and redraws. The text output describes each click and the chart after each click is shown below it.

const canvas = document.createElement("canvas");
canvas.width = 480;
canvas.height = 280;
const ctx = canvas.getContext("2d");

const data = [
    { label: "Mon", value: 40 },
    { label: "Tue", value: 65 },
    { label: "Wed", value: 50 },
    { label: "Thu", value: 80 },
    { label: "Fri", value: 55 }
];

let selected = null;

const max = Math.max(...data.map(d => d.value));
const barWidth = 50;
const gap = 25;
const startX = 40;
const baseY = 220;

// Turn every data item into a rectangle (x, y, width, height)
function getBars() {
    return data.map((d, i) => {
        const height = (d.value / max) * 170;
        const x = startX + i * (barWidth + gap);
        return { ...d, x, y: baseY - height, width: barWidth, height };
    });
}

// Hit-test: is the point (px, py) inside the rectangle of this bar?
function pointInRect(px, py, bar) {
    return px >= bar.x && px <= bar.x + bar.width &&
           py >= bar.y && py <= bar.y + bar.height;
}

function draw(mouse) {
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    getBars().forEach(bar => {
        ctx.fillStyle = "#3b82f6";
        ctx.fillRect(bar.x, bar.y, bar.width, bar.height);

        if (bar.label === selected) {
            ctx.strokeStyle = "#f97316";
            ctx.lineWidth = 4;
            ctx.strokeRect(bar.x - 2, bar.y - 2, bar.width + 4, bar.height + 4);
        }

        ctx.fillStyle = "#1e293b";
        ctx.font = "13px Arial";
        ctx.textAlign = "center";
        ctx.fillText(bar.label, bar.x + bar.width / 2, baseY + 20);
    });

    if (mouse) drawMouse(ctx, mouse.x, mouse.y);
}

// What happens when the user clicks at (mx, my)
function handleClick(mx, my) {
    const hit = getBars().find(b => pointInRect(mx, my, b));
    selected = hit ? hit.label : null;
    console.log("Click at (" + mx + ", " + my + ") -> " + (selected ? "Selected: " + selected : "Selection cleared"));
    draw({ x: mx, y: my });
    showCanvas(selected ? "Selected: " + selected : "Selection cleared", canvas);
}

handleClick(280, 100);   // click on Thu
handleClick(140, 150);   // click on Tue: the selection moves
handleClick(20, 30);     // click on empty space: selection cleared

// ===== Helpers below are not part of the lesson =====
function showCanvas(label, source) {
    const copy = document.createElement("canvas");
    copy.width = source.width;
    copy.height = source.height;
    copy.getContext("2d").drawImage(source, 0, 0);
    copy.style.maxWidth = "100%";
    setTimeout(() => {
        const box = document.createElement("div");
        box.className = "demo-canvas-box";
        box.innerHTML = "<p class='demo-canvas-label'>" + label + "</p>";
        box.appendChild(copy);
        document.getElementById("previewContent").appendChild(box);
    }, 0);
}

function drawMouse(ctx, x, y) {
    ctx.save();
    ctx.strokeStyle = "#d946ef";
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.arc(x, y, 7, 0, Math.PI * 2);
    ctx.moveTo(x - 12, y);
    ctx.lineTo(x + 12, y);
    ctx.moveTo(x, y - 12);
    ctx.lineTo(x, y + 12);
    ctx.stroke();
    ctx.restore();
}

Nearest Point on a Line Chart

Dots on a line chart are tiny, so asking whether the mouse is exactly inside one is too strict. Instead, find the point that is closest to the mouse. The distance between two points is Math.hypot(dx, dy).

What this code does: Draws a line chart of seven values and finds the data point nearest to a simulated mouse position (220, 100). The text output lists the distance from the mouse to every point, so you can check that the winner really is the closest. The picture shows the line, the nearest point enlarged in orange, and a dashed line from the mouse to that point.

const canvas = document.createElement("canvas");
canvas.width = 480;
canvas.height = 280;
const ctx = canvas.getContext("2d");

const values = [12, 25, 18, 32, 28, 40, 35];
const max = Math.max(...values);
const startX = 40;
const stepX = 60;
const baseY = 220;

function getPoints() {
    return values.map((v, i) => ({
        value: v,
        x: startX + i * stepX,
        y: baseY - (v / max) * 170
    }));
}

function findNearest(points, px, py) {
    let nearest = null;
    let minDist = Infinity;

    points.forEach(p => {
        const dist = Math.hypot(p.x - px, p.y - py);
        if (dist < minDist) {
            minDist = dist;
            nearest = p;
        }
    });
    return nearest;
}

function draw(points, highlighted, mouse) {
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    // Draw line
    ctx.beginPath();
    points.forEach((p, i) => i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y));
    ctx.strokeStyle = "#0ea5e9";
    ctx.lineWidth = 3;
    ctx.stroke();

    // Dashed line from the mouse to the nearest point
    if (mouse && highlighted) {
        ctx.setLineDash([5, 4]);
        ctx.strokeStyle = "#d946ef";
        ctx.lineWidth = 1.5;
        ctx.beginPath();
        ctx.moveTo(mouse.x, mouse.y);
        ctx.lineTo(highlighted.x, highlighted.y);
        ctx.stroke();
        ctx.setLineDash([]);
    }

    // Draw points
    points.forEach(p => {
        const isHi = p === highlighted;
        ctx.beginPath();
        ctx.arc(p.x, p.y, isHi ? 8 : 5, 0, Math.PI * 2);
        ctx.fillStyle = isHi ? "#f97316" : "#0369a1";
        ctx.fill();
    });

    if (mouse) drawMouse(ctx, mouse.x, mouse.y);
}

const points = getPoints();
const mouse = { x: 220, y: 100 };

points.forEach(p => {
    console.log("Point " + p.value + " at (" + p.x + ", " + p.y.toFixed(0) + "): distance " + Math.hypot(p.x - mouse.x, p.y - mouse.y).toFixed(1) + "px");
});

const nearest = findNearest(points, mouse.x, mouse.y);

draw(points, nearest, mouse);
console.log("Nearest point value:", nearest.value);
showCanvas("The nearest point to the mouse is highlighted", canvas);

// ===== Helpers below are not part of the lesson =====
function showCanvas(label, source) {
    const copy = document.createElement("canvas");
    copy.width = source.width;
    copy.height = source.height;
    copy.getContext("2d").drawImage(source, 0, 0);
    copy.style.maxWidth = "100%";
    setTimeout(() => {
        const box = document.createElement("div");
        box.className = "demo-canvas-box";
        box.innerHTML = "<p class='demo-canvas-label'>" + label + "</p>";
        box.appendChild(copy);
        document.getElementById("previewContent").appendChild(box);
    }, 0);
}

function drawMouse(ctx, x, y) {
    ctx.save();
    ctx.strokeStyle = "#d946ef";
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.arc(x, y, 7, 0, Math.PI * 2);
    ctx.moveTo(x - 12, y);
    ctx.lineTo(x + 12, y);
    ctx.moveTo(x, y - 12);
    ctx.lineTo(x, y + 12);
    ctx.stroke();
    ctx.restore();
}

Practice: Full Interactive Bar Chart

Now everything is live: hover highlights and shows a tooltip, a click selects, and leaving the chart removes the hover. Hover and selection are two separate pieces of state.

What this code does: Builds a complete interactive bar chart and puts the real canvas in the output, so you can use it with your mouse: move over a bar for a highlight and tooltip, click to select it (orange outline), and click empty space to clear the selection. A status line under the chart reports what is happening. The code starts in a simulated state (hovering Wed, Thu selected) and the text output explains it. Mouse positions are converted from the browser page to canvas pixels by toCanvasCoords().

const canvas = document.createElement("canvas");
canvas.width = 500;
canvas.height = 300;
const ctx = canvas.getContext("2d");

const data = [
    { label: "Mon", value: 42 },
    { label: "Tue", value: 68 },
    { label: "Wed", value: 55 },
    { label: "Thu", value: 90 },
    { label: "Fri", value: 75 }
];

let selected = "Thu";   // already selected for demo
let hoveredLabel = null;

const max = Math.max(...data.map(d => d.value));
const barWidth = 55;
const gap = 22;
const startX = 40;
const baseY = 230;

function getBars() {
    return data.map((d, i) => {
        const height = (d.value / max) * 170;
        const x = startX + i * (barWidth + gap);
        return { ...d, x, y: baseY - height, width: barWidth, height };
    });
}

function barAt(x, y) {
    return getBars().find(b => x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height);
}

// Browser mouse coordinates -> canvas coordinates
function toCanvasCoords(event) {
    const rect = canvas.getBoundingClientRect();
    return {
        x: (event.clientX - rect.left) * (canvas.width / rect.width),
        y: (event.clientY - rect.top) * (canvas.height / rect.height)
    };
}

function draw() {
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    getBars().forEach(bar => {
        // Bar color
        ctx.fillStyle = bar.label === hoveredLabel ? "#60a5fa" : "#3b82f6";
        ctx.fillRect(bar.x, bar.y, bar.width, bar.height);

        // Selection outline
        if (bar.label === selected) {
            ctx.strokeStyle = "#f97316";
            ctx.lineWidth = 4;
            ctx.strokeRect(bar.x - 2, bar.y - 2, bar.width + 4, bar.height + 4);
        }

        // Label
        ctx.fillStyle = "#1e293b";
        ctx.font = "13px Arial";
        ctx.textAlign = "center";
        ctx.fillText(bar.label, bar.x + bar.width / 2, baseY + 20);
    });

    // Tooltip
    const hovered = getBars().find(b => b.label === hoveredLabel);
    if (hovered) {
        const text = `${hovered.label}: ${hovered.value}`;
        ctx.font = "13px Arial";
        ctx.textAlign = "center";
        const w = ctx.measureText(text).width + 16;
        const cx = hovered.x + hovered.width / 2;
        ctx.fillStyle = "#1e293b";
        ctx.fillRect(cx - w / 2, hovered.y - 38, w, 28);
        ctx.fillStyle = "#ffffff";
        ctx.fillText(text, cx, hovered.y - 19);
    }
}

// Put the REAL canvas in the output so the mouse works on it
const status = showLive("Interactive chart: hover a bar, click to select", canvas);

canvas.addEventListener("mousemove", (event) => {
    const p = toCanvasCoords(event);
    const bar = barAt(p.x, p.y);
    hoveredLabel = bar ? bar.label : null;
    canvas.style.cursor = bar ? "pointer" : "default";
    status.textContent = bar ? "Hovering " + bar.label + " (" + bar.value + ")" : "Not over a bar";
    draw();
});

canvas.addEventListener("click", (event) => {
    const p = toCanvasCoords(event);
    const bar = barAt(p.x, p.y);
    selected = bar ? bar.label : null;
    status.textContent = selected ? "Selected " + selected : "Selection cleared";
    draw();
});

canvas.addEventListener("mouseleave", () => {
    hoveredLabel = null;
    status.textContent = "Mouse left the chart";
    draw();
});

// Initial state: simulated hover over "Wed", Thu already selected
hoveredLabel = "Wed";
draw();
status.textContent = "Hovering Wed (55), Thu is selected";
console.log("Initial state: hovering Wed, selected Thu");
console.log("Hover and selection are two separate pieces of state");
console.log("Now try it on the chart below: move the mouse over the bars and click.");

// ===== Helpers below are not part of the lesson =====
function showLive(label, canvas) {
    const status = document.createElement("div");
    status.className = "demo-status";
    status.style.cssText = "font:13px monospace;color:#334155;margin-top:6px;";
    status.textContent = "Move the mouse over the chart";
    canvas.style.maxWidth = "100%";
    setTimeout(() => {
        const box = document.createElement("div");
        box.className = "demo-canvas-box";
        box.innerHTML = "<p class='demo-canvas-label'>" + label + "</p>";
        box.appendChild(canvas);
        box.appendChild(status);
        document.getElementById("previewContent").appendChild(box);
    }, 0);
    return status;
}

Quick Summary

  • Hit-testing = checking if the mouse is inside a bar, slice, or near a point.
  • Bars use simple rectangle checks.
  • Line charts use Math.hypot() to find the nearest point.
  • Tooltips are just a small filled rectangle + text, drawn last so they sit on top.
  • Mouse positions must be converted to canvas pixels with getBoundingClientRect().
  • Keep hover and selection as two separate pieces of state, and compare items by a stable value (such as a label), not by object identity.
Tip: Click “Test My Code” on the last example and use the chart with your mouse: hover for the tooltip, click to select, click empty space to clear.

🧪 Test Your JavaScript Code

Edit the code on the left and click “Run Code” to see the console output on the right.

📝 JavaScript Code
👁 Console Output

Click “Run Code” to see the output here.

Ctrl + Enter to run code Esc to close