✋ Mano

Corso · 10 lezioni da 2 ore

Da zero a una mano che ti imita

Un corso di TypeScript per chi ha visto poco codice. Si parte dalla prima variabile e si arriva a un'applicazione vera: la webcam guarda la tua mano e il computer la ridisegna sullo schermo, dito per dito.

Durata 10 lezioni da 2 ore (20 ore in aula)
Prerequisiti Saper usare un computer. Aver visto un po' di codice aiuta, ma non è richiesto.
Dove si scrive Lezioni 1-5 online senza account, dalla 6 sul proprio computer
Progetto finale La mano disegnata in 2D che segue la webcam (il 3D è l'estensione finale)

Come è fatto questo corso

Ogni lezione dura 120 minuti ed è sempre divisa allo stesso modo: si spiega poco e si prova molto. Nessuna lezione è solo teoria, e nessun esercizio chiede cose non ancora spiegate.

30' Teoria — il concetto nuovo, spiegato con esempi piccoli e mostrato dal vivo
15' Demo — il docente scrive davanti a tutti un pezzo del progetto finale
60' Laboratorio — gli studenti scrivono codice; il docente gira fra i banchi
15' Chiusura — verifica veloce, errori visti in aula, compito per la volta dopo

Il filo conduttore è uno solo: ogni costrutto del linguaggio si impara perché serve alla mano. Le variabili servono a tenere una coordinata, gli array a tenere 21 punti, le interfacce a dire com'è fatto un punto, le funzioni asincrone ad accendere la webcam. Nessun esercizio astratto senza un perché.

Nota per il docente Il progetto finito, il suo codice commentato e la spiegazione riga per riga sono già online: la demo della mano, il tutorial e, nel progetto, docs/COME-FUNZIONA.md. Servono come soluzione di riferimento e come traguardo da mostrare agli studenti il primo giorno: vedere dove si va a parare cambia la motivazione di tutte le venti ore.

Dove scrivono gli studenti

Le prime cinque lezioni non richiedono di installare niente: si lavora in ambienti che si aprono nel browser e funzionano senza registrazione. Dalla lezione 6 si passa al computer, perché la webcam e il progetto vero vogliono un progetto locale.

AmbienteQuandoCosa offreAccount
TypeScript Playground
typescriptlang.org/play
Lezioni 1-4 Il playground ufficiale del linguaggio: scrivi TypeScript a sinistra, vedi gli errori mentre scrivi, il JavaScript prodotto e i console.log a destra. Il link contiene il codice, quindi il docente può distribuire un esercizio incollando un indirizzo. No
vite.new/vanilla-ts
(StackBlitz)
Lezioni 5-6 Apre un progetto Vite + TypeScript completo dentro il browser, con terminale e anteprima della pagina: è identico a quello che poi si installerà in locale, quindi il passaggio non spiazza nessuno. No (serve solo per salvare)
JSFiddle Lezione 5, prove rapide Quattro riquadri (HTML, CSS, JS, risultato) e via: comodo per un esperimento di due minuti sul canvas senza creare un progetto. No
VS Code + Node.js Dalla lezione 6 L'ambiente vero: editor, terminale, npm, il compilatore TypeScript. Si installa insieme in aula nella lezione 6. No
Prima della lezione: quali ambienti si aprono senza account I tre ambienti online si aprono in un browser qualunque, senza registrarsi, e mostrano subito l'editor. vite.new/vanilla-ts installa le dipendenze e avvia il server da solo in una decina di secondi. CodePen non è adatto: senza account risponde con un errore di accesso.
Un limite da conoscere prima La webcam non funziona dentro il riquadro d'anteprima degli ambienti online: è una regola dei browser, che negano camera e microfono ai riquadri incorporati che arrivano da un altro sito. È il motivo per cui dalla lezione 7 si lavora in locale. Chi non è riuscito a installare niente lavora in coppia con un compagno che ha il progetto sul proprio computer, oppure prova il codice sul filmato registrato dal docente. Solo come ultima possibilità si apre l'anteprima di StackBlitz in una scheda separata, dove la camera funziona: da spiegare in classe, però, che lì la pagina arriva dai server di StackBlitz — i fotogrammi restano comunque nel browser, ma non vale più la garanzia tecnica (la CSP) che questo progetto si dà.

Mappa: cosa si impara e dove serve

Questa tabella è anche il modo più rapido, per il docente, di rispondere alla domanda «a cosa mi servirà?» — che con i principianti arriva sempre, ed è legittima.

LezioneConcetti di TypeScriptA cosa serve nella mano
1let/const, tipi string, number, boolean, annotazioni e inferenza, console.logTenere una coordinata, un contatore di fotogrammi, il nome di un gesto
2if/else, operatori, cicli for e for…of, array tipizzati, tipi unioneScorrere i 21 punti della mano; decidere se una mano c'è o no
3Funzioni, parametri e tipo di ritorno, void, funzioni freccia, map e filterCalcolare la distanza fra due dita, convertire coordinate
4Oggetti, interface, proprietà opzionali, null e undefined, ?. e ??Il tipo Punto {x, y, z}: il dato su cui gira tutto il progetto
5Il DOM tipizzato, as, eventi, il canvas 2DDisegnare punti e ossa sullo schermo
6import/export, tsconfig.json, npm, la compilazioneDividere il progetto in file come quello vero
7async/await, Promise, try/catch, requestAnimationFrameAccendere la webcam e girare 30 volte al secondo
8Librerie esterne, tipi generici (leggeri), restringimento dei tipiChiedere a MediaPipe dove sono le dita e disegnarle
9class, this, campi privati, tipi unione come statoFiltro anti-tremolio, gesti, pulsanti accendi/spegni
10Ripasso, cenni al 3D, come si mette onlineLa mano in tre dimensioni e il progetto presentato

Il programma, lezione per lezione

  1. 01Il primo programma: variabili e tipiPlayground
  2. 02Decidere e ripetere: if, cicli, arrayPlayground
  3. 03Funzioni: dare un nome a un pezzo di lavoroPlayground
  4. 04Oggetti e interfacce: il tipo PuntoPlayground
  5. 05La pagina e il canvas: disegnare una mano fintaStackBlitz
  6. 06Sul proprio computer: Node, Vite, moduliLocale
  7. 07La webcam: async, permessi, fotogrammiLocale
  8. 08MediaPipe: i 21 punti della mano veraLocale
  9. 09Rifinire: classi, stato, gesti, tremolioLocale
  10. 10La terza dimensione, la pubblicazione, le presentazioniLocale
Lezione 1

Il primo programma: variabili e tipi

Cosa succede davvero quando un programma «gira», come si tiene da parte un valore, e perché TypeScript ti sgrida prima di eseguire il codice invece che dopo.

30' Cos'è un programma · l'editor · variabili · tipi
15' Il docente mostra la mano finita e poi scrive le prime righe dal vivo, sbagliando apposta
60' Esercizi 1.1 → 1.5
15' Verifica veloce · compito

Teoria

Un programma è un elenco di istruzioni che il computer esegue una dopo l'altra, dall'alto verso il basso. L'istruzione più utile all'inizio è quella che scrive un messaggio:

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

Due righe, due messaggi, in quest'ordine. Non c'è magia: se inverti le righe, si inverte il risultato.

Una variabile è un nome dato a un valore, per poterlo riusare. In TypeScript se ne dichiarano di due tipi:

let punteggio = 0;        // potrà cambiare
const nome = "Giulia";    // non cambierà mai

punteggio = punteggio + 10;   // ok
nome = "Marco";               // ERRORE: const non si può riassegnare
Regola pratica Si usa const sempre, e si passa a let solo quando ci si accorge che il valore deve cambiare. Non è pignoleria: leggendo il codice, const dice «questo qui non ti sorprenderà».

Ogni valore ha un tipo. I tre che servono oggi: string (testo, fra virgolette), number (numeri, interi o con la virgola — che in programmazione è un punto) e boolean (solo true o false).

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

Quel : string è l'annotazione di tipo: è la parte che in JavaScript non esiste ed è il motivo per cui questo linguaggio si chiama TypeScript. Spesso si può omettere, perché il compilatore capisce il tipo da solo guardando il valore (si dice che lo inferisce):

const nome = "Giulia";   // TypeScript sa già che è una string
nome.toUpperCase();      // infatti qui ti suggerisce i metodi del testo

E a cosa serve tutto questo? A far comparire gli errori mentre scrivi, non mentre il programma gira davanti agli utenti:

let eta = 17;
eta = "diciassette";
// Errore: Type 'string' is not assignable to type 'number'.
// Tradotto: "avevi detto che qui ci va un numero, e questo è testo".
Come mostrarlo in aula Scrivi l'errore dal vivo e lascia che la riga si sottolinei di rosso. Poi passa il mouse sopra e leggi il messaggio ad alta voce, traducendolo. Il messaggio d'errore va presentato subito come un aiuto: è la differenza fra chi impara in fretta e chi si blocca.

Ultimo pezzo: unire testo e valori. Con gli apici inclinati ` (backtick) si scrivono stringhe in cui si possono infilare valori con ${…}:

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

Demo del docente (15')

  1. Apri la mano finita e muovi la mano davanti alla webcam. Dì: «fra dieci lezioni lo scrivete voi».
  2. Apri il Playground e scrivi tre righe con console.log.
  3. Dichiara const punta = 8; e spiega che quel numero, nel progetto finale, sarà la punta dell'indice.
  4. Sbaglia apposta un tipo e leggi l'errore insieme alla classe.

Laboratorio (60')

Esercizio 1.1 — Il primo messaggio base

Apri typescriptlang.org/play, cancella tutto quello che c'è e fai scrivere al programma il tuo nome e la tua età, su due righe separate.

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

Va bene anche con le variabili — anzi, meglio: è il passo successivo.

Esercizio 1.2 — Tre variabili base

Crea tre variabili: il tuo nome (testo), quanti anni hai (numero), se ti piace la pizza (booleano). Poi stampa una frase sola che le contenga tutte e tre, usando i backtick.

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

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

Esercizio 1.3 — Rompilo apposta base

Prendi l'esercizio precedente e prova a fare queste tre cose, una alla volta, leggendo ogni volta l'errore che compare:

  1. assegna un testo alla variabile dell'età;
  2. riassegna un valore a una variabile dichiarata con const;
  3. scrivi console.log(nomeSbagliato), con un nome che non esiste.

Scrivi su un foglio, con parole tue, cosa dice il computer nei tre casi.

Cosa devono aver capito
  • Type 'string' is not assignable to type 'number' → il tipo non corrisponde a quello dichiarato o dedotto.
  • Cannot assign to 'nome' because it is a constant → const non si riassegna.
  • Cannot find name 'nomeSbagliato' → quella variabile non esiste: quasi sempre è un errore di battitura.

Il punto dell'esercizio non è il codice: è imparare che l'errore dice dove e cosa, e che leggerlo costa meno che indovinare.

Esercizio 1.4 — Un po' di conti medio

La webcam del progetto finale lavora a 640 pixel di larghezza e 480 di altezza, e produce 30 fotogrammi al secondo. Dichiara le tre variabili con il tipo giusto e stampa:

  • quanti pixel ha un fotogramma;
  • quanti fotogrammi arrivano in un minuto;
  • quante volte al secondo il programma dovrà disegnare (uguale ai fotogrammi).
Soluzione
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 pixel per fotogramma, 1800 fotogrammi al minuto: numeri utili da avere in testa quando, alla lezione 7, si parlerà di velocità.

Esercizio 1.5 — Un punto sullo schermo sfida

Nel progetto finale ogni punto della mano ha due coordinate fra 0 e 1: 0 è il bordo sinistro (o alto) dell'immagine, 1 il bordo destro (o basso). Dichiara x = 0.5 e y = 0.25 e stampa a quale pixel corrispondono su un'immagine di 640×480.

Soluzione
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

È esattamente il calcolo che farà il programma vero, moltissime volte al secondo, per ogni punto della mano. Chi finisce presto: stampare anche il punto al centro esatto dell'immagine.

Errori tipici in aula

Verifica veloce (5 domande)

Domande e risposte
  1. Qual è la differenza fra let e const? → const non si può riassegnare.
  2. Che tipo ha "17"? → string: è fra virgolette, quindi è testo.
  3. Cosa stampa console.log(2 + 3)? E console.log("2" + "3")? → 5 e 23: il + su testo unisce.
  4. Perché scrivere : number se TypeScript lo capisce da solo? → Per dichiarare l'intenzione e far scoprire l'errore a chi scriverà dopo di noi.
  5. Quando compare un errore di tipo: prima o dopo aver eseguito il programma? → Prima, mentre si scrive.

Compito per casa (20-30 minuti)

Scrivi un programma che descriva la tua mano: quante dita, il nome del dito più lungo, se sei mancino. Deve usare almeno un const, un let, i tre tipi visti e una frase con i backtick. Poi condividi il link del Playground con il docente (pulsante Share: il codice viaggia dentro l'indirizzo, non serve un account).

Lezione 2

Decidere e ripetere: if, cicli, array

Finora il programma andava dritto dall'alto in basso. Oggi impara a scegliere una strada invece di un'altra e a ripetere lo stesso lavoro su molti valori. Serve perché la mano non è un numero: sono ventuno punti, e vanno guardati tutti.

30' Confronti e booleani · if · i tre modi di ripetere · array · tipi unione
15' Il docente scorre un elenco di 21 numeri e conta quelli che stanno in una metà dell'immagine
60' Esercizi 2.1 → 2.5
15' Errori tipici · verifica veloce · compito

Teoria

A cosa serve. Un programma che fa sempre le stesse cose nello stesso ordine non è molto utile. La mano davanti alla webcam a volte c'è e a volte no, le dita a volte sono alzate e a volte chiuse: il programma deve decidere. E siccome i punti della mano sono ventuno, scrivere ventuno volte la stessa riga non è una strada percorribile: il programma deve ripetere.

Si comincia dal confronto. Confrontare due valori produce sempre un boolean, cioè true o false: è lo stesso tipo visto la volta scorsa, ma questa volta non lo scriviamo a mano, lo calcola il computer.

const dita = 5;

console.log(dita === 5);    // true   "è uguale a"
console.log(dita !== 5);    // false  "è diverso da"
console.log(dita > 3);      // true   "maggiore di"
console.log(dita < 3);      // false  "minore di"
console.log(dita >= 5);     // true   "maggiore o uguale"
console.log(dita <= 4);     // false  "minore o uguale"
Tre uguali, non due In questo linguaggio esistono sia == sia ===, e non fanno la stessa cosa. Il doppio uguale, prima di confrontare, converte i valori: per lui "5" == 5 è true, anche se uno è testo e l'altro è un numero. Il triplo uguale non converte niente: "5" === 5 è false, perché sono di tipo diverso. Regola del corso, senza eccezioni: si usa sempre === e !==. Il doppio uguale produce bug che si trovano solo a programma finito.

I confronti si combinano con gli operatori logici. Sono tre e si leggono ad alta voce come in italiano:

const x = 0.4;

console.log(x >= 0 && x <= 1);   // true   "e"   → devono essere vere entrambe
console.log(x < 0 || x > 1);     // false  "o"   → basta che ne sia vera una
console.log(!(x < 0));           // true   "non" → capovolge il risultato

Con un valore boolean in mano si può finalmente scegliere. L'istruzione if esegue un blocco di codice solo se la condizione è vera:

const dita = 5;

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

Le parentesi graffe racchiudono il blocco, cioè le righe che appartengono a quel if. Con else si dice cosa fare quando la condizione è falsa, e con else if si mettono in fila più domande. Il computer le prova dall'alto in basso e si ferma alla prima vera:

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");
}

// stampa: vittoria
L'ultimo else non è obbligatorio Ma quasi sempre conviene metterlo. È il ramo del «non so cosa sia»: senza, quando nessuna condizione è vera il programma non stampa niente e tu resti lì a chiederti se si è rotto. Nel progetto finale quel ramo esiste davvero: il riconoscitore, quando non capisce il gesto, risponde "None", e l'applicazione mostra una mano generica.

Passiamo al ripetere. Il modo più classico è il ciclo for. Scritto tutto insieme fa paura, quindi si legge un pezzo alla volta:

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

// stampa: 0 1 2 3 4

Dentro le tonde ci sono tre cose separate da punto e virgola, e ognuna ha un compito:

  1. let i = 0 — il punto di partenza. Si crea una variabile, di solito chiamata i (da indice), che vale zero.
  2. i < 5 — la condizione per continuare. Prima di ogni giro il computer la controlla: se è vera fa il giro, se è falsa esce dal ciclo.
  3. i++ — cosa fare alla fine di ogni giro. i++ è la scrittura breve di i = i + 1.

Quindi il computer esegue: i vale 0, 0 è minore di 5, stampa, i diventa 1… finché i vale 5, che non è minore di 5, e il ciclo finisce. Il corpo è stato eseguito cinque volte, con i che valeva 0, 1, 2, 3, 4.

Il ciclo infinito Se la condizione non diventa mai falsa — per esempio perché ti sei dimenticato i++ — il ciclo non finisce più e la scheda del browser si blocca. Non è grave: si chiude la scheda e si riapre il Playground, che conserva il codice nell'indirizzo. Ma succederà a qualcuno, quindi è bene dirlo prima.

Adesso serve qualcosa su cui ripetere. Un array è un elenco ordinato di valori, tutti dello stesso tipo. Si scrive fra parentesi quadre, e il suo tipo è il tipo degli elementi seguito da []:

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

console.log(numeri.length);   // 4   → quanti elementi ci sono
console.log(numeri[0]);       // 10  → il PRIMO elemento
console.log(numeri[3]);       // 40  → l'ultimo
console.log(numeri[4]);       // undefined → non esiste

Due cose vanno imparate subito, perché sono la fonte di metà degli errori dei principianti. Primo: gli indici partono da zero. Un array di quattro elementi ha gli indici 0, 1, 2, 3. Secondo: di conseguenza l'ultimo indice è length - 1, mai length.

Fuori dai limiti: undefined Se chiedi un elemento che non c'è, il programma non si arrabbia: restituisce undefined, che è il valore speciale «qui non c'è niente». Nel Playground TypeScript ti dice che numeri[4] è un number e non protesta: si fida del fatto che tu sappia quanto è lungo l'array. Poi, all'esecuzione, salta fuori undefined e i conti danno NaN (Not a Number, «non è un numero»). Se vedi NaN stampato a schermo, nove volte su dieci hai letto oltre la fine di un array.

Mettendo insieme ciclo e array si ottiene la riga più usata di tutto il corso:

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

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

Si scrive i < numeri.length e non i < 4: così il codice continua a funzionare se domani l'array cambia lunghezza. E nel progetto finale cambia eccome, perché un array di punti può contenerne 21 (una mano) o 33 (un corpo intero).

Quando l'indice non ti serve, e ti interessa solo il valore, esiste una forma più corta e più leggibile, for…of:

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

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

Si legge letteralmente: «per ogni valore di numeri». Si usa const perché a ogni giro nasce una variabile nuova. Quando usare quale: se ti basta il valore, for…of; se ti serve anche sapere in che posizione sei — e per trovare il dito più alto serve — allora il for classico con l'indice.

Esiste un terzo modo, while, che ripete finché una condizione resta vera senza contatore automatico. Si usa quando non sai in partenza quanti giri serviranno:

let rimaste = 3;

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

Nel progetto della mano non compare mai: quando si lavora su elenchi di punti si sa sempre quanti sono. È utile conoscerlo, non è utile usarlo oggi.

Ultimo concetto, ed è quello che fa di TypeScript qualcosa di più di JavaScript: i tipi unione. A cosa servono: spesso una variabile non può contenere qualunque testo, ma solo due o tre parole precise. Dirlo al compilatore evita una intera famiglia di errori.

type Stato = "spenta" | "accesa";

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

La barra verticale | si legge «oppure». Senza il tipo unione avresti scritto let camera: string, e quel "acesa" con una c sola sarebbe passato liscio: il programma avrebbe controllato camera === "accesa", avrebbe trovato false e non avrebbe acceso niente, senza un solo messaggio d'errore. Un pomeriggio buttato via, per una lettera.

Dove si vede nel progetto vero Nel file main.ts dell'applicazione c'è esattamente questa riga: type State = "idle" | "loading" | "running" | "error". Sono i quattro stati possibili della pagina: spenta, in avvio, in funzione, in errore. Niente altro è ammesso, e il compilatore lo fa rispettare.

Chiudiamo con una trappola che merita i suoi trenta secondi. const su un array non rende l'array immutabile: impedisce solo di sostituirlo con un altro array.

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

numeri.push(4);           // PERMESSO: l'array ora è [1, 2, 3, 4]
console.log(numeri);

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

Il motivo: const protegge il nome, non il contenuto. La variabile numeri continuerà a indicare quell'elenco lì; cosa ci sia dentro l'elenco è un altro discorso. push aggiunge un elemento in fondo ed è il modo normale di costruire un array poco per volta.

Demo del docente (15')

  1. Scrivi un array di cinque numeri e stampalo con un for classico, leggendo ad alta voce i tre pezzi delle tonde.
  2. Riscrivi lo stesso ciclo con for…of e chiedi alla classe cosa si è guadagnato e cosa si è perso (si è persa la posizione).
  3. Togli i++ e mostra la scheda che si blocca. Poi rimettilo. Serve a togliere la paura: è successo, si chiude, si riapre.
  4. Dichiara un array di 21 numeri fra 0 e 1 — «sono le posizioni orizzontali dei ventuno punti della mano» — e conta con un if dentro il ciclo quanti stanno sotto 0.5.
  5. Scrivi type Gesto = "pugno" | "mano aperta", sbaglia una lettera e mostra l'errore. È il momento in cui i tipi unione si capiscono.

Laboratorio (60')

Esercizio 2.1 — Somma e media base

Dato l'array qui sotto, calcola e stampa la somma di tutti i numeri e la loro media. Copia il codice di partenza e completa.

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

let somma = 0;
// scrivi qui il ciclo che aggiunge ogni numero a `somma`

console.log(`somma: ${somma}`);
Soluzione
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

Perché così. La variabile somma è dichiarata fuori dal ciclo e con let: dentro il ciclo nascerebbe e morirebbe a ogni giro, e alla fine non ci sarebbe più niente da stampare. Per la media si divide per numeri.length e non per 5: se domani aggiungi un numero, il codice è già giusto.

Esercizio 2.2 — La coordinata è dentro l'immagine? base

Nel progetto finale ogni punto della mano arriva con due coordinate comprese fra 0 e 1: 0 è il bordo sinistro (o alto) dell'immagine, 1 il bordo destro (o basso). Ogni tanto, quando la mano esce dall'inquadratura, arriva un valore fuori da quell'intervallo.

Dichiara const x = 0.42; e scrivi un if che stampi "punto dentro l'immagine" se x sta fra 0 e 1 (estremi compresi) e "punto fuori dall'immagine" altrimenti. Poi prova a cambiare x in 1.3 e in -0.2 e controlla che risponda bene in tutti e tre i casi.

Soluzione
const x = 0.42;

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

Perché così. Servono due confronti uniti da &&, perché devono essere vere entrambe le condizioni. Attenzione all'errore classico: 0 <= x <= 1 in italiano si dice, ma nel codice non funziona — il computer calcola prima 0 <= x, ottiene un booleano, e poi confronta un booleano con 1. Risultato senza senso, e il compilatore infatti protesta.

Esercizio 2.3 — Quante dita sono alzate medio

Il progetto finale, per riconoscere un gesto, parte da cinque risposte sì/no: pollice alzato? indice alzato? e così via. Le teniamo in un array di cinque boolean, in ordine: pollice, indice, medio, anulare, mignolo.

Conta quante dita sono alzate e stampa il numero. Poi, con un if, aggiungi una frase: 0 dita è un pugno, 5 dita è una mano aperta, qualunque altro numero è «altro».

const alzate: boolean[] = [false, true, true, false, false];
Soluzione
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");
}

Perché così. if (dito === true) si può scrivere anche solo if (dito), perché dito è già un booleano: la forma corta è quella che troverai nel codice vero. Qui si è scelta quella lunga perché all'inizio è più chiara — l'importante è sapere che sono la stessa cosa.

Esercizio 2.4 — Il massimo e la sua posizione medio

Trova il valore più grande dell'array e anche a quale indice si trova. Stampa tutti e due.

const numeri: number[] = [12, 7, 30, 4, 22];
Perché serve davvero Non è un esercizio da manuale. Nel progetto finale, per capire quale dito è più in alto, si scorrono le cinque punte e si cerca la più alta: il valore da solo non basta, perché serve sapere di quale dito si tratta. E il dito è la posizione nell'elenco. Da qui l'obbligo di usare il for con l'indice e non for…of.
Soluzione
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

Perché così. Si parte dal primo elemento come campione da battere e il ciclo comincia da i = 1: confrontare il primo con se stesso sarebbe inutile. Le due variabili si aggiornano sempre insieme, dentro lo stesso if: se si aggiornasse solo il valore, l'indice resterebbe quello di prima e la risposta sarebbe sbagliata in modo silenzioso.

Un errore molto comune è partire da let massimo = 0. Funziona con questi numeri, ma se l'array contenesse solo numeri negativi la risposta sarebbe 0, che nell'array non c'è nemmeno. Partire dal primo elemento è sempre corretto.

Esercizio 2.5 — I punti nella metà sinistra sfida

Ecco le ventuno posizioni orizzontali dei punti di una mano, come le produrrebbe il riconoscitore: numeri fra 0 (bordo sinistro dell'immagine) e 1 (bordo destro).

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,
];

Scrivi un programma che stampi:

  1. quanti punti stanno nella metà sinistra dell'immagine (cioè hanno x minore di 0.5);
  2. quanti nella metà destra;
  3. una frase che dica dove sta la mano: «più a sinistra», «più a destra» o «al centro» se i due conteggi sono uguali.

Controlla anche che i due conteggi sommati facciano 21: è un modo rapido di accorgersi di aver saltato un elemento.

Soluzione
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");
}

Perché così. Usando if/else ogni punto finisce in uno e un solo conteggio: per questo la somma deve fare per forza 21. Il valore esattamente uguale a 0.5 (il polso) finisce a destra, perché la condizione è < 0.5: è una scelta, va bene, ma va fatta apposta e non per caso.

Chi finisce presto: stampa anche la posizione orizzontale media dei ventuno punti, riusando il metodo dell'esercizio 2.1. È il modo più semplice di dire «dove sta il centro della mano», e nella lezione 4 lo si riscriverà per davvero.

Errori tipici in aula

Verifica veloce (5 domande)

Domande e risposte
  1. Quanti giri fa for (let i = 0; i < 3; i++)? → Tre: con i uguale a 0, 1 e 2.
  2. Qual è l'ultimo indice valido di un array di 21 elementi? → 20, cioè length - 1.
  3. Perché si scrive === e non ==? → Perché il doppio uguale converte i tipi prima di confrontare, e "5" == 5 risulta vero. Il triplo uguale confronta anche il tipo.
  4. Cosa stampa console.log([1, 2, 3][5])? → undefined: quell'elemento non esiste.
  5. Si può fare push su un array dichiarato con const? → Sì. const impedisce di sostituire l'array, non di modificarne il contenuto.

Compito per casa (30 minuti)

Scrivi un programma che, partendo dall'array x dell'esercizio 2.5, stampi il punto più a destra (valore e posizione) e il punto più a sinistra, e dica se la mano è larga o stretta: chiama «larga» una mano in cui la differenza fra il massimo e il minimo supera 0.35, «stretta» altrimenti. Dichiara la risposta in una variabile con il tipo type Larghezza = "larga" | "stretta". Condividi il link del Playground con il docente.

Lezione 3

Funzioni: dare un nome a un pezzo di lavoro

Nella lezione scorsa hai scritto tre volte quasi lo stesso ciclo. Oggi impari a scriverlo una volta sola, dargli un nome e richiamarlo quando serve. È il passo che trasforma un elenco di istruzioni in un programma che si può leggere.

30' Perché le funzioni · parametri e ritorno · void · freccia · map e filter
15' Il docente scrive distanza() e la usa per misurare quanto è grande una mano
60' Esercizi 3.1 → 3.5
15' Errori tipici · verifica veloce · compito

Teoria

A cosa serve. Due motivi, in ordine di importanza. Il primo è che dare un nome a un pezzo di lavoro lo rende comprensibile: distanza(a, b) si capisce al volo, quattro righe di radici quadrate no. Il secondo è che una cosa scritta in un punto solo si corregge in un punto solo. Se la stessa formula è copiata in cinque punti, prima o poi quattro sono aggiornati e uno no.

Una funzione è un blocco di codice con un nome, che riceve dei valori in ingresso e ne restituisce uno in uscita. Si dichiara così:

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

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

Pezzo per pezzo, perché ogni parte ha un nome che poi il docente userà:

raddoppia(5) è la chiamata: il 5 entra, prende il posto di numero, e la funzione restituisce 10. Il valore restituito si può stampare, mettere in una variabile, o darlo in pasto a un'altra funzione.

return chiude la porta Tutto quello che sta dopo un return eseguito non viene mai eseguito. Non è un difetto, è la cosa utile: if (dimensione === 0) return 0; all'inizio di una funzione è il modo standard di sbrigare subito i casi strani e poi scrivere il caso normale senza annidare cinque if uno dentro l'altro.

I parametri possono essere più di uno, separati da virgola, ognuno con il proprio tipo:

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

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

L'ordine conta. area(640, 480) e area(480, 640) qui danno lo stesso risultato per caso, ma in generale no: il primo valore finisce nel primo parametro, punto.

Alcune funzioni non restituiscono niente: stampano, disegnano, accendono. Il loro tipo di ritorno si scrive void, che significa «vuoto»:

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

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

const risultato = saluta("Marco");
console.log(risultato);   // undefined: non c'era niente da restituire
Come riconoscerle Se la funzione risponde a una domanda («quanto è lunga?», «quante dita?») restituisce un valore. Se invece esegue un'azione («stampa», «disegna», «spegni la camera») di solito è void. Nel progetto finale stop(), che spegne tutto, è void; realSide(), che risponde «è la destra o la sinistra», restituisce un valore.

Esiste una seconda scrittura, più breve, chiamata funzione freccia. Fa esattamente la stessa cosa:

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

// e quando il corpo è una sola espressione, si può togliere anche return e graffe:
const triplica = (numero: number): number => numero * 3;

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

La funzione viene messa in una const come fosse un valore qualsiasi — perché lo è. Questa forma serve soprattutto quando una funzione va passata a qualcun altro, che è il prossimo argomento.

I parametri possono avere un valore predefinito, usato quando chi chiama non lo passa:

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

console.log(aPixel(0.5));        // 320  → usa 640
console.log(aPixel(0.5, 480));   // 240  → usa quello che gli passi

Oppure possono essere opzionali, con un punto interrogativo. In quel caso, se non vengono passati, valgono undefined, e la funzione deve prevederlo:

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
Gli opzionali vanno in fondo Un parametro opzionale non può stare prima di uno obbligatorio: il compilatore lo rifiuta, e giustamente — non avrebbe modo di capire quale valore hai saltato.

Le funzioni possono chiamarne altre, e questo è il modo normale di costruire programmi: si parte da pezzi piccoli e sicuri e si compongono.

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));   // circa 0.3002

Math è un insieme di strumenti matematici già pronti che il linguaggio mette a disposizione: Math.sqrt è la radice quadrata, Math.abs il valore assoluto, Math.round l'arrotondamento, Math.min e Math.max il più piccolo e il più grande fra due numeri.

Ultimo pezzo di oggi: map e filter. Sono due strumenti degli array che prendono una funzione come argomento. Sembra strano la prima volta, quindi conviene leggerli come frasi in italiano:

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

// map: "fammi un array NUOVO, applicando questa funzione a ogni elemento"
const doppi = numeri.map((n) => n * 2);          // [2, 4, 6, 8]

// filter: "fammi un array NUOVO con i soli elementi per cui questa funzione dice true"
const grandi = numeri.filter((n) => n > 2);      // [3, 4]

console.log(doppi);
console.log(grandi);
console.log(numeri);   // [1, 2, 3, 4] — l'originale non è stato toccato

Entrambi non modificano l'array di partenza: ne restituiscono uno nuovo. Sono la scrittura breve di un ciclo for che costruisce un array con push, e nel codice vero si usano quasi sempre al posto di quel ciclo perché dicono in una riga quale sia l'intenzione.

Chiudiamo con un'idea, non con una sintassi. Una funzione si dice pura quando, dati gli stessi ingressi, restituisce sempre la stessa uscita e non tocca niente fuori da sé. distanza è pura: con quei quattro numeri risponderà sempre uguale, oggi e fra un mese. saluta non lo è, perché stampa — cambia qualcosa nel mondo esterno. Le funzioni pure sono più facili da provare e non riservano sorprese: quando puoi scriverne una, scrivila.

Demo del docente (15')

  1. Prendi il codice dell'esercizio 2.1 (somma e media) e trasformalo in una funzione mediaDi. Chiamala due volte su due array diversi: è il momento in cui si vede il guadagno.
  2. Scrivi distanza dal vivo, con Math.sqrt, spiegando che è il teorema di Pitagora e nient'altro.
  3. Mostra Math.hypot(dx, dy), che fa lo stesso in una riga, e di' che nel progetto vero si usa quella.
  4. Calcola la distanza fra polso (0.50, 0.88) e nocca del medio (0.51, 0.58): «questo numero, nell'applicazione vera, decide quanto sono grosse le dita disegnate sullo schermo». Mostra la demo della mano e avvicina la mano alla camera.
  5. Dimentica apposta il return e fai vedere che la funzione stampa undefined.

Laboratorio (60')

Esercizio 3.1 — La distanza fra due punti base

Scrivi la funzione distanza(x1, y1, x2, y2) che restituisce la distanza fra due punti, usando Math.sqrt. Provala con (0, 0) e (3, 4): deve dare esattamente 5.

Poi riscrivila con Math.hypot, che calcola direttamente la radice della somma dei quadrati, e controlla che il risultato sia identico.

Soluzione
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

Perché così. dx e dy sono i due cateti del triangolo; la distanza è l'ipotenusa. Mettere i due cateti in due const con un nome, invece di scrivere tutto in una riga, rende la formula leggibile e evita di calcolare due volte la stessa sottrazione.

Nel progetto vero questa è la riga che misura quanto è grande la mano inquadrata: la distanza fra il polso e la nocca del medio. Da quel numero dipende lo spessore delle ossa e il raggio delle articolazioni disegnate — così la mano resta credibile sia vicina alla camera (grande) sia lontana (piccola).

Esercizio 3.2 — Da coordinata a pixel base

Le coordinate del riconoscitore vanno da 0 a 1; lo schermo invece ragiona in pixel. Scrivi aPixel(valore: number, dimensione: number): number che fa la conversione, e provala con tutti questi casi:

  • aPixel(0.5, 640) deve dare 320;
  • aPixel(0, 640) deve dare 0;
  • aPixel(1, 480) deve dare 480.

Poi aggiungi un valore predefinito di 640 per la dimensione e riprova senza passarla.

Soluzione
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 — usa il predefinito

Perché così. È una moltiplicazione sola, e proprio per questo merita una funzione: le darai un nome una volta e poi la userai decine di volte senza più chiederti se andava moltiplicato o diviso. Nella lezione 5 la userai per davvero, per disegnare i punti sul canvas.

Esercizio 3.3 — La media, ma come funzione base

Scrivi mediaDi(numeri: number[]): number, che restituisce la media degli elementi. Deve funzionare con array di lunghezza qualsiasi; se l'array è vuoto deve restituire 0 invece di NaN.

Soluzione
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

Perché così. Il controllo sull'array vuoto sta in cima e chiude subito con un return: è il caso strano tolto di mezzo prima di iniziare. Senza, la divisione sarebbe 0 / 0, cioè NaN, e quel NaN si propagherebbe in tutti i conti successivi senza dire da dove viene.

Esercizio 3.4 — Tutte le coordinate in pixel, con map medio

Ecco le posizioni orizzontali di alcuni punti della mano, fra 0 e 1. Convertile tutte in pixel per un'immagine larga 640, usando map e la funzione aPixel dell'esercizio 3.2.

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

Poi, con filter, ricava un secondo array con i soli punti che stanno nella metà sinistra dell'immagine (meno di 320 pixel) e stampa quanti sono.

Soluzione
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] — circa

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

Perché così. map riceve una funzione e la applica a ogni elemento: qui la funzione freccia (valore) => aPixel(valore, 640) serve solo a fissare il secondo argomento. Il risultato è un array nuovo, della stessa lunghezza; filter invece restituisce un array più corto, con i soli elementi che superano la prova.

I numeri con la virgola non sono un errore: i pixel interi si otterrebbero con Math.round. Per disegnare non serve — il canvas accetta benissimo le posizioni frazionarie, anzi risultano più morbide. Se vedi stampato 230.39999999999998 invece di 230.4 non hai sbagliato niente: è il modo in cui il computer conserva i numeri con la virgola, e nel disegno non si nota di un pixel.

Esercizio 3.5 — Dal conteggio al nome del gesto sfida

Due funzioni, una che usa l'altra.

  1. ditaAlzate(stati: boolean[]): number — riceve i cinque sì/no (pollice, indice, medio, anulare, mignolo) e restituisce quante dita sono alzate.
  2. nomeGesto(stati: boolean[]): "pugno" | "mano aperta" | "altro" — usa la prima funzione e restituisce il nome del gesto: 0 dita alzate è un pugno, 5 è una mano aperta, tutto il resto è «altro».

Provale con almeno tre array diversi.

Soluzione
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

Perché così. Il tipo di ritorno è un tipo unione, quindi il compilatore controlla ogni return: se scrivessi return "mano aperta!" con un punto esclamativo di troppo, l'errore comparirebbe subito invece che in aula durante la presentazione.

Notare anche che non serve else: ogni return chiude la funzione, quindi se il codice arriva all'ultima riga significa che le due condizioni precedenti erano false. Si legge meglio così.

Chi finisce presto: aggiungi il gesto "vittoria" quando le dita alzate sono esattamente indice e medio. Serve guardare le singole posizioni dell'array, non solo il totale: stati[1] e stati[2] veri e gli altri tre falsi.

Errori tipici in aula

Verifica veloce (5 domande)

Domande e risposte
  1. Che differenza c'è fra dichiarare e chiamare una funzione? → Dichiarare la definisce, chiamare la esegue. Una funzione mai chiamata non fa niente.
  2. Cosa significa : void? → Che la funzione non restituisce nessun valore: fa un'azione.
  3. Cosa restituisce [1, 2, 3].map((n) => n + 1)? → Un array nuovo, [2, 3, 4]. L'originale resta [1, 2, 3].
  4. Quando si mette ? su un parametro? → Quando è facoltativo; dentro la funzione può valere undefined e va previsto.
  5. Cos'è una funzione pura? → Una che, con gli stessi ingressi, dà sempre lo stesso risultato e non cambia niente fuori da sé.

Compito per casa (30 minuti)

Scrivi tre funzioni e provale: massimoDi(numeri: number[]): number, minimoDi(numeri: number[]): number e larghezzaDi(numeri: number[]): number, che restituisce la differenza fra massimo e minimo riusando le prime due. Provale sull'array delle ventuno posizioni orizzontali dell'esercizio 2.5. Nessuna delle tre deve contenere un console.log: devono restituire, non stampare — la stampa la fai fuori.

Lezione 4

Oggetti e interfacce: il tipo Punto

Fin qui una coordinata erano due variabili separate, x e y, che bisognava ricordarsi di tenere insieme. Oggi diventano una cosa sola con un nome e un tipo. È il dato su cui gira tutto il progetto finale: dalla prossima lezione non scriveremo quasi più altro.

30' Oggetti · interface · array di oggetti · readonly · ?. e ??
15' Il docente scrive Punto e la funzione che trova il centro della mano
60' Esercizi 4.1 → 4.5
15' Errori tipici · verifica veloce · compito

Teoria

A cosa serve. Prova a immaginare di scrivere il progetto finale con quello che sai adesso: ventuno valori orizzontali in un array, ventuno verticali in un altro, e la promessa di non sbagliare mai l'accoppiamento. Basta un ordinamento su un array solo e la mano si smonta. Un oggetto tiene insieme i valori che descrivono la stessa cosa, e li tiene insieme per costruzione.

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 }

Fra graffe si elencano le proprietà: un nome, due punti, un valore, separate da virgola. Si leggono con il punto: polso.x. È tutto qui. Se la variabile è let, o se la proprietà non è protetta, si possono anche modificare:

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; }'.

Quell'ultimo errore è già TypeScript che lavora: ha dedotto da solo la forma dell'oggetto e non accetta proprietà inventate. Però quella forma è scritta in un punto solo, e se domani ci servisse in dieci funzioni diverse dovremmo ricopiarla dieci volte. Per questo esiste l'interface: dà un nome a una forma.

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);

Dentro l'interfaccia le proprietà si separano con punto e virgola (o a capo) e non con la virgola come negli oggetti: è la confusione numero uno della lezione. Da qui in poi Punto è un tipo a tutti gli effetti, e si usa come number o string: come tipo di una variabile, di un parametro, di un valore di ritorno, e dentro un 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 si legge da sinistra a destra: prendi l'elemento di indice 1, e di quell'oggetto prendi la proprietà x. Questa riga, con punti[8].x, comparirà nel progetto vero decine di volte: 8 è la punta dell'indice.

I punti veri della mano, da incollare Questi valori sono realistici: sono nove dei ventuno punti di una mano destra aperta, inquadrata di fronte, con le dita verso l'alto. Usali negli esercizi di oggi.
interface Punto {
  x: number;
  y: number;
  z?: number;
}

const mano: Punto[] = [
  { x: 0.50, y: 0.88 },   // 0  polso
  { x: 0.42, y: 0.84 },   // 1  base del pollice
  { x: 0.30, y: 0.64 },   // 2  punta del pollice   (nel modello vero è il punto 4)
  { x: 0.44, y: 0.60 },   // 3  nocca dell'indice   (nel modello vero è il 5)
  { x: 0.40, y: 0.37 },   // 4  punta dell'indice   (nel modello vero è l'8)
  { x: 0.51, y: 0.58 },   // 5  nocca del medio     (nel modello vero è il 9)
  { x: 0.51, y: 0.33 },   // 6  punta del medio     (nel modello vero è il 12)
  { x: 0.63, y: 0.63 },   // 7  nocca del mignolo   (nel modello vero è il 17)
  { x: 0.70, y: 0.45 },   // 8  punta del mignolo   (nel modello vero è il 20)
];

Una proprietà può essere dichiarata readonly: si legge, non si riscrive. Serve a dire «questo dato, una volta creato, non si tocca».

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.

Adesso il pezzo più importante della lezione, e quello che separa chi scriverà programmi solidi da chi passerà le serate a cercare bug: i valori che potrebbero non esserci. Ce ne sono due, e significano cose leggermente diverse:

Nel progetto finale entrambi compaiono: let stream: MediaStream | null = null significa «la webcam adesso è spenta, e non è una dimenticanza». Il problema è che leggere una proprietà di un valore che non c'è fa esplodere il programma:

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

Ci sono tre modi di gestirlo, in ordine di eleganza. Il primo è l'accesso sicuro ?.: prova a leggere, e se davanti non c'è niente restituisce undefined invece di esplodere.

let punta: Punto | undefined = undefined;

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

Il secondo è il valore di riserva ??, che si legge «altrimenti»: se quello che sta a sinistra è undefined o null, prende quello a destra. I due si usano quasi sempre insieme:

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
?? non è || Sembrano intercambiabili e non lo sono. || scatta su qualunque valore «vuoto», e per lui anche lo zero è vuoto: 0 || 0.5 dà 0.5. Con le coordinate della mano è un disastro, perché 0 è il bordo sinistro dell'immagine, un valore legittimo. 0 ?? 0.5 invece dà 0, che è la risposta giusta. Regola: per i valori mancanti si usa ??.

Il terzo modo è il restringimento del tipo: un if che controlla, e dentro il quale il compilatore sa che il valore c'è. È il più chiaro quando devi usare il valore più di una volta:

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

Il compilatore segue il ragionamento: dopo quel return, l'unico modo per essere ancora lì è che punto non sia undefined. Non è magia, è la stessa cosa che avresti pensato tu leggendo il codice — solo che la controlla lui.

interface o type? Si può scrivere anche type Punto = { x: number; y: number }, e per i casi di questo corso le due forme sono equivalenti. Convenzione del corso, per non doverci più pensare: interface per descrivere la forma di un oggetto, type per gli alias e per i tipi unione come type Gesto = "pugno" | "mano aperta", che con interface non si possono nemmeno scrivere.

Demo del docente (15')

  1. Scrivi interface Punto alla lavagna e poi nel Playground. Crea polso e punta dell'indice con i valori realistici visti sopra.
  2. Metti il z e togli il z, mostrando che con z? entrambe le versioni sono valide.
  3. Scrivi centro(punti: Punto[]): Punto dal vivo, riusando l'idea della media della lezione 3. Falla restituire un oggetto: è la prima funzione del corso che restituisce qualcosa di composto.
  4. Apri la demo della mano, mostra i puntini bianchi sull'anteprima e di': «ognuno di quei puntini è un oggetto come quello che abbiamo appena scritto; sono ventuno, e arrivano trenta volte al secondo».
  5. Scrivi punti[99].x e fai vedere l'esplosione in esecuzione. Poi sistemala con punti[99]?.x ?? 0.

Laboratorio (60')

Esercizio 4.1 — Il tipo Punto base

Definisci l'interfaccia Punto con x e y obbligatori e z facoltativo. Poi crea due variabili: il polso in (0.50, 0.88) e la punta dell'indice in (0.40, 0.37), quest'ultima con anche z uguale a -0.02. Stampa una frase che le descriva entrambe.

Soluzione
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}`);

