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).
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:
| Method | Purpose |
|---|---|
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).
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).
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)
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.
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();
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();
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);
}
}
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();
}
});
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';
});
<div> overlay is simpler and performs better. This is actually what Chart.js does internally.
The rendering order for a complete line chart:
ctx.clearRect()See the complete implementation in line-chart.html.
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:
| Feature | Our Scale | D3 scaleLinear() |
|---|---|---|
| Linear mapping | Yes | Yes |
| Clamping (values outside domain) | No | .clamp(true) |
| Nice tick values | Manual | .nice() |
| Tick generation | Manual | .ticks(count) |
| Inverse mapping (range → domain) | No | .invert(pixel) |
| Color interpolation | No | Yes (with color ranges) |
D3 also provides specialized scales we will use later:
scaleTime() — maps Date objects to pixels, with smart tick formattingscaleLog() — logarithmic mapping for data with huge rangescaleOrdinal() — maps categories (strings) to colors or positionsscaleBand() — maps categories to evenly-spaced bands (for bar charts)beginPath() — Without it, every stroke() redraws all previous paths, creating a mess of overlapping lines.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.logScale() function that maps values logarithmically. When would you use this instead of a linear scale?resize event and recalculate your scales with new canvas dimensions.