<!doctype html>

<html lang="it">


  <title>Particle Face+Hands Flow (MediaPipe)</title>

  <style>

    :root{

      --bg:#070A12;

      --panel:rgba(255,255,255,.08);

      --panel2:rgba(255,255,255,.12);

      --text:rgba(255,255,255,.92);

      --muted:rgba(255,255,255,.65);

      --accent:#7CF7FF;

      --danger:#FF4FD8;

    }

    html,body{height:100%;margin:0;background:var(--bg);font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;}

    #app{position:fixed;inset:0;overflow:hidden;}

    canvas{display:block;}

    #intro{

      position:fixed;inset:0;display:flex;align-items:center;justify-content:center;

      background:radial-gradient(1200px 800px at 50% 40%, rgba(124,247,255,.08), transparent 55%),

                 radial-gradient(1000px 700px at 60% 60%, rgba(255,79,216,.07), transparent 60%),

                 var(--bg);

      z-index:50;

    }

    .card{

      width:min(560px,92vw);

      padding:28px 26px;

      border-radius:18px;

      background:rgba(255,255,255,.06);

      border:1px solid rgba(255,255,255,.12);

      box-shadow:0 14px 50px rgba(0,0,0,.45);

      color:var(--text);

    }

    .title{font-size:20px;font-weight:700;letter-spacing:.2px;margin:0 0 10px;}

    .desc{margin:0 0 18px;color:var(--muted);line-height:1.35}

    .btn{

      display:inline-flex;align-items:center;gap:10px;

      padding:12px 16px;border-radius:12px;

      border:1px solid rgba(255,255,255,.16);

      background:rgba(255,255,255,.10);

      color:var(--text);cursor:pointer;

      font-weight:650;

      user-select:none;

      transition:transform .05s ease, background .15s ease;

    }

    .btn:hover{background:rgba(255,255,255,.14)}

    .btn:active{transform:translateY(1px)}

    .pill{

      display:inline-block;margin-left:8px;padding:2px 8px;border-radius:999px;

      font-size:12px;color:rgba(255,255,255,.8);border:1px solid rgba(255,255,255,.14);

      background:rgba(255,255,255,.06);

    }


    /* Overlay UI */

    #hud{position:fixed;inset:0;pointer-events:none;z-index:10;}

    .panel{

      pointer-events:auto;

      position:absolute;

      background:var(--panel);

      border:1px solid rgba(255,255,255,.12);

      border-radius:14px;

      color:var(--text);

      backdrop-filter: blur(10px);

      -webkit-backdrop-filter: blur(10px);

      box-shadow:0 10px 32px rgba(0,0,0,.35);

    }

    #controls{

      top:14px;left:14px;padding:10px;display:flex;gap:10px;align-items:center;

    }

    .toggle{

      display:flex;align-items:center;gap:8px;

      padding:10px 12px;border-radius:12px;

      border:1px solid rgba(255,255,255,.14);

      background:rgba(255,255,255,.08);

      cursor:pointer;

      user-select:none;

      font-weight:650;

      font-size:13px;

    }

    .toggle small{display:block;font-weight:600;color:var(--muted);margin-top:2px}

    .dot{

      width:10px;height:10px;border-radius:999px;background:rgba(255,255,255,.28);

      box-shadow:0 0 0 2px rgba(0,0,0,.15) inset;

    }

    .dot.on{background:var(--accent); box-shadow:0 0 18px rgba(124,247,255,.35);}

    #status{

      top:14px;right:14px;padding:10px 12px;min-width:210px;display:flex;flex-direction:column;gap:4px;

    }

    #status .line1{font-weight:750;font-size:13px}

    #status .line2{color:var(--muted);font-size:12px}

    #shortcuts{

      right:14px;bottom:14px;padding:10px 12px;max-width:min(320px,92vw);

      display:flex;flex-direction:column;gap:6px;

    }

    .kbdrow{display:flex;justify-content:space-between;gap:12px;color:var(--muted);font-size:12px}

    kbd{

      font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;

      font-size:12px;color:rgba(255,255,255,.85);

      padding:2px 8px;border-radius:8px;

      border:1px solid rgba(255,255,255,.16);

      background:rgba(0,0,0,.25);

    }


    /* Camera preview */

    #previewWrap{

      position:absolute;

      top:14px;left:50%;transform:translateX(-50%);

      width:256px;height:144px;

      border-radius:16px;

      overflow:hidden;

      background:rgba(0,0,0,.35);

      border:1px solid rgba(255,255,255,.14);

      box-shadow:0 16px 42px rgba(0,0,0,.45);

      pointer-events:none;

    }

    #previewVideo{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;transform:scaleX(-1);}

    #previewOverlay{position:absolute;inset:0;width:100%;height:100%;}

    #themeTag{

      position:absolute;left:50%;transform:translateX(-50%);

      top:168px;

      padding:6px 10px;border-radius:999px;

      background:rgba(255,255,255,.06);

      border:1px solid rgba(255,255,255,.12);

      color:rgba(255,255,255,.86);

      font-size:12px;font-weight:700;

      pointer-events:none;

    }

  </style>

</head>

<body>

<div id="app">

  <canvas id="fx"></canvas>


  <div id="hud">

    <div id="previewWrap">

      <video id="previewVideo" playsinline muted></video>

      <canvas id="previewOverlay" width="256" height="144"></canvas>

    </div>

    <div id="themeTag" class="panel" style="border-radius:999px;padding:6px 10px;">Tema: <span id="themeName">Arcobaleno</span></div>


    <div id="controls" class="panel">

      <div id="modeBtn" class="toggle" title="Spazio: Attrazione/Repulsione">

        <span class="dot on" id="modeDot"></span>

        <div>

          <div id="modeLabel">Attrazione</div>

          <small>Spazio</small>

        </div>

      </div>

      <div id="camBtn" class="toggle" title="V: Attiva/Disattiva fotocamera">

        <span class="dot on" id="camDot"></span>

        <div>

          <div id="camLabel">Camera ON</div>

          <small>V</small>

        </div>

      </div>

    </div>


    <div id="status" class="panel">

      <div class="line1" id="status1">Caricamento…</div>

      <div class="line2" id="status2">Inizializzo MediaPipe</div>

    </div>


    <div id="shortcuts" class="panel">

      <div style="font-weight:750;font-size:13px">Scorciatoie</div>

      <div class="kbdrow"><span>Modalità</span><span><kbd>Spazio</kbd></span></div>

      <div class="kbdrow"><span>Camera</span><span><kbd>V</kbd></span></div>

      <div class="kbdrow"><span>Tema (pugno)</span><span><kbd>Gesto</kbd></span></div>

    </div>

  </div>


  <div id="intro">

    <div class="card">

      <h1 class="title">Particelle in tempo reale: volto + mani</h1>

      <p class="desc">

        Migliaia di particelle scorrono come un liquido per formare la tua faccia e le tue mani.

        Premi <b>Abilita fotocamera</b>, poi mostra le mani davanti alla camera.

        <span class="pill">MediaPipe Hands + FaceMesh</span>

      </p>

      <button id="startBtn" class="btn">Abilita fotocamera</button>

      <p class="desc" style="margin-top:14px;font-size:12px;">

        Nota: serve un server locale (non aprire il file direttamente).

      </p>

    </div>

  </div>

</div>


