✋ Mano

Course · 10 two-hour lessons

From zero to a hand that copies you

A TypeScript course for people who have seen little code. You start from your first variable and end with a real application: the webcam watches your hand and the computer redraws it on the screen, finger by finger.

Length 10 two-hour lessons (20 hours in class)
Prerequisites Being able to use a computer. Having seen a little code helps, but is not required.
Where you write code Lessons 1-5 online with no account, from lesson 6 on your own computer
Final project The hand drawn in 2D following the webcam (3D is the final extension)

How this course works

Each lesson lasts 120 minutes and is always split the same way: little explaining, a lot of trying. No lesson is theory only, and no exercise asks for things not yet explained.

30' Theory — the new concept, explained with small examples and shown live
15' Demo — the teacher writes a piece of the final project in front of everyone
60' Lab — the students write code; the teacher walks around the desks
15' Wrap-up — quick check, errors seen in class, homework for next time

There is a single common thread: every language construct is learned because the hand needs it. Variables hold a coordinate, arrays hold 21 points, interfaces say what a point looks like, async functions turn on the webcam. No abstract exercise without a reason.

Note for the teacher The finished project, its commented code and the line-by-line explanation are already online: the hand demo, the tutorial and, in the project, docs/COME-FUNZIONA.md. They serve as the reference solution and as the goal to show the students on day one: seeing where you are heading changes the motivation for all twenty hours.

Where the students write code

The first five lessons need nothing installed: you work in environments that open in the browser and work without signing up. From lesson 6 you move to the computer, because the webcam and the real project need a local project.

EnvironmentWhenWhat it offersAccount
TypeScript Playground
typescriptlang.org/play
Lessons 1-4 The official playground of the language: write TypeScript on the left, see errors as you type, and the generated JavaScript and the console.log output on the right. The link contains the code, so the teacher can hand out an exercise by pasting an address. No
vite.new/vanilla-ts
(StackBlitz)
Lessons 5-6 Opens a complete Vite + TypeScript project inside the browser, with a terminal and a page preview: it is identical to what you will later install locally, so the switch throws nobody off. No (needed only to save)
JSFiddle Lesson 5, quick tests Four panes (HTML, CSS, JS, result) and off you go: handy for a two-minute experiment on the canvas without creating a project. No
VS Code + Node.js From lesson 6 The real environment: editor, terminal, npm, the TypeScript compiler. It is installed together in class in lesson 6. No
Before the lesson: which environments open without an account The three online environments open in any browser, without signing up, and show the editor right away. vite.new/vanilla-ts installs the dependencies and starts the server on its own in about ten seconds. CodePen is not suitable: without an account it answers with an access error.
A limit to know beforehand The webcam does not work inside the preview pane of the online environments: it is a browser rule, which denies camera and microphone to embedded frames coming from another site. That is why from lesson 7 you work locally. Anyone who could not install anything works in pairs with a classmate who has the project on their own computer, or tests the code on the video recorded by the teacher. Only as a last resort do you open the StackBlitz preview in a separate tab, where the camera works: but explain in class that there the page comes from StackBlitz's servers — the frames still stay in the browser, but the technical guarantee (the CSP) that this project gives itself no longer applies.

Map: what you learn and where it is used

This table is also the quickest way for the teacher to answer the question "what will I need this for?" — which always comes up with beginners, and is fair.

LessonTypeScript conceptsWhat it is for in the hand
1let/const, types string, number, boolean, annotations and inference, console.logHolding a coordinate, a frame counter, the name of a gesture
2if/else, operators, for and for…of loops, typed arrays, union typesGoing through the 21 points of the hand; deciding whether a hand is there or not
3Functions, parameters and return type, void, arrow functions, map and filterComputing the distance between two fingers, converting coordinates
4Objects, interface, optional properties, null and undefined, ?. and ??The type Punto {x, y, z}: the data the whole project runs on
5The typed DOM, as, events, the 2D canvasDrawing points and bones on the screen
6import/export, tsconfig.json, npm, the buildSplitting the project into files like the real one
7async/await, Promise, try/catch, requestAnimationFrameTurning on the webcam and running 30 times a second
8External libraries, generic types (lightly), type narrowingAsking MediaPipe where the fingers are and drawing them
9class, this, private fields, union types as stateAnti-jitter filter, gestures, on/off buttons
10Review, an intro to 3D, how to put it onlineThe hand in three dimensions and the project presented

The syllabus, lesson by lesson

  1. 01The first program: variables and typesPlayground
  2. 02Deciding and repeating: if, loops, arraysPlayground
  3. 03Functions: giving a name to a piece of workPlayground
  4. 04Objects and interfaces: the Punto typePlayground
  5. 05The page and the canvas: drawing a fake handStackBlitz
  6. 06On your own computer: Node, Vite, modulesLocal
  7. 07The webcam: async, permissions, framesLocal
  8. 08MediaPipe: the 21 points of the real handLocal
  9. 09Polishing: classes, state, gestures, jitterLocal
  10. 10The third dimension, publishing, presentationsLocal
Lesson 1

The first program: variables and types

What really happens when a program "runs", how you set a value aside, and why TypeScript scolds you before running the code instead of after.

30' What a program is · the editor · variables · types
15' The teacher shows the finished hand and then writes the first lines live, making mistakes on purpose
60' Exercises 1.1 → 1.5
15' Quick check · homework

Theory

A program is a list of statements that the computer runs one after the other, from top to bottom. The most useful statement at the start is the one that writes a message:

console.log("ciao");
console.log("mondo");

Two lines, two messages, in that order. There is no magic: if you swap the lines, the result is swapped too.

A variable is a name given to a value, so you can reuse it. In TypeScript you declare two kinds:

let punteggio = 0;        // can change later
const nome = "Giulia";    // will never change

punteggio = punteggio + 10;   // ok
nome = "Marco";               // ERROR: const cannot be reassigned
Rule of thumb Always use const, and switch to let only when you notice that the value must change. It is not pedantry: when reading the code, const says "this one won't surprise you".

Every value has a type. The three you need today: string (text, in quotes), number (numbers, whole or with a decimal point) and boolean (only true or false).

const nome: string = "Giulia";
const eta: number = 17;
const maggiorenne: boolean = false;

That : string is the type annotation: it is the part that does not exist in JavaScript and the reason this language is called TypeScript. You can often leave it out, because the compiler works out the type by itself by looking at the value (we say it infers it):

const nome = "Giulia";   // TypeScript already knows it is a string
nome.toUpperCase();      // in fact here it suggests the text methods

And what is all this for? To make errors appear while you type, not while the program runs in front of users:

let eta = 17;
eta = "diciassette";
// Error: Type 'string' is not assignable to type 'number'.
// In plain words: "you said a number goes here, and this is text".
How to show it in class Write the error live and let the line get a red underline. Then hover the mouse over it and read the message aloud, explaining it. Present the error message right away as help: it is the difference between those who learn fast and those who get stuck.

Last piece: joining text and values. With backticks ` you write strings into which you can insert values with ${…}:

const dita = 5;
console.log(`la mano ha ${dita} dita`);
console.log(`e ${dita * 4} falangi... circa`);

Teacher demo (15')

  1. Open the finished hand and move your hand in front of the webcam. Say: "in ten lessons you will write this yourselves".
  2. Open the Playground and write three lines with console.log.
  3. Declare const punta = 8; and explain that this number, in the final project, will be the tip of the index finger.
  4. Get a type wrong on purpose and read the error together with the class.

Lab (60')

Exercise 1.1 — The first message basic

Open typescriptlang.org/play, delete everything there and make the program write your name and your age, on two separate lines.

Solution
console.log("Giulia");
console.log(17);

Using variables is fine too — better, in fact: it is the next step.

Exercise 1.2 — Three variables basic

Create three variables: your name (text), how old you are (number), whether you like pizza (boolean). Then print a single sentence containing all three, using backticks.

Solution
const nome = "Giulia";
const eta = 17;
const amaLaPizza = true;

console.log(`${nome} ha ${eta} anni e la pizza le piace: ${amaLaPizza}`);

Exercise 1.3 — Break it on purpose basic

Take the previous exercise and try these three things, one at a time, reading the error that appears each time:

  1. assign some text to the age variable;
  2. reassign a value to a variable declared with const;
  3. write console.log(nomeSbagliato), with a name that does not exist.

Write on a sheet of paper, in your own words, what the computer says in the three cases.

What they should have understood
  • Type 'string' is not assignable to type 'number' → the type does not match the declared or inferred one.
  • Cannot assign to 'nome' because it is a constant → const cannot be reassigned.
  • Cannot find name 'nomeSbagliato' → that variable does not exist: almost always a typo.

The point of the exercise is not the code: it is learning that the error says where and what, and that reading it costs less than guessing.

Exercise 1.4 — Some arithmetic medium

The webcam of the final project works at 640 pixels wide and 480 high, and produces 30 frames per second. Declare the three variables with the right type and print:

  • how many pixels a frame has;
  • how many frames arrive in a minute;
  • how many times per second the program will have to draw (the same as the frames).
Solution
const larghezza: number = 640;
const altezza: number = 480;
const fotogrammiAlSecondo: number = 30;

console.log(`un fotogramma ha ${larghezza * altezza} pixel`);
console.log(`in un minuto arrivano ${fotogrammiAlSecondo * 60} fotogrammi`);
console.log(`il disegno gira ${fotogrammiAlSecondo} volte al secondo`);

307,200 pixels per frame, 1800 frames per minute: useful numbers to keep in mind when, in lesson 7, we talk about speed.

Exercise 1.5 — A point on the screen challenge

In the final project every point of the hand has two coordinates between 0 and 1: 0 is the left (or top) edge of the image, 1 the right (or bottom) edge. Declare x = 0.5 and y = 0.25 and print which pixel they correspond to on a 640×480 image.

Solution
const x = 0.5;
const y = 0.25;
const larghezza = 640;
const altezza = 480;

console.log(`pixel x: ${x * larghezza}`);   // 320
console.log(`pixel y: ${y * altezza}`);     // 120

This is exactly the calculation the real program will do, many times per second, for every point of the hand. For those who finish early: also print the point at the exact centre of the image.

Typical errors in class

Quick check (5 questions)

Questions and answers
  1. What is the difference between let and const? → const cannot be reassigned.
  2. What type is "17"? → string: it is in quotes, so it is text.
  3. What does console.log(2 + 3) print? And console.log("2" + "3")? → 5 and 23: + on text joins.
  4. Why write : number if TypeScript works it out by itself? → To declare the intention and let whoever writes after us discover the error.
  5. When does a type error appear: before or after running the program? → Before, while you type.

Homework (20-30 minutes)

Write a program that describes your hand: how many fingers, the name of the longest finger, whether you are left-handed. It must use at least one const, one let, the three types seen and a sentence with backticks. Then share the Playground link with the teacher (Share button: the code travels inside the address, no account needed).

Lesson 2

Deciding and repeating: if, loops, arrays

So far the program went straight from top to bottom. Today it learns to choose one path instead of another and to repeat the same work on many values. It matters because the hand is not a number: it is twenty-one points, and all of them must be checked.

30' Comparisons and booleans · if · the three ways to repeat · arrays · union types
15' The teacher goes through a list of 21 numbers and counts those in one half of the image
60' Exercises 2.1 → 2.5
15' Typical errors · quick check · homework

Theory

What it is for. A program that always does the same things in the same order is not very useful. The hand in front of the webcam is sometimes there and sometimes not, the fingers are sometimes raised and sometimes closed: the program must decide. And since the hand has twenty-one points, writing the same line twenty-one times is not a workable option: the program must repeat.

We start with comparison. Comparing two values always produces a boolean, that is true or false: the same type seen last time, but this time we do not write it by hand, the computer calculates it.

const dita = 5;

console.log(dita === 5);    // true   "is equal to"
console.log(dita !== 5);    // false  "is different from"
console.log(dita > 3);      // true   "greater than"
console.log(dita < 3);      // false  "less than"
console.log(dita >= 5);     // true   "greater than or equal"
console.log(dita <= 4);     // false  "less than or equal"
Three equals signs, not two This language has both == and ===, and they do not do the same thing. Double equals, before comparing, converts the values: for it "5" == 5 is true, even though one is text and the other a number. Triple equals converts nothing: "5" === 5 is false, because they are of different types. Course rule, no exceptions: always use === and !==. Double equals produces bugs you only find when the program is finished.

Comparisons are combined with logical operators. There are three, and you read them aloud like plain English:

const x = 0.4;

console.log(x >= 0 && x <= 1);   // true   "and" → both must be true
console.log(x < 0 || x > 1);     // false  "or"  → one being true is enough
console.log(!(x < 0));           // true   "not" → flips the result

With a boolean value in hand you can finally choose. The if statement runs a block of code only if the condition is true:

const dita = 5;

if (dita === 5) {
  console.log("mano aperta");
}

The curly braces enclose the block, that is the lines that belong to that if. With else you say what to do when the condition is false, and with else if you line up several questions. The computer tries them from top to bottom and stops at the first true one:

const dita = 2;

if (dita === 0) {
  console.log("pugno");
} else if (dita === 5) {
  console.log("mano aperta");
} else if (dita === 2) {
  console.log("vittoria");
} else {
  console.log("gesto non riconosciuto");
}

// prints: vittoria
The last else is not mandatory But it is almost always worth adding. It is the "I don't know what this is" branch: without it, when no condition is true the program prints nothing and you are left wondering whether it broke. In the final project that branch really exists: when the recognizer does not understand the gesture, it answers "None", and the application shows a generic hand.

Now on to repeating. The most classic way is the for loop. Written all at once it looks scary, so read it one piece at a time:

for (let i = 0; i < 5; i++) {
  console.log(i);
}

// prints: 0 1 2 3 4

Inside the round brackets there are three things separated by semicolons, and each has a job:

  1. let i = 0 — the starting point. You create a variable, usually called i (for index), set to zero.
  2. i < 5 — the condition to keep going. Before every round the computer checks it: if true it does the round, if false it leaves the loop.
  3. i++ — what to do at the end of every round. i++ is the short form of i = i + 1.

So the computer runs: i is 0, 0 is less than 5, print, i becomes 1… until i is 5, which is not less than 5, and the loop ends. The body ran five times, with i equal to 0, 1, 2, 3, 4.

The infinite loop If the condition never becomes false — for instance because you forgot i++ — the loop never ends and the browser tab freezes. It is not serious: close the tab and reopen the Playground, which keeps the code in the address. But it will happen to someone, so say it beforehand.

Now you need something to repeat over. An array is an ordered list of values, all of the same type. It is written in square brackets, and its type is the type of the elements followed by []:

const numeri: number[] = [10, 20, 30, 40];
const nomi: string[] = ["pollice", "indice", "medio"];

console.log(numeri.length);   // 4   → how many elements there are
console.log(numeri[0]);       // 10  → the FIRST element
console.log(numeri[3]);       // 40  → the last one
console.log(numeri[4]);       // undefined → does not exist

Two things must be learned right away, because they cause half of beginners' errors. First: indices start from zero. An array of four elements has indices 0, 1, 2, 3. Second: as a consequence the last index is length - 1, never length.

Out of bounds: undefined If you ask for an element that is not there, the program does not get angry: it returns undefined, the special value "nothing here". In the Playground TypeScript tells you that numeri[4] is a number and does not complain: it trusts that you know how long the array is. Then, at run time, out pops undefined and the arithmetic gives NaN (Not a Number). If you see NaN printed on screen, nine times out of ten you read past the end of an array.

Putting loop and array together gives the most used line of the whole course:

const numeri: number[] = [10, 20, 30, 40];

for (let i = 0; i < numeri.length; i++) {
  console.log(`l'elemento numero ${i} vale ${numeri[i]}`);
}

You write i < numeri.length and not i < 4: that way the code keeps working if tomorrow the array changes length. And in the final project it certainly does, because an array of points can hold 21 (a hand) or 33 (a whole body).

When you do not need the index, and only care about the value, there is a shorter and more readable form, for…of:

const numeri: number[] = [10, 20, 30, 40];

for (const valore of numeri) {
  console.log(valore);
}

It reads literally: "for each valore of numeri". You use const because a new variable is born at every round. When to use which: if the value is enough, for…of; if you also need to know which position you are at — and to find the highest finger you do — then the classic for with the index.

There is a third way, while, which repeats as long as a condition stays true, with no automatic counter. You use it when you do not know in advance how many rounds you will need:

let rimaste = 3;

while (rimaste > 0) {
  console.log(`mancano ${rimaste} prove`);
  rimaste = rimaste - 1;
}

It never appears in the hand project: when you work on lists of points you always know how many there are. It is useful to know it, not useful to use it today.

Last concept, and it is what makes TypeScript more than JavaScript: union types. What they are for: often a variable cannot hold any text, only two or three specific words. Telling the compiler avoids a whole family of errors.

type Stato = "spenta" | "accesa";

let camera: Stato = "spenta";
camera = "accesa";    // ok
camera = "acesa";     // ERROR: Type '"acesa"' is not assignable to type 'Stato'.

The vertical bar | reads "or". Without the union type you would have written let camera: string, and that "acesa" with a single c would have slipped through: the program would have checked camera === "accesa", found false and turned nothing on, without a single error message. An afternoon wasted, for one letter.

Where you see it in the real project In the application's main.ts file there is exactly this line: type State = "idle" | "loading" | "running" | "error". They are the four possible states of the page: off, starting, running, in error. Nothing else is allowed, and the compiler enforces it.

We close with a trap worth its thirty seconds. const on an array does not make the array immutable: it only prevents replacing it with another array.

const numeri: number[] = [1, 2, 3];

numeri.push(4);           // ALLOWED: the array is now [1, 2, 3, 4]
console.log(numeri);

numeri = [9, 9, 9];       // ERROR: Cannot assign to 'numeri' because it is a constant.

The reason: const protects the name, not the contents. The variable numeri will keep pointing at that list; what is inside the list is another matter. push adds an element at the end and is the normal way to build an array bit by bit.