Perché così. Sul polso non abbiamo messo z ed è lecito, perché è facoltativa. Proprio per questo, quando la si legge, si usa ?? 0: polso.z sarebbe undefined, e stampare «profondità: undefined» non aiuta nessuno.

Nel progetto vero la z c'è sempre ed è la profondità rispetto al polso: negativa significa più vicino alla camera. È la coordinata meno precisa delle tre, perché una sola telecamera non vede davvero in profondità: la stima.

Esercizio 4.2 — Il centro della mano medio

Scrivi centro(punti: Punto[]): Punto, che restituisce il punto medio di un elenco: la x del risultato è la media delle x, la y è la media delle y. Provala sull'array mano dato nella teoria.

Soluzione
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)

Perché così. Si scorre l'array una volta sola accumulando due somme: fare due cicli separati funzionerebbe ma sarebbe lavoro doppio. Il return costruisce un oggetto nuovo sul momento — non serve dichiararlo prima in una variabile.

Nel progetto vero il centro serve a decidere dove piazzare la mano nella scena, e a capire se si sta spostando: se il centro si muove molto da un fotogramma all'altro, la mano è in movimento veloce, e il filtro anti-tremolio della lezione 9 si comporterà di conseguenza.

Esercizio 4.3 — Il punto più in alto medio

Scrivi piuAlto(punti: Punto[]): Punto, che restituisce il punto più in alto dell'elenco. Provala sull'array mano: deve restituire la punta del medio, cioè { x: 0.51, y: 0.33 }.

La y cresce verso il BASSO Questa è la trappola più costosa di tutto il progetto, e ci cascano tutti almeno una volta. Nelle immagini, e sullo schermo, l'origine sta in alto a sinistra: y = 0 è il bordo superiore, y = 1 è quello inferiore. Quindi «più in alto» vuol dire «y più piccola», e la funzione va scritta cercando il minimo, non il massimo. A scuola il piano cartesiano andava all'incontrario; qui no. Nel progetto vero questa inversione si paga di nuovo nel passaggio alla scena tridimensionale, dove invece la y torna a crescere verso l'alto e va rovesciata un'altra volta.
Soluzione
function piuAlto(punti: Punto[]): Punto {
  let migliore = punti[0];

  for (const p of punti) {
    if (p.y < migliore.y) {   // y PIÙ PICCOLA = più in alto nell'immagine
      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)  → la punta del medio

Perché così. È il massimo dell'esercizio 2.4 con il confronto girato e, soprattutto, con gli oggetti al posto dei numeri: teniamo da parte l'intero punto, non la sola coordinata, così chi chiama la funzione ottiene anche la x senza doverla ricercare. Il commento sulla riga del confronto non è decorativo: fra sei mesi sarà l'unica cosa che ti eviterà di «correggere» quel < in >.

Chi finisce presto: fai restituire anche l'indice, come nella lezione 2. Dovrai usare il for con il contatore e restituire un oggetto con due proprietà, per esempio { punto: …, indice: … }.

Esercizio 4.4 — Un punto che potrebbe non esserci medio

Il riconoscitore, quando la mano esce dall'inquadratura, non restituisce ventuno punti: non ne restituisce nessuno. Simuliamo la cosa.

Scrivi puntoOppureNiente(punti: Punto[], indice: number): Punto | undefined, che restituisce il punto richiesto oppure undefined se l'indice è fuori dall'elenco. Poi scrivi descrivi(punto: Punto | undefined): string che restituisce "x: 0.4" se il punto c'è, e "punto assente" se non c'è. Provale con un indice valido e con l'indice 99.

Soluzione
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

// la versione corta, quando serve solo un numero:
const x = puntoOppureNiente(mano, 99)?.x ?? 0;
console.log(x);   // 0

Perché così. Il controllo dell'indice usa || perché basta che una delle due condizioni sia vera per essere fuori. Dentro descrivi si è usato il restringimento con if invece di ?.: quando il punto va usato più volte è più leggibile, e si evita una fila di punti interrogativi.

Le ultime due righe mostrano la forma compatta: ?. per non esplodere e ?? per il valore di riserva. È esattamente la scrittura che troverai nel progetto vero, dove una riga come result.gestures[i]?.[0]?.categoryName ?? "None" significa «il nome del gesto, se c'è; altrimenti nessuno».

Esercizio 4.5 — L'oggetto Mano sfida

Un oggetto può contenere un array, e le sue proprietà possono avere tipi unione. Definisci:

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

Crea una mano destra con i punti dell'array mano della teoria e senza gesto, e una mano sinistra con due soli punti e gesto "pugno". Poi scrivi riassumi(m: Mano): string che produce una riga come:

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

Per arrotondare a tre decimali usa .toFixed(3) su un numero. Riusa la funzione centro dell'esercizio 4.2.

Soluzione
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

Perché così. m.gesto ?? "nessuno" in una riga risolve la proprietà facoltativa. Il tipo unione su lato impedisce di scrivere "Destra" con la maiuscola o "dx": con un string generico passerebbero entrambi, e il confronto m.lato === "destra" più avanti fallirebbe in silenzio.

Nel progetto vero questa è quasi esattamente la struttura che arriva dal riconoscitore: per ogni mano inquadrata ci sono il lato, i ventuno punti e, facoltativo, il nome del gesto riconosciuto fra quelli noti. E il lato, nel codice vero, è un tipo unione identico a questo.

Errori tipici in aula

Verifica veloce (5 domande)

Domande e risposte
  1. Cosa significa z?: number dentro un'interfaccia? → Che la proprietà è facoltativa: se manca vale undefined.
  2. Cosa restituisce punti[99]?.x se l'array ha 21 elementi? → undefined, senza far esplodere il programma.
  3. Nell'immagine, qual è il punto più in alto: quello con y 0.2 o quello con y 0.8? → Quello con y 0.2. La y cresce verso il basso.
  4. Perché 0 ?? 5 dà 0 mentre 0 || 5 dà 5? → Perché ?? scatta solo su null e undefined, || su qualunque valore considerato «vuoto», zero compreso.
  5. Si può modificare una proprietà di un oggetto dichiarato con const? → Sì, a meno che non sia readonly. const protegge il nome, non il contenuto.

Compito per casa (30-40 minuti)

Scrivi dimensioneMano(punti: Punto[]): number, che restituisce la distanza fra il primo punto dell'array (il polso) e quello di indice 5 (la nocca del medio, nel nostro array ridotto), riusando la funzione distanza della lezione 3 — adattala però perché riceva due Punto invece di quattro numeri. Poi scrivi aperta(punti: Punto[]): boolean, che risponde true se la punta del medio sta più in alto della nocca del medio di almeno un quarto della dimensione della mano. Provala sull'array mano e su una versione modificata in cui hai abbassato la punta del medio a y: 0.56.

Lezione 5

La pagina e il canvas: disegnare una mano finta

Fin qui il programma parlava solo attraverso console.log. Oggi si passa a una pagina vera e a una tela su cui disegnare, e alla fine delle due ore sullo schermo comparirà una mano. Finta, ferma, disegnata da te: ma è la stessa mano che fra quattro lezioni si muoverà seguendo la tua.

30' La pagina · prendere un elemento · il contesto 2D · cerchi, linee, colori · le coordinate
15' Il docente apre StackBlitz e disegna un cerchio dal nulla, poi collega un pulsante
60' Esercizi 5.1 → 5.5
15' Errori tipici · verifica veloce · compito

Teoria

A cosa serve. Un programma che stampa numeri in una console non lo guarda nessuno. Il progetto finale è una pagina web: c'è un pulsante, c'è un riquadro con la webcam e c'è una tela su cui compare la mano. Oggi impariamo i due meccanismi che tengono insieme il codice e quello che si vede: prendere un elemento della pagina e disegnare dentro di esso.

Cominciamo dalla pagina. Una pagina HTML è fatta di tag, che si aprono e si chiudono e contengono altri tag. Questa è la più piccola che ci serva:

<!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> contiene le informazioni sulla pagina, <body> quello che si vede. Il <canvas> è la tela: un rettangolo vuoto, qui largo 480 pixel e alto 640, dentro cui il codice può disegnare. L'id è il nome con cui il codice lo cercherà: deve essere unico nella pagina. L'ultima riga carica il nostro programma.

Su StackBlitz vite.new/vanilla-ts apre in una decina di secondi un progetto già pronto, con l'anteprima a destra che si aggiorna da sola a ogni salvataggio. Due file interessano: index.html, di cui sostituirai il contenuto del <body>, e src/main.ts, che puoi svuotare completamente prima di iniziare. È lo stesso identico strumento che si installerà sul proprio computer nella lezione 6: non si butta via niente.

Adesso il codice deve mettere le mani su quel <canvas>. Lo si chiede alla pagina per nome:

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

document è la pagina, vista dal codice. getElementById cerca l'elemento con quell'id. Se però provi a usarlo, TypeScript si mette di traverso:

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

Due proteste, e hanno tutte e due ragione:

  1. Potrebbe non esserci. Il tipo di ritorno è HTMLElement | null: quel tipo unione dice «un elemento, oppure niente». Se hai scritto male l'id, o se il codice parte prima che la pagina sia pronta, la risposta è null. È lo stesso null della lezione 4.
  2. Non sa che tipo di elemento è. Sa che è un elemento della pagina, HTMLElement, e basta. Ma un titolo non ha una larghezza di disegno, un canvas sì: finché il compilatore non sa che è un canvas, non ti fa usare le cose da canvas.

Il secondo problema si risolve con as, che si legge «trattalo come»: è una promessa che fai tu al compilatore.

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) si legge «se tela NON c'è»: è il punto esclamativo della lezione 2, quello che capovolge. Dopo quel blocco il compilatore sa che tela esiste — è lo stesso restringimento della lezione 4.

as è una promessa, non un controllo Scrivere as HTMLCanvasElement non verifica niente: convince il compilatore e basta. Se nell'HTML quell'id è su un <div>, il programma si romperà lo stesso, solo più tardi e con un messaggio più oscuro. Si usa as quando sai com'è fatta la pagina perché l'hai scritta tu — ed è esattamente il caso del nostro progetto, dove gli id sono scritti a mano e non cambiano mai.

Sulla tela non si disegna direttamente: si chiede il contesto, cioè l'oggetto che possiede tutti gli attrezzi da disegno. Ne esistono di diversi; a noi serve quello bidimensionale.

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

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

Anche questo può restituire null (per esempio se alla stessa tela fosse già stato chiesto un contesto di tipo diverso), quindi si controlla come prima. Da qui in poi ctx è l'oggetto con cui si disegna tutto.

Le coordinate. Prima di disegnare bisogna sapere dove finiranno le cose. Il canvas ha l'origine in alto a sinistra: la x cresce verso destra, la y cresce verso il basso. È la stessa convenzione delle immagini vista nella lezione 4 — e per una volta questo è un vantaggio: le coordinate del riconoscitore, moltiplicate per la dimensione della tela, finiscono al posto giusto senza rovesciare niente.

// 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

Disegnare un cerchio. Serve per le articolazioni della mano. Sono tre righe, sempre le stesse:

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

Gli argomenti di arc, in ordine: centro x, centro y, raggio, angolo di partenza, angolo di arrivo. 0 e Math.PI * 2 sono l'inizio e la fine del giro completo: è la ricetta per «cerchio intero», e si copia così com'è.

beginPath non è facoltativo Senza, ogni nuova figura si aggiunge a quelle precedenti nello stesso disegno: cambiare colore le ricolora tutte, e i cerchi risultano collegati da linee misteriose. Se disegnando i ventuno punti della mano ottieni una ragnatela, la causa è questa. Regola: una figura, un beginPath.

Disegnare una linea. Serve per le ossa. Stesso schema:

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

La differenza fra fill e stroke è la stessa fra riempire e contornare: fillStyle/fill per il dentro, strokeStyle/stroke per il bordo. I colori si scrivono in esadecimale ("#38bdf8") o per nome ("white"). E niente viene disegnato davvero finché non chiami fill o stroke: le istruzioni prima si limitano a descrivere la forma.

Cancellare. Quando la mano si muove non si disegna sopra alla precedente: si pulisce tutto e si ridisegna da capo, trenta volte al secondo.

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

Rispondere a un clic. Ultimo attrezzo di oggi. Si prende il pulsante come si è preso il canvas, e gli si dice cosa fare quando viene premuto:

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

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

addEventListener vuole due cose: il nome dell'evento ("click") e una funzione da eseguire quando succede. Quella funzione è una funzione freccia senza parametri, della lezione 3. Nessuno la chiama esplicitamente: la chiama il browser, quando e se l'utente preme. Nel progetto finale è esattamente così che parte la webcam, dal clic sul pulsante «Accendi la camera».

Demo del docente (15')

  1. Apri vite.new/vanilla-ts davanti alla classe: far vedere che in dieci secondi c'è un progetto funzionante toglie l'ansia dell'installazione.
  2. Svuota src/main.ts e il <body> di index.html, metti il <canvas>, salva: la pagina resta bianca. «La tela c'è, è vuota.»
  3. Scrivi getElementById senza as e mostra i due errori rossi. Aggiungi as e il controllo, e commentali: sono i due problemi che il compilatore ha appena evitato.
  4. Disegna un cerchio. È il momento «funziona!» della lezione: aspetta due secondi prima di continuare.
  5. Disegna tre cerchi dimenticando beginPath e fai vedere la ragnatela. Poi aggiungilo.
  6. Collega un pulsante che cambia il colore del cerchio e ridisegna.

Laboratorio (60')

Esercizio 5.1 — Il primo cerchio e la prima linea base

Su vite.new/vanilla-ts: metti nel <body> di index.html un canvas 480 × 640 con id="tela", svuota src/main.ts e disegna un cerchio azzurro al centro della tela e una linea rosa dall'angolo in alto a sinistra al centro.

<body>
  <canvas id="tela" width="480" height="640"></canvas>
  <script type="module" src="/src/main.ts"></script>
</body>
Soluzione
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");
}

// il cerchio azzurro al centro
ctx.beginPath();
ctx.arc(240, 320, 20, 0, Math.PI * 2);
ctx.fillStyle = "#38bdf8";
ctx.fill();

// la linea rosa dall'angolo al centro
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(240, 320);
ctx.strokeStyle = "#f472b6";
ctx.lineWidth = 4;
ctx.stroke();

Perché così. I due controlli in cima non sono burocrazia: sono la differenza fra un messaggio che ti dice cosa è andato storto e una pagina bianca silenziosa. Il centro è (240, 320) perché la tela è 480 × 640: metà e metà.

Esercizio 5.2 — I ventuno punti di una mano finta medio

Ecco una mano vera, in coordinate da 0 a 1: sono i ventuno punti di una destra aperta con le dita verso l'alto, nell'ordine esatto usato dal progetto finale. Copiali.

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

const MANO_APERTA: Punto[] = [
  { x: 0.50, y: 0.88 },   //  0 polso
  { x: 0.42, y: 0.84 },   //  1 pollice, base
  { x: 0.36, y: 0.77 },   //  2 pollice
  { x: 0.33, y: 0.70 },   //  3 pollice
  { x: 0.30, y: 0.64 },   //  4 pollice, PUNTA
  { x: 0.44, y: 0.60 },   //  5 indice, nocca
  { x: 0.42, y: 0.50 },   //  6 indice
  { x: 0.41, y: 0.43 },   //  7 indice
  { x: 0.40, y: 0.37 },   //  8 indice, PUNTA
  { x: 0.51, y: 0.58 },   //  9 medio, nocca
  { x: 0.51, y: 0.47 },   // 10 medio
  { x: 0.51, y: 0.40 },   // 11 medio
  { x: 0.51, y: 0.33 },   // 12 medio, PUNTA
  { x: 0.57, y: 0.60 },   // 13 anulare, nocca
  { x: 0.58, y: 0.49 },   // 14 anulare
  { x: 0.59, y: 0.43 },   // 15 anulare
  { x: 0.60, y: 0.37 },   // 16 anulare, PUNTA
  { x: 0.63, y: 0.63 },   // 17 mignolo, nocca
  { x: 0.66, y: 0.55 },   // 18 mignolo
  { x: 0.68, y: 0.50 },   // 19 mignolo
  { x: 0.70, y: 0.45 },   // 20 mignolo, PUNTA
];

Scrivi aPixel (è quella della lezione 3) e disegna un cerchio azzurro di raggio 6 per ognuno dei ventuno punti. Usa tela.width e tela.height, non i numeri 480 e 640 scritti a mano.

Soluzione
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();
}

Perché così. for…of perché qui serve solo il valore, non la posizione. beginPath sta dentro il ciclo: uno per cerchio, altrimenti i ventuno punti si collegano fra loro. E usare tela.width invece di 480 significa che cambiando la dimensione nell'HTML il disegno si adatta da solo, senza toccare il codice.

Dovresti già intravedere la forma di una mano. Non è ancora una mano: mancano le ossa.

Esercizio 5.3 — Le ossa medio

Un dito è fatto di punti collegati fra loro. Quali punti vadano collegati a quali è una informazione fissa, che nel progetto vero arriva dalla libreria di riconoscimento: queste sono le ventuno connessioni, identiche a quelle usate nell'applicazione.

const CONNESSIONI: number[][] = [
  [0, 1], [1, 2], [2, 3], [3, 4],           // pollice
  [0, 5], [5, 6], [6, 7], [7, 8],           // indice
  [5, 9], [9, 10], [10, 11], [11, 12],      // medio
  [9, 13], [13, 14], [14, 15], [15, 16],    // anulare
  [13, 17], [17, 18], [18, 19], [19, 20],   // mignolo
  [0, 17],                                  // il lato del palmo
];

Ogni elemento è un array di due numeri: gli indici dei due punti da unire con una linea. Disegna tutte le ossa in azzurro con spessore 4, prima dei cerchi, così i punti restano sopra.

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

// 1) le ossa, sotto
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) le articolazioni, sopra
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();
}

Perché così. osso[0] e osso[1] sono i due indici; usarli per pescare dentro MANO_APERTA è la riga chiave dell'esercizio. L'ordine conta: il canvas disegna in ordine di istruzione, quindi quello che viene dopo copre quello di prima. Ossa prima, articolazioni dopo.

strokeStyle e lineWidth sono impostati una volta fuori dal ciclo: valgono finché non vengono cambiati, non c'è bisogno di ripeterli ventuno volte.

Adesso sullo schermo c'è una mano. L'hai disegnata tu: è la stessa figura che nella lezione 8 comincerà a muoversi.

Esercizio 5.4 — Punte bianche medio

Nel progetto vero le cinque punte delle dita — gli indici 4, 8, 12, 16 e 20 — sono bianche e un po' più grandi delle altre articolazioni: servono a leggere il gesto a colpo d'occhio. Fai la stessa cosa.

Ti servirà il for con l'indice, non for…of: devi sapere in che posizione sei. Suggerimento: const PUNTE = [4, 8, 12, 16, 20]; e poi PUNTE.includes(i), che risponde true se il numero è nell'elenco.

Soluzione
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();
}

Perché così. Qui serve l'indice perché l'informazione «è una punta» sta nella posizione, non nel valore: è lo stesso motivo dell'esercizio 2.4.

punta ? 9 : 6 è una scrittura nuova, l'operatore condizionale: si legge «se punta è vero allora 9, altrimenti 6». È un if/else che sta dentro un'espressione e restituisce un valore. Si usa solo per scelte piccolissime come questa; per qualunque cosa più complessa resta più leggibile un if normale.

Esercizio 5.5 — Il pulsante che cambia posa sfida

Aggiungi un pulsante alla pagina. A ogni clic la mano passa da aperta a pugno e viceversa, ridisegnando tutto da capo.

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

Ecco la seconda posa, con le dita chiuse verso il palmo:

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 },
];

Il pezzo difficile è organizzare il codice: metti tutto il disegno dentro una funzione disegna(punti: Punto[]): void, così puoi richiamarla con una posa o con l'altra.

Soluzione
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);
  });
}

Perché così. La funzione disegna riceve i punti come parametro invece di leggere una variabile globale: così la stessa funzione serve per tutte e due le pose, e domani servirà anche per i punti veri della webcam senza cambiare una riga. È il motivo per cui esistono i parametri.

aperta = !aperta capovolge il booleano a ogni clic: è la scrittura standard per un interruttore. E il clearRect in cima a disegna è indispensabile: senza, le due pose si accumulerebbero sulla tela e dopo tre clic si vedrebbe un groviglio.

Chi finisce presto: disegna il palmo. È un poligono che unisce i punti 0, 1, 5, 9, 13 e 17: si fa con moveTo sul primo, lineTo su tutti gli altri, ctx.closePath() e ctx.fill(). È esattamente quello che fa il progetto vero, dove il palmo è un poligono semitrasparente.

Il momento della lezione L'esercizio 5.3 è il punto di svolta di tutto il corso: per la prima volta il lavoro delle quattro lezioni precedenti — variabili, cicli, array, funzioni, oggetti — produce una cosa che si guarda. Vale la pena fermarsi, far girare il docente fra i banchi e far vedere a tutti lo schermo di tutti. Da qui in poi, quando qualcuno chiederà «a cosa serve?», basterà indicare lo schermo.

Errori tipici in aula

Verifica veloce (5 domande)

Domande e risposte
  1. Perché document.getElementById restituisce HTMLElement | null? → Perché quell'id potrebbe non esistere nella pagina: il tipo lo dice, e obbliga a controllare.
  2. Cosa fa as HTMLCanvasElement? → Convince il compilatore che quell'elemento è un canvas. Non verifica niente: è una promessa di chi scrive.
  3. A cosa serve beginPath? → A iniziare una figura nuova, staccata dalla precedente. Senza, le figure si uniscono.
  4. Dove si trova il punto (0, 0) di un canvas? → In alto a sinistra. La y cresce verso il basso.
  5. Che differenza c'è fra fill e stroke? → fill riempie l'interno della figura, stroke ne traccia il contorno.

Compito per casa (40 minuti)

Parti dalla soluzione dell'esercizio 5.5 e aggiungi una terza posa a tua scelta — il gesto della vittoria, il pollice in su, la mano che saluta — inventando tu le ventuno coordinate. Il pulsante deve ruotare fra le tre pose invece di alternarne due: tieni un let posa = 0, portalo avanti a ogni clic e riportalo a zero quando arriva a tre. Mettendo le tre pose in un array di array — const POSE: Punto[][] = [MANO_APERTA, PUGNO, LA_TUA] — il codice diventa di tre righe. Porta il link di StackBlitz alla prossima lezione: è l'ultima volta che si lavora nel browser, perché la sesta lezione si apre installando gli strumenti veri sul proprio computer.

Lezione 6

Sul proprio computer: Node, Vite, moduli

Fino a ieri il codice girava dentro una pagina web altrui. Da oggi il progetto è tuo: sta in una cartella sul tuo computer, si avvia con un comando e si divide in più file. È il passaggio che rende possibile tutto il resto del corso.

30' Perché serve un progetto vero · Node e npm · Vite · i file di configurazione · moduli
15' Il docente crea un progetto da zero davanti alla classe, dal terminale vuoto alla pagina che si apre
60' Esercizi 6.1 → 6.6 (installazione guidata e trasloco del codice della lezione 5)
15' Checklist di verifica · errori visti in aula · compito

Teoria

Nelle prime cinque lezioni hai scritto codice dentro una pagina che qualcun altro teneva aperta per te. Ha funzionato benissimo per imparare, ma adesso c'è un ostacolo preciso: la webcam non si accende dentro l'anteprima degli ambienti online. Il riquadro che mostra il risultato è una pagina incorniciata dentro un'altra, che arriva da un sito diverso, e i browser negano camera e microfono a quei riquadri. Non è un difetto dell'ambiente: è una regola pensata per impedire che un sito qualunque accenda la tua telecamera nascondendosi dentro una cornice.

Dalla prossima lezione ti serve la webcam. Quindi oggi ti serve un progetto vero.

Node.js: JavaScript fuori dal browser

Node.js è un programma che esegue JavaScript sul tuo computer, senza browser. A te non serve per far girare la mano — quella girerà nel browser come sempre — ma serve agli strumenti: il compilatore di TypeScript, il server di sviluppo, il programma che mette insieme i file. Sono tutti scritti in JavaScript e girano su Node.

Insieme a Node si installa npm (node package manager), che fa due cose:

Non confondere i due mondi Node gira sul tuo computer e sa aprire file e cartelle, ma non sa cos'è una pagina web: lì document e window non esistono. Il browser è il contrario. Il tuo codice della mano vive nel browser; Node lo prepara e glielo serve.

Vite: il progetto già pronto

Creare a mano la cartella giusta, il file di configurazione giusto e il server giusto è noioso e non insegna niente. Vite lo fa per te con un comando:

npm create vite@latest mano-corso

Ti fa due domande. Alla prima (Select a framework) scegli Vanilla, che vuol dire «niente framework, solo la pagina e il codice». Alla seconda (Select a variant) scegli TypeScript. In breve si dice «il modello vanilla-ts», e lo puoi anche chiedere direttamente:

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

Dentro la cartella appena creata trovi questo:

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

In fondo a index.html c'è una riga sola che collega la pagina al codice:

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

Poi due comandi, sempre gli stessi, per tutto il resto del corso:

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 stampa un indirizzo, di solito http://localhost:5173. Lo apri nel browser e vedi la tua pagina. Il server resta acceso: lascia quella finestra del terminale aperta e scrivi il codice nell'altra finestra. Ogni volta che salvi, la pagina si aggiorna da sola in una frazione di secondo. Per fermarlo: Ctrl + C.

localhost vuol dire «questo computer». Nessuno, da fuori, vede quella pagina. Ed è anche un indirizzo di cui il browser si fida: come vedrai la prossima lezione, la webcam si accende solo su https://… o su localhost, e questo è il motivo per cui il progetto locale funziona subito.

I due file di configurazione

package.json è un file di testo che descrive il progetto: il nome, le librerie che usa e i comandi disponibili.

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

Quello che sta in scripts sono scorciatoie: npm run dev esegue quello che c'è scritto accanto a "dev". Quello che sta in devDependencies sono gli strumenti scaricati da npm install. Il numero accanto al nome è la versione, e conta: alla lezione 8 vedrai cosa succede quando una libreria cambia sotto i piedi di un progetto.

tsconfig.json dice al compilatore quanto essere severo. La riga che ci interessa oggi è una:

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

Con "strict": true TypeScript smette di lasciar correre. I due controlli che noterai subito:

// 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");
Perché accettare di farsi sgridare Ogni errore che strict ti fa comparire adesso è un errore che non comparirà alla lezione 8, mentre muovi la mano davanti alla webcam e non capisci perché lo schermo resta nero. Il momento giusto per scoprire che una cosa può essere null è quando la scrivi.

Dividere il codice in file: export e import

Il progetto vero della mano — quello che vedi online — è diviso in cinque file, ognuno con un compito: accendere la camera, riconoscere la mano, smorzare il tremolio, disegnare, mettere tutto in fila. Non è pignoleria: è l'unico modo per ritrovare le cose.

Un file diventa un modulo nel momento in cui exporta qualcosa. Quello che non è esportato resta privato di quel 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);

Tre cose da notare, e sono sempre le stesse tre:

  1. fra graffe si scrivono gli stessi nomi usati nell'export: se lì è disegnaPunti, qui non può essere DisegnaPunti;
  2. il percorso comincia con ./ (stessa cartella) o ../ (cartella sopra), e l'estensione .ts non si scrive;
  3. per importare un tipo si scrive type davanti al nome: dice al compilatore «questo serve solo a te, non finirà nel codice eseguito».

Cosa fa il compilatore, davvero

Il browser non sa cos'è TypeScript. Non ha mai saputo cosa sono i tipi e non li imparerà. Quello che succede è più semplice di quanto sembri: i tipi vengono cancellati.

// 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);
}

Identico, meno le annotazioni. Da qui discendono due conseguenze che vale la pena fissare adesso:

In sviluppo questa trasformazione la fa Vite, al volo, a ogni salvataggio. Il controllo dei tipi invece lo fa tsc, che gira quando lanci npm run build e in continuazione dentro VS Code, che è il motivo per cui le sottolineature rosse compaiono mentre scrivi.

Demo del docente (15')

  1. Terminale vuoto: node -v e npm -v, per mostrare che ci sono.
  2. npm create vite@latest demo-aula -- --template vanilla-ts, poi npm install e npm run dev: dal niente alla pagina aperta, in meno di due minuti.
  3. Cambia una riga in src/main.ts, salva e fai vedere la pagina che si aggiorna da sola mentre nessuno la ricarica.
  4. Crea src/saluta.ts con dentro export function saluta(nome: string) { … } e importala in main.ts. Poi sbaglia apposta: togli l'export e leggi l'errore insieme alla classe.
  5. Apri tsconfig.json, metti "strict": true e mostra la riga che diventa rossa.

Laboratorio (60')

Piano B, da annunciare all'inizio del laboratorio Su un portatile della scuola o con un account senza permessi di amministratore, l'installazione può non riuscire — e succede. Chi si blocca non resta indietro: apre vite.new/vanilla-ts, che è esattamente lo stesso progetto vanilla-ts ma dentro il browser, con terminale e anteprima. Gli esercizi da 6.3 in poi si fanno identici, riga per riga. Per la lezione 7 servirà la webcam: chi è rimasto online lavora in coppia con un compagno che ha il progetto in locale, o prova il codice sul filmato registrato dal docente. L'anteprima di StackBlitz in una scheda separata accende la camera, ma è l'ultima possibilità e va detto perché: quella pagina arriva dai server di StackBlitz e non ha la CSP del nostro progetto. Prendi nota di chi è in questa situazione: da lì in poi si lavora a coppie fisse, non improvvisate ogni volta.

Esercizio 6.1 — Installare gli strumenti base

Tre installazioni, in quest'ordine. Se una macchina ce le ha già, salta e passa all'esercizio 6.2.

  1. Node.js, versione LTS (quella consigliata, non la «Current»): nodejs.org. Avanti, avanti, fine: non cambiare nessuna opzione.
  2. Visual Studio Code: code.visualstudio.com.
  3. In VS Code, due estensioni utili (icona dei quadratini nella barra a sinistra): Italian Language Pack se preferisci i menu in italiano, e Prettier, che rimette in ordine il codice da solo quando salvi. Il supporto a TypeScript è già dentro VS Code: non c'è niente da installare per quello.

Poi apri il terminale di VS Code (menu Terminale → Nuovo terminale) e scrivi:

node -v
npm -v
Cosa devi vedere
v22.20.0     # un numero pari ≥ 20, il tuo può essere diverso
10.9.3       # qualunque numero ≥ 9

Se invece compare «node non è riconosciuto come comando», quasi sempre basta chiudere e riaprire VS Code: il terminale aveva già in memoria l'elenco dei programmi di prima dell'installazione. Se dopo la riapertura l'errore resta, passa al piano B e riprova a casa: non perdere l'ora di laboratorio.

Esercizio 6.2 — Il tuo progetto base

Crea un progetto chiamato mano-corso, installa gli strumenti e avvialo. Poi apri l'indirizzo che compare e fai vedere la pagina al tuo vicino di banco.

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

Il terminale scrive Local: http://localhost:5173/. In molti terminali si apre con Ctrl + clic sull'indirizzo. La finestra con npm run dev resta aperta per tutta la lezione: se la chiudi, la pagina smette di rispondere.

Esercizio 6.3 — Fai piazza pulita base

Il progetto appena creato contiene un esempio con i loghi di Vite. Non serve. Riduci index.html alla pagina che ti servirà davvero — un titolo, un pulsante e un canvas — e svuota src/main.ts lasciando solo l'importazione del foglio di stile e un console.log. Controlla nella console del browser (F12) che il messaggio compaia.

Soluzione
<!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");

Cancella pure src/counter.ts e src/typescript.svg: se poi la pagina si lamenta di non trovarli, vuol dire che era rimasta un'importazione da togliere. È un ottimo primo errore da leggere.

Esercizio 6.4 — Il trasloco della lezione 5 medio

Riprendi il codice della lezione 5 — la mano finta disegnata sul canvas — e portalo qui dentro, diviso in due file:

  • src/disegna.ts: l'interfaccia Punto, la funzione che disegna i punti e quella che disegna le ossa. Tutte esportate.
  • src/main.ts: prende il canvas dalla pagina, importa le funzioni e le chiama.

Se nella lezione 5 avevi dato nomi diversi alle funzioni, tienili: l'esercizio è il trasloco, non la riscrittura.

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

/** Le 21 ossa della mano: ogni coppia dice quali due punti collegare. */
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);

Le funzioni sono rimaste identiche a quelle della lezione 5: l'unica cosa nuova sono le parole export e import. È esattamente così che è fatto il progetto vero.

Esercizio 6.5 — Il secondo modulo: i dati medio

Il file disegna.ts sa come si disegna; non deve sapere cosa. Crea allora src/punti.ts con dentro due cose esportate:

  • MANO_FINTA: i 21 punti inventati della lezione 5, ma con le coordinate fra 0 e 1, come arriveranno dalla webcam alla lezione 8;
  • aPixel(p, larghezza, altezza): la funzione della lezione 3 che converte un punto normalizzato in pixel.

Poi in main.ts converti i punti prima di disegnarli.

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

/** Una mano aperta inventata: coordinate fra 0 e 1, come quelle vere. */
export const MANO_FINTA: Punto[] = [
  { x: 0.50, y: 0.90, z: 0 },   // 0  polso
  { x: 0.40, y: 0.84, z: 0 },   // 1  pollice
  { 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  punta del pollice
  { x: 0.44, y: 0.62, z: 0 },   // 5  indice
  { 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  punta dell'indice
  { x: 0.52, y: 0.60, z: 0 },   // 9  medio
  { 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 punta del medio
  { x: 0.59, y: 0.62, z: 0 },   // 13 anulare
  { 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 punta dell'anulare
  { x: 0.65, y: 0.67, z: 0 },   // 17 mignolo
  { 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 punta del mignolo
];

/** Da coordinate 0…1 a pixel di una tela larga `larghezza` e alta `altezza`. */
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 — la parte che cambia
import { MANO_FINTA, aPixel } from "./punti";

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

Adesso la divisione dei compiti è netta: punti.ts tiene i dati, disegna.ts il disegno, main.ts mette in fila le due cose. Alla lezione 8 cambierà solo la prima riga: i punti finti diventeranno quelli veri della tua mano, e il disegno non se ne accorgerà nemmeno.

Esercizio 6.6 — Accendi strict sfida

Apri tsconfig.json, assicurati che ci sia "strict": true dentro compilerOptions, salva e guarda cosa si colora di rosso. Correggi tutto, poi lancia npx tsc --noEmit per avere la conferma dal compilatore invece che dall'editor.

Soluzione

I due errori che compaiono quasi sempre, e come si sistemano:

// 1) 'const ctx' is possibly 'null'
//    getContext("2d") restituisce null se il canvas non supporta il 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
//    dentro map il tipo di solito è dedotto; se non lo è, si scrive.
const punti = MANO_FINTA.map((p: Punto) => aPixel(p, tela.width, tela.height));

npx tsc --noEmit significa «controlla i tipi e non scrivere niente»: è lo stesso controllo che il progetto vero fa prima di ogni pubblicazione. Se non stampa niente, va tutto bene — i programmi di controllo, quando sono contenti, tacciono.

Checklist di fine lezione Prima di uscire dall'aula, ognuno deve poter rispondere sì a tutte e cinque:
  1. node -v stampa un numero;
  2. npm -v stampa un numero;
  3. nella cartella del progetto, npm run dev parte e stampa un indirizzo;
  4. http://localhost:5173 mostra la tua pagina, con la mano finta disegnata;
  5. salvando un file, la pagina si aggiorna da sola.
Chi non arriva a cinque lo dice adesso, non la settimana prossima: senza progetto che parte, la lezione 7 non si può seguire.

Errori tipici in aula

Verifica veloce (5 domande)

Domande e risposte
  1. Perché non possiamo restare sugli ambienti online? → Perché i browser negano la webcam alle anteprime incorniciate che arrivano da un altro sito, e dalla lezione 7 la webcam serve.
  2. Cosa fa npm install? → Legge package.json e scarica in node_modules le librerie e gli strumenti elencati lì.
  3. Cosa succede ai tipi quando il codice arriva al browser? → Vengono cancellati: il browser esegue JavaScript normale.
  4. Cosa serve per usare in main.ts una funzione scritta in disegna.ts? → export davanti alla funzione e import { … } from "./disegna" in cima al file.
  5. Cosa cambia accendendo strict? → Il compilatore non accetta più tipi impliciti e obbliga a gestire i casi in cui un valore può essere null.

Compito per casa (30-40 minuti)

Sistema il progetto e rendilo tuo: metti la mano finta al centro della tela qualunque sia la dimensione del canvas, aggiungi un terzo modulo src/colori.ts che esporta i colori usati dal disegno (così cambiando un file cambia tutto l'aspetto), e verifica che npx tsc --noEmit non dica niente. Porta la prossima volta il progetto che parte: la lezione 7 comincia con npm run dev già acceso.

Lezione 7

La webcam: async, permessi, fotogrammi

Oggi la tua pagina guarda. Per riuscirci servono tre cose nuove: chiedere qualcosa che richiede tempo (async e await), reagire quando va storto (try/catch) e ridisegnare trenta volte al secondo senza inceppare il browser.

30' Operazioni che richiedono tempo · async/await · errori · permessi · il ciclo dei fotogrammi
15' Il docente scrive dal vivo camera.ts: accendi, spegni, e la spia della webcam che si spegne davvero
60' Esercizi 7.1 → 7.6
15' Regole di riservatezza in aula · verifica veloce · compito

Teoria

Alcune cose non sono pronte subito

Finora ogni riga di codice dava il suo risultato immediatamente. Accendere la webcam no: il browser deve chiedere il permesso all'utente, che magari ci pensa cinque secondi, poi deve accendere una telecamera fisica. Nel frattempo il programma non può fermarsi: se si fermasse, la pagina si bloccherebbe e nemmeno il pulsante «Consenti» funzionerebbe.

La soluzione di JavaScript si chiama Promise: non il risultato, ma la promessa di un risultato. Una funzione come getUserMedia ti restituisce subito una promessa, e quella promessa prima o poi si mantiene (arriva il flusso video) oppure si rompe (l'utente ha detto no).

Per lavorare con le promesse ci sono due parole:

async function accendi() {
  const flusso = await navigator.mediaDevices.getUserMedia({ video: true });
  console.log("acceso!", flusso);
}
Il tipo di una funzione asincrona Una funzione async restituisce sempre una promessa, anche quando dentro scrivi return 5. Per questo il suo tipo di ritorno si scrive Promise<number>, e quando non restituisce niente Promise<void>. Quelle parentesi angolari dicono «promessa di che cosa».
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;
}

Quando va storto: try / catch

Una promessa può rompersi, e con la webcam si rompe spesso: permesso negato, nessuna telecamera, telecamera già usata da un altro programma. Se non fai niente, l'utente vede una pagina che non reagisce e non capisce perché.

try {
  flusso = await accendiCamera(video);
  // qui ci arrivi solo se è andata bene
} catch (e) {
  // qui ci arrivi solo se è andata male
  console.log("non ha funzionato:", e);
}

Il browser non ti manda una frase, ti manda un errore con un nome. I tre che vedrai in aula oggi:

NomeCosa è successoCosa dire all'utente
NotAllowedErrorL'utente ha negato il permesso, o l'aveva già negato in passato«Consenti l'uso della camera dalle impostazioni del browser e riprova»
NotFoundErrorNon c'è nessuna webcam collegata«Nessuna webcam trovata su questo dispositivo»
NotReadableErrorC'è, ma la sta usando un altro programma«La webcam è occupata da un'altra applicazione»

Tradurli è banale e cambia tutto: è la differenza fra un'applicazione e un compito.

Il contesto sicuro

La webcam si accende solo se la pagina arriva da https://… oppure da localhost. Su un http:// normale, navigator.mediaDevices non esiste proprio: il codice fallisce con un errore oscuro.

Il motivo è serio. Su http:// chiunque stia in mezzo alla rete — il gestore del wi-fi, per dire — può cambiare la pagina mentre viaggia, e quindi far accendere la telecamera a una pagina che non lo aveva chiesto. https lo impedisce. localhost è ammesso perché non viaggia da nessuna parte: è il tuo computer.

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

È esattamente il controllo che fa il progetto vero, prima di ogni altra cosa. E ti spiega anche perché alla lezione 10, quando parleremo di mettere il sito online, HTTPS non sarà un dettaglio facoltativo.

Dove finisce il video

Il flusso della webcam non si guarda da solo: va agganciato a un elemento <video>.

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

Tre attributi, tre motivi:

Il <video> però è solo una finestrella. Per disegnarci sopra i punti della mano ti serve il canvas, e per prima cosa devi copiarci dentro l'immagine:

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

Una riga, e nel canvas c'è il fotogramma corrente. Da lì in poi disegni quello che vuoi sopra: alla lezione 8 saranno 21 puntini.

Il ciclo dei fotogrammi

Disegnare una volta sola non basta: l'immagine cambia in continuazione. Serve un ciclo che riparta a ogni fotogramma, e lo fornisce il browser:

function ciclo(): void {
  requestAnimationFrame(ciclo);          // prenota il prossimo giro
  ctx.drawImage(video, 0, 0, tela.width, tela.height);
}
ciclo();                                  // il primo giro lo chiami tu

requestAnimationFrame — «chiedi un fotogramma di animazione» — dice al browser: richiamami un istante prima del prossimo ridisegno dello schermo. Di solito sono 60 volte al secondo.

Perché non un timer, cioè setInterval(ciclo, 16)? Tre motivi concreti:

Il ciclo si ferma con cancelAnimationFrame, e per farlo bisogna essersi tenuti il numero che requestAnimationFrame restituisce:

let idCiclo = 0;

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

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

Spegnere la camera davvero

Questa è la parte che quasi tutti sbagliano la prima volta. Mettere in pausa il <video> non spegne la telecamera: il flusso resta aperto e la lucina accanto all'obiettivo resta accesa. Bisogna fermare le tracce, cioè i singoli canali del flusso, una per una:

function spegniCamera(video: HTMLVideoElement, flusso: MediaStream | null): void {
  flusso?.getTracks().forEach((t) => t.stop());   // ← questa è la riga che conta
  video.pause();
  video.srcObject = null;
}

La verifica è fisica e non ammette discussioni: la spia della webcam deve spegnersi. Se resta accesa, non hai spento niente.

Demo del docente (15')

  1. Pagina con due pulsanti e un <video>. Scrivi accendiCamera dal vivo e fai comparire la richiesta di permesso sul proiettore.
  2. Nega il permesso apposta. Mostra che la pagina non dice niente, poi aggiungi il try/catch e il messaggio tradotto. È il momento più utile della lezione.
  3. Scrivi spegniCamera prima senza getTracks().forEach(...): la spia resta accesa. Poi aggiungi la riga: la spia si spegne. Fai vedere la lucina, non il codice.
  4. Aggiungi il ciclo con requestAnimationFrame e il drawImage sul canvas: adesso ci sono due immagini identiche, il video e la tela. Chiedi alla classe perché ne servono due — la risposta («sulla tela possiamo disegnarci sopra») è la lezione 8.

Laboratorio (60')

Regole d'aula, prima di accendere qualunque cosa Si riprende solo la propria mano: non i compagni, non il docente, non l'aula. Chi non vuole inquadrarsi non è obbligato: può lavorare con un video al posto della webcam (il docente fornisce un breve filmato di una mano) oppure in coppia con un compagno. Non si fanno foto, non si registra, non si condividono schermi con la camera accesa. Le immagini non escono dal computer: tutto quello che scrivi oggi gira nel browser, e nel codice non c'è — e non ci sarà — nessuna riga che manda un'immagine da qualche parte. È lo stesso motivo per cui il progetto pubblicato non ha nessun server a cui mandare qualcosa: ciò che non viene raccolto non può essere perso.

Esercizio 7.1 — Accendi la camera base

Nella tua pagina metti un pulsante «Accendi la camera» e un elemento <video>. Al clic sul pulsante, la webcam si accende e si vede. Niente di più.

<!-- 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>
Soluzione
// 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();
});

Quel void davanti alla chiamata dice «so che è una promessa e so che non la sto aspettando»: senza, il compilatore in modalità severa può protestare. La camera si accende solo al clic, mai all'apertura della pagina: è una regola del progetto ed è anche il modo in cui il browser mostra la richiesta di permesso in un momento che ha senso per l'utente.

Esercizio 7.2 — Errori che si capiscono base

Metti l'accensione dentro try/catch e scrivi nel paragrafo #messaggio una frase comprensibile. Usa un oggetto di messaggi invece di una catena di if. Poi provalo davvero: nega il permesso dalla finestrella del browser.

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

/** I nomi standard degli errori del browser, tradotti in frasi utili. */
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> è il tipo di «un oggetto con chiavi di testo e valori di testo»: un piccolo dizionario. Il ?? della lezione 4 copre i casi non previsti. Per rimettere il permesso com'era: icona della telecamera nella barra degli indirizzi, oppure impostazioni del sito.

Esercizio 7.3 — Spegni davvero base

Fai funzionare il pulsante «Spegni». La prova non è il codice: è la spia della webcam che si spegne. Provala prima senza fermare le tracce, poi fermandole, e guarda la differenza.

Soluzione
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);

Un MediaStream contiene una o più tracce (qui una sola, video). Finché una traccia è viva, la telecamera è accesa. flusso?.getTracks() usa il ?. della lezione 4: se il flusso è null, la riga non fa niente invece di esplodere.

Esercizio 7.4 — Il video sulla tela, specchiato medio

Avvia un ciclo con requestAnimationFrame che copia ogni fotogramma sul canvas. Poi specchia l'immagine: alzando la mano destra, sullo schermo deve alzarsi la mano che ti sembra destra, come davanti a uno specchio. Ferma il ciclo quando spegni la camera.

Soluzione
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();                       // mette da parte l'orientamento attuale
  ctx.translate(tela.width, 0);     // sposta l'origine sul bordo destro
  ctx.scale(-1, 1);                 // ribalta l'asse x: ecco lo specchio
  ctx.drawImage(video, 0, 0, tela.width, tela.height);
  ctx.restore();                    // rimette tutto com'era
}

save e restore sono importanti: senza restore, tutto quello che disegnerai dopo — punti, ossa, scritte — resterebbe specchiato, e le scritte al contrario si notano parecchio. Il ciclo va avviato dopo video.play() e fermato in spegniCamera con cancelAnimationFrame(idCiclo), seguito da ctx.clearRect(0, 0, tela.width, tela.height) per lasciare la tela pulita.

Esercizio 7.5 — Quanti fotogrammi al secondo medio

Mostra in un angolo della pagina quanti giri al secondo sta facendo il ciclo. Non si calcola sul singolo giro (il numero ballerebbe in modo illeggibile): si contano i giri e si divide una volta al secondo.

Soluzione
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;
  }
}

Si chiama contaFps() dentro il ciclo. performance.now() dà i millisecondi trascorsi dall'apertura della pagina, con i decimali: è l'orologio giusto per misurare le durate, e lo ritroverai tale e quale alla lezione 8. Su un computer normale leggerai circa 60: è il ritmo dello schermo. Alla lezione 8, quando in mezzo ci sarà il riconoscimento, quel numero diventerà molto più interessante.

Esercizio 7.6 — Camera spenta se nessuno guarda sfida

Fai in modo che cambiando scheda del browser la camera si spenga da sola. Lo strumento è l'evento visibilitychange sul document e la proprietà document.hidden. Verifica con la spia della webcam.

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

Tre righe, ma è una scelta di riservatezza, non un ottimizzazione: se non stai guardando la pagina, non c'è motivo che la pagina guardi te. Nel progetto vero c'è esattamente questo, più un secondo controllo per il caso limite in cui l'utente cambia scheda mentre la camera si sta accendendo. Chi vuole provarci: dopo l'await dell'accensione, se document.hidden è vero, spegni subito.

Errori tipici in aula

Verifica veloce (5 domande)

Domande e risposte
  1. Cosa restituisce getUserMedia, di preciso? → Una Promise: la promessa di un flusso video, non il flusso.
  2. Dove si può scrivere await? → Dentro una funzione dichiarata async.
  3. Su quali indirizzi si accende la webcam? → Solo https://… e localhost: il contesto sicuro.
  4. Perché requestAnimationFrame e non setInterval? → Perché segue il ritmo dello schermo, non accumula ritardi e si sospende quando la scheda non è visibile.
  5. Basta video.pause() per spegnere la camera? → No: finché le tracce del flusso non vengono fermate, la telecamera resta accesa.

Compito per casa (30-40 minuti)

Sposta tutto quello che riguarda la camera in un modulo src/camera.ts, che esporta due funzioni sole: accendiCamera(video) che restituisce Promise<MediaStream> e spegniCamera(video, flusso). In main.ts devono restare i pulsanti, i messaggi e il ciclo. Aggiungi anche il controllo del contesto sicuro all'inizio dell'accensione. È il file camera.ts del progetto vero, scritto da te: la prossima lezione lo userai così com'è.

Lezione 8

MediaPipe: i 21 punti della mano vera

È la lezione in cui il progetto funziona. Alla fine di queste due ore muovi la mano davanti alla webcam e sullo schermo si muove una mano disegnata da te, con le tue dita e i tuoi movimenti. Tutto il resto del corso sono rifiniture.

30' Cos'è un modello · installare una libreria · caricare il riconoscitore · la forma del risultato
15' Il docente carica il modello dal vivo e stampa i primi punti veri in console
60' Esercizi 8.1 → 8.5: dal primo console.log alla mano completa sullo schermo
15' Il giro d'aula: tutti muovono la mano · verifica veloce · compito

Teoria

Cos'è un modello, in due paragrafi

Finora ogni riga del tuo programma diceva al computer esattamente cosa fare: «disegna un cerchio qui», «moltiplica questo per quello». Trovare una mano in un'immagine così non si può fare: nessuno sa scrivere le regole per riconoscere una mano — con qualunque luce, qualunque pelle, qualunque anello, qualunque sfondo. Ci hanno provato per vent'anni.

Un modello è un altro approccio: invece delle regole, gli esempi. Qualcuno — qui Google — ha raccolto decine di migliaia di fotografie di mani e ha segnato a mano, su ognuna, dove stanno le 21 articolazioni. Poi un programma di addestramento ha aggiustato, un pochino alla volta, milioni di numeri interni finché il sistema non ha cominciato a indovinare quei 21 punti anche su fotografie mai viste. Il risultato di quel lavoro è un file: gesture_recognizer.task, otto megabyte di numeri. Non contiene fotografie, non contiene mani: contiene solo i numeri che servono a fare quel calcolo.

Da questo discendono tre cose pratiche, che vale la pena avere chiare:

Installare una libreria, e fissarne la versione

npm install @mediapipe/tasks-vision@1.0.1

Questo comando scarica la libreria di Google dentro node_modules e aggiunge una riga in package.json. Quel @1.0.1 alla fine è la parte importante della lezione.

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

Scritto così — senza ^, senza ~, senza @latest — vuol dire «esattamente questa versione». Chiunque installi il progetto fra due anni avrà gli stessi identici file, e il progetto funzionerà come oggi.

Perché «l'ultima versione» è una pessima idea Con @latest il tuo progetto cambia da solo, in un giorno che non hai scelto, mentre tu non stai guardando. Una mattina si apre e non funziona più, e tu non hai toccato niente: è una delle esperienze più frustranti che esistano nel mestiere. Nel progetto vero della mano sono fissati con precisione tre elementi, e devono essere allineati fra loro: la libreria, la cartella wasm che la accompagna e il file del modello .task. La documentazione ufficiale di Google usa @latest negli esempi. Va benissimo per un esempio; non per qualcosa che deve continuare a funzionare. Da non confondere con il comando della lezione 6, npm create vite@latest: lì @latest è la versione dello strumento che crea la cartella una volta sola, non una dipendenza che resta nel progetto.

Caricare il riconoscitore

Servono due indirizzi: il runtime (il motore di calcolo, scritto in WebAssembly) e il modello. Il caricamento richiede tempo — sono una ventina di megabyte la prima volta — quindi è tutto async, come la webcam della lezione scorsa.

// 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,
  });
}

