Module 02: Line Charts & Scales

In Module 01 we drew rectangles and text on a canvas. That is enough for bar charts, but most analytics dashboards live and die by the line chart — the go-to encoding for time-series data like daily pageviews, session counts, or error rates. To draw one, we need two new ideas: paths (connected line segments) and scale functions (the math that maps your data values to pixel coordinates).

Module Files

1. Canvas Paths: moveTo and lineTo

A bar chart uses fillRect() for each bar. A line chart uses a path — a sequence of connected points. The Canvas API gives us two core methods:

MethodPurpose
ctx.moveTo(x, y)Lift the pen and move to a new starting point (no line drawn)
ctx.lineTo(x, y)Draw a straight line from the current position to (x, y)

Every path follows the same ceremony:

ctx.beginPath();           // Start a new path
ctx.moveTo(x0, y0);       // Starting point
ctx.lineTo(x1, y1);       // First segment
ctx.lineTo(x2, y2);       // Second segment
// ... more points
ctx.stroke();              // Actually draw the line

Think of it like a pen plotter: beginPath() lifts the pen, moveTo() positions it, and each lineTo() draws a segment. Nothing appears on screen until you call stroke() (outline) or fill() (filled shape).

2. The Scale Problem

Suppose you have 30 days of pageview data. Day 1 had 312 pageviews; day 30 had 587. Your canvas is 800 pixels wide and 400 pixels tall. Where do you plot each point?

You cannot just say lineTo(1, 312) — that would draw at pixel (1, 312), which ignores your chart margins and makes the y-axis meaningless if your data range is 200–800 but your canvas is only 400 pixels tall.

You need a scale function: a mapping from your data's domain (the min and max of your actual values) to a pixel range (the min and max coordinates on the canvas).

Data Domain Scale Function Pixel Range ┌───────────┐ ┌─────────────────┐ ┌────────────┐ │ min: 200 │ ─────────► │ linearScale() │ ──────► │ max: 350 │ │ max: 800 │ │ │ │ min: 30 │ │ │ │ input → output │ │ │ │ value: 500│ ─────────► │ 500 → 190 │ ──────► │ pixel: 190 │ └───────────┘ └─────────────────┘ └────────────┘ Note: y-axis is INVERTED on canvas — low pixel values are at the TOP. So domain min (200) maps to range max (350, bottom of chart area) and domain max (800) maps to range min (30, top of chart area).

3. Building a Linear Scale by Hand

A linear scale is just a proportion calculation. Given a value in the domain, compute where it falls within the range:

function linearScale(domainMin, domainMax, rangeMin, rangeMax) {
    return function(value) {
        // What fraction of the domain does this value represent?
        const fraction = (value - domainMin) / (domainMax - domainMin);
        // Apply that same fraction to the range
        return rangeMin + fraction * (rangeMax - rangeMin);
    };
}

// Example: map pageview counts [200, 800] to y-pixels [350, 30]
const yScale = linearScale(200, 800, 350, 30);

yScale(200);  // → 350  (minimum value → bottom of chart)
yScale(800);  // → 30   (maximum value → top of chart)
yScale(500);  // → 190  (middle value → middle of chart)
Why is the y-range inverted? Canvas coordinates start at the top-left corner. Pixel y=0 is the top of the canvas, not the bottom. So to draw higher values further up, the range goes from a large number (bottom) to a small number (top). This trips up every beginner — if your chart looks upside down, check your range direction.

We need two scales for a line chart:

// Chart area boundaries (leaving room for labels)
const margin = { top: 30, right: 20, bottom: 50, left: 60 };
const chartWidth  = canvas.width  - margin.left - margin.right;
const chartHeight = canvas.height - margin.top  - margin.bottom;

// X scale: day index [0, 29] → horizontal pixels
const xScale = linearScale(0, data.length - 1,
                            margin.left,
                            margin.left + chartWidth);

