01. Hello Canvas

In this module you will learn to draw directly on the HTML Canvas element — the lowest level of client-side charting. Before any library can render a bar chart or a line graph, something has to paint pixels onto a surface. That something is the Canvas 2D API. Understanding it gives you insight into what every charting library does behind the scenes.

Connection to the Overview: This module implements the client-side rendering approach described in Section 7 of the Data Visualization Overview. The browser receives data and renders the chart locally using the Canvas API. No server-side image generation is involved.

Demo Files

Run: Open the HTML files directly in a browser. No server or build step required.

The Canvas Element

The <canvas> element is a blank rectangular area in which JavaScript can draw pixels. It was introduced in HTML5 and is now supported by every modern browser. Unlike SVG, which uses a retained-mode DOM of shapes, Canvas is immediate-mode: you issue drawing commands and the pixels are painted. There is no DOM tree of shapes to inspect or style with CSS afterward.

<canvas id="myChart" width="600" height="400"></canvas>

<script>
const canvas = document.getElementById('myChart');
const ctx = canvas.getContext('2d');

// ctx is a CanvasRenderingContext2D object
// All drawing methods are called on ctx
ctx.fillStyle = '#16a085';
ctx.fillRect(50, 50, 200, 100);
</script>

The getContext('2d') call returns a CanvasRenderingContext2D object. Every drawing operation — rectangles, lines, text, arcs — is a method on this context object.

Canvas dimensions matter: The width and height attributes on the <canvas> element set the drawing surface resolution in pixels. CSS width/height only stretch or shrink the display. If you set the drawing surface to 300x150 (the default) but display it at 600x400 via CSS, everything will look blurry. Always set the attributes explicitly.

The Coordinate System

Canvas uses a coordinate system where the origin (0, 0) is the top-left corner. The x-axis increases to the right and the y-axis increases downward. This is the opposite of the mathematical convention where y increases upward, and it has a direct impact on how you map data values to pixel positions.

(0,0) ───────────────────────── x (width) │ │ Canvas coordinate system │ │ - Origin at top-left │ - x increases rightward │ - y increases DOWNWARD │ │ (100, 200) means: │ 100px from the left edge │ 200px from the top edge │ y (height)

When you draw a bar chart, taller bars need smaller y values (closer to the top of the canvas). This is the single most common source of confusion for students drawing their first chart on Canvas. You will see the practical consequence of this in the bar chart section below.

Core Drawing Methods

The 2D context provides a small set of methods that cover almost every charting need:

Rectangles

// Filled rectangle
ctx.fillStyle = '#2ecc71';
ctx.fillRect(x, y, width, height);

// Outlined rectangle (stroke only)
ctx.strokeStyle = '#333';
ctx.lineWidth = 2;
ctx.strokeRect(x, y, width, height);

// Clear a rectangular area (erase pixels)
ctx.clearRect(x, y, width, height);

Rectangles are the workhorse of bar charts. Each bar is a single fillRect call.

Paths: Lines and Shapes

// Draw a line from (50,50) to (200,150)
ctx.beginPath();
ctx.moveTo(50, 50);       // Move pen to start
ctx.lineTo(200, 150);     // Draw line to end
ctx.strokeStyle = '#333';
ctx.lineWidth = 2;
ctx.stroke();              // Actually render the path

// Draw a triangle (closed path)
ctx.beginPath();
ctx.moveTo(300, 50);
ctx.lineTo(350, 150);
ctx.lineTo(250, 150);
ctx.closePath();           // Close back to start
ctx.fillStyle = '#3498db';
ctx.fill();

The path system follows a pen metaphor: beginPath() lifts the pen, moveTo() moves it without drawing, lineTo() draws a straight line, and stroke() or fill() renders the accumulated path. Axis lines, gridlines, and line charts all use this system.

Arcs and Circles

// Full circle
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = '#e74c3c';
ctx.fill();

// Half circle (arc from 0 to PI)
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI);
ctx.stroke();

The arc() method takes a center point, radius, start angle, and end angle in radians. A full circle goes from 0 to 2π. Pie charts and donut charts are sequences of arc calls with different start and end angles.

Text

ctx.font = '14px -apple-system, sans-serif';
ctx.fillStyle = '#333';
ctx.textAlign = 'center';       // 'left', 'center', 'right'
ctx.textBaseline = 'top';       // 'top', 'middle', 'bottom'
ctx.fillText('Hello Canvas', x, y);

// Measure text width (useful for positioning)
const metrics = ctx.measureText('Hello Canvas');
console.log(metrics.width);     // e.g., 98.5

Text rendering is essential for axis labels, chart titles, and data annotations. The textAlign and textBaseline properties control where the text is placed relative to the (x, y) coordinate.

Mapping Data to Pixels

The central challenge of drawing a chart is converting data values (like "1250 pageviews") into pixel coordinates on the canvas. This is the concept of a scale function — a mapping from a data domain to a pixel range.

Data Domain Scale Function Pixel Range ───────── ───────────────── ──────────── min: 0 ──► pixel = (value / max) ──► top of chart area max: 1250 * chartHeight bottom of chart area Example: value = 890 chartHeight = 300 pixel = (890 / 1250) * 300 = 213.6 But remember: y=0 is the TOP of the canvas! So the bar's top-left y = chartBottom - pixel = 350 - 213.6 = 136.4

A linear scale function for mapping a data value to a vertical pixel position looks like this:

// Scale function: data value → pixel y-coordinate
function scaleY(value, maxValue, chartTop, chartHeight) {
    // Normalize to 0..1, multiply by chart height, invert for canvas coords
    return chartTop + chartHeight - (value / maxValue) * chartHeight;
}

// Example: bar for 890 pageviews on a chart from y=50 to y=350
const pixelY = scaleY(890, 1250, 50, 300);
// pixelY = 50 + 300 - (890/1250)*300 = 50 + 300 - 213.6 = 136.4
This is exactly what libraries automate: When you use Chart.js or D3.js, their scale functions (d3.scaleLinear(), Chart.js's internal scales) do this same arithmetic. Understanding the manual process makes it clear what those abstractions are doing for you.

Handling HiDPI (Retina) Displays

On high-DPI screens (Retina MacBooks, modern phones), the physical pixel density is 2x or 3x the CSS pixel grid. A canvas set to 600x400 CSS pixels is actually backed by 1200x800 physical pixels on a 2x display. If you don't account for this, your chart will look blurry because the browser upscales the lower-resolution drawing surface.

const canvas = document.getElementById('myChart');
const ctx = canvas.getContext('2d');

// Get the device pixel ratio (1 on standard, 2 on Retina, 3 on some phones)
const dpr = window.devicePixelRatio || 1;

// Desired display size in CSS pixels
const displayWidth = 600;
const displayHeight = 400;

// Set the actual drawing surface to match physical pixels
canvas.width = displayWidth * dpr;
canvas.height = displayHeight * dpr;

// Scale the CSS size back down so it appears the right size
canvas.style.width = displayWidth + 'px';
canvas.style.height = displayHeight + 'px';

// Scale all drawing operations by dpr
ctx.scale(dpr, dpr);

// Now draw as if the canvas is 600x400 — it will be crisp on any screen
ctx.fillRect(50, 50, 200, 100);

After the ctx.scale(dpr, dpr) call, you can write all your drawing code using the logical CSS pixel coordinates (600x400). The context transform handles the physical pixel mapping transparently.

Do this once at setup time: Set the canvas dimensions and scale before any drawing code runs. If you resize the canvas later (e.g., on window resize), you need to repeat this setup and redraw everything, because resizing the canvas clears its contents.

Building a Bar Chart Step by Step

Let's bring everything together and build a bar chart showing pageviews by page. This is the same chart you can run in first-bar-chart.html.

Step 1: Define the Data and Layout

const data = [
    { page: '/home',     views: 1250 },
    { page: '/about',    views: 430 },
    { page: '/products', views: 890 },
    { page: '/contact',  views: 320 },
    { page: '/blog',     views: 675 }
];

// Chart layout (in CSS pixels)
const margin = { top: 40, right: 20, bottom: 60, left: 60 };
const canvasWidth = 600;
const canvasHeight = 400;
const chartWidth = canvasWidth - margin.left - margin.right;
const chartHeight = canvasHeight - margin.top - margin.bottom;

The margin convention reserves space around the chart area for the title, axis labels, and tick marks. The chart itself draws within the inner rectangle. This is the same pattern used by D3.js and most other charting code.

┌──────────────────────────────────────────┐ │ margin.top (title) │ │ ┌────────────────────────────────┐ │ │ │ │ │ │ m │ Chart Area │ m │ │ a │ │ a │ │ r │ chartWidth x chartHeight │ r │ │ g │ │ g │ │ . │ │ . │ │ l │ │ r │ │ └────────────────────────────────┘ │ │ margin.bottom (labels) │ └──────────────────────────────────────────┘ canvasWidth x canvasHeight

Step 2: Compute Scales

const maxViews = Math.max(...data.map(d => d.views));
const barWidth = chartWidth / data.length * 0.7;  // 70% of slot width
const gap = chartWidth / data.length * 0.3;       // 30% gap

function scaleY(value) {
    return margin.top + chartHeight - (value / maxViews) * chartHeight;
}

Step 3: Draw Gridlines and Axes

// Horizontal gridlines
ctx.strokeStyle = '#e0e0e0';
ctx.lineWidth = 1;
const gridSteps = 5;
for (let i = 0; i <= gridSteps; i++) {
    const value = (maxViews / gridSteps) * i;
    const y = scaleY(value);
    ctx.beginPath();
    ctx.moveTo(margin.left, y);
    ctx.lineTo(margin.left + chartWidth, y);
    ctx.stroke();

    // Y-axis label
    ctx.fillStyle = '#666';
    ctx.font = '12px -apple-system, sans-serif';
    ctx.textAlign = 'right';
    ctx.textBaseline = 'middle';
    ctx.fillText(Math.round(value).toString(), margin.left - 10, y);
}

// X-axis line
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(margin.left, margin.top + chartHeight);
ctx.lineTo(margin.left + chartWidth, margin.top + chartHeight);
ctx.stroke();

Step 4: Draw the Bars

data.forEach((d, i) => {
    const slotWidth = chartWidth / data.length;
    const x = margin.left + i * slotWidth + (slotWidth - barWidth) / 2;
    const barHeight = (d.views / maxViews) * chartHeight;
    const y = margin.top + chartHeight - barHeight;

    // Draw bar
    ctx.fillStyle = '#16a085';
    ctx.fillRect(x, y, barWidth, barHeight);

    // X-axis label
    ctx.fillStyle = '#333';
    ctx.font = '12px -apple-system, sans-serif';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'top';
    ctx.fillText(d.page, x + barWidth / 2, margin.top + chartHeight + 8);

    // Value label above bar
    ctx.fillStyle = '#16a085';
    ctx.font = 'bold 12px -apple-system, sans-serif';
    ctx.textBaseline = 'bottom';
    ctx.fillText(d.views.toLocaleString(), x + barWidth / 2, y - 4);
});

Step 5: Add the Title

ctx.fillStyle = '#333';
ctx.font = 'bold 16px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText('Pageviews by Page', canvasWidth / 2, 10);

That is the complete bar chart. Every charting library in the world performs these same five steps: define layout, compute scales, draw axes, draw data marks, and add annotations. The library just hides the boilerplate behind a configuration object.

Canvas vs. SVG

The Canvas API is one of two primary rendering technologies for charts in the browser. The other is SVG (Scalable Vector Graphics). Here is how they compare:

Factor Canvas SVG
Rendering mode Immediate (pixels) Retained (DOM nodes)
Performance with many elements Excellent (10,000+ points) Degrades (DOM overhead)
Interactivity Manual hit-testing Native DOM events on each element
Accessibility Opaque bitmap (needs ARIA) Structured, inspectable markup
Resolution independence Requires HiDPI handling Scales automatically
CSS styling Not applicable Full CSS support
Used by Chart.js D3.js (typically)

Chart.js uses Canvas internally. D3.js typically outputs SVG. Both are valid choices. For analytics dashboards with many data points, Canvas often wins on performance. For interactive, accessible visualizations, SVG has advantages. You will use both in this tutorial series.

Going deeper: For a comprehensive exploration of all five rendering surfaces (static images, CSS, Canvas, SVG, WebGL) — including interactive stress-test demos and a decision framework — see Module 09: The Rendering Landscape and Section 8 of the Data Visualization Overview.

Summary