Riga per riga, le tre cose che contano:

Nel progetto vero i file non arrivano da internet Il sito della mano tiene runtime e modello sul proprio dominio, copiati durante la costruzione del sito. Due motivi: la pagina così non contatta nessun altro — e la promessa «le immagini non escono dal browser» diventa verificabile invece che detta — e i file restano quelli della versione fissata, non quelli che una CDN pubblica oggi. Per oggi, in aula, la CDN va benissimo: è un indirizzo in meno da configurare.

Chiedere i punti, a ogni fotogramma

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

Questa riga va dentro il ciclo della lezione 7. Due dettagli, entrambi fonte di errori il primo giorno:

  1. Non è await. In modalità VIDEO il calcolo avviene subito e restituisce subito il risultato. È anche il motivo per cui, se il computer è lento, gli fps crollano: il disegno aspetta il riconoscimento.
  2. Il tempo deve crescere sempre. Il secondo parametro è l'istante del fotogramma, in millisecondi. Se passi un tempo uguale o minore del precedente, la libreria si ferma con un errore. Con performance.now() non succede, perché un orologio va avanti — ma la regola va conosciuta, perché è la causa di un errore che altrimenti sembra incomprensibile.

C'è anche un accorgimento che fa risparmiare metà del lavoro. Lo schermo si ridisegna 60 volte al secondo, ma la webcam manda 30 fotogrammi al secondo: metà dei giri del ciclo vedono la stessa immagine di prima. Analizzarla due volte è lavoro buttato:

let ultimoIstante = -1;

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

  // readyState >= 2 significa "c'è un fotogramma pronto"
  if (video.readyState >= 2 && video.currentTime !== ultimoIstante) {
    ultimoIstante = video.currentTime;
    const risultato = riconoscitore.recognizeForVideo(video, performance.now());
    disegnaTutto(risultato);
  }
}

Com'è fatto il risultato

risultato.landmarks è un array di mani; ogni mano è un array di 21 punti; ogni punto ha x, y, z. Tre livelli, e bisogna tenerli distinti:

risultato.landmarks            // [ [21 punti], [21 punti] ]  ← 0, 1 o 2 mani
risultato.landmarks[0]         // i 21 punti della prima mano
risultato.landmarks[0][8]      // la punta dell'indice
risultato.landmarks[0][8].x    // 0.42 — un numero fra 0 e 1
CampoSignificatoIntervallo
xposizione orizzontale sull'immagine0 = bordo sinistro … 1 = bordo destro
yposizione verticale0 = alto … 1 = basso
zprofondità rispetto al polsostessa scala della x; negativa = più vicino alla camera

Sono le stesse coordinate fra 0 e 1 della mano finta della lezione 5, e non è un caso: il corso è stato costruito perché oggi tu debba cambiare una riga sola. Sono numeri relativi all'immagine, non pixel: se domani cambi la dimensione della tela, i punti continuano a funzionare.

E i numeri dei punti sono sempre gli stessi, per tutti: 0 il polso, 4 la punta del pollice, 8 la punta dell'indice, 12 il medio, 16 l'anulare, 20 il mignolo. Sono i numeri che hai già usato nella tabella delle ossa alla lezione 6.

Quando la mano non c'è

Se togli la mano dall'inquadratura, landmarks è un array vuoto. Scrivere risultato.landmarks[0][0].x senza controllare fa esplodere il programma. Il ?. della lezione 4 serve esattamente a questo:

const mano = risultato.landmarks[0];
if (!mano) return;          // niente mano in questo fotogramma: si salta il disegno

// oppure, in una riga:
const polso = risultato.landmarks[0]?.[0];
console.log(polso?.x ?? "nessuna mano");

Ed è la risposta pratica a quello che si diceva alla lezione 6: i tipi spariscono prima dell'esecuzione, quindi non ti proteggono mentre il programma gira. Il controllo lo devi scrivere tu.

Demo del docente (15')

  1. npm install @mediapipe/tasks-vision@1.0.1 sul proiettore; apri package.json e mostra la riga comparsa, senza ^.
  2. Scrivi creaRiconoscitore e chiamala all'avvio. Mostra il pannello Rete degli strumenti per sviluppatori: si vedono scendere i megabyte del modello. Poi ricarica: la seconda volta è istantaneo, perché il browser li ha in cache.
  3. Nel ciclo, console.log(risultato.landmarks.length): muovi la mano dentro e fuori dall'inquadratura e commenta gli 1 e gli 0 che scorrono.
  4. Stampa risultato.landmarks[0]?.[8] e muovi lentamente l'indice: fai leggere alla classe la x che va da 0,1 a 0,9. È il momento in cui i numeri diventano il proprio dito: vale la pena lasciarlo respirare mezzo minuto.
  5. Sbaglia apposta: passa 0 invece di performance.now() e leggi l'errore sui tempi crescenti.

Laboratorio (60')

Preparare la classe al download Venti macchine che scaricano insieme venti megabyte possono mettere in ginocchio la rete della scuola. Due contromisure: far lanciare l'esercizio 8.1 a scaglioni di cinque macchine, oppure — molto meglio — far girare una volta il progetto su ogni postazione prima della lezione, così i file restano nella cache. Se la rete non collabora comunque, il modello può essere distribuito su chiavetta e messo in public/, con modelAssetPath: "/gesture_recognizer.task": è esattamente la soluzione del progetto vero.

Esercizio 8.1 — Il modello parla base

Installa la libreria alla versione 1.0.1, crea src/tracker.ts con creaRiconoscitore() e, nel ciclo dei fotogrammi, stampa in console quante mani vede e quanti punti ha la prima. Muovi la mano dentro e fuori dall'inquadratura e guarda i numeri cambiare.

Soluzione
// src/main.ts — le parti nuove
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();   // prima il modello: non chiede niente all'utente
  await accendiCamera();                        // poi la camera: il permesso si chiede alla fine
  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);
}

L'ordine conta: prima il modello, poi la camera. Così la richiesta di permesso arriva quando manca poco al via, invece di tenere la telecamera accesa per dieci secondi mentre si scarica il modello. È lo stesso ordine del progetto vero. Il numero dei punti, quando la mano c'è, è sempre 21.

Esercizio 8.2 — I punti veri sullo schermo base

Basta console. Disegna i 21 punti sulla tela, sopra l'immagine della webcam, riutilizzando aPixel della lezione 6 e disegnaPunti della lezione 5. Cancella la tela a ogni fotogramma, altrimenti resta la scia.

Soluzione
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);
}

// nel ciclo, dopo aver ottenuto il risultato:
const mano = risultato.landmarks[0];
if (mano) mostra(mano);
else ctx.clearRect(0, 0, tela.width, tela.height);

I puntini staranno dalla parte sbagliata, ed è giusto così: hai specchiato l'immagine con scale(-1, 1) ma non i punti. Due strade: o non specchi il video, o specchi anche le coordinate con x: 1 - p.x. La seconda è quella del progetto vero, ed è la stessa idea dello 0.5 - x che incontrerai alla lezione 10.

Esercizio 8.3 — Le ossa medio

Aggiungi le 21 ossa con disegnaOssa, la funzione che hai già scritto alla lezione 6. Disegna prima le ossa e poi i punti: le articolazioni devono stare sopra le linee, non sotto.

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

const inPixel = punti.map((p) => aPixel(p, tela.width, tela.height));
disegnaOssa(ctx, inPixel);    // prima le linee
disegnaPunti(ctx, inPixel);   // poi i pallini, sopra

Ed eccolo il momento della lezione: la funzione non è cambiata di una virgola. Alla lezione 6 riceveva venti punti inventati; adesso riceve la tua mano. Questo è il guadagno vero di aver separato «come si disegna» da «cosa si disegna» — e per il resto del corso continuerà a ripagare.

Esercizio 8.4 — Che gesto sto facendo medio

Il modello che stai usando non dà solo i punti: riconosce anche otto gesti. Mostra il nome del gesto corrente, tradotto in italiano, in un elemento della pagina. Quando non riconosce niente il nome è "None", e non va mostrato.

