Try this first
Your plot from lesson 2 shows dots and nothing else. No axis, no numbers, no way to tell an apple from an orange.
Before writing any code, list what you would have to add to make the picture answer a question on its own. Then read on and compare your list to this one.
Three additions
A grid, so you can read a position off the picture. Color by label, so the groups are visible. A legend, so the colors mean something. That is it. Everything else is decoration.
A grid you can read
function grid(step = 1) {
ctx.strokeStyle = "#eee";
ctx.lineWidth = 1;
for (let x = Math.ceil(view.xMin / step) * step; x <= view.xMax; x += step) {
const [sx] = toScreen(x, 0);
ctx.beginPath();
ctx.moveTo(sx, 0);
ctx.lineTo(sx, canvas.height);
ctx.stroke();
}
for (let y = Math.ceil(view.yMin / step) * step; y <= view.yMax; y += step) {
const [, sy] = toScreen(0, y);
ctx.beginPath();
ctx.moveTo(0, sy);
ctx.lineTo(canvas.width, sy);
ctx.stroke();
}
}
The awkward looking start value rounds view.xMin up to the next multiple of step, so the lines land on whole numbers instead of wherever the view happens to begin. Each line is drawn from the top of the canvas to the bottom in pixels, but its horizontal position comes from toScreen, so it moves correctly when the view changes.
Color by label
const palette = ["#c33", "#e80", "#38a", "#4a4", "#84c"];
function labels(data) {
return [...new Set(data.map(row => row.y))];
}
function colorFor(label, names) {
return palette[names.indexOf(label) % palette.length];
}
labels collects the distinct labels in the order they first appear. colorFor gives each one a color by its position in that list, wrapping round if there are more labels than colors. Nothing here assumes two classes, so the same code works for the ten digits in section 11.
The legend
function legend(names) {
ctx.font = "13px system-ui";
names.forEach((name, k) => {
const y = 16 + k * 20;
ctx.fillStyle = colorFor(name, names);
ctx.beginPath();
ctx.arc(16, y, 5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#333";
ctx.fillText(name, 28, y + 4);
});
}
The legend is drawn in pixel coordinates, with no call to toScreen. That is on purpose. It should sit in the same corner no matter what the view is, because it describes the picture rather than the data.
Putting it together
function scatter(data, i, j, step = 1) {
const names = labels(data);
autoScale(data.map(row => [row.x[i], row.x[j]]));
clear();
grid(step);
for (const row of data) {
dot(row.x[i], row.x[j], colorFor(row.y, names));
}
legend(names);
}
scatter(fruit, 0, 2, 10);
That plots weight against orangeness. The oranges sit in a band along the top and the apples along the bottom, separated by a wide empty gap, and no amount of moving left or right closes it. Now try scatter(fruit, 0, 1, 1), which is weight against diameter. The two colors are mixed together. Same data, same code, different columns.
What to watch
Order matters. Grid first, then points, then legend. Canvas draws in the order you call it, so a grid drawn last paints faint lines over the top of every dot.
autoScale takes plain pairs, so we build them with map before calling it. It would be tempting to make autoScale accept rows instead, but then it would only work on labeled data, and we plot unlabeled points often enough that it is worth keeping the two separate.
Exercises
- Add axis numbers by calling
ctx.fillTextnext to the bottom and left grid lines. Watch out: those need pixel positions, like the legend. - Plot all three pairs of fruit features side by side by calling
scatterthree times and taking a screenshot of each. Rank the pairs by how well they separate the classes. - Give
dotan optional shape so labels differ by shape as well as color. Roughly one person in twelve with male ancestry cannot rely on red against green, and this costs you four lines. - Make a copy of
scatterthat takes a second dataset and draws it with hollow circles. In section 9 you will use this to show training points against test points on one plot.