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.
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.
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.
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.
| Environment | When | What it offers | Account |
|---|---|---|---|
TypeScript Playgroundtypescriptlang.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 |
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.
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.
| Lesson | TypeScript concepts | What it is for in the hand |
|---|---|---|
| 1 | let/const, types string, number, boolean, annotations and inference, console.log | Holding a coordinate, a frame counter, the name of a gesture |
| 2 | if/else, operators, for and for…of loops, typed arrays, union types | Going through the 21 points of the hand; deciding whether a hand is there or not |
| 3 | Functions, parameters and return type, void, arrow functions, map and filter | Computing the distance between two fingers, converting coordinates |
| 4 | Objects, interface, optional properties, null and undefined, ?. and ?? | The type Punto {x, y, z}: the data the whole project runs on |
| 5 | The typed DOM, as, events, the 2D canvas | Drawing points and bones on the screen |
| 6 | import/export, tsconfig.json, npm, the build | Splitting the project into files like the real one |
| 7 | async/await, Promise, try/catch, requestAnimationFrame | Turning on the webcam and running 30 times a second |
| 8 | External libraries, generic types (lightly), type narrowing | Asking MediaPipe where the fingers are and drawing them |
| 9 | class, this, private fields, union types as state | Anti-jitter filter, gestures, on/off buttons |
| 10 | Review, an intro to 3D, how to put it online | The hand in three dimensions and the project presented |
The syllabus, lesson by lesson
- 01The first program: variables and typesPlayground
- 02Deciding and repeating:
if, loops, arraysPlayground - 03Functions: giving a name to a piece of workPlayground
- 04Objects and interfaces: the
PuntotypePlayground - 05The page and the canvas: drawing a fake handStackBlitz
- 06On your own computer: Node, Vite, modulesLocal
- 07The webcam:
async, permissions, framesLocal - 08MediaPipe: the 21 points of the real handLocal
- 09Polishing: classes, state, gestures, jitterLocal
- 10The third dimension, publishing, presentationsLocal
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.
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
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".
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')
- Open the finished hand and move your hand in front of the webcam. Say: "in ten lessons you will write this yourselves".
- Open the Playground and write three lines with
console.log. - Declare
const punta = 8;and explain that this number, in the final project, will be the tip of the index finger. - 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:
- assign some text to the age variable;
- reassign a value to a variable declared with
const; - 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 →
constcannot 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
- Wrong quotes: the operating system's autocorrect turns
"into“ ”. It does not happen in the Playground, but it does if someone writes the code in a text document and pastes it. - Backtick nowhere to be found: on an Italian keyboard it is Alt Gr + '. Worth showing on the board: without it, exercise 1.2 gets stuck.
- Comma instead of a point in decimals:
0,5is not a number,0.5is. - "I can't see anything": the Playground's message panel is on the right, Logs tab. Show it once and for all at the start.
Quick check (5 questions)
Questions and answers
- What is the difference between
letandconst? →constcannot be reassigned. - What type is
"17"? →string: it is in quotes, so it is text. - What does
console.log(2 + 3)print? Andconsole.log("2" + "3")? →5and23:+on text joins. - Why write
: numberif TypeScript works it out by itself? → To declare the intention and let whoever writes after us discover the error. - 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).
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.
if · the three ways to repeat · arrays · union typesTheory
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"
== 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
"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:
let i = 0— the starting point. You create a variable, usually calledi(for index), set to zero.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.i++— what to do at the end of every round.i++is the short form ofi = 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.
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.
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.
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')
- Write an array of five numbers and print it with a classic
for, reading aloud the three pieces in the round brackets. - Rewrite the same loop with
for…ofand ask the class what was gained and what was lost (the position was lost). - Remove
i++and show the tab freezing. Then put it back. It takes away the fear: it happened, you close, you reopen. - 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
ifinside the loop count how many are below 0.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];
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:
- how many points are in the left half of the image (that is, have
xless than 0.5); - how many in the right half;
- 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
- Mixing up
=and===:if (dita = 5)does not compare, it assigns. The compiler helps here and flags the error, but the message is not intuitive: worth showing it before they meet it on their own. - Index starting from 1:
for (let i = 1; i <= numeri.length; i++)skips the first element and reads one that does not exist. Double damage in a single line. - The variable declared inside the loop:
let somma = 0written inside the braces is reset at every round, and at the end only the last value is printed. - Forgotten braces:
if (x > 0) console.log("a"); console.log("b");always prints"b". Course rule: always use braces, even for a single line. NaNprinted on screen: almost always anumeri[i]read past the end of the array, or a division by alengthequal to zero.- Infinite loop: the tab freezes. Close it, reopen the Playground, the code is in the address. No drama.
Quick check (5 questions)
Questions and answers
- How many rounds does
for (let i = 0; i < 3; i++)do? → Three: withiequal to 0, 1 and 2. - What is the last valid index of an array of 21 elements? → 20, that is
length - 1. - Why write
===and not==? → Because double equals converts types before comparing, and"5" == 5comes out true. Triple equals also compares the type. - What does
console.log([1, 2, 3][5])print? →undefined: that element does not exist. - Can you
pushonto an array declared withconst? → Yes.constprevents 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.
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.
void · arrow · map and filterdistanza() and uses it to measure how big a hand isTheory
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:
function— the word announcing "I am about to declare a function".raddoppia— the name. Choose a verb, because a function does something.numero: number— the parameter, with its type. It is a variable that exists only inside the function.: numberafter the round brackets — the return type: what comes out.return— the statement that hands over the result and immediately ends the function.
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
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
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')
- 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. - Write
distanzalive, withMath.sqrt, explaining that it is Pythagoras' theorem and nothing else. - Show
Math.hypot(dx, dy), which does the same in one line, and say that the real project uses that one. - 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. - Forget the
returnon purpose and show that the function printsundefined.
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.
ditaAlzate(stati: boolean[]): number— receives the five yes/no values (thumb, index, middle, ring, little finger) and returns how many fingers are raised.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
- Forgotten
return: the function prints but does not return, and the caller getsundefined. If TypeScript knows the return type it flags it right away — a great reason to always write it. - Declaring and not calling: writing
function distanza(…)and expecting something to happen. Declaring is like writing a recipe; calling is cooking. - Calling without the brackets:
console.log(mediaDi)prints the text of the function, not the result. You needmediaDi(numeri). - Swapped parameters:
aPixel(640, 0.5)gives 320 just likeaPixel(0.5, 640)— multiplication forgives — but withdistanzathe wrong order produces nonsense. Better to get used to reading the signature right away. - Using a variable declared inside another function: parameters and
consts declared inside a function exist only in there. The message is Cannot find name. - The semicolon after the arrow:
const f = () => { … };wants the final semicolon because it is a variable declaration;function f() { … }does not. Nobody remembers it, and that is perfectly fine: the formatter fixes it.
Quick check (5 questions)
Questions and answers
- What is the difference between declaring and calling a function? → Declaring defines it, calling runs it. A function never called does nothing.
- What does
: voidmean? → That the function returns no value: it performs an action. - What does
[1, 2, 3].map((n) => n + 1)return? → A new array,[2, 3, 4]. The original stays[1, 2, 3]. - When do you put
?on a parameter? → When it is optional; inside the function it may beundefinedand you must allow for it. - 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.
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.
interface · arrays of objects · readonly · ?. and ??Punto and the function that finds the centre of the handTheory
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.
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:
undefined— "nothing was ever put here". It is what you get from an optional property with no value, from an optional parameter that was not passed, or from an element past the end of an array.null— "here, on purpose, there is nothing". A programmer writes it to say "this slot is empty, and I know it".
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')
- Write
interface Puntoon the board and then in the Playground. Create the wrist and the index tip with the realistic values seen above. - Add the
zand remove thez, showing that withz?both versions are valid. - Write
centro(punti: Punto[]): Puntolive, reusing the average idea from lesson 3. Make it return an object: it is the first function in the course that returns something composite. - 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".
- Write
punti[99].xand show the crash at run time. Then fix it withpunti[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 = 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
- Comma inside the
interface: you writex: number;with a semicolon (or just a new line). With a comma the compiler complains and the message is not very clear. - Colon instead of the equals sign:
const polso = { x: 0.5 }inside the object wants the colon, but the variable is created with the equals sign. Mixing the two up is perfectly normal on the first day. - Braces in the
return:return { x: 1, y: 2 };returns an object. The braces here are not a code block, they are the object: people end up hunting a non-existent error because of this. - Looking for the maximum to find the highest point: in images y grows downwards. If the drawn hand comes out upside down, the problem is almost always here.
- Using
||instead of??: it works until a legitimate zero arrives, and then it breaks in a way that is very hard to track down. - Comparing objects with
===:{ x: 1 } === { x: 1 }isfalse. The comparison checks whether they are the same object, not whether they have the same content. To compare content you compare the properties one by one.
Quick check (5 questions)
Questions and answers
- What does
z?: numbermean inside an interface? → That the property is optional: if it is missing its value isundefined. - What does
punti[99]?.xreturn if the array has 21 elements? →undefined, without blowing up the program. - 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.
- Why does
0 ?? 5give 0 while0 || 5gives 5? → Because??fires only onnullandundefined,||on any value considered "empty", zero included. - Can you change a property of an object declared with
const? → Yes, unless it isreadonly.constprotects 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.
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.
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.
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:
-
It might not be there. The return type is
HTMLElement | null: that union type says "an element, or nothing". If you misspelled theid, or if the code starts before the page is ready, the answer isnull. It is the samenullas in lesson 4. -
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')
- Open
vite.new/vanilla-tsin front of the class: showing that in ten seconds there is a working project removes the installation anxiety. - Empty
src/main.tsand the<body>ofindex.html, add the<canvas>, save: the page stays white. "The canvas is there, it is empty." - Write
getElementByIdwithoutasand show the two red errors. Addasand the check, and comment on them: they are the two problems the compiler just prevented. - Draw a circle. It is the "it works!" moment of the lesson: wait two seconds before going on.
- Draw three circles forgetting
beginPathand show the spider web. Then add it. - 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.
Typical mistakes in class
- White page and no error: almost always the
idin the HTML and the one in the code do not match, or the line<script type="module" src="/src/main.ts">is missing. First place to look: the browser console. - The spider web:
beginPathforgotten inside the loop. All the circles end up joined by lines. - Nothing shows but no error:
fillorstrokeforgotten. The drawing was described but not executed. - The upside-down hand: someone "fixed" y thinking 0 was at the bottom. In the canvas y grows downwards, as in the image: leave it as it is.
- The canvas sized with CSS: setting width and height outside the
widthandheightattributes stretches the drawing instead of enlarging it. For today: attributes in the HTML only. - Tiny hand in a corner: someone used
aPixel(p.x, 1)or forgot to multiply. Coordinates 0…1 without multiplication all sit in the first pixel. - The button does not respond: the listener was registered on a
nullelement inside anifthat is never true, or theidis wrong.
Quick check (5 questions)
Questions and answers
- Why does
document.getElementByIdreturnHTMLElement | null? → Because thatidmight not exist in the page: the type says so, and forces you to check. - What does
as HTMLCanvasElementdo? → It convinces the compiler that the element is a canvas. It verifies nothing: it is a promise by whoever writes it. - What is
beginPathfor? → To start a new shape, separate from the previous one. Without it, shapes get joined. - Where is the point
(0, 0)of a canvas? → At the top left. y grows downwards. - What is the difference between
fillandstroke? →fillfills the inside of the shape,strokedraws 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.
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.
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:
- it downloads libraries written by others — in lesson 8 you will download Google's hand recognizer with one command;
- it runs the project's commands, the ones written in
package.json:npm run dev,npm run build.
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:
-
no accidental
any: a parameter without a type becomes an error, because "I don't know" is not an acceptable answer; -
no hidden
null: if something might not exist — likedocument.getElementById("tela"), which returns the element ornull— you have to say what to do in that case.
// 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");
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:
-
between braces you write the same names used in the
export: if there it isdisegnaPunti, here it cannot beDisegnaPunti; -
the path starts with
./(same folder) or../(folder above), and you do not write the.tsextension; -
to import a type you write
typebefore 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:
- types cost nothing to whoever runs the program: by then they are already gone;
-
types do not protect you while the program runs. If in lesson 8 the
recognizer returns zero hands, no type stops it: you will have to check it
yourself with an
if.
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')
- Empty terminal:
node -vandnpm -v, to show they are there. npm create vite@latest demo-aula -- --template vanilla-ts, thennpm installandnpm run dev: from nothing to the open page, in less than two minutes.- Change a line in
src/main.ts, save and show the page updating by itself while nobody reloads it. - Create
src/saluta.tscontainingexport function saluta(nome: string) { … }and import it inmain.ts. Then make a mistake on purpose: remove theexportand read the error together with the class. - Open
tsconfig.json, set"strict": trueand show the line that turns red.
Lab (60')
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.
- Node.js, LTS version (the recommended one, not the "Current"): nodejs.org. Next, next, finish: do not change any option.
- Visual Studio Code: code.visualstudio.com.
- 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: thePuntointerface, 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.
node -vprints a number;npm -vprints a number;- in the project folder,
npm run devstarts and prints an address; http://localhost:5173shows your page, with the fake hand drawn;- when you save a file, the page updates by itself.
Typical mistakes in class
- Command run in the wrong folder:
npm run devanswers "Could not read package.json". Thecd mano-corsois missing. The terminal always shows which folder you are in: read it before typing. - Spaces or accents in the path: a folder called
Documenti scuola/progèttowill sooner or later cause trouble. Use short, lowercase names, without spaces. - The server is already on: "Port 5173 is in use". Vite picks another one and says so — or you stop the first with Ctrl + C.
- The page is white: almost always an error in
main.ts. Open the browser console with F12 and read. The console is the first place to look, not the last. - "I fixed it but the error is still there": the file was not saved. Ctrl + S. The dot next to the file name, at the top, means "not saved".
importpath without./:import { … } from "disegna"makes it look for a library called "disegna" insidenode_modules. You need"./disegna".node_modules"takes up too much space": yes, hundreds of megabytes, and that is normal. Do not touch it, do not copy it, do not put it on a USB stick: it is recreated in thirty seconds withnpm install.
Quick check (5 questions)
Questions and answers
- 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.
- What does
npm installdo? → It readspackage.jsonand downloads intonode_modulesthe libraries and tools listed there. - What happens to the types when the code reaches the browser? → They are erased: the browser runs plain JavaScript.
- What do you need to use in
main.tsa function written indisegna.ts? →exportbefore the function andimport { … } from "./disegna"at the top of the file. - 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 benull.
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.
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.
async/await · errors · permissions · the frame loopcamera.ts live: turn on, turn off, and the webcam light that really goes offTheory
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);
}
-
awaitmeans «wait until this promise is kept, then give me the value». While it waits, the rest of the page keeps working: buttons respond, animations run. -
asyncin front offunctionis the permission to useawaitinside it. If you forget it, the compiler tells you right away.
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:
| Name | What happened | What to tell the user |
|---|---|---|
NotAllowedError | The user denied permission, or had already denied it in the past | «Allow camera use in the browser settings and try again» |
NotFoundError | No webcam is connected | «No webcam found on this device» |
NotReadableError | There 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:
autoplay— it starts by itself, without a second click;muted— without it browsers block autoplay; and we do not need audio anyway, in fact we ask foraudio: false;playsinline— on phones it stops the video from opening full screen and swallowing the page.
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 timer does not know when the screen repaints, so now and then you draw twice between one repaint and the next (wasted work) or zero times (a visible stutter);
-
if the computer is slow, timers pile up and the page chokes;
requestAnimationFrameinstead simply skips a round; -
when you switch tab, the browser suspends
requestAnimationFrame: no battery spent drawing a page nobody is looking at. The timer would keep going regardless.
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')
- A page with two buttons and a
<video>. WriteaccendiCameralive and make the permission request appear on the projector. - Deny permission on purpose. Show that the page says nothing, then add the
try/catchand the translated message. It is the most useful moment of the lesson. - Write
spegniCamerafirst withoutgetTracks().forEach(...): the light stays on. Then add the line: the light goes off. Show the little light, not the code. - Add the loop with
requestAnimationFrameand thedrawImageon 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')
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
- Permission denied by mistake the first time: the browser remembers and does not ask again, so the page looks broken. Fix it from the camera icon in the address bar. Show this to everyone before the lab, not after.
awaitoutside anasyncfunction: compiler error. The little wordasyncgoes in front of thefunctionthat contains theawait.- The video shows but the canvas is black: the loop has not started, or the canvas has zero width and height. The canvas
widthandheightare written as HTML attributes, not as CSS. - Everything mirrored, text included:
ctx.restore()is missing. - The light stays on after «Spegni»:
getTracks().forEach((t) => t.stop())is missing. That is why this check is done with your eyes on the webcam. - Two loops in parallel: turn on, turn off, turn on again, and suddenly the fps doubles. Every start-up launched a loop without stopping the previous one. The fix is
cancelAnimationFrame(idCiclo)before every start. - The webcam does not start in the online environment: it is the framed preview. From here on you work locally, or in pairs with someone who has it; the preview in a separate tab is the last resort (lesson 6).
Quick check (5 questions)
Questions and answers
- What exactly does
getUserMediareturn? → APromise: the promise of a video stream, not the stream. - Where can you write
await? → Inside a function declaredasync. - On which addresses does the webcam turn on? → Only
https://…andlocalhost: the secure context. - Why
requestAnimationFrameand notsetInterval? → Because it follows the screen's rhythm, does not pile up delays and pauses when the tab is not visible. - 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.
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.
console.log to the full hand on screenTheory
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:
- The model is downloaded once and runs on your computer. There is no server to query: the image of your hand never leaves the browser. That is the reason this project can promise what it promises.
- The model makes mistakes. Not «may make mistakes»: it does, and you will see it today. Against the light, with the hand tilted or half out of the frame, the points jitter or the hand disappears. It is not your bug: it is the nature of the thing, and in lesson 9 you will learn to live with it.
- The model understands nothing. It gives numbers, and the numbers have a confidence. It does not know what a hand is, it does not know it is yours.
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.
@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:
-
FilesetResolver.forVisionTasksprepares the engine: it looks at what your processor can do and picks the right WebAssembly variant by itself. -
runningMode: "VIDEO"says «you will get frames in sequence, not unrelated photos». In this mode the recognizer follows the hand from one frame to the next instead of searching for it from scratch every time: it is much faster and much more stable. The alternative is"IMAGE", for single photographs. -
numHands: 1— one hand is enough to start. Setting it to 2 is exercise 8.5.
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:
-
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. -
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
| Field | Meaning | Range |
|---|---|---|
x | horizontal position on the image | 0 = left edge … 1 = right edge |
y | vertical position | 0 = top … 1 = bottom |
z | depth relative to the wrist | same 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')
npm install @mediapipe/tasks-vision@1.0.1on the projector; openpackage.jsonand show the new line, without^.- Write
creaRiconoscitoreand 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. - 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. - Print
risultato.landmarks[0]?.[8]and slowly move your index finger: have the class read thexgoing from 0.1 to 0.9. It is the moment the numbers become your own finger: worth letting it breathe for half a minute. - Make a mistake on purpose: pass
0instead ofperformance.now()and read the error about increasing timestamps.
Lab (60')
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.
Typical mistakes in class
- «Cannot read properties of undefined»:
landmarks[0][0]was read in a frame with no hands. You need the check withifor the?.. - Error about non-increasing timestamps: a fixed number was passed, or the same
performance.now()saved in a variable and reused. The time is read at every call. - The page freezes for ten seconds on first start: it is the twenty-megabyte download. It is not a bug — but the user must be told with a «Loading the model…» message, otherwise it looks broken.
- Points all piled up in the top-left corner: they are the 0…1 coordinates drawn as if they were pixels.
aPixelis missing. - Points mirrored relative to the image: you mirrored the video but not the coordinates (or the other way round). Decide once and be consistent.
- The trail:
clearRectis missing, or the videodrawImagedoes not cover the whole canvas. - Two or three frames per second: recognition is running on the processor instead of the graphics card, which is common on school laptops. It is not solved today; note it down and measure it with exercise 7.5.
Quick check (5 questions)
Questions and answers
- 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.
- 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. - What shape does
risultato.landmarkshave? → An array of hands; each hand is an array of 21 points withx,y,zbetween 0 and 1. - What happens if you pass the same moment twice to
recognizeForVideo? → An error: in VIDEO mode timestamps must always increase. - How do you go from
x = 0.42to a pixel on a canvas 640 wide? → You multiply: 0.42 × 640 ≈ 269. It isaPixel, 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.
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.
MediaMobile live and shows the hand before and after, with the filter on and offTheory
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:
- class — the mould: it says what an object of that type is made of and what it can do;
new— build an object from that mould;- constructor — what has to be done at birth;
- field — a value the object keeps (
valori,quanti); - method — a function the object can run (
aggiungi,valore).
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.
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.
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')
- 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.
- Write
MediaMobilelive, calmly: it is the first class they see. Point out thatthis.valoriof two different objects are two different arrays. - 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.
- 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.
- Show the finished
setStateand 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
- Forgotten
this: inside a method someone writesvalori.push(...)instead ofthis.valori.push(...). The compiler says «Cannot find name 'valori'»: the field exists only throughthis. - Forgotten
new:const f = FiltroMano();builds nothing. You neednew FiltroMano(). - A single filter for all the points: the hand crumples towards a single point, and it is a memorable sight. You need 63 independent filters, one per coordinate.
- Filter never thrown away: the hand coming back into the frame crawls in from where it left.
- The hand «in treacle»: window too long. Bring it back to 5 and look again.
precedenti = puntiinstead of the copy: you save a reference to the array, not the values; at the next comparison the differences all come out zero and the measured jitter is 0.00000. It looks like a resounding success and it is not.- Two
setStatecalls in a row with contradictory messages: the last one wins, and it is often the wrong one. One path, one state.
Quick check (5 questions)
Questions and answers
- What is
privatefor? → To prevent a field from being used outside the class: whoever uses it sees only the methods. - Why are 63 moving averages needed and not one? → Because every coordinate of every point has its own history: 21 points × 3 axes.
- What is the flaw of the moving average? → The delay: the more values it keeps, the later the hand arrives on fast movements.
- 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.
- 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.
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.
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:
- scene (
Scene) — the stage: the list of things that exist; - camera (
PerspectiveCamera) — where you look from, and with what field of view: distant things get smaller, as in reality; - lights — without them, everything is black. An ambient light that brightens everywhere, a directional one that gives volume;
- mesh (
Mesh) — an object: a geometry (the shape: a sphere, a cylinder) plus a material (the colour, how shiny it is).
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.
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:
- it moves the origin from the corner to the centre: the point at the centre of the image (0.5) becomes 0, that is the centre of the scene;
-
it reverses the direction of the axis. On
yit takes you from the «y down» of images to the «y up» of 3D graphics. Onxit is the mirror effect: move your hand to the right and the hand on screen goes to the right, as in front of a mirror. Without it, it feels like watching someone else, and it is strangely unpleasant.
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:
-
Building:
npm run build. The command checks the types, translates the TypeScript into JavaScript, joins the files, shortens them and writes everything into thedist/folder. Inside there is a normal site — pages, scripts, images — that no longer needs either Node or Vite. - A computer that is always on to serve those files to whoever asks for them, and a name (a domain) pointing to that computer.
-
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.
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')
- 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. - 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.
- 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.
- Make a mistake on purpose: remove the
0.5 -from they. The hand turns upside down. Put it back and explain the subtraction while everyone watches the effect.
Lab (80')
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:
- What does your project do? Show it, do not describe it.
- Which piece of code are you most pleased with, and why?
- What cost you the most time? How did you get out of it?
- 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
- Black screen: the lights are missing, or the canvas has no size in the CSS, or
disegna()is never called. Check them in that order. - The hand is upside down: the
0.5 -on theyis missing, or it has becomep.y - 0.5. - The hand moves the wrong way: the mirror on the
xis missing. It is the same reasoning as they, but for a different reason — here it is the user's comfort, there it is a conversion between two coordinate systems. - The hand is huge or invisible: it was given points in pixels instead of between 0 and 1.
- Everything stutters: new objects are being created inside the loop. The spheres are created once, then moved.
npm run buildfails whilenpm run devworked: it istscchecking the types for real. The errors are real and must be fixed; during development they were only ignored squiggles.
Quick check (5 questions)
Questions and answers
- What are the four ingredients of a Three.js scene? → Scene, camera, lights and mesh (geometry + material).
- 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». - 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.
- What does
npm run buildproduce? → Thedist/folder: a static site already translated into JavaScript, which no longer needs either Node or Vite. - 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:
- Count the raised fingers properly, measuring distances from the wrist instead of heights (exercise 10.5, serious version).
- 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.
- Drive a small game: a paddle that follows your index finger, a target to hit, a score. You already know everything you need.
- Two hands together, with different colours and the right/left labels sorted out properly.
- 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.
- 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.
- 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.
- 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.
| Criterion | Weight | How 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. |
await here?». Whoever did the work answers, even if they get the word wrong.
Classroom rules: webcam, privacy, tired people
- You film only your own hand. No filming classmates, no other people's screens. It is a rule of good manners before it is a rule of law.
- Whoever does not want to appear does not have to. A hand is enough for the frame, but if someone is uncomfortable they can work with a fake video (a clip recorded beforehand by the teacher, used instead of the camera) or in a pair with someone who turns on their own.
- Nothing leaves the computer. It must be said explicitly and shown: in the whole project there is not a single line that sends images anywhere. It is also a good opportunity to talk about what apps that ask for the camera do instead.
- The lights go off. At the end of the lesson, everyone closes the tab or presses «turn off camera»: it is part of the exercise, not a detail.
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»:
- Every lesson restarts from a restore point. The teacher keeps the complete code of the previous lesson ready: whoever got lost copies it and starts again from there, without dragging the delay along for twenty hours.
- Exercises are labelled. basic for everyone, intermediate for most, challenge for those who finish early — so the fast ones have something to do and the slow ones do not feel guilty for not finishing.
- Rotating pairs. From lesson 6 you work in twos at a single computer, swapping at the keyboard every 15 minutes. The one watching explains, the one typing carries out: you learn more in both roles.
What the teacher needs, lesson by lesson
| Lesson | To prepare beforehand |
|---|---|
| 1-4 | The projector, the Playground open, the exercise links ready to paste into the chat. Nothing else. |
| 5 | A vite.new/vanilla-ts project already open and working, to show; the array of 21 fake points to hand out. |
| 6 | The 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. |
| 7 | A clip of a hand, recorded in advance, for those who do not want to or cannot use the camera. |
| 8 | The MediaPipe model already downloaded and put on the local network, if the line is slow: it is about 8 MB per student. |
| 9 | The complete code of lesson 8, as a restore point. |
| 10 | The 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:
- Go deeper into the language with the official handbook (TypeScript Handbook) and the Playground exercises.
- Go deeper into this project: the tutorial on this site follows the same path on your own and goes as far as 3D and publishing.
- Change field: the same tools (webcam, canvas, a ready-made model) apply to the whole body, the face, object recognition. The Body page of this site is exactly the same code with a different model.