Soluzione
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";

Due ?. in fila perché i livelli sono due: gestures ha un elenco per ogni mano, e ogni mano ha un elenco di ipotesi ordinate dalla più probabile — [0] è quella buona. Chi vuole: gesto?.score è la confidenza, fra 0 e 1. Mostrarla come percentuale fa capire meglio di qualunque spiegazione che il modello stima, non sa.

Esercizio 8.5 — Due mani, due colori sfida

Porta numHands a 2 e disegna la prima mano in azzurro e la seconda in rosa. Serve un ciclo su risultato.landmarks e un colore passato alle funzioni di disegno.

Soluzione
// tracker.ts: numHands: 2

// disegna.ts: le due funzioni prendono un colore in più
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");
});

Attenzione a una cosa che scoprirai da solo: l'ordine delle mani non è stabile. Da un fotogramma all'altro la tua destra può diventare [1], e i colori si scambiano. Per risolverlo davvero serve risultato.handedness[i]?.[0]?.categoryName, che dice «Left» o «Right» — con un tranello: MediaPipe ragiona come se l'immagine fosse già specchiata, quindi la sua «Left» è la tua destra. Nel progetto vero c'è una funzione di tre righe che non fa altro che ribaltare quell'etichetta.

Fermatevi e guardate A questo punto il progetto funziona: la mano sullo schermo segue la vostra, dito per dito, trenta volte al secondo. Quello che resta — smorzare il tremolio, i pulsanti, le tre dimensioni — sono rifiniture su una cosa che già c'è. Vale la pena fermare il laboratorio cinque minuti e fare il giro dei banchi a guardare gli schermi degli altri: è il momento per cui sono state fatte le sette lezioni precedenti.

Errori tipici in aula

Verifica veloce (5 domande)

Domande e risposte
  1. Dove gira il modello e dove finiscono le immagini della webcam? → Gira nel browser, sul computer dell'utente; le immagini non vengono inviate da nessuna parte.
  2. Perché fissare la versione con @1.0.1? → Perché il progetto non cambi da solo quando esce una versione nuova, e perché libreria, runtime e modello restino allineati.
  3. Che forma ha risultato.landmarks? → Un array di mani; ogni mano è un array di 21 punti con x, y, z fra 0 e 1.
  4. Cosa succede se passi due volte lo stesso istante a recognizeForVideo? → Errore: in modalità VIDEO i tempi devono crescere sempre.
  5. Come si passa da x = 0.42 a un pixel su una tela larga 640? → Si moltiplica: 0,42 × 640 ≈ 269. È aPixel, scritta alla lezione 3.

Compito per casa (30-40 minuti)

Rendi presentabile quello che hai: un messaggio «Carico il modello…» durante l'attesa, «Nessuna mano inquadrata» quando la mano non c'è, il nome del gesto in grande, e il contatore di fps della lezione 7 lasciato acceso in un angolo. Poi annota su un foglio quanti fps fa il tuo computer con il riconoscimento attivo e in quali condizioni la mano si perde (controluce? mano di taglio? troppo vicina?). Porta i numeri la prossima volta: la lezione 9 comincia da lì.

Lezione 9

Rifinire: classi, stato, gesti, tremolio

Il progetto funziona già. Oggi si passa da «funziona» a «si può dare in mano a qualcuno»: la mano smette di vibrare, i pulsanti sanno sempre in che stato sono, e le mani fantasma smettono di lampeggiare.

30' Classi · lo stato dell'interfaccia · perché i punti tremano e come si smorza · falsi positivi
15' Il docente scrive MediaMobile dal vivo e mostra la mano prima e dopo, con il filtro acceso e spento
60' Esercizi 9.1 → 9.6
15' Confronto dei numeri del tremolio · verifica veloce · compito (preparare la presentazione)

Teoria

Le classi: un oggetto che si porta dietro le sue funzioni

Alla lezione 4 hai imparato gli oggetti: scatole con dentro dei valori. Una classe è la stessa idea, con una cosa in più: dentro ci stanno anche le funzioni che lavorano su quei valori.

Guarda il problema di oggi. Per smorzare il tremolio devi ricordarti gli ultimi cinque valori di una coordinata. Con quello che sai finora:

let ultimiValori: number[] = [];

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

Funziona… per una coordinata. Ma le coordinate sono 21 punti × 3 assi = 63, e ognuna ha bisogno della propria memoria. Con le variabili sciolte servirebbero 63 array e 63 funzioni. Con una classe, ne scrivi una e ne crei 63.

class MediaMobile {
  private valori: number[] = [];      // campo: la memoria di QUESTO oggetto
  private readonly quanti: number;    // quanti valori tenere

  constructor(quanti = 5) {           // si esegue alla nascita dell'oggetto
    this.quanti = quanti;
  }

  aggiungi(valore: number): void {    // metodo: una funzione dell'oggetto
    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);   // memoria separata: due oggetti, due storie

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

Il vocabolario è tutto qui, e sono cinque parole:

this vuol dire «questo oggetto qui, non gli altri 62». E private vuol dire «da fuori non si tocca»: chi usa un MediaMobile chiama aggiungi e valore, e non deve neanche sapere che dentro c'è un array. Se domani cambi il modo di calcolare la media, nessun altro file se ne accorge.

Quando serve una classe e quando no Una classe serve quando un pezzo di programma deve ricordarsi qualcosa fra una chiamata e l'altra, e ne esistono più copie indipendenti: il filtro di ogni coordinata, il riconoscitore, la scena 3D. Per un calcolo che non ricorda niente — aPixel, distanza — una funzione è più semplice e va benissimo. Nel progetto vero ci sono quattro classi in tutto: non di più.

Lo stato dell'interfaccia

Guarda la tua pagina della lezione 8 e conta: il pulsante «Accendi», quello «Spegni», il messaggio, il riquadro d'errore, il contatore di fps. Cinque cose che devono essere coerenti fra loro. Se le accendi e le spegni una per una, sparse nel codice, prima o poi arrivi a una schermata assurda — «Camera spenta» scritto mentre la spia è accesa, il pulsante «Avvio in corso…» che resta lì per sempre dopo un errore.

La soluzione è dire per prima cosa quali stati esistono. È un tipo unione, come quelli della lezione 2:

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

Quattro parole, e nessun'altra: se scrivi "acesa", il compilatore ti ferma subito. Poi una sola funzione decide come si vede ogni stato:

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;   // utile al CSS e a chi legge la pagina
}

Da quel momento in poi, in tutto il resto del programma, non si tocca più nessun pulsante: si chiama setState("avvio", "Carico il modello…") e basta. Il vantaggio non è scrivere meno righe: è che gli stati impossibili diventano impossibili, invece di restare in agguato.

Perché i punti tremano

Tieni la mano ferma, ferma davvero. I punti sullo schermo continuano a ballare di qualche pixel. Non è colpa tua e non è un bug: il modello analizza ogni fotogramma da solo, e ogni fotogramma è leggermente diverso dal precedente — il rumore del sensore, un filo di luce che cambia. Piccole differenze in ingresso, piccole differenze in uscita. Trenta volte al secondo, e l'occhio le legge come una vibrazione.

L'idea più semplice per toglierla è quella che conosci già dai voti a scuola: invece del valore di adesso, la media degli ultimi cinque. I saltelli in su e in giù si compensano e la mano si calma.

// senza filtro                      con media su 5
// 0.412 0.418 0.409 0.421 0.414     0.415 0.415 0.416 0.415 0.416

Ma c'è un prezzo, e va capito bene perché è il cuore della lezione. Se muovi la mano di scatto, la media contiene ancora quattro valori vecchi: la mano sullo schermo arriva in ritardo, come se strisciasse dietro alla tua. Più il filtro è forte, più è lento. Con 5 valori il ritardo è appena percettibile; con 20 la mano sembra nella melassa. Provali entrambi: è l'esercizio 9.2.

Come fa il progetto vero (e perché non lo scriviamo oggi) Il sito usa il filtro One Euro, che risolve il compromesso invece di subirlo: misura quanto velocemente si sta muovendo il punto e cambia forza da solo — filtra molto quando la mano è ferma (niente vibrazione), quasi niente quando la mano corre (niente ritardo). Sono una quindicina di righe di matematica, scritte a mano nel file filter.ts. Oggi scrivi la media mobile perché ti fa capire il problema con quello che già sai; chi vuole guardare la versione adulta trova il file commentato in italiano nel codice del sito.

La conferma su più fotogrammi

C'è un secondo difetto, più subdolo. Ogni tanto — un fotogramma su cinquanta, quando la luce cambia o passa qualcosa nell'inquadratura — il modello «vede» una mano che non esiste. Dura un fotogramma solo, ma sullo schermo è un lampo fastidioso.

Il rimedio costa tre righe: non fidarti della prima volta. Si conta da quanti fotogrammi consecutivi la mano è presente, e si disegna solo da tre in poi.

let consecutivi = 0;
const CONFERME = 3;

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

if (consecutivi >= CONFERME) {
  // solo adesso la mano si disegna
}

Costo: un decimo di secondo di ritardo alla comparsa, che nessuno nota. Guadagno: le mani fantasma spariscono. È lo stesso ragionamento del filtro — si paga un pochino di ritardo per comprare stabilità — ed è una delle scelte più frequenti in tutto il mestiere.

Demo del docente (15')

  1. Metti la mano ferma davanti alla webcam e zooma sullo schermo: tutta la classe deve vedere i punti che ballano da soli. Il problema prima della soluzione.
  2. Scrivi MediaMobile dal vivo, con calma: è la prima classe che vedono. Fai notare che this.valori di due oggetti diversi sono due array diversi.
  3. Applicala e riguarda la mano ferma: i punti si calmano. Poi muovi la mano di scatto e fai vedere il ritardo. Non nasconderlo, mostralo: è la lezione.
  4. Alza la finestra a 20 valori: la mano diventa gelatina. Riportala a 5. Chiedi alla classe quale preferirebbe, e perché non esiste una risposta giusta per tutti gli usi.
  5. Mostra il setState finito e prova la sequenza accendi → errore → spegni → riaccendi, per far vedere che i pulsanti non si ingarbugliano mai.

Laboratorio (60')

Esercizio 9.1 — La tua prima classe base

Scrivi in src/filtro.ts la classe MediaMobile, con aggiungi(valore) e valore(). Provala senza webcam, con dei numeri scritti a mano, e controlla in console che faccia quello che pensi.

Soluzione
// 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();
  }

  /** La media dei valori in memoria. Se non ce n'è nessuno, restituisce 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;
  }
}
// prova
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);            // il 10 esce dalla finestra
console.log(m.valore());   // 36.66…

shift() toglie il primo elemento dell'array: è così che la finestra «scorre». Il controllo su array vuoto evita una divisione per zero, che darebbe NaN — e un NaN nelle coordinate fa sparire la mano senza nessun messaggio d'errore.

Esercizio 9.2 — Smorzare la mano vera medio

Scrivi una seconda classe, FiltroMano, che tiene 63 medie mobili (21 punti × 3 coordinate) e ha un metodo smorza(punti) che restituisce i punti addolciti. Applicala prima di disegnare. Poi prova con finestre di 2, 5 e 20 valori e guarda la differenza con i tuoi occhi.

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

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

  constructor(quanti = 5) {
    // 21 punti × 3 coordinate: un filtro per ciascuna, con la sua memoria
    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;

// quando la mano compare
filtro ??= new FiltroMano(5);
const addolciti = filtro.smorza(mano);
// …e da qui in poi si disegna `addolciti` invece di `mano`

// quando la mano sparisce: si butta il filtro
filtro = null;

L'ultima riga è più importante di quanto sembri. Se tieni il filtro quando la mano esce dall'inquadratura, alla ricomparsa la media contiene ancora le posizioni vecchie: la mano arriva strisciando dal punto dove stava prima. Si butta il filtro e se ne crea uno nuovo. Nel progetto vero c'è esattamente questa riga, con lo stesso commento.

Esercizio 9.3 — Un solo posto che comanda medio

Definisci il tipo Stato con le quattro parole e scrivi setState. Poi togli dal resto del codice ogni riga che tocca direttamente un pulsante o un messaggio: devono restare solo chiamate a setState.

Soluzione
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");
}

Nota il ??= su riconoscitore: alla seconda accensione il modello è già in memoria e non si ricarica. Nota anche che spegni() lascia tutto pulito — ciclo fermo, camera rilasciata, filtro buttato: è la regola d'oro dello spegnimento.

Esercizio 9.4 — Dillo, che non vedi niente base

Quando nessuna mano è inquadrata, la pagina deve scriverlo: «Nessuna mano inquadrata». Quando la mano torna, il messaggio sparisce. Sembra un dettaglio: è la differenza fra un programma rotto e un programma che sta aspettando.

Soluzione
const mano = risultato.landmarks[0];

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

Chi finisce presto: mostra anche quante mani vede, e con numHands: 2 scrivi «Destra» e «Sinistra» usando risultato.handedness, ricordando che va ribaltato (lezione 8).

Esercizio 9.5 — Niente mani fantasma medio

Aggiungi la conferma su tre fotogrammi: la mano si disegna solo se è stata vista tre volte di fila. Attenzione a un dettaglio: il filtro va alimentato anche durante i tre fotogrammi di attesa, altrimenti quando la mano compare si vede assestarsi sotto gli occhi dell'utente.

Soluzione
const CONFERME = 3;
let consecutivi = 0;

if (mano) {
  consecutivi++;
  filtro ??= new FiltroMano(5);
  const addolciti = filtro.smorza(mano);   // si filtra sempre…
  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;
}

Il contatore si azzera al primo fotogramma senza mano: una mano vera resta per decine di fotogrammi, una fantasma no, e la soglia di tre le separa. Per provarlo: passa rapidamente un oggetto davanti alla camera e guarda se compare un lampo.

Esercizio 9.6 — Misura il tremolio sfida

Basta con le impressioni: misura. Scrivi una funzione che calcola quanto si spostano in media i punti da un fotogramma al successivo, e stampala ogni secondo. Poi tieni la mano il più ferma possibile per dieci secondi e annota il numero senza filtro e con il filtro. Porta i due numeri alla chiusura.

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

/** Spostamento medio dei 21 punti rispetto al fotogramma precedente (in unità 0…1). */
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++;
  }
  // copia dei valori, non dei riferimenti: servono com'erano adesso
  precedenti = punti.map((p) => ({ x: p.x, y: p.y, z: p.z }));
}

// una volta al secondo, insieme agli fps:
if (misure > 0) {
  etichettaTremolio.textContent = `tremolio: ${(sommaMovimento / misure).toFixed(5)}`;
  sommaMovimento = 0;
  misure = 0;
}

Con la mano ferma, valori tipici: circa 0,0015 senza filtro e 0,0005 con la media su 5 — tre volte meno. Poi rifai la misura muovendo la mano avanti e indietro: lì il numero deve essere grande in entrambi i casi, perché quello non è tremolio, è movimento. Un filtro che spegnesse anche quello sarebbe un filtro che ti mangia il gesto. Questo esercizio è il modo giusto di giudicare qualunque filtro: due misure, non due opinioni.

Errori tipici in aula

Verifica veloce (5 domande)

Domande e risposte
  1. A cosa serve private? → A impedire che un campo si usi da fuori della classe: chi la usa vede solo i metodi.
  2. Perché servono 63 medie mobili e non una? → Perché ogni coordinata di ogni punto ha una storia sua: 21 punti × 3 assi.
  3. Qual è il difetto della media mobile? → Il ritardo: più valori tiene, più la mano arriva in ritardo sui movimenti rapidi.
  4. Cosa fa il filtro One Euro in più? → Cambia forza da solo secondo la velocità del punto: filtra molto da fermo, poco in movimento.
  5. Perché aspettare tre fotogrammi prima di disegnare una mano? → Per non mostrare le mani fantasma che il modello vede per un fotogramma solo.

Compito per casa (40 minuti)

Due cose. Una: rifinisci il progetto come vuoi tu — colori, dimensioni dei punti, un titolo, il gesto mostrato in grande, magari il colore della mano che cambia con il gesto riconosciuto. Due: prepara la presentazione della lezione 10. Tre o quattro minuti, in cui devi dire: cosa fa il tuo progetto, la parte di codice di cui sei più soddisfatto, la cosa che ti ha fatto perdere più tempo e come l'hai risolta. Niente diapositive: si mostra il progetto che gira.

Lezione 10

La terza dimensione, la pubblicazione, le presentazioni

Ultima lezione, e si respira: la mano esce dal piano e diventa una figura nello spazio, si guarda cosa vuol dire mettere davvero online un sito, e poi si presenta. Venti ore fa nessuno di voi aveva scritto una riga di TypeScript.