<script type="module">

  import { Hands } from "https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js";

  import { FaceMesh } from "https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/face_mesh.js";

  import { Camera } from "https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js";


  // ---------- DOM ----------

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

  const fxc = fx.getContext("2d", { alpha: true });


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

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


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

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

  const poc = previewOverlay.getContext("2d");


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

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


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

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

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


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

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

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


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


  // ---------- Canvas sizing ----------

  function resize() {

    const dpr = Math.min(2, window.devicePixelRatio || 1);

    fx.width = Math.floor(innerWidth * dpr);

    fx.height = Math.floor(innerHeight * dpr);

    fx.style.width = innerWidth + "px";

    fx.style.height = innerHeight + "px";

    fxc.setTransform(dpr, 0, 0, dpr, 0, 0);

  }

  addEventListener("resize", resize);

  resize();


  // ---------- Themes ----------

  const THEMES = [

    { name: "Arcobaleno", mode: "hsv",   base: 0,    sat: 1.0, lum: 0.62 },

    { name: "Fuoco",      mode: "fire",  base: 18,   sat: 1.0, lum: 0.62 },

    { name: "Oceano",     mode: "ocean", base: 190,  sat: 0.95,lum: 0.60 },

    { name: "Galassia",   mode: "galaxy",base: 270,  sat: 0.95,lum: 0.62 },

    { name: "Matrice",    mode: "matrix",base: 120,  sat: 0.95,lum: 0.55 },

  ];

  let themeIndex = 0;


  function hsl(h,s,l,a=1){ return `hsla(${(h%360+360)%360},${Math.round(s*100)}%,${Math.round(l*100)}%,${a})`; }


  function particleColor(t, speed01, seed01){

    const th = THEMES[themeIndex];

    if (th.mode === "hsv")   return hsl(th.base + seed01*360, th.sat, th.lum + 0.1*speed01, 1);

    if (th.mode === "fire")  return hsl(th.base + seed01*55,  1.0, 0.48 + 0.25*speed01, 1);

    if (th.mode === "ocean") return hsl(th.base + seed01*45,  0.95,0.40 + 0.25*speed01, 1);

    if (th.mode === "galaxy")return hsl(th.base + seed01*70,  0.90,0.45 + 0.28*speed01, 1);

    if (th.mode === "matrix")return hsl(th.base + seed01*18,  0.90,0.40 + 0.22*speed01, 1);

    return hsl(200,1,.6,1);

  }


  function setTheme(idx){

    themeIndex = (idx + THEMES.length) % THEMES.length;

    themeName.textContent = THEMES[themeIndex].name;

  }

  setTheme(0);


  // ---------- Particle system ----------

  const GOLDEN = 0.61803398875;

  const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));


  const P_MIN = 8000, P_MAX = 15000;

  const P_COUNT = 12000; // puoi cambiare tra 8000..15000

  const px = new Float32Array(P_COUNT);

  const py = new Float32Array(P_COUNT);

  const vx = new Float32Array(P_COUNT);

  const vy = new Float32Array(P_COUNT);

  const seed = new Float32Array(P_COUNT);

  const tgt = new Uint32Array(P_COUNT);


  function rand01(i){

    // deterministic-ish seed

    const x = Math.sin(i*12.9898) * 43758.5453;

    return x - Math.floor(x);

  }


  function initParticles(){

    for(let i=0;i<P_COUNT;i++){

      px[i] = innerWidth * rand01(i*3+1);

      py[i] = innerHeight* rand01(i*3+2);

      vx[i] = (rand01(i*3+3)-0.5)*0.4;

      vy[i] = (rand01(i*3+4)-0.5)*0.4;

      seed[i] = rand01(i*7+9);

      tgt[i] = 0;

    }

  }

  initParticles();


  let repel = false;  // false=attrazione, true=repulsione

  function setMode(isRepel){

    repel = isRepel;

    modeLabel.textContent = repel ? "Repulsione" : "Attrazione";

    modeDot.classList.toggle("on", !repel); // on = attrazione (accent)

  }

  setMode(false);


  // trails: draw translucent rect each frame

  function fadeTrails(){

    fxc.fillStyle = "rgba(5,7,14,0.14)";

    fxc.fillRect(0,0,innerWidth,innerHeight);

  }


  // ---------- Targets (hands + face) ----------

  // target: {x,y,w} in screen space (pixels) where w = "spread" (tight vs wide)

  let targets = [];

  let haveHands = false;

  let haveFace = false;


  // Hand connections (MediaPipe)

  const HAND_CONN = [

    [0,1],[1,2],[2,3],[3,4],     // thumb

    [0,5],[5,6],[6,7],[7,8],     // index

    [0,9],[9,10],[10,11],[11,12],// middle

    [0,13],[13,14],[14,15],[15,16],// ring

    [0,17],[17,18],[18,19],[19,20],// pinky

    [5,9],[9,13],[13,17]         // palm

  ];


  // Face contour sets (approx) for colored outlines (useful subset)

  // Note: we still use all 468 points for particle targets.

  const FACE_OVAL = [10,338,297,332,284,251,389,356,454,323,361,288,397,365,379,378,400,377,152,148,176,149,150,136,172,58,132,93,234,127,162,21,54,103,67,109];

  const LIPS = [61,146,91,181,84,17,314,405,321,375,291,308,324,318,402,317,14,87,178,88,95,78];

  const LEFT_EYE = [33,7,163,144,145,153,154,155,133,173,157,158,159,160,161,246];

  const RIGHT_EYE = [263,249,390,373,374,380,381,382,362,398,384,385,386,387,388,466];


  function toScreen(xNorm,yNorm){

    return { x: (1-xNorm) * innerWidth, y: yNorm * innerHeight }; // mirror like selfie

  }

  function toPreview(xNorm,yNorm){

    // preview is also mirrored via video CSS, but we draw overlay directly mirrored

    return { x: (1-xNorm) * 256, y: yNorm * 144 };

  }


  function addCluster(cx, cy, count, spreadPx){

    // Golden-angle disc for organic, uniform-ish distribution around a point

    for(let i=0;i<count;i++){

      const r = spreadPx * Math.sqrt((i + 0.5) / count);

      const a = i * GOLDEN_ANGLE;

      targets.push({

        x: cx + Math.cos(a)*r,

        y: cy + Math.sin(a)*r,

        w: Math.max(1.0, spreadPx * 0.35)

      });

    }

  }


  function addSegmentSamples(ax,ay,bx,by, samples, spreadA, spreadB){

    // sample along segment using golden ratio fractional stepping

    for(let i=0;i<samples;i++){

      const t = (i * GOLDEN) % 1; // golden section stepping

      const x = ax + (bx-ax)*t;

      const y = ay + (by-ay)*t;

      const spread = spreadA + (spreadB - spreadA)*t;

      addCluster(x,y, 1, spread);

    }

  }


  function buildHandTargets(handLandmarks){

    // taper: tips tight, segments medium, palm wide

    // landmark indices: wrist=0, tips: 4,8,12,16,20

    const lm = handLandmarks;


    // Palm area clusters (wider)

    const palmIdx = [0,5,9,13,17];

    for (const idx of palmIdx){

      const s = toScreen(lm[idx].x, lm[idx].y);

      addCluster(s.x, s.y, 6, 12); // wider

    }


    // Wrist (a bit tighter than palm)

    {

      const s = toScreen(lm[0].x, lm[0].y);

      addCluster(s.x, s.y, 6, 8);

    }


    // Bones (medium) + tips (tight)

    for (const [a,b] of HAND_CONN){

      const A = toScreen(lm[a].x, lm[a].y);

      const B = toScreen(lm[b].x, lm[b].y);


      const isTip = (b===4||b===8||b===12||b===16||b===20);

      const spreadA = 7;

      const spreadB = isTip ? 2.2 : 5.2;

      const len = Math.hypot(B.x-A.x, B.y-A.y);

      const samples = Math.max(2, Math.min(10, Math.round(len/18)));

      addSegmentSamples(A.x,A.y,B.x,B.y, samples, spreadA, spreadB);


      if (isTip){

        addCluster(B.x, B.y, 10, 2.0); // very tight fingertip clustering

      }

    }

  }


  // Face depth accent: exaggerate z around nose/cheeks/orbits

  const FACE_BOOST = new Map([

    [1, 2.5],   // nose tip-ish

    [4, 2.0],   // nose ridge-ish

    [168,1.6],  // mid-face

    [234,1.8],  // left cheek-ish

    [454,1.8],  // right cheek-ish

    [33, 1.6],  // left eye corner

    [263,1.6],  // right eye corner

    [159,1.7],  // left upper lid

    [386,1.7],  // right upper lid

  ]);


  function buildFaceTargets(faceLandmarks){

    const lm = faceLandmarks;

    // Use all 468 points, but keep clustering extremely tight (1–2 px)

    for(let i=0;i<lm.length;i++){

      const p = lm[i];

      const s = toScreen(p.x, p.y);


      // parallax-ish boost: push points away from center by z

      const cx = innerWidth*0.5, cy = innerHeight*0.5;

      const dx = s.x - cx, dy = s.y - cy;


      const w = FACE_BOOST.get(i) ?? 1.0;

      const z = (p.z ?? 0) * -1; // z is usually negative towards camera

      const k = 120 * w;         // strength

      const bx = s.x + dx * z * 0.22 * w;

      const by = s.y + dy * z * 0.22 * w;


      targets.push({ x: bx, y: by, w: 1.4 }); // tight mesh

    }

  }


  function rebuildTargets(handsResults, faceResults){

    targets.length = 0;

    haveHands = false;

    haveFace = false;


    // Hands

    if (handsResults?.multiHandLandmarks?.length){

      haveHands = true;

      for(const hand of handsResults.multiHandLandmarks){

        buildHandTargets(hand);

      }

    }


    // Face

    if (faceResults?.multiFaceLandmarks?.length){

      haveFace = true;

      // only first face

      buildFaceTargets(faceResults.multiFaceLandmarks[0]);

    }


    // Ensure at least some targets to avoid NaNs (float around center)

    if (targets.length === 0){

      const cx = innerWidth*0.5, cy = innerHeight*0.5;

      for(let i=0;i<300;i++){

        const r = 120*Math.sqrt((i+0.5)/300);

        const a = i*GOLDEN_ANGLE;

        targets.push({x:cx+Math.cos(a)*r,y:cy+Math.sin(a)*r,w:18});

      }

    }


    // Assign particles uniformly across targets

    const T = targets.length >>> 0;

    const offset = (performance.now() * 0.001 * 97) >>> 0;

    for(let i=0;i<P_COUNT;i++){

      tgt[i] = (i + offset) % T;

    }

  }


  // ---------- Drawing preview overlay ----------

  function drawHandOverlayPreview(handLandmarks){

    // Draw skeleton with cyan + pink alternating

    const lm = handLandmarks;

    const cyan = "rgba(124,247,255,0.95)";

    const pink = "rgba(255,79,216,0.90)";


    poc.lineWidth = 2.0;

    poc.lineCap = "round";

    poc.lineJoin = "round";


    for(let i=0;i<HAND_CONN.length;i++){

      const [a,b] = HAND_CONN[i];

      const A = toPreview(lm[a].x,lm[a].y);

      const B = toPreview(lm[b].x,lm[b].y);

      poc.strokeStyle = (i%2===0) ? cyan : pink;

      poc.beginPath();

      poc.moveTo(A.x,A.y);

      poc.lineTo(B.x,B.y);

      poc.stroke();

    }


    // Points (bigger at wrist + tips)

    const tips = new Set([4,8,12,16,20,0]);

    for(let i=0;i<lm.length;i++){

      const p = toPreview(lm[i].x,lm[i].y);

      const big = tips.has(i);

      poc.fillStyle = big ? "rgba(255,255,255,0.92)" : "rgba(255,255,255,0.55)";

      poc.beginPath();

      poc.arc(p.x,p.y, big ? 3.6 : 2.0, 0, Math.PI*2);

      poc.fill();

    }

  }


  function strokeLoop(indices, color){

    if (!indices || indices.length<2) return;

    poc.strokeStyle = color;

    poc.lineWidth = 2.0;

    poc.beginPath();

    for(let i=0;i<indices.length;i++){

      const idx = indices[i];

      const p = currentFaceLm ? toPreview(currentFaceLm[idx].x, currentFaceLm[idx].y) : null;

      if(!p) continue;

      if(i===0) poc.moveTo(p.x,p.y); else poc.lineTo(p.x,p.y);

    }

    poc.closePath();

    poc.stroke();

  }


  let currentFaceLm = null;


  function drawFaceOverlayPreview(faceLandmarks){

    currentFaceLm = faceLandmarks;


    // light mesh points (not full triangulation — keeps it fast in preview)

    poc.fillStyle = "rgba(255,255,255,0.28)";

    for(let i=0;i<faceLandmarks.length;i+=2){

      const p = toPreview(faceLandmarks[i].x, faceLandmarks[i].y);

      poc.fillRect(p.x, p.y, 1, 1);

    }


    // feature outlines

    strokeLoop(LEFT_EYE,  "rgba(104,255,225,0.90)"); // aqua green

    strokeLoop(RIGHT_EYE, "rgba(104,255,225,0.90)");

    strokeLoop(LIPS,      "rgba(255,79,216,0.92)");  // pink

    strokeLoop(FACE_OVAL, "rgba(124,247,255,0.90)"); // cyan

  }


  function updateStatus(){

    if (!ready){

      status1.textContent = "Caricamento…";

      status2.textContent = "Inizializzo MediaPipe";

      return;

    }

    if (!camOn){

      status1.textContent = "Camera disattivata";

      status2.textContent = "Premi V per riattivare";

      return;

    }

    if (haveFace || haveHands){

      const parts = [];

      if (haveFace) parts.push("volto");

      if (haveHands) parts.push("mani");

      status1.textContent = "Rilevamento attivo";

      status2.textContent = "Vedo: " + parts.join(" + ");

    } else {

      status1.textContent = "In attesa…";

      status2.textContent = "Mostra le mani (o il volto) alla camera";

    }

  }


  // ---------- Fist gesture (theme cycle) ----------

  function dist(a,b){

    const dx=a.x-b.x, dy=a.y-b.y;

    return Math.hypot(dx,dy);

  }

  function isFist(lm){

    // heuristic: for index/middle/ring/pinky, tip closer to wrist than MCP by a margin

    const wrist = lm[0];

    const pairs = [

      { tip: 8,  mcp: 5 },

      { tip: 12, mcp: 9 },

      { tip: 16, mcp: 13},

      { tip: 20, mcp: 17},

    ];

    let curled = 0;

    for (const p of pairs){

      const dt = dist(lm[p.tip], wrist);

      const dm = dist(lm[p.mcp], wrist);

      if (dt < dm * 0.88) curled++;

    }

    // thumb optional (varies a lot), so just require 3/4 curled

    return curled >= 3;

  }

  let lastFistTime = 0;


  // ---------- MediaPipe wiring ----------

  let ready = false;

  let camOn = false;

  let camera = null;


  let lastHandsResults = null;

  let lastFaceResults = null;


  const hands = new Hands({

    locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`

  });

  hands.setOptions({

    maxNumHands: 2,

    modelComplexity: 1,

    minDetectionConfidence: 0.6,

    minTrackingConfidence: 0.6,

    selfieMode: true

  });


  const faceMesh = new FaceMesh({

    locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/${file}`

  });

  faceMesh.setOptions({

    maxNumFaces: 1,

    refineLandmarks: true,

    minDetectionConfidence: 0.6,

    minTrackingConfidence: 0.6,

    selfieMode: true

  });


  hands.onResults((res)=>{

    lastHandsResults = res;


    // theme cycle on fist gesture

    if (res?.multiHandLandmarks?.length){

      for (const hand of res.multiHandLandmarks){

        if (isFist(hand) && (performance.now() - lastFistTime) > 650){

          lastFistTime = performance.now();

          setTheme(themeIndex + 1);

          break;

        }

      }

    }

  });


  faceMesh.onResults((res)=>{

    lastFaceResults = res;

  });


  async function startCamera(){

    status1.textContent = "Caricamento…";

    status2.textContent = "Richiesta accesso fotocamera";


    // attach video

    previewVideo.setAttribute("autoplay","");

    previewVideo.setAttribute("playsinline","");

    previewVideo.muted = true;


    camera = new Camera(previewVideo, {

      onFrame: async () => {

        if (!camOn) return;


        // Send same frame to both models (serial to avoid overload)

        await hands.send({ image: previewVideo });

        await faceMesh.send({ image: previewVideo });


        // rebuild targets from latest results

        rebuildTargets(lastHandsResults, lastFaceResults);


        // draw preview overlay (mirrored feed is in CSS; overlay is drawn mirrored via x->1-x mapping)

        poc.clearRect(0,0,256,144);

        if (lastFaceResults?.multiFaceLandmarks?.length){

          drawFaceOverlayPreview(lastFaceResults.multiFaceLandmarks[0]);

        }

        if (lastHandsResults?.multiHandLandmarks?.length){

          for (const hand of lastHandsResults.multiHandLandmarks){

            drawHandOverlayPreview(hand);

          }

        }


        updateStatus();

      },

      width: 640,

      height: 360

    });


    camOn = true;

    camDot.classList.add("on");

    camLabel.textContent = "Camera ON";


    await camera.start();

  }


  async function stopCamera(){

    camOn = false;

    camDot.classList.remove("on");

    camLabel.textContent = "Camera OFF";

    updateStatus();


    // Best-effort stop tracks

    try{

      const stream = previewVideo.srcObject;

      if (stream && stream.getTracks){

        for (const t of stream.getTracks()) t.stop();

      }

      previewVideo.srcObject = null;

    }catch(e){}

    try{ camera?.stop(); }catch(e){}

    camera = null;

  }


  // ---------- Simulation step ----------

  function stepParticles(){

    const T = targets.length;

    if (!T) return;


    // physics params

    const attract = repel ? -1 : 1;

    const pull = 0.020 * attract;        // attraction/repulsion strength

    const damp = 0.985;                  // viscous damping (fluid-ish)

    const jitter = 0.08;                 // micro turbulence

    const maxSpeed = 3.4;


    for(let i=0;i<P_COUNT;i++){

      const t = targets[tgt[i] % T];

      const dx = (t.x - px[i]);

      const dy = (t.y - py[i]);


      // weight by tightness: face targets (w~1.4) pull tighter; palm wider softer

      const w = t.w;

      const inv = 1.0 / (Math.hypot(dx,dy) + 10*w);


      // swirl term to make it look liquid

      const swx = -dy * inv * 0.65;

      const swy =  dx * inv * 0.65;


      vx[i] = (vx[i] + dx * inv * pull * (14/w) + swx*0.012 + (seed[i]-0.5)*jitter*0.03) * damp;

      vy[i] = (vy[i] + dy * inv * pull * (14/w) + swy*0.012 + (seed[i]-0.5)*jitter*0.03) * damp;


      // cap speed

      const sp = Math.hypot(vx[i], vy[i]);

      if (sp > maxSpeed){

        const s = maxSpeed / (sp + 1e-6);

        vx[i] *= s; vy[i] *= s;

      }


      px[i] += vx[i];

      py[i] += vy[i];


      // wrap edges (keeps density stable)

      if (px[i] < -20) px[i] = innerWidth + 20;

      else if (px[i] > innerWidth+20) px[i] = -20;

      if (py[i] < -20) py[i] = innerHeight + 20;

      else if (py[i] > innerHeight+20) py[i] = -20;

    }

  }


  function drawParticles(){

    // draw points with soft glow via multiple passes (cheap-ish)

    // we avoid per-particle shadowBlur (slow); instead draw small circles + additive-ish

    fxc.globalCompositeOperation = "lighter";


    for(let i=0;i<P_COUNT;i++){

      const sp = Math.min(1, Math.hypot(vx[i],vy[i]) / 3.2);

      fxc.fillStyle = particleColor(THEMES[themeIndex], sp, seed[i]);


      // radius: face points tighter; general use small

      const r = 1.15 + sp*0.9;

      fxc.beginPath();

      fxc.arc(px[i], py[i], r, 0, Math.PI*2);

      fxc.fill();

    }


    fxc.globalCompositeOperation = "source-over";

  }


  function loop(){

    fadeTrails();

    stepParticles();

    drawParticles();

    requestAnimationFrame(loop);

  }


  // ---------- UI interactions ----------

  modeBtn.addEventListener("click", ()=> setMode(!repel));

  camBtn.addEventListener("click", async ()=>{

    if (!ready) return;

    if (camOn) await stopCamera();

    else await startCamera();

  });


  addEventListener("keydown", async (e)=>{

    if (e.code === "Space"){

      e.preventDefault();

      setMode(!repel);

    }

    if (e.key.toLowerCase() === "v"){

      if (!ready) return;

      if (camOn) await stopCamera();

      else await startCamera();

    }

  });


  // ---------- Start ----------

  startBtn.addEventListener("click", async ()=>{

    try{

      intro.style.display = "none";

      status1.textContent = "Caricamento…";

      status2.textContent = "Carico modelli e avvio camera";

      ready = true;

      updateStatus();

      await startCamera();

      loop();

    }catch(err){

      console.error(err);

      intro.style.display = "flex";

      status1.textContent = "Errore";

      status2.textContent = "Impossibile avviare la fotocamera (permessi / https / server locale).";

      ready = false;

      camOn = false;

    }

  });


  // boot status

  updateStatus();

</script>

</body>

</html>