Teacher demo (15')

  1. Write an array of five numbers and print it with a classic for, reading aloud the three pieces in the round brackets.
  2. Rewrite the same loop with for…of and ask the class what was gained and what was lost (the position was lost).
  3. Remove i++ and show the tab freezing. Then put it back. It takes away the fear: it happened, you close, you reopen.
  4. Declare an array of 21 numbers between 0 and 1 — "these are the horizontal positions of the twenty-one points of the hand" — and with an if inside the loop count how many are below 0.5.
  5. Write type Gesto = "pugno" | "mano aperta", get a letter wrong and show the error. That is the moment union types click.

Lab (60')

Exercise 2.1 — Sum and average basic

Given the array below, compute and print the sum of all the numbers and their average. Copy the starting code and complete it.

const numeri: number[] = [12, 7, 30, 4, 22];

let somma = 0;
// write here the loop that adds each number to `somma`

console.log(`somma: ${somma}`);
Solution
const numeri: number[] = [12, 7, 30, 4, 22];

let somma = 0;
for (const valore of numeri) {
  somma = somma + valore;
}

const media = somma / numeri.length;

console.log(`somma: ${somma}`);    // 75
console.log(`media: ${media}`);    // 15

Why this way. The variable somma is declared outside the loop and with let: inside the loop it would be born and die at every round, and at the end there would be nothing left to print. For the average you divide by numeri.length and not by 5: if tomorrow you add a number, the code is already right.

Exercise 2.2 — Is the coordinate inside the image? basic

In the final project every point of the hand arrives with two coordinates between 0 and 1: 0 is the left (or top) edge of the image, 1 the right (or bottom) edge. Every now and then, when the hand leaves the frame, a value outside that range arrives.

Declare const x = 0.42; and write an if that prints "punto dentro l'immagine" if x is between 0 and 1 (ends included) and "punto fuori dall'immagine" otherwise. Then try changing x to 1.3 and to -0.2 and check that it answers correctly in all three cases.

Solution
const x = 0.42;

if (x >= 0 && x <= 1) {
  console.log("punto dentro l'immagine");
} else {
  console.log("punto fuori dall'immagine");
}

Why this way. You need two comparisons joined by &&, because both conditions must be true. Watch out for the classic error: 0 <= x <= 1 is how you say it in maths, but in code it does not work — the computer first computes 0 <= x, gets a boolean, and then compares a boolean with 1. A meaningless result, and indeed the compiler complains.

Exercise 2.3 — How many fingers are raised medium

To recognize a gesture, the final project starts from five yes/no answers: thumb raised? index raised? and so on. We keep them in an array of five booleans, in order: thumb, index, middle, ring, little finger.

Count how many fingers are raised and print the number. Then, with an if, add a sentence: 0 fingers is a fist, 5 fingers is an open hand, any other number is "other".

const alzate: boolean[] = [false, true, true, false, false];
Solution
const alzate: boolean[] = [false, true, true, false, false];

let quante = 0;
for (const dito of alzate) {
  if (dito === true) {
    quante = quante + 1;
  }
}

console.log(`dita alzate: ${quante}`);   // 2

if (quante === 0) {
  console.log("pugno");
} else if (quante === 5) {
  console.log("mano aperta");
} else {
  console.log("altro");
}

Why this way. if (dito === true) can also be written just if (dito), because dito is already a boolean: the short form is the one you will find in real code. Here the long one was chosen because at the start it is clearer — what matters is knowing that they are the same thing.

Exercise 2.4 — The maximum and its position medium

Find the largest value in the array and also at which index it is. Print both.

const numeri: number[] = [12, 7, 30, 4, 22];
Why it really matters This is not a textbook exercise. In the final project, to work out which finger is highest, you go through the five fingertips and look for the highest one: the value alone is not enough, because you need to know which finger it is. And the finger is the position in the list. Hence you must use the for with the index and not for…of.
Solution
const numeri: number[] = [12, 7, 30, 4, 22];

let massimo = numeri[0];
let indiceDelMassimo = 0;

for (let i = 1; i < numeri.length; i++) {
  if (numeri[i] > massimo) {
    massimo = numeri[i];
    indiceDelMassimo = i;
  }
}

console.log(`il massimo è ${massimo}, alla posizione ${indiceDelMassimo}`);
// il massimo è 30, alla posizione 2

Why this way. You start from the first element as the champion to beat and the loop starts from i = 1: comparing the first with itself would be pointless. The two variables are always updated together, inside the same if: if only the value were updated, the index would stay the old one and the answer would be silently wrong.

A very common error is starting from let massimo = 0. It works with these numbers, but if the array held only negative numbers the answer would be 0, which is not even in the array. Starting from the first element is always correct.

Exercise 2.5 — The points in the left half challenge

Here are the twenty-one horizontal positions of the points of a hand, as the recognizer would produce them: numbers between 0 (left edge of the image) and 1 (right edge).

const x: number[] = [
  0.50, 0.42, 0.36, 0.33, 0.30,
  0.44, 0.42, 0.41, 0.40,
  0.51, 0.51, 0.51, 0.51,
  0.57, 0.58, 0.59, 0.60,
  0.63, 0.66, 0.68, 0.70,
];

Write a program that prints:

  1. how many points are in the left half of the image (that is, have x less than 0.5);
  2. how many in the right half;
  3. a sentence saying where the hand is: "more to the left", "more to the right" or "in the centre" if the two counts are equal.

Also check that the two counts add up to 21: it is a quick way to notice you skipped an element.

Solution
const x: number[] = [
  0.50, 0.42, 0.36, 0.33, 0.30,
  0.44, 0.42, 0.41, 0.40,
  0.51, 0.51, 0.51, 0.51,
  0.57, 0.58, 0.59, 0.60,
  0.63, 0.66, 0.68, 0.70,
];

let sinistra = 0;
let destra = 0;

for (const valore of x) {
  if (valore < 0.5) {
    sinistra = sinistra + 1;
  } else {
    destra = destra + 1;
  }
}

console.log(`punti totali: ${x.length}`);              // 21
console.log(`a sinistra: ${sinistra}`);                // 8
console.log(`a destra: ${destra}`);                    // 13
console.log(`controllo: ${sinistra + destra}`);        // 21

if (sinistra > destra) {
  console.log("la mano sta più a sinistra");
} else if (destra > sinistra) {
  console.log("la mano sta più a destra");
} else {
  console.log("la mano sta al centro");
}

Why this way. Using if/else every point ends up in one and only one count: that is why the sum must be 21. The value exactly equal to 0.5 (the wrist) ends up on the right, because the condition is < 0.5: it is a choice, that is fine, but make it on purpose and not by chance.

For those who finish early: also print the average horizontal position of the twenty-one points, reusing the method from exercise 2.1. It is the simplest way to say "where the centre of the hand is", and in lesson 4 you will write it for real.

Typical errors in class

Quick check (5 questions)

Questions and answers
  1. How many rounds does for (let i = 0; i < 3; i++) do? → Three: with i equal to 0, 1 and 2.
  2. What is the last valid index of an array of 21 elements? → 20, that is length - 1.
  3. Why write === and not ==? → Because double equals converts types before comparing, and "5" == 5 comes out true. Triple equals also compares the type.
  4. What does console.log([1, 2, 3][5]) print? → undefined: that element does not exist.
  5. Can you push onto an array declared with const? → Yes. const prevents replacing the array, not changing its contents.

Homework (30 minutes)

Write a program that, starting from the x array of exercise 2.5, prints the rightmost point (value and position) and the leftmost point, and says whether the hand is wide or narrow: call a hand "larga" (wide) when the difference between the maximum and the minimum exceeds 0.35, "stretta" (narrow) otherwise. Declare the answer in a variable with the type type Larghezza = "larga" | "stretta". Share the Playground link with the teacher.

Lesson 3

Functions: giving a name to a piece of work

In the last lesson you wrote almost the same loop three times. Today you learn to write it only once, give it a name and call it when needed. It is the step that turns a list of statements into a program you can read.

30' Why functions · parameters and return · void · arrow · map and filter
15' The teacher writes distanza() and uses it to measure how big a hand is
60' Exercises 3.1 → 3.5
15' Typical errors · quick check · homework

Theory

What it is for. Two reasons, in order of importance. The first is that giving a name to a piece of work makes it understandable: distanza(a, b) is clear at a glance, four lines of square roots are not. The second is that something written in one place is fixed in one place. If the same formula is copied in five places, sooner or later four are updated and one is not.

A function is a block of code with a name, which receives some input values and returns one as output. You declare it like this:

function raddoppia(numero: number): number {
  return numero * 2;
}

console.log(raddoppia(5));    // 10
console.log(raddoppia(21));   // 42

Piece by piece, because each part has a name the teacher will use later:

raddoppia(5) is the call: the 5 goes in, takes the place of numero, and the function returns 10. The returned value can be printed, put in a variable, or fed to another function.

return shuts the door Everything after an executed return never runs. It is not a flaw, it is the useful part: if (dimensione === 0) return 0; at the start of a function is the standard way to deal with odd cases right away and then write the normal case without nesting five ifs inside each other.

There can be more than one parameter, separated by commas, each with its own type:

function area(larghezza: number, altezza: number): number {
  return larghezza * altezza;
}

console.log(area(640, 480));   // 307200

Order matters. area(640, 480) and area(480, 640) give the same result here by chance, but not in general: the first value goes into the first parameter, full stop.

Some functions return nothing: they print, draw, switch on. Their return type is written void, which means "empty":

function saluta(nome: string): void {
  console.log(`ciao ${nome}`);
}

saluta("Giulia");   // prints: ciao Giulia

const risultato = saluta("Marco");
console.log(risultato);   // undefined: there was nothing to return
How to recognize them If the function answers a question ("how long is it?", "how many fingers?") it returns a value. If instead it performs an action ("print", "draw", "turn off the camera") it is usually void. In the final project stop(), which turns everything off, is void; realSide(), which answers "is it the right or the left", returns a value.

There is a second, shorter way to write it, called an arrow function. It does exactly the same thing:

const raddoppia = (numero: number): number => {
  return numero * 2;
};

// and when the body is a single expression, you can also drop return and the braces:
const triplica = (numero: number): number => numero * 3;

console.log(raddoppia(5));    // 10
console.log(triplica(5));     // 15

The function is put in a const as if it were any other value — because it is. This form is useful above all when a function must be passed to someone else, which is the next topic.

Parameters can have a default value, used when the caller does not pass one:

function aPixel(valore: number, dimensione: number = 640): number {
  return valore * dimensione;
}

console.log(aPixel(0.5));        // 320  → uses 640
console.log(aPixel(0.5, 480));   // 240  → uses what you pass

Or they can be optional, with a question mark. In that case, if they are not passed, they are undefined, and the function must allow for it:

function etichetta(nome: string, gesto?: string): string {
  if (gesto === undefined) {
    return nome;
  }
  return `${nome}: ${gesto}`;
}

console.log(etichetta("Destra"));               // Destra
console.log(etichetta("Destra", "pugno"));      // Destra: pugno
Optional parameters go last An optional parameter cannot come before a required one: the compiler rejects it, and rightly so — it would have no way to tell which value you skipped.

Functions can call other functions, and this is the normal way to build programs: you start from small, safe pieces and combine them.

function distanza(x1: number, y1: number, x2: number, y2: number): number {
  const dx = x2 - x1;
  const dy = y2 - y1;
  return Math.sqrt(dx * dx + dy * dy);
}

function dimensioneMano(xPolso: number, yPolso: number, xNocca: number, yNocca: number): number {
  return distanza(xPolso, yPolso, xNocca, yNocca);
}

console.log(dimensioneMano(0.50, 0.88, 0.51, 0.58));   // about 0.3002

Math is a set of ready-made maths tools that the language provides: Math.sqrt is the square root, Math.abs the absolute value, Math.round rounding, Math.min and Math.max the smaller and the larger of two numbers.

Last piece for today: map and filter. They are two array tools that take a function as an argument. It looks odd the first time, so read them as plain sentences:

const numeri: number[] = [1, 2, 3, 4];

// map: "make me a NEW array, applying this function to every element"
const doppi = numeri.map((n) => n * 2);          // [2, 4, 6, 8]

// filter: "make me a NEW array with only the elements for which this function says true"
const grandi = numeri.filter((n) => n > 2);      // [3, 4]

console.log(doppi);
console.log(grandi);
console.log(numeri);   // [1, 2, 3, 4] — the original was not touched

Neither of them changes the starting array: they return a new one. They are the short form of a for loop that builds an array with push, and in real code they are almost always used instead of that loop because they state the intention in one line.

We close with an idea, not a syntax. A function is called pure when, given the same inputs, it always returns the same output and touches nothing outside itself. distanza is pure: with those four numbers it will always answer the same, today and in a month. saluta is not, because it prints — it changes something in the outside world. Pure functions are easier to test and hold no surprises: when you can write one, do.

Teacher demo (15')

  1. Take the code of exercise 2.1 (sum and average) and turn it into a function mediaDi. Call it twice on two different arrays: that is when you see the gain.
  2. Write distanza live, with Math.sqrt, explaining that it is Pythagoras' theorem and nothing else.
  3. Show Math.hypot(dx, dy), which does the same in one line, and say that the real project uses that one.
  4. Compute the distance between the wrist (0.50, 0.88) and the middle-finger knuckle (0.51, 0.58): "this number, in the real application, decides how thick the fingers drawn on the screen are". Show the hand demo and move your hand closer to the camera.
  5. Forget the return on purpose and show that the function prints undefined.

Lab (60')

Exercise 3.1 — The distance between two points basic

Write the function distanza(x1, y1, x2, y2) that returns the distance between two points, using Math.sqrt. Test it with (0, 0) and (3, 4): it must give exactly 5.

Then rewrite it with Math.hypot, which directly computes the root of the sum of the squares, and check that the result is identical.

Solution
function distanza(x1: number, y1: number, x2: number, y2: number): number {
  const dx = x2 - x1;
  const dy = y2 - y1;
  return Math.sqrt(dx * dx + dy * dy);
}

function distanzaBreve(x1: number, y1: number, x2: number, y2: number): number {
  return Math.hypot(x2 - x1, y2 - y1);
}

console.log(distanza(0, 0, 3, 4));        // 5
console.log(distanzaBreve(0, 0, 3, 4));   // 5

Why this way. dx and dy are the two legs of the triangle; the distance is the hypotenuse. Putting the two legs in two named consts, instead of writing everything in one line, makes the formula readable and avoids computing the same subtraction twice.

In the real project this is the line that measures how big the framed hand is: the distance between the wrist and the middle-finger knuckle. On that number depend the thickness of the bones and the radius of the joints drawn — so the hand stays believable both close to the camera (big) and far away (small).

Exercise 3.2 — From coordinate to pixel basic

The recognizer's coordinates go from 0 to 1; the screen, instead, thinks in pixels. Write aPixel(valore: number, dimensione: number): number that does the conversion, and test it with all these cases:

  • aPixel(0.5, 640) must give 320;
  • aPixel(0, 640) must give 0;
  • aPixel(1, 480) must give 480.

Then add a default value of 640 for the size and try again without passing it.

Solution
function aPixel(valore: number, dimensione: number = 640): number {
  return valore * dimensione;
}

console.log(aPixel(0.5, 640));   // 320
console.log(aPixel(0, 640));     // 0
console.log(aPixel(1, 480));     // 480
console.log(aPixel(0.25));       // 160 — uses the default

Why this way. It is a single multiplication, and that is exactly why it deserves a function: you name it once and then use it dozens of times without ever again wondering whether to multiply or divide. In lesson 5 you will use it for real, to draw the points on the canvas.

Exercise 3.3 — The average, but as a function basic

Write mediaDi(numeri: number[]): number, which returns the average of the elements. It must work with arrays of any length; if the array is empty it must return 0 instead of NaN.

Solution
function mediaDi(numeri: number[]): number {
  if (numeri.length === 0) {
    return 0;
  }

  let somma = 0;
  for (const valore of numeri) {
    somma = somma + valore;
  }
  return somma / numeri.length;
}

console.log(mediaDi([12, 7, 30, 4, 22]));   // 15
console.log(mediaDi([0.4, 0.6]));           // 0.5
console.log(mediaDi([]));                   // 0

Why this way. The empty-array check is at the top and ends right away with a return: the odd case cleared out of the way before starting. Without it, the division would be 0 / 0, that is NaN, and that NaN would spread into all later calculations without saying where it came from.

Exercise 3.4 — All coordinates in pixels, with map medium

Here are the horizontal positions of some points of the hand, between 0 and 1. Convert them all to pixels for an image 640 wide, using map and the function aPixel from exercise 3.2.

const x: number[] = [0.50, 0.42, 0.36, 0.44, 0.63, 0.70];

Then, with filter, get a second array with only the points in the left half of the image (less than 320 pixels) and print how many there are.

Solution
function aPixel(valore: number, dimensione: number): number {
  return valore * dimensione;
}

const x: number[] = [0.50, 0.42, 0.36, 0.44, 0.63, 0.70];

const inPixel = x.map((valore) => aPixel(valore, 640));
console.log(inPixel);   // [320, 268.8, 230.4, 281.6, 403.2, 448] — roughly

const aSinistra = inPixel.filter((p) => p < 320);
console.log(`a sinistra ce ne sono ${aSinistra.length}`);   // 3

Why this way. map receives a function and applies it to every element: here the arrow function (valore) => aPixel(valore, 640) only serves to fix the second argument. The result is a new array, of the same length; filter instead returns a shorter array, with only the elements that pass the test.

Numbers with decimals are not an error: whole pixels would come from Math.round. For drawing it is not needed — the canvas happily accepts fractional positions, and they actually look smoother. If you see 230.39999999999998 printed instead of 230.4 you did nothing wrong: it is how the computer stores decimal numbers, and in the drawing it is not off by even one pixel.

Exercise 3.5 — From the count to the gesture name challenge

Two functions, one using the other.

  1. ditaAlzate(stati: boolean[]): number — receives the five yes/no values (thumb, index, middle, ring, little finger) and returns how many fingers are raised.
  2. nomeGesto(stati: boolean[]): "pugno" | "mano aperta" | "altro" — uses the first function and returns the name of the gesture: 0 raised fingers is a fist ("pugno"), 5 is an open hand ("mano aperta"), everything else is "altro" (other).

Test them with at least three different arrays.

Solution
type Gesto = "pugno" | "mano aperta" | "altro";

function ditaAlzate(stati: boolean[]): number {
  let quante = 0;
  for (const dito of stati) {
    if (dito) {
      quante = quante + 1;
    }
  }
  return quante;
}

function nomeGesto(stati: boolean[]): Gesto {
  const quante = ditaAlzate(stati);
  if (quante === 0) {
    return "pugno";
  }
  if (quante === 5) {
    return "mano aperta";
  }
  return "altro";
}

console.log(nomeGesto([false, false, false, false, false]));   // pugno
console.log(nomeGesto([true, true, true, true, true]));        // mano aperta
console.log(nomeGesto([false, true, true, false, false]));     // altro

Why this way. The return type is a union type, so the compiler checks every return: if you wrote return "mano aperta!" with one exclamation mark too many, the error would appear right away instead of in class during the presentation.

Note also that no else is needed: each return ends the function, so if the code reaches the last line it means the two previous conditions were false. It reads better this way.

For those who finish early: add the gesture "vittoria" (victory) when the raised fingers are exactly index and middle. You need to look at the individual positions of the array, not just the total: stati[1] and stati[2] true and the other three false.

Typical errors in class

Quick check (5 questions)

Questions and answers
  1. What is the difference between declaring and calling a function? → Declaring defines it, calling runs it. A function never called does nothing.
  2. What does : void mean? → That the function returns no value: it performs an action.
  3. What does [1, 2, 3].map((n) => n + 1) return? → A new array, [2, 3, 4]. The original stays [1, 2, 3].
  4. When do you put ? on a parameter? → When it is optional; inside the function it may be undefined and you must allow for it.
  5. What is a pure function? → One that, with the same inputs, always gives the same result and changes nothing outside itself.

Homework (30 minutes)

Write three functions and test them: massimoDi(numeri: number[]): number, minimoDi(numeri: number[]): number and larghezzaDi(numeri: number[]): number, which returns the difference between maximum and minimum reusing the first two. Test them on the array of the twenty-one horizontal positions from exercise 2.5. None of the three may contain a console.log: they must return, not print — you do the printing outside.

Lesson 4

Objects and interfaces: the Punto type

Until now a coordinate was two separate variables, x and y, that you had to remember to keep together. Today they become one thing with a name and a type. It is the data the whole final project runs on: from the next lesson we will write almost nothing else.

30' Objects · interface · arrays of objects · readonly · ?. and ??
15' The teacher writes Punto and the function that finds the centre of the hand
60' Exercises 4.1 → 4.5
15' Typical mistakes · quick check · homework

Theory

What it is for. Try to imagine writing the final project with what you know now: twenty-one horizontal values in one array, twenty-one vertical ones in another, and the promise never to get the pairing wrong. A single sort on one array and the hand falls apart. An object keeps together the values that describe the same thing, and keeps them together by construction.

const polso = { x: 0.50, y: 0.88 };

console.log(polso.x);   // 0.5
console.log(polso.y);   // 0.88
console.log(polso);     // { x: 0.5, y: 0.88 }

Between braces you list the properties: a name, a colon, a value, separated by commas. You read them with the dot: polso.x. That is all. If the variable is let, or if the property is not protected, you can also change them:

const polso = { x: 0.50, y: 0.88 };

polso.x = 0.55;         // PERMESSO, anche con const (vale la stessa regola degli array)
console.log(polso.x);   // 0.55

polso.z = 0.1;          // ERRORE: Property 'z' does not exist on type '{ x: number; y: number; }'.

That last error is already TypeScript at work: it worked out the shape of the object by itself and does not accept made-up properties. But that shape is written in one place only, and if tomorrow we needed it in ten different functions we would have to copy it ten times. That is why interface exists: it gives a name to a shape.

interface Punto {
  x: number;
  y: number;
  z?: number;     // il punto interrogativo: facoltativa
}

const polso: Punto = { x: 0.50, y: 0.88 };
const puntaIndice: Punto = { x: 0.40, y: 0.37, z: -0.02 };

console.log(polso.x, puntaIndice.z);

Inside the interface, properties are separated by semicolons (or new lines) and not by commas as in objects: it is the number one confusion of the lesson. From here on Punto is a type in every respect, and it is used like number or string: as the type of a variable, of a parameter, of a return value, and inside an array.

const punti: Punto[] = [
  { x: 0.50, y: 0.88 },
  { x: 0.40, y: 0.37 },
  { x: 0.51, y: 0.58 },
];

console.log(punti.length);   // 3
console.log(punti[1].x);     // 0.4  → l'elemento 1, e di quello la x

for (const p of punti) {
  console.log(`(${p.x}, ${p.y})`);
}

punti[1].x reads from left to right: take the element at index 1, and of that object take the property x. This line, as punti[8].x, will appear dozens of times in the real project: 8 is the tip of the index finger.

The real hand points, ready to paste These values are realistic: they are nine of the twenty-one points of an open right hand, seen from the front, fingers pointing up. Use them in today's exercises.
interface Punto {
  x: number;
  y: number;
  z?: number;
}

const mano: Punto[] = [
  { x: 0.50, y: 0.88 },   // 0  wrist
  { x: 0.42, y: 0.84 },   // 1  base of the thumb
  { x: 0.30, y: 0.64 },   // 2  thumb tip           (in the real model it is point 4)
  { x: 0.44, y: 0.60 },   // 3  index knuckle       (in the real model it is 5)
  { x: 0.40, y: 0.37 },   // 4  index tip           (in the real model it is 8)
  { x: 0.51, y: 0.58 },   // 5  middle knuckle      (in the real model it is 9)
  { x: 0.51, y: 0.33 },   // 6  middle tip          (in the real model it is 12)
  { x: 0.63, y: 0.63 },   // 7  little finger knuckle (in the real model it is 17)
  { x: 0.70, y: 0.45 },   // 8  little finger tip   (in the real model it is 20)
];

A property can be declared readonly: it can be read, not rewritten. It says "this data, once created, must not be touched".

interface PuntoFisso {
  readonly x: number;
  readonly y: number;
}

const origine: PuntoFisso = { x: 0, y: 0 };
origine.x = 5;   // ERRORE: Cannot assign to 'x' because it is a read-only property.

Now the most important part of the lesson, the one that separates those who will write solid programs from those who will spend their evenings hunting bugs: values that might not be there. There are two, and they mean slightly different things:

Both appear in the final project: let stream: MediaStream | null = null means "the webcam is off right now, and it is not an oversight". The problem is that reading a property of a value that is not there blows up the program:

interface Punto { x: number; y: number }

let punta: Punto | undefined = undefined;

console.log(punta.x);   // ERRORE del compilatore: 'punta' is possibly 'undefined'.
                        // e se lo eseguissi davvero: TypeError, il programma si ferma

There are three ways to handle it, in order of elegance. The first is optional chaining ?.: it tries to read, and if there is nothing in front it returns undefined instead of blowing up.

let punta: Punto | undefined = undefined;

console.log(punta?.x);   // undefined — nessun errore

The second is the fallback value ??, which reads "otherwise": if what is on the left is undefined or null, it takes what is on the right. The two are almost always used together:

let punta: Punto | undefined = undefined;

const x = punta?.x ?? 0;
console.log(x);   // 0

punta = { x: 0.40, y: 0.37 };
console.log(punta?.x ?? 0);   // 0.4
?? is not || They look interchangeable and they are not. || fires on any "empty" value, and for it zero is empty too: 0 || 0.5 gives 0.5. With hand coordinates that is a disaster, because 0 is the left edge of the image, a legitimate value. 0 ?? 0.5 instead gives 0, which is the right answer. Rule: for missing values use ??.

The third way is type narrowing: an if that checks, and inside which the compiler knows the value is there. It is the clearest when you need to use the value more than once:

function stampaPunto(punto: Punto | undefined): void {
  if (punto === undefined) {
    console.log("nessun punto");
    return;
  }

  // da qui in poi TypeScript sa che `punto` esiste: niente ?. e niente errori
  console.log(`x vale ${punto.x} e y vale ${punto.y}`);
}

stampaPunto(undefined);              // nessun punto
stampaPunto({ x: 0.4, y: 0.37 });    // x vale 0.4 e y vale 0.37

The compiler follows the reasoning: after that return, the only way to still be there is that punto is not undefined. It is not magic, it is the same thing you would have thought reading the code — only it checks it for you.

interface or type? You can also write type Punto = { x: number; y: number }, and for the cases in this course the two forms are equivalent. Course convention, so we never have to think about it again: interface to describe the shape of an object, type for aliases and for union types like type Gesto = "pugno" | "mano aperta", which you cannot even write with interface.

Teacher demo (15')

  1. Write interface Punto on the board and then in the Playground. Create the wrist and the index tip with the realistic values seen above.
  2. Add the z and remove the z, showing that with z? both versions are valid.
  3. Write centro(punti: Punto[]): Punto live, reusing the average idea from lesson 3. Make it return an object: it is the first function in the course that returns something composite.
  4. Open the hand demo, show the white dots on the preview and say: "each of those dots is an object like the one we just wrote; there are twenty-one, and they arrive thirty times a second".
  5. Write punti[99].x and show the crash at run time. Then fix it with punti[99]?.x ?? 0.

Lab (60')

Exercise 4.1 — The Punto type base

Define the Punto interface with x and y required and z optional. Then create two variables: the wrist at (0.50, 0.88) and the index tip at (0.40, 0.37), the latter also with z equal to -0.02. Print a sentence that describes both.

Solution
interface Punto {
  x: number;
  y: number;
  z?: number;
}

const polso: Punto = { x: 0.50, y: 0.88 };
const puntaIndice: Punto = { x: 0.40, y: 0.37, z: -0.02 };

console.log(`il polso sta in (${polso.x}, ${polso.y})`);
console.log(`la punta dell'indice sta in (${puntaIndice.x}, ${puntaIndice.y})`);
console.log(`profondità della punta: ${puntaIndice.z ?? 0}`);

Why this way. We did not put z on the wrist, and that is allowed, because it is optional. For exactly that reason, when you read it you use ?? 0: polso.z would be undefined, and printing "depth: undefined" helps nobody.

In the real project z is always there and is the depth relative to the wrist: negative means closer to the camera. It is the least precise coordinate of the three, because a single camera does not really see depth: it estimates it.

Exercise 4.2 — The centre of the hand medium

Write centro(punti: Punto[]): Punto, which returns the midpoint of a list: the x of the result is the average of the x values, the y is the average of the y values. Try it on the mano array given in the theory.

Solution
function centro(punti: Punto[]): Punto {
  if (punti.length === 0) {
    return { x: 0, y: 0 };
  }

  let sommaX = 0;
  let sommaY = 0;
  for (const p of punti) {
    sommaX = sommaX + p.x;
    sommaY = sommaY + p.y;
  }

  return { x: sommaX / punti.length, y: sommaY / punti.length };
}

const c = centro(mano);
console.log(`il centro della mano sta in (${c.x}, ${c.y})`);
// il centro della mano sta in (0.49, 0.5911111111111111)

Why this way. You go through the array only once, accumulating two sums: two separate loops would work but would be double the work. The return builds a new object on the spot — there is no need to declare it first in a variable.

In the real project the centre is used to decide where to place the hand in the scene, and to tell whether it is moving: if the centre moves a lot from one frame to the next, the hand is moving fast, and the anti-jitter filter of lesson 9 will behave accordingly.

Exercise 4.3 — The highest point medium

Write piuAlto(punti: Punto[]): Punto, which returns the highest point in the list. Try it on the mano array: it must return the middle fingertip, that is { x: 0.51, y: 0.33 }.

y grows DOWNWARDS This is the most expensive trap in the whole project, and everyone falls into it at least once. In images, and on screen, the origin is at the top left: y = 0 is the top edge, y = 1 is the bottom one. So "higher" means "smaller y", and the function must be written by looking for the minimum, not the maximum. At school the Cartesian plane went the other way round; here it does not. In the real project this inversion is paid for again when moving to the three-dimensional scene, where y goes back to growing upwards and has to be flipped once more.
Solution
function piuAlto(punti: Punto[]): Punto {
  let migliore = punti[0];

  for (const p of punti) {
    if (p.y < migliore.y) {   // SMALLER y = higher in the image
      migliore = p;
    }
  }

  return migliore;
}

const alto = piuAlto(mano);
console.log(`il punto più in alto è (${alto.x}, ${alto.y})`);
// il punto più in alto è (0.51, 0.33)  → the middle fingertip

Why this way. It is the maximum of exercise 2.4 with the comparison flipped and, above all, with objects instead of numbers: we keep aside the whole point, not just the coordinate, so whoever calls the function also gets the x without having to look for it again. The comment on the comparison line is not decoration: six months from now it will be the only thing stopping you from "fixing" that < into >.

If you finish early: make it return the index too, as in lesson 2. You will need the for with a counter and to return an object with two properties, for example { punto: …, indice: … }.

Exercise 4.4 — A point that might not be there medium

When the hand leaves the frame, the recognizer does not return twenty-one points: it returns none. Let us simulate that.

Write puntoOppureNiente(punti: Punto[], indice: number): Punto | undefined, which returns the requested point or undefined if the index is outside the list. Then write descrivi(punto: Punto | undefined): string which returns "x: 0.4" if the point is there, and "punto assente" if it is not. Try them with a valid index and with index 99.

Solution
function puntoOppureNiente(punti: Punto[], indice: number): Punto | undefined {
  if (indice < 0 || indice >= punti.length) {
    return undefined;
  }
  return punti[indice];
}

function descrivi(punto: Punto | undefined): string {
  if (punto === undefined) {
    return "punto assente";
  }
  return `x: ${punto.x}`;
}

console.log(descrivi(puntoOppureNiente(mano, 4)));    // x: 0.4
console.log(descrivi(puntoOppureNiente(mano, 99)));   // punto assente

// the short version, when you only need a number:
const x = puntoOppureNiente(mano, 99)?.x ?? 0;
console.log(x);   // 0

Why this way. The index check uses || because it is enough for one of the two conditions to be true to be outside. Inside descrivi narrowing with if was used instead of ?.: when the point is used several times it is more readable, and you avoid a row of question marks.

The last two lines show the compact form: ?. so as not to blow up and ?? for the fallback value. It is exactly the style you will find in the real project, where a line like result.gestures[i]?.[0]?.categoryName ?? "None" means "the name of the gesture, if there is one; otherwise none".

Exercise 4.5 — The Mano object challenge

An object can contain an array, and its properties can have union types. Define:

interface Mano {
  lato: "destra" | "sinistra";
  punti: Punto[];
  gesto?: string;
}

Create a right hand with the points of the theory's mano array and no gesture, and a left hand with only two points and gesture "pugno". Then write riassumi(m: Mano): string which produces a line like:

mano destra · 9 punti · centro (0.490, 0.591) · gesto: nessuno

To round to three decimals use .toFixed(3) on a number. Reuse the centro function from exercise 4.2.

Solution
interface Mano {
  lato: "destra" | "sinistra";
  punti: Punto[];
  gesto?: string;
}

function riassumi(m: Mano): string {
  const c = centro(m.punti);
  const nomeGesto = m.gesto ?? "nessuno";
  return `mano ${m.lato} · ${m.punti.length} punti · centro (${c.x.toFixed(3)}, ${c.y.toFixed(3)}) · gesto: ${nomeGesto}`;
}

const destra: Mano = { lato: "destra", punti: mano };

const sinistra: Mano = {
  lato: "sinistra",
  punti: [
    { x: 0.20, y: 0.70 },
    { x: 0.24, y: 0.62 },
  ],
  gesto: "pugno",
};

console.log(riassumi(destra));
// mano destra · 9 punti · centro (0.490, 0.591) · gesto: nessuno

console.log(riassumi(sinistra));
// mano sinistra · 2 punti · centro (0.220, 0.660) · gesto: pugno

Why this way. m.gesto ?? "nessuno" handles the optional property in one line. The union type on lato prevents writing "Destra" with a capital letter or "dx": with a generic string both would pass, and the comparison m.lato === "destra" further on would fail silently.

In the real project this is almost exactly the structure that comes from the recognizer: for each hand in the frame there is the side, the twenty-one points and, optionally, the name of the gesture recognized among the known ones. And the side, in the real code, is a union type identical to this one.

Typical mistakes in class

Quick check (5 questions)

Questions and answers
  1. What does z?: number mean inside an interface? → That the property is optional: if it is missing its value is undefined.
  2. What does punti[99]?.x return if the array has 21 elements? → undefined, without blowing up the program.
  3. In the image, which is the higher point: the one with y 0.2 or the one with y 0.8? → The one with y 0.2. y grows downwards.
  4. Why does 0 ?? 5 give 0 while 0 || 5 gives 5? → Because ?? fires only on null and undefined, || on any value considered "empty", zero included.
  5. Can you change a property of an object declared with const? → Yes, unless it is readonly. const protects the name, not the content.

Homework (30-40 minutes)

Write dimensioneMano(punti: Punto[]): number, which returns the distance between the first point of the array (the wrist) and the one at index 5 (the middle knuckle, in our reduced array), reusing the distanza function from lesson 3 — but adapt it so that it takes two Punto values instead of four numbers. Then write aperta(punti: Punto[]): boolean, which answers true if the middle fingertip is higher than the middle knuckle by at least a quarter of the size of the hand. Try it on the mano array and on a modified version in which you have lowered the middle fingertip to y: 0.56.

Lesson 5

The page and the canvas: drawing a fake hand

Until now the program only spoke through console.log. Today we move to a real page and a canvas to draw on, and by the end of the two hours a hand will appear on the screen. Fake, still, drawn by you: but it is the same hand that in four lessons will move following yours.

30' The page · getting an element · the 2D context · circles, lines, colours · coordinates
15' The teacher opens StackBlitz and draws a circle from scratch, then wires up a button
60' Exercises 5.1 → 5.5
15' Typical mistakes · quick check · homework

Theory

What it is for. Nobody looks at a program that prints numbers in a console. The final project is a web page: there is a button, there is a box with the webcam and there is a canvas on which the hand appears. Today we learn the two mechanisms that hold the code and what you see together: getting an element of the page and drawing inside it.

Let us start with the page. An HTML page is made of tags, which open and close and contain other tags. This is the smallest one we need:

<!doctype html>
<html lang="it">
  <head>
    <meta charset="UTF-8" />
    <title>La mia mano</title>
  </head>
  <body>
    <canvas id="tela" width="480" height="640"></canvas>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

<head> contains information about the page, <body> what you see. The <canvas> is the canvas: an empty rectangle, here 480 pixels wide and 640 high, inside which the code can draw. The id is the name the code will look it up by: it must be unique in the page. The last line loads our program.

On StackBlitz vite.new/vanilla-ts opens a ready-made project in about ten seconds, with the preview on the right that updates by itself on every save. Two files matter: index.html, whose <body> content you will replace, and src/main.ts, which you can empty completely before starting. It is exactly the same tool you will install on your own computer in lesson 6: nothing is thrown away.

Now the code has to get hold of that <canvas>. You ask the page for it by name:

const tela = document.getElementById("tela");

document is the page, as seen from the code. getElementById looks for the element with that id. But if you try to use it, TypeScript gets in the way:

const tela = document.getElementById("tela");
console.log(tela.width);
// ERRORE: 'tela' is possibly 'null'.
// ERRORE: Property 'width' does not exist on type 'HTMLElement'.

Two complaints, and both are right:

  1. It might not be there. The return type is HTMLElement | null: that union type says "an element, or nothing". If you misspelled the id, or if the code starts before the page is ready, the answer is null. It is the same null as in lesson 4.
  2. It does not know what kind of element it is. It knows it is an element of the page, HTMLElement, and that is all. But a heading has no drawing width, a canvas does: until the compiler knows it is a canvas, it will not let you use canvas things.

The second problem is solved with as, which reads "treat it as": it is a promise you make to the compiler.

const tela = document.getElementById("tela") as HTMLCanvasElement | null;

if (!tela) {
  throw new Error("canvas non trovato: controlla l'id nell'HTML");
}

console.log(tela.width);   // 480 — adesso va

if (!tela) reads "if tela is NOT there": it is the exclamation mark from lesson 2, the one that flips things. After that block the compiler knows that tela exists — it is the same narrowing as in lesson 4.

as is a promise, not a check Writing as HTMLCanvasElement verifies nothing: it convinces the compiler and that is it. If in the HTML that id is on a <div>, the program will break anyway, only later and with a more obscure message. You use as when you know what the page looks like because you wrote it — and that is exactly the case in our project, where the ids are written by hand and never change.

You do not draw directly on the canvas: you ask for the context, that is the object that holds all the drawing tools. There are several kinds; we need the two-dimensional one.

const ctx = tela.getContext("2d");

if (!ctx) {
  throw new Error("contesto 2d non disponibile");
}

This too can return null (for example if the same canvas had already been asked for a context of a different kind), so you check it as before. From here on ctx is the object you draw everything with.

Coordinates. Before drawing you need to know where things will end up. The canvas has its origin at the top left: x grows to the right, y grows downwards. It is the same convention as images seen in lesson 4 — and for once this is an advantage: the recognizer's coordinates, multiplied by the canvas size, end up in the right place without flipping anything.

// su una tela 480 × 640:
// (0, 0)     angolo in alto a sinistra
// (480, 0)   angolo in alto a destra
// (0, 640)   angolo in basso a sinistra
// (240, 320) centro esatto

function aPixel(valore: number, dimensione: number): number {
  return valore * dimensione;
}

console.log(aPixel(0.5, 480));   // 240 → metà della larghezza

Drawing a circle. You need it for the joints of the hand. It takes a few lines, always the same:

ctx.beginPath();                              // 1. inizia un disegno nuovo
ctx.arc(240, 320, 8, 0, Math.PI * 2);         // 2. descrivi un cerchio
ctx.fillStyle = "#38bdf8";                    // 3. scegli il colore di riempimento
ctx.fill();                                   // 4. riempilo

The arguments of arc, in order: centre x, centre y, radius, start angle, end angle. 0 and Math.PI * 2 are the start and the end of a full turn: it is the recipe for "whole circle", and you copy it as it is.

beginPath is not optional Without it, every new shape is added to the previous ones in the same drawing: changing colour recolours them all, and the circles end up joined by mysterious lines. If drawing the twenty-one points of the hand gives you a spider web, this is the cause. Rule: one shape, one beginPath.

Drawing a line. You need it for the bones. Same pattern:

ctx.beginPath();
ctx.moveTo(100, 100);            // porta la penna qui, senza disegnare
ctx.lineTo(300, 400);            // traccia fino a qui
ctx.strokeStyle = "#f472b6";     // colore della linea
ctx.lineWidth = 4;               // spessore in pixel
ctx.stroke();                    // disegna davvero

The difference between fill and stroke is the same as between filling and outlining: fillStyle/fill for the inside, strokeStyle/stroke for the edge. Colours are written in hexadecimal ("#38bdf8") or by name ("white"). And nothing is actually drawn until you call fill or stroke: the instructions before only describe the shape.

Clearing. When the hand moves you do not draw over the previous one: you clear everything and redraw from scratch, thirty times a second.

ctx.clearRect(0, 0, tela.width, tela.height);   // svuota tutta la tela

Responding to a click. The last tool for today. You get the button the way you got the canvas, and you tell it what to do when it is pressed:

const pulsante = document.getElementById("cambia") as HTMLButtonElement | null;

if (pulsante) {
  pulsante.addEventListener("click", () => {
    console.log("premuto!");
  });
}

addEventListener wants two things: the name of the event ("click") and a function to run when it happens. That function is an arrow function with no parameters, from lesson 3. Nobody calls it explicitly: the browser calls it, when and if the user presses. In the final project this is exactly how the webcam starts, from the click on the "Turn on camera" button.

Teacher demo (15')

  1. Open vite.new/vanilla-ts in front of the class: showing that in ten seconds there is a working project removes the installation anxiety.
  2. Empty src/main.ts and the <body> of index.html, add the <canvas>, save: the page stays white. "The canvas is there, it is empty."
  3. Write getElementById without as and show the two red errors. Add as and the check, and comment on them: they are the two problems the compiler just prevented.
  4. Draw a circle. It is the "it works!" moment of the lesson: wait two seconds before going on.
  5. Draw three circles forgetting beginPath and show the spider web. Then add it.
  6. Wire up a button that changes the colour of the circle and redraws.

Lab (60')

Exercise 5.1 — The first circle and the first line base

On vite.new/vanilla-ts: put in the <body> of index.html a 480 × 640 canvas with id="tela", empty src/main.ts and draw a light-blue circle in the centre of the canvas and a pink line from the top-left corner to the centre.

<body>
  <canvas id="tela" width="480" height="640"></canvas>
  <script type="module" src="/src/main.ts"></script>
</body>
Solution
const tela = document.getElementById("tela") as HTMLCanvasElement | null;
if (!tela) {
  throw new Error("canvas non trovato");
}

const ctx = tela.getContext("2d");
if (!ctx) {
  throw new Error("contesto 2d non disponibile");
}

// the light-blue circle in the centre
ctx.beginPath();
ctx.arc(240, 320, 20, 0, Math.PI * 2);
ctx.fillStyle = "#38bdf8";
ctx.fill();

// the pink line from the corner to the centre
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(240, 320);
ctx.strokeStyle = "#f472b6";
ctx.lineWidth = 4;
ctx.stroke();

Why this way. The two checks at the top are not bureaucracy: they are the difference between a message telling you what went wrong and a silent white page. The centre is (240, 320) because the canvas is 480 × 640: half and half.

Exercise 5.2 — The twenty-one points of a fake hand medium

Here is a real hand, in coordinates from 0 to 1: the twenty-one points of an open right hand with the fingers pointing up, in the exact order used by the final project. Copy them.

interface Punto {
  x: number;
  y: number;
}

const MANO_APERTA: Punto[] = [
  { x: 0.50, y: 0.88 },   //  0 wrist
  { x: 0.42, y: 0.84 },   //  1 thumb, base
  { x: 0.36, y: 0.77 },   //  2 thumb
  { x: 0.33, y: 0.70 },   //  3 thumb
  { x: 0.30, y: 0.64 },   //  4 thumb, TIP
  { x: 0.44, y: 0.60 },   //  5 index, knuckle
  { x: 0.42, y: 0.50 },   //  6 index
  { x: 0.41, y: 0.43 },   //  7 index
  { x: 0.40, y: 0.37 },   //  8 index, TIP
  { x: 0.51, y: 0.58 },   //  9 middle, knuckle
  { x: 0.51, y: 0.47 },   // 10 middle
  { x: 0.51, y: 0.40 },   // 11 middle
  { x: 0.51, y: 0.33 },   // 12 middle, TIP
  { x: 0.57, y: 0.60 },   // 13 ring, knuckle
  { x: 0.58, y: 0.49 },   // 14 ring
  { x: 0.59, y: 0.43 },   // 15 ring
  { x: 0.60, y: 0.37 },   // 16 ring, TIP
  { x: 0.63, y: 0.63 },   // 17 little finger, knuckle
  { x: 0.66, y: 0.55 },   // 18 little finger
  { x: 0.68, y: 0.50 },   // 19 little finger
  { x: 0.70, y: 0.45 },   // 20 little finger, TIP
];

Write aPixel (it is the one from lesson 3) and draw a light-blue circle of radius 6 for each of the twenty-one points. Use tela.width and tela.height, not the numbers 480 and 640 typed by hand.

Solution
function aPixel(valore: number, dimensione: number): number {
  return valore * dimensione;
}

ctx.clearRect(0, 0, tela.width, tela.height);

for (const p of MANO_APERTA) {
  const px = aPixel(p.x, tela.width);
  const py = aPixel(p.y, tela.height);

  ctx.beginPath();
  ctx.arc(px, py, 6, 0, Math.PI * 2);
  ctx.fillStyle = "#38bdf8";
  ctx.fill();
}

Why this way. for…of because here you only need the value, not the position. beginPath is inside the loop: one per circle, otherwise the twenty-one points get joined together. And using tela.width instead of 480 means that if you change the size in the HTML the drawing adapts by itself, without touching the code.

You should already glimpse the shape of a hand. It is not a hand yet: the bones are missing.

Exercise 5.3 — The bones medium

A finger is made of points joined together. Which points are joined to which is a fixed piece of information, which in the real project comes from the recognition library: these are the twenty-one connections, identical to those used in the application.

const CONNESSIONI: number[][] = [
  [0, 1], [1, 2], [2, 3], [3, 4],           // thumb
  [0, 5], [5, 6], [6, 7], [7, 8],           // index
  [5, 9], [9, 10], [10, 11], [11, 12],      // middle
  [9, 13], [13, 14], [14, 15], [15, 16],    // ring
  [13, 17], [17, 18], [18, 19], [19, 20],   // little finger
  [0, 17],                                  // the side of the palm
];

Each element is an array of two numbers: the indices of the two points to join with a line. Draw all the bones in light blue with thickness 4, before the circles, so the points stay on top.

Solution
ctx.clearRect(0, 0, tela.width, tela.height);

// 1) the bones, underneath
ctx.strokeStyle = "#38bdf8";
ctx.lineWidth = 4;

for (const osso of CONNESSIONI) {
  const a = MANO_APERTA[osso[0]];
  const b = MANO_APERTA[osso[1]];

  ctx.beginPath();
  ctx.moveTo(aPixel(a.x, tela.width), aPixel(a.y, tela.height));
  ctx.lineTo(aPixel(b.x, tela.width), aPixel(b.y, tela.height));
  ctx.stroke();
}

// 2) the joints, on top
for (const p of MANO_APERTA) {
  ctx.beginPath();
  ctx.arc(aPixel(p.x, tela.width), aPixel(p.y, tela.height), 6, 0, Math.PI * 2);
  ctx.fillStyle = "#38bdf8";
  ctx.fill();
}

Why this way. osso[0] and osso[1] are the two indices; using them to pick from MANO_APERTA is the key line of the exercise. Order matters: the canvas draws in the order of the instructions, so what comes later covers what came before. Bones first, joints after.

strokeStyle and lineWidth are set once outside the loop: they stay valid until they are changed, there is no need to repeat them twenty-one times.

Now there is a hand on the screen. You drew it: it is the same figure that will start to move in lesson 8.

Exercise 5.4 — White tips medium

In the real project the five fingertips — indices 4, 8, 12, 16 and 20 — are white and a little bigger than the other joints: they help read the gesture at a glance. Do the same.

You will need the for with the index, not for…of: you need to know which position you are at. Hint: const PUNTE = [4, 8, 12, 16, 20]; and then PUNTE.includes(i), which answers true if the number is in the list.

Solution
const PUNTE: number[] = [4, 8, 12, 16, 20];

for (let i = 0; i < MANO_APERTA.length; i++) {
  const p = MANO_APERTA[i];
  const punta = PUNTE.includes(i);

  ctx.beginPath();
  ctx.arc(aPixel(p.x, tela.width), aPixel(p.y, tela.height), punta ? 9 : 6, 0, Math.PI * 2);
  ctx.fillStyle = punta ? "#ffffff" : "#38bdf8";
  ctx.fill();
}

Why this way. Here you need the index because the information "it is a tip" lies in the position, not in the value: it is the same reason as in exercise 2.4.

punta ? 9 : 6 is a new notation, the conditional operator: it reads "if punta is true then 9, otherwise 6". It is an if/else that fits inside an expression and returns a value. Use it only for tiny choices like this one; for anything more complex a normal if stays more readable.

Exercise 5.5 — The button that changes pose challenge

Add a button to the page. On every click the hand switches from open to fist and back, redrawing everything from scratch.

<body>
  <button id="cambia">Cambia posa</button>
  <canvas id="tela" width="480" height="640"></canvas>
  <script type="module" src="/src/main.ts"></script>
</body>

Here is the second pose, with the fingers closed towards the palm:

const PUGNO: Punto[] = [
  { x: 0.50, y: 0.88 }, { x: 0.42, y: 0.84 }, { x: 0.37, y: 0.78 },
  { x: 0.36, y: 0.72 }, { x: 0.40, y: 0.68 },
  { x: 0.44, y: 0.62 }, { x: 0.43, y: 0.54 }, { x: 0.46, y: 0.56 },
  { x: 0.48, y: 0.60 },
  { x: 0.51, y: 0.60 }, { x: 0.51, y: 0.52 }, { x: 0.53, y: 0.55 },
  { x: 0.54, y: 0.60 },
  { x: 0.57, y: 0.61 }, { x: 0.58, y: 0.53 }, { x: 0.59, y: 0.57 },
  { x: 0.59, y: 0.62 },
  { x: 0.63, y: 0.64 }, { x: 0.65, y: 0.58 }, { x: 0.65, y: 0.62 },
  { x: 0.64, y: 0.66 },
];

The hard part is organizing the code: put all the drawing inside a function disegna(punti: Punto[]): void, so you can call it with one pose or with the other.

Solution
function disegna(punti: Punto[]): void {
  ctx.clearRect(0, 0, tela.width, tela.height);

  ctx.strokeStyle = "#38bdf8";
  ctx.lineWidth = 4;
  for (const osso of CONNESSIONI) {
    const a = punti[osso[0]];
    const b = punti[osso[1]];
    ctx.beginPath();
    ctx.moveTo(aPixel(a.x, tela.width), aPixel(a.y, tela.height));
    ctx.lineTo(aPixel(b.x, tela.width), aPixel(b.y, tela.height));
    ctx.stroke();
  }

  for (let i = 0; i < punti.length; i++) {
    const p = punti[i];
    const punta = PUNTE.includes(i);
    ctx.beginPath();
    ctx.arc(aPixel(p.x, tela.width), aPixel(p.y, tela.height), punta ? 9 : 6, 0, Math.PI * 2);
    ctx.fillStyle = punta ? "#ffffff" : "#38bdf8";
    ctx.fill();
  }
}

let aperta = true;
disegna(MANO_APERTA);
const pulsante = document.getElementById("cambia") as HTMLButtonElement | null;

if (pulsante) {
  pulsante.addEventListener("click", () => {
    aperta = !aperta;
    disegna(aperta ? MANO_APERTA : PUGNO);
  });
}

Why this way. The disegna function takes the points as a parameter instead of reading a global variable: that way the same function works for both poses, and tomorrow it will also work for the real webcam points without changing a line. That is why parameters exist.

aperta = !aperta flips the boolean on every click: it is the standard way to write a toggle. And the clearRect at the top of disegna is essential: without it, the two poses would pile up on the canvas and after three clicks you would see a tangle.

If you finish early: draw the palm. It is a polygon joining points 0, 1, 5, 9, 13 and 17: you do it with moveTo on the first, lineTo on all the others, ctx.closePath() and ctx.fill(). It is exactly what the real project does, where the palm is a semi-transparent polygon.

The moment of the lesson Exercise 5.3 is the turning point of the whole course: for the first time the work of the four previous lessons — variables, loops, arrays, functions, objects — produces something you can look at. It is worth stopping, having the teacher walk between the desks and letting everyone see everyone's screen. From here on, when someone asks "what is it for?", it will be enough to point at the screen.

Typical mistakes in class

Quick check (5 questions)

Questions and answers
  1. Why does document.getElementById return HTMLElement | null? → Because that id might not exist in the page: the type says so, and forces you to check.
  2. What does as HTMLCanvasElement do? → It convinces the compiler that the element is a canvas. It verifies nothing: it is a promise by whoever writes it.
  3. What is beginPath for? → To start a new shape, separate from the previous one. Without it, shapes get joined.
  4. Where is the point (0, 0) of a canvas? → At the top left. y grows downwards.
  5. What is the difference between fill and stroke? → fill fills the inside of the shape, stroke draws its outline.

Homework (40 minutes)

Start from the solution of exercise 5.5 and add a third pose of your choice — the victory sign, thumbs up, a waving hand — making up the twenty-one coordinates yourself. The button must cycle through the three poses instead of alternating two: keep a let posa = 0, move it forward on every click and bring it back to zero when it reaches three. By putting the three poses in an array of arrays — const POSE: Punto[][] = [MANO_APERTA, PUGNO, LA_TUA] — the code becomes three lines. Bring the StackBlitz link to the next lesson: it is the last time we work in the browser, because lesson six opens by installing the real tools on your own computer.

Lesson 6

On your own computer: Node, Vite, modules

Until yesterday the code ran inside someone else's web page. From today the project is yours: it lives in a folder on your computer, starts with a command and is split into several files. It is the step that makes the rest of the course possible.

30' Why you need a real project · Node and npm · Vite · the configuration files · modules
15' The teacher creates a project from scratch in front of the class, from the empty terminal to the page opening
60' Exercises 6.1 → 6.6 (guided installation and moving the code from lesson 5)
15' Check list · mistakes seen in class · homework

Theory

In the first five lessons you wrote code inside a page that someone else kept open for you. It worked very well for learning, but now there is a precise obstacle: the webcam does not turn on inside the preview of online environments. The box that shows the result is a page framed inside another, coming from a different site, and browsers deny camera and microphone to those frames. It is not a flaw of the environment: it is a rule designed to stop any site from turning on your camera by hiding inside a frame.

From the next lesson you need the webcam. So today you need a real project.

Node.js: JavaScript outside the browser

Node.js is a program that runs JavaScript on your computer, without a browser. You do not need it to run the hand — that will run in the browser as always — but the tools need it: the TypeScript compiler, the development server, the program that puts the files together. They are all written in JavaScript and run on Node.

Together with Node you install npm (node package manager), which does two things:

Do not mix up the two worlds Node runs on your computer and can open files and folders, but it does not know what a web page is: there document and window do not exist. The browser is the opposite. Your hand code lives in the browser; Node prepares it and serves it.

Vite: the ready-made project

Creating the right folder, the right configuration file and the right server by hand is boring and teaches nothing. Vite does it for you with one command:

npm create vite@latest mano-corso

It asks you two questions. For the first (Select a framework) choose Vanilla, which means "no framework, just the page and the code". For the second (Select a variant) choose TypeScript. In short people say "the vanilla-ts template", and you can also ask for it directly:

npm create vite@latest mano-corso -- --template vanilla-ts

Inside the newly created folder you find this:

mano-corso/
├── index.html          ← la pagina: il titolo, i pulsanti, il canvas
├── package.json        ← il "documento d'identità" del progetto
├── tsconfig.json       ← le regole del compilatore TypeScript
├── public/             ← file serviti così come sono (immagini, icone)
└── src/
    ├── main.ts         ← il codice che parte all'apertura della pagina
    └── style.css       ← l'aspetto

At the bottom of index.html there is a single line that links the page to the code:

<script type="module" src="/src/main.ts"></script>

Then two commands, always the same, for the rest of the course:

cd mano-corso     # entra nella cartella
npm install       # scarica gli strumenti: crea node_modules/ (pesante, non si tocca)
npm run dev       # avvia il server di sviluppo

npm run dev prints an address, usually http://localhost:5173. You open it in the browser and see your page. The server stays on: leave that terminal window open and write the code in the other window. Every time you save, the page updates by itself in a fraction of a second. To stop it: Ctrl + C.

localhost means "this computer". Nobody outside sees that page. And it is also an address the browser trusts: as you will see next lesson, the webcam turns on only on https://… or on localhost, and that is why the local project works straight away.

The two configuration files

package.json is a text file that describes the project: the name, the libraries it uses and the available commands.

{
  "name": "mano-corso",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "devDependencies": {
    "typescript": "~5.9.0",
    "vite": "^7.0.0"
  }
}

What is in scripts are shortcuts: npm run dev runs what is written next to "dev". What is in devDependencies are the tools downloaded by npm install. The number next to the name is the version, and it matters: in lesson 8 you will see what happens when a library changes under a project's feet.

tsconfig.json tells the compiler how strict to be. The line we care about today is one:

{
  "compilerOptions": {
    "target": "ES2022",
    "strict": true
  },
  "include": ["src"]
}

With "strict": true TypeScript stops letting things slide. The two checks you will notice straight away:

// con strict acceso
const tela = document.getElementById("tela");
tela.textContent = "ciao";
// Errore: 'tela' is possibly 'null'.

// due modi onesti per risolverlo
const tela = document.getElementById("tela") as HTMLCanvasElement;   // "fidati, esiste"
// oppure, meglio:
const tela = document.getElementById("tela");
if (!tela) throw new Error("manca l'elemento #tela nella pagina");
Why accept being told off Every error that strict shows you now is an error that will not show up in lesson 8, while you move your hand in front of the webcam and cannot understand why the screen stays black. The right moment to find out that something can be null is when you write it.

Splitting the code into files: export and import

The real hand project — the one you see online — is split into five files, each with one job: turning on the camera, recognizing the hand, damping the jitter, drawing, putting it all in order. It is not fussiness: it is the only way to find things again.

A file becomes a module the moment it exports something. Whatever is not exported stays private to that file.

// src/disegna.ts
export interface Punto {
  x: number;
  y: number;
  z: number;
}

const COLORE_OSSA = "#38bdf8";   // niente export: la usa solo questo file

export function disegnaPunti(ctx: CanvasRenderingContext2D, punti: Punto[]): void {
  for (const p of punti) {
    ctx.beginPath();
    ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
    ctx.fill();
  }
}
// src/main.ts
import { disegnaPunti, type Punto } from "./disegna";

const punti: Punto[] = [{ x: 100, y: 50, z: 0 }];
// disegnaPunti(ctx, punti);

Three things to notice, and they are always the same three:

  1. between braces you write the same names used in the export: if there it is disegnaPunti, here it cannot be DisegnaPunti;
  2. the path starts with ./ (same folder) or ../ (folder above), and you do not write the .ts extension;
  3. to import a type you write type before the name: it tells the compiler "this is only for you, it will not end up in the executed code".

What the compiler really does

The browser does not know what TypeScript is. It has never known what types are and it will not learn. What happens is simpler than it looks: the types are erased.

// quello che scrivi tu (TypeScript)
function distanza(a: Punto, b: Punto): number {
  const dx: number = a.x - b.x;
  const dy: number = a.y - b.y;
  return Math.sqrt(dx * dx + dy * dy);
}

// quello che arriva al browser (JavaScript)
function distanza(a, b) {
  const dx = a.x - b.x;
  const dy = a.y - b.y;
  return Math.sqrt(dx * dx + dy * dy);
}

Identical, minus the annotations. Two consequences follow that are worth fixing in your mind now:

During development Vite does this transformation, on the fly, on every save. Type checking instead is done by tsc, which runs when you launch npm run build and continuously inside VS Code, which is why the red underlines appear while you type.

Teacher demo (15')

  1. Empty terminal: node -v and npm -v, to show they are there.
  2. npm create vite@latest demo-aula -- --template vanilla-ts, then npm install and npm run dev: from nothing to the open page, in less than two minutes.
  3. Change a line in src/main.ts, save and show the page updating by itself while nobody reloads it.
  4. Create src/saluta.ts containing export function saluta(nome: string) { … } and import it in main.ts. Then make a mistake on purpose: remove the export and read the error together with the class.
  5. Open tsconfig.json, set "strict": true and show the line that turns red.

Lab (60')

Plan B, to announce at the start of the lab On a school laptop or with an account without administrator rights, the installation may fail — and it does happen. Whoever gets stuck does not fall behind: they open vite.new/vanilla-ts, which is exactly the same vanilla-ts project but inside the browser, with terminal and preview. Exercises from 6.3 on are done identically, line by line. For lesson 7 the webcam will be needed: whoever stayed online works in pairs with a classmate who has the project locally, or tests the code on the video recorded by the teacher. The StackBlitz preview in a separate tab does turn on the camera, but it is the last resort and you should say why: that page comes from StackBlitz's servers and does not have our project's CSP. Take note of who is in this situation: from then on they work in fixed pairs, not improvised each time.

Exercise 6.1 — Installing the tools base

Three installations, in this order. If a machine already has them, skip ahead to exercise 6.2.

  1. Node.js, LTS version (the recommended one, not the "Current"): nodejs.org. Next, next, finish: do not change any option.
  2. Visual Studio Code: code.visualstudio.com.
  3. In VS Code, two useful extensions (the squares icon in the left bar): Italian Language Pack if you prefer the menus in Italian, and Prettier, which tidies up the code by itself when you save. TypeScript support is already built into VS Code: there is nothing to install for that.

Then open the VS Code terminal (menu Terminal → New Terminal) and type:

node -v
npm -v
What you should see
v22.20.0     # an even number ≥ 20, yours may differ
10.9.3       # any number ≥ 9

If instead you get "node is not recognized as a command", almost always it is enough to close and reopen VS Code: the terminal still had in memory the list of programs from before the installation. If the error remains after reopening, switch to plan B and try again at home: do not lose the lab hour.

Exercise 6.2 — Your project base

Create a project called mano-corso, install the tools and start it. Then open the address that appears and show the page to your desk mate.

Solution
npm create vite@latest mano-corso -- --template vanilla-ts
cd mano-corso
npm install
npm run dev

The terminal writes Local: http://localhost:5173/. In many terminals you open it with Ctrl + click on the address. The window with npm run dev stays open for the whole lesson: if you close it, the page stops responding.

Exercise 6.3 — Clean slate base

The newly created project contains an example with the Vite logos. You do not need it. Reduce index.html to the page you will really need — a heading, a button and a canvas — and empty src/main.ts leaving only the stylesheet import and a console.log. Check in the browser console (F12) that the message appears.

Solution
<!doctype html>
<html lang="it">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>La mia mano</title>
  </head>
  <body>
    <h1>La mia mano</h1>
    <button id="disegna">Disegna</button>
    <canvas id="tela" width="640" height="480"></canvas>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
// src/main.ts
import "./style.css";

console.log("il progetto è vivo");

Go ahead and delete src/counter.ts and src/typescript.svg: if the page then complains it cannot find them, it means an import was left to remove. It is an excellent first error to read.

Exercise 6.4 — Moving lesson 5 over medium

Take the code from lesson 5 — the fake hand drawn on the canvas — and bring it in here, split into two files:

  • src/disegna.ts: the Punto interface, the function that draws the points and the one that draws the bones. All exported.
  • src/main.ts: gets the canvas from the page, imports the functions and calls them.

If in lesson 5 you gave the functions different names, keep them: the exercise is the move, not the rewrite.

Solution
// src/disegna.ts
export interface Punto {
  x: number;
  y: number;
  z: number;
}

/** The 21 bones of the hand: each pair says which two points to join. */
export const OSSA: number[][] = [
  [0, 1], [1, 2], [2, 3], [3, 4],
  [0, 5], [5, 6], [6, 7], [7, 8],
  [5, 9], [9, 10], [10, 11], [11, 12],
  [9, 13], [13, 14], [14, 15], [15, 16],
  [13, 17], [17, 18], [18, 19], [19, 20],
  [0, 17],
];

export function disegnaPunti(ctx: CanvasRenderingContext2D, punti: Punto[]): void {
  ctx.fillStyle = "#ffffff";
  for (const p of punti) {
    ctx.beginPath();
    ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
    ctx.fill();
  }
}

export function disegnaOssa(ctx: CanvasRenderingContext2D, punti: Punto[]): void {
  ctx.strokeStyle = "#38bdf8";
  ctx.lineWidth = 3;
  for (const osso of OSSA) {
    const a = punti[osso[0]];
    const b = punti[osso[1]];
    ctx.beginPath();
    ctx.moveTo(a.x, a.y);
    ctx.lineTo(b.x, b.y);
    ctx.stroke();
  }
}
// src/main.ts
import "./style.css";
import { disegnaOssa, disegnaPunti, type Punto } from "./disegna";
import { MANO_FINTA } from "./punti";

const tela = document.getElementById("tela") as HTMLCanvasElement;
const ctx = tela.getContext("2d") as CanvasRenderingContext2D;

const punti: Punto[] = MANO_FINTA;
ctx.clearRect(0, 0, tela.width, tela.height);
disegnaOssa(ctx, punti);
disegnaPunti(ctx, punti);

The functions stayed identical to those of lesson 5: the only new things are the words export and import. That is exactly how the real project is built.

Exercise 6.5 — The second module: the data medium

The file disegna.ts knows how to draw; it must not know what. So create src/punti.ts with two exported things in it:

  • MANO_FINTA: the 21 made-up points from lesson 5, but with coordinates between 0 and 1, as they will arrive from the webcam in lesson 8;
  • aPixel(p, larghezza, altezza): the function from lesson 3 that converts a normalized point into pixels.

Then in main.ts convert the points before drawing them.

Solution
// src/punti.ts
import type { Punto } from "./disegna";

/** A made-up open hand: coordinates between 0 and 1, like the real ones. */
export const MANO_FINTA: Punto[] = [
  { x: 0.50, y: 0.90, z: 0 },   // 0  wrist
  { x: 0.40, y: 0.84, z: 0 },   // 1  thumb
  { x: 0.33, y: 0.76, z: 0 },   // 2
  { x: 0.28, y: 0.68, z: 0 },   // 3
  { x: 0.24, y: 0.61, z: 0 },   // 4  thumb tip
  { x: 0.44, y: 0.62, z: 0 },   // 5  index
  { x: 0.43, y: 0.50, z: 0 },   // 6
  { x: 0.42, y: 0.42, z: 0 },   // 7
  { x: 0.42, y: 0.35, z: 0 },   // 8  index tip
  { x: 0.52, y: 0.60, z: 0 },   // 9  middle
  { x: 0.52, y: 0.47, z: 0 },   // 10
  { x: 0.52, y: 0.38, z: 0 },   // 11
  { x: 0.52, y: 0.31, z: 0 },   // 12 middle tip
  { x: 0.59, y: 0.62, z: 0 },   // 13 ring
  { x: 0.60, y: 0.50, z: 0 },   // 14
  { x: 0.61, y: 0.42, z: 0 },   // 15
  { x: 0.61, y: 0.35, z: 0 },   // 16 ring tip
  { x: 0.65, y: 0.67, z: 0 },   // 17 little finger
  { x: 0.68, y: 0.58, z: 0 },   // 18
  { x: 0.70, y: 0.52, z: 0 },   // 19
  { x: 0.72, y: 0.46, z: 0 },   // 20 little finger tip
];

/** From 0…1 coordinates to pixels of a canvas `larghezza` wide and `altezza` high. */
export function aPixel(p: Punto, larghezza: number, altezza: number): Punto {
  return { x: p.x * larghezza, y: p.y * altezza, z: p.z };
}
// src/main.ts — the part that changes
import { MANO_FINTA, aPixel } from "./punti";

const punti = MANO_FINTA.map((p) => aPixel(p, tela.width, tela.height));
disegnaOssa(ctx, punti);
disegnaPunti(ctx, punti);

Now the division of jobs is clean: punti.ts holds the data, disegna.ts the drawing, main.ts puts the two in order. In lesson 8 only the first line will change: the fake points will become the real ones of your hand, and the drawing will not even notice.

Exercise 6.6 — Turn on strict challenge

Open tsconfig.json, make sure there is "strict": true inside compilerOptions, save and see what turns red. Fix everything, then run npx tsc --noEmit to get confirmation from the compiler instead of the editor.

Solution

The two errors that almost always appear, and how to fix them:

// 1) 'const ctx' is possibly 'null'
//    getContext("2d") returns null if the canvas does not support 2D.
const ctx = tela.getContext("2d");
if (!ctx) throw new Error("questo browser non sa disegnare in 2D");

// 2) Parameter 'p' implicitly has an 'any' type
//    inside map the type is usually inferred; if it is not, you write it.
const punti = MANO_FINTA.map((p: Punto) => aPixel(p, tela.width, tela.height));

npx tsc --noEmit means "check the types and do not write anything": it is the same check the real project runs before every publication. If it does not print anything, all is well — checking programs, when they are happy, stay quiet.

End-of-lesson check list Before leaving the classroom, everyone must be able to answer yes to all five:
  1. node -v prints a number;
  2. npm -v prints a number;
  3. in the project folder, npm run dev starts and prints an address;
  4. http://localhost:5173 shows your page, with the fake hand drawn;
  5. when you save a file, the page updates by itself.
Whoever does not get to five says so now, not next week: without a project that starts, lesson 7 cannot be followed.

Typical mistakes in class

Quick check (5 questions)

Questions and answers
  1. Why can we not stay on the online environments? → Because browsers deny the webcam to framed previews coming from another site, and from lesson 7 we need the webcam.
  2. What does npm install do? → It reads package.json and downloads into node_modules the libraries and tools listed there.
  3. What happens to the types when the code reaches the browser? → They are erased: the browser runs plain JavaScript.
  4. What do you need to use in main.ts a function written in disegna.ts? → export before the function and import { … } from "./disegna" at the top of the file.
  5. What changes when you turn on strict? → The compiler no longer accepts implicit types and forces you to handle the cases where a value can be null.

Homework (30-40 minutes)

Tidy up the project and make it yours: put the fake hand in the centre of the canvas whatever the size of the canvas, add a third module src/colori.ts that exports the colours used by the drawing (so changing one file changes the whole look), and check that npx tsc --noEmit says nothing. Bring the project next time, ready to start: lesson 7 begins with npm run dev already running.

Lesson 7

The webcam: async, permissions, frames

Today your page sees. To get there you need three new things: asking for something that takes time (async and await), reacting when it goes wrong (try/catch) and redrawing thirty times a second without jamming the browser.

30' Operations that take time · async/await · errors · permissions · the frame loop
15' The teacher writes camera.ts live: turn on, turn off, and the webcam light that really goes off
60' Exercises 7.1 → 7.6
15' Privacy rules in the classroom · quick check · homework

Theory

Some things are not ready right away

Until now every line of code gave its result immediately. Turning on the webcam does not: the browser has to ask the user for permission, and the user may think about it for five seconds; then it has to turn on a physical camera. Meanwhile the program cannot stop: if it stopped, the page would freeze and not even the «Allow» button would work.

JavaScript's solution is called Promise: not the result, but the promise of a result. A function like getUserMedia gives you a promise right away, and sooner or later that promise is kept (the video stream arrives) or broken (the user said no).

To work with promises there are two words:

async function accendi() {
  const flusso = await navigator.mediaDevices.getUserMedia({ video: true });
  console.log("acceso!", flusso);
}
The type of an asynchronous function An async function always returns a promise, even when inside you write return 5. That is why its return type is written Promise<number>, and when it returns nothing Promise<void>. Those angle brackets say «a promise of what».
async function accendiCamera(video: HTMLVideoElement): Promise<MediaStream> {
  const flusso = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
  video.srcObject = flusso;
  await video.play();
  return flusso;
}

When it goes wrong: try / catch

A promise can break, and with the webcam it breaks often: permission denied, no camera, camera already used by another program. If you do nothing, the user sees a page that does not react and does not understand why.

try {
  flusso = await accendiCamera(video);
  // you only get here if it went well
} catch (e) {
  // you only get here if it went badly
  console.log("non ha funzionato:", e);
}

The browser does not send you a sentence, it sends you an error with a name. The three you will see in class today:

NameWhat happenedWhat to tell the user
NotAllowedErrorThe user denied permission, or had already denied it in the past«Allow camera use in the browser settings and try again»
NotFoundErrorNo webcam is connected«No webcam found on this device»
NotReadableErrorThere is one, but another program is using it«The webcam is busy with another application»

Translating them is trivial and changes everything: it is the difference between an application and a homework exercise.

The secure context

The webcam only turns on if the page comes from https://… or from localhost. On a plain http://, navigator.mediaDevices does not exist at all: the code fails with an obscure error.

The reason is serious. On http:// anyone in the middle of the network — the wi-fi operator, for instance — can change the page while it travels, and so make a page that had not asked for it turn on the camera. https prevents this. localhost is allowed because it travels nowhere: it is your computer.

if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia) {
  throw new Error("La camera funziona solo su una pagina HTTPS o su localhost.");
}

It is exactly the check the real project does, before anything else. And it also explains why in lesson 10, when we talk about putting the site online, HTTPS will not be an optional detail.

Where the video ends up

The webcam stream does not show itself: it has to be attached to a <video> element.

<video id="video" autoplay muted playsinline></video>
<canvas id="tela" width="640" height="480"></canvas>

Three attributes, three reasons:

The <video>, though, is only a little window. To draw the hand landmarks on top you need the canvas, and first you have to copy the image into it:

ctx.drawImage(video, 0, 0, tela.width, tela.height);

One line, and the current frame is in the canvas. From there on you draw whatever you want on top: in lesson 8 it will be 21 dots.

The frame loop

Drawing once is not enough: the image changes all the time. You need a loop that restarts at every frame, and the browser provides it:

function ciclo(): void {
  requestAnimationFrame(ciclo);          // book the next round
  ctx.drawImage(video, 0, 0, tela.width, tela.height);
}
ciclo();                                  // you call the first round yourself

requestAnimationFrame — «request an animation frame» — tells the browser: call me back just before the next screen repaint. It is usually 60 times a second.

Why not a timer, that is setInterval(ciclo, 16)? Three concrete reasons:

The loop is stopped with cancelAnimationFrame, and to do that you must have kept the number that requestAnimationFrame returns:

let idCiclo = 0;

function ciclo(): void {
  idCiclo = requestAnimationFrame(ciclo);
  // …drawing…
}

function fermaCiclo(): void {
  cancelAnimationFrame(idCiclo);
}

Really turning the camera off

This is the part almost everyone gets wrong the first time. Pausing the <video> does not turn off the camera: the stream stays open and the little light next to the lens stays on. You have to stop the tracks, that is the individual channels of the stream, one by one:

function spegniCamera(video: HTMLVideoElement, flusso: MediaStream | null): void {
  flusso?.getTracks().forEach((t) => t.stop());   // ← this is the line that matters
  video.pause();
  video.srcObject = null;
}

The check is physical and leaves no room for argument: the webcam light must go off. If it stays on, you have turned nothing off.

Teacher demo (15')

  1. A page with two buttons and a <video>. Write accendiCamera live and make the permission request appear on the projector.
  2. Deny permission on purpose. Show that the page says nothing, then add the try/catch and the translated message. It is the most useful moment of the lesson.
  3. Write spegniCamera first without getTracks().forEach(...): the light stays on. Then add the line: the light goes off. Show the little light, not the code.
  4. Add the loop with requestAnimationFrame and the drawImage on the canvas: now there are two identical images, the video and the canvas. Ask the class why two are needed — the answer («we can draw on top of the canvas») is lesson 8.

Lab (60')

Classroom rules, before turning anything on You film only your own hand: not your classmates, not the teacher, not the classroom. Anyone who does not want to be on camera does not have to be: they can work with a video instead of the webcam (the teacher provides a short clip of a hand) or pair up with a classmate. No photos, no recording, no screen sharing with the camera on. The images do not leave the computer: everything you write today runs in the browser, and in the code there is not — and there will not be — a single line that sends an image anywhere. It is the same reason the published project has no server to send anything to: what is not collected cannot be lost.

Exercise 7.1 — Turn on the camera basic

In your page put a «Turn on camera» button and a <video> element. When the button is clicked, the webcam turns on and you can see it. Nothing more.

<!-- index.html -->
<button id="accendi">Accendi la camera</button>
<button id="spegni">Spegni</button>
<p id="messaggio"></p>
<video id="video" autoplay muted playsinline></video>
<canvas id="tela" width="640" height="480"></canvas>
Solution
// src/main.ts
import "./style.css";

const bottoneAccendi = document.getElementById("accendi") as HTMLButtonElement;
const video = document.getElementById("video") as HTMLVideoElement;

let flusso: MediaStream | null = null;

async function accendiCamera(): Promise<void> {
  flusso = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
  video.srcObject = flusso;
  await video.play();
}

bottoneAccendi.addEventListener("click", () => {
  void accendiCamera();
});

That void in front of the call says «I know it is a promise and I know I am not waiting for it»: without it, the compiler in strict mode may complain. The camera turns on only on click, never when the page opens: it is a project rule and it is also how the browser shows the permission request at a moment that makes sense to the user.

Exercise 7.2 — Errors people understand basic

Put the start-up inside try/catch and write an understandable sentence in the #messaggio paragraph. Use an object of messages instead of a chain of ifs. Then really try it: deny permission from the browser's little window.

Solution
const messaggio = document.getElementById("messaggio") as HTMLParagraphElement;

/** The standard browser error names, translated into useful sentences. */
const MESSAGGI: Record<string, string> = {
  NotAllowedError: "Permesso negato: consenti l'uso della camera dalle impostazioni del browser e riprova.",
  NotFoundError: "Nessuna webcam trovata su questo dispositivo.",
  NotReadableError: "La webcam è occupata da un'altra applicazione.",
  OverconstrainedError: "La webcam non supporta la risoluzione richiesta.",
};

async function accendiCamera(): Promise<void> {
  messaggio.textContent = "";
  try {
    flusso = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
    video.srcObject = flusso;
    await video.play();
    messaggio.textContent = "● Camera accesa";
  } catch (e) {
    const nome = e instanceof DOMException ? e.name : "";
    messaggio.textContent = MESSAGGI[nome] ?? "Non riesco ad accendere la camera.";
  }
}

Record<string, string> is the type of «an object with text keys and text values»: a small dictionary. The ?? from lesson 4 covers the unexpected cases. To reset the permission: the camera icon in the address bar, or the site settings.

Exercise 7.3 — Really turn it off basic

Make the «Spegni» (turn off) button work. The proof is not the code: it is the webcam light going off. Try it first without stopping the tracks, then stopping them, and look at the difference.

Solution
const bottoneSpegni = document.getElementById("spegni") as HTMLButtonElement;

function spegniCamera(): void {
  flusso?.getTracks().forEach((t) => t.stop());
  video.pause();
  video.srcObject = null;
  flusso = null;
  messaggio.textContent = "Camera spenta";
}

bottoneSpegni.addEventListener("click", spegniCamera);

A MediaStream contains one or more tracks (here just one, video). As long as a track is alive, the camera is on. flusso?.getTracks() uses the ?. from lesson 4: if the stream is null, the line does nothing instead of blowing up.

Exercise 7.4 — The video on the canvas, mirrored intermediate

Start a loop with requestAnimationFrame that copies every frame onto the canvas. Then mirror the image: when you raise your right hand, the hand that looks right to you must go up on screen, as in front of a mirror. Stop the loop when you turn off the camera.

Solution
const tela = document.getElementById("tela") as HTMLCanvasElement;
const ctx = tela.getContext("2d");
if (!ctx) throw new Error("questo browser non sa disegnare in 2D");

let idCiclo = 0;

function ciclo(): void {
  idCiclo = requestAnimationFrame(ciclo);

  ctx.save();                       // set aside the current orientation
  ctx.translate(tela.width, 0);     // move the origin to the right edge
  ctx.scale(-1, 1);                 // flip the x axis: there is the mirror
  ctx.drawImage(video, 0, 0, tela.width, tela.height);
  ctx.restore();                    // put everything back as it was
}

save and restore matter: without restore, everything you draw afterwards — points, bones, text — would stay mirrored, and backwards text is quite noticeable. The loop must be started after video.play() and stopped in spegniCamera with cancelAnimationFrame(idCiclo), followed by ctx.clearRect(0, 0, tela.width, tela.height) to leave the canvas clean.

Exercise 7.5 — How many frames per second intermediate

Show in a corner of the page how many rounds per second the loop is doing. It is not computed on a single round (the number would jump around unreadably): you count the rounds and divide once a second.

Solution
const etichettaFps = document.getElementById("fps") as HTMLSpanElement;

let fotogrammi = 0;
let inizioFinestra = performance.now();

function contaFps(): void {
  fotogrammi++;
  const adesso = performance.now();
  const trascorso = adesso - inizioFinestra;
  if (trascorso >= 1000) {
    etichettaFps.textContent = `${Math.round((fotogrammi * 1000) / trascorso)} fps`;
    fotogrammi = 0;
    inizioFinestra = adesso;
  }
}

You call contaFps() inside the loop. performance.now() gives the milliseconds elapsed since the page opened, with decimals: it is the right clock for measuring durations, and you will meet it again unchanged in lesson 8. On a normal computer you will read about 60: it is the screen's rate. In lesson 8, when recognition sits in the middle, that number will become much more interesting.

Exercise 7.6 — Camera off if nobody is looking challenge

Make the camera turn itself off when you switch browser tab. The tool is the visibilitychange event on the document and the document.hidden property. Check it with the webcam light.

Solution
document.addEventListener("visibilitychange", () => {
  if (document.hidden && flusso) spegniCamera();
});

Three lines, but it is a privacy choice, not an optimization: if you are not looking at the page, there is no reason for the page to look at you. The real project has exactly this, plus a second check for the edge case where the user switches tab while the camera is turning on. If you want to try: after the start-up await, if document.hidden is true, turn it off immediately.

Typical mistakes in class

Quick check (5 questions)

Questions and answers
  1. What exactly does getUserMedia return? → A Promise: the promise of a video stream, not the stream.
  2. Where can you write await? → Inside a function declared async.
  3. On which addresses does the webcam turn on? → Only https://… and localhost: the secure context.
  4. Why requestAnimationFrame and not setInterval? → Because it follows the screen's rhythm, does not pile up delays and pauses when the tab is not visible.
  5. Is video.pause() enough to turn off the camera? → No: until the stream's tracks are stopped, the camera stays on.

Homework (30-40 minutes)

Move everything to do with the camera into a module src/camera.ts, which exports only two functions: accendiCamera(video) returning Promise<MediaStream> and spegniCamera(video, flusso). In main.ts only the buttons, the messages and the loop remain. Also add the secure-context check at the start of the start-up. It is the camera.ts file of the real project, written by you: next lesson you will use it as it is.

Lesson 8

MediaPipe: the 21 landmarks of a real hand

This is the lesson in which the project works. At the end of these two hours you move your hand in front of the webcam and on screen a hand drawn by you moves, with your fingers and your movements. The rest of the course is finishing touches.

30' What a model is · installing a library · loading the recognizer · the shape of the result
15' The teacher loads the model live and prints the first real points in the console
60' Exercises 8.1 → 8.5: from the first console.log to the full hand on screen
15' Round of the class: everyone moves their hand · quick check · homework

Theory

What a model is, in two paragraphs

Until now every line of your program told the computer exactly what to do: «draw a circle here», «multiply this by that». Finding a hand in an image cannot be done that way: nobody knows how to write the rules to recognize a hand — in any light, any skin, any ring, any background. People tried for twenty years.

A model is a different approach: instead of rules, examples. Someone — Google, here — collected tens of thousands of photographs of hands and marked by hand, on each one, where the 21 joints are. Then a training program adjusted millions of internal numbers, a little at a time, until the system started guessing those 21 points even on photographs it had never seen. The result of that work is a file: gesture_recognizer.task, eight megabytes of numbers. It contains no photographs, no hands: only the numbers needed to do that calculation.

Three practical things follow from this, worth having clear:

Installing a library, and pinning its version

npm install @mediapipe/tasks-vision@1.0.1

This command downloads Google's library into node_modules and adds a line to package.json. That @1.0.1 at the end is the important part of the lesson.

"dependencies": {
  "@mediapipe/tasks-vision": "1.0.1"
}

Written like this — without ^, without ~, without @latest — it means «exactly this version». Anyone who installs the project two years from now will get the very same files, and the project will work as it does today.

Why «the latest version» is a terrible idea With @latest your project changes by itself, on a day you did not choose, while you are not looking. One morning you open it and it no longer works, and you touched nothing: it is one of the most frustrating experiences in the trade. In the real hand project three things are pinned precisely, and they must be aligned with each other: the library, the wasm folder that goes with it and the .task model file. Google's official documentation uses @latest in its examples. That is fine for an example; not for something that has to keep working. Do not confuse it with the lesson 6 command, npm create vite@latest: there @latest is the version of the tool that creates the folder once, not a dependency that stays in the project.

Loading the recognizer

You need two addresses: the runtime (the calculation engine, written in WebAssembly) and the model. Loading takes time — about twenty megabytes the first time — so it is all async, like last lesson's webcam.

// src/tracker.ts
import { FilesetResolver, GestureRecognizer } from "@mediapipe/tasks-vision";

const WASM = "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@1.0.1/wasm";
const MODELLO =
  "https://storage.googleapis.com/mediapipe-models/gesture_recognizer/gesture_recognizer/float16/1/gesture_recognizer.task";

export async function creaRiconoscitore(): Promise<GestureRecognizer> {
  const vision = await FilesetResolver.forVisionTasks(WASM);
  return GestureRecognizer.createFromOptions(vision, {
    baseOptions: { modelAssetPath: MODELLO },
    runningMode: "VIDEO",
    numHands: 1,
  });
}

Line by line, the three things that matter:

In the real project the files do not come from the internet The hand site keeps runtime and model on its own domain, copied during the site build. Two reasons: this way the page contacts nobody else — and the promise «the images do not leave the browser» becomes verifiable instead of just stated — and the files stay those of the pinned version, not whatever a public CDN serves today. For today, in class, the CDN is perfectly fine: it is one less address to configure.

Asking for the points, at every frame

const risultato = riconoscitore.recognizeForVideo(video, performance.now());

This line goes inside the loop from lesson 7. Two details, both sources of mistakes on the first day:

  1. It is not await. In VIDEO mode the calculation happens immediately and returns the result immediately. It is also why, if the computer is slow, the fps collapse: drawing waits for recognition.
  2. The time must always increase. The second parameter is the moment of the frame, in milliseconds. If you pass a time equal to or less than the previous one, the library stops with an error. With performance.now() it does not happen, because a clock moves forward — but you need to know the rule, because it causes an error that otherwise looks incomprehensible.

There is also a trick that saves half the work. The screen repaints 60 times a second, but the webcam sends 30 frames a second: half of the loop's rounds see the same image as before. Analysing it twice is wasted work:

let ultimoIstante = -1;

function ciclo(): void {
  idCiclo = requestAnimationFrame(ciclo);

  // readyState >= 2 means "a frame is ready"
  if (video.readyState >= 2 && video.currentTime !== ultimoIstante) {
    ultimoIstante = video.currentTime;
    const risultato = riconoscitore.recognizeForVideo(video, performance.now());
    disegnaTutto(risultato);
  }
}

What the result looks like

risultato.landmarks is an array of hands; each hand is an array of 21 points; each point has x, y, z. Three levels, and you have to keep them apart:

risultato.landmarks            // [ [21 points], [21 points] ]  ← 0, 1 or 2 hands
risultato.landmarks[0]         // the 21 points of the first hand
risultato.landmarks[0][8]      // the index fingertip
risultato.landmarks[0][8].x    // 0.42 — a number between 0 and 1
FieldMeaningRange
xhorizontal position on the image0 = left edge … 1 = right edge
yvertical position0 = top … 1 = bottom
zdepth relative to the wristsame scale as x; negative = closer to the camera

They are the same 0-to-1 coordinates as the fake hand in lesson 5, and that is no accident: the course was built so that today you have to change just one line. They are numbers relative to the image, not pixels: if tomorrow you change the size of the canvas, the points keep working.

And the point numbers are always the same, for everyone: 0 the wrist, 4 the thumb tip, 8 the index fingertip, 12 the middle finger, 16 the ring finger, 20 the little finger. They are the numbers you already used in the bone table in lesson 6.

When the hand is not there

If you take your hand out of the frame, landmarks is an empty array. Writing risultato.landmarks[0][0].x without checking blows the program up. The ?. from lesson 4 is for exactly this:

const mano = risultato.landmarks[0];
if (!mano) return;          // no hand in this frame: skip drawing

// or, in one line:
const polso = risultato.landmarks[0]?.[0];
console.log(polso?.x ?? "nessuna mano");

And it is the practical answer to what was said in lesson 6: types disappear before execution, so they do not protect you while the program runs. You have to write the check yourself.

Teacher demo (15')

  1. npm install @mediapipe/tasks-vision@1.0.1 on the projector; open package.json and show the new line, without ^.
  2. Write creaRiconoscitore and call it at start-up. Show the Network panel of the developer tools: you can see the model's megabytes coming down. Then reload: the second time it is instant, because the browser has them in cache.
  3. In the loop, console.log(risultato.landmarks.length): move your hand in and out of the frame and comment on the 1s and 0s scrolling by.
  4. Print risultato.landmarks[0]?.[8] and slowly move your index finger: have the class read the x going from 0.1 to 0.9. It is the moment the numbers become your own finger: worth letting it breathe for half a minute.
  5. Make a mistake on purpose: pass 0 instead of performance.now() and read the error about increasing timestamps.

Lab (60')

Preparing the class for the download Twenty machines downloading twenty megabytes at once can bring the school network to its knees. Two countermeasures: have exercise 8.1 started in batches of five machines, or — much better — run the project once on every workstation before the lesson, so the files stay in the cache. If the network still does not cooperate, the model can be handed out on a USB stick and put in public/, with modelAssetPath: "/gesture_recognizer.task": it is exactly the solution of the real project.

Exercise 8.1 — The model speaks basic

Install the library at version 1.0.1, create src/tracker.ts with creaRiconoscitore() and, in the frame loop, print to the console how many hands it sees and how many points the first one has. Move your hand in and out of the frame and watch the numbers change.

Solution
// src/main.ts — the new parts
import { creaRiconoscitore } from "./tracker";
import type { GestureRecognizer } from "@mediapipe/tasks-vision";

let riconoscitore: GestureRecognizer | null = null;
let ultimoIstante = -1;

async function avvia(): Promise<void> {
  messaggio.textContent = "Carico il modello…";
  riconoscitore = await creaRiconoscitore();   // model first: it asks the user nothing
  await accendiCamera();                        // then the camera: permission is asked last
  ciclo();
}

function ciclo(): void {
  idCiclo = requestAnimationFrame(ciclo);
  if (!riconoscitore) return;
  if (video.readyState < 2 || video.currentTime === ultimoIstante) return;
  ultimoIstante = video.currentTime;

  const risultato = riconoscitore.recognizeForVideo(video, performance.now());
  console.log("mani:", risultato.landmarks.length, "punti:", risultato.landmarks[0]?.length ?? 0);
}

Order matters: model first, then camera. This way the permission request arrives when you are nearly ready to go, instead of keeping the camera on for ten seconds while the model downloads. It is the same order as the real project. The number of points, when the hand is there, is always 21.

Exercise 8.2 — The real points on screen basic

Enough console. Draw the 21 points on the canvas, on top of the webcam image, reusing aPixel from lesson 6 and disegnaPunti from lesson 5. Clear the canvas at every frame, otherwise a trail is left.

Solution
import { disegnaPunti, type Punto } from "./disegna";
import { aPixel } from "./punti";

function mostra(punti: Punto[]): void {
  ctx.save();
  ctx.translate(tela.width, 0);
  ctx.scale(-1, 1);
  ctx.drawImage(video, 0, 0, tela.width, tela.height);
  ctx.restore();

  const inPixel = punti.map((p) => aPixel(p, tela.width, tela.height));
  disegnaPunti(ctx, inPixel);
}

// in the loop, after getting the result:
const mano = risultato.landmarks[0];
if (mano) mostra(mano);
else ctx.clearRect(0, 0, tela.width, tela.height);

The dots will be on the wrong side, and that is as it should be: you mirrored the image with scale(-1, 1) but not the points. Two ways: either you do not mirror the video, or you mirror the coordinates too with x: 1 - p.x. The second is the real project's way, and it is the same idea as the 0.5 - x you will meet in lesson 10.

Exercise 8.3 — The bones intermediate

Add the 21 bones with disegnaOssa, the function you already wrote in lesson 6. Draw the bones first and then the points: the joints must sit on top of the lines, not underneath.

Solution
import { disegnaOssa, disegnaPunti } from "./disegna";

const inPixel = punti.map((p) => aPixel(p, tela.width, tela.height));
disegnaOssa(ctx, inPixel);    // lines first
disegnaPunti(ctx, inPixel);   // then the dots, on top

And here is the moment of the lesson: the function has not changed by a single comma. In lesson 6 it received twenty made-up points; now it receives your hand. This is the real gain of having separated «how to draw» from «what to draw» — and for the rest of the course it will keep paying off.

Exercise 8.4 — What gesture am I making intermediate

The model you are using does not only give points: it also recognizes eight gestures. Show the name of the current gesture, translated into Italian, in an element of the page. When it recognizes nothing the name is "None", and it must not be shown.

Solution
const etichettaGesto = document.getElementById("gesto") as HTMLParagraphElement;

const GESTI: Record<string, string> = {
  Closed_Fist: "✊ Pugno",
  Open_Palm: "🖐️ Mano aperta",
  Pointing_Up: "☝️ Indice in su",
  Thumb_Down: "👎 Pollice giù",
  Thumb_Up: "👍 Pollice su",
  Victory: "✌️ Vittoria",
  ILoveYou: "🤟 Ti voglio bene",
};

const gesto = risultato.gestures[0]?.[0];
const nome = gesto?.categoryName;
etichettaGesto.textContent =
  nome && nome !== "None" ? GESTI[nome] ?? nome : "nessun gesto riconosciuto";

Two ?. in a row because there are two levels: gestures has a list for each hand, and each hand has a list of hypotheses ordered from the most likely — [0] is the good one. If you like: gesto?.score is the confidence, between 0 and 1. Showing it as a percentage makes it clearer than any explanation that the model estimates, it does not know.

Exercise 8.5 — Two hands, two colours challenge

Set numHands to 2 and draw the first hand in light blue and the second in pink. You need a loop over risultato.landmarks and a colour passed to the drawing functions.

Solution
// tracker.ts: numHands: 2

// disegna.ts: both functions take one more parameter, a colour
export function disegnaOssa(ctx: CanvasRenderingContext2D, punti: Punto[], colore: string): void {
  ctx.strokeStyle = colore;
  ctx.lineWidth = 3;
  for (const osso of OSSA) {
    const a = punti[osso[0]];
    const b = punti[osso[1]];
    ctx.beginPath();
    ctx.moveTo(a.x, a.y);
    ctx.lineTo(b.x, b.y);
    ctx.stroke();
  }
}

// main.ts
const COLORI = ["#38bdf8", "#f472b6"];

risultato.landmarks.forEach((mano, i) => {
  const inPixel = mano.map((p) => aPixel(p, tela.width, tela.height));
  disegnaOssa(ctx, inPixel, COLORI[i]);
  disegnaPunti(ctx, inPixel, "#ffffff");
});

Watch out for something you will discover by yourself: the order of the hands is not stable. From one frame to the next your right hand can become [1], and the colours swap. To really fix it you need risultato.handedness[i]?.[0]?.categoryName, which says «Left» or «Right» — with a catch: MediaPipe reasons as if the image were already mirrored, so its «Left» is your right. In the real project there is a three-line function that does nothing but flip that label.

Stop and look At this point the project works: the hand on screen follows yours, finger by finger, thirty times a second. What is left — damping the jitter, the buttons, the three dimensions — is finishing touches on something that already exists. It is worth stopping the lab for five minutes and walking round the desks to look at each other's screens: it is the moment the previous seven lessons were made for.

Typical mistakes in class

Quick check (5 questions)

Questions and answers
  1. Where does the model run and where do the webcam images go? → It runs in the browser, on the user's computer; the images are not sent anywhere.
  2. Why pin the version with @1.0.1? → So that the project does not change by itself when a new version comes out, and so that library, runtime and model stay aligned.
  3. What shape does risultato.landmarks have? → An array of hands; each hand is an array of 21 points with x, y, z between 0 and 1.
  4. What happens if you pass the same moment twice to recognizeForVideo? → An error: in VIDEO mode timestamps must always increase.
  5. How do you go from x = 0.42 to a pixel on a canvas 640 wide? → You multiply: 0.42 × 640 ≈ 269. It is aPixel, written in lesson 3.

Homework (30-40 minutes)

Make what you have presentable: a «Loading the model…» message while waiting, «No hand in view» when the hand is not there, the gesture name in large type, and the fps counter from lesson 7 left on in a corner. Then write down on a sheet of paper how many fps your computer gets with recognition on and under which conditions the hand gets lost (against the light? hand edge-on? too close?). Bring the numbers next time: lesson 9 starts from there.

Lesson 9

Polishing: classes, state, gestures, jitter

The project already works. Today we go from «it works» to «you can hand it to someone»: the hand stops shaking, the buttons always know what state they are in, and the ghost hands stop flickering.

30' Classes · interface state · why the points jitter and how to damp it · false positives
15' The teacher writes MediaMobile live and shows the hand before and after, with the filter on and off
60' Exercises 9.1 → 9.6
15' Comparing the jitter numbers · quick check · homework (preparing the presentation)

Theory

Classes: an object that carries its own functions

In lesson 4 you learned objects: boxes with values inside. A class is the same idea, with one thing more: inside it there are also the functions that work on those values.

Look at today's problem. To damp the jitter you need to remember the last five values of a coordinate. With what you know so far:

let ultimiValori: number[] = [];

function aggiungi(valore: number): void {
  ultimiValori.push(valore);
  if (ultimiValori.length > 5) ultimiValori.shift();
}

It works… for one coordinate. But the coordinates are 21 points × 3 axes = 63, and each one needs its own memory. With loose variables you would need 63 arrays and 63 functions. With a class, you write one and create 63.

class MediaMobile {
  private valori: number[] = [];      // field: the memory of THIS object
  private readonly quanti: number;    // how many values to keep

  constructor(quanti = 5) {           // runs when the object is born
    this.quanti = quanti;
  }

  aggiungi(valore: number): void {    // method: a function of the object
    this.valori.push(valore);
    if (this.valori.length > this.quanti) this.valori.shift();
  }

  valore(): number {
    let somma = 0;
    for (const v of this.valori) somma += v;
    return somma / this.valori.length;
  }
}
const x = new MediaMobile(5);
const y = new MediaMobile(5);   // separate memory: two objects, two histories

x.aggiungi(0.42);
x.aggiungi(0.44);
console.log(x.valore());        // 0.43

That is all the vocabulary, and it is five words:

this means «this object here, not the other 62». And private means «not to be touched from outside»: whoever uses a MediaMobile calls aggiungi and valore, and does not even need to know there is an array inside. If tomorrow you change how the average is computed, no other file notices.

When you need a class and when you do not A class is needed when a piece of the program has to remember something between one call and the next, and there are several independent copies of it: the filter of each coordinate, the recognizer, the 3D scene. For a calculation that remembers nothing — aPixel, distanza — a function is simpler and perfectly fine. The real project has four classes in all: no more.

The interface state

Look at your lesson 8 page and count: the «Accendi» (turn on) button, the «Spegni» (turn off) one, the message, the error box, the fps counter. Five things that must be consistent with each other. If you switch them on and off one by one, scattered through the code, sooner or later you reach an absurd screen — «Camera off» written while the light is on, the «Starting…» button that stays there forever after an error.

The solution is to say first of all which states exist. It is a union type, like the ones in lesson 2:

type Stato = "spenta" | "avvio" | "accesa" | "errore";

Four words, and no others: if you write "acesa", the compiler stops you straight away. Then a single function decides how each state looks:

function setState(stato: Stato, messaggio: string): void {
  etichettaStato.textContent = messaggio;
  bottoneAccendi.disabled = stato === "avvio" || stato === "accesa";
  bottoneSpegni.disabled = stato !== "accesa";
  riquadroErrore.hidden = stato !== "errore";
  document.body.dataset.stato = stato;   // useful to the CSS and to whoever reads the page
}

From then on, in all the rest of the program, no button is touched any more: you call setState("avvio", "Carico il modello…") and that is it. The advantage is not writing fewer lines: it is that impossible states become impossible, instead of lying in wait.

Why the points jitter

Hold your hand still, really still. The points on screen keep dancing by a few pixels. It is not your fault and it is not a bug: the model analyses each frame on its own, and each frame is slightly different from the previous one — sensor noise, a little change in light. Small differences in, small differences out. Thirty times a second, and the eye reads them as a vibration.

The simplest idea to remove it is one you already know from school marks: instead of the current value, the average of the last five. The little jumps up and down cancel out and the hand calms down.

// no filter                         average over 5
// 0.412 0.418 0.409 0.421 0.414     0.415 0.415 0.416 0.415 0.416

But there is a price, and it must be well understood because it is the heart of the lesson. If you move your hand suddenly, the average still contains four old values: the hand on screen arrives late, as if it were crawling behind yours. The stronger the filter, the slower it is. With 5 values the delay is barely noticeable; with 20 the hand seems to be in treacle. Try both: it is exercise 9.2.

How the real project does it (and why we do not write it today) The site uses the One Euro filter, which solves the trade-off instead of putting up with it: it measures how fast the point is moving and changes strength by itself — it filters a lot when the hand is still (no vibration), almost nothing when the hand is racing (no delay). It is about fifteen lines of maths, hand-written in the file filter.ts. Today you write the moving average because it lets you understand the problem with what you already know; if you want to look at the grown-up version you will find the file, commented in Italian, in the site's code.

Confirmation over several frames

There is a second, sneakier defect. Every now and then — one frame in fifty, when the light changes or something passes through the frame — the model «sees» a hand that does not exist. It lasts a single frame, but on screen it is an annoying flash.

The remedy costs three lines: do not trust the first time. You count for how many consecutive frames the hand has been present, and draw only from three onwards.

let consecutivi = 0;
const CONFERME = 3;

if (risultato.landmarks.length > 0) consecutivi++;
else consecutivi = 0;

if (consecutivi >= CONFERME) {
  // only now is the hand drawn
}

Cost: a tenth of a second of delay when it appears, which nobody notices. Gain: the ghost hands disappear. It is the same reasoning as the filter — you pay a little delay to buy stability — and it is one of the most common choices in the whole trade.

Teacher demo (15')

  1. Hold your hand still in front of the webcam and zoom in on the screen: the whole class must see the points dancing by themselves. The problem before the solution.
  2. Write MediaMobile live, calmly: it is the first class they see. Point out that this.valori of two different objects are two different arrays.
  3. Apply it and look at the still hand again: the points calm down. Then move your hand suddenly and show the delay. Do not hide it, show it: it is the lesson.
  4. Raise the window to 20 values: the hand becomes jelly. Bring it back to 5. Ask the class which they would prefer, and why there is no answer that is right for every use.
  5. Show the finished setState and try the sequence turn on → error → turn off → turn on again, to show that the buttons never get tangled.

Lab (60')

Exercise 9.1 — Your first class basic

Write the class MediaMobile in src/filtro.ts, with aggiungi(valore) and valore(). Test it without a webcam, with numbers written by hand, and check in the console that it does what you think.

Solution
// src/filtro.ts
export class MediaMobile {
  private valori: number[] = [];
  private readonly quanti: number;

  constructor(quanti = 5) {
    this.quanti = quanti;
  }

  aggiungi(valore: number): void {
    this.valori.push(valore);
    if (this.valori.length > this.quanti) this.valori.shift();
  }

  /** The average of the values in memory. If there are none, it returns 0. */
  valore(): number {
    if (this.valori.length === 0) return 0;
    let somma = 0;
    for (const v of this.valori) somma += v;
    return somma / this.valori.length;
  }
}
// test
const m = new MediaMobile(3);
m.aggiungi(10);
console.log(m.valore());   // 10
m.aggiungi(20);
console.log(m.valore());   // 15
m.aggiungi(30);
m.aggiungi(60);            // the 10 leaves the window
console.log(m.valore());   // 36.66…

shift() removes the first element of the array: that is how the window «slides». The empty-array check avoids a division by zero, which would give NaN — and a NaN in the coordinates makes the hand disappear without any error message.

Exercise 9.2 — Damping the real hand intermediate

Write a second class, FiltroMano, that keeps 63 moving averages (21 points × 3 coordinates) and has a method smorza(punti) that returns the smoothed points. Apply it before drawing. Then try with windows of 2, 5 and 20 values and see the difference with your own eyes.

Solution
// src/filtro.ts
import type { Punto } from "./disegna";

export class FiltroMano {
  private filtri: MediaMobile[] = [];

  constructor(quanti = 5) {
    // 21 points × 3 coordinates: one filter for each, with its own memory
    for (let i = 0; i < 21 * 3; i++) this.filtri.push(new MediaMobile(quanti));
  }

  smorza(punti: Punto[]): Punto[] {
    const risultato: Punto[] = [];
    for (let i = 0; i < punti.length; i++) {
      this.filtri[i * 3].aggiungi(punti[i].x);
      this.filtri[i * 3 + 1].aggiungi(punti[i].y);
      this.filtri[i * 3 + 2].aggiungi(punti[i].z);
      risultato.push({
        x: this.filtri[i * 3].valore(),
        y: this.filtri[i * 3 + 1].valore(),
        z: this.filtri[i * 3 + 2].valore(),
      });
    }
    return risultato;
  }
}
// main.ts
import { FiltroMano } from "./filtro";

let filtro: FiltroMano | null = null;

// when the hand appears
filtro ??= new FiltroMano(5);
const addolciti = filtro.smorza(mano);
// …and from here on you draw `addolciti` instead of `mano`

// when the hand disappears: throw the filter away
filtro = null;

The last line is more important than it looks. If you keep the filter when the hand leaves the frame, when it reappears the average still contains the old positions: the hand comes crawling from where it was before. You throw the filter away and create a new one. The real project has exactly this line, with the same comment.

Exercise 9.3 — A single place in charge intermediate

Define the Stato type with the four words and write setState. Then remove from the rest of the code every line that directly touches a button or a message: only calls to setState must remain.

Solution
type Stato = "spenta" | "avvio" | "accesa" | "errore";

function setState(stato: Stato, messaggio: string): void {
  etichettaStato.textContent = messaggio;
  bottoneAccendi.disabled = stato === "avvio" || stato === "accesa";
  bottoneSpegni.disabled = stato !== "accesa";
  riquadroErrore.hidden = stato !== "errore";
  document.body.dataset.stato = stato;
}

async function avvia(): Promise<void> {
  setState("avvio", "Carico il modello…");
  try {
    riconoscitore ??= await creaRiconoscitore();
    await accendiCamera();
    setState("accesa", "● Camera accesa");
    ciclo();
  } catch (e) {
    spegni();
    riquadroErrore.textContent = messaggioErrore(e);
    setState("errore", "Camera spenta");
  }
}

function spegni(): void {
  cancelAnimationFrame(idCiclo);
  spegniCamera(video, flusso);
  flusso = null;
  filtro = null;
  setState("spenta", "Camera spenta");
}

Note the ??= on riconoscitore: the second time you turn on, the model is already in memory and is not reloaded. Note also that spegni() leaves everything clean — loop stopped, camera released, filter thrown away: it is the golden rule of shutting down.

Exercise 9.4 — Say so, if you see nothing basic

When no hand is in view, the page must say so: «No hand in view». When the hand comes back, the message disappears. It looks like a detail: it is the difference between a broken program and a program that is waiting.

Solution
const mano = risultato.landmarks[0];

if (mano) {
  etichettaMani.textContent = "";
  // …filter and draw…
} else {
  etichettaMani.textContent = "Nessuna mano inquadrata";
  ctx.clearRect(0, 0, tela.width, tela.height);
  filtro = null;
}

If you finish early: also show how many hands it sees, and with numHands: 2 write «Right» and «Left» using risultato.handedness, remembering that it must be flipped (lesson 8).

Exercise 9.5 — No ghost hands intermediate

Add confirmation over three frames: the hand is drawn only if it has been seen three times in a row. Watch out for a detail: the filter must be fed during the three waiting frames too, otherwise when the hand appears you see it settling in front of the user's eyes.

Solution
const CONFERME = 3;
let consecutivi = 0;

if (mano) {
  consecutivi++;
  filtro ??= new FiltroMano(5);
  const addolciti = filtro.smorza(mano);   // always filter…
  if (consecutivi >= CONFERME) {
    disegnaOssa(ctx, addolciti.map((p) => aPixel(p, tela.width, tela.height)), "#38bdf8");
    disegnaPunti(ctx, addolciti.map((p) => aPixel(p, tela.width, tela.height)));
  }
} else {
  consecutivi = 0;
  filtro = null;
}

The counter resets at the first frame without a hand: a real hand stays for dozens of frames, a ghost does not, and the threshold of three separates them. To test it: quickly pass an object in front of the camera and see whether a flash appears.

Exercise 9.6 — Measure the jitter challenge

Enough impressions: measure. Write a function that computes how far on average the points move from one frame to the next, and print it every second. Then hold your hand as still as possible for ten seconds and write down the number without the filter and with the filter. Bring the two numbers to the wrap-up.

Solution
let precedenti: Punto[] | null = null;
let sommaMovimento = 0;
let misure = 0;

/** Average displacement of the 21 points relative to the previous frame (in 0…1 units). */
function misuraTremolio(punti: Punto[]): void {
  if (precedenti && precedenti.length === punti.length) {
    let somma = 0;
    for (let i = 0; i < punti.length; i++) {
      const dx = punti[i].x - precedenti[i].x;
      const dy = punti[i].y - precedenti[i].y;
      somma += Math.sqrt(dx * dx + dy * dy);
    }
    sommaMovimento += somma / punti.length;
    misure++;
  }
  // copy of the values, not of the references: we need them as they were now
  precedenti = punti.map((p) => ({ x: p.x, y: p.y, z: p.z }));
}

// once a second, together with the fps:
if (misure > 0) {
  etichettaTremolio.textContent = `tremolio: ${(sommaMovimento / misure).toFixed(5)}`;
  sommaMovimento = 0;
  misure = 0;
}

With the hand still, typical values: about 0.0015 without the filter and 0.0005 with the average over 5 — three times less. Then repeat the measurement moving your hand back and forth: there the number must be large in both cases, because that is not jitter, it is movement. A filter that also suppressed that would be a filter that eats your gesture. This exercise is the right way to judge any filter: two measurements, not two opinions.

Typical mistakes in class

Quick check (5 questions)

Questions and answers
  1. What is private for? → To prevent a field from being used outside the class: whoever uses it sees only the methods.
  2. Why are 63 moving averages needed and not one? → Because every coordinate of every point has its own history: 21 points × 3 axes.
  3. What is the flaw of the moving average? → The delay: the more values it keeps, the later the hand arrives on fast movements.
  4. What does the One Euro filter do in addition? → It changes strength by itself according to the speed of the point: it filters a lot when still, little when moving.
  5. Why wait three frames before drawing a hand? → So as not to show the ghost hands the model sees for a single frame.

Homework (40 minutes)

Two things. One: polish the project however you like — colours, point sizes, a title, the gesture shown in large type, maybe the hand's colour changing with the recognized gesture. Two: prepare the presentation for lesson 10. Three or four minutes, in which you must say: what your project does, the part of the code you are most pleased with, the thing that cost you the most time and how you solved it. No slides: you show the project running.

Lesson 10

The third dimension, publishing, presentations

Last lesson, and you can breathe: the hand leaves the plane and becomes a figure in space, we look at what it really means to put a site online, and then everyone presents. Twenty hours ago none of you had written a line of TypeScript.

15' Three.js in ten minutes · going online · privacy and CSP
10' The teacher opens the starter file, connects a single point and makes it move
80' Exercises 10.1 → 10.5 (40') and project presentations (40')
15' Course wrap-up · ideas for going further · goodbyes

Theory

Three.js: scene, camera, lights, mesh

The browser can draw in three dimensions, but the interface it offers (WebGL) is ferociously awkward: matrices, programs for the graphics card, buffers of numbers. Three.js is the library that steps in between and talks the way people talk in a theatre. Four words and you have understood everything you need today:

const sfera = new THREE.Mesh(
  new THREE.SphereGeometry(1, 20, 14),                    // shape
  new THREE.MeshStandardMaterial({ color: 0x38bdf8 }),    // appearance
);
sfera.position.set(0, 0, 0);
scena.add(sfera);

And then, at every frame, a single line: renderer.render(scena, camera). Three.js looks at where the objects are at that moment and draws the picture.

The idea that makes the difference Objects are created only once, at the start; at every frame they are moved. Creating and throwing away 21 spheres sixty times a second would keep the garbage collector working nonstop, and the hand would stutter. It applies to the 3D scene as to anything that runs at frame rate.

From 21 points to 21 spheres

Here is all the maths of the lesson, and it is three lines. The points coming from the webcam live in a world like this: x and y between 0 and 1, origin in the top-left corner, y growing downwards as in all images. The 3D world instead has its origin in the centre and the y growing upwards, as at school.

function nelloSpazio(p: Punto) {
  const larghezza = 6;    // how wide the video rectangle is in the 3D world
  const altezza = 4.5;
  return {
    x: (0.5 - p.x) * larghezza,       // centre AND mirror
    y: (0.5 - p.y) * altezza,         // centre AND flip
    z: -p.z * larghezza * 1.6,        // depth, a little exaggerated
  };
}

Look at what that 0.5 - x does, because it does two jobs in one subtraction:

The z has its sign flipped (MediaPipe's depth is negative towards the camera, in 3D the observer is on the positive side) and is multiplied by 1.6: the depth estimated from a single camera is small, and without some gain bringing your hand closer would hardly be noticed. The value is chosen by eye — any higher, and the hand starts to look deformed.

Putting a site online

So far you have worked on localhost, which is just your computer. «Putting online» means three things, and none of them is magic:

  1. Building: npm run build. The command checks the types, translates the TypeScript into JavaScript, joins the files, shortens them and writes everything into the dist/ folder. Inside there is a normal site — pages, scripts, images — that no longer needs either Node or Vite.
  2. A computer that is always on to serve those files to whoever asks for them, and a name (a domain) pointing to that computer.
  3. HTTPS, and here there is no choice: without a certificate, as you saw in lesson 7, the webcam does not turn on. Your project online on http:// would be a dead page.

The site you are looking at, for instance, runs on a small rented computer: one program serves the dist/ folder, another (Caddy) takes care of the domain and gets the HTTPS certificate by itself. Before publishing, a script checks that nothing strange has appeared in the built files and that all the pages are there; if a check fails, publishing stops and yesterday's version, which worked, stays online.

Nobody has to publish today Putting a site online requires an address, often a credit card and a few choices that should not be rushed on the last day of the course. Exercise 10.4 stops at npm run build and at looking at what is inside dist/: that is the part that counts. If you want to go further at home, you will find the simplest routes in the final list.

A few closing words: privacy and CSP

Your project handles images of people. It is the most sensitive data there is, and you handled it the right way almost without noticing: the images do not leave the browser, because there is nowhere to send them; the camera turns on only on click; it turns off when you switch tab. It is not by chance, it is a design choice — and the rule underneath it applies well beyond this course: the data you do not collect is the only data you cannot lose.

There is also a way not to just promise it. A site can declare to the browser what it is allowed to do — it is the Content Security Policy. The hand site declares, among other things, that the page may talk only to its own domain. From then on it is no longer the programmer who guarantees that the images are not sent to someone: it is the browser that refuses to send them, even if the code tried. The whole difference between a promise and a guarantee lies there.

In the real project this rule had an interesting side effect: it blocked a call nobody had written. It was the recognition library, which every minute sent some usage data to its servers — no images, but still a call to a stranger, and with no way to switch it off. It has been disabled. Without the CSP, nobody would ever have noticed.

Teacher demo (10')

  1. Open the starter file src/scena3d.ts (you will find it below, in the teacher box) and show that everything is already there: scene, camera, lights, 21 spheres lined up in a row.
  2. Connect a single point, the wrist, and move it with your hand in front of the webcam. A sphere following a wrist is already a striking effect, and it explains the mechanism better than twenty.
  3. Then connect all 21 points: the hand appears. Get the class looking at the screen and bring your hand closer to the camera to show the depth.
  4. Make a mistake on purpose: remove the 0.5 - from the y. The hand turns upside down. Put it back and explain the subtraction while everyone watches the effect.

Lab (80')

Plan for the last lesson 15' theory · 10' demo · 40' 3D lab · 40' presentations (bring it to 50' if the class is large, taking ten minutes from the lab) · 15' wrap-up and ideas for going further. The starter file must be handed out before starting (USB stick or shared folder): it is the piece that guarantees that even those who fell behind see their own hand in three dimensions today. For the presentations: four minutes each with the timer visible, pairs count as one. If someone does not feel up to speaking in front of the class, they can show the project to the teacher alone while the others try the exercises: the goal is for everyone to tell someone about their own work, not to pass a public-speaking test.

Exercise 10.1 — The starter file basic

Install Three.js, copy the starter file src/scena3d.ts into the project and add to the page the full-screen canvas it needs. Check that you can see the row of 21 spheres: it means that scene, lights and camera work.

npm install three@0.186.0
npm install --save-dev @types/three@0.186.0
The starter file (handed out by the teacher)
// src/scena3d.ts — ready-made 3D scene: you only need to call mostraMano() and disegna()
import * as THREE from "three";
import type { Punto } from "./disegna";

const tela3d = document.getElementById("scena") as HTMLCanvasElement;

const scena = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 100);
camera.position.set(0, 0, 10);

const renderer = new THREE.WebGLRenderer({ canvas: tela3d, antialias: true, alpha: true });

function adatta(): void {
  const w = tela3d.clientWidth || window.innerWidth;
  const h = tela3d.clientHeight || window.innerHeight;
  renderer.setSize(w, h, false);
  camera.aspect = w / h;
  camera.updateProjectionMatrix();   // required after changing the proportions
}
window.addEventListener("resize", adatta);
adatta();

// Lights: an ambient one so nothing stays black, a directional one that gives volume.
scena.add(new THREE.HemisphereLight(0xdbeafe, 0x1e1b4b, 1.4));
const principale = new THREE.DirectionalLight(0xffffff, 2.2);
principale.position.set(3, 5, 8);
scena.add(principale);

// 21 spheres, created ONCE only: at every frame they are just moved.
const geometria = new THREE.SphereGeometry(1, 20, 14);
const materiale = new THREE.MeshStandardMaterial({ color: 0x38bdf8, roughness: 0.35 });
const sfere: THREE.Mesh[] = [];
for (let i = 0; i < 21; i++) {
  const s = new THREE.Mesh(geometria, materiale);
  s.position.set((i - 10) * 0.35, 0, 0);   // in a row, until a real hand arrives
  s.scale.setScalar(0.12);
  scena.add(s);
  sfere.push(s);
}

const LARGHEZZA = 6;
const ALTEZZA = 4.5;

/** Moves the 21 spheres onto the 21 points of the hand (MediaPipe 0…1 coordinates). */
export function mostraMano(punti: Punto[]): void {
  for (let i = 0; i < 21; i++) {
    const p = punti[i];
    sfere[i].position.set(
      (0.5 - p.x) * LARGHEZZA,
      (0.5 - p.y) * ALTEZZA,
      -p.z * LARGHEZZA * 1.6,
    );
  }
  mostra(true);
}

/** Shows or hides the whole hand. */
export function mostra(visibile: boolean): void {
  for (const s of sfere) s.visible = visibile;
}

/** One frame. To be called inside the loop in main.ts. */
export function disegna(): void {
  renderer.render(scena, camera);
}
<!-- index.html: the scene canvas, behind everything else -->
<canvas id="scena"></canvas>

In the stylesheet give it full width and height (for example position: fixed; inset: 0; width: 100%; height: 100%;): a canvas without dimensions stays at 300×150 pixels and it looks as if nothing works.

Exercise 10.2 — Your hand in space basic

Connect the real points: in the frame loop, after the lesson 9 filter, call mostraMano() with the normalized points — not the ones in pixels — and disegna() at every round. When the hand is not there, hide it.

Solution
// main.ts
import { disegna, mostra, mostraMano } from "./scena3d";

function ciclo(): void {
  idCiclo = requestAnimationFrame(ciclo);

  if (riconoscitore && video.readyState >= 2 && video.currentTime !== ultimoIstante) {
    ultimoIstante = video.currentTime;
    const risultato = riconoscitore.recognizeForVideo(video, performance.now());
    const mano = risultato.landmarks[0];

    if (mano) {
      filtro ??= new FiltroMano(5);
      mostraMano(filtro.smorza(mano));    // 0…1 points: the scene does the conversion
    } else {
      filtro = null;
      mostra(false);
    }
  }

  disegna();   // outside the if: the scene is redrawn at every frame
}

Two mistakes to avoid: passing points already converted to pixels (the hand ends up off screen, hundreds of units away) and calling disegna() inside the if (the scene only updates when a new frame arrives, and it shows).

Exercise 10.3 — Make it yours intermediate

Three touches, pick at least two: make the fingertips (4, 8, 12, 16, 20) bigger than the rest; change the hand's colour when the recognized gesture is «thumb up»; make the spheres grow when the hand gets closer to the camera.

Solution
// inside mostraMano, in scena3d.ts
const PUNTE = [4, 8, 12, 16, 20];
sfere[i].scale.setScalar(PUNTE.includes(i) ? 0.16 : 0.12);

// colour: the material is shared, so one line changes the whole hand
export function coloraMano(colore: number): void {
  materiale.color.setHex(colore);
}
// in main.ts
coloraMano(nome === "Thumb_Up" ? 0x22c55e : 0x38bdf8);

// size according to distance: wrist closer = more negative z
const vicinanza = 1 - punti[0].z * 2;
sfere[i].scale.setScalar(0.12 * Math.max(0.5, Math.min(2, vicinanza)));

There is a single colour for all the spheres because there is a single material, shared: changing it changes them all together. It is the same property that, in the real project, lets the whole hand fade out by acting on a single value. Math.max and Math.min together keep a number within a range: a trick you will use all the time.

Exercise 10.4 — Build the site intermediate

Run npm run build and look at what appeared in dist/. Then npm run preview, which serves that folder as a real server would, and try your «built» site. Open one of the generated .js files: you will not understand a thing, and that is as it should be.

Solution
npm run build
npm run preview     # opens http://localhost:4173

In dist/ you find index.html and an assets/ folder with oddly named files like index-a1b2c3d4.js. Those letters are the content fingerprint: if you change a line of code, the name changes, and browsers that had the old version in memory are forced to download the new one. The code inside is all on a few very long lines, with variable names cut down to one letter: it is minification, which makes for fewer bytes to download. And it is also the final proof of what was said in lesson 6: of your types, in there, there is no trace left.

Exercise 10.5 — Count the raised fingers challenge

The last challenge of the course, and you do it with what you know from lesson 3. A finger is raised if its tip is higher than the corresponding knuckle — remember that a small y means up. Show how many fingers you see, from 0 to 4 (the thumb is a special case: it moves sideways, not up).

Solution
/** Index, middle, ring, little finger: tip and knuckle of each. */
const DITA = [
  { punta: 8, nocca: 5 },
  { punta: 12, nocca: 9 },
  { punta: 16, nocca: 13 },
  { punta: 20, nocca: 17 },
];

function contaDita(punti: Punto[]): number {
  let alzate = 0;
  for (const dito of DITA) {
    // smaller y = higher up in the image
    if (punti[dito.punta].y < punti[dito.nocca].y) alzate++;
  }
  return alzate;
}

It works with the hand upright and gets confused with the hand tilted, because it compares heights on the image and not relative to the palm. The serious version measures the distances from the wrist (distanza(punta, polso) > distanza(nocca, polso)) and is robust to rotations: it is a great first project to do at home. For the thumb, you look at the horizontal distance from the index knuckle.

The presentations (40')

Three or four minutes each, with the project running. Four questions, always the same, so everyone knows what to prepare:

  1. What does your project do? Show it, do not describe it.
  2. Which piece of code are you most pleased with, and why?
  3. What cost you the most time? How did you get out of it?
  4. If you had two more hours, what would you add?

The third question is the most important of the four. Telling the story of a mistake and how you solved it is what, in this trade, separates those who know how to work from those who only know how to make things work: it will happen in every interview, in every meeting, for the rest of your working life. You might as well start today.

Typical mistakes in class

Quick check (5 questions)

Questions and answers
  1. What are the four ingredients of a Three.js scene? → Scene, camera, lights and mesh (geometry + material).
  2. What are the two jobs of (0.5 - x)? → It moves the origin to the centre and reverses the direction of the axis: on x it is the mirror effect, on y the conversion between «y down» and «y up».
  3. Why are the 21 spheres created outside the loop? → Because creating and destroying them at every frame would keep the garbage collector busy and cause stutters.
  4. What does npm run build produce? → The dist/ folder: a static site already translated into JavaScript, which no longer needs either Node or Vite.
  5. Why must a site with the webcam be on HTTPS? → Because outside a secure context the browser does not give access to the camera: the page would not work.

Homework (no deadline)

The course ends here, the project does not. Eight directions in which it can grow, from the easiest to the most ambitious:

  1. Count the raised fingers properly, measuring distances from the wrist instead of heights (exercise 10.5, serious version).
  2. Draw with your finger: when the index is raised and the others closed, leave a trail on the canvas. Closed fist = erase. It is thirty lines and great fun.
  3. Drive a small game: a paddle that follows your index finger, a target to hit, a score. You already know everything you need.
  4. Two hands together, with different colours and the right/left labels sorted out properly.
  5. Your own gestures: measure the distances between the fingertips and invent your own commands — thumb and index touching, L-shaped hand, three fingers. Remember the confirmation over several frames from lesson 9.
  6. The whole body: the very same loop with 33 points from head to toe. Look at the Body page of this site: only the model changes.
  7. Replace the spheres with a real hand: cylinders for the bones, a semi-transparent palm. That is what the demo on this site does, and the code is commented in Italian line by line in the tutorial.
  8. Publish your site and send the link to someone. It is the simplest thing on the whole list and the most satisfying: npm run build, then any platform that hosts static sites with HTTPS included.

Twenty hours ago you had never written console.log. Now you have an application that turns on a camera, asks an artificial intelligence model where your fingers are, damps the noise in the data, draws a figure in three dimensions sixty times a second and sends your data to nobody. It is not an exercise: it is a real program. Keep going.

How to assess

With beginners, assessing only «the program works» rewards those who had already seen some code and penalizes those who start from zero and are really learning. The weights below take the journey into account, not only the result on the last day.

CriterionWeightHow it is measured
Homework handed in (lessons 1-5) 20% Five short assignments, handed in as Playground links. What counts is handing in and trying, not perfection.
The project works 30% The camera turns on, the hand appears, the points follow the fingers. It is the bare minimum, and everyone gets there if lessons 6-8 went well.
Readable code and well-used types 25% Understandable names, short functions, the work split into at least two files, no any scattered around to silence the compiler, interfaces instead of anonymous objects.
Something of your own 15% Any personal change: colours, a finger counter, a message, a sound, the second hand. It separates those who copied from those who understood.
Final presentation 10% 3-4 minutes: what it does, how you did it, what did not work. Being able to tell the story of a bug is worth as much as having fixed it.
Three questions that expose copy-and-paste Without embarrassing anyone, during the presentation: «what happens if I change this number from 8 to 12?», «where does it say there are 21 points?», «why is there an await here?». Whoever did the work answers, even if they get the word wrong.

Classroom rules: webcam, privacy, tired people

If someone falls behind (it will happen)

In a class of beginners, after lesson 3 the gap between the fastest and the slowest is already large. Three measures that work better than «catch up at home»:

What the teacher needs, lesson by lesson

LessonTo prepare beforehand
1-4The projector, the Playground open, the exercise links ready to paste into the chat. Nothing else.
5A vite.new/vanilla-ts project already open and working, to show; the array of 21 fake points to hand out.
6The Node.js and VS Code installers on a USB stick or on the local network (the school connection always clogs up on installation day); administrator rights checked beforehand.
7A clip of a hand, recorded in advance, for those who do not want to or cannot use the camera.
8The MediaPipe model already downloaded and put on the local network, if the line is slow: it is about 8 MB per student.
9The complete code of lesson 8, as a restore point.
10The Three.js starter file ready; the order of the presentations; something to end on a high note (the whole-body demo at /corpo/ works really well).

After the course

Whoever finished the project has much more than a moving hand: they can declare types, split a program into functions and files, use an external library, read a compiler error and run a loop at 30 frames per second. From here, three directions: