From b537638f941075a409823809652c13a5255a3be3 Mon Sep 17 00:00:00 2001 From: Tobias Leuschner Date: Thu, 19 Feb 2026 17:12:25 +0100 Subject: [PATCH] Add initial project files including HTML, CSS, and JavaScript for Finger Chooser app --- .vscode/launch.json | 15 ++++ css/style.css | 33 +++++++++ index.html | 39 ++++++++++ js/app.js | 176 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 263 insertions(+) create mode 100644 .vscode/launch.json create mode 100644 css/style.css create mode 100644 index.html create mode 100644 js/app.js diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..2ba986f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "chrome", + "request": "launch", + "name": "Launch Chrome against localhost", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..962b184 --- /dev/null +++ b/css/style.css @@ -0,0 +1,33 @@ +/* Mobile-first styles */ +html,body,#stage{height:100%;margin:0;padding:0} +body{font-family:system-ui,-apple-system,Segoe UI,Roboto,'Helvetica Neue',Arial;background:#111;color:#fff} +#stage{position:relative;overflow:hidden;touch-action:none} +#overlay{position:absolute;left:12px;right:12px;top:18px;text-align:center} +#overlay h1{margin:0;font-size:20px;letter-spacing:0.5px} +.hint{opacity:.7;font-size:13px} + +.finger{position:absolute;pointer-events:none;width:88px;height:88px;border-radius:50%;transform:translate(-50%,-50%);display:flex;align-items:center;justify-content:center;font-weight:600;color:#fff;background:rgba(255,255,255,0.06);border:3px solid rgba(255,255,255,0.12);backdrop-filter: blur(6px)} +.finger .label{font-size:18px} +.finger.highlight{border-color:#ffd54f;box-shadow:0 0 24px rgba(255,213,79,0.18)} +.finger.winner{background:linear-gradient(135deg,#66ff99,#00b894);border-color:#00a86b;color:#00311a;box-shadow:0 8px 30px rgba(0,168,107,0.28);transform:translate(-50%,-50%) scale(1.05)} +.finger.lost{opacity:0;transform:translate(-50%,-50%) scale(0.6);transition:all 350ms ease} +.finger{transition:all 120ms linear} + +@media (min-width:600px){ + .finger{width:72px;height:72px} + .finger .label{font-size:16px} +} + +.pulse{animation:pulse 1200ms infinite} +@keyframes pulse{0%{transform:translate(-50%,-50%) scale(1)}50%{transform:translate(-50%,-50%) scale(1.08)}100%{transform:translate(-50%,-50%) scale(1)}} + +.top-center-hint{position:absolute;left:50%;transform:translateX(-50%);top:8px;font-size:12px;opacity:.9} + +/* Spinner visuals (subtle ring around centroid while selecting) */ +#spinner{position:absolute;left:0;top:0;width:0;height:0;pointer-events:none;transform-origin:0 0} +#spinner .ring{position:absolute;left:0;top:0;width:0;height:0;border-radius:50%;border:2px dashed rgba(255,255,255,0.06);box-shadow:0 2px 12px rgba(0,0,0,0.35)} +#spinner .pointer{position:absolute;left:50%;top:0;width:12px;height:12px;margin-left:-6px;background:#ffd54f;border-radius:50%;box-shadow:0 6px 18px rgba(255,213,79,0.18)} + +/* Transition helpers */ +.finger{transition:transform 130ms linear, opacity 220ms ease, border-color 160ms ease} + diff --git a/index.html b/index.html new file mode 100644 index 0000000..937191f --- /dev/null +++ b/index.html @@ -0,0 +1,39 @@ + + + + + + Finger Chooser — Wer bleibt übrig? + + + + + + + + + + + +
+
+

Finger Chooser

+

Lege 2 oder mehr Finger auf das Display — einer wird zufällig ausgewählt.

+

Hebe alle Finger an, um neu zu starten.

+
+
+ + + + + + + + diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..70238ce --- /dev/null +++ b/js/app.js @@ -0,0 +1,176 @@ +document.addEventListener('DOMContentLoaded', function(){ + const stage = document.getElementById('stage'); + const touches = new Map(); // id -> {el,x,y} + let selecting = false; + let selectionDone = false; + + function createFinger(t){ + const id = (t.identifier !== undefined && t.identifier !== null) ? String(t.identifier) : ('m'+Date.now()); + const el = document.createElement('div'); + el.className = 'finger'; + el.setAttribute('data-id', id); + const label = document.createElement('div'); + label.className = 'label'; + label.textContent = '1'; + el.appendChild(label); + el.style.left = t.clientX + 'px'; + el.style.top = t.clientY + 'px'; + stage.appendChild(el); + touches.set(id, {el: el, x: t.clientX, y: t.clientY}); + updateLabels(); + return id; + } + + function updateFinger(t){ + const id = (t.identifier !== undefined && t.identifier !== null) ? String(t.identifier) : 'm'; + const entry = touches.get(id); + if(entry){ entry.x = t.clientX; entry.y = t.clientY; entry.el.style.left = t.clientX + 'px'; entry.el.style.top = t.clientY + 'px'; } + } + + function removeFingerById(id){ + const key = String(id); + const entry = touches.get(key); + if(entry){ entry.el.remove(); touches.delete(key); updateLabels(); } + } + + function updateLabels(){ + let i=1; + for(const entry of touches.values()){ + const lbl = entry.el.querySelector('.label'); + if(lbl) lbl.textContent = String(i++); + } + } + + function computeOrderedItems(){ + const arr = Array.from(touches.entries()).map(([id,entry])=>({id,el:entry.el,x:entry.x,y:entry.y})); + const cx = arr.reduce((s,a)=>s+a.x,0)/arr.length; + const cy = arr.reduce((s,a)=>s+a.y,0)/arr.length; + arr.forEach(a=>{ + const ang = Math.atan2(a.y-cy,a.x-cx) * 180/Math.PI; + a.angle = (ang+360)%360; + a.dist = Math.hypot(a.x-cx,a.y-cy); + }); + arr.sort((a,b)=>a.angle-b.angle); + return {items:arr, center:{x:cx,y:cy}}; + } + + function computeOrderedItems(){ + const arr = Array.from(touches.entries()).map(([id,entry])=>({id,el:entry.el,x:entry.x,y:entry.y})); + const cx = arr.reduce((s,a)=>s+a.x,0)/arr.length; + const cy = arr.reduce((s,a)=>s+a.y,0)/arr.length; + arr.forEach(a=>{ + const ang = Math.atan2(a.y-cy,a.x-cx) * 180/Math.PI; // -180..180 + a.angle = (ang+360)%360; // 0..360 + a.dist = Math.hypot(a.x-cx,a.y-cy); + }); + arr.sort((a,b)=>a.angle-b.angle); + return {items:arr, center:{x:cx,y:cy}}; + } + + function startSelectionIfNeeded(){ + if(selecting || selectionDone) return; + if(touches.size < 1) return; // require at least 1 finger + selecting = true; + + const {items} = computeOrderedItems(); + const els = items.map(i=>i.el); + const n = els.length; + + // If only one finger, immediately mark it as winner (short visual) + if(n === 1){ + const el = els[0]; + el.classList.add('winner','pulse'); + selecting = false; + selectionDone = true; + return; + } + + const rotations = Math.floor(Math.random()*3)+3; // 3-5 rotations + const winnerIndex = Math.floor(Math.random()*n); + const totalSteps = rotations * n + winnerIndex; + + let cumulative = 0; + let current = 0; + for(let step=0; step<=totalSteps; step++){ + const t = step/totalSteps; + const delay = 40 + Math.pow(t,2)*520; // ease-out timing + cumulative += delay; + const idx = current % n; + setTimeout(()=>{ + els.forEach((el,i)=>{ + if(i===idx) el.classList.add('highlight'); else el.classList.remove('highlight'); + }); + }, cumulative); + current++; + } + + setTimeout(()=>{ + els.forEach((el,i)=>{ + if(i===winnerIndex){ el.classList.remove('highlight'); el.classList.add('winner','pulse'); } + else { el.classList.remove('highlight'); el.classList.add('lost'); } + }); + selecting = false; + selectionDone = true; + }, cumulative + 80); + } + + function cancelSelection(){ + selecting = false; + selectionDone = false; + for(const entry of touches.values()){ entry.el.classList.remove('highlight','lost','winner','pulse'); entry.el.style.opacity = '1'; } + } + + function resetWhenAllUp(){ + if(touches.size===0){ + setTimeout(()=>{ + selecting=false; selectionDone=false; + const nodes = Array.from(stage.querySelectorAll('.finger')); + nodes.forEach(node=>node.remove()); + touches.clear(); + },160); + } + } + + // Touch handlers + stage.addEventListener('touchstart', function(e){ + e.preventDefault(); + const changed = Array.from(e.changedTouches || []); + changed.forEach(t=>createFinger(t)); + cancelSelection(); + startSelectionIfNeeded(); + }, {passive:false}); + stage.addEventListener('touchmove', function(e){ + e.preventDefault(); + const changed = Array.from(e.changedTouches || []); + changed.forEach(t=>updateFinger(t)); + }, {passive:false}); + stage.addEventListener('touchend', function(e){ + e.preventDefault(); + const changed = Array.from(e.changedTouches || []); + changed.forEach(t=>removeFingerById(t.identifier)); + if(!selectionDone) cancelSelection(); + if(touches.size===0) resetWhenAllUp(); + }, {passive:false}); + stage.addEventListener('touchcancel', function(e){ + e.preventDefault(); + const changed = Array.from(e.changedTouches || []); + changed.forEach(t=>removeFingerById(t.identifier)); + if(!selectionDone) cancelSelection(); + if(touches.size===0) resetWhenAllUp(); + }, {passive:false}); + + // Support mouse for desktop testing + let mouseDown = false; + const mouseId = 'mouse'; + stage.addEventListener('mousedown', function(e){ + mouseDown = true; + createFinger({identifier:mouseId, clientX:e.clientX, clientY:e.clientY}); + cancelSelection(); + startSelectionIfNeeded(); + }); + window.addEventListener('mousemove', function(e){ if(!mouseDown) return; updateFinger({identifier:mouseId, clientX:e.clientX, clientY:e.clientY}); }); + window.addEventListener('mouseup', function(e){ if(!mouseDown) return; mouseDown=false; removeFingerById(mouseId); if(!selectionDone) cancelSelection(); if(touches.size===0) resetWhenAllUp(); }); + +}); + +});