15' Three.js in dieci minuti · mettere online · privacy e CSP
10' Il docente apre il file di avvio, collega un punto solo e lo fa muovere
80' Esercizi 10.1 → 10.5 (40') e presentazioni dei progetti (40')
15' Chiusura del corso · idee per continuare · saluti

Teoria

Three.js: scena, telecamera, luci, mesh

Il browser sa disegnare in tre dimensioni, ma l'interfaccia che offre (WebGL) è di una scomodità feroce: matrici, programmi per la scheda grafica, buffer di numeri. Three.js è la libreria che ci si mette in mezzo e parla come si parla in un teatro. Quattro parole e hai capito tutto quello che serve oggi:

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

E poi, a ogni fotogramma, una riga sola: renderer.render(scena, camera). Three.js guarda dove sono gli oggetti in quel momento e disegna il quadro.

L'idea che fa la differenza Gli oggetti si creano una volta sola, all'inizio; a ogni fotogramma si spostano. Creare e buttare 21 sfere sessanta volte al secondo farebbe lavorare senza sosta il raccoglitore di memoria, e la mano andrebbe a scatti. Vale per la scena 3D come per qualunque cosa giri a ritmo di fotogramma.

Da 21 punti a 21 sfere

Qui c'è tutta la matematica della lezione, e sono tre righe. I punti che arrivano dalla webcam vivono in un mondo fatto così: x e y fra 0 e 1, origine nell'angolo in alto a sinistra, y che cresce verso il basso come in tutte le immagini. Il mondo 3D invece ha l'origine al centro e la y che cresce verso l'alto, come a scuola.

function nelloSpazio(p: Punto) {
  const larghezza = 6;    // quanto è largo, nel mondo 3D, il rettangolo del video
  const altezza = 4.5;
  return {
    x: (0.5 - p.x) * larghezza,       // centra E specchia
    y: (0.5 - p.y) * altezza,         // centra E rovescia
    z: -p.z * larghezza * 1.6,        // profondità, un po' esagerata
  };
}

Guarda cosa fa quel 0.5 - x, perché fa due lavori in una sottrazione:

La z ha il segno invertito (la profondità di MediaPipe è negativa verso la camera, in 3D l'osservatore sta dalla parte positiva) e viene moltiplicata per 1,6: la profondità stimata da una sola telecamera è piccola, e senza un po' di guadagno avvicinare la mano non si noterebbe quasi. Il valore è scelto a occhio — più in alto, la mano comincia a sembrare deformata.

Mettere online un sito

Finora hai lavorato su localhost, che è solo il tuo computer. «Mettere online» vuol dire tre cose, e nessuna è magica:

  1. Costruire: npm run build. Il comando controlla i tipi, traduce il TypeScript in JavaScript, unisce i file, li accorcia e scrive tutto nella cartella dist/. Lì dentro c'è un sito normale — pagine, script, immagini — che non ha più bisogno né di Node né di Vite.
  2. Un computer sempre acceso che serva quei file a chi li chiede, e un nome (un dominio) che punti a quel computer.
  3. HTTPS, e qui non c'è scelta: senza certificato, come hai visto alla lezione 7, la webcam non si accende. Il tuo progetto online in http:// sarebbe una pagina morta.

Il sito che state guardando, per dire, gira su un piccolo computer in affitto: un programma serve la cartella dist/, un altro (Caddy) si occupa del dominio e si procura il certificato HTTPS da solo. Prima di pubblicare, uno script controlla che nei file costruiti non sia comparso niente di strano e che tutte le pagine ci siano; se un controllo fallisce, la pubblicazione si ferma e online resta la versione di ieri, che funzionava.

Nessuno è obbligato a pubblicare oggi Mettere online un sito richiede un indirizzo, spesso una carta di credito e qualche scelta che non si fa di fretta l'ultimo giorno di corso. L'esercizio 10.4 si ferma a npm run build e a guardare cosa c'è dentro dist/: è la parte che conta. Chi vuole andare avanti a casa trova nell'elenco finale le strade più semplici.

Due parole di chiusura: privacy e CSP

Il vostro progetto tratta immagini di persone. È il dato più delicato che esista, e voi l'avete gestito nel modo giusto senza quasi accorgervene: le immagini non escono dal browser, perché non c'è nessun posto dove mandarle; la camera si accende solo al clic; si spegne quando cambiate scheda. Non è un caso, è una scelta di progettazione — e la regola che ci sta sotto vale ben oltre questo corso: il dato che non raccogli è l'unico che non puoi perdere.

C'è anche un modo per non limitarsi a prometterlo. Un sito può dichiarare al browser cosa gli è permesso fare — è la Content Security Policy. Il sito della mano dichiara, fra le altre cose, che la pagina può parlare solo con il proprio dominio. Da quel momento non è più il programmatore a garantire che le immagini non vengano inviate a qualcuno: è il browser che rifiuta di inviarle, anche se il codice ci provasse. La differenza fra una promessa e una garanzia è tutta lì.

Nel progetto vero questa regola ha avuto un effetto collaterale interessante: ha bloccato una chiamata che nessuno aveva scritto. Era la libreria di riconoscimento, che ogni minuto mandava ai suoi server qualche dato d'uso — nessuna immagine, ma comunque una chiamata a un estraneo, e senza alcun modo di spegnerla. È stata disattivata. Senza la CSP, nessuno se ne sarebbe mai accorto.

Demo del docente (10')

  1. Apri il file di avvio src/scena3d.ts (lo trovi qui sotto, nel riquadro del docente) e mostra che c'è già tutto: scena, telecamera, luci, 21 sfere allineate in fila.
  2. Collega un solo punto, il polso, e muovilo con la mano davanti alla webcam. Una sfera che segue un polso è già un effetto notevole, e chiarisce il meccanismo meglio di venti.
  3. Poi collega tutti e 21 i punti: la mano appare. Ruota la testa della classe verso lo schermo e avvicina la mano alla camera per far vedere la profondità.
  4. Sbaglia apposta: togli il 0.5 - dalla y. La mano si capovolge. Rimettilo e spiega la sottrazione mentre tutti guardano l'effetto.

Laboratorio (80')

Scaletta dell'ultima lezione 15' teoria · 10' demo · 40' laboratorio 3D · 40' presentazioni (portale a 50' se la classe è numerosa, togliendo dieci minuti al laboratorio) · 15' chiusura e idee per continuare. Il file di avvio va distribuito prima di cominciare (chiavetta o cartella condivisa): è il pezzo che garantisce che anche chi è rimasto indietro veda la propria mano in tre dimensioni oggi. Per le presentazioni: quattro minuti a testa con il cronometro visibile, le coppie contano come uno. Se qualcuno non se la sente di parlare davanti alla classe, può mostrare il progetto al solo docente mentre gli altri provano gli esercizi: l'obiettivo è che ognuno racconti il proprio lavoro a qualcuno, non che superi una prova di oratoria.

Esercizio 10.1 — Il file di avvio base

Installa Three.js, copia nel progetto il file di avvio src/scena3d.ts e aggiungi alla pagina il canvas a tutto schermo che gli serve. Verifica che si veda la fila di 21 sfere: vuol dire che scena, luci e telecamera funzionano.

npm install three@0.186.0
npm install --save-dev @types/three@0.186.0
Il file di avvio (lo distribuisce il docente)
// src/scena3d.ts — scena 3D già pronta: tu devi solo chiamare mostraMano() e 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();   // obbligatorio dopo aver cambiato le proporzioni
}
window.addEventListener("resize", adatta);
adatta();

// Luci: una diffusa perché niente resti nero, una direzionale che dà 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 sfere, create UNA volta sola: a ogni fotogramma si spostano soltanto.
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 fila, finché non arriva una mano vera
  s.scale.setScalar(0.12);
  scena.add(s);
  sfere.push(s);
}

const LARGHEZZA = 6;
const ALTEZZA = 4.5;

/** Sposta le 21 sfere sui 21 punti della mano (coordinate 0…1 di MediaPipe). */
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);
}

/** Mostra o nasconde la mano in blocco. */
export function mostra(visibile: boolean): void {
  for (const s of sfere) s.visible = visibile;
}

/** Un fotogramma. Va chiamata dentro il ciclo di main.ts. */
export function disegna(): void {
  renderer.render(scena, camera);
}
<!-- index.html: il canvas della scena, dietro a tutto il resto -->
<canvas id="scena"></canvas>

Nel foglio di stile dagli larghezza e altezza piene (per esempio position: fixed; inset: 0; width: 100%; height: 100%;): un canvas senza dimensioni resta di 300×150 pixel e sembra che non funzioni niente.

Esercizio 10.2 — La tua mano nello spazio base

Collega i punti veri: nel ciclo dei fotogrammi, dopo il filtro della lezione 9, chiama mostraMano() con i punti normalizzati — non quelli in pixel — e disegna() a ogni giro. Quando la mano non c'è, nascondila.

Soluzione
// 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));    // punti 0…1: la conversione la fa la scena
    } else {
      filtro = null;
      mostra(false);
    }
  }

  disegna();   // fuori dall'if: la scena si ridisegna a ogni fotogramma
}

Due errori da evitare: passare i punti già convertiti in pixel (la mano finisce fuori dallo schermo, a centinaia di unità di distanza) e chiamare disegna() dentro l'if (la scena si aggiorna solo quando arriva un fotogramma nuovo, e si vede).

Esercizio 10.3 — Rendila tua medio

Tre ritocchi, scegline almeno due: fai le punte delle dita (4, 8, 12, 16, 20) più grandi del resto; cambia il colore della mano quando il gesto riconosciuto è «pollice su»; fai crescere le sfere quando la mano si avvicina alla camera.

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

// colore: il materiale è condiviso, quindi una riga cambia tutta la mano
export function coloraMano(colore: number): void {
  materiale.color.setHex(colore);
}
// in main.ts
coloraMano(nome === "Thumb_Up" ? 0x22c55e : 0x38bdf8);

// dimensione secondo la distanza: il polso più vicino = z più negativa
const vicinanza = 1 - punti[0].z * 2;
sfere[i].scale.setScalar(0.12 * Math.max(0.5, Math.min(2, vicinanza)));

Il colore è uno solo per tutte le sfere perché il materiale è uno solo, condiviso: cambiarlo le cambia tutte insieme. È la stessa proprietà che, nel progetto vero, permette di far svanire la mano intera agendo su un valore solo. Math.max e Math.min insieme tengono un numero dentro un intervallo: un trucco che userai spessissimo.

Esercizio 10.4 — Costruisci il sito medio

Lancia npm run build e guarda cosa è comparso in dist/. Poi npm run preview, che serve quella cartella come farebbe un vero server, e prova il tuo sito «costruito». Apri uno dei file .js generati: non ci capirai niente, ed è giusto così.

Soluzione
npm run build
npm run preview     # apre http://localhost:4173

In dist/ trovi index.html e una cartella assets/ con file dai nomi strani tipo index-a1b2c3d4.js. Quelle lettere sono la firma del contenuto: se cambi una riga di codice, il nome cambia, e i browser che avevano in memoria la versione vecchia sono costretti a scaricare quella nuova. Il codice dentro è tutto su poche righe lunghissime, con i nomi delle variabili ridotti a una lettera: è la minificazione, che serve a far scaricare meno byte. Ed è anche la prova finale di quello che si diceva alla lezione 6: dei tuoi tipi, lì dentro, non c'è più traccia.

Esercizio 10.5 — Conta le dita alzate sfida

L'ultima sfida del corso, e si fa con quello che sai dalla lezione 3. Un dito è alzato se la sua punta è più in alto della nocca corrispondente — ricorda che y piccola vuol dire in alto. Mostra quante dita vedi, da 0 a 4 (il pollice è un caso a parte: si muove di lato, non in su).

Soluzione
/** Indice, medio, anulare, mignolo: punta e nocca di ciascuno. */
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) {
    // y più piccola = più in alto nell'immagine
    if (punti[dito.punta].y < punti[dito.nocca].y) alzate++;
  }
  return alzate;
}

Funziona con la mano dritta e si confonde con la mano inclinata, perché confronta le altezze sull'immagine e non rispetto al palmo. La versione seria misura le distanze dal polso (distanza(punta, polso) > distanza(nocca, polso)) ed è robusta alle rotazioni: è un ottimo primo progetto da fare a casa. Per il pollice, si guarda la distanza orizzontale dalla nocca dell'indice.

Le presentazioni (40')

Tre o quattro minuti a testa, con il progetto che gira. Quattro domande, sempre le stesse, perché tutti sappiano cosa preparare:

  1. Cosa fa il tuo progetto? Mostralo, non descriverlo.
  2. Qual è il pezzo di codice di cui sei più soddisfatto, e perché?
  3. Cosa ti ha fatto perdere più tempo? Come ne sei uscito?
  4. Se avessi altre due ore, cosa aggiungeresti?

La terza domanda è la più importante delle quattro. Raccontare un errore e come l'hai risolto è la cosa che, in questo mestiere, distingue chi sa lavorare da chi sa solo far funzionare: succederà in ogni colloquio, in ogni riunione, per il resto della vostra vita professionale. Tanto vale cominciare oggi.

Errori tipici in aula

Verifica veloce (5 domande)

Domande e risposte
  1. Quali sono i quattro ingredienti di una scena Three.js? → Scena, telecamera, luci e mesh (geometria + materiale).
  2. Cosa fanno i due lavori di (0.5 - x)? → Sposta l'origine al centro e inverte il verso dell'asse: sulla x è l'effetto specchio, sulla y la conversione fra «y in giù» e «y in su».
  3. Perché le 21 sfere si creano fuori dal ciclo? → Perché crearle e distruggerle a ogni fotogramma farebbe lavorare il raccoglitore di memoria e produrrebbe scatti.
  4. Cosa produce npm run build? → La cartella dist/: un sito statico già tradotto in JavaScript, che non ha più bisogno né di Node né di Vite.
  5. Perché un sito con la webcam deve stare su HTTPS? → Perché fuori da un contesto sicuro il browser non dà accesso alla camera: la pagina non funzionerebbe.

Compito per casa (nessuna scadenza)

Il corso finisce qui, il progetto no. Otto direzioni in cui può crescere, dalla più facile alla più ambiziosa:

  1. Contare le dita alzate per bene, misurando le distanze dal polso invece delle altezze (esercizio 10.5, versione seria).
  2. Disegnare con il dito: quando l'indice è alzato e gli altri chiusi, lascia una scia sul canvas. Pugno chiuso = cancella. Sono trenta righe e diverte moltissimo.
  3. Pilotare un piccolo gioco: una racchetta che segue l'indice, un bersaglio da colpire, un punteggio. Tutto quello che serve lo sai già.
  4. Due mani insieme, con colori diversi e le etichette destra/sinistra sistemate come si deve.
  5. Gesti tuoi: misura le distanze fra le punte e inventa i tuoi comandi — pollice e indice che si toccano, mano a L, tre dita. Ricordati la conferma su più fotogrammi della lezione 9.
  6. Il corpo intero: lo stesso identico giro con 33 punti da testa a piedi. Guarda la pagina Corpo di questo sito: cambia solo il modello.
  7. Sostituire le sfere con una mano vera: cilindri per le ossa, un palmo semitrasparente. È quello che fa la demo di questo sito, e il codice è commentato in italiano riga per riga nel tutorial.
  8. Pubblicare il tuo sito e mandare il link a qualcuno. È la cosa più semplice di tutto l'elenco e la più soddisfacente: npm run build, poi una qualunque piattaforma che ospita siti statici con HTTPS incluso.

Venti ore fa non avevi mai scritto console.log. Adesso hai un'applicazione che accende una telecamera, chiede a un modello di intelligenza artificiale dove sono le tue dita, smorza il rumore dei dati, disegna una figura in tre dimensioni sessanta volte al secondo e non manda i tuoi dati a nessuno. Non è un esercizio: è un programma vero. Continua.

Come si valuta

Con dei principianti, valutare solo «il programma funziona» premia chi aveva già visto del codice e punisce chi parte da zero e sta imparando davvero. I pesi qui sotto tengono conto del percorso, non solo del risultato dell'ultimo giorno.

CriterioPesoCome si misura
Compiti consegnati (lezioni 1-5) 20% Cinque compiti brevi, consegnati come link del Playground. Conta la consegna e il tentativo, non la perfezione.
Il progetto funziona 30% La camera si accende, la mano compare, i punti seguono le dita. È il minimo sindacale, e ci arrivano tutti se le lezioni 6-8 sono andate bene.
Codice leggibile e tipi usati bene 25% Nomi comprensibili, funzioni corte, il lavoro diviso in almeno due file, niente any sparsi per far tacere il compilatore, interfacce al posto di oggetti anonimi.
Qualcosa di proprio 15% Una modifica personale qualsiasi: colori, un contatore di dita, un messaggio, un suono, la seconda mano. Serve a distinguere chi ha copiato da chi ha capito.
Presentazione finale 10% 3-4 minuti: cosa fa, come l'hai fatto, cosa non ha funzionato. Sapere raccontare un bug vale quanto averlo risolto.
Tre domande che smascherano il copia-incolla Senza mettere in imbarazzo nessuno, durante la presentazione: «cosa succede se cambio questo numero da 8 a 12?», «dove sta scritto che i punti sono 21?», «perché qui c'è un await?». Chi ha lavorato risponde anche sbagliando la parola giusta.

Regole d'aula: webcam, privacy, gente stanca

Se qualcuno resta indietro (succederà)

In una classe di principianti, dopo la lezione 3 la distanza fra il più veloce e il più lento è già grande. Tre accorgimenti che funzionano meglio del «recuperate a casa»:

Cosa serve al docente, lezione per lezione

LezioneDa preparare prima
1-4Il proiettore, il Playground aperto, i link degli esercizi già pronti da incollare in chat. Nient'altro.
5Un progetto vite.new/vanilla-ts già aperto e funzionante, da mostrare; l'array dei 21 punti finti da distribuire.
6I file di installazione di Node.js e VS Code su una chiavetta o in rete locale (le connessioni della scuola, il giorno dell'installazione, si intasano sempre); i permessi di amministratore verificati prima.
7Un filmato di una mano, registrato in anticipo, per chi non vuole o non può usare la camera.
8Il modello di MediaPipe già scaricato e messo in rete locale, se la linea è lenta: sono circa 8 MB per ogni studente.
9Il codice completo della lezione 8, come punto di ripristino.
10Il file di avvio con Three.js già pronto; l'ordine delle presentazioni; qualcosa per chiudere in bellezza (la demo del corpo intero su /corpo/ funziona benissimo).

Dopo il corso

Chi ha finito il progetto ha in mano molto più di una mano che si muove: sa dichiarare tipi, dividere un programma in funzioni e file, usare una libreria esterna, leggere un errore del compilatore e far girare un ciclo a 30 fotogrammi al secondo. Da qui, tre direzioni: