{
  "nbformat": 4,
  "nbformat_minor": 5,
  "metadata": {
    "kernelspec": {
      "name": "python3",
      "display_name": "Python 3",
      "language": "python"
    },
    "language_info": {
      "name": "python"
    },
    "colab": {
      "name": "module-1.ipynb",
      "provenance": []
    },
    "orbit": {
      "missionId": "module-1",
      "moduleId": 1,
      "minutes": 30,
      "audience": "student",
      "state": "prepared-not-executed"
    }
  },
  "cells": [
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "# Module 1 — Geste, état et rendu\n",
        "\n",
        "**Objectif :** Expliquer quelle donnée le geste modifie, puis distinguer relâchement et annulation.\n",
        "\n",
        "Activité de **30 minutes**, incluse dans les **3 heures solo** : **2 h lecture guidée → 30 min Colab → 30 min bilan**. Le webinaire de **1 heure** a lieu après la préparation autonome. Formation complète : **10 h accompagnées + 30 h solo = 40 h**.\n",
        "\n",
        "CPU gratuit, sans GPU, API payante ou IA Colab obligatoire. Rien à installer. Les sorties JS sont exécutées par votre navigateur. Three.js, lorsque nécessaire, est chargé depuis un CDN à version fixée; l’alternative HTML reste disponible si le réseau ou WebGL est indisponible.\n",
        "\n",
        "Faites une copie personnelle dans Drive. Vos productions sont conservables et réutilisables. Le partage du notebook peut partager son code, ses textes et ses sorties. Ni votre journal ni vos fichiers ne sont envoyés dans Sanity automatiquement.\n",
        "\n",
        "**Trois petites étapes :** prédire (5 min), changer/observer (15 min), expliquer/exporter (10 min). Vous pouvez demander une réponse à votre assistant; indiquez l’aide reçue et vérifiez son application."
      ],
      "id": "m1-0"
    },
    {
      "cell_type": "code",
      "metadata": {},
      "source": [
        "import base64, hashlib, html, io, json, math, re, time, zipfile\n",
        "from pathlib import Path\n",
        "from IPython.display import HTML, display\n",
        "\n",
        "# CPU standard; rien à installer. Les sorties HTML s'exécutent dans votre navigateur.\n",
        "MODULE_ID = 1\n",
        "MISSION_ID = 'module-' + str(MODULE_ID)\n",
        "reflection = {\n",
        "    'prediction': '', 'observations': [], 'explanation': '',\n",
        "    'assistance': '', 'limitations': '', 'openQuestion': ''\n",
        "}\n",
        "parameters = {}\n",
        "print('Préparation prête. Votre notebook appartient à votre compte Google; le partager peut partager code et sorties.')"
      ],
      "outputs": [],
      "execution_count": null,
      "id": "m1-1"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## 1. Prédire — 5 min\n",
        "Après deux pressions vers la droite, quelle position attendez-vous ? Que devient la capture après une annulation ?\n",
        "\n",
        "Notez votre prédiction avant de voir l’effet. Une prédiction imparfaite est une trace utile."
      ],
      "id": "m1-2"
    },
    {
      "cell_type": "code",
      "metadata": {},
      "source": [
        "reflection['prediction'] = ''  # Votre hypothèse AVANT l’essai\n",
        "parameters = {}  # Notez la valeur initiale, la valeur testée et les conditions conservées."
      ],
      "outputs": [],
      "execution_count": null,
      "id": "m1-3"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## 2. Modifier et observer — 15 min\n",
        "Dans interactionOptions, comparez keyboardStep 0.04 puis 0.08. Gardez la zone et le même nombre de pressions.\n",
        "\n",
        "Le fichier est une chaîne Python contenant du JavaScript/Astro réel. L’aperçu et l’export utilisent **la même** variable `student_files`. Conservez les exports nommés et les signatures; un changement hors contrat doit être examiné plutôt que remplacé silencieusement."
      ],
      "id": "m1-4"
    },
    {
      "cell_type": "code",
      "metadata": {},
      "source": [
        "student_files = {}\n",
        "student_files['interaction-state.js'] = r'''/** Module 1: input changes state; rendering is supplied by the host. */\n",
        "export const interactionOptions = { keyboardStep: 0.04 }; // STUDENT: compare 0.04 and 0.08.\n",
        "export function createInteraction(element, onChange, options = interactionOptions) {\n",
        "  if (!(options.keyboardStep > 0 && options.keyboardStep <= 0.25)) throw new Error('Keyboard step outside the learning limits.');\n",
        "  const lifetime = new AbortController();\n",
        "  let state = { id: 'element-1', phase: 'idle', position: { x: 0.5, y: 0.5 }, lastEvent: 'ready', pointerId: null };\n",
        "  const copy = () => ({ ...state, position: { ...state.position } });\n",
        "  const publish = (event) => { state.lastEvent = event; onChange(copy()); };\n",
        "  const position = (event) => {\n",
        "    const rect = element.getBoundingClientRect();\n",
        "    return { x: Math.max(0, Math.min(1, (event.clientX - rect.left) / Math.max(1, rect.width))),\n",
        "      y: Math.max(0, Math.min(1, (event.clientY - rect.top) / Math.max(1, rect.height))) };\n",
        "  };\n",
        "  const listen = (name, run) => element.addEventListener(name, run, { signal: lifetime.signal });\n",
        "  listen('pointerdown', (event) => {\n",
        "    if (event.button !== 0 || state.pointerId !== null) return;\n",
        "    // Native buttons remain clickable. The host can put them outside this area.\n",
        "    if (event.target.closest?.('a,button,input,select,textarea')) return;\n",
        "    state = { ...state, phase: 'held', pointerId: event.pointerId, position: position(event) };\n",
        "    element.setPointerCapture(event.pointerId); element.focus({ preventScroll: true }); publish('pointerdown');\n",
        "  });\n",
        "  listen('pointermove', (event) => {\n",
        "    if (state.pointerId !== event.pointerId) return; // Crossing an airborne item does not stop it.\n",
        "    state.position = position(event); publish('pointermove');\n",
        "  });\n",
        "  const finish = (event, cancelled) => {\n",
        "    if (state.pointerId !== event.pointerId) return;\n",
        "    const pointerId = state.pointerId;\n",
        "    state.phase = cancelled ? 'cancelled' : 'released'; state.pointerId = null;\n",
        "    if (element.hasPointerCapture(pointerId)) element.releasePointerCapture(pointerId);\n",
        "    publish(cancelled ? 'pointercancel' : 'pointerup');\n",
        "  };\n",
        "  listen('pointerup', (event) => finish(event, false));\n",
        "  listen('pointercancel', (event) => finish(event, true));\n",
        "  listen('lostpointercapture', (event) => finish(event, true));\n",
        "  listen('keydown', (event) => {\n",
        "    const directions = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };\n",
        "    if (directions[event.key]) {\n",
        "      event.preventDefault(); const [dx, dy] = directions[event.key];\n",
        "      state.position = { x: Math.max(0, Math.min(1, state.position.x + dx * options.keyboardStep)),\n",
        "        y: Math.max(0, Math.min(1, state.position.y + dy * options.keyboardStep)) };\n",
        "      state.phase = 'keyboard'; publish(event.key);\n",
        "    } else if (event.key === 'Escape') {\n",
        "      if (state.pointerId !== null) finish({ pointerId: state.pointerId }, true);\n",
        "    }\n",
        "  });\n",
        "  return { snapshot: copy, dispose() { lifetime.abort(); if (state.pointerId !== null && element.hasPointerCapture(state.pointerId)) element.releasePointerCapture(state.pointerId); state.pointerId = null; } };\n",
        "}\n",
        "'''\n",
        "# Modifiez le texte ci-dessus, puis réexécutez cette cellule et l’aperçu."
      ],
      "outputs": [],
      "execution_count": null,
      "id": "m1-5"
    },
    {
      "cell_type": "code",
      "metadata": {},
      "source": [
        "PREVIEW_TEMPLATE = '<!doctype html><html lang=\"fr\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width\">\\n<style>body{font:16px system-ui;color:#172033;background:#f5f7fb;padding:1rem}button{margin:.25rem;padding:.5rem}.stage{height:270px;position:relative;background:#11152a;color:white;overflow:hidden;touch-action:none;outline-offset:2px}.marker{position:absolute;background:#ffa234;width:24px;height:24px;border-radius:50%;transform:translate(-50%,-50%);pointer-events:none}canvas{display:block;width:100%;height:100%}pre{white-space:pre-wrap;overflow-wrap:anywhere}.error{color:#a31515}label{display:inline-block;margin:.5rem}li{margin:.5rem}</style></head><body>\\n<p>Une variable à la fois. Gardez votre prédiction visible dans le notebook.</p>\\n<label><input id=\"static\" type=\"checkbox\"> Figer l’image et le temps</label><button id=\"restart\">Recharger l’aperçu</button>\\n<div id=\"stage\" class=\"stage\" tabindex=\"0\" aria-label=\"Zone d’expérience : utilisez aussi les flèches\"><span id=\"marker\" class=\"marker\"></span></div>\\n<section id=\"cards\"><ul data-learning-cards></ul><p data-learning-description aria-live=\"polite\"></p></section>\\n<p id=\"info\" role=\"status\"></p><pre id=\"trace\"></pre><p id=\"error\" class=\"error\" role=\"alert\"></p>\\n<script type=\"module\">\\nconst studentFiles = __STUDENT_FILES__;\\nconst lifetimes = [];\\nconst urls = Object.fromEntries(Object.entries(studentFiles).filter(([name]) => name.endsWith(\\'.js\\')).map(([name,text]) => [name, URL.createObjectURL(new Blob([text], {type:\\'text/javascript\\'}))]));\\nconst stage=document.getElementById(\\'stage\\'), marker=document.getElementById(\\'marker\\'), info=document.getElementById(\\'info\\'), trace=document.getElementById(\\'trace\\');\\nconst staticControl=document.getElementById(\\'static\\'); let isStatic=matchMedia(\\'(prefers-reduced-motion: reduce)\\').matches, last=0, frame=0;\\nstaticControl.checked=isStatic;\\nconst show=(value)=>{trace.textContent=JSON.stringify(value,null,2)};\\nconst point=(event)=>{const r=stage.getBoundingClientRect();return {x:Math.max(0,Math.min(1,(event.clientX-r.left)/r.width)),y:Math.max(0,Math.min(1,(event.clientY-r.top)/r.height))}};\\nconst drawPoint=(p)=>{marker.style.left=(p.x*100)+\\'%\\';marker.style.top=(p.y*100)+\\'%\\'};\\nlet step=()=>{}, onStatic=()=>{};\\nfunction loop(now){if(isStatic)return;const dt=last?Math.min((now-last)/1000,.05):0;last=now;step(dt);frame=requestAnimationFrame(loop)}\\nstaticControl.addEventListener(\\'change\\',()=>{isStatic=staticControl.checked;onStatic(isStatic);cancelAnimationFrame(frame);last=0;if(!isStatic)frame=requestAnimationFrame(loop)});\\ndocument.getElementById(\\'restart\\').addEventListener(\\'click\\',()=>location.reload());\\nwindow.addEventListener(\\'pagehide\\',()=>{cancelAnimationFrame(frame);lifetimes.forEach(f=>f());Object.values(urls).forEach(URL.revokeObjectURL)});\\nconst items=[{id:\\'element-1\\',title:\\'Geste\\',content:\\'Une position enregistrée.\\',position:{x:.5,y:.5}},{id:\\'element-2\\',title:\\'Observation\\',content:\\'Un résultat à examiner.\\',position:{x:.25,y:.3}},{id:\\'element-3\\',title:\\'Transfert\\',content:\\'Une nouvelle situation.\\',position:{x:.75,y:.3}}];\\ntry {\\nconst {createInteraction}=await import(urls[\\'interaction-state.js\\']);\\nconst interaction=createInteraction(stage,state=>{if(isStatic)return;drawPoint(state.position);show(state)});lifetimes.push(()=>interaction.dispose());drawPoint(interaction.snapshot().position);\\ninfo.textContent=\\'Glissez, relâchez, utilisez les flèches. Pour une annulation reproductible, pressez Échap pendant le geste. Une sortie de zone capturée n’est pas pointercancel.\\';\\nonStatic(isStatic);step(0);if(!isStatic)frame=requestAnimationFrame(loop);\\n} catch(error) { document.getElementById(\\'error\\').textContent=\\'Erreur de l’essai : \\'+error.message+\\' — corrigez le fichier ou reprenez sa version précédente.\\'; }\\n</script></body></html>'\n",
        "PREVIEW_CODE_TEXT = 'if not reflection[\\'prediction\\'].strip():\\n    raise ValueError(\\'Écrivez votre prédiction avant l’essai, puis exécutez cette cellule à nouveau.\\')\\nfor name, source in student_files.items():\\n    if not isinstance(source, str) or len(source.encode(\\'utf-8\\')) > 200_000:\\n        raise ValueError(\\'Fichier élève trop gros ou non textuel.\\')\\npreview_document = PREVIEW_TEMPLATE.replace(\\'__STUDENT_FILES__\\', json.dumps(student_files, ensure_ascii=False).replace(\\'<\\', r\\'\\\\u003c\\'))\\nencoded = base64.b64encode(preview_document.encode(\\'utf-8\\')).decode(\\'ascii\\')\\ndisplay(HTML(\\'<iframe title=\"Expérience frontend du module\" sandbox=\"allow-scripts allow-same-origin\" \\'\\n             \\'style=\"width:100%;height:620px;border:1px solid #8796b4\" src=\"data:text/html;base64,\\' + encoded + \\'\"></iframe>\\'))\\nprint(\\'Aperçu navigateur du code dans student_files. Ce résultat n’est pas une compilation Astro ni un appel WebMCP certifié.\\')\\n'\n",
        "INTEGRATION_TEXT = '# Raccordement — module 1\\n\\n- `src/learning/module-1/interaction-state.js`\\n\\nCes fichiers proviennent de votre variable student_files; aucun corrigé ne remplace le code à l’export.\\nConservez identifiants, coordonnées normalisées, dt en secondes et fonctions de nettoyage.\\nL’aperçu n’est pas une compilation Astro, un appel WebMCP réel ni une mesure de performance Three.js.\\nLa remise reste sélectionnée par vous. Sanity ne reçoit rien de ce notebook.\\nLe notebook.ipynb exporté conserve le code et le bilan pour reprise, sans prétendre capturer l’exécution indépendante.\\n\\nTransfert : Réutilisez les états dans une carte déplaçable, avec clavier et annulation.\\n'"
      ],
      "outputs": [],
      "execution_count": null,
      "id": "m1-6"
    },
    {
      "cell_type": "code",
      "metadata": {},
      "source": [
        "if not reflection['prediction'].strip():\n",
        "    raise ValueError('Écrivez votre prédiction avant l’essai, puis exécutez cette cellule à nouveau.')\n",
        "for name, source in student_files.items():\n",
        "    if not isinstance(source, str) or len(source.encode('utf-8')) > 200_000:\n",
        "        raise ValueError('Fichier élève trop gros ou non textuel.')\n",
        "preview_document = PREVIEW_TEMPLATE.replace('__STUDENT_FILES__', json.dumps(student_files, ensure_ascii=False).replace('<', r'\\u003c'))\n",
        "encoded = base64.b64encode(preview_document.encode('utf-8')).decode('ascii')\n",
        "display(HTML('<iframe title=\"Expérience frontend du module\" sandbox=\"allow-scripts allow-same-origin\" '\n",
        "             'style=\"width:100%;height:620px;border:1px solid #8796b4\" src=\"data:text/html;base64,' + encoded + '\"></iframe>'))\n",
        "print('Aperçu navigateur du code dans student_files. Ce résultat n’est pas une compilation Astro ni un appel WebMCP certifié.')"
      ],
      "outputs": [],
      "execution_count": null,
      "id": "m1-7"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## 3. Expliquer et exporter — 10 min\n",
        "Comparez prédiction et observation. Expliquez une ligne ou une décision avec vos mots. Indiquez votre aide reçue, une limite et une prochaine question.\n",
        "\n",
        "**Transfert proposé :** Réutilisez les états dans une carte déplaçable, avec clavier et annulation.\n",
        "\n",
        "Le bilan de 30 minutes qui suit cette activité sert à choisir ce que vous remettez à l’enseignant et à préparer sa revue. Un ZIP valide ou une empreinte ne prouve pas une maîtrise."
      ],
      "id": "m1-8"
    },
    {
      "cell_type": "code",
      "metadata": {},
      "source": [
        "reflection['observations'] = []\n",
        "reflection['explanation'] = ''\n",
        "reflection['assistance'] = ''  # Écrivez aussi « aucune » si pertinent.\n",
        "reflection['limitations'] = ''\n",
        "reflection['openQuestion'] = ''"
      ],
      "outputs": [],
      "execution_count": null,
      "id": "m1-9"
    },
    {
      "cell_type": "code",
      "metadata": {},
      "source": [
        "# Faites votre bilan avant de rendre. Les observations sont déclarées par vous.\n",
        "required = ['prediction', 'explanation', 'assistance', 'limitations']\n",
        "if any(not isinstance(reflection.get(key), str) or not reflection[key].strip() for key in required):\n",
        "    raise ValueError('Complétez prédiction, explication, aide reçue (ou aucune) et limite avant le rendu.')\n",
        "if not reflection.get('observations'):\n",
        "    raise ValueError('Conservez au moins une observation sélectionnée.')\n",
        "if any(len(str(value)) > 12000 for value in reflection.values()):\n",
        "    raise ValueError('Le bilan est trop long; sélectionnez les éléments utiles, pas toute la conversation.')\n",
        "attempt_id = 'module-' + str(MODULE_ID) + '-' + str(time.time_ns())\n",
        "artifacts = []\n",
        "export_files = {}\n",
        "for name, source in student_files.items():\n",
        "    if not re.fullmatch(r'[A-Za-z0-9_.-]+\\.(js|astro)', name):\n",
        "        raise ValueError('Chemin frontend non autorisé : ' + name)\n",
        "    raw = source.encode('utf-8')\n",
        "    if len(raw) > 200_000:\n",
        "        raise ValueError('Brique trop volumineuse.')\n",
        "    path = 'frontend/' + name\n",
        "    export_files[path] = raw\n",
        "    artifacts.append({'id': 'module-' + str(MODULE_ID) + '-' + name, 'path': path, 'content': source,\n",
        "                      'mediaType': 'text/plain', 'sha256': hashlib.sha256(raw).hexdigest()})\n",
        "result = {'schemaVersion': 'orbit-learning-colab-v1', 'missionId': MISSION_ID, 'moduleId': MODULE_ID,\n",
        "          'attemptId': attempt_id, 'parameters': parameters, **reflection,\n",
        "          'status': 'external-declared', 'artifacts': artifacts,\n",
        "          'verification': {'sourceCaptured': True, 'independentExecutionVerified': False,\n",
        "                           'humanApproval': False, 'masteryCertified': False}}\n",
        "export_files['orbit-learning-result.json'] = json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False).encode('utf-8')\n",
        "export_files['INTEGRATION.md'] = INTEGRATION_TEXT.encode('utf-8')\n",
        "\n",
        "# Copie rejouable de votre code effectif, non une capture certifiée des sorties du navigateur.\n",
        "def cell(kind, source):\n",
        "    value = {'cell_type': kind, 'metadata': {}, 'source': source.splitlines(True)}\n",
        "    if kind == 'code': value.update(outputs=[], execution_count=None)\n",
        "    return value\n",
        "record = {'nbformat': 4, 'nbformat_minor': 5,\n",
        "          'metadata': {'kernelspec': {'display_name': 'Python 3', 'language': 'python', 'name': 'python3'},\n",
        "                       'orbit': {'kind': 'source-capture-replay', 'notIndependentExecutionProof': True}},\n",
        "          'cells': [cell('markdown', '# Mon rendu — module ' + str(MODULE_ID) + '\\nCode capturé depuis student_files; observations déclarées. Les sorties navigateur ne sont pas archivées automatiquement.'),\n",
        "                    cell('code', 'from IPython.display import HTML, display\\nimport base64, json\\nstudent_files = ' + repr(student_files) + '\\nreflection = ' + repr(reflection) + '\\nparameters = ' + repr(parameters)),\n",
        "                    cell('code', 'PREVIEW_TEMPLATE = ' + repr(PREVIEW_TEMPLATE) + '\\n' + PREVIEW_CODE_TEXT),\n",
        "                    cell('markdown', 'Revue : reformuler une décision, refaire un essai et tenter un transfert.') ]}\n",
        "export_files['notebook.ipynb'] = json.dumps(record, ensure_ascii=False, indent=2).encode('utf-8')\n",
        "manifest = {'schemaVersion': 'orbit-learning-artifact-bundle-v1', 'missionId': MISSION_ID,\n",
        "            'attemptId': attempt_id, 'status': 'external-declared',\n",
        "            'files': [{'path': path, 'sha256': hashlib.sha256(raw).hexdigest()} for path, raw in sorted(export_files.items())]}\n",
        "export_files['manifest.json'] = json.dumps(manifest, ensure_ascii=False, indent=2).encode('utf-8')\n",
        "archive_buffer = io.BytesIO()\n",
        "# ZIP_STORED makes the bounded browser import possible without an extra decompression dependency.\n",
        "with zipfile.ZipFile(archive_buffer, 'w', compression=zipfile.ZIP_STORED) as archive:\n",
        "    for path, raw in sorted(export_files.items()):\n",
        "        archive.writestr(path, raw)\n",
        "filename = 'orbit-module-' + str(MODULE_ID) + '-' + attempt_id + '.zip'\n",
        "Path(filename).write_bytes(archive_buffer.getvalue())\n",
        "try:\n",
        "    from google.colab import files\n",
        "    files.download(filename)\n",
        "except ImportError:\n",
        "    print('Fichier prêt dans le répertoire du notebook :', filename)\n",
        "print(json.dumps({'state': 'EXPORTED_DECLARED_WORK', 'moduleId': MODULE_ID,\n",
        "                  'files': len(export_files), 'sha256': hashlib.sha256(archive_buffer.getvalue()).hexdigest()}))"
      ],
      "outputs": [],
      "execution_count": null,
      "id": "m1-10"
    }
  ]
}