// Y scale: pageview count → vertical pixels (inverted!)
const yMin = Math.min(...data.map(d => d.value));
const yMax = Math.max(...data.map(d => d.value));
const yScale = linearScale(yMin, yMax,
                            margin.top + chartHeight,  // bottom
                            margin.top);                // top

Try the Scale Playground to see this mapping in real time — drag a slider and watch the domain value translate to a range value.

4. Drawing the Line

With scales in hand, drawing the line is straightforward — loop through the data, convert each point to pixel coordinates, and build a path:

ctx.beginPath();
ctx.strokeStyle = '#16a085';
ctx.lineWidth = 2;

data.forEach((point, i) => {
    const x = xScale(i);
    const y = yScale(point.value);
    if (i === 0) {
        ctx.moveTo(x, y);
    } else {
        ctx.lineTo(x, y);
    }
});

ctx.stroke();

5. Filled Area Under the Line

A filled area chart is just a line chart with a closed shape underneath. After drawing the line path, continue the path down to the x-axis and back:

// Continue the path to close the area
ctx.lineTo(xScale(data.length - 1), margin.top + chartHeight); // right side, bottom
ctx.lineTo(xScale(0), margin.top + chartHeight);                // left side, bottom
ctx.closePath();

// Fill with semi-transparent color
ctx.fillStyle = 'rgba(22, 160, 133, 0.15)';
ctx.fill();

// Draw the line on top
ctx.strokeStyle = '#16a085';
ctx.lineWidth = 2;
ctx.stroke();
Layer order matters. Canvas draws in painter's order — whatever you draw last is on top. Fill the area first, then stroke the line, so the line sits crisply on top of the shading.

6. Grid Lines for Readability

A bare line on a white canvas is hard to read. Grid lines give the viewer reference points to estimate values. Draw them before the data line so they appear behind it:

function drawGridLines(ctx, yScale, margin, chartWidth, tickCount) {
    ctx.strokeStyle = '#e0e0e0';
    ctx.lineWidth = 1;

    const yMin = yScale.domainMin;
    const yMax = yScale.domainMax;
    const step = (yMax - yMin) / tickCount;

    for (let i = 0; i <= tickCount; i++) {
        const value = yMin + step * i;
        const y = yScale(value);

        // Horizontal grid line
        ctx.beginPath();
        ctx.moveTo(margin.left, y);
        ctx.lineTo(margin.left + chartWidth, y);
        ctx.stroke();

        // Y-axis label
        ctx.fillStyle = '#666';
        ctx.textAlign = 'right';
        ctx.fillText(Math.round(value).toString(), margin.left - 8, y + 4);
    }
}

7. Time-Series X-Axis Labels

For a 30-day chart, showing all 30 date labels would create a wall of overlapping text. A common pattern is to show every Nth label:

// Show every 5th date label
ctx.fillStyle = '#666';
ctx.textAlign = 'center';
ctx.font = '11px sans-serif';

data.forEach((point, i) => {
    if (i % 5 === 0 || i === data.length - 1) {
        const x = xScale(i);
        const y = margin.top + chartHeight + 20;

        // Format date: "Jan 5", "Jan 10", etc.
        const dateStr = point.date.toLocaleDateString('en-US', {
            month: 'short', day: 'numeric'
        });
        ctx.fillText(dateStr, x, y);

        // Optional: small tick mark
        ctx.beginPath();
        ctx.moveTo(x, margin.top + chartHeight);
        ctx.lineTo(x, margin.top + chartHeight + 5);
        ctx.strokeStyle = '#ccc';
        ctx.stroke();
    }
});

8. Hover Tooltips with Mouse Events

Canvas is a single bitmap — it does not have DOM elements you can attach event listeners to. To create tooltips, you listen for mouse events on the canvas element itself, calculate which data point the cursor is nearest to, and show a positioned HTML <div> as the tooltip.

// Create a tooltip div
const tooltip = document.createElement('div');
tooltip.style.cssText = `
    position: absolute; display: none; background: #333; color: white;
    padding: 8px 12px; border-radius: 4px; font-size: 13px;
    pointer-events: none; z-index: 10;
`;
document.body.appendChild(tooltip);

canvas.addEventListener('mousemove', (e) => {
    const rect = canvas.getBoundingClientRect();
    const mouseX = e.clientX - rect.left;
    const mouseY = e.clientY - rect.top;

    // Find the nearest data point by x-position
    let nearest = null;
    let nearestDist = Infinity;

    data.forEach((point, i) => {
        const px = xScale(i);
        const dist = Math.abs(mouseX - px);
        if (dist < nearestDist) {
            nearestDist = dist;
            nearest = { point, index: i, px, py: yScale(point.value) };
        }
    });

    if (nearest && nearestDist < 20) {
        tooltip.style.display = 'block';
        tooltip.style.left = (rect.left + nearest.px + 10) + 'px';
        tooltip.style.top  = (rect.top  + nearest.py - 30) + 'px';
        tooltip.innerHTML = `
            ${nearest.point.date.toLocaleDateString()}
${nearest.point.value.toLocaleString()} pageviews `; } else { tooltip.style.display = 'none'; } }); canvas.addEventListener('mouseleave', () => { tooltip.style.display = 'none'; });
Why not draw the tooltip on the canvas? You could, but then you would need to redraw the entire chart every time the mouse moves (to erase the old tooltip). Using an HTML <div> overlay is simpler and performs better. This is actually what Chart.js does internally.

9. Putting It All Together

The rendering order for a complete line chart:

  1. Clear the canvasctx.clearRect()
  2. Draw grid lines — light gray horizontals with y-axis labels
  3. Draw x-axis labels — date strings along the bottom
  4. Draw the filled area — semi-transparent fill under the line
  5. Draw the line — the main data path
  6. Draw data points — optional small circles at each point
  7. Draw the title — chart title centered at the top

See the complete implementation in line-chart.html.

10. The Scale Function Is Everything

Key Insight: Every charting library contains a scale function at its core. D3's scaleLinear() does exactly what we just built by hand. Chart.js computes scales internally from your data config. When you understand scales, you understand the fundamental mechanism of every data visualization.

Our hand-rolled linearScale() is intentionally simple. Production scale functions handle additional concerns:

FeatureOur ScaleD3 scaleLinear()
Linear mappingYesYes
Clamping (values outside domain)No.clamp(true)
Nice tick valuesManual.nice()
Tick generationManual.ticks(count)
Inverse mapping (range → domain)No.invert(pixel)
Color interpolationNoYes (with color ranges)

D3 also provides specialized scales we will use later:

11. Common Mistakes

  1. Forgetting beginPath() — Without it, every stroke() redraws all previous paths, creating a mess of overlapping lines.
  2. Y-axis not inverted — If your chart is upside down, your range is [top, bottom] when it should be [bottom, top] (or equivalently [chartHeight, 0]).
  3. Data points at the edges — If the first and last points are right at the margin boundary, they can get clipped. Add a few pixels of padding to your range.
  4. Too many x-axis labels — 30 labels on a 600px chart means 20px per label, which is unreadable. Show every Nth label or use date-aware ticking.
  5. Not calling getBoundingClientRect() for mouse position — Using e.offsetX alone can be unreliable if the canvas has CSS transforms or borders. Always compute relative position from the bounding rect.

12. Exercises

  1. Multi-line chart: Modify line-chart.html to plot two data series (e.g., pageviews and unique visitors) with different colors and a legend.
  2. Log scale: Write a logScale() function that maps values logarithmically. When would you use this instead of a linear scale?
  3. Crosshair: Instead of snapping to the nearest point, draw a vertical crosshair line that follows the mouse cursor across the chart area.
  4. Responsive resize: Make the chart redraw when the window resizes. Hint: listen for the resize event and recalculate your scales with new canvas